├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── axum-rs-derive ├── Cargo.toml └── src │ ├── db.rs │ └── lib.rs ├── config-example.toml ├── scripts ├── docker │ └── postgres.sql.sh ├── sql │ ├── axum-rs.sql │ ├── init-data.sql │ └── views.sql └── systemd │ └── axum.rs.service └── src ├── api ├── admin │ ├── announcement.rs │ ├── mod.rs │ ├── order.rs │ ├── profile.rs │ ├── promotion.rs │ ├── router.rs │ ├── service.rs │ ├── session.rs │ ├── statistics.rs │ ├── subject.rs │ ├── tag.rs │ ├── topic.rs │ └── user.rs ├── auth.rs ├── mod.rs ├── router.rs ├── user │ ├── announcement.rs │ ├── mod.rs │ ├── order.rs │ ├── pay.rs │ ├── ping.rs │ ├── promotion.rs │ ├── read_history.rs │ ├── router.rs │ ├── service.rs │ ├── subject.rs │ ├── tag.rs │ ├── topic.rs │ └── user.rs └── web.rs ├── captcha.rs ├── config.rs ├── err.rs ├── form ├── announcement.rs ├── auth.rs ├── mod.rs ├── order.rs ├── pay.rs ├── profile.rs ├── promotion.rs ├── service.rs ├── subject.rs ├── tag.rs ├── topic.rs └── user.rs ├── interfaces ├── auth.rs └── mod.rs ├── lib.rs ├── mail.rs ├── main.rs ├── mid ├── admin_auth.rs ├── ip_ua.rs ├── mod.rs └── user_auth.rs ├── model ├── activation_code.rs ├── admin.rs ├── announcement.rs ├── check_in_log.rs ├── currency.rs ├── login_log.rs ├── mod.rs ├── order.rs ├── pagination.rs ├── pay.rs ├── promotion.rs ├── protected_content.rs ├── read_history.rs ├── service.rs ├── session.rs ├── subject.rs ├── tag.rs ├── tag_views.rs ├── topic.rs ├── topic_tag.rs ├── topic_views.rs └── user.rs ├── resp ├── mod.rs └── subject.rs ├── service ├── activation_code.rs ├── admin.rs ├── mod.rs ├── order.rs ├── pay.rs ├── promotion.rs ├── subject.rs ├── tag.rs ├── topic.rs ├── topic_section.rs ├── topic_tag.rs └── user.rs ├── state.rs ├── tron.rs └── utils ├── dt.rs ├── hash.rs ├── http.rs ├── id.rs ├── md.rs ├── mod.rs ├── password.rs ├── session.rs ├── str.rs ├── topic.rs ├── user_agent.rs └── vec.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .vscode 3 | .idea 4 | .atom 5 | /.env 6 | *.log 7 | .DS_Store 8 | /templates 9 | /config.toml 10 | /.env.* 11 | /test-sections-data -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-rs" 3 | version = "2.2.1" 4 | edition = "2021" 5 | authors = ["axum.rs "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/axumrs/axum-rs" 9 | homepage = "https://axum.eu.org" 10 | description = "axum中文网" 11 | 12 | [workspace] 13 | members = [".", "axum-rs-derive"] 14 | 15 | [dependencies] 16 | tokio.workspace = true 17 | axum.workspace = true 18 | serde.workspace = true 19 | chrono.workspace = true 20 | sqlx.workspace = true 21 | pulldown-cmark.workspace = true 22 | bcrypt.workspace = true 23 | config.workspace = true 24 | tracing.workspace = true 25 | tracing-subscriber.workspace = true 26 | reqwest.workspace = true 27 | serde_json.workspace = true 28 | validator.workspace = true 29 | tower-http.workspace = true 30 | xid.workspace = true 31 | sha2.workspace = true 32 | base16ct.workspace = true 33 | lazy_static.workspace = true 34 | regex.workspace = true 35 | rand.workspace = true 36 | anyhow.workspace = true 37 | rust_decimal.workspace = true 38 | rust_decimal_macros.workspace = true 39 | scraper.workspace = true 40 | lettre.workspace = true 41 | bitflags.workspace = true 42 | utf8_slice.workspace = true 43 | rss.workspace = true 44 | axum-rs-derive = { path = "./axum-rs-derive" } 45 | 46 | 47 | [workspace.dependencies] 48 | tokio = { version = "1", features = ["full"] } 49 | axum = { version = "0.8", features = ["ws", "multipart"] } 50 | serde = { version = "1", features = ["derive"] } 51 | chrono = { version = "0.4", features = ["serde"] } 52 | sqlx = { version = "0.8", features = [ 53 | "runtime-tokio", 54 | "postgres", 55 | "chrono", 56 | "rust_decimal", 57 | ] } 58 | pulldown-cmark = "0.13" 59 | bcrypt = "0.17" 60 | config = { version = "0.15", features = ["toml"] } 61 | tracing = "0.1" 62 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 63 | reqwest = { version = "0.12", features = ["json"] } 64 | serde_json = "1" 65 | validator = { version = "0.20", features = ["derive"] } 66 | tower-http = { version = "0.6", features = ["cors", "limit"] } 67 | xid = "1" 68 | sha2 = "0.10" 69 | base16ct = "0.2" 70 | lazy_static = "1" 71 | regex = "1" 72 | rand = "0.9" 73 | anyhow = "1" 74 | rust_decimal = "1.36" 75 | rust_decimal_macros = "1.36" 76 | scraper = "0.23" 77 | lettre = { version = "0.11", features = ["tokio1-native-tls"] } 78 | bitflags = "2.9" 79 | utf8_slice = "1" 80 | rss = "2" 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present AXUM.EU.ORG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AXUM 中文网官方网站 2 | 3 | 本仓库为[AXUM 中文网](https://axum.rs)后端程序。我们整个项目由 3 部分组成: 4 | 5 | - 后端程序:[axumrs/axum-rs](https://github.com/axumrs/axum-rs) (本仓库) 6 | 7 | - 前台 UI:[axumrs/axum-rs-ui](https://github.com/axumrs/axum-rs-ui) 8 | 9 | - 后台 UI:[axumrs/axum-rs-admin-ui](https://github.com/axumrs/axum-rs-admin-ui) 10 | 11 | ## 技术栈 12 | 13 | - Rust 14 | 15 | - Axum 16 | 17 | - PostgreSQL 18 | 19 | ## 捐助 20 | 21 | - USDT/TRX: `TPGEtKJmPJU3naosCcRrVReE2ckFhE9sYM` 22 | 23 | 更多详细内容,请访问[关于我们](https://axum.rs/about) 24 | -------------------------------------------------------------------------------- /axum-rs-derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-rs-derive" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["axum.rs "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/axumrs/axum-rs" 9 | homepage = "https://axum.rs" 10 | description = "axum.rs数据库宏" 11 | 12 | [lib] 13 | proc-macro = true 14 | 15 | 16 | [dependencies] 17 | syn = { version = "2", features = ["extra-traits"] } 18 | quote = "1" 19 | proc-macro2 = "1" 20 | 21 | 22 | [dev-dependencies] 23 | tokio.workspace = true 24 | serde.workspace = true 25 | sqlx.workspace = true 26 | tracing.workspace = true 27 | tracing-subscriber.workspace = true 28 | -------------------------------------------------------------------------------- /axum-rs-derive/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use quote::quote; 3 | use syn::{parse_macro_input, DeriveInput}; 4 | 5 | mod db; 6 | 7 | #[proc_macro_derive(Db, attributes(db))] 8 | pub fn db_derive(input: TokenStream) -> TokenStream { 9 | let ast = parse_macro_input!(input as DeriveInput); 10 | let ident = &ast.ident; 11 | 12 | let dm = db::DbMeta::new(&ast); 13 | 14 | let fields_ts = dm.fields_ts(); 15 | let insert_ts = dm.insert_ts(); 16 | let update_ts = dm.update_ts(); 17 | let real_del_ts = dm.real_del_ts(); 18 | let del_restore_ts = dm.del_restore_ts(); 19 | let self_update_ts = dm.self_update_ts(); 20 | let exists_ts = dm.exists_ts(); 21 | 22 | let find_by_ts = dm.find_by_ts(); 23 | let find_ts = dm.find_ts(); 24 | 25 | let list_filter_ts = dm.list_filter_ts(); 26 | let list_ts = dm.list_ts(); 27 | 28 | let list_all_filter_ts = dm.list_all_filter_ts(); 29 | let list_all_ts = dm.list_all_ts(); 30 | 31 | // println!("{:?}", dm); 32 | quote! { 33 | impl #ident { 34 | #fields_ts 35 | #insert_ts 36 | #update_ts 37 | #real_del_ts 38 | #del_restore_ts 39 | #self_update_ts 40 | #exists_ts 41 | #find_ts 42 | #list_ts 43 | #list_all_ts 44 | } 45 | 46 | #find_by_ts 47 | #list_filter_ts 48 | #list_all_filter_ts 49 | } 50 | .into() 51 | } 52 | -------------------------------------------------------------------------------- /config-example.toml: -------------------------------------------------------------------------------- 1 | log = "axum_rs=" 2 | cleaner_max_try = 0 3 | topic_section_secret_key = "" 4 | host = "https://axum.rs" 5 | 6 | [web] 7 | addr = "0.0.0.0:9527" 8 | prefix = "/v1" 9 | 10 | [db] 11 | dsn = "postgres://axum_rs:axum_rs@127.0.0.1:5432/axum_rs?sslmode=disable" 12 | max_conns = 5 13 | 14 | [session] 15 | secret_key = "" 16 | default_timeout = 20 17 | max_timeout = 1440 18 | admin_timeout = 600 19 | 20 | [[mails]] 21 | name = "user@cock.li" 22 | smtp = "mail.cock.li" 23 | user = "user@cock.li" 24 | password = "password" 25 | 26 | [[mails]] 27 | name = "user@user.serv00.net" 28 | smtp = "mail.serv00.com" 29 | user = "user@user.serv00.net" 30 | password = "password" 31 | 32 | 33 | [protected_content] 34 | max_sections = 3 35 | min_sections = 2 36 | guest_captcha = 'HCaptcha' 37 | user_captcha = 'Turnstile' 38 | timeout = 5 39 | placeholder = '' 40 | 41 | [captcha] 42 | timeout = 30 43 | 44 | [captcha.hcaptcha] 45 | secret_key = '0x0000000000000000000000000000000000000000' 46 | validation_url = 'https://api.hcaptcha.com/siteverify' 47 | 48 | [captcha.turnstile] 49 | secret_key = '1x0000000000000000000000000000000AA' 50 | validation_url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' 51 | 52 | [upload] 53 | max_size = 5242880 # 5m 54 | 55 | [tron] 56 | wallet = "TPGEtKJmPJU3naosCcRrVReE2ckFhE9sYM" 57 | usdt_contract_addr = "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj" # TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t 58 | api_url = "https://nileapi.tronscan.org" # https://apilist.tronscanapi.com 59 | fetch_timeout = 10 60 | proxy = '' 61 | 62 | [currency] 63 | trx_rate = 8 64 | cny_rate = 8.1 65 | pointer_rate = 1000 66 | -------------------------------------------------------------------------------- /scripts/docker/postgres.sql.sh: -------------------------------------------------------------------------------- 1 | docker run \ 2 | --name axum_rs_pg \ 3 | -e POSTGRES_PASSWORD=axum_rs \ 4 | -e POSTGRES_USER=axum_rs \ 5 | -e POSTGRES_DB=axum_rs \ 6 | -e TZ=PRC \ 7 | --restart=always \ 8 | -e PGDATA=/var/lib/postgresql/data/pgdata \ 9 | -v /var/docker/axum_rs_pg:/var/lib/postgresql/data \ 10 | -p 127.0.0.1:55432:5432 \ 11 | -d postgres:alpine 12 | 13 | # postgres://axum_rs:axum_rs@127.0.0.1:55432/axum_rs?sslmode=disable -------------------------------------------------------------------------------- /scripts/sql/init-data.sql: -------------------------------------------------------------------------------- 1 | -- 管理员:root / axum.rs 2 | INSERT INTO "admins" ("id", "username", "password") VALUES ('crppg84drfahppqfjqo0', 'root', '$2b$12$XmdlzflFwkM2DWKsI6EL4eAAKq5c3foXC/f.oj/r3thwUg/OTzHS.'); 3 | 4 | -- 推广 5 | INSERT INTO "promotions" ("id", "name", "content", "url", "img", "dateline") VALUES 6 | ('01JHHPWDZ5B6M4SZTMFG', 'aicnn', '一站式AI聚合平台,超高性价比,稳定不折腾!', 'http://aicnn.cn/loginPage?aff=ajlmKQqQbr', '', '2025-05-07 10:58:51+08'), 7 | ('01JHHPXGKMDN0SSV2SZQ', 'ephone', '致力于为开发者提供快速、便捷的 Web API 接口调用方案,打造稳定且易于使用的 API 接口平台,一站式集成几乎所有AI大模型。', 'https://api.ephone.ai/register?aff=D4vD', '', '2025-05-07 10:58:51+08'), 8 | ('01JHHPXGKM3718GYHVS3', 'gueai', '完全兼容OpenAI接口协议,确保集成无缝。100%使用官方企业高速渠道,已稳定运行1年,承诺永久运营!', 'https://api.gueai.com/register?aff=eqgk', '', '2025-05-07 10:58:51+08'); -------------------------------------------------------------------------------- /scripts/sql/views.sql: -------------------------------------------------------------------------------- 1 | -- 文章-专题关联视图 2 | CREATE VIEW "v_topic_subjects" AS 3 | SELECT 4 | t.id, t.title, t.subject_id, t.slug, t.summary, t.author, t.src, t.hit, t.dateline, t.try_readable, t.is_del, t.cover, t.md, t.pin 5 | ,s."name", s.slug AS subject_slug, s.summary AS subject_summary, s.is_del AS subject_is_del, s.cover AS subject_cover, s.status, s.price, s.pin AS subject_pin 6 | FROM 7 | topics AS t 8 | INNER JOIN 9 | subjects AS s 10 | ON 11 | t.subject_id = s.id 12 | ; 13 | 14 | -- 文章标签-标签关联视图 15 | CREATE VIEW "v_topic_tag_with_tags" AS 16 | SELECT 17 | t.id, t."name", t.is_del 18 | , tt.id AS topic_tag_id, topic_id 19 | FROM 20 | topic_tags AS tt 21 | INNER JOIN 22 | tags AS t 23 | ON 24 | tt.tag_id = t.id 25 | ; 26 | 27 | -- 订单-用户关联视图 28 | CREATE VIEW "v_order_users" AS 29 | SELECT 30 | o.id, user_id, amount, actual_amount, o.status, "snapshot", allow_pointer, o.dateline 31 | , u.email, u.nickname 32 | FROM 33 | orders AS o 34 | INNER JOIN 35 | users AS u 36 | ON o.user_id = u.id 37 | ; -------------------------------------------------------------------------------- /scripts/systemd/axum.rs.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=axum.rs 3 | After=network.target 4 | Wants=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=www-data 9 | Group=www-data 10 | PIDFile=/run/axum.rs.pid 11 | WorkingDirectory=/var/www/axum.rs 12 | ExecStart=/var/www/axum.rs/axum-rs 13 | Restart=always 14 | RestartSec=3 15 | RestartPreventExitStatus=23 16 | 17 | [Install] 18 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /src/api/admin/announcement.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | Json, 4 | }; 5 | use chrono::Local; 6 | use validator::Validate; 7 | 8 | use crate::{ 9 | api::{get_pool, log_error}, 10 | form, model, resp, utils, ArcAppState, Error, Result, 11 | }; 12 | 13 | pub async fn add( 14 | State(state): State, 15 | Json(frm): Json, 16 | ) -> Result { 17 | let handler_name = "admin/announcement/add"; 18 | frm.validate() 19 | .map_err(Error::from) 20 | .map_err(log_error(handler_name))?; 21 | 22 | let p = get_pool(&state); 23 | 24 | let id = utils::id::new(); 25 | let m = model::announcement::Announcement { 26 | id, 27 | title: frm.title, 28 | content: frm.content, 29 | dateline: Local::now(), 30 | hit: 0, 31 | }; 32 | 33 | m.insert(&*p) 34 | .await 35 | .map_err(Error::from) 36 | .map_err(log_error(handler_name))?; 37 | 38 | Ok(resp::ok(resp::IDResp { id: m.id })) 39 | } 40 | 41 | pub async fn edit( 42 | State(state): State, 43 | Json(frm): Json, 44 | ) -> Result { 45 | let handler_name = "admin/announcement/edit"; 46 | frm.validate() 47 | .map_err(Error::from) 48 | .map_err(log_error(handler_name))?; 49 | 50 | let p = get_pool(&state); 51 | let mut tx = p 52 | .begin() 53 | .await 54 | .map_err(Error::from) 55 | .map_err(log_error(handler_name))?; 56 | 57 | let m = match model::announcement::Announcement::find( 58 | &mut *tx, 59 | &model::announcement::AnnouncementFindFilter { id: Some(frm.id) }, 60 | ) 61 | .await 62 | { 63 | Ok(v) => match v { 64 | Some(v) => v, 65 | None => return Err(Error::new("不存在的公告")).map_err(log_error(handler_name)), 66 | }, 67 | Err(e) => { 68 | tx.rollback() 69 | .await 70 | .map_err(Error::from) 71 | .map_err(log_error(handler_name))?; 72 | return Err(e.into()).map_err(log_error(handler_name)); 73 | } 74 | }; 75 | 76 | let m = model::announcement::Announcement { 77 | title: frm.base.title, 78 | content: frm.base.content, 79 | ..m 80 | }; 81 | 82 | let aff = match m.update(&mut *tx).await { 83 | Ok(v) => v, 84 | Err(e) => { 85 | tx.rollback() 86 | .await 87 | .map_err(Error::from) 88 | .map_err(log_error(handler_name))?; 89 | return Err(e.into()).map_err(log_error(handler_name)); 90 | } 91 | }; 92 | 93 | tx.commit() 94 | .await 95 | .map_err(Error::from) 96 | .map_err(log_error(handler_name))?; 97 | Ok(resp::ok(resp::AffResp { aff })) 98 | } 99 | 100 | pub async fn del( 101 | State(state): State, 102 | Path(id): Path, 103 | ) -> Result { 104 | let handler_name = "admin/announcement/del"; 105 | let p = get_pool(&state); 106 | let aff = model::announcement::Announcement::real_del(&*p, &id) 107 | .await 108 | .map_err(Error::from) 109 | .map_err(log_error(handler_name))?; 110 | Ok(resp::ok(resp::AffResp { aff })) 111 | } 112 | 113 | pub async fn list( 114 | State(state): State, 115 | Query(frm): Query, 116 | ) -> Result> { 117 | let handler_name = "admin/announcement/list"; 118 | let p = get_pool(&state); 119 | let data = model::announcement::Announcement::list( 120 | &*p, 121 | &model::announcement::AnnouncementListFilter { 122 | pq: model::announcement::AnnouncementPaginateReq { 123 | page: frm.pq.page(), 124 | page_size: frm.pq.page_size(), 125 | }, 126 | order: None, 127 | title: frm.title, 128 | }, 129 | ) 130 | .await 131 | .map_err(Error::from) 132 | .map_err(log_error(handler_name))?; 133 | 134 | Ok(resp::ok(data)) 135 | } 136 | -------------------------------------------------------------------------------- /src/api/admin/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod announcement; 2 | pub mod order; 3 | pub mod profile; 4 | pub mod promotion; 5 | pub mod router; 6 | pub mod service; 7 | pub mod session; 8 | pub mod statistics; 9 | pub mod subject; 10 | pub mod tag; 11 | pub mod topic; 12 | pub mod user; 13 | -------------------------------------------------------------------------------- /src/api/admin/profile.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, Json}; 2 | use validator::Validate; 3 | 4 | use crate::{ 5 | api::{get_pool, log_error}, 6 | form, mid, model, resp, utils, ArcAppState, Error, Result, 7 | }; 8 | 9 | pub async fn change_pwd( 10 | State(state): State, 11 | mid::AdminAuth { admin, .. }: mid::AdminAuth, 12 | Json(frm): Json, 13 | ) -> Result { 14 | let handler_name = "admin/profile/change_pwd"; 15 | 16 | frm.validate() 17 | .map_err(Error::from) 18 | .map_err(log_error(handler_name))?; 19 | 20 | if frm.password == frm.new_password { 21 | return Err(Error::new("玩呢?一样的密码改个寂寞")); 22 | } 23 | if frm.new_password != frm.re_password { 24 | return Err(Error::new("两次输入的密码不一致")); 25 | } 26 | 27 | let p = get_pool(&state); 28 | 29 | let m = match model::admin::Admin::find( 30 | &*p, 31 | &model::admin::AdminFindFilter { 32 | by: model::admin::AdminFindBy::Id(admin.id.clone()), 33 | }, 34 | ) 35 | .await 36 | .map_err(Error::from) 37 | .map_err(log_error(handler_name))? 38 | { 39 | Some(v) => v, 40 | None => return Err(Error::new("管理员不存在")), 41 | }; 42 | 43 | if !utils::password::verify(&frm.password, &m.password).map_err(log_error(handler_name))? { 44 | return Err(Error::new("密码错误")); 45 | } 46 | 47 | let password = utils::password::hash(&frm.new_password).map_err(log_error(handler_name))?; 48 | 49 | let aff = match sqlx::query(r#"UPDATE "admins" SET "password"=$1 WHERE "id"=$2"#) 50 | .bind(&password) 51 | .bind(&admin.id) 52 | .execute(&*p) 53 | .await 54 | { 55 | Err(e) => return Err(e.into()).map_err(log_error(handler_name)), 56 | Ok(v) => v.rows_affected(), 57 | }; 58 | 59 | Ok(resp::ok(resp::AffResp { aff })) 60 | } 61 | -------------------------------------------------------------------------------- /src/api/admin/promotion.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | Json, 4 | }; 5 | use chrono::Local; 6 | use validator::Validate; 7 | 8 | use crate::{ 9 | api::{get_pool, log_error}, 10 | form, mid, model, resp, utils, ArcAppState, Error, Result, 11 | }; 12 | 13 | pub async fn create( 14 | State(state): State, 15 | _: mid::AdminAuth, 16 | Json(frm): Json, 17 | ) -> Result> { 18 | let hanlder_name = "admin/promotion/create"; 19 | frm.validate() 20 | .map_err(Error::from) 21 | .map_err(log_error(hanlder_name))?; 22 | 23 | let id = utils::id::new(); 24 | let p = get_pool(&state); 25 | 26 | let m = model::promotion::Promotion { 27 | id, 28 | name: frm.inner.name, 29 | content: frm.inner.content, 30 | url: frm.inner.url, 31 | img: frm.inner.img, 32 | dateline: Local::now(), 33 | }; 34 | 35 | m.insert(&*p) 36 | .await 37 | .map_err(Error::from) 38 | .map_err(log_error(hanlder_name))?; 39 | Ok(resp::ok(m.id)) 40 | } 41 | 42 | pub async fn edit( 43 | State(state): State, 44 | _: mid::AdminAuth, 45 | Json(frm): Json, 46 | ) -> Result { 47 | let handler_name = "admin/promotion/edit"; 48 | frm.validate() 49 | .map_err(Error::from) 50 | .map_err(log_error(handler_name))?; 51 | let mut tx = state 52 | .pool 53 | .begin() 54 | .await 55 | .map_err(Error::from) 56 | .map_err(log_error(handler_name))?; 57 | 58 | let m = match model::promotion::Promotion::find( 59 | &mut *tx, 60 | &model::promotion::PromotionFindFilter { 61 | id: Some(frm.id.clone()), 62 | }, 63 | ) 64 | .await 65 | { 66 | Ok(v) => match v { 67 | Some(v) => v, 68 | None => return Err(Error::new("不存在的推广")), 69 | }, 70 | Err(e) => { 71 | tx.rollback() 72 | .await 73 | .map_err(Error::from) 74 | .map_err(log_error(handler_name))?; 75 | return Err(e.into()); 76 | } 77 | }; 78 | 79 | let m = model::promotion::Promotion { 80 | name: frm.inner.name, 81 | content: frm.inner.content, 82 | url: frm.inner.url, 83 | img: frm.inner.img, 84 | ..m 85 | }; 86 | 87 | let aff = match m.update(&mut *tx).await { 88 | Ok(v) => v, 89 | Err(e) => { 90 | tx.rollback() 91 | .await 92 | .map_err(Error::from) 93 | .map_err(log_error(handler_name))?; 94 | return Err(e.into()); 95 | } 96 | }; 97 | 98 | tx.commit() 99 | .await 100 | .map_err(Error::from) 101 | .map_err(log_error(handler_name))?; 102 | Ok(resp::ok(resp::AffResp { aff })) 103 | } 104 | 105 | pub async fn del( 106 | State(state): State, 107 | _: mid::AdminAuth, 108 | Path(id): Path, 109 | ) -> Result { 110 | let handler_name = "admin/promotion/del"; 111 | let p = get_pool(&state); 112 | let aff = model::promotion::Promotion::real_del(&*p, &id) 113 | .await 114 | .map_err(Error::from) 115 | .map_err(log_error(handler_name))?; 116 | Ok(resp::ok(resp::AffResp { aff })) 117 | } 118 | 119 | pub async fn list( 120 | State(state): State, 121 | _: mid::AdminAuth, 122 | Query(frm): Query, 123 | ) -> Result> { 124 | let handler_name = "admin/promotion/list"; 125 | let p = get_pool(&state); 126 | let data = model::promotion::Promotion::list( 127 | &*p, 128 | &model::promotion::PromotionListFilter { 129 | name: frm.name, 130 | pq: model::promotion::PromotionPaginateReq { 131 | page: frm.pq.page(), 132 | page_size: frm.pq.page_size(), 133 | }, 134 | order: Some("id DESC".into()), 135 | }, 136 | ) 137 | .await 138 | .map_err(Error::from) 139 | .map_err(log_error(handler_name))?; 140 | Ok(resp::ok(data)) 141 | } 142 | -------------------------------------------------------------------------------- /src/api/admin/router.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | middleware, 3 | routing::{delete, get, post, put}, 4 | Router, 5 | }; 6 | 7 | use crate::{mid, ArcAppState}; 8 | 9 | use super::{ 10 | announcement, order, profile, promotion, service, session, statistics, subject, tag, topic, 11 | user, 12 | }; 13 | 14 | pub fn init(state: ArcAppState) -> Router { 15 | Router::new() 16 | .nest("/subject", subject_init(state.clone())) 17 | .nest("/tag", tag_init(state.clone())) 18 | .nest("/topic", topic_init(state.clone())) 19 | .nest("/profile", profile_init(state.clone())) 20 | .nest("/session", session_init(state.clone())) 21 | .nest("/user", user_init(state.clone())) 22 | .nest("/service", service_init(state.clone())) 23 | .nest("/order", order_init(state.clone())) 24 | .nest("/statistics", statistics_init(state.clone())) 25 | .nest("/announcement", announcement_init(state.clone())) 26 | .nest("/promotion", promotion_init(state.clone())) 27 | .layer(middleware::from_extractor_with_state::< 28 | mid::AdminAuth, 29 | ArcAppState, 30 | >(state.clone())) 31 | } 32 | 33 | fn subject_init(state: ArcAppState) -> Router { 34 | Router::new() 35 | .route( 36 | "/", 37 | get(subject::list).post(subject::add).put(subject::edit), 38 | ) 39 | .route("/{id}", delete(subject::del).patch(subject::res)) 40 | .route("/all", get(subject::all)) 41 | .with_state(state) 42 | } 43 | 44 | fn tag_init(state: ArcAppState) -> Router { 45 | Router::new() 46 | .route("/", get(tag::list).post(tag::add).put(tag::edit)) 47 | .route("/all", get(tag::all)) 48 | .route("/{id}", delete(tag::real_del)) 49 | .with_state(state) 50 | } 51 | 52 | fn topic_init(state: ArcAppState) -> Router { 53 | Router::new() 54 | .route("/", post(topic::add).put(topic::edit).get(topic::list)) 55 | .route("/{id}", delete(topic::del).patch(topic::res)) 56 | .with_state(state) 57 | } 58 | 59 | fn profile_init(state: ArcAppState) -> Router { 60 | Router::new() 61 | .route("/change-pwd", put(profile::change_pwd)) 62 | .with_state(state) 63 | } 64 | 65 | fn session_init(state: ArcAppState) -> Router { 66 | Router::new() 67 | .route("/logout", delete(session::logout)) 68 | .with_state(state) 69 | } 70 | 71 | fn user_init(state: ArcAppState) -> Router { 72 | Router::new() 73 | .route("/", get(user::list).post(user::add).put(user::edit)) 74 | .route("/{id}", delete(user::del).get(user::find_by_id)) 75 | .route("/search", get(user::search)) 76 | .with_state(state) 77 | } 78 | 79 | fn service_init(state: ArcAppState) -> Router { 80 | Router::new() 81 | .route( 82 | "/", 83 | get(service::list) 84 | .post(service::add) 85 | .put(service::edit) 86 | .patch(service::import), 87 | ) 88 | .route( 89 | "/{id}", 90 | put(service::on_off) 91 | .delete(service::del) 92 | .patch(service::sync), 93 | ) 94 | .route("/search", get(service::search)) 95 | .route("/all", get(service::list_all)) 96 | .with_state(state) 97 | } 98 | 99 | fn order_init(state: ArcAppState) -> Router { 100 | Router::new() 101 | .route("/", get(order::list).post(order::add).put(order::edit)) 102 | .route("/pay/{order_id}", get(order::find_pay)) 103 | .route("/{id}", put(order::close)) 104 | .with_state(state) 105 | } 106 | 107 | fn statistics_init(state: ArcAppState) -> Router { 108 | Router::new() 109 | .route("/", get(statistics::index)) 110 | .with_state(state) 111 | } 112 | 113 | fn announcement_init(state: ArcAppState) -> Router { 114 | Router::new() 115 | .route( 116 | "/", 117 | get(announcement::list) 118 | .post(announcement::add) 119 | .put(announcement::edit), 120 | ) 121 | .route("/{id}", delete(announcement::del)) 122 | .with_state(state) 123 | } 124 | 125 | fn promotion_init(state: ArcAppState) -> Router { 126 | Router::new() 127 | .route( 128 | "/", 129 | get(promotion::list) 130 | .post(promotion::create) 131 | .put(promotion::edit), 132 | ) 133 | .route("/{id}", delete(promotion::del)) 134 | .with_state(state) 135 | } 136 | -------------------------------------------------------------------------------- /src/api/admin/session.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | 3 | use crate::{ 4 | api::{get_pool, log_error}, 5 | mid, resp, ArcAppState, Result, 6 | }; 7 | 8 | pub async fn logout( 9 | State(state): State, 10 | mid::AdminAuth { token, .. }: mid::AdminAuth, 11 | ) -> Result { 12 | let handler_name = "admin/profile/logout"; 13 | 14 | let p = get_pool(&state); 15 | 16 | let aff = match sqlx::query("DELETE FROM sessions WHERE token = $1 AND is_admin = true") 17 | .bind(&token) 18 | .execute(&*p) 19 | .await 20 | { 21 | Err(e) => return Err(e.into()).map_err(log_error(handler_name)), 22 | Ok(v) => v.rows_affected(), 23 | }; 24 | 25 | Ok(resp::ok(resp::AffResp { aff })) 26 | } 27 | -------------------------------------------------------------------------------- /src/api/admin/statistics.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::{ 5 | api::{get_pool, log_error}, 6 | model, resp, utils, ArcAppState, Error, Result, 7 | }; 8 | 9 | #[derive(Serialize, Default, Deserialize, sqlx::FromRow)] 10 | pub struct Statistics { 11 | pub title: String, 12 | pub all_users: i64, 13 | pub today_users: i64, 14 | pub all_orders: i64, 15 | pub today_orders: i64, 16 | pub subject: i64, 17 | pub topic: i64, 18 | pub service: i64, 19 | pub purchased_service: i64, 20 | } 21 | pub async fn index(State(state): State) -> Result> { 22 | let handler_name = "admin/statistics/index"; 23 | let p = get_pool(&state); 24 | 25 | let (start, end) = utils::dt::today(); 26 | 27 | let r: Vec = sqlx::query_as( 28 | r#"SELECT t.title 29 | ,COALESCE(MAX(t.c) FILTER (WHERE title='all_users'), 0) AS all_users 30 | ,COALESCE(max(t.c) FILTER( WHERE title='today_users'), 0) AS today_users 31 | ,COALESCE(max(t.c) FILTER( WHERE title='all_orders'), 0) AS all_orders 32 | ,COALESCE(max(t.c) FILTER( WHERE title='today_orders'), 0) AS today_orders 33 | ,COALESCE(max(t.c) FILTER( WHERE title='subject'), 0) AS subject 34 | ,COALESCE(max(t.c) FILTER( WHERE title='topic'), 0) AS topic 35 | ,COALESCE(max(t.c) FILTER( WHERE title='service'), 0) AS service 36 | ,COALESCE(max(t.c) FILTER( WHERE title='purchased_service'), 0) AS purchased_service 37 | FROM ( 38 | SELECT 'all_users' AS title, COUNT(*) AS c FROM users 39 | UNION ALL 40 | SELECT 'today_users' AS title, COUNT(*) AS c FROM users WHERE dateline BETWEEN $1 AND $2 41 | UNION ALL 42 | SELECT 'all_orders' AS title, COUNT(*) AS c FROM orders 43 | UNION ALL 44 | SELECT 'today_orders' AS title, COUNT(*) AS c FROM orders WHERE dateline BETWEEN $1 AND $2 45 | UNION ALL 46 | SELECT 'subject' AS title, COUNT(*) AS c FROM subjects 47 | UNION ALL 48 | SELECT 'topic' AS title, COUNT(*) AS c FROM topics 49 | UNION ALL 50 | SELECT 'service' AS title, COUNT(*) AS c FROM services 51 | UNION ALL 52 | SELECT 'purchased_service' AS title, 0 AS c 53 | ) AS t 54 | GROUP BY title"#, 55 | ) 56 | .bind(&start) 57 | .bind(&end) 58 | .fetch_all(&*p) 59 | .await 60 | .map_err(Error::from) 61 | .map_err(log_error(handler_name))?; 62 | 63 | let mut data = Statistics::default(); 64 | 65 | for v in r { 66 | match v.title.as_str() { 67 | "all_users" => data.all_users = v.all_users.max(0), 68 | "today_users" => data.today_users = v.today_users.max(0), 69 | "all_orders" => data.all_orders = v.all_orders.max(0), 70 | "today_orders" => data.today_orders = v.today_orders.max(0), 71 | "subject" => data.subject = v.subject.max(0), 72 | "topic" => data.topic = v.topic.max(0), 73 | "service" => data.service = v.service.max(0), 74 | //"purchased_service" => data.purchased_service = v.purchased_service.max(0), 75 | _ => {} 76 | } 77 | } 78 | 79 | // 处理已购服务 80 | 81 | let ol = model::order::Order::list_all( 82 | &*p, 83 | &model::order::OrderListAllFilter { 84 | limit: None, 85 | order: None, 86 | user_id: None, 87 | status: Some(model::order::Status::Finished), 88 | }, 89 | ) 90 | .await 91 | .map_err(Error::from) 92 | .map_err(log_error(handler_name))?; 93 | 94 | let mut purchased_service = 0; 95 | let order_service_list = ol.into_iter().map(|o| o.to_snapshot()).collect::>(); 96 | for oss in order_service_list { 97 | for os in oss { 98 | purchased_service += os.service.num as i64; 99 | } 100 | } 101 | 102 | data.purchased_service = purchased_service; 103 | 104 | Ok(resp::ok(data)) 105 | } 106 | -------------------------------------------------------------------------------- /src/api/admin/subject.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | Json, 4 | }; 5 | use rust_decimal::Decimal; 6 | use validator::Validate; 7 | 8 | use crate::{ 9 | api::{get_pool, log_error}, 10 | form, model, resp, service, ArcAppState, Error, Result, 11 | }; 12 | 13 | pub async fn list( 14 | State(state): State, 15 | Query(frm): Query, 16 | ) -> Result> { 17 | let handler_name = "admin/subject/list"; 18 | let p = get_pool(&state); 19 | let subjects = model::subject::Subject::list( 20 | &*p, 21 | &model::subject::SubjectListFilter { 22 | pq: model::subject::SubjectPaginateReq { 23 | page: frm.pq.page(), 24 | page_size: frm.pq.page_size(), 25 | }, 26 | order: None, 27 | is_del: frm.is_del(), 28 | status: frm.status, 29 | name: frm.name, 30 | slug: frm.slug, 31 | }, 32 | ) 33 | .await 34 | .map_err(Error::from) 35 | .map_err(log_error(handler_name))?; 36 | Ok(resp::ok(subjects)) 37 | } 38 | 39 | pub async fn add( 40 | State(state): State, 41 | Json(frm): Json, 42 | ) -> Result { 43 | let handler_name = "admin/subject/add"; 44 | 45 | frm.validate() 46 | .map_err(Error::from) 47 | .map_err(log_error(handler_name))?; 48 | 49 | let p = get_pool(&state); 50 | let m = service::subject::add( 51 | &*p, 52 | model::subject::Subject { 53 | name: frm.name, 54 | slug: frm.slug, 55 | summary: frm.summary, 56 | is_del: false, 57 | cover: frm.cover, 58 | status: frm.status, 59 | price: frm.price, 60 | pin: frm.pin, 61 | ..Default::default() 62 | }, 63 | ) 64 | .await 65 | .map_err(log_error(handler_name))?; 66 | Ok(resp::ok(resp::IDResp { id: m.id })) 67 | } 68 | 69 | pub async fn edit( 70 | State(state): State, 71 | Json(frm): Json, 72 | ) -> Result { 73 | let handler_name = "admin/subject/edit"; 74 | 75 | frm.validate() 76 | .map_err(Error::from) 77 | .map_err(log_error(handler_name))?; 78 | 79 | let p = get_pool(&state); 80 | let aff = service::subject::edit( 81 | &*p, 82 | &model::subject::Subject { 83 | id: frm.id, 84 | name: frm.base.name, 85 | slug: frm.base.slug, 86 | summary: frm.base.summary, 87 | cover: frm.base.cover, 88 | status: frm.base.status, 89 | price: frm.base.price, 90 | pin: frm.base.pin, 91 | ..Default::default() 92 | }, 93 | ) 94 | .await 95 | .map_err(log_error(handler_name))?; 96 | Ok(resp::ok(resp::AffResp { aff })) 97 | } 98 | 99 | pub async fn del( 100 | State(state): State, 101 | Path(id): Path, 102 | Query(frm): Query, 103 | ) -> Result { 104 | let handler_name = "admin/subject/del"; 105 | let p = get_pool(&state); 106 | let real = match frm.real { 107 | Some(v) => v, 108 | None => false, 109 | }; 110 | let aff = if real { 111 | model::subject::Subject::real_del(&*p, &id).await 112 | } else { 113 | model::subject::Subject::update_is_del(&*p, &true, &id).await 114 | } 115 | .map_err(Error::from) 116 | .map_err(log_error(handler_name))?; 117 | Ok(resp::ok(resp::AffResp { aff })) 118 | } 119 | 120 | pub async fn res( 121 | State(state): State, 122 | Path(id): Path, 123 | ) -> Result { 124 | let handler_name = "admin/subject/res"; 125 | let p = get_pool(&state); 126 | let aff = model::subject::Subject::update_is_del(&*p, &false, &id) 127 | .await 128 | .map_err(Error::from) 129 | .map_err(log_error(handler_name))?; 130 | Ok(resp::ok(resp::AffResp { aff })) 131 | } 132 | 133 | pub async fn all( 134 | State(state): State, 135 | Query(frm): Query, 136 | ) -> Result>> { 137 | let handler_name = "admin/subject/all"; 138 | let p = get_pool(&state); 139 | let data = model::subject::Subject::list_all( 140 | &*p, 141 | &model::subject::SubjectListAllFilter { 142 | limit: frm.limit, 143 | order: Some("id ASC".into()), 144 | name: None, 145 | slug: None, 146 | is_del: Some(false), 147 | status: None, 148 | }, 149 | ) 150 | .await 151 | .map_err(Error::from) 152 | .map_err(log_error(handler_name))?; 153 | 154 | if let Some(has_price) = frm.has_price { 155 | if has_price { 156 | return Ok(resp::ok( 157 | data.into_iter() 158 | .filter(|v| v.price.gt(&Decimal::ZERO)) 159 | .collect(), 160 | )); 161 | } 162 | } 163 | Ok(resp::ok(data)) 164 | } 165 | -------------------------------------------------------------------------------- /src/api/admin/tag.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | Json, 4 | }; 5 | 6 | use crate::{ 7 | api::{get_pool, log_error}, 8 | form, model, resp, service, ArcAppState, Error, Result, 9 | }; 10 | 11 | pub async fn all( 12 | State(state): State, 13 | Query(frm): Query, 14 | ) -> Result>> { 15 | let handler_name = "admin/tag/all"; 16 | let p = get_pool(&state); 17 | let data = model::tag::Tag::list_all( 18 | &*p, 19 | &model::tag::TagListAllFilter { 20 | limit: frm.limit, 21 | order: Some("id ASC".into()), 22 | name: None, 23 | is_del: Some(false), 24 | }, 25 | ) 26 | .await 27 | .map_err(Error::from) 28 | .map_err(log_error(handler_name))?; 29 | Ok(resp::ok(data)) 30 | } 31 | 32 | pub async fn list( 33 | State(state): State, 34 | Query(frm): Query, 35 | ) -> Result> { 36 | let handler_name = "admin/tag/list"; 37 | let p = get_pool(&state); 38 | let data = model::tag::Tag::list( 39 | &*p, 40 | &model::tag::TagListFilter { 41 | pq: model::tag::TagPaginateReq { 42 | page: frm.pq.page(), 43 | page_size: frm.pq.page_size(), 44 | }, 45 | order: None, 46 | is_del: frm.is_del(), 47 | name: frm.name, 48 | }, 49 | ) 50 | .await 51 | .map_err(Error::from) 52 | .map_err(log_error(handler_name))?; 53 | 54 | Ok(resp::ok(data)) 55 | } 56 | 57 | pub async fn real_del( 58 | State(state): State, 59 | Path(id): Path, 60 | ) -> Result { 61 | let handler_name = "admin/tag/del"; 62 | let p = get_pool(&state); 63 | let aff = model::tag::Tag::real_del(&*p, &id) 64 | .await 65 | .map_err(Error::from) 66 | .map_err(log_error(handler_name))?; 67 | Ok(resp::ok(resp::AffResp { aff })) 68 | } 69 | 70 | pub async fn add( 71 | State(state): State, 72 | Json(frm): Json, 73 | ) -> Result { 74 | let handler_name = "admin/tag/add"; 75 | let p = get_pool(&state); 76 | let m = service::tag::add( 77 | &*p, 78 | model::tag::Tag { 79 | name: frm.name, 80 | ..Default::default() 81 | }, 82 | ) 83 | .await 84 | .map_err(Error::from) 85 | .map_err(log_error(handler_name))?; 86 | Ok(resp::ok(resp::IDResp { id: m.id })) 87 | } 88 | 89 | pub async fn edit( 90 | State(state): State, 91 | Json(frm): Json, 92 | ) -> Result { 93 | let handler_name = "admin/tag/edit"; 94 | let p = get_pool(&state); 95 | let m = match model::tag::Tag::find( 96 | &*p, 97 | &model::tag::TagFindFilter { 98 | id: Some(frm.id.clone()), 99 | name: None, 100 | is_del: Some(false), 101 | }, 102 | ) 103 | .await 104 | .map_err(Error::from) 105 | .map_err(log_error(handler_name))? 106 | { 107 | Some(v) => v, 108 | None => return Err(Error::new("不存在的标签")), 109 | }; 110 | 111 | let aff = service::tag::edit( 112 | &*p, 113 | &model::tag::Tag { 114 | name: frm.base.name, 115 | ..m 116 | }, 117 | ) 118 | .await 119 | .map_err(Error::from) 120 | .map_err(log_error(handler_name))?; 121 | Ok(resp::ok(resp::AffResp { aff })) 122 | } 123 | -------------------------------------------------------------------------------- /src/api/admin/topic.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | Json, 4 | }; 5 | use chrono::Local; 6 | use validator::Validate; 7 | 8 | use crate::{ 9 | api::{get_pool, log_error}, 10 | form, model, resp, service, ArcAppState, Error, Result, 11 | }; 12 | 13 | pub async fn list( 14 | State(state): State, 15 | Query(frm): Query, 16 | ) -> Result>> { 17 | let handler_name = "admin/topic/list"; 18 | let p = get_pool(&state); 19 | 20 | let subject_id = if let Some(subject_name) = &frm.subject_name { 21 | let sql = format!( 22 | "SELECT id FROM {} WHERE name ILIKE $1", 23 | &model::subject::Subject::table() 24 | ); 25 | let param = format!("%{}%", subject_name); 26 | let sb: Option<(String,)> = sqlx::query_as(&sql) 27 | .bind(¶m) 28 | .fetch_optional(&*p) 29 | .await 30 | .map_err(Error::from) 31 | .map_err(log_error(handler_name))?; 32 | match sb { 33 | Some(v) => Some(v.0), 34 | None => None, 35 | } 36 | } else { 37 | None 38 | }; 39 | 40 | let data = service::topic::list_opt( 41 | &*p, 42 | &model::topic_views::VTopicSubjectListFilter { 43 | is_del: frm.is_del(), 44 | order: Some("id DESC".into()), 45 | title: frm.title, 46 | subject_id, 47 | slug: frm.slug, 48 | 49 | subject_slug: frm.subject_slug, 50 | subject_is_del: None, 51 | status: None, 52 | pq: model::topic_views::VTopicSubjectPaginateReq { 53 | page: frm.pq.page(), 54 | page_size: frm.pq.page_size(), 55 | }, 56 | v_topic_subject_list_between_datelines: None, 57 | }, 58 | ) 59 | .await 60 | .map_err(Error::from) 61 | .map_err(log_error(handler_name))?; 62 | 63 | Ok(resp::ok(data)) 64 | } 65 | 66 | pub async fn add( 67 | State(state): State, 68 | Json(frm): Json, 69 | ) -> Result { 70 | let handler_name = "admin/topic/add"; 71 | 72 | frm.validate() 73 | .map_err(Error::from) 74 | .map_err(log_error(handler_name))?; 75 | 76 | let p = get_pool(&state); 77 | 78 | let tag_names = frm 79 | .tag_names 80 | .iter() 81 | .map(|tn| tn.as_str()) 82 | .collect::>(); 83 | 84 | let m = service::topic::add( 85 | &*p, 86 | model::topic::Topic { 87 | title: frm.title, 88 | subject_id: frm.subject_id, 89 | slug: frm.slug, 90 | summary: frm.summary, 91 | author: frm.author, 92 | src: frm.src, 93 | hit: 0, 94 | dateline: Local::now(), 95 | try_readable: frm.try_readable, 96 | is_del: false, 97 | cover: frm.cover, 98 | md: frm.md, 99 | pin: frm.pin, 100 | ..Default::default() 101 | }, 102 | &tag_names, 103 | &state.cfg.topic_section_secret_key, 104 | ) 105 | .await 106 | .map_err(log_error(handler_name))?; 107 | 108 | Ok(resp::ok(resp::IDResp { id: m.id })) 109 | } 110 | 111 | pub async fn edit( 112 | State(state): State, 113 | Json(frm): Json, 114 | ) -> Result { 115 | let handler_name = "admin/topic/edit"; 116 | 117 | frm.validate() 118 | .map_err(Error::from) 119 | .map_err(log_error(handler_name))?; 120 | 121 | let tag_names = frm 122 | .base 123 | .tag_names 124 | .iter() 125 | .map(|tn| tn.as_str()) 126 | .collect::>(); 127 | 128 | let p = get_pool(&state); 129 | 130 | let m = match model::topic::Topic::find( 131 | &*p, 132 | &model::topic::TopicFindFilter { 133 | id: Some(frm.id.clone()), 134 | subject_id: None, 135 | slug: None, 136 | }, 137 | ) 138 | .await 139 | .map_err(Error::from) 140 | .map_err(log_error(handler_name))? 141 | { 142 | Some(v) => v, 143 | None => return Err(Error::new("不存在的文章")), 144 | }; 145 | 146 | let aff = service::topic::edit( 147 | &*p, 148 | &model::topic::Topic { 149 | title: frm.base.title, 150 | subject_id: frm.base.subject_id, 151 | slug: frm.base.slug, 152 | summary: frm.base.summary, 153 | author: frm.base.author, 154 | src: frm.base.src, 155 | try_readable: frm.base.try_readable, 156 | cover: frm.base.cover, 157 | md: frm.base.md, 158 | pin: frm.base.pin, 159 | ..m 160 | }, 161 | &tag_names, 162 | &state.cfg.topic_section_secret_key, 163 | ) 164 | .await 165 | .map_err(log_error(handler_name))?; 166 | 167 | Ok(resp::ok(resp::AffResp { aff })) 168 | } 169 | 170 | pub async fn del( 171 | State(state): State, 172 | Path(id): Path, 173 | ) -> Result { 174 | let handler_name = "admin/topic/del"; 175 | let p = get_pool(&state); 176 | 177 | let aff = model::topic::Topic::update_is_del(&*p, &true, &id) 178 | .await 179 | .map_err(Error::from) 180 | .map_err(log_error(handler_name))?; 181 | Ok(resp::ok(resp::AffResp { aff })) 182 | } 183 | 184 | pub async fn res( 185 | State(state): State, 186 | Path(id): Path, 187 | ) -> Result { 188 | let handler_name = "admin/topic/res"; 189 | let p = get_pool(&state); 190 | let aff = model::topic::Topic::update_is_del(&*p, &false, &id) 191 | .await 192 | .map_err(Error::from) 193 | .map_err(log_error(handler_name))?; 194 | Ok(resp::ok(resp::AffResp { aff })) 195 | } 196 | -------------------------------------------------------------------------------- /src/api/admin/user.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | Json, 4 | }; 5 | use chrono::Local; 6 | use sqlx::QueryBuilder; 7 | use validator::Validate; 8 | 9 | use crate::{ 10 | api::{get_pool, log_error}, 11 | form, model, resp, service, utils, ArcAppState, Error, Result, 12 | }; 13 | 14 | pub async fn list( 15 | State(state): State, 16 | Query(frm): Query, 17 | ) -> Result> { 18 | let handler_name = "admin/user/list"; 19 | let p = get_pool(&state); 20 | 21 | let data = model::user::User::list( 22 | &*p, 23 | &model::user::UserListFilter { 24 | pq: model::user::UserPaginateReq { 25 | page: frm.pq.page(), 26 | page_size: frm.pq.page_size(), 27 | }, 28 | order: None, 29 | email: frm.email, 30 | nickname: frm.nickname, 31 | status: frm.status, 32 | kind: frm.kind, 33 | }, 34 | ) 35 | .await 36 | .map_err(Error::from) 37 | .map_err(log_error(handler_name))?; 38 | 39 | Ok(resp::ok(data)) 40 | } 41 | 42 | pub async fn add( 43 | State(state): State, 44 | Json(frm): Json, 45 | ) -> Result { 46 | let handler_name = "admin/user/add"; 47 | 48 | frm.validate() 49 | .map_err(Error::from) 50 | .map_err(log_error(handler_name))?; 51 | 52 | if frm.base.password != frm.base.re_password { 53 | return Err(Error::new("两次输入的密码不一致")); 54 | } 55 | 56 | let password = utils::password::hash(&frm.base.password) 57 | .map_err(Error::from) 58 | .map_err(log_error(handler_name))?; 59 | 60 | let p = get_pool(&state); 61 | 62 | let m = service::user::add( 63 | &*p, 64 | model::user::User { 65 | sub_exp: frm.sub_exp(), 66 | email: frm.base.email, 67 | nickname: frm.base.nickname, 68 | password, 69 | status: frm.status, 70 | dateline: Local::now(), 71 | kind: frm.kind, 72 | points: frm.points, 73 | allow_device_num: frm.allow_device_num, 74 | session_exp: frm.session_exp, 75 | 76 | ..Default::default() 77 | }, 78 | ) 79 | .await 80 | .map_err(log_error(handler_name))?; 81 | 82 | Ok(resp::ok(resp::IDResp { id: m.id })) 83 | } 84 | 85 | pub async fn edit( 86 | State(state): State, 87 | Json(frm): Json, 88 | ) -> Result { 89 | let handler_name = "admin/user/edit"; 90 | 91 | frm.validate() 92 | .map_err(Error::from) 93 | .map_err(log_error(handler_name))?; 94 | 95 | if frm.password != frm.re_password { 96 | return Err(Error::new("两次输入的密码不一致")); 97 | } 98 | 99 | let p = get_pool(&state); 100 | 101 | let user = match model::user::User::find( 102 | &*p, 103 | &model::user::UserFindFilter { 104 | by: model::user::UserFindBy::Id(frm.id.clone()), 105 | status: None, 106 | }, 107 | ) 108 | .await 109 | .map_err(Error::from) 110 | .map_err(log_error(handler_name))? 111 | { 112 | Some(v) => { 113 | let password = if let Some(ref v) = frm.password { 114 | utils::password::hash(v) 115 | .map_err(Error::from) 116 | .map_err(log_error(handler_name))? 117 | } else { 118 | v.password.clone() 119 | }; 120 | model::user::User { 121 | sub_exp: frm.sub_exp(), 122 | email: frm.email, 123 | nickname: frm.nickname, 124 | status: frm.status, 125 | kind: frm.kind, 126 | points: frm.points, 127 | allow_device_num: frm.allow_device_num, 128 | session_exp: frm.session_exp, 129 | password, 130 | ..v 131 | } 132 | } 133 | None => return Err(Error::new("不存在的用户")), 134 | }; 135 | 136 | let aff = service::user::edit(&*p, &user) 137 | .await 138 | .map_err(log_error(handler_name))?; 139 | 140 | Ok(resp::ok(resp::AffResp { aff })) 141 | } 142 | 143 | pub async fn del( 144 | State(state): State, 145 | Path(id): Path, 146 | ) -> Result { 147 | let handler_name = "admin/user/del"; 148 | let p = get_pool(&state); 149 | let aff = model::user::User::real_del(&*p, &id) 150 | .await 151 | .map_err(Error::from) 152 | .map_err(log_error(handler_name))?; 153 | Ok(resp::ok(resp::AffResp { aff })) 154 | } 155 | 156 | pub async fn search( 157 | State(state): State, 158 | Query(frm): Query, 159 | ) -> Result>> { 160 | let handler_name = "admin/user/search"; 161 | 162 | let p = get_pool(&state); 163 | let sql = format!( 164 | "SELECT {} FROM {:?} WHERE 1=1 ", 165 | &model::user::User::fields(), 166 | &model::user::User::table() 167 | ); 168 | 169 | let mut q = QueryBuilder::new(&sql); 170 | if let Some(ref user_id) = frm.user_id { 171 | q.push(" AND id =").push_bind(user_id); 172 | } else { 173 | let keyword = format!("%{}%", frm.q); 174 | q.push(" AND (email ILIKE ") 175 | .push_bind(keyword.clone()) 176 | .push(" OR nickname ILIKE ") 177 | .push_bind(keyword) 178 | .push(")"); 179 | } 180 | q.push(" AND status=") 181 | .push_bind(&model::user::Status::Actived); 182 | q.push(" ORDER BY id DESC LIMIT 30"); 183 | 184 | let rows = q 185 | .build_query_as() 186 | .fetch_all(&*p) 187 | .await 188 | .map_err(Error::from) 189 | .map_err(log_error(handler_name))?; 190 | Ok(resp::ok(rows)) 191 | } 192 | 193 | pub async fn find_by_id( 194 | State(state): State, 195 | Path(id): Path, 196 | ) -> Result> { 197 | let handler_name = "admin/user/find_by_id"; 198 | let p = get_pool(&state); 199 | let m = model::user::User::find( 200 | &*p, 201 | &model::user::UserFindFilter { 202 | by: model::user::UserFindBy::Id(id), 203 | status: None, 204 | }, 205 | ) 206 | .await 207 | .map_err(Error::from) 208 | .map_err(log_error(handler_name))? 209 | .ok_or(Error::new("不存在的用户"))?; 210 | Ok(resp::ok(m)) 211 | } 212 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::{AppState, Error}; 4 | 5 | pub(super) mod admin; 6 | pub(super) mod auth; 7 | pub mod router; 8 | pub(super) mod user; 9 | pub(super) mod web; 10 | 11 | fn log_error(handler_name: &str) -> Box Error> { 12 | let handler_name = handler_name.to_string(); 13 | Box::new(move |err| { 14 | tracing::error!("👉 [{}] - {:?}", handler_name, err); 15 | err 16 | }) 17 | } 18 | 19 | fn get_pool(state: &AppState) -> Arc { 20 | state.pool.clone() 21 | } 22 | -------------------------------------------------------------------------------- /src/api/router.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::DefaultBodyLimit, 3 | middleware::from_extractor, 4 | routing::{get, post}, 5 | Router, 6 | }; 7 | use tower_http::{ 8 | cors::{Any, CorsLayer}, 9 | limit::RequestBodyLimitLayer, 10 | }; 11 | 12 | use crate::{mid, ArcAppState}; 13 | 14 | use super::{admin, auth, user, web}; 15 | 16 | pub fn init(state: ArcAppState) -> Router { 17 | let r = Router::new() 18 | .merge(web_init(state.clone())) 19 | .nest("/auth", auth_init(state.clone())) 20 | .nest("/user", user::router::init(state.clone())) 21 | .nest("/admin", admin::router::init(state.clone())); 22 | 23 | Router::new() 24 | .nest(&state.cfg.web.prefix, r) 25 | .layer( 26 | CorsLayer::new() 27 | .allow_headers(Any) 28 | .allow_methods(Any) 29 | .allow_origin(Any), 30 | ) 31 | .layer(DefaultBodyLimit::disable()) 32 | .layer(RequestBodyLimitLayer::new(state.cfg.upload.max_size)) 33 | .layer(from_extractor::()) 34 | } 35 | 36 | fn web_init(state: ArcAppState) -> Router { 37 | Router::new() 38 | .route("/ping", get(web::ping)) 39 | .route("/rss", get(web::rss)) 40 | .with_state(state) 41 | } 42 | 43 | fn auth_init(state: ArcAppState) -> Router { 44 | Router::new() 45 | .route("/login", post(auth::login)) 46 | .route("/register", post(auth::register)) 47 | .route("/send-code", post(auth::send_code)) 48 | .route("/admin-login", post(auth::admin_login)) 49 | .route("/active", post(auth::active)) 50 | .route("/reset-pwd", post(auth::reset_password)) 51 | .with_state(state) 52 | } 53 | -------------------------------------------------------------------------------- /src/api/user/announcement.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::{Path, Query, State}; 2 | 3 | use crate::{ 4 | api::{get_pool, log_error}, 5 | form, model, resp, ArcAppState, Error, Result, 6 | }; 7 | 8 | pub async fn list( 9 | State(state): State, 10 | Query(frm): Query, 11 | ) -> Result>> { 12 | let handler_name = "api/user/announcement/list"; 13 | let p = get_pool(&state); 14 | 15 | let mut q = sqlx::QueryBuilder::new("SELECT id,title,dateline FROM announcements "); 16 | q.push(" ORDER BY id DESC ") 17 | .push(" LIMIT ") 18 | .push_bind(frm.page_size_to_bind()) 19 | .push(" OFFSET ") 20 | .push_bind(frm.offset_to_bind()); 21 | 22 | let ls = q 23 | .build_query_as() 24 | .fetch_all(&*p) 25 | .await 26 | .map_err(Error::from) 27 | .map_err(log_error(handler_name))?; 28 | let c: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM announcements") 29 | .fetch_one(&*p) 30 | .await 31 | .map_err(Error::from) 32 | .map_err(log_error(handler_name))?; 33 | 34 | let data = model::pagination::Paginate::quick(c, frm.page(), frm.page_size(), ls); 35 | Ok(resp::ok(data)) 36 | } 37 | 38 | pub async fn detail( 39 | State(state): State, 40 | Path(id): Path, 41 | ) -> Result> { 42 | let handler_name = "api/user/announcement/detail"; 43 | let p = get_pool(&state); 44 | 45 | let mut tx = p 46 | .begin() 47 | .await 48 | .map_err(Error::from) 49 | .map_err(log_error(handler_name))?; 50 | 51 | let data = match model::announcement::Announcement::find( 52 | &mut *tx, 53 | &model::announcement::AnnouncementFindFilter { id: Some(id) }, 54 | ) 55 | .await 56 | { 57 | Ok(v) => match v { 58 | Some(v) => v, 59 | None => return Err(Error::new("不存在的公告")).map_err(log_error(handler_name)), 60 | }, 61 | Err(e) => { 62 | tx.rollback() 63 | .await 64 | .map_err(Error::from) 65 | .map_err(log_error(handler_name))?; 66 | return Err(e.into()).map_err(log_error(handler_name)); 67 | } 68 | }; 69 | 70 | let data = model::announcement::Announcement { 71 | hit: data.hit + 1, 72 | ..data 73 | }; 74 | 75 | if let Err(e) = data.update(&mut *tx).await { 76 | tx.rollback() 77 | .await 78 | .map_err(Error::from) 79 | .map_err(log_error(handler_name))?; 80 | return Err(e.into()).map_err(log_error(handler_name)); 81 | } 82 | 83 | tx.commit() 84 | .await 85 | .map_err(Error::from) 86 | .map_err(log_error(handler_name))?; 87 | 88 | Ok(resp::ok(data)) 89 | } 90 | -------------------------------------------------------------------------------- /src/api/user/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod announcement; 2 | pub mod order; 3 | pub mod pay; 4 | pub mod ping; 5 | pub mod promotion; 6 | pub mod read_history; 7 | pub mod router; 8 | pub mod service; 9 | pub mod subject; 10 | pub mod tag; 11 | pub mod topic; 12 | pub mod user; 13 | -------------------------------------------------------------------------------- /src/api/user/order.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | Json, 4 | }; 5 | use chrono::Local; 6 | use rust_decimal::Decimal; 7 | use validator::Validate; 8 | 9 | use crate::{ 10 | api::{get_pool, log_error}, 11 | form, mid, model, resp, utils, ArcAppState, Error, Result, 12 | }; 13 | 14 | pub async fn create( 15 | State(state): State, 16 | user_auth: mid::UserAuth, 17 | Json(frm): Json, 18 | ) -> Result { 19 | let handler_name = "user/order/create"; 20 | 21 | let user = user_auth.user().map_err(log_error(handler_name))?; 22 | 23 | frm.validate() 24 | .map_err(Error::from) 25 | .map_err(log_error(handler_name))?; 26 | 27 | if frm.services.is_empty() { 28 | return Err(Error::new("订购的服务不能为空")); 29 | } 30 | 31 | let p = get_pool(&state); 32 | 33 | let mut tx = p 34 | .begin() 35 | .await 36 | .map_err(Error::from) 37 | .map_err(log_error(handler_name))?; 38 | 39 | // 服务列表 40 | let service_ids = frm 41 | .services 42 | .iter() 43 | .map(|s| s.id.clone()) 44 | .collect::>(); 45 | let mut q = sqlx::QueryBuilder::new( 46 | r#"SELECT id, "name", is_subject, target_id, duration, price, cover, allow_pointer, normal_discount, sub_discount, yearly_sub_discount, is_off, "desc", pin FROM services WHERE id IN "#, 47 | ); 48 | q.push_tuples(&service_ids, |mut b, id| { 49 | b.push_bind(id); 50 | }); 51 | let service_list: Vec = 52 | match q.build_query_as().fetch_all(&mut *tx).await { 53 | Ok(v) => v, 54 | Err(e) => { 55 | tx.rollback() 56 | .await 57 | .map_err(Error::from) 58 | .map_err(log_error(handler_name))?; 59 | return Err(e.into()).map_err(log_error(handler_name)); 60 | } 61 | }; 62 | 63 | // 快照 64 | let mut snapshot_list = Vec::with_capacity(service_list.len()); 65 | for (idx, s) in service_list.into_iter().enumerate() { 66 | let num = match frm.services.get(idx) { 67 | Some(v) => v.num, 68 | None => return Err(Error::new("服务不存在")), 69 | }; 70 | let amount = s.price * Decimal::from_i128_with_scale(num as i128, 0); 71 | let discount = 1; // TODO: 折扣<后续版本> 72 | let actual_price = s.price * Decimal::from_i128_with_scale(discount as i128, 0); 73 | let actual_amount = actual_price * Decimal::from_i128_with_scale(num as i128, 0); 74 | let snap_service = model::order::OrderSnapshotService { 75 | service: model::service::Service { ..s }, 76 | actual_price, 77 | amount, 78 | actual_amount, 79 | discount, 80 | num, 81 | }; 82 | let snap = model::order::OrderSnapshot { 83 | service: snap_service, 84 | user: user.clone(), 85 | }; 86 | snapshot_list.push(snap); 87 | } 88 | 89 | // 计算总价 90 | let mut amount = Decimal::ZERO; 91 | let mut actual_amount = Decimal::ZERO; 92 | for s in snapshot_list.iter() { 93 | amount += s.service.amount; 94 | actual_amount += s.service.actual_amount; 95 | } 96 | 97 | // 检测提交的金额和实际金额是否一致 98 | if !(frm.amount == amount && frm.actual_amount == actual_amount) { 99 | return Err(Error::new("订单金额不符")).map_err(log_error(handler_name)); 100 | } 101 | 102 | let snapshot = model::order::Order::snapshot_to_str(&snapshot_list); 103 | let id = utils::id::new(); 104 | 105 | let m = model::order::Order { 106 | id, 107 | user_id: user.id.clone(), 108 | dateline: Local::now(), 109 | status: model::order::Status::Pending, 110 | amount, 111 | actual_amount, 112 | snapshot, 113 | allow_pointer: false, 114 | ..Default::default() 115 | }; 116 | 117 | if let Err(e) = m.insert(&mut *tx).await { 118 | tx.rollback() 119 | .await 120 | .map_err(Error::from) 121 | .map_err(log_error(handler_name))?; 122 | return Err(e.into()).map_err(log_error(handler_name)); 123 | } 124 | 125 | tx.commit() 126 | .await 127 | .map_err(Error::from) 128 | .map_err(log_error(handler_name))?; 129 | Ok(resp::ok(resp::IDResp { id: m.id })) 130 | } 131 | 132 | pub async fn list( 133 | State(state): State, 134 | user_auth: mid::UserAuth, 135 | Query(frm): Query, 136 | ) -> Result> { 137 | let handler_name = "user/order/list"; 138 | 139 | let user = user_auth.user().map_err(log_error(handler_name))?; 140 | 141 | let p = get_pool(&state); 142 | 143 | let data = model::order::Order::list( 144 | &*p, 145 | &model::order::OrderListFilter { 146 | pq: model::order::OrderPaginateReq { 147 | page: frm.pq.page(), 148 | page_size: frm.pq.page_size(), 149 | }, 150 | order: None, 151 | user_id: Some(user.id.clone()), 152 | status: frm.status, 153 | }, 154 | ) 155 | .await 156 | .map_err(Error::from) 157 | .map_err(log_error(handler_name))?; 158 | 159 | Ok(resp::ok(data)) 160 | } 161 | 162 | pub async fn detail( 163 | State(state): State, 164 | user_auth: mid::UserAuth, 165 | Path(id): Path, 166 | ) -> Result> { 167 | let handler_name = "user/order/detail"; 168 | 169 | let user = user_auth.user().map_err(log_error(handler_name))?; 170 | 171 | let p = get_pool(&state); 172 | 173 | let data = match model::order::Order::find( 174 | &*p, 175 | &model::order::OrderFindFilter { 176 | id: Some(id), 177 | user_id: Some(user.id.clone()), 178 | status: None, 179 | }, 180 | ) 181 | .await 182 | .map_err(Error::from) 183 | .map_err(log_error(handler_name))? 184 | { 185 | Some(v) => v, 186 | None => return Err(Error::new("不存在的订单")), 187 | }; 188 | 189 | Ok(resp::ok(data)) 190 | } 191 | 192 | pub async fn cancel( 193 | State(state): State, 194 | user_auth: mid::UserAuth, 195 | Path(id): Path, 196 | ) -> Result { 197 | let handler_name = "user/order/cancel"; 198 | let user = user_auth.user().map_err(log_error(handler_name))?; 199 | 200 | let p = get_pool(&state); 201 | let mut tx = p 202 | .begin() 203 | .await 204 | .map_err(Error::from) 205 | .map_err(log_error(handler_name))?; 206 | 207 | let o = match model::order::Order::find( 208 | &mut *tx, 209 | &model::order::OrderFindFilter { 210 | id: Some(id), 211 | user_id: Some(user.id.clone()), 212 | status: Some(model::order::Status::Pending), 213 | }, 214 | ) 215 | .await 216 | { 217 | Ok(v) => match v { 218 | Some(v) => v, 219 | None => return Err(Error::new("不存在的订单")), 220 | }, 221 | Err(e) => { 222 | tx.rollback() 223 | .await 224 | .map_err(Error::from) 225 | .map_err(log_error(handler_name))?; 226 | return Err(Error::from(e)).map_err(log_error(handler_name))?; 227 | } 228 | }; 229 | 230 | let o = model::order::Order { 231 | status: model::order::Status::Cancelled, 232 | ..o 233 | }; 234 | 235 | let aff = match o.update(&mut *tx).await { 236 | Ok(v) => v, 237 | Err(e) => { 238 | tx.rollback() 239 | .await 240 | .map_err(Error::from) 241 | .map_err(log_error(handler_name))?; 242 | return Err(Error::from(e)).map_err(log_error(handler_name))?; 243 | } 244 | }; 245 | 246 | tx.commit() 247 | .await 248 | .map_err(Error::from) 249 | .map_err(log_error(handler_name))?; 250 | Ok(resp::ok(resp::AffResp { aff })) 251 | } 252 | -------------------------------------------------------------------------------- /src/api/user/pay.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, State}, 3 | Json, 4 | }; 5 | 6 | use crate::{ 7 | api::{get_pool, log_error}, 8 | form, mid, model, resp, service, ArcAppState, Error, Result, 9 | }; 10 | 11 | /// 支付 12 | pub async fn add( 13 | State(state): State, 14 | user_auth: mid::UserAuth, 15 | Json(frm): Json, 16 | ) -> Result { 17 | let handler_name = "user/pay/add"; 18 | let user = user_auth.user().map_err(log_error(handler_name))?; 19 | let p = get_pool(&state); 20 | 21 | let m = model::pay::Pay { 22 | order_id: frm.order_id, 23 | user_id: user.id.clone(), 24 | amount: frm.amount, 25 | currency: frm.currency, 26 | tx_id: frm.tx_id, 27 | method: frm.method, 28 | status: model::pay::Status::Pending, 29 | is_via_admin: false, 30 | ..Default::default() 31 | }; 32 | 33 | let m = service::pay::create(&*p, m, &state.cfg.currency, frm.re_pay) 34 | .await 35 | .map_err(log_error(handler_name))?; 36 | Ok(resp::ok(resp::IDResp { id: m.id })) 37 | } 38 | 39 | /// 完成支付 40 | pub async fn complete( 41 | State(state): State, 42 | user_auth: mid::UserAuth, 43 | Json(frm): Json, 44 | ) -> Result { 45 | let handler_name = "user/pay/complete"; 46 | let user = user_auth.user().map_err(log_error(handler_name))?; 47 | let p = get_pool(&state); 48 | let mut tx = p 49 | .begin() 50 | .await 51 | .map_err(Error::from) 52 | .map_err(log_error(handler_name))?; 53 | 54 | let aff = service::pay::complete( 55 | &mut tx, 56 | frm.pay_id, 57 | frm.order_id, 58 | &state.cfg, 59 | Some(user.id.clone()), 60 | false, 61 | ) 62 | .await 63 | .map_err(log_error(handler_name))?; 64 | 65 | tx.commit().await.map_err(Error::from)?; 66 | 67 | Ok(resp::ok(resp::AffResp { aff })) 68 | } 69 | 70 | /// 支付详情 71 | pub async fn detail_by_order( 72 | State(state): State, 73 | user_auth: mid::UserAuth, 74 | Path(order_id): Path, 75 | ) -> Result>> { 76 | let handler_name = "user/pay/detail"; 77 | let user = user_auth.user().map_err(log_error(handler_name))?; 78 | let p = get_pool(&state); 79 | let pay = service::pay::find( 80 | &*p, 81 | &model::pay::PayFindFilter { 82 | id: None, 83 | user_id: Some(user.id.clone()), 84 | order_id: Some(order_id), 85 | }, 86 | ) 87 | .await 88 | .map_err(log_error(handler_name))?; 89 | 90 | Ok(resp::ok(pay)) 91 | } 92 | -------------------------------------------------------------------------------- /src/api/user/ping.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | 3 | use crate::{ 4 | mid::{IpAndUserAgent, UserAuth}, 5 | ArcAppState, 6 | }; 7 | 8 | pub async fn ping( 9 | State(state): State, 10 | auth: UserAuth, 11 | ip_and_user_agent: IpAndUserAgent, 12 | ) -> String { 13 | format!( 14 | "[PONG] prefix: {}, user: {:?}, client: {:?}", 15 | &state.cfg.web.prefix, 16 | auth.user(), 17 | ip_and_user_agent, 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/api/user/promotion.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::{Path, State}; 2 | 3 | use crate::{ 4 | api::{get_pool, log_error}, 5 | model, resp, service, ArcAppState, Error, Result, 6 | }; 7 | 8 | pub async fn take( 9 | State(state): State, 10 | ) -> Result> { 11 | let handler_name = "user/promotion/take"; 12 | let p = get_pool(&state); 13 | let m = service::promotion::random_take(&*p) 14 | .await 15 | .map_err(Error::from) 16 | .map_err(log_error(handler_name))?; 17 | let m = match m { 18 | Some(v) => v, 19 | None => return Err(Error::new("没有推广数据")), 20 | }; 21 | Ok(resp::ok(m)) 22 | } 23 | 24 | pub async fn get( 25 | State(state): State, 26 | Path(id): Path, 27 | ) -> Result> { 28 | let handler_name = "user/promotion/get"; 29 | if id.len() < 20 { 30 | return Err(Error::new("参数错误")); 31 | } 32 | // 兼容老数据 33 | let id = if id.len() > 20 { 34 | id[0..20].to_string() 35 | } else { 36 | id 37 | }; 38 | 39 | let p = get_pool(&state); 40 | let m = model::promotion::Promotion::find( 41 | &*p, 42 | &model::promotion::PromotionFindFilter { id: Some(id) }, 43 | ) 44 | .await 45 | .map_err(Error::from) 46 | .map_err(log_error(handler_name))?; 47 | let m = match m { 48 | Some(v) => v, 49 | None => return Err(Error::new("没有推广数据")), 50 | }; 51 | Ok(resp::ok(m)) 52 | } 53 | -------------------------------------------------------------------------------- /src/api/user/read_history.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::{Query, State}; 2 | 3 | use crate::{ 4 | api::{get_pool, log_error}, 5 | form, mid, model, resp, ArcAppState, Error, Result, 6 | }; 7 | 8 | pub async fn list( 9 | State(state): State, 10 | user_auth: mid::UserAuth, 11 | Query(frm): Query, 12 | ) -> Result> { 13 | let handler_name = "user/read_history/list"; 14 | let p = get_pool(&state); 15 | let user = user_auth.user().map_err(log_error(handler_name))?; 16 | 17 | let (page, page_size) = match &user.kind { 18 | &model::user::Kind::Normal => (0, 5), 19 | &model::user::Kind::Subscriber | &model::user::Kind::YearlySubscriber => { 20 | (frm.page(), frm.page_size()) 21 | } 22 | }; 23 | 24 | let data = model::read_history::ReadHistorie::list( 25 | &*p, 26 | &model::read_history::ReadHistorieListFilter { 27 | pq: model::read_history::ReadHistoriePaginateReq { page, page_size }, 28 | order: None, 29 | user_id: Some(user.id.clone()), 30 | }, 31 | ) 32 | .await 33 | .map_err(Error::from) 34 | .map_err(log_error(handler_name))?; 35 | 36 | Ok(resp::ok(data)) 37 | } 38 | -------------------------------------------------------------------------------- /src/api/user/router.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | middleware, 3 | routing::{delete, get, post, put}, 4 | Router, 5 | }; 6 | 7 | use crate::{mid, ArcAppState}; 8 | 9 | use super::{ 10 | announcement, order, pay, ping, promotion, read_history, service, subject, tag, topic, user, 11 | }; 12 | 13 | pub fn init(state: ArcAppState) -> Router { 14 | Router::new() 15 | .merge(ping(state.clone())) 16 | .nest("/subject", subject_init(state.clone())) 17 | .nest("/topic", topic_init(state.clone())) 18 | .nest("/tag", tag_init(state.clone())) 19 | .nest("/user", user_init(state.clone())) 20 | .nest("/service", service_init(state.clone())) 21 | .nest("/order", order_init(state.clone())) 22 | .nest("/pay", pay_init(state.clone())) 23 | .nest("/read-history", read_history_init(state.clone())) 24 | .nest("/announcement", announcement_init(state.clone())) 25 | .nest("/promotion", promotion_init(state.clone())) 26 | .layer(middleware::from_extractor_with_state::< 27 | mid::UserAuth, 28 | ArcAppState, 29 | >(state.clone())) 30 | } 31 | 32 | fn ping(state: ArcAppState) -> Router { 33 | Router::new() 34 | .route("/ping", get(ping::ping)) 35 | .with_state(state) 36 | } 37 | 38 | fn subject_init(state: ArcAppState) -> Router { 39 | Router::new() 40 | .route("/top", get(subject::top)) 41 | .route("/", get(subject::list)) 42 | .route("/detail/{slug}", get(subject::detail)) 43 | .route("/slug/{id}", get(subject::get_slug)) 44 | .route("/purchased", get(subject::purchased)) 45 | .route("/is-purchased/{id}", get(subject::is_purchased)) 46 | .route("/latest", get(subject::latest)) 47 | .with_state(state) 48 | } 49 | 50 | fn topic_init(state: ArcAppState) -> Router { 51 | Router::new() 52 | .route("/top", get(topic::top)) 53 | .route("/", get(topic::list)) 54 | .route("/detail/{subject_slug}/{slug}", get(topic::detail)) 55 | .route("/protected-content", post(topic::get_protected_content)) 56 | .route("/latest", get(topic::latest)) 57 | .with_state(state) 58 | } 59 | 60 | fn tag_init(state: ArcAppState) -> Router { 61 | Router::new() 62 | .route("/", get(tag::list)) 63 | .route("/{name}", get(tag::detail)) 64 | .with_state(state) 65 | } 66 | 67 | fn user_init(state: ArcAppState) -> Router { 68 | Router::new() 69 | .route("/logout", delete(user::logout)) 70 | .route("/check-in", get(user::check_in)) 71 | .route("/sessions", get(user::session_list)) 72 | .route("/login-logs", get(user::login_log_list)) 73 | .route("/password", put(user::change_pwd)) 74 | .route("/profile", put(user::update_profile)) 75 | .with_state(state) 76 | } 77 | 78 | fn service_init(state: ArcAppState) -> Router { 79 | Router::new() 80 | .route("/", get(service::list)) 81 | .route("/subject/{subject_id}", get(service::find_by_subject)) 82 | .with_state(state) 83 | } 84 | 85 | fn order_init(state: ArcAppState) -> Router { 86 | Router::new() 87 | .route("/", get(order::list).post(order::create)) 88 | .route("/{id}", get(order::detail).put(order::cancel)) 89 | .with_state(state) 90 | } 91 | 92 | fn pay_init(state: ArcAppState) -> Router { 93 | Router::new() 94 | .route("/", post(pay::add).put(pay::complete)) 95 | .route("/order/{order_id}", get(pay::detail_by_order)) 96 | .with_state(state) 97 | } 98 | 99 | fn read_history_init(state: ArcAppState) -> Router { 100 | Router::new() 101 | .route("/", get(read_history::list)) 102 | .with_state(state) 103 | } 104 | 105 | fn announcement_init(state: ArcAppState) -> Router { 106 | Router::new() 107 | .route("/", get(announcement::list)) 108 | .route("/{id}", get(announcement::detail)) 109 | .with_state(state) 110 | } 111 | 112 | fn promotion_init(state: ArcAppState) -> Router { 113 | Router::new() 114 | .route("/take", get(promotion::take)) 115 | .route("/{id}", get(promotion::get)) 116 | .with_state(state) 117 | } 118 | -------------------------------------------------------------------------------- /src/api/user/service.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::{Path, Query, State}; 2 | 3 | use crate::{ 4 | api::{get_pool, log_error}, 5 | form, model, resp, ArcAppState, Error, Result, 6 | }; 7 | 8 | pub async fn list( 9 | State(state): State, 10 | Query(frm): Query, 11 | ) -> Result> { 12 | let handler_name = "user/service/list"; 13 | let p = get_pool(&state); 14 | 15 | let data = model::service::Service::list( 16 | &*p, 17 | &model::service::ServiceListFilter { 18 | pq: model::service::ServicePaginateReq { 19 | page: frm.page(), 20 | page_size: frm.page_size(), 21 | }, 22 | order: Some("is_subject ASC, pin DESC, id DESC".into()), 23 | name: None, 24 | is_subject: None, 25 | is_off: Some(false), 26 | }, 27 | ) 28 | .await 29 | .map_err(Error::from) 30 | .map_err(log_error(handler_name))?; 31 | 32 | Ok(resp::ok(data)) 33 | } 34 | 35 | pub async fn find_by_subject( 36 | State(state): State, 37 | Path(subject_id): Path, 38 | ) -> Result> { 39 | let handler_name = "user/service/find_by_subject"; 40 | let p = get_pool(&state); 41 | 42 | let data = match model::service::Service::find( 43 | &*p, 44 | &model::service::ServiceFindFilter { 45 | id: None, 46 | is_subject: Some(true), 47 | target_id: Some(subject_id), 48 | }, 49 | ) 50 | .await 51 | .map_err(Error::from) 52 | .map_err(log_error(handler_name))? 53 | { 54 | Some(v) => v, 55 | None => return Err(Error::new("该专题尚未上架")), 56 | }; 57 | 58 | if data.is_off { 59 | return Err(Error::new("该服务已下架")); 60 | } 61 | 62 | Ok(resp::ok(data)) 63 | } 64 | -------------------------------------------------------------------------------- /src/api/user/subject.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use axum::extract::{Path, Query, State}; 4 | 5 | use crate::{ 6 | api::{get_pool, log_error}, 7 | form, mid, model, resp, service, ArcAppState, Error, Result, 8 | }; 9 | 10 | pub async fn top( 11 | State(state): State, 12 | ) -> Result>> { 13 | let handler_name = "api/user/subject/top"; 14 | let sql = format!( 15 | "SELECT {} FROM {} WHERE is_del=false ORDER BY pin DESC,id DESC LIMIT 6", 16 | &model::subject::Subject::fields(), 17 | &model::subject::Subject::table() 18 | ); 19 | 20 | let p = get_pool(&state); 21 | 22 | let ls = sqlx::query_as(&sql) 23 | .fetch_all(&*p) 24 | .await 25 | .map_err(Error::from) 26 | .map_err(log_error(handler_name))?; 27 | Ok(resp::ok(ls)) 28 | } 29 | 30 | pub async fn latest( 31 | State(state): State, 32 | ) -> Result>> { 33 | let handler_name = "api/user/subject/top"; 34 | let sql = format!( 35 | "SELECT {} FROM {} WHERE is_del=false ORDER BY id DESC LIMIT 6", 36 | &model::subject::Subject::fields(), 37 | &model::subject::Subject::table() 38 | ); 39 | 40 | let p = get_pool(&state); 41 | 42 | let ls = sqlx::query_as(&sql) 43 | .fetch_all(&*p) 44 | .await 45 | .map_err(Error::from) 46 | .map_err(log_error(handler_name))?; 47 | Ok(resp::ok(ls)) 48 | } 49 | 50 | pub async fn list( 51 | State(state): State, 52 | Query(frm): Query, 53 | ) -> Result> { 54 | let handler_name = "api/user/subject/list"; 55 | let p = get_pool(&state); 56 | let data = model::subject::Subject::list( 57 | &*p, 58 | &model::subject::SubjectListFilter { 59 | pq: model::subject::SubjectPaginateReq { 60 | page: frm.page(), 61 | page_size: frm.page_size(), 62 | }, 63 | order: None, 64 | status: None, 65 | is_del: Some(false), 66 | name: None, 67 | slug: None, 68 | }, 69 | ) 70 | .await 71 | .map_err(Error::from) 72 | .map_err(log_error(handler_name))?; 73 | 74 | Ok(resp::ok(data)) 75 | } 76 | 77 | pub async fn detail( 78 | State(state): State, 79 | Path(slug): Path, 80 | ) -> Result> { 81 | let handler_name = "api/user/subject/topic_list"; 82 | let p = get_pool(&state); 83 | 84 | let subject = model::subject::Subject::find( 85 | &*p, 86 | &model::subject::SubjectFindFilter { 87 | by: model::subject::SubjectFindBy::Slug(slug.clone()), 88 | is_del: Some(false), 89 | }, 90 | ) 91 | .await 92 | .map_err(Error::from) 93 | .map_err(log_error(handler_name))?; 94 | 95 | let subject = match subject { 96 | Some(v) => v, 97 | None => return Err(Error::new("不存在的专题")), 98 | }; 99 | 100 | let topic_list = service::topic::list_all_for_subject(&*p, slug) 101 | .await 102 | .map_err(log_error(handler_name))?; 103 | 104 | Ok(resp::ok(resp::subject::Detail { 105 | subject, 106 | topic_list, 107 | })) 108 | } 109 | 110 | pub async fn get_slug( 111 | State(state): State, 112 | Path(id): Path, 113 | ) -> Result> { 114 | let handler_name = "api/user/subject/get_slug"; 115 | let p = get_pool(&state); 116 | let subject = match model::subject::Subject::find( 117 | &*p, 118 | &model::subject::SubjectFindFilter { 119 | by: model::subject::SubjectFindBy::Id(id), 120 | is_del: Some(false), 121 | }, 122 | ) 123 | .await 124 | .map_err(Error::from) 125 | .map_err(log_error(handler_name))? 126 | { 127 | Some(v) => v, 128 | None => return Err(Error::new("不存在的专题")), 129 | }; 130 | Ok(resp::ok(subject.slug)) 131 | } 132 | 133 | #[derive(serde::Deserialize)] 134 | pub struct Purchased { 135 | pub ids: String, 136 | } 137 | impl Purchased { 138 | pub fn ids(&self) -> Vec<&str> { 139 | self.ids.split(',').map(|s| s).collect() 140 | } 141 | } 142 | pub async fn purchased( 143 | State(state): State, 144 | user_auth: mid::UserAuth, 145 | Query(frm): Query, 146 | ) -> Result>> { 147 | let handler_name = "api/user/subject/purchased"; 148 | let user = match user_auth.user_opt() { 149 | Some(v) => v, 150 | None => return Ok(resp::ok(HashMap::new())), 151 | }; 152 | 153 | let p = get_pool(&state); 154 | 155 | let data = service::order::purchased_services(&*p, &user.id, &frm.ids()) 156 | .await 157 | .map_err(log_error(handler_name))?; 158 | Ok(resp::ok(data)) 159 | } 160 | 161 | pub async fn is_purchased( 162 | State(state): State, 163 | user_auth: mid::UserAuth, 164 | Path(id): Path, 165 | ) -> Result> { 166 | let handler_name = "api/user/subject/is_purchased"; 167 | let user = match user_auth.user_opt() { 168 | Some(v) => v, 169 | None => return Ok(resp::ok(false)), 170 | }; 171 | let p = get_pool(&state); 172 | let data = service::order::is_a_purchased_service(&*p, &user.id, &id) 173 | .await 174 | .map_err(log_error(handler_name))?; 175 | Ok(resp::ok(data)) 176 | } 177 | -------------------------------------------------------------------------------- /src/api/user/tag.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::{Path, Query, State}; 2 | 3 | use crate::{ 4 | api::{get_pool, log_error}, 5 | form, model, resp, service, ArcAppState, Error, Result, 6 | }; 7 | 8 | pub async fn list( 9 | State(state): State, 10 | Query(frm): Query, 11 | ) -> Result>> { 12 | let handler_name = "user/tag/list"; 13 | let p = get_pool(&state); 14 | let data = service::tag::list_with_topic_count(&*p, frm.page(), frm.page_size()) 15 | .await 16 | .map_err(Error::from) 17 | .map_err(log_error(handler_name))?; 18 | 19 | Ok(resp::ok(data)) 20 | } 21 | 22 | pub async fn detail( 23 | State(state): State, 24 | Path(name): Path, 25 | Query(frm): Query, 26 | ) -> Result> { 27 | let handler_name = "user/tag/detail"; 28 | let p = get_pool(&state); 29 | let f = model::tag::TagFindFilter { 30 | id: None, 31 | name: Some(name), 32 | is_del: Some(false), 33 | }; 34 | let tag_with_topic_count = match service::tag::find_with_topic_count(&*p, Some(&f), None) 35 | .await 36 | .map_err(Error::from) 37 | .map_err(log_error(handler_name))? 38 | { 39 | Some(v) => v, 40 | None => return Err(Error::new("不存在该标签").into()), 41 | }; 42 | 43 | let tp = model::topic_tag::TopicTag::list( 44 | &*p, 45 | &model::topic_tag::TopicTagListFilter { 46 | pq: model::topic_tag::TopicTagPaginateReq { 47 | page: frm.page(), 48 | page_size: frm.page_size(), 49 | }, 50 | order: None, 51 | topic_id: None, 52 | tag_id: Some(tag_with_topic_count.tag.id.clone()), 53 | }, 54 | ) 55 | .await 56 | .map_err(Error::from) 57 | .map_err(log_error(handler_name))?; 58 | 59 | let mut r = Vec::with_capacity(tp.data.len()); 60 | 61 | for t in tp.data { 62 | let f = model::topic_views::VTopicSubjectFindFilter { 63 | id: Some(t.topic_id.clone()), 64 | subject_id: None, 65 | slug: None, 66 | is_del: Some(false), 67 | subject_slug: None, 68 | subject_is_del: Some(false), 69 | }; 70 | let tf = model::topic_tag::VTopicTagWithTagListAllFilter { 71 | limit: None, 72 | order: None, 73 | topic_id: t.topic_id, 74 | name: None, 75 | is_del: Some(false), 76 | }; 77 | let m = service::topic::find_opt(&*p, Some(&f), &tf, None) 78 | .await 79 | .map_err(Error::from) 80 | .map_err(log_error(handler_name))?; 81 | if let Some(m) = m { 82 | r.push(m); 83 | } 84 | } 85 | 86 | Ok(resp::ok(model::tag::TagWithTopicListAndCount { 87 | tag_with_topic_count, 88 | topic_paginate: model::pagination::Paginate { 89 | total: tp.total, 90 | total_page: tp.total_page, 91 | page: tp.page, 92 | page_size: tp.page_size, 93 | data: r, 94 | }, 95 | })) 96 | } 97 | -------------------------------------------------------------------------------- /src/api/user/user.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, Json}; 2 | use chrono::Local; 3 | use rand::Rng; 4 | use rust_decimal::Decimal; 5 | use validator::Validate; 6 | 7 | use crate::{ 8 | api::{get_pool, log_error}, 9 | form, mid, model, resp, 10 | utils::{self, dt}, 11 | ArcAppState, Error, Result, 12 | }; 13 | 14 | pub async fn logout( 15 | State(state): State, 16 | user_auth: mid::UserAuth, 17 | ) -> Result { 18 | let handler_name = "user/logout"; 19 | 20 | let user = user_auth.user().map_err(log_error(handler_name))?; 21 | let token = user_auth.token().map_err(log_error(handler_name))?; 22 | 23 | let p = get_pool(&state); 24 | 25 | let aff = sqlx::query("DELETE FROM sessions WHERE token=$1 AND is_admin=false AND user_id=$2") 26 | .bind(token) 27 | .bind(&user.id) 28 | .execute(&*p) 29 | .await 30 | .map_err(Error::from) 31 | .map_err(log_error(handler_name))? 32 | .rows_affected(); 33 | 34 | Ok(resp::ok(resp::AffResp { aff })) 35 | } 36 | 37 | pub async fn check_in( 38 | State(state): State, 39 | user_auth: mid::UserAuth, 40 | ) -> Result> { 41 | let handler_name = "user/check_in"; 42 | let user = user_auth.user().map_err(log_error(handler_name))?; 43 | let max_point: i16 = match &user.kind { 44 | &model::user::Kind::Normal => 10, 45 | &model::user::Kind::Subscriber => 20, 46 | &model::user::Kind::YearlySubscriber => 30, 47 | }; 48 | 49 | let points = rand::rng().random_range(1..=max_point); 50 | 51 | let p = get_pool(&state); 52 | 53 | let mut tx = p 54 | .begin() 55 | .await 56 | .map_err(Error::from) 57 | .map_err(log_error(handler_name))?; 58 | 59 | // 是否已经签到 60 | let (start, end) = dt::today(); 61 | let has_check_in_count: (i64,) = match sqlx::query_as( 62 | "SELECT COUNT(*) FROM check_in_logs WHERE user_id=$1 AND (dateline BETWEEN $2 AND $3)", 63 | ) 64 | .bind(&user.id) 65 | .bind(&start) 66 | .bind(&end) 67 | .fetch_one(&mut *tx) 68 | .await 69 | { 70 | Ok(v) => v, 71 | Err(e) => { 72 | tx.rollback() 73 | .await 74 | .map_err(Error::from) 75 | .map_err(log_error(handler_name))?; 76 | return Err(e.into()); 77 | } 78 | }; 79 | 80 | if has_check_in_count.0 > 0 { 81 | return Err(Error::new("今天已经签过到了")); 82 | } 83 | 84 | // 签到日志 85 | let cil = model::check_in_log::CheckInLog { 86 | id: utils::id::new(), 87 | user_id: user.id.clone(), 88 | points, 89 | dateline: Local::now(), 90 | }; 91 | 92 | if let Err(e) = cil.insert(&mut *tx).await { 93 | tx.rollback() 94 | .await 95 | .map_err(Error::from) 96 | .map_err(log_error(handler_name))?; 97 | return Err(e.into()); 98 | } 99 | 100 | // 更新用户积分 101 | let points_dec = Decimal::from_i128_with_scale(points as i128, 0); 102 | if let Err(e) = sqlx::query("UPDATE users SET points=points+$1 WHERE id=$2") 103 | .bind(&points_dec) 104 | .bind(&user.id) 105 | .execute(&mut *tx) 106 | .await 107 | { 108 | tx.rollback() 109 | .await 110 | .map_err(Error::from) 111 | .map_err(log_error(handler_name))?; 112 | return Err(e.into()); 113 | } 114 | 115 | tx.commit() 116 | .await 117 | .map_err(Error::from) 118 | .map_err(log_error(handler_name))?; 119 | Ok(resp::ok(points)) 120 | } 121 | 122 | pub async fn session_list( 123 | State(state): State, 124 | user_auth: mid::UserAuth, 125 | ) -> Result>> { 126 | let handler_name = "user/session_list"; 127 | let user = user_auth.user().map_err(log_error(handler_name))?; 128 | 129 | let p = get_pool(&state); 130 | let data = model::session::Session::list_all( 131 | &*p, 132 | &model::session::SessionListAllFilter { 133 | limit: Some(10), 134 | order: None, 135 | user_id: Some(user.id.clone()), 136 | token: None, 137 | is_admin: Some(false), 138 | }, 139 | ) 140 | .await 141 | .map_err(Error::from) 142 | .map_err(log_error(handler_name))?; 143 | Ok(resp::ok(data)) 144 | } 145 | 146 | pub async fn login_log_list( 147 | State(state): State, 148 | user_auth: mid::UserAuth, 149 | ) -> Result>> { 150 | let handler_name = "user/login_log_list"; 151 | let user = user_auth.user().map_err(log_error(handler_name))?; 152 | 153 | let p = get_pool(&state); 154 | let data = model::login_log::LoginLog::list_all( 155 | &*p, 156 | &model::login_log::LoginLogListAllFilter { 157 | limit: Some(30), 158 | order: None, 159 | user_id: Some(user.id.clone()), 160 | }, 161 | ) 162 | .await 163 | .map_err(Error::from) 164 | .map_err(log_error(handler_name))?; 165 | Ok(resp::ok(data)) 166 | } 167 | 168 | pub async fn change_pwd( 169 | State(state): State, 170 | user_auth: mid::UserAuth, 171 | Json(frm): Json, 172 | ) -> Result { 173 | let handler_name = "user/change_pwd"; 174 | frm.validate() 175 | .map_err(Error::from) 176 | .map_err(log_error(handler_name))?; 177 | 178 | if frm.new_password == frm.password { 179 | return Err(Error::new("新密码不能和现用密码相同")); 180 | } 181 | 182 | if frm.new_password != frm.re_password { 183 | return Err(Error::new("两次密码不一致")); 184 | } 185 | 186 | let user = user_auth.user().map_err(log_error(handler_name))?; 187 | 188 | let p = get_pool(&state); 189 | 190 | let m = match model::user::User::find( 191 | &*p, 192 | &model::user::UserFindFilter { 193 | by: model::user::UserFindBy::Id(user.id.clone()), 194 | status: Some(model::user::Status::Actived), 195 | }, 196 | ) 197 | .await 198 | { 199 | Ok(v) => match v { 200 | Some(v) => v, 201 | None => return Err(Error::new("用户不存在")), 202 | }, 203 | Err(e) => return Err(e.into()).map_err(log_error(handler_name)), 204 | }; 205 | 206 | if !utils::password::verify(&frm.password, &m.password).map_err(log_error(handler_name))? { 207 | return Err(Error::new("现用密码错误")); 208 | } 209 | 210 | let password = utils::password::hash(&frm.new_password).map_err(log_error(handler_name))?; 211 | let m = model::user::User { password, ..m }; 212 | 213 | let aff = match m.update(&*p).await { 214 | Ok(v) => v, 215 | Err(e) => return Err(e.into()).map_err(log_error(handler_name)), 216 | }; 217 | Ok(resp::ok(resp::AffResp { aff })) 218 | } 219 | 220 | pub async fn update_profile( 221 | State(state): State, 222 | user_auth: mid::UserAuth, 223 | Json(frm): Json, 224 | ) -> Result { 225 | let handler_name = "user/update_profile"; 226 | frm.validate() 227 | .map_err(Error::from) 228 | .map_err(log_error(handler_name))?; 229 | 230 | let user = user_auth.user().map_err(log_error(handler_name))?; 231 | 232 | let (allow_device_num, session_exp) = match &user.kind { 233 | &model::user::Kind::Normal => (frm.allow_device_num.min(1), frm.session_exp.min(20)), 234 | &model::user::Kind::Subscriber => (frm.allow_device_num.min(3), frm.session_exp.min(120)), 235 | &model::user::Kind::YearlySubscriber => { 236 | (frm.allow_device_num.min(5), frm.session_exp.min(300)) 237 | } 238 | }; 239 | 240 | let p = get_pool(&state); 241 | 242 | let m = match model::user::User::find( 243 | &*p, 244 | &model::user::UserFindFilter { 245 | by: model::user::UserFindBy::Id(user.id.clone()), 246 | status: Some(model::user::Status::Actived), 247 | }, 248 | ) 249 | .await 250 | { 251 | Ok(v) => match v { 252 | Some(v) => v, 253 | None => return Err(Error::new("用户不存在")), 254 | }, 255 | Err(e) => return Err(e.into()).map_err(log_error(handler_name)), 256 | }; 257 | 258 | if !utils::password::verify(&frm.password, &m.password).map_err(log_error(handler_name))? { 259 | return Err(Error::new("密码错误")); 260 | } 261 | 262 | let m = model::user::User { 263 | allow_device_num, 264 | session_exp, 265 | ..m 266 | }; 267 | 268 | let aff = match m.update(&*p).await { 269 | Ok(v) => v, 270 | Err(e) => return Err(e.into()).map_err(log_error(handler_name)), 271 | }; 272 | Ok(resp::ok(resp::AffResp { aff })) 273 | } 274 | -------------------------------------------------------------------------------- /src/api/web.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, http::HeaderMap, response::IntoResponse}; 2 | use chrono::Local; 3 | 4 | use crate::{mid::IpAndUserAgent, model, resp, service, ArcAppState, Error, Result}; 5 | use rss::{CategoryBuilder, ChannelBuilder, ImageBuilder, ItemBuilder}; 6 | 7 | use super::{get_pool, log_error}; 8 | 9 | pub async fn ping( 10 | State(state): State, 11 | ip_and_user_agent: IpAndUserAgent, 12 | ) -> resp::JsonResp { 13 | resp::ok(format!( 14 | "[PONG] prefix: {}, client: {:?}", 15 | &state.cfg.web.prefix, &ip_and_user_agent 16 | )) 17 | } 18 | 19 | pub async fn rss(State(state): State) -> Result { 20 | let handler_name = "web/rss"; 21 | let p = get_pool(&state); 22 | let host = &state.cfg.host; 23 | 24 | let data = service::topic::list_all_opt( 25 | &*p, 26 | &model::topic_views::VTopicSubjectListAllFilter { 27 | limit: Some(30), 28 | order: Some("id DESC".into()), 29 | title: None, 30 | subject_id: None, 31 | slug: None, 32 | is_del: Some(false), 33 | subject_slug: None, 34 | subject_is_del: Some(false), 35 | status: None, 36 | v_topic_subject_list_all_between_datelines: None, 37 | }, 38 | ) 39 | .await 40 | .map_err(Error::from) 41 | .map_err(log_error(handler_name))?; 42 | 43 | let items = data 44 | .into_iter() 45 | .map(|i| { 46 | ItemBuilder::default() 47 | .title(Some(i.topic_subjects.title)) 48 | .pub_date(Some(i.topic_subjects.dateline.to_rfc2822())) 49 | .link(Some(format!( 50 | "{}/topic/{}/{}", 51 | host, &i.topic_subjects.subject_slug, &i.topic_subjects.slug 52 | ))) 53 | .category( 54 | CategoryBuilder::default() 55 | .name(&i.topic_subjects.name) 56 | // .domain(format!("{}/subject/{}", host, &i.topic_subjects.slug)) 57 | .build(), 58 | ) 59 | .description(Some(i.topic_subjects.summary)) 60 | .build() 61 | }) 62 | .collect::>(); 63 | 64 | let mut channel = ChannelBuilder::default() 65 | .title("AXUM中文网") 66 | .link(host) 67 | .description("AXUM中文网为你提供了企业级axum Web开发中所需要的大部分知识。") 68 | .image( 69 | ImageBuilder::default() 70 | .url("https://file.axum.eu.org/asset/logo.png") 71 | .title("AXUM中文网") 72 | .link(host) 73 | .build(), 74 | ) 75 | .copyright(format!("2021-present AXUM中文网 {}", host)) 76 | .pub_date(Local::now().to_rfc2822()) 77 | .build(); 78 | channel.items.extend(items); 79 | 80 | channel.write_to(::std::io::sink()).unwrap(); // // write to the channel to a writer 81 | let string = channel.to_string(); // convert the channel to a string 82 | 83 | let mut header = HeaderMap::new(); 84 | header.insert("Content-Type", "text/xml".parse().unwrap()); 85 | Ok((header, string).into_response()) 86 | } 87 | -------------------------------------------------------------------------------- /src/captcha.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::{config, Error, Result}; 4 | 5 | pub struct Captcha<'a> { 6 | pub kind: config::CaptchaKind, 7 | pub secret: &'a str, 8 | pub validation_url: &'a str, 9 | pub timeout: u8, 10 | } 11 | 12 | #[derive(Deserialize)] 13 | pub struct Response { 14 | pub success: bool, 15 | } 16 | 17 | impl<'a> Captcha<'a> { 18 | pub fn from_cfg(kind: config::CaptchaKind, cfg: &'a config::Config) -> Self { 19 | let (secret, validation_url) = match kind { 20 | config::CaptchaKind::HCaptcha => ( 21 | &cfg.captcha.hcaptcha.secret_key, 22 | &cfg.captcha.hcaptcha.validation_url, 23 | ), 24 | config::CaptchaKind::Turnstile => ( 25 | &cfg.captcha.turnstile.secret_key, 26 | &cfg.captcha.turnstile.validation_url, 27 | ), 28 | }; 29 | Self { 30 | kind, 31 | secret, 32 | validation_url, 33 | timeout: cfg.captcha.timeout, 34 | } 35 | } 36 | pub fn hcaptch(cfg: &'a config::Config) -> Self { 37 | Self::from_cfg(config::CaptchaKind::HCaptcha, cfg) 38 | } 39 | 40 | pub fn turnstile(cfg: &'a config::Config) -> Self { 41 | Self::from_cfg(config::CaptchaKind::Turnstile, cfg) 42 | } 43 | } 44 | 45 | pub async fn verify<'a>(c: Captcha<'a>, token: &'a str) -> Result { 46 | let form = [("secret", c.secret), ("response", token)]; 47 | let client = reqwest::ClientBuilder::new() 48 | .timeout(std::time::Duration::from_secs(c.timeout as u64)) 49 | .build() 50 | .map_err(Error::from)?; 51 | let res = client.post(c.validation_url).form(&form).send().await?; 52 | let res = res.json().await?; 53 | Ok(res) 54 | } 55 | 56 | pub async fn verify_hcaptcha<'a>(cfg: &'a config::Config, token: &'a str) -> Result { 57 | let res = verify(Captcha::hcaptch(cfg), token).await?; 58 | Ok(res.success) 59 | } 60 | pub async fn verify_turnstile<'a>(cfg: &'a config::Config, token: &'a str) -> Result { 61 | let res = verify(Captcha::turnstile(cfg), token).await?; 62 | Ok(res.success) 63 | } 64 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use rust_decimal::Decimal; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Deserialize)] 6 | pub struct WebConfig { 7 | pub addr: String, 8 | pub prefix: String, 9 | } 10 | 11 | #[derive(Debug, Deserialize)] 12 | pub struct DbConfig { 13 | pub dsn: String, 14 | pub max_conns: u32, 15 | } 16 | 17 | #[derive(Debug, Deserialize)] 18 | pub struct SessionConfig { 19 | pub secret_key: String, 20 | pub default_timeout: u32, 21 | pub max_timeout: u32, 22 | pub admin_timeout: u32, 23 | } 24 | 25 | #[derive(Debug, Deserialize)] 26 | pub struct MailConfig { 27 | pub name: String, 28 | pub smtp: String, 29 | pub user: String, 30 | pub password: String, 31 | } 32 | 33 | #[derive(Debug, Deserialize)] 34 | pub struct ProtectedContentConfig { 35 | pub max_sections: u8, 36 | pub min_sections: u8, 37 | pub guest_captcha: CaptchaKind, 38 | pub user_captcha: CaptchaKind, 39 | pub timeout: u8, 40 | pub placeholder: String, 41 | } 42 | 43 | #[derive(Debug, Deserialize, Serialize, Default, Clone)] 44 | pub enum CaptchaKind { 45 | #[default] 46 | HCaptcha, 47 | Turnstile, 48 | } 49 | 50 | #[derive(Debug, Deserialize)] 51 | pub struct CaptchaItemConfig { 52 | pub secret_key: String, 53 | pub validation_url: String, 54 | } 55 | 56 | #[derive(Debug, Deserialize)] 57 | pub struct CaptchaConfig { 58 | pub timeout: u8, 59 | pub hcaptcha: CaptchaItemConfig, 60 | pub turnstile: CaptchaItemConfig, 61 | } 62 | 63 | #[derive(Debug, Deserialize)] 64 | pub struct UploadConfig { 65 | pub max_size: usize, 66 | } 67 | 68 | #[derive(Debug, Deserialize)] 69 | pub struct TronConfig { 70 | pub wallet: String, 71 | pub usdt_contract_addr: String, 72 | pub api_url: String, 73 | pub fetch_timeout: u8, 74 | pub proxy: Option, 75 | } 76 | 77 | #[derive(Debug, Deserialize)] 78 | pub struct CurrencyConfig { 79 | pub trx_rate: Decimal, 80 | pub cny_rate: Decimal, 81 | pub pointer_rate: Decimal, 82 | } 83 | 84 | #[derive(Debug, Deserialize)] 85 | pub struct Config { 86 | pub log: String, 87 | pub cleaner_max_try: u32, 88 | pub topic_section_secret_key: String, 89 | pub host: String, 90 | pub web: WebConfig, 91 | pub db: DbConfig, 92 | pub session: SessionConfig, 93 | pub mails: Vec, 94 | pub protected_content: ProtectedContentConfig, 95 | pub captcha: CaptchaConfig, 96 | pub upload: UploadConfig, 97 | pub tron: TronConfig, 98 | pub currency: CurrencyConfig, 99 | } 100 | 101 | impl Config { 102 | pub fn from_toml() -> Result { 103 | config::Config::builder() 104 | .add_source(config::File::with_name("config")) 105 | .build()? 106 | .try_deserialize() 107 | } 108 | 109 | pub fn get_mail(&self) -> crate::Result<&MailConfig> { 110 | let idx = rand::rng().random_range(0..self.mails.len()); 111 | let m = match self.mails.get(idx) { 112 | Some(m) => m, 113 | None => return Err(crate::Error::new("msg")), 114 | }; 115 | Ok(m) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/err.rs: -------------------------------------------------------------------------------- 1 | use axum::response::IntoResponse; 2 | 3 | use crate::resp; 4 | 5 | #[derive(Debug)] 6 | pub struct Error(anyhow::Error); 7 | 8 | impl Error { 9 | pub fn new(msg: &str) -> Self { 10 | Self(anyhow::anyhow!("{}", msg)) 11 | } 12 | } 13 | 14 | impl std::fmt::Display for Error { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | write!(f, "{:?}", self.0) 17 | } 18 | } 19 | 20 | impl From for Error 21 | where 22 | E: Into, 23 | { 24 | fn from(e: E) -> Self { 25 | Self(e.into()) 26 | } 27 | } 28 | 29 | impl IntoResponse for Error { 30 | fn into_response(self) -> axum::response::Response { 31 | resp::err(self).into_response() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/form/announcement.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use validator::Validate; 3 | 4 | #[derive(Deserialize, Validate)] 5 | pub struct Add { 6 | #[validate(length(min = 1, max = 255))] 7 | pub title: String, 8 | 9 | #[validate(length(min = 1))] 10 | pub content: String, 11 | } 12 | #[derive(Deserialize, Validate)] 13 | pub struct Edit { 14 | #[validate(length(min = 20, max = 20))] 15 | pub id: String, 16 | #[serde(flatten)] 17 | pub base: Add, 18 | } 19 | 20 | #[derive(Deserialize)] 21 | pub struct ListForAdmin { 22 | #[serde(flatten)] 23 | pub pq: super::PageQueryStr, 24 | pub title: Option, 25 | } 26 | -------------------------------------------------------------------------------- /src/form/auth.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use validator::Validate; 3 | 4 | use crate::model; 5 | 6 | use super::user; 7 | 8 | #[derive(Deserialize, Validate)] 9 | pub struct LoginForm { 10 | #[validate(email)] 11 | #[validate(length(max = 255))] 12 | pub email: String, 13 | 14 | #[validate(length(min = 6))] 15 | pub password: String, 16 | 17 | #[validate(length(min = 6))] 18 | pub captcha: String, 19 | } 20 | #[derive(Deserialize, Validate)] 21 | pub struct AdminLoginForm { 22 | #[validate(length(min = 3, max = 50))] 23 | pub username: String, 24 | 25 | #[validate(length(min = 6))] 26 | pub password: String, 27 | 28 | #[validate(length(min = 6))] 29 | pub captcha: String, 30 | } 31 | 32 | #[derive(Deserialize, Validate)] 33 | pub struct RegisterForm { 34 | #[serde(flatten)] 35 | pub user: user::AddForm, 36 | 37 | /// 邀请码 38 | pub invite: Option, 39 | 40 | #[validate(length(min = 6))] 41 | pub captcha: String, 42 | } 43 | 44 | #[derive(Deserialize, Validate)] 45 | pub struct SendCodeForm { 46 | #[validate(email)] 47 | #[validate(length(max = 255))] 48 | pub email: String, 49 | 50 | #[validate(length(min = 6))] 51 | pub captcha: String, 52 | 53 | pub kind: model::activation_code::Kind, 54 | } 55 | 56 | #[derive(Deserialize, Validate)] 57 | pub struct ActiveForm { 58 | #[validate(email)] 59 | #[validate(length(max = 255))] 60 | pub email: String, 61 | 62 | #[validate(length(min = 6))] 63 | pub activation_code: String, 64 | 65 | #[validate(length(min = 6))] 66 | pub captcha: String, 67 | 68 | pub kind: model::activation_code::Kind, 69 | } 70 | 71 | #[derive(Deserialize, Validate)] 72 | pub struct ResetPasswordForm { 73 | #[validate(email)] 74 | #[validate(length(max = 255))] 75 | pub email: String, 76 | 77 | #[validate(length(min = 6))] 78 | pub activation_code: String, 79 | 80 | #[validate(length(min = 6))] 81 | pub captcha: String, 82 | 83 | #[validate(length(min = 6))] 84 | pub password: String, 85 | 86 | #[validate(length(min = 6))] 87 | pub re_password: String, 88 | } 89 | -------------------------------------------------------------------------------- /src/form/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub mod announcement; 4 | pub mod auth; 5 | pub mod order; 6 | pub mod pay; 7 | pub mod profile; 8 | pub mod promotion; 9 | pub mod service; 10 | pub mod subject; 11 | pub mod tag; 12 | pub mod topic; 13 | pub mod user; 14 | 15 | #[derive(Debug, Default, Deserialize, Serialize)] 16 | pub struct PageQuery { 17 | pub page: Option, 18 | pub page_size: Option, 19 | } 20 | 21 | impl PageQuery { 22 | pub fn page(&self) -> u32 { 23 | self.page.unwrap_or(0) 24 | } 25 | 26 | pub fn page_size(&self) -> u32 { 27 | self.page_size.unwrap_or(30) 28 | } 29 | } 30 | 31 | #[derive(Debug, Default, Deserialize, Serialize)] 32 | pub struct PageQueryStr { 33 | pub page: Option, 34 | pub page_size: Option, 35 | } 36 | 37 | impl PageQueryStr { 38 | pub fn page(&self) -> u32 { 39 | self.page 40 | .clone() 41 | .unwrap_or("0".into()) 42 | .parse() 43 | .unwrap_or_default() 44 | } 45 | 46 | pub fn page_size(&self) -> u32 { 47 | self.page_size 48 | .clone() 49 | .unwrap_or("30".into()) 50 | .parse() 51 | .unwrap_or(30) 52 | } 53 | 54 | pub fn page_to_bind(&self) -> i64 { 55 | self.page() as i64 56 | } 57 | 58 | pub fn page_size_to_bind(&self) -> i64 { 59 | self.page_size() as i64 60 | } 61 | 62 | pub fn offset_to_bind(&self) -> i64 { 63 | self.page_to_bind() * self.page_size_to_bind() 64 | } 65 | } 66 | 67 | #[derive(Deserialize)] 68 | pub struct ListAll { 69 | pub limit: Option, 70 | pub has_price: Option, 71 | } 72 | -------------------------------------------------------------------------------- /src/form/order.rs: -------------------------------------------------------------------------------- 1 | use rust_decimal::Decimal; 2 | use serde::Deserialize; 3 | use validator::Validate; 4 | 5 | use crate::model::{self, currency::Currency, pay::Method}; 6 | 7 | #[derive(Deserialize, Validate)] 8 | pub struct Create { 9 | pub services: Vec, 10 | pub amount: Decimal, 11 | pub actual_amount: Decimal, 12 | } 13 | 14 | #[derive(Deserialize, Validate)] 15 | pub struct ServiceForCreate { 16 | #[validate(length(min = 20, max = 20))] 17 | pub id: String, 18 | #[validate(range(min = 1, max = 96))] 19 | pub num: i16, 20 | } 21 | 22 | #[derive(Deserialize)] 23 | pub struct ListForAdmin { 24 | #[serde(flatten)] 25 | pub pq: super::PageQueryStr, 26 | pub status: Option, 27 | pub nickname: Option, 28 | pub email: Option, 29 | } 30 | 31 | #[derive(Deserialize, Validate)] 32 | pub struct AddForAdmin { 33 | #[validate(length(min = 20, max = 20))] 34 | pub user_id: String, 35 | pub snap: Vec, 36 | pub amount: Decimal, 37 | pub currency: Currency, 38 | pub method: Method, 39 | pub tx_id: String, 40 | pub is_via_admin: bool, 41 | pub approved_opinion: String, 42 | pub proof: String, 43 | } 44 | #[derive(Deserialize, Validate)] 45 | pub struct EditForAdmin { 46 | #[validate(length(min = 20, max = 20))] 47 | pub id: String, 48 | #[validate(length(min = 20, max = 20))] 49 | pub user_id: String, 50 | pub amount: Decimal, 51 | pub currency: Currency, 52 | pub method: Method, 53 | pub tx_id: String, 54 | pub is_via_admin: bool, 55 | pub approved_opinion: String, 56 | pub proof: String, 57 | } 58 | 59 | #[derive(Deserialize)] 60 | pub struct ListForUser { 61 | #[serde(flatten)] 62 | pub pq: super::PageQueryStr, 63 | pub status: Option, 64 | } 65 | -------------------------------------------------------------------------------- /src/form/pay.rs: -------------------------------------------------------------------------------- 1 | use rust_decimal::Decimal; 2 | use serde::Deserialize; 3 | use validator::Validate; 4 | 5 | use crate::model::{currency::Currency, pay::Method}; 6 | 7 | #[derive(Deserialize, Validate)] 8 | pub struct UserPay { 9 | #[validate(length(min = 20, max = 20))] 10 | pub order_id: String, 11 | 12 | pub amount: Decimal, 13 | pub currency: Currency, 14 | pub method: Method, 15 | #[validate(length(min = 64, max = 64))] 16 | pub tx_id: String, 17 | 18 | pub re_pay: bool, 19 | } 20 | 21 | #[derive(Deserialize, Validate)] 22 | pub struct UserConfirm { 23 | #[validate(length(min = 20, max = 20))] 24 | pub order_id: String, 25 | pub currency: Currency, 26 | #[validate(length(min = 20, max = 20))] 27 | pub pay_id: String, 28 | } 29 | -------------------------------------------------------------------------------- /src/form/profile.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use validator::Validate; 3 | 4 | #[derive(Deserialize, Validate)] 5 | pub struct ChangePassword { 6 | #[validate(length(min = 6))] 7 | pub password: String, 8 | #[validate(length(min = 6))] 9 | pub new_password: String, 10 | #[validate(length(min = 6))] 11 | pub re_password: String, 12 | } 13 | 14 | #[derive(Deserialize, Validate)] 15 | pub struct UpdateProfile { 16 | #[validate(length(min = 6))] 17 | pub password: String, 18 | 19 | #[validate(range(min = 1, max = 5))] 20 | pub allow_device_num: i16, 21 | 22 | #[validate(range(min = 20, max = 1440))] 23 | pub session_exp: i16, 24 | } 25 | -------------------------------------------------------------------------------- /src/form/promotion.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use validator::Validate; 5 | 6 | #[derive(Default, Serialize, Deserialize, Validate)] 7 | pub struct Base { 8 | #[validate(length(min = 1, max = 50))] 9 | pub name: String, 10 | #[validate(length(min = 1, max = 255))] 11 | pub content: String, 12 | #[validate(length(min = 1, max = 255))] 13 | pub url: String, 14 | #[validate(length(max = 255))] 15 | pub img: String, 16 | } 17 | 18 | #[derive(Default, Serialize, Deserialize, Validate)] 19 | pub struct Add { 20 | #[serde(flatten)] 21 | pub inner: Base, 22 | } 23 | 24 | impl Deref for Add { 25 | type Target = Base; 26 | 27 | fn deref(&self) -> &Self::Target { 28 | &self.inner 29 | } 30 | } 31 | 32 | #[derive(Default, Serialize, Deserialize, Validate)] 33 | pub struct Edit { 34 | #[validate(length(min = 20, max = 20))] 35 | pub id: String, 36 | #[serde(flatten)] 37 | pub inner: Base, 38 | } 39 | 40 | impl Deref for Edit { 41 | type Target = Base; 42 | 43 | fn deref(&self) -> &Self::Target { 44 | &self.inner 45 | } 46 | } 47 | 48 | #[derive(Deserialize)] 49 | pub struct ListForAdmin { 50 | #[serde(flatten)] 51 | pub pq: super::PageQueryStr, 52 | pub name: Option, 53 | } 54 | -------------------------------------------------------------------------------- /src/form/service.rs: -------------------------------------------------------------------------------- 1 | use rust_decimal::Decimal; 2 | use serde::Deserialize; 3 | use validator::Validate; 4 | 5 | #[derive(Deserialize, Validate)] 6 | pub struct Add { 7 | #[validate(length(min = 1, max = 100))] 8 | pub name: String, // VARCHAR(100) NOT NULL, 9 | 10 | /// 是否专题 11 | pub is_subject: bool, // BOOLEAN NOT NULL DEFAULT FALSE, 12 | 13 | /// 目标ID 14 | pub target_id: String, // CHAR(20) NOT NULL, 15 | /// 时效(天) 16 | pub duration: i16, // SMALLINT NOT NULL DEFAULT 0, 17 | /// 价格 18 | pub price: Decimal, // DECIMAL(10,2) NOT NULL, 19 | /// 封面 20 | #[validate(length(max = 100))] 21 | pub cover: String, // VARCHAR(100) NOT NULL DEFAULT '', 22 | /// 是否允许积分兑换 23 | pub allow_pointer: bool, // BOOLEAN NOT NULL DEFAULT FALSE, 24 | /// 普通用户折扣 25 | pub normal_discount: i16, // SMALLINT NOT NULL DEFAULT 0, 26 | /// 订阅用户折扣 27 | pub sub_discount: i16, // SMALLINT NOT NULL DEFAULT 0, 28 | /// 年费用户折扣 29 | pub yearly_sub_discount: i16, // SMALLINT NOT NULL DEFAULT 0, 30 | 31 | /// 是否下架 32 | pub is_off: bool, // BOOLEAN NOT NULL DEFAULT FALSE 33 | pub desc: String, 34 | pub pin: i32, 35 | } 36 | 37 | #[derive(Deserialize, Validate)] 38 | pub struct Edit { 39 | #[serde(flatten)] 40 | pub base: Add, 41 | 42 | #[validate(length(min = 20, max = 20))] 43 | pub id: String, 44 | } 45 | 46 | #[derive(Deserialize)] 47 | pub struct ListForAdmin { 48 | #[serde(flatten)] 49 | pub pq: super::PageQueryStr, 50 | pub name: Option, 51 | pub is_subject: Option, 52 | pub is_off: Option, 53 | } 54 | 55 | impl ListForAdmin { 56 | pub fn is_subject(&self) -> Option { 57 | if let Some(ref v) = self.is_subject { 58 | Some(v == "1") 59 | } else { 60 | None 61 | } 62 | } 63 | 64 | pub fn is_off(&self) -> Option { 65 | if let Some(ref v) = self.is_off { 66 | Some(v == "1") 67 | } else { 68 | None 69 | } 70 | } 71 | } 72 | 73 | pub type ListForUser = super::PageQuery; 74 | 75 | #[derive(Deserialize)] 76 | pub struct SearchForAdmin { 77 | pub q: String, 78 | pub ids: Option, 79 | } 80 | impl SearchForAdmin { 81 | pub fn ids(&self) -> Option> { 82 | if let Some(ref v) = self.ids { 83 | Some(v.split(',').map(|s| s).collect()) 84 | } else { 85 | None 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/form/subject.rs: -------------------------------------------------------------------------------- 1 | use rust_decimal::Decimal; 2 | use serde::Deserialize; 3 | use validator::Validate; 4 | 5 | use crate::model::{self, subject::Status}; 6 | 7 | #[derive(Deserialize)] 8 | pub struct ListForAdmin { 9 | #[serde(flatten)] 10 | pub pq: super::PageQueryStr, 11 | pub name: Option, 12 | pub slug: Option, 13 | pub status: Option, 14 | pub is_del: Option, 15 | } 16 | 17 | impl ListForAdmin { 18 | pub fn is_del(&self) -> Option { 19 | if let Some(ref v) = self.is_del { 20 | Some(v == "1") 21 | } else { 22 | None 23 | } 24 | } 25 | } 26 | 27 | #[derive(Deserialize, Validate)] 28 | pub struct Add { 29 | #[validate(length(min = 1, max = 100))] 30 | pub name: String, 31 | 32 | #[validate(length(min = 1, max = 100))] 33 | pub slug: String, 34 | 35 | #[validate(length(min = 1, max = 255))] 36 | pub summary: String, 37 | 38 | #[validate(length(min = 0, max = 100))] 39 | pub cover: String, 40 | 41 | pub status: Status, 42 | 43 | pub price: Decimal, 44 | pub pin: i32, 45 | } 46 | 47 | #[derive(Deserialize, Validate)] 48 | pub struct Edit { 49 | #[validate(length(min = 20, max = 20))] 50 | pub id: String, 51 | 52 | #[serde(flatten)] 53 | pub base: Add, 54 | } 55 | 56 | #[derive(Deserialize)] 57 | pub struct RealDel { 58 | pub real: Option, 59 | } 60 | -------------------------------------------------------------------------------- /src/form/tag.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use validator::Validate; 3 | 4 | #[derive(Deserialize)] 5 | pub struct ListForAdmin { 6 | #[serde(flatten)] 7 | pub pq: super::PageQueryStr, 8 | pub name: Option, 9 | pub is_del: Option, 10 | } 11 | 12 | impl ListForAdmin { 13 | pub fn is_del(&self) -> Option { 14 | if let Some(ref v) = self.is_del { 15 | Some(v == "1") 16 | } else { 17 | None 18 | } 19 | } 20 | } 21 | 22 | #[derive(Deserialize, Validate)] 23 | pub struct Add { 24 | #[validate(length(min = 1, max = 100))] 25 | pub name: String, 26 | } 27 | #[derive(Deserialize, Validate)] 28 | pub struct Edit { 29 | #[validate(length(min = 20, max = 20))] 30 | pub id: String, 31 | 32 | #[serde(flatten)] 33 | pub base: Add, 34 | } 35 | -------------------------------------------------------------------------------- /src/form/topic.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use validator::Validate; 3 | 4 | #[derive(Deserialize, Validate)] 5 | pub struct GetProtectedContent { 6 | #[validate(length(min = 1))] 7 | pub ids: Vec, 8 | 9 | #[validate(length(min = 20, max = 20))] 10 | pub topic_id: String, 11 | 12 | #[validate(length(min = 6))] 13 | pub captcha: String, 14 | } 15 | 16 | #[derive(Deserialize, Validate)] 17 | pub struct Add { 18 | #[validate(length(min = 1))] 19 | pub tag_names: Vec, 20 | 21 | #[validate(length(min = 1, max = 255))] 22 | pub title: String, 23 | 24 | #[validate(length(min = 20, max = 20))] 25 | pub subject_id: String, 26 | 27 | #[validate(length(min = 1, max = 100))] 28 | pub slug: String, 29 | 30 | #[validate(length(min = 1, max = 255))] 31 | pub summary: String, 32 | 33 | #[validate(length(min = 1, max = 50))] 34 | pub author: String, 35 | 36 | #[validate(length(min = 1, max = 50))] 37 | pub src: String, 38 | 39 | pub try_readable: bool, 40 | 41 | #[validate(length(max = 100))] 42 | pub cover: String, 43 | 44 | #[validate(length(min = 1))] 45 | pub md: String, 46 | 47 | pub pin: i32, 48 | } 49 | 50 | #[derive(Deserialize, Validate)] 51 | pub struct Edit { 52 | #[validate(length(min = 20, max = 20))] 53 | pub id: String, 54 | 55 | #[serde(flatten)] 56 | pub base: Add, 57 | } 58 | 59 | #[derive(Deserialize)] 60 | pub struct ListForAdmin { 61 | #[serde(flatten)] 62 | pub pq: super::PageQueryStr, 63 | 64 | pub title: Option, 65 | pub subject_name: Option, 66 | pub subject_slug: Option, 67 | pub slug: Option, 68 | pub is_del: Option, 69 | } 70 | 71 | impl ListForAdmin { 72 | pub fn is_del(&self) -> Option { 73 | if let Some(ref v) = self.is_del { 74 | Some(v == "1") 75 | } else { 76 | None 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/form/user.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Local}; 2 | use rust_decimal::Decimal; 3 | use serde::Deserialize; 4 | use validator::Validate; 5 | 6 | use crate::{model, utils}; 7 | 8 | #[derive(Deserialize, Validate)] 9 | pub struct AddForm { 10 | #[validate(email)] 11 | #[validate(length(max = 255))] 12 | pub email: String, 13 | 14 | #[validate(length(min = 3, max = 30))] 15 | pub nickname: String, 16 | 17 | #[validate(length(min = 6))] 18 | pub password: String, 19 | 20 | #[validate(length(min = 6))] 21 | #[validate(must_match(other = "password"))] 22 | pub re_password: String, 23 | } 24 | 25 | #[derive(Deserialize, Validate)] 26 | pub struct AddForAdmin { 27 | #[serde(flatten)] 28 | pub base: AddForm, 29 | 30 | pub status: model::user::Status, 31 | pub kind: model::user::Kind, 32 | pub sub_exp: Option, 33 | pub points: Decimal, 34 | pub allow_device_num: i16, 35 | pub session_exp: i16, 36 | } 37 | impl AddForAdmin { 38 | pub fn sub_exp(&self) -> DateTime { 39 | let default_ts = utils::dt::parse("1970-01-01 00:00:00").unwrap_or_default(); 40 | match self.sub_exp { 41 | Some(ref v) => utils::dt::parse(v).unwrap_or(default_ts), 42 | None => default_ts, 43 | } 44 | } 45 | } 46 | 47 | #[derive(Deserialize, Validate)] 48 | pub struct EditForAdmin { 49 | #[validate(email)] 50 | #[validate(length(max = 255))] 51 | pub email: String, 52 | 53 | #[validate(length(min = 3, max = 30))] 54 | pub nickname: String, 55 | 56 | pub status: model::user::Status, 57 | pub kind: model::user::Kind, 58 | pub sub_exp: Option, 59 | pub points: Decimal, 60 | pub allow_device_num: i16, 61 | pub session_exp: i16, 62 | 63 | #[validate(length(min = 6))] 64 | pub password: Option, 65 | 66 | #[validate(length(min = 6))] 67 | pub re_password: Option, 68 | 69 | #[validate(length(min = 20, max = 20))] 70 | pub id: String, 71 | } 72 | 73 | impl EditForAdmin { 74 | pub fn sub_exp(&self) -> DateTime { 75 | let default_ts = utils::dt::parse("1970-01-01 00:00:00").unwrap_or_default(); 76 | match self.sub_exp { 77 | Some(ref v) => utils::dt::parse(v).unwrap_or(default_ts), 78 | None => default_ts, 79 | } 80 | } 81 | } 82 | 83 | #[derive(Deserialize, Validate)] 84 | pub struct ListForAdmin { 85 | #[serde(flatten)] 86 | pub pq: super::PageQueryStr, 87 | pub email: Option, 88 | pub nickname: Option, 89 | pub status: Option, 90 | pub kind: Option, 91 | } 92 | 93 | #[derive(Deserialize)] 94 | pub struct SearchForAdmin { 95 | pub q: String, 96 | pub user_id: Option, 97 | } 98 | -------------------------------------------------------------------------------- /src/interfaces/auth.rs: -------------------------------------------------------------------------------- 1 | pub trait AsAuth {} 2 | -------------------------------------------------------------------------------- /src/interfaces/mod.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | 3 | pub use auth::*; 4 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod captcha; 3 | pub mod config; 4 | mod err; 5 | pub mod form; 6 | pub mod interfaces; 7 | pub mod mail; 8 | pub mod mid; 9 | pub mod model; 10 | mod resp; 11 | pub mod service; 12 | mod state; 13 | pub mod tron; 14 | pub mod utils; 15 | 16 | pub use err::Error; 17 | pub use resp::*; 18 | pub use state::*; 19 | 20 | pub type Result = std::result::Result; 21 | -------------------------------------------------------------------------------- /src/mail.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use lettre::{ 4 | message::header::ContentType, 5 | transport::smtp::{authentication::Credentials, response::Response}, 6 | AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, 7 | }; 8 | 9 | use crate::{config, Error, Result}; 10 | 11 | pub struct Data { 12 | pub subject: String, // 邮件主题 13 | pub body: String, // 邮件内容 14 | pub to: String, // 收件人 15 | } 16 | 17 | impl Data { 18 | pub fn to_message(&self, cfg: &config::MailConfig) -> Result { 19 | let user = cfg.user.as_str().parse().map_err(Error::from)?; 20 | let to = self.to.parse().map_err(Error::from)?; 21 | Message::builder() 22 | .from(user) 23 | .to(to) 24 | .subject(self.subject.as_str()) 25 | .header(ContentType::TEXT_PLAIN) 26 | .body(self.body.clone()) 27 | .map_err(Error::from) 28 | } 29 | } 30 | 31 | pub async fn send(cfg: Arc, m: Data) -> Result { 32 | let cfg = cfg.get_mail()?; 33 | let message = m.to_message(cfg)?; 34 | let creds = Credentials::new(cfg.user.clone(), cfg.password.clone()); 35 | let mailer = AsyncSmtpTransport::::relay(&cfg.smtp) 36 | .map_err(Error::from)? 37 | .credentials(creds) 38 | .build(); 39 | mailer.send(message).await.map_err(Error::from) 40 | } 41 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum_rs::{api, config, model, AppState}; 4 | use sqlx::{postgres::PgPoolOptions, PgPool}; 5 | use tokio::net::TcpListener; 6 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 7 | 8 | #[tokio::main] 9 | async fn main() { 10 | let cfg = config::Config::from_toml().unwrap(); 11 | 12 | tracing_subscriber::registry() 13 | .with( 14 | tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { 15 | if cfg.log.is_empty() { 16 | format!("{}=debug", env!("CARGO_CRATE_NAME")).into() 17 | } else { 18 | cfg.log.as_str().into() 19 | } 20 | }), 21 | ) 22 | .with(tracing_subscriber::fmt::layer()) 23 | .init(); 24 | 25 | let pool = PgPoolOptions::new() 26 | .max_connections(cfg.db.max_conns) 27 | .connect(&cfg.db.dsn) 28 | .await 29 | .unwrap(); 30 | let pool = Arc::new(pool); 31 | 32 | let mut ses_handler = tokio::spawn(session_cleaner(pool.clone(), cfg.cleaner_max_try)); 33 | let mut act_handler = tokio::spawn(activation_cleaner(pool.clone(), cfg.cleaner_max_try)); 34 | let mut pc_handler = tokio::spawn(protected_content_cleaner(pool.clone(), cfg.cleaner_max_try)); 35 | let mut user_sub_handler = 36 | tokio::spawn(user_subscriber_cleaner(pool.clone(), cfg.cleaner_max_try)); 37 | 38 | let web_addr = cfg.web.addr.as_str(); 39 | 40 | let tcp_listener = TcpListener::bind(web_addr).await.unwrap(); 41 | tracing::info!("Web服务监听于:{},路由前缀:{}", web_addr, &cfg.web.prefix); 42 | 43 | let state = Arc::new(AppState { 44 | pool, 45 | cfg: Arc::new(cfg), 46 | }); 47 | 48 | let app = api::router::init(state); 49 | 50 | let mut svr_handler = tokio::spawn(async move { axum::serve(tcp_listener, app).await }); 51 | 52 | loop { 53 | tokio::select! { 54 | _ = &mut svr_handler => { 55 | tracing::info!("Web服务退出"); 56 | ses_handler.abort(); 57 | act_handler.abort(); 58 | pc_handler.abort(); 59 | user_sub_handler.abort(); 60 | break; 61 | } 62 | _ = &mut ses_handler => { 63 | tracing::info!("会话清理退出"); 64 | act_handler.abort(); 65 | pc_handler.abort(); 66 | svr_handler.abort(); 67 | user_sub_handler.abort(); 68 | break; 69 | } 70 | _ = &mut act_handler => { 71 | tracing::info!("激活码清理退出"); 72 | ses_handler.abort(); 73 | pc_handler.abort(); 74 | svr_handler.abort(); 75 | user_sub_handler.abort(); 76 | break; 77 | } 78 | _ = &mut pc_handler => { 79 | tracing::info!("内容保护清理退出"); 80 | ses_handler.abort(); 81 | act_handler.abort(); 82 | svr_handler.abort(); 83 | user_sub_handler.abort(); 84 | break; 85 | } 86 | _ = &mut user_sub_handler => { 87 | tracing::info!("用户订阅清理退出"); 88 | ses_handler.abort(); 89 | act_handler.abort(); 90 | pc_handler.abort(); 91 | svr_handler.abort(); 92 | break; 93 | } 94 | _ = tokio::signal::ctrl_c() => { 95 | tracing::info!("Ctrl+C退出"); 96 | ses_handler.abort(); 97 | act_handler.abort(); 98 | pc_handler.abort(); 99 | user_sub_handler.abort(); 100 | svr_handler.abort(); 101 | break; 102 | } 103 | } 104 | } 105 | } 106 | 107 | async fn session_cleaner(pool: Arc, max_try: u32) { 108 | let mut tried = 0u32; 109 | loop { 110 | if max_try > 0 && tried >= max_try { 111 | tracing::info!("[session_cleaner] 已尝试 {} 次", tried); 112 | break; 113 | } 114 | let aff = match sqlx::query("DELETE FROM sessions WHERE expire_time <=$1") 115 | .bind(&chrono::Local::now()) 116 | .execute(&*pool) 117 | .await 118 | { 119 | Ok(v) => { 120 | tried = 0; 121 | v.rows_affected() 122 | } 123 | Err(e) => { 124 | tried += 1; 125 | tracing::error!("[session_cleaner] {}", e); 126 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; 127 | continue; 128 | } 129 | }; 130 | tracing::info!("[session_cleaner] 已清理 {} 个过期会话", aff); 131 | tokio::time::sleep(std::time::Duration::from_secs(60)).await; 132 | } 133 | } 134 | 135 | async fn activation_cleaner(pool: Arc, max_try: u32) { 136 | let mut tried = 0u32; 137 | loop { 138 | if max_try > 0 && tried >= max_try { 139 | tracing::info!("[activation_cleaner] 已尝试 {} 次", tried); 140 | break; 141 | } 142 | let aff = match sqlx::query("DELETE FROM activation_codes WHERE expire_time <=$1") 143 | .bind(&(chrono::Local::now())) 144 | .execute(&*pool) 145 | .await 146 | { 147 | Ok(v) => { 148 | tried = 0; 149 | v.rows_affected() 150 | } 151 | Err(e) => { 152 | tried += 1; 153 | tracing::error!("[activation_cleaner] {}", e); 154 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; 155 | continue; 156 | } 157 | }; 158 | tracing::info!("[activation_cleaner] 已清理 {} 个过期激活码", aff); 159 | tokio::time::sleep(std::time::Duration::from_secs(60)).await; 160 | } 161 | } 162 | 163 | async fn protected_content_cleaner(pool: Arc, max_try: u32) { 164 | let mut tried = 0u32; 165 | loop { 166 | if max_try > 0 && tried >= max_try { 167 | tracing::info!("[protected_content_cleaner] 已尝试 {} 次", tried); 168 | break; 169 | } 170 | 171 | let aff = match sqlx::query("DELETE FROM protected_contents WHERE expire_time <=$1") 172 | .bind(&chrono::Local::now()) 173 | .execute(&*pool) 174 | .await 175 | { 176 | Ok(v) => { 177 | tried = 0; 178 | v.rows_affected() 179 | } 180 | Err(e) => { 181 | tried += 1; 182 | tracing::error!("[protected_content_cleaner] {}", e); 183 | tokio::time::sleep(std::time::Duration::from_secs(5000)).await; 184 | continue; 185 | } 186 | }; 187 | tracing::info!("[protected_content_cleaner] 已清理 {} 个过期保护内容", aff); 188 | tokio::time::sleep(std::time::Duration::from_secs(60)).await; 189 | } 190 | } 191 | 192 | async fn user_subscriber_cleaner(pool: Arc, max_try: u32) { 193 | let mut tried = 0u32; 194 | loop { 195 | if max_try > 0 && tried >= max_try { 196 | tracing::info!("[user_subscriber_cleaner] 已尝试 {} 次", tried); 197 | break; 198 | } 199 | 200 | let aff = match sqlx::query( 201 | "UPDATE users SET kind=$2,allow_device_num=1,session_exp=20 WHERE sub_exp <=$1 AND kind<>$2", 202 | ) 203 | .bind(&chrono::Local::now()) 204 | .bind(&model::user::Kind::Normal) 205 | .execute(&*pool) 206 | .await 207 | { 208 | Ok(v) => { 209 | tried = 0; 210 | v.rows_affected() 211 | } 212 | Err(e) => { 213 | tried += 1; 214 | tracing::error!("[user_subscriber_cleaner] {}", e); 215 | tokio::time::sleep(std::time::Duration::from_secs(5000)).await; 216 | continue; 217 | } 218 | }; 219 | tracing::info!("[user_subscriber_cleaner] 已清理 {} 个过期订阅", aff); 220 | tokio::time::sleep(std::time::Duration::from_secs(60)).await; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/mid/admin_auth.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::FromRequestParts, http::request::Parts}; 2 | use chrono::Local; 3 | 4 | use crate::{model, utils, ArcAppState, Error}; 5 | 6 | pub struct AdminAuth { 7 | pub admin: model::admin::Admin, 8 | pub token: String, 9 | } 10 | 11 | impl AdminAuth { 12 | pub fn admin(&self) -> &model::admin::Admin { 13 | &self.admin 14 | } 15 | } 16 | 17 | impl FromRequestParts for AdminAuth { 18 | type Rejection = Error; 19 | 20 | async fn from_request_parts( 21 | parts: &mut Parts, 22 | state: &ArcAppState, 23 | ) -> Result { 24 | let token = match utils::http::get_auth_token(&parts.headers) { 25 | Some(v) => v, 26 | None => return Err(Error::new("未授权")), 27 | }; 28 | 29 | let sesc = match model::session::Session::find( 30 | &*state.pool, 31 | &model::session::SessionFindFilter { 32 | token: Some(token.into()), 33 | user_id: None, 34 | is_admin: Some(true), 35 | id: None, 36 | }, 37 | ) 38 | .await? 39 | { 40 | Some(v) => v, 41 | None => return Err(Error::new("非法令牌")), 42 | }; 43 | 44 | if sesc.expire_time < Local::now() { 45 | return Err(Error::new("登录已过期")); 46 | } 47 | 48 | let u = match model::admin::Admin::find( 49 | &*state.pool, 50 | &model::admin::AdminFindFilter { 51 | by: model::admin::AdminFindBy::Id(sesc.user_id), 52 | }, 53 | ) 54 | .await? 55 | { 56 | Some(v) => v, 57 | None => return Err(Error::new("不存在的用户")), 58 | }; 59 | Ok(AdminAuth { 60 | admin: u, 61 | token: token.into(), 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/mid/ip_ua.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::FromRequestParts, http::request::Parts}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::{utils, Error}; 5 | 6 | #[derive(Serialize, Deserialize, Debug)] 7 | pub struct IpAndUserAgent { 8 | pub ip: String, 9 | pub ip_location: String, 10 | pub user_agent: String, 11 | } 12 | 13 | impl FromRequestParts for IpAndUserAgent 14 | where 15 | S: Send + Sync, 16 | { 17 | type Rejection = Error; 18 | 19 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 20 | let ip = utils::http::get_ip(&parts.headers); 21 | let user_agent = utils::http::get_user_agent(&parts.headers); 22 | let ip_location = utils::http::get_cf_location(&parts.headers); 23 | 24 | Ok(Self { 25 | ip: ip.to_string(), 26 | user_agent: user_agent.to_string(), 27 | ip_location: ip_location.to_string(), 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/mid/mod.rs: -------------------------------------------------------------------------------- 1 | mod admin_auth; 2 | 3 | mod ip_ua; 4 | mod user_auth; 5 | 6 | pub use admin_auth::*; 7 | pub use ip_ua::*; 8 | pub use user_auth::*; 9 | -------------------------------------------------------------------------------- /src/mid/user_auth.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::FromRequestParts, http::request::Parts}; 2 | use chrono::Local; 3 | 4 | use crate::{model, utils, ArcAppState, Error}; 5 | 6 | pub struct UserAuth { 7 | pub user: Option, 8 | pub token: Option, 9 | } 10 | 11 | impl UserAuth { 12 | pub fn user_opt(&self) -> &Option { 13 | &self.user 14 | } 15 | pub fn user(&self) -> crate::Result<&model::user::User> { 16 | match self.user_opt() { 17 | Some(v) => Ok(v), 18 | None => Err(Error::new("UNAUTHORIZED-请登录")), 19 | } 20 | } 21 | 22 | pub fn token_opt(&self) -> &Option { 23 | &self.token 24 | } 25 | 26 | pub fn token(&self) -> crate::Result<&str> { 27 | match self.token_opt() { 28 | Some(v) => Ok(v), 29 | None => Err(Error::new("UNAUTHORIZED-请登录")), 30 | } 31 | } 32 | } 33 | 34 | impl FromRequestParts for UserAuth { 35 | type Rejection = Error; 36 | 37 | async fn from_request_parts( 38 | parts: &mut Parts, 39 | state: &ArcAppState, 40 | ) -> Result { 41 | let token = match utils::http::get_auth_token(&parts.headers) { 42 | Some(v) => v, 43 | None => { 44 | return Ok(UserAuth { 45 | user: None, 46 | token: None, 47 | }) 48 | } 49 | }; 50 | 51 | let sesc = match model::session::Session::find( 52 | &*state.pool, 53 | &model::session::SessionFindFilter { 54 | token: Some(token.into()), 55 | user_id: None, 56 | is_admin: Some(false), 57 | id: None, 58 | }, 59 | ) 60 | .await? 61 | { 62 | Some(v) => v, 63 | None => return Err(Error::new("UNAUTHORIZED-非法令牌")), 64 | }; 65 | 66 | if sesc.expire_time < Local::now() { 67 | return Err(Error::new("UNAUTHORIZED-登录已过期")); 68 | } 69 | 70 | let u = match model::user::User::find( 71 | &*state.pool, 72 | &model::user::UserFindFilter { 73 | by: model::user::UserFindBy::Id(sesc.user_id), 74 | status: Some(model::user::Status::Actived), 75 | }, 76 | ) 77 | .await? 78 | { 79 | Some(v) => v, 80 | None => return Err(Error::new("UNAUTHORIZED-不存在的用户")), 81 | }; 82 | 83 | Ok(UserAuth { 84 | user: Some(u), 85 | token: Some(token.into()), 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/model/activation_code.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type, Clone)] 7 | #[sqlx(type_name = "activation_kind")] 8 | pub enum Kind { 9 | #[default] 10 | Active, 11 | ResetPassword, 12 | } 13 | 14 | impl std::fmt::Display for Kind { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | write!(f, "{:?}", self) 17 | } 18 | } 19 | 20 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 21 | #[db(table = activation_codes, pk = id)] 22 | pub struct ActivationCode { 23 | #[db(find_opt)] 24 | #[db(skip_update)] 25 | pub id: String, 26 | 27 | #[db(find_opt)] 28 | #[db(skip_update)] 29 | pub email: String, 30 | 31 | #[db(skip_update)] 32 | #[db(find_opt)] 33 | pub code: String, 34 | 35 | #[db(find_opt)] 36 | pub kind: Kind, 37 | 38 | #[db(skip_update)] 39 | pub dateline: DateTime, 40 | 41 | #[db(skip_update)] 42 | pub expire_time: DateTime, 43 | } 44 | -------------------------------------------------------------------------------- /src/model/admin.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::interfaces; 5 | 6 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 7 | #[db(table = admins, pk = id)] 8 | pub struct Admin { 9 | #[db(skip_update)] 10 | #[db(find)] 11 | pub id: String, 12 | 13 | #[db(find)] 14 | #[db(skip_update)] 15 | #[db(exists)] 16 | pub username: String, 17 | 18 | #[serde(skip_serializing)] 19 | pub password: String, 20 | } 21 | 22 | impl interfaces::AsAuth for Admin {} 23 | -------------------------------------------------------------------------------- /src/model/announcement.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = announcements, pk = id)] 7 | pub struct Announcement { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | 12 | #[db(list_opt)] 13 | #[db(list_opt_like)] 14 | pub title: String, 15 | 16 | pub content: String, 17 | pub hit: i64, 18 | 19 | #[db(skip_update)] 20 | pub dateline: DateTime, 21 | } 22 | 23 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow)] 24 | pub struct AnnouncementLite { 25 | pub id: String, 26 | pub title: String, 27 | pub dateline: DateTime, 28 | } 29 | -------------------------------------------------------------------------------- /src/model/check_in_log.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = check_in_logs, pk = id)] 7 | pub struct CheckInLog { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | 12 | #[db(find_opt)] 13 | #[db(skip_update)] 14 | #[db(list_opt)] 15 | pub user_id: String, 16 | 17 | #[db(skip_update)] 18 | pub points: i16, 19 | 20 | #[db(skip_update)] 21 | pub dateline: DateTime, 22 | } 23 | -------------------------------------------------------------------------------- /src/model/currency.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type)] 4 | #[sqlx(type_name = "currency")] 5 | pub enum Currency { 6 | #[default] 7 | USDT, 8 | TRX, 9 | CNY, 10 | /// 积分 11 | PNT, 12 | } 13 | 14 | impl std::fmt::Display for Currency { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | write!(f, "{:?}", self) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/model/login_log.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = login_logs, pk = id)] 7 | pub struct LoginLog { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | 12 | #[db(find_opt)] 13 | #[db(skip_update)] 14 | #[db(list_opt)] 15 | pub user_id: String, 16 | 17 | #[db(skip_update)] 18 | pub dateline: DateTime, 19 | 20 | #[db(skip_update)] 21 | pub ip: String, 22 | 23 | #[db(skip_update)] 24 | pub user_agent: String, 25 | } 26 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod activation_code; 2 | pub mod admin; 3 | pub mod announcement; 4 | pub mod check_in_log; 5 | pub mod currency; 6 | pub mod login_log; 7 | pub mod order; 8 | pub mod pagination; 9 | pub mod pay; 10 | pub mod promotion; 11 | pub mod protected_content; 12 | pub mod read_history; 13 | pub mod service; 14 | pub mod session; 15 | pub mod subject; 16 | pub mod tag; 17 | pub mod tag_views; 18 | pub mod topic; 19 | pub mod topic_tag; 20 | pub mod topic_views; 21 | pub mod user; 22 | -------------------------------------------------------------------------------- /src/model/order.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use rust_decimal::Decimal; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type)] 7 | #[sqlx(type_name = "order_status")] 8 | pub enum Status { 9 | #[default] 10 | Pending, 11 | Finished, 12 | Cancelled, 13 | Closed, 14 | } 15 | 16 | impl std::fmt::Display for Status { 17 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 | write!(f, "{:?}", self) 19 | } 20 | } 21 | 22 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 23 | #[db(table = orders, pk = id)] 24 | pub struct Order { 25 | #[db(find_opt)] 26 | #[db(skip_update)] 27 | pub id: String, 28 | 29 | #[db(find_opt)] 30 | #[db(list_opt)] 31 | #[db(skip_update)] 32 | pub user_id: String, 33 | 34 | pub amount: Decimal, 35 | pub actual_amount: Decimal, 36 | 37 | #[db(find_opt)] 38 | #[db(list_opt)] 39 | pub status: Status, 40 | pub snapshot: String, 41 | pub allow_pointer: bool, 42 | pub dateline: DateTime, 43 | } 44 | 45 | impl Order { 46 | pub fn to_snapshot(&self) -> Vec { 47 | serde_json::from_str(&self.snapshot).unwrap() 48 | } 49 | pub fn snapshot_to_str(snapshot_list: &Vec) -> String { 50 | serde_json::json!(snapshot_list).to_string() 51 | } 52 | } 53 | 54 | #[derive(Debug, Default, Deserialize, Serialize)] 55 | pub struct OrderSnapshot { 56 | pub service: OrderSnapshotService, 57 | pub user: super::user::User, 58 | } 59 | 60 | #[derive(Debug, Default, Deserialize, Serialize)] 61 | pub struct OrderSnapshotService { 62 | #[serde(flatten)] 63 | pub service: super::service::Service, 64 | pub actual_price: Decimal, 65 | pub amount: Decimal, 66 | pub actual_amount: Decimal, 67 | pub discount: i16, 68 | pub num: i16, 69 | } 70 | 71 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow)] 72 | 73 | pub struct OrderWithUser { 74 | #[serde(flatten)] 75 | #[sqlx(flatten)] 76 | pub order: Order, 77 | pub email: String, 78 | pub nickname: String, 79 | } 80 | -------------------------------------------------------------------------------- /src/model/pagination.rs: -------------------------------------------------------------------------------- 1 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Default, Deserialize, Serialize)] 4 | pub struct Paginate { 5 | pub total: u32, 6 | pub total_page: u32, 7 | pub page: u32, 8 | pub page_size: u32, 9 | pub data: Vec, 10 | } 11 | 12 | impl Paginate { 13 | pub fn new(total: u32, page: u32, page_size: u32, data: Vec) -> Self { 14 | let total_page = (total as f64 / page_size as f64).ceil() as u32; 15 | Self { 16 | total, 17 | page, 18 | page_size, 19 | data, 20 | total_page, 21 | } 22 | } 23 | 24 | pub fn quick(count: (i64,), page: u32, page_size: u32, data: Vec) -> Self { 25 | Self::new(count.0 as u32, page, page_size, data) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/model/pay.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use rust_decimal::Decimal; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use super::currency::Currency; 7 | 8 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type)] 9 | #[sqlx(type_name = "pay_status")] 10 | pub enum Status { 11 | #[default] 12 | Pending, 13 | Failed, 14 | Success, 15 | } 16 | 17 | impl std::fmt::Display for Status { 18 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 19 | write!(f, "{:?}", self) 20 | } 21 | } 22 | 23 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type)] 24 | #[sqlx(type_name = "pay_method")] 25 | pub enum Method { 26 | #[default] 27 | Online, 28 | QrCode, 29 | WechatAlipay, 30 | Pointer, 31 | } 32 | 33 | impl std::fmt::Display for Method { 34 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 35 | write!(f, "{:?}", self) 36 | } 37 | } 38 | 39 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 40 | #[db(table = pays, pk = id)] 41 | pub struct Pay { 42 | #[db(find_opt)] 43 | #[db(skip_update)] 44 | pub id: String, // CHAR(20) PRIMARY KEY , 45 | 46 | /// 订单ID 47 | #[db(find_opt)] 48 | #[db(skip_update)] 49 | #[db(exists)] 50 | pub order_id: String, // CHAR(20) NOT NULL, 51 | 52 | /// 用户ID 53 | #[db(find_opt)] 54 | #[db(skip_update)] 55 | pub user_id: String, // CHAR(20) NOT NULL, 56 | 57 | /// 支付金额 58 | pub amount: Decimal, // DECIMAL(10,2) NOT NULL, 59 | /// 货币 60 | pub currency: Currency, // currency NOT NULL DEFAULT 'USDT', 61 | /// 支付工具的交易ID 62 | pub tx_id: String, // VARCHAR(255) NOT NULL DEFAULT '', 63 | /// 支付方式 64 | pub method: Method, // pay_method NOT NULL DEFAULT 'Online', 65 | 66 | /// 支付状态 67 | pub status: Status, // pay_status NOT NULL DEFAULT 'Pending', 68 | /// 是否管理员生成 69 | pub is_via_admin: bool, // BOOLEAN NOT NULL DEFAULT FALSE, 70 | /// 审核时间 71 | pub approved_time: DateTime, // TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 08:00:00+08', 72 | /// 审核意见 73 | pub approved_opinion: String, // VARCHAR(255) 74 | 75 | /// 支付证明截图 76 | pub proof: String, //VARCHAR(255) NOT NULL DEFAULT '', 77 | /// 时间 78 | pub dateline: DateTime, // TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP 79 | } 80 | -------------------------------------------------------------------------------- /src/model/promotion.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Db)] 6 | #[db(table = promotions, pk = id)] 7 | pub struct Promotion { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | /// 名称 12 | #[db(list_opt)] 13 | #[db(list_opt_like)] 14 | pub name: String, 15 | /// 内容 16 | pub content: String, 17 | /// 链接 18 | pub url: String, 19 | /// 图片 20 | pub img: String, 21 | /// 创建时间 22 | #[db(skip_update)] 23 | pub dateline: DateTime, 24 | } 25 | -------------------------------------------------------------------------------- /src/model/protected_content.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = protected_contents, pk = id)] 7 | pub struct ProtectedContent { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | 12 | #[db(find_opt)] 13 | #[db(skip_update)] 14 | #[db(list_opt)] 15 | pub section_id: String, 16 | 17 | #[db(skip_update)] 18 | pub content: String, 19 | 20 | #[db(skip_update)] 21 | #[db(list_opt)] 22 | #[db(list_opt_between)] 23 | #[db(find_opt)] 24 | #[db(find_opt_between)] 25 | pub expire_time: DateTime, 26 | } 27 | -------------------------------------------------------------------------------- /src/model/read_history.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = read_histories, pk = id)] 7 | pub struct ReadHistorie { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | 12 | #[db(find_opt)] 13 | #[db(skip_update)] 14 | #[db(list_opt)] 15 | pub user_id: String, 16 | 17 | #[db(skip_update)] 18 | pub subject_slug: String, 19 | 20 | #[db(skip_update)] 21 | pub slug: String, 22 | 23 | #[db(skip_update)] 24 | pub subject_name: String, 25 | 26 | #[db(skip_update)] 27 | pub topic_title: String, 28 | 29 | #[db(skip_update)] 30 | pub dateline: DateTime, 31 | } 32 | -------------------------------------------------------------------------------- /src/model/service.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use rust_decimal::Decimal; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = services, pk = id)] 7 | pub struct Service { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, // CHAR(20) PRIMARY KEY, 11 | 12 | #[db(list_opt)] 13 | #[db(list_opt_like)] 14 | #[db(exists)] 15 | pub name: String, // VARCHAR(100) NOT NULL, 16 | 17 | /// 是否专题 18 | #[db(list_opt)] 19 | #[db(find_opt)] 20 | pub is_subject: bool, // BOOLEAN NOT NULL DEFAULT FALSE, 21 | 22 | /// 目标ID 23 | #[db(exists)] 24 | #[db(find_opt)] 25 | pub target_id: String, // CHAR(20) NOT NULL, 26 | /// 时效(天) 27 | pub duration: i16, // SMALLINT NOT NULL DEFAULT 0, 28 | /// 价格 29 | pub price: Decimal, // DECIMAL(10,2) NOT NULL, 30 | /// 封面 31 | pub cover: String, // VARCHAR(100) NOT NULL DEFAULT '', 32 | /// 是否允许积分兑换 33 | pub allow_pointer: bool, // BOOLEAN NOT NULL DEFAULT FALSE, 34 | /// 普通用户折扣 35 | pub normal_discount: i16, // SMALLINT NOT NULL DEFAULT 0, 36 | /// 订阅用户折扣 37 | pub sub_discount: i16, // SMALLINT NOT NULL DEFAULT 0, 38 | /// 年费用户折扣 39 | pub yearly_sub_discount: i16, // SMALLINT NOT NULL DEFAULT 0, 40 | 41 | /// 是否下架 42 | #[db(list_opt)] 43 | pub is_off: bool, // BOOLEAN NOT NULL DEFAULT FALSE 44 | pub desc: String, 45 | pub pin: i32, 46 | } 47 | 48 | #[cfg(test)] 49 | mod test { 50 | 51 | #[test] 52 | fn test() { 53 | let s = super::Service::default(); 54 | println!("{}", serde_json::json!(&s)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/model/session.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = sessions, pk = id)] 7 | pub struct Session { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | 12 | #[db(find_opt)] 13 | #[db(skip_update)] 14 | #[db(list_opt)] 15 | pub user_id: String, 16 | 17 | #[db(skip_update)] 18 | #[db(list_opt)] 19 | #[db(find_opt)] 20 | pub token: String, 21 | 22 | #[db(skip_update)] 23 | #[db(list_opt)] 24 | #[db(find_opt)] 25 | pub is_admin: bool, 26 | 27 | #[db(skip_update)] 28 | pub dateline: DateTime, 29 | 30 | #[db(skip_update)] 31 | pub ip: String, 32 | 33 | #[db(skip_update)] 34 | pub ua: String, 35 | 36 | #[db(skip_update)] 37 | pub loc: String, 38 | 39 | #[db(skip_update)] 40 | pub expire_time: DateTime, 41 | } 42 | -------------------------------------------------------------------------------- /src/model/subject.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use rust_decimal::Decimal; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type)] 6 | #[sqlx(type_name = "subject_status")] 7 | pub enum Status { 8 | #[default] 9 | Writing, 10 | Finished, 11 | } 12 | 13 | impl std::fmt::Display for Status { 14 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 15 | write!(f, "{:?}", self) 16 | } 17 | } 18 | 19 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 20 | #[db(table = subjects, pk = id, del_field = is_del)] 21 | pub struct Subject { 22 | #[db(find)] 23 | #[db(skip_update)] 24 | pub id: String, 25 | 26 | #[db(list_opt)] 27 | #[db(list_opt_like)] 28 | pub name: String, 29 | 30 | #[db(find)] 31 | #[db(exists)] 32 | #[db(list_opt)] 33 | #[db(list_opt_like)] 34 | pub slug: String, 35 | 36 | pub summary: String, 37 | 38 | #[db(find_opt)] 39 | #[db(list_opt)] 40 | pub is_del: bool, 41 | pub cover: String, 42 | 43 | #[db(list_opt)] 44 | pub status: Status, 45 | 46 | pub price: Decimal, 47 | pub pin: i32, 48 | } 49 | -------------------------------------------------------------------------------- /src/model/tag.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::pagination::Paginate; 5 | 6 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 7 | #[db(table = tags, pk = id)] 8 | pub struct Tag { 9 | #[db(skip_update)] 10 | #[db(find_opt)] 11 | pub id: String, 12 | 13 | #[db(exists)] 14 | #[db(find_opt)] 15 | #[db(list_opt)] 16 | #[db(list_opt_like)] 17 | pub name: String, 18 | 19 | #[db(find_opt)] 20 | #[db(list_opt)] 21 | pub is_del: bool, 22 | } 23 | 24 | #[derive(Debug, Default, Deserialize, Serialize)] 25 | pub struct TagWithTopicCount { 26 | #[serde(flatten)] 27 | pub tag: Tag, 28 | pub topic_count: i64, 29 | } 30 | 31 | #[derive(Debug, Default, Deserialize, Serialize)] 32 | pub struct TagWithTopicListAndCount { 33 | pub tag_with_topic_count: TagWithTopicCount, 34 | pub topic_paginate: Paginate, 35 | } 36 | -------------------------------------------------------------------------------- /src/model/tag_views.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/model/topic.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 6 | #[db(table = topics, pk = id)] 7 | pub struct Topic { 8 | #[db(find_opt)] 9 | #[db(skip_update)] 10 | pub id: String, 11 | 12 | pub title: String, 13 | 14 | #[db(find_opt)] 15 | pub subject_id: String, 16 | 17 | #[db(find_opt)] 18 | pub slug: String, 19 | 20 | pub summary: String, 21 | pub author: String, 22 | pub src: String, 23 | pub hit: i64, 24 | pub dateline: DateTime, 25 | pub try_readable: bool, 26 | 27 | #[db(list_opt)] 28 | pub is_del: bool, 29 | pub cover: String, 30 | pub md: String, 31 | 32 | pub pin: i32, 33 | } 34 | 35 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 36 | #[db(table = topic_sections, pk = id)] 37 | pub struct TopicSection { 38 | #[db(find)] 39 | #[db(skip_update)] 40 | pub id: String, 41 | 42 | #[db(list_opt)] 43 | pub topic_id: String, 44 | 45 | pub sort: i32, 46 | 47 | pub hash: String, 48 | pub content: String, 49 | } 50 | -------------------------------------------------------------------------------- /src/model/topic_tag.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 5 | #[db(table = topic_tags, pk = id)] 6 | pub struct TopicTag { 7 | #[db(skip_update)] 8 | #[db(find_opt)] 9 | pub id: String, 10 | 11 | #[db(find_opt)] 12 | #[db(list_opt)] 13 | pub topic_id: String, 14 | 15 | #[db(find_opt)] 16 | #[db(list_opt)] 17 | pub tag_id: String, 18 | } 19 | 20 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 21 | #[db(table = v_topic_tag_with_tags, pk = id, is_view)] 22 | pub struct VTopicTagWithTag { 23 | // tag 24 | /// 标签ID 25 | #[db(find_opt)] 26 | pub id: String, 27 | 28 | /// 标签名称 29 | #[db(find_opt)] 30 | #[db(list_opt)] 31 | pub name: String, 32 | 33 | /// 标签是否删除 34 | #[db(find_opt)] 35 | #[db(list_opt)] 36 | pub is_del: bool, 37 | 38 | // topic_tags 39 | /// 关联表ID 40 | pub topic_tag_id: String, 41 | 42 | /// 文章ID 43 | #[db(list)] 44 | pub topic_id: String, 45 | } 46 | -------------------------------------------------------------------------------- /src/model/topic_views.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use rust_decimal::Decimal; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::config; 7 | 8 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db)] 9 | #[db(table = v_topic_subjects, pk = id)] 10 | pub struct VTopicSubject { 11 | #[db(find_opt)] 12 | pub id: String, 13 | 14 | #[db(list_opt)] 15 | #[db(list_opt_like)] 16 | pub title: String, 17 | 18 | #[db(find_opt)] 19 | #[db(list_opt)] 20 | pub subject_id: String, 21 | 22 | #[db(find_opt)] 23 | #[db(list_opt)] 24 | pub slug: String, 25 | 26 | pub summary: String, 27 | pub author: String, 28 | pub src: String, 29 | pub hit: i64, 30 | 31 | #[db(list_opt)] 32 | #[db(list_opt_between)] 33 | pub dateline: DateTime, 34 | pub try_readable: bool, 35 | 36 | #[db(find_opt)] 37 | #[db(list_opt)] 38 | pub is_del: bool, 39 | pub cover: String, 40 | pub md: String, 41 | pub pin: i32, 42 | 43 | // subject 44 | pub name: String, 45 | #[db(find_opt)] 46 | #[db(list_opt)] 47 | pub subject_slug: String, 48 | pub subject_summary: String, 49 | 50 | #[db(find_opt)] 51 | #[db(list_opt)] 52 | pub subject_is_del: bool, 53 | 54 | pub subject_cover: String, 55 | #[db(list_opt)] 56 | pub status: super::subject::Status, 57 | pub price: Decimal, 58 | pub subject_pin: i32, 59 | } 60 | 61 | #[derive(Debug, Default, Deserialize, Serialize)] 62 | pub struct TopicSubjectWithTags { 63 | #[serde(flatten)] 64 | pub topic_subjects: VTopicSubject, 65 | pub tags: Vec, 66 | } 67 | 68 | #[derive(Debug, Default, Deserialize, Serialize)] 69 | pub struct TopicSubjectWithTagsAndSections { 70 | #[serde(flatten)] 71 | pub topic_subject_with_tags: TopicSubjectWithTags, 72 | pub sections: Vec, 73 | } 74 | 75 | #[derive(Debug, Default, Deserialize, Serialize)] 76 | pub struct TopicProctedMeta { 77 | pub ids: Vec, 78 | pub catpcha: config::CaptchaKind, 79 | } 80 | 81 | #[derive(Debug, Default, Deserialize, Serialize)] 82 | pub struct TopicSubjectWithTagsAndProctedSections { 83 | #[serde(flatten)] 84 | pub topic_subject_with_tags_and_sections: TopicSubjectWithTagsAndSections, 85 | pub protected: TopicProctedMeta, 86 | pub need_buy: bool, 87 | } 88 | #[derive(Debug, Default, Deserialize, Serialize)] 89 | pub struct TopicSubjectWithTagsAndProctedSectionsAndNeedLogin { 90 | #[serde(flatten)] 91 | pub topic_subject_with_tags_and_procted_sections: TopicSubjectWithTagsAndProctedSections, 92 | pub need_login: bool, 93 | } 94 | -------------------------------------------------------------------------------- /src/model/user.rs: -------------------------------------------------------------------------------- 1 | use axum_rs_derive::Db; 2 | use chrono::{DateTime, Local}; 3 | use rust_decimal::Decimal; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{interfaces, utils, Result}; 7 | 8 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type, Clone)] 9 | #[sqlx(type_name = "user_status")] 10 | pub enum Status { 11 | #[default] 12 | Pending, 13 | Actived, 14 | Freezed, 15 | } 16 | 17 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::Type, Clone)] 18 | #[sqlx(type_name = "user_kind")] 19 | pub enum Kind { 20 | #[default] 21 | Normal, 22 | Subscriber, 23 | YearlySubscriber, 24 | } 25 | 26 | impl std::fmt::Display for Status { 27 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 | write!(f, "{:?}", self) 29 | } 30 | } 31 | impl std::fmt::Display for Kind { 32 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 33 | write!(f, "{:?}", self) 34 | } 35 | } 36 | 37 | #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, Db, Clone)] 38 | #[db(table = users, pk = id)] 39 | pub struct User { 40 | #[db(find)] 41 | #[db(skip_update)] 42 | pub id: String, 43 | 44 | #[db(find)] 45 | #[db(exists)] 46 | #[db(list_opt)] 47 | #[db(list_opt_like)] 48 | pub email: String, 49 | 50 | #[db(exists)] 51 | #[db(list_opt)] 52 | #[db(list_opt_like)] 53 | pub nickname: String, 54 | 55 | #[serde(skip)] 56 | pub password: String, 57 | 58 | #[db(find_opt)] 59 | #[db(list_opt)] 60 | pub status: Status, 61 | 62 | #[db(skip_update)] 63 | pub dateline: DateTime, 64 | 65 | #[db(list_opt)] 66 | pub kind: Kind, 67 | pub sub_exp: DateTime, 68 | pub points: Decimal, 69 | pub allow_device_num: i16, 70 | pub session_exp: i16, 71 | } 72 | 73 | impl interfaces::AsAuth for User {} 74 | 75 | pub struct UserBuilder(User); 76 | impl UserBuilder { 77 | pub fn new(email: String, nickname: String, password: String) -> Self { 78 | Self(User { 79 | email, 80 | nickname, 81 | password, 82 | ..Default::default() 83 | }) 84 | } 85 | 86 | pub fn id(self, id: String) -> Self { 87 | Self(User { id, ..self.0 }) 88 | } 89 | 90 | pub fn status(self, status: Status) -> Self { 91 | Self(User { status, ..self.0 }) 92 | } 93 | 94 | pub fn kind(self, kind: Kind) -> Self { 95 | Self(User { kind, ..self.0 }) 96 | } 97 | 98 | pub fn sub_exp(self, sub_exp: DateTime) -> Self { 99 | Self(User { sub_exp, ..self.0 }) 100 | } 101 | 102 | pub fn dateline(self, dateline: DateTime) -> Self { 103 | Self(User { dateline, ..self.0 }) 104 | } 105 | pub fn dateline_now(self) -> Self { 106 | self.dateline(Local::now()) 107 | } 108 | 109 | pub fn points(self, points: Decimal) -> Self { 110 | Self(User { points, ..self.0 }) 111 | } 112 | pub fn allow_device_num(self, allow_device_num: i16) -> Self { 113 | Self(User { 114 | allow_device_num, 115 | ..self.0 116 | }) 117 | } 118 | pub fn session_exp(self, session_exp: i16) -> Self { 119 | Self(User { 120 | session_exp, 121 | ..self.0 122 | }) 123 | } 124 | 125 | pub fn build(self) -> Result { 126 | let password = utils::password::hash(&self.0.password)?; 127 | Ok(User { password, ..self.0 }) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/resp/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::Json; 2 | use serde::Serialize; 3 | 4 | pub mod subject; 5 | 6 | use crate::{interfaces::AsAuth, Error}; 7 | 8 | #[derive(Serialize)] 9 | pub struct Resp { 10 | pub code: i32, 11 | pub msg: String, 12 | pub data: T, 13 | } 14 | 15 | impl Resp { 16 | pub fn new(code: i32, msg: impl ToString, data: T) -> Self { 17 | Self { 18 | code, 19 | msg: msg.to_string(), 20 | data, 21 | } 22 | } 23 | 24 | pub fn ok(data: T) -> Self { 25 | Self::new(0, "OK", data) 26 | } 27 | 28 | pub fn to_json(self) -> Json { 29 | Json(self) 30 | } 31 | } 32 | 33 | impl Resp<()> { 34 | pub fn empty_ok() -> Self { 35 | Self::ok(()) 36 | } 37 | pub fn err(e: Error) -> Self { 38 | Self::new(-1, e, ()) 39 | } 40 | } 41 | 42 | #[derive(Serialize)] 43 | pub struct IDResp { 44 | pub id: String, 45 | } 46 | 47 | #[derive(Serialize)] 48 | pub struct AffResp { 49 | pub aff: u64, 50 | } 51 | 52 | #[derive(Serialize)] 53 | pub struct AuthResp { 54 | pub user: T, 55 | pub token: String, 56 | pub expire_time: chrono::DateTime, 57 | } 58 | 59 | pub type JsonResp = Json>; 60 | pub type JsonIDResp = JsonResp; 61 | pub type JsonAffResp = JsonResp; 62 | 63 | pub fn ok(data: T) -> JsonResp { 64 | Resp::ok(data).to_json() 65 | } 66 | 67 | pub fn err(e: Error) -> JsonResp<()> { 68 | Resp::err(e).to_json() 69 | } 70 | -------------------------------------------------------------------------------- /src/resp/subject.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | use crate::model; 4 | 5 | #[derive(Serialize)] 6 | pub struct Detail { 7 | pub subject: model::subject::Subject, 8 | pub topic_list: Vec, 9 | } 10 | -------------------------------------------------------------------------------- /src/service/activation_code.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Duration, Local}; 2 | use sqlx::{PgExecutor, PgPool}; 3 | 4 | use crate::{model, utils, Error, Result}; 5 | 6 | pub async fn get<'a>( 7 | c: impl PgExecutor<'a>, 8 | email: &str, 9 | kind: model::activation_code::Kind, 10 | code: Option, 11 | ) -> sqlx::Result> { 12 | let expired = Local::now() + Duration::minutes(5); 13 | let m = model::activation_code::ActivationCode::find( 14 | c, 15 | &model::activation_code::ActivationCodeFindFilter { 16 | id: None, 17 | email: Some(email.to_string()), 18 | kind: Some(kind), 19 | code, 20 | }, 21 | ) 22 | .await?; 23 | 24 | if let Some(m) = m { 25 | if expired >= m.expire_time { 26 | return Ok(Some(m)); 27 | } 28 | } 29 | Ok(None) 30 | } 31 | 32 | pub async fn exists<'a>( 33 | c: impl PgExecutor<'a>, 34 | email: &str, 35 | kind: &model::activation_code::Kind, 36 | ) -> sqlx::Result { 37 | let count: (i64,) = sqlx::query_as( 38 | "SELECT count(*) FROM activation_codes WHERE email=$1 AND kind=$2 AND expire_time>=$3", 39 | ) 40 | .bind(email) 41 | .bind(kind) 42 | .bind(Local::now()) 43 | .fetch_one(c) 44 | .await?; 45 | Ok(count.0 > 0) 46 | } 47 | 48 | pub async fn add( 49 | p: &PgPool, 50 | m: model::activation_code::ActivationCode, 51 | ) -> Result { 52 | let mut tx = p.begin().await.map_err(Error::from)?; 53 | 54 | let exists = match exists(&mut *tx, &m.email, &m.kind).await { 55 | Ok(v) => v, 56 | Err(e) => { 57 | tx.rollback().await.map_err(Error::from)?; 58 | return Err(e.into()); 59 | } 60 | }; 61 | 62 | if exists { 63 | return Err(Error::new("请求过于频繁,请稍后再试")); 64 | } 65 | 66 | let id = utils::id::new(); 67 | let expire_time = m.dateline + Duration::minutes(5); 68 | let m = model::activation_code::ActivationCode { 69 | id, 70 | expire_time, 71 | ..m 72 | }; 73 | 74 | if let Err(e) = m.insert(&mut *tx).await { 75 | tx.rollback().await.map_err(Error::from)?; 76 | return Err(e.into()); 77 | } 78 | 79 | tx.commit().await.map_err(Error::from)?; 80 | Ok(m) 81 | } 82 | -------------------------------------------------------------------------------- /src/service/admin.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use sqlx::PgPool; 3 | 4 | use crate::{model, utils, Error, Result}; 5 | 6 | pub async fn add(p: &PgPool, m: model::admin::Admin) -> Result { 7 | let id = utils::id::new(); 8 | let m = model::admin::Admin { id, ..m }; 9 | 10 | let mut tx = p.begin().await.map_err(Error::from)?; 11 | 12 | let exists = match model::admin::Admin::username_is_exists(&mut *tx, &m.username, None).await { 13 | Ok(v) => v, 14 | Err(e) => { 15 | tx.rollback().await.map_err(Error::from)?; 16 | return Err(e.into()); 17 | } 18 | }; 19 | 20 | if exists { 21 | return Err(anyhow!("管理员已存在").into()); 22 | } 23 | 24 | if let Err(e) = m.insert(&mut *tx).await { 25 | tx.rollback().await.map_err(Error::from)?; 26 | return Err(e.into()); 27 | } 28 | 29 | tx.commit().await.map_err(Error::from)?; 30 | 31 | Ok(m) 32 | } 33 | #[cfg(test)] 34 | mod test { 35 | use sqlx::{postgres::PgPoolOptions, PgPool, Result}; 36 | 37 | use crate::{model, utils}; 38 | 39 | async fn get_pool() -> Result { 40 | let dsn = std::env::var("DB_DSN").unwrap(); 41 | PgPoolOptions::new().max_connections(1).connect(&dsn).await 42 | } 43 | 44 | #[tokio::test] 45 | async fn test_add_admin() { 46 | let p = get_pool().await.unwrap(); 47 | let username = "root".to_string(); 48 | let password = utils::password::hash("axum.rs").unwrap(); 49 | println!("password len: {}", password.len()); 50 | let m = model::admin::Admin { 51 | username, 52 | password, 53 | ..Default::default() 54 | }; 55 | let m = super::add(&p, m).await.unwrap(); 56 | assert_eq!(m.id.is_empty(), false); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod activation_code; 2 | pub mod admin; 3 | pub mod order; 4 | pub mod pay; 5 | pub mod promotion; 6 | pub mod subject; 7 | pub mod tag; 8 | pub mod topic; 9 | pub mod topic_section; 10 | pub mod topic_tag; 11 | pub mod user; 12 | 13 | use sqlx::{Postgres, Transaction}; 14 | pub type Tx<'a> = Transaction<'a, Postgres>; 15 | -------------------------------------------------------------------------------- /src/service/order.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use rust_decimal::Decimal; 4 | use sqlx::PgExecutor; 5 | 6 | use crate::{ 7 | config, 8 | model::{self, currency::Currency}, 9 | utils, Error, Result, 10 | }; 11 | 12 | use super::Tx; 13 | 14 | /// 验证订单金额 15 | pub async fn valid_amount<'a>( 16 | e: impl PgExecutor<'a>, 17 | id: Option, 18 | amount: &Decimal, 19 | currency: &Currency, 20 | cfg: &config::CurrencyConfig, 21 | order: Option, 22 | ) -> Result<()> { 23 | let m = if order.is_none() { 24 | if id.is_none() { 25 | return Err(Error::new("缺少订单id")); 26 | } 27 | match model::order::Order::find( 28 | e, 29 | &model::order::OrderFindFilter { 30 | id, 31 | user_id: None, 32 | status: None, 33 | }, 34 | ) 35 | .await 36 | { 37 | Ok(v) => match v { 38 | Some(v) => v, 39 | None => return Err(Error::new("不存在的订单")), 40 | }, 41 | Err(e) => return Err(Error::from(e)), 42 | } 43 | } else { 44 | order.unwrap() 45 | }; 46 | 47 | let expected_amount = match currency { 48 | Currency::USDT => &m.amount, 49 | Currency::TRX => &(&m.amount * &cfg.trx_rate), 50 | Currency::CNY => &(&m.amount * &cfg.cny_rate), 51 | Currency::PNT => &(&m.amount * &cfg.pointer_rate), 52 | }; 53 | 54 | if expected_amount != amount { 55 | return Err(Error::new("金额不匹配")); 56 | } 57 | 58 | Ok(()) 59 | } 60 | 61 | pub async fn update_status( 62 | e: impl PgExecutor<'_>, 63 | id: &str, 64 | status: &model::order::Status, 65 | pre_state: Option<&model::order::Status>, 66 | ) -> sqlx::Result { 67 | let mut q = sqlx::QueryBuilder::new("UPDATE orders SET status = "); 68 | q.push_bind(status); 69 | 70 | q.push(" WHERE id=").push_bind(id); 71 | 72 | if let Some(pre_state) = pre_state { 73 | q.push(" AND status=").push_bind(pre_state); 74 | } 75 | 76 | let aff = q.build().execute(e).await?.rows_affected(); 77 | Ok(aff) 78 | } 79 | 80 | /// 更新已购服务 81 | /// ⚠️请在外部回滚/提交事务 82 | pub async fn update_purchased_service( 83 | tx: &mut Tx<'_>, 84 | order_id: &str, 85 | user_id: &str, 86 | ) -> Result { 87 | // 订单 88 | let order = match model::order::Order::find( 89 | &mut **tx, 90 | &model::order::OrderFindFilter { 91 | id: Some(order_id.into()), 92 | user_id: Some(user_id.into()), 93 | status: Some(model::order::Status::Finished), 94 | }, 95 | ) 96 | .await 97 | { 98 | Ok(v) => match v { 99 | Some(v) => v, 100 | None => return Err(Error::new("订单不存在")), 101 | }, 102 | Err(e) => return Err(Error::from(e)), 103 | }; 104 | // 购买的项目 105 | let snap_list = order 106 | .to_snapshot() 107 | .into_iter() 108 | .map(|s| s.service) 109 | .collect::>(); 110 | for oss in snap_list { 111 | if oss.service.is_subject { 112 | // 专题 113 | continue; 114 | } else { 115 | // 更新用户订阅 116 | super::user::update_subscribe(tx, user_id, oss.service.duration, oss.num).await?; 117 | } 118 | } 119 | 120 | Ok(0) 121 | } 122 | 123 | pub async fn purchased_services( 124 | e: impl PgExecutor<'_>, 125 | user_id: &str, 126 | subject_ids: &[&str], 127 | ) -> Result> { 128 | let order_list = model::order::Order::list_all( 129 | e, 130 | &model::order::OrderListAllFilter { 131 | limit: None, 132 | order: Some("id ASC".into()), 133 | user_id: Some(user_id.into()), 134 | status: Some(model::order::Status::Finished), 135 | }, 136 | ) 137 | .await?; 138 | 139 | let mut ps_list = HashMap::with_capacity(subject_ids.len()); 140 | 141 | for id in subject_ids { 142 | ps_list.insert(id.to_string(), false); 143 | } 144 | 145 | for o in order_list { 146 | let snap_list = o.to_snapshot(); 147 | for oss in snap_list { 148 | if oss.service.service.is_subject { 149 | let is_purchased = 150 | utils::vec::is_in(subject_ids, &oss.service.service.target_id.as_str()); 151 | ps_list 152 | .entry(oss.service.service.target_id) 153 | .and_modify(|v| *v = is_purchased); 154 | } 155 | } 156 | } 157 | 158 | Ok(ps_list) 159 | } 160 | 161 | pub async fn is_a_purchased_service( 162 | e: impl PgExecutor<'_>, 163 | user_id: &str, 164 | subject_id: &str, 165 | ) -> Result { 166 | let psl = purchased_services(e, user_id, &[subject_id]).await?; 167 | let r = match psl.get(subject_id) { 168 | Some(v) => *v, 169 | None => false, 170 | }; 171 | Ok(r) 172 | } 173 | -------------------------------------------------------------------------------- /src/service/pay.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use sqlx::{PgExecutor, PgPool}; 3 | 4 | use crate::{config, model, tron, utils, Error, Result}; 5 | 6 | pub async fn create( 7 | p: &PgPool, 8 | m: model::pay::Pay, 9 | cfg: &config::CurrencyConfig, 10 | re_pay: bool, 11 | ) -> Result { 12 | let mut tx = p.begin().await?; 13 | if re_pay { 14 | if let Err(e) = sqlx::query("DELETE FROM pays WHERE order_id = $1") 15 | .bind(&m.order_id) 16 | .execute(&mut *tx) 17 | .await 18 | { 19 | tx.rollback().await.map_err(Error::from)?; 20 | return Err(Error::from(e)); 21 | } 22 | } 23 | let order_is_exists = 24 | match model::pay::Pay::order_id_is_exists(&mut *tx, &m.order_id, None).await { 25 | Ok(v) => v, 26 | Err(e) => { 27 | tx.rollback().await.map_err(Error::from)?; 28 | return Err(Error::from(e)); 29 | } 30 | }; 31 | if order_is_exists { 32 | return Err(Error::new("订单已存在支付记录")); 33 | } 34 | 35 | if let Err(e) = super::order::valid_amount( 36 | &mut *tx, 37 | Some(m.order_id.clone()), 38 | &m.amount, 39 | &m.currency, 40 | cfg, 41 | None, 42 | ) 43 | .await 44 | { 45 | tx.rollback().await.map_err(Error::from)?; 46 | return Err(e); 47 | } 48 | 49 | let id = utils::id::new(); 50 | let m = model::pay::Pay { 51 | id, 52 | dateline: Local::now(), 53 | ..m 54 | }; 55 | if let Err(e) = m.insert(&mut *tx).await { 56 | tx.rollback().await.map_err(Error::from)?; 57 | return Err(Error::from(e)); 58 | } 59 | 60 | tx.commit().await?; 61 | Ok(m) 62 | } 63 | 64 | pub async fn find( 65 | e: impl PgExecutor<'_>, 66 | f: &model::pay::PayFindFilter, 67 | ) -> Result> { 68 | model::pay::Pay::find(e, f).await.map_err(Error::from) 69 | } 70 | 71 | pub async fn complete( 72 | // p: &PgPool, 73 | tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, 74 | id: String, // 支付ID 75 | order_id: String, // 订单ID 76 | cfg: &config::Config, // 配置 77 | user_id: Option, // 用户ID 78 | skip_check_confirmed: bool, // 是否跳过区块链确认 79 | ) -> Result { 80 | // 获取支付记录 81 | let pay = match find( 82 | &mut **tx, 83 | &model::pay::PayFindFilter { 84 | id: Some(id), 85 | order_id: Some(order_id), 86 | user_id: user_id.clone(), 87 | }, 88 | ) 89 | .await 90 | { 91 | Ok(v) => match v { 92 | Some(v) => v, 93 | None => return Err(Error::new("支付记录不存在")), 94 | }, 95 | Err(e) => { 96 | return Err(Error::from(e)); 97 | } 98 | }; 99 | // 获取订单 100 | let order = match model::order::Order::find( 101 | &mut **tx, 102 | &model::order::OrderFindFilter { 103 | id: Some(pay.order_id), 104 | user_id, 105 | status: Some(model::order::Status::Pending), 106 | }, 107 | ) 108 | .await 109 | { 110 | Ok(v) => match v { 111 | Some(v) => v, 112 | None => return Err(Error::new("订单不存在")), 113 | }, 114 | Err(e) => { 115 | return Err(Error::from(e)); 116 | } 117 | }; 118 | let order_id = order.id.clone(); 119 | let order_user_id = order.user_id.clone(); 120 | 121 | let mut amount = order.amount.clone(); 122 | // 区块链是否确认 123 | let is_online = matches!(pay.method, model::pay::Method::Online); 124 | if is_online && !skip_check_confirmed { 125 | match &pay.currency { 126 | &model::currency::Currency::USDT => { 127 | let tron_tx = match tron::usdt_tran(&cfg.tron, &pay.tx_id).await { 128 | Ok(v) => v, 129 | Err(e) => { 130 | return Err(e); 131 | } 132 | }; 133 | if !tron_tx.is_valid(&cfg.tron, &order.amount)? { 134 | return Err(Error::new("区块链交易无效")); 135 | } 136 | amount = tron_tx.amount(); 137 | } 138 | &model::currency::Currency::TRX => { 139 | let tron_tx = match tron::trx_tran(&cfg.tron, &pay.tx_id).await { 140 | Ok(v) => v, 141 | Err(e) => { 142 | return Err(e); 143 | } 144 | }; 145 | if !tron_tx.is_valid(&cfg.tron, &(&order.amount * &cfg.currency.trx_rate))? { 146 | return Err(Error::new("区块链交易无效")); 147 | } 148 | amount = tron_tx.amount(); 149 | } 150 | _ => { 151 | unreachable!() 152 | } 153 | } 154 | } 155 | 156 | // 校验金额 区块链->订单 157 | if let Err(e) = super::order::valid_amount( 158 | &mut **tx, 159 | None, 160 | &amount, 161 | &pay.currency, 162 | &cfg.currency, 163 | Some(order), 164 | ) 165 | .await 166 | { 167 | return Err(e); 168 | } 169 | 170 | // 订单状态 171 | if let Err(e) = super::order::update_status( 172 | &mut **tx, 173 | &order_id, 174 | &model::order::Status::Finished, 175 | Some(&model::order::Status::Pending), 176 | ) 177 | .await 178 | { 179 | return Err(Error::from(e)); 180 | } 181 | // 支付状态 182 | if let Err(e) = update_status( 183 | &mut **tx, 184 | &pay.id, 185 | &model::pay::Status::Success, 186 | Some(&model::pay::Status::Pending), 187 | ) 188 | .await 189 | { 190 | return Err(Error::from(e)); 191 | } 192 | 193 | // 已购服务 194 | if let Err(e) = super::order::update_purchased_service(tx, &order_id, &order_user_id).await { 195 | return Err(Error::from(e)); 196 | } 197 | 198 | Ok(0) 199 | } 200 | 201 | pub async fn update_status( 202 | e: impl PgExecutor<'_>, 203 | id: &str, 204 | status: &model::pay::Status, 205 | pre_state: Option<&model::pay::Status>, 206 | ) -> sqlx::Result { 207 | let mut q = sqlx::QueryBuilder::new("UPDATE pays SET status = "); 208 | q.push_bind(status); 209 | 210 | q.push(" WHERE id=").push_bind(id); 211 | 212 | if let Some(pre_state) = pre_state { 213 | q.push(" AND status=").push_bind(pre_state); 214 | } 215 | 216 | let aff = q.build().execute(e).await?.rows_affected(); 217 | Ok(aff) 218 | } 219 | -------------------------------------------------------------------------------- /src/service/promotion.rs: -------------------------------------------------------------------------------- 1 | use sqlx::PgExecutor; 2 | 3 | use crate::{model, Result}; 4 | 5 | type Model = model::promotion::Promotion; 6 | 7 | pub async fn random_take(c: impl PgExecutor<'_>) -> Result> { 8 | let sql = format!( 9 | "SELECT {} FROM {} ORDER BY RANDOM() LIMIT 1", 10 | &Model::fields(), 11 | &Model::table() 12 | ); 13 | let r = sqlx::query_as(&sql).fetch_optional(c).await?; 14 | Ok(r) 15 | } 16 | -------------------------------------------------------------------------------- /src/service/subject.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use sqlx::PgPool; 3 | 4 | use crate::{model::subject, utils, Error, Result}; 5 | 6 | pub async fn add(p: &PgPool, m: subject::Subject) -> Result { 7 | let id = utils::id::new(); 8 | let m = subject::Subject { id, ..m }; 9 | 10 | let mut tx = p.begin().await.map_err(Error::from)?; 11 | 12 | let slug_exists = match subject::Subject::slug_is_exists(&mut *tx, &m.slug, None).await { 13 | Ok(v) => v, 14 | Err(e) => { 15 | tx.rollback().await.map_err(Error::from)?; 16 | return Err(e.into()); 17 | } 18 | }; 19 | 20 | if slug_exists { 21 | return Err(anyhow!("slug已存在").into()); 22 | } 23 | 24 | if let Err(e) = m.insert(&mut *tx).await { 25 | tx.rollback().await.map_err(Error::from)?; 26 | return Err(e.into()); 27 | } 28 | 29 | tx.commit().await.map_err(Error::from)?; 30 | Ok(m) 31 | } 32 | 33 | pub async fn edit(p: &PgPool, m: &subject::Subject) -> Result { 34 | if m.id.is_empty() { 35 | return Err(anyhow!("未指定ID").into()); 36 | } 37 | 38 | let mut tx = p.begin().await.map_err(Error::from)?; 39 | 40 | let slug_exists = 41 | match subject::Subject::slug_is_exists(&mut *tx, &m.slug, Some(m.id.clone())).await { 42 | Ok(v) => v, 43 | Err(e) => { 44 | tx.rollback().await.map_err(Error::from)?; 45 | return Err(e.into()); 46 | } 47 | }; 48 | 49 | if slug_exists { 50 | return Err(anyhow!("slug已存在").into()); 51 | } 52 | 53 | let aff = match m.update(&mut *tx).await { 54 | Ok(v) => v, 55 | Err(e) => { 56 | tx.rollback().await.map_err(Error::from)?; 57 | return Err(e.into()); 58 | } 59 | }; 60 | 61 | tx.commit().await.map_err(Error::from)?; 62 | 63 | Ok(aff) 64 | } 65 | 66 | #[cfg(test)] 67 | mod test { 68 | use sqlx::{postgres::PgPoolOptions, PgPool, Result}; 69 | 70 | use crate::model; 71 | 72 | async fn get_pool() -> Result { 73 | let dsn = std::env::var("DB_DSN").unwrap(); 74 | PgPoolOptions::new().max_connections(1).connect(&dsn).await 75 | } 76 | 77 | #[tokio::test] 78 | async fn test_add_subject() { 79 | let p = get_pool().await.unwrap(); 80 | let m = model::subject::Subject { 81 | name: format!("专题-{}", 0), 82 | slug: format!("subject-{}", 0), 83 | summary: format!("专题摘要-{}", 0), 84 | ..Default::default() 85 | }; 86 | let m = super::add(&p, m).await.unwrap(); 87 | println!("{:?}", m); 88 | } 89 | 90 | #[tokio::test] 91 | async fn test_batch_add_subject() { 92 | let p = get_pool().await.unwrap(); 93 | for i in 1..10 { 94 | let m = model::subject::Subject { 95 | name: format!("专题-{}", i), 96 | slug: format!("subject-{}", i), 97 | summary: format!("专题摘要-{}", i), 98 | ..Default::default() 99 | }; 100 | let m = super::add(&p, m).await.unwrap(); 101 | println!("{:?}", m); 102 | } 103 | } 104 | 105 | #[tokio::test] 106 | async fn test_edit_subject() { 107 | let p = get_pool().await.unwrap(); 108 | let m = model::subject::Subject { 109 | id: "crpnr6kdrfart0b9j8u0".into(), 110 | name: format!("专题-{}", 0), 111 | slug: format!("subject-{}", 0), 112 | summary: format!("专题摘要-{}", 0), 113 | ..Default::default() 114 | }; 115 | let aff = super::edit(&p, &m).await.unwrap(); 116 | assert!(aff > 0); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/service/tag.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use sqlx::{PgExecutor, PgPool}; 3 | 4 | use crate::{model, utils, Error, Result}; 5 | 6 | pub async fn add(p: &PgPool, m: model::tag::Tag) -> Result { 7 | let id = utils::id::new(); 8 | let m = model::tag::Tag { id, ..m }; 9 | 10 | let mut tx = p.begin().await.map_err(Error::from)?; 11 | 12 | let name_exists = match model::tag::Tag::name_is_exists(&mut *tx, &m.name, None).await { 13 | Ok(v) => v, 14 | Err(e) => { 15 | tx.rollback().await.map_err(Error::from)?; 16 | return Err(e.into()); 17 | } 18 | }; 19 | 20 | if name_exists { 21 | return Err(anyhow!("标签已存在").into()); 22 | } 23 | 24 | if let Err(e) = m.insert(&mut *tx).await { 25 | tx.rollback().await.map_err(Error::from)?; 26 | return Err(e.into()); 27 | }; 28 | 29 | tx.commit().await.map_err(Error::from)?; 30 | 31 | Ok(m) 32 | } 33 | 34 | pub async fn edit(p: &PgPool, m: &model::tag::Tag) -> Result { 35 | if m.id.is_empty() { 36 | return Err(anyhow!("未指定ID").into()); 37 | } 38 | 39 | let mut tx = p.begin().await.map_err(Error::from)?; 40 | 41 | let name_exists = 42 | match model::tag::Tag::name_is_exists(&mut *tx, &m.name, Some(m.id.clone())).await { 43 | Ok(v) => v, 44 | Err(e) => { 45 | tx.rollback().await.map_err(Error::from)?; 46 | return Err(e.into()); 47 | } 48 | }; 49 | 50 | if name_exists { 51 | return Err(anyhow!("标签已存在").into()); 52 | } 53 | 54 | let aff = match m.update(&mut *tx).await { 55 | Ok(v) => v, 56 | Err(e) => { 57 | tx.rollback().await.map_err(Error::from)?; 58 | return Err(e.into()); 59 | } 60 | }; 61 | 62 | tx.commit().await.map_err(Error::from)?; 63 | 64 | Ok(aff) 65 | } 66 | 67 | pub async fn find(p: &PgPool, f: &model::tag::TagFindFilter) -> Result> { 68 | model::tag::Tag::find(p, f).await.map_err(Error::from) 69 | } 70 | 71 | pub async fn find_by_id(p: &PgPool, id: &str) -> Result> { 72 | find( 73 | p, 74 | &model::tag::TagFindFilter { 75 | id: Some(id.to_string()), 76 | name: None, 77 | is_del: None, 78 | }, 79 | ) 80 | .await 81 | } 82 | 83 | pub async fn find_by_name(p: &PgPool, name: &str) -> Result> { 84 | find( 85 | p, 86 | &model::tag::TagFindFilter { 87 | id: None, 88 | name: Some(name.to_string()), 89 | is_del: None, 90 | }, 91 | ) 92 | .await 93 | } 94 | 95 | pub async fn insert_if_not_exists<'a>(c: impl PgExecutor<'a>, name: &str) -> sqlx::Result { 96 | let sql = r#"INSERT INTO tags (id, "name", is_del) VALUES ($1, $2, FALSE) ON CONFLICT ("name") DO UPDATE SET is_del=EXCLUDED.is_del RETURNING id"#; 97 | 98 | let gen_id = utils::id::new(); 99 | let id: (String,) = sqlx::query_as(sql) 100 | .bind(&gen_id) 101 | .bind(name) 102 | .fetch_one(c) 103 | .await?; 104 | 105 | Ok(id.0) 106 | } 107 | 108 | pub async fn del(p: &PgPool, id: String) -> Result<(u64, u64)> { 109 | let mut tx = p.begin().await.map_err(Error::from)?; 110 | 111 | let tag_aff = match model::tag::Tag::real_del(&mut *tx, &id).await { 112 | Ok(v) => v, 113 | Err(e) => { 114 | tx.rollback().await.map_err(Error::from)?; 115 | return Err(e.into()); 116 | } 117 | }; 118 | 119 | let clean_topic_tag_aff = match super::topic_tag::clean_by_tag(&mut *tx, &id).await { 120 | Ok(v) => v, 121 | Err(e) => { 122 | tx.rollback().await.map_err(Error::from)?; 123 | return Err(e.into()); 124 | } 125 | }; 126 | 127 | tx.commit().await.map_err(Error::from)?; 128 | 129 | Ok((tag_aff, clean_topic_tag_aff)) 130 | } 131 | 132 | pub async fn find_with_topic_count( 133 | p: &PgPool, 134 | f: Option<&model::tag::TagFindFilter>, 135 | m: Option, 136 | ) -> Result> { 137 | let t = if let Some(v) = m { 138 | v 139 | } else { 140 | let f = match f { 141 | Some(v) => v, 142 | None => return Err(Error::new("参数错误")), 143 | }; 144 | 145 | match model::tag::Tag::find(p, f).await? { 146 | Some(v) => v, 147 | None => return Ok(None), 148 | } 149 | }; 150 | 151 | let c: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM topic_tags WHERE tag_id = $1") 152 | .bind(&t.id) 153 | .fetch_one(&*p) 154 | .await?; 155 | 156 | Ok(Some(model::tag::TagWithTopicCount { 157 | tag: t, 158 | topic_count: c.0, 159 | })) 160 | } 161 | pub async fn list_with_topic_count( 162 | p: &PgPool, 163 | page: u32, 164 | page_size: u32, 165 | ) -> Result> { 166 | let tp = model::tag::Tag::list( 167 | &*p, 168 | &model::tag::TagListFilter { 169 | pq: model::tag::TagPaginateReq { page, page_size }, 170 | order: None, 171 | name: None, 172 | is_del: Some(false), 173 | }, 174 | ) 175 | .await?; 176 | 177 | let mut r = Vec::with_capacity(tp.data.len()); 178 | for t in tp.data { 179 | let m = find_with_topic_count(p, None, Some(t)).await?.unwrap(); 180 | r.push(m); 181 | } 182 | 183 | Ok(model::pagination::Paginate { 184 | total: tp.total, 185 | total_page: tp.total_page, 186 | page: tp.page, 187 | page_size: tp.page_size, 188 | data: r, 189 | }) 190 | } 191 | 192 | #[cfg(test)] 193 | mod test { 194 | use sqlx::{postgres::PgPoolOptions, PgPool, Result}; 195 | 196 | async fn get_pool() -> Result { 197 | let dsn = std::env::var("DB_DSN").unwrap(); 198 | PgPoolOptions::new().max_connections(1).connect(&dsn).await 199 | } 200 | 201 | #[tokio::test] 202 | async fn test_insert_tag_if_not_exists() { 203 | let p = get_pool().await.unwrap(); 204 | 205 | let name = "postgres"; 206 | 207 | let id = super::insert_if_not_exists(&p, name).await.unwrap(); 208 | println!("tag id: {}", id); 209 | } 210 | #[tokio::test] 211 | async fn test_batch_insert_tag_if_not_exists() { 212 | let p = get_pool().await.unwrap(); 213 | 214 | let mut ids = vec![]; 215 | let mut tx = p.begin().await.unwrap(); 216 | for name in &["postgres", "axum", "异步"] { 217 | let id = match super::insert_if_not_exists(&mut *tx, name).await { 218 | Ok(v) => v, 219 | Err(e) => { 220 | tx.rollback().await.unwrap(); 221 | panic!("{}", e); 222 | } 223 | }; 224 | ids.push(id); 225 | } 226 | tx.commit().await.unwrap(); 227 | println!("{:?}", ids); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/service/topic_section.rs: -------------------------------------------------------------------------------- 1 | // use std::borrow::Cow; 2 | 3 | use sqlx::PgExecutor; 4 | 5 | // use crate::model; 6 | 7 | // pub async fn batch_insert<'a>( 8 | // c: impl PgExecutor<'a>, 9 | // tcs: &[&model::topic::TopicSection], 10 | // ) -> sqlx::Result>> { 11 | // if tcs.is_empty() { 12 | // return Ok(vec![]); 13 | // } 14 | // let mut ids = vec![]; 15 | // let mut q = sqlx::QueryBuilder::new("INSERT INTO ") 16 | // Ok(ids) 17 | // } 18 | 19 | /// 根据文章ID清空段落 20 | pub async fn clean<'a>(c: impl PgExecutor<'a>, topic_id: &str) -> sqlx::Result { 21 | let aff = sqlx::query("DELETE FROM topic_sections WHERE topic_id=$1") 22 | .bind(topic_id) 23 | .execute(c) 24 | .await? 25 | .rows_affected(); 26 | Ok(aff) 27 | } 28 | -------------------------------------------------------------------------------- /src/service/topic_tag.rs: -------------------------------------------------------------------------------- 1 | use sqlx::PgExecutor; 2 | 3 | /// 根据文章ID清空标签 4 | pub async fn clean<'a>(c: impl PgExecutor<'a>, topic_id: &str) -> sqlx::Result { 5 | let aff = sqlx::query("DELETE FROM topic_tags WHERE topic_id=$1") 6 | .bind(topic_id) 7 | .execute(c) 8 | .await? 9 | .rows_affected(); 10 | Ok(aff) 11 | } 12 | 13 | /// 根据标签ID清空文章 14 | pub async fn clean_by_tag<'a>(c: impl PgExecutor<'a>, tag_id: &str) -> sqlx::Result { 15 | let aff = sqlx::query("DELETE FROM topic_tags WHERE tag_id=$1") 16 | .bind(tag_id) 17 | .execute(c) 18 | .await? 19 | .rows_affected(); 20 | Ok(aff) 21 | } 22 | -------------------------------------------------------------------------------- /src/service/user.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use sqlx::PgPool; 3 | 4 | use crate::{model, utils, Error, Result}; 5 | 6 | use super::Tx; 7 | 8 | pub async fn add(p: &PgPool, user: model::user::User) -> Result { 9 | let id = utils::id::new(); 10 | let user = model::user::User { id, ..user }; 11 | 12 | let mut tx = p.begin().await.map_err(Error::from)?; 13 | 14 | let email_exists = match model::user::User::email_is_exists(&mut *tx, &user.email, None).await { 15 | Ok(v) => v, 16 | Err(e) => { 17 | tx.rollback().await.map_err(Error::from)?; 18 | return Err(e.into()); 19 | } 20 | }; 21 | 22 | if email_exists { 23 | return Err(Error::new("邮箱已存在")); 24 | } 25 | 26 | let nickname_exists = 27 | match model::user::User::nickname_is_exists(&mut *tx, &user.nickname, None).await { 28 | Ok(v) => v, 29 | Err(e) => { 30 | tx.rollback().await.map_err(Error::from)?; 31 | return Err(e.into()); 32 | } 33 | }; 34 | 35 | if nickname_exists { 36 | return Err(Error::new("昵称已存在")); 37 | } 38 | 39 | if let Err(e) = user.insert(&mut *tx).await { 40 | tx.rollback().await.map_err(Error::from)?; 41 | return Err(e.into()); 42 | } 43 | 44 | tx.commit().await.map_err(Error::from)?; 45 | Ok(user) 46 | } 47 | 48 | pub async fn edit(p: &PgPool, user: &model::user::User) -> Result { 49 | if user.id.is_empty() { 50 | return Err(Error::new("未指定ID")); 51 | } 52 | 53 | let mut tx = p.begin().await.map_err(Error::from)?; 54 | 55 | let email_exists = match model::user::User::email_is_exists( 56 | &mut *tx, 57 | &user.email, 58 | Some(user.id.clone()), 59 | ) 60 | .await 61 | { 62 | Ok(v) => v, 63 | Err(e) => { 64 | tx.rollback().await.map_err(Error::from)?; 65 | return Err(e.into()); 66 | } 67 | }; 68 | 69 | if email_exists { 70 | return Err(Error::new("邮箱已存在")); 71 | } 72 | 73 | let nickname_exists = match model::user::User::nickname_is_exists( 74 | &mut *tx, 75 | &user.nickname, 76 | Some(user.id.clone()), 77 | ) 78 | .await 79 | { 80 | Ok(v) => v, 81 | Err(e) => { 82 | tx.rollback().await.map_err(Error::from)?; 83 | return Err(e.into()); 84 | } 85 | }; 86 | 87 | if nickname_exists { 88 | return Err(Error::new("昵称已存在")); 89 | } 90 | 91 | let aff = match user.update(&mut *tx).await { 92 | Ok(v) => v, 93 | Err(e) => { 94 | tx.rollback().await.map_err(Error::from)?; 95 | return Err(e.into()); 96 | } 97 | }; 98 | 99 | tx.commit().await.map_err(Error::from)?; 100 | Ok(aff) 101 | } 102 | 103 | /// 更新订阅时间 104 | pub async fn update_subscribe( 105 | tx: &mut Tx<'_>, 106 | user_id: &str, 107 | duration: i16, 108 | num: i16, 109 | ) -> Result { 110 | let duration = duration * num; 111 | 112 | // 是否年付 113 | let is_yearly = duration >= 365; 114 | 115 | let user = match model::user::User::find( 116 | &mut **tx, 117 | &model::user::UserFindFilter { 118 | by: model::user::UserFindBy::Id(user_id.into()), 119 | status: Some(model::user::Status::Actived), 120 | }, 121 | ) 122 | .await 123 | { 124 | Ok(v) => match v { 125 | Some(v) => v, 126 | None => return Err(Error::new("不存在的用户")), 127 | }, 128 | Err(e) => return Err(Error::from(e)), 129 | }; 130 | 131 | let user = if duration > 0 { 132 | // 当前订阅过期?今天开始 133 | let now = Local::now(); 134 | let start_date = if user.sub_exp <= now { 135 | now 136 | } else { 137 | user.sub_exp 138 | }; 139 | let sub_exp = start_date + chrono::Duration::days(duration as i64); 140 | 141 | let kind = if is_yearly { 142 | model::user::Kind::YearlySubscriber 143 | } else { 144 | model::user::Kind::Subscriber 145 | }; 146 | model::user::User { 147 | sub_exp, 148 | kind, 149 | ..user 150 | } 151 | } else { 152 | model::user::User { 153 | kind: model::user::Kind::Normal, 154 | ..user 155 | } 156 | }; 157 | user.update(&mut **tx).await.map_err(Error::from)?; 158 | Ok(0) 159 | } 160 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use sqlx::PgPool; 4 | 5 | use crate::config::Config; 6 | 7 | pub struct AppState { 8 | pub pool: Arc, 9 | pub cfg: Arc, 10 | } 11 | 12 | pub type ArcAppState = Arc; 13 | -------------------------------------------------------------------------------- /src/tron.rs: -------------------------------------------------------------------------------- 1 | use crate::{config, utils, Error, Result}; 2 | 3 | use rust_decimal::Decimal; 4 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 5 | 6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 7 | pub enum TransactionKind { 8 | #[default] 9 | TRX, 10 | USDT, 11 | } 12 | 13 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 14 | #[serde(rename_all = "camelCase")] 15 | pub struct TransactionBase { 16 | pub contract_ret: String, 17 | pub confirmed: bool, 18 | } 19 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct TransactionTRX { 22 | #[serde(flatten)] 23 | pub base: TransactionBase, 24 | pub contract_data: ContractData, 25 | } 26 | 27 | impl TransactionTRX { 28 | pub fn is_confirmed(&self) -> bool { 29 | self.base.contract_ret == "SUCCESS" && self.base.confirmed 30 | } 31 | 32 | pub fn amount(&self) -> Decimal { 33 | Decimal::from_i128_with_scale(self.contract_data.amount as i128, 0) 34 | / Decimal::from_i128_with_scale(1000000, 0) 35 | } 36 | 37 | pub fn is_to_my(&self, addr: &str) -> bool { 38 | self.contract_data.to_address == addr 39 | } 40 | 41 | pub fn is_valid(&self, cfg: &config::TronConfig, amount: &Decimal) -> Result { 42 | if !self.is_confirmed() { 43 | return Err(Error::new("交易未确认")); 44 | } 45 | 46 | if !self.is_to_my(&cfg.wallet) { 47 | return Err(Error::new("收款地址错误")); 48 | } 49 | 50 | if &self.amount() != amount { 51 | return Err(Error::new("金额错误")); 52 | } 53 | Ok(true) 54 | } 55 | } 56 | 57 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 58 | #[serde(rename_all = "camelCase")] 59 | pub struct TransactionTRC20 { 60 | #[serde(flatten)] 61 | pub base: TransactionBase, 62 | pub contract_data: ContractDataTrc20, 63 | #[serde(rename = "trc20TransferInfo")] 64 | pub trc20transfer_info: Vec, 65 | } 66 | 67 | impl TransactionTRC20 { 68 | pub fn is_confirmed(&self, contract_addr: &str) -> bool { 69 | self.base.contract_ret == "SUCCESS" 70 | && self.base.confirmed 71 | && self.trc20transfer_info.len() > 0 72 | && self.contract_data.contract_address == contract_addr 73 | } 74 | 75 | pub fn amount(&self) -> Decimal { 76 | Decimal::from_str_exact(self.trc20transfer_info[0].amount_str.as_str()).unwrap_or_default() 77 | / Decimal::from_i128_with_scale(1000000, 0) 78 | } 79 | 80 | pub fn is_to_my(&self, addr: &str) -> bool { 81 | self.trc20transfer_info[0].to_address == addr 82 | } 83 | 84 | pub fn is_valid(&self, cfg: &config::TronConfig, amount: &Decimal) -> Result { 85 | if !self.is_confirmed(&cfg.usdt_contract_addr) { 86 | return Err(Error::new("交易未确认")); 87 | } 88 | 89 | if !self.is_to_my(&cfg.wallet) { 90 | return Err(Error::new("收款地址错误")); 91 | } 92 | 93 | if &self.amount() != amount { 94 | return Err(Error::new("金额错误")); 95 | } 96 | Ok(true) 97 | } 98 | } 99 | 100 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 101 | #[serde(rename_all = "camelCase")] 102 | pub struct ContractData { 103 | pub amount: i64, 104 | #[serde(rename = "owner_address")] 105 | pub owner_address: String, 106 | #[serde(rename = "to_address")] 107 | pub to_address: String, 108 | } 109 | 110 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 111 | #[serde(rename_all = "camelCase")] 112 | pub struct ContractDataTrc20 { 113 | pub data: String, 114 | #[serde(rename = "owner_address")] 115 | pub owner_address: String, 116 | #[serde(rename = "contract_address")] 117 | pub contract_address: String, 118 | } 119 | 120 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 121 | #[serde(rename_all = "camelCase")] 122 | pub struct Trc20TransferInfo { 123 | #[serde(rename = "icon_url")] 124 | pub icon_url: String, 125 | pub symbol: String, 126 | pub level: String, 127 | #[serde(rename = "to_address")] 128 | pub to_address: String, 129 | #[serde(rename = "contract_address")] 130 | pub contract_address: String, 131 | #[serde(rename = "type")] 132 | pub type_field: String, 133 | pub decimals: i64, 134 | pub name: String, 135 | pub vip: bool, 136 | pub token_type: String, 137 | #[serde(rename = "from_address")] 138 | pub from_address: String, 139 | #[serde(rename = "amount_str")] 140 | pub amount_str: String, 141 | pub status: i64, 142 | } 143 | 144 | async fn _get_transaction_info( 145 | txid: &str, 146 | api_url: &str, 147 | timeout: u8, 148 | ) -> reqwest::Result { 149 | let url = format!("{}/api/transaction-info?hash={}", api_url, txid); 150 | let cli = reqwest::ClientBuilder::new() 151 | .timeout(std::time::Duration::from_secs(timeout as u64)) 152 | .user_agent(utils::user_agent::get()) 153 | .build()?; 154 | cli.get(&url).send().await 155 | } 156 | 157 | pub async fn get_transaction_info( 158 | txid: &str, 159 | api_url: &str, 160 | timeout: u8, 161 | ) -> Result { 162 | let res = _get_transaction_info(txid, api_url, timeout) 163 | .await? 164 | .json::() 165 | .await?; 166 | Ok(res) 167 | } 168 | 169 | pub async fn get_usdt_tran_info( 170 | txid: &str, 171 | api_url: &str, 172 | timeout: u8, 173 | ) -> Result { 174 | get_transaction_info(txid, api_url, timeout).await 175 | } 176 | 177 | pub async fn get_trx_transaction_info( 178 | txid: &str, 179 | api_url: &str, 180 | timeout: u8, 181 | ) -> Result { 182 | get_transaction_info(txid, api_url, timeout).await 183 | } 184 | 185 | pub async fn usdt_tran(cfg: &config::TronConfig, txid: &str) -> Result { 186 | get_usdt_tran_info(txid, &cfg.api_url, cfg.fetch_timeout).await 187 | } 188 | 189 | pub async fn trx_tran(cfg: &config::TronConfig, txid: &str) -> Result { 190 | get_trx_transaction_info(txid, &cfg.api_url, cfg.fetch_timeout).await 191 | } 192 | 193 | #[cfg(test)] 194 | mod tests { 195 | #[tokio::test] 196 | async fn test_tron_get_usdt_transaction() { 197 | let info = super::get_usdt_tran_info( 198 | "4799e4960a90c6e2526f23ad2c5326f9e6a27f5995f0c6b1912be710fb167a32", 199 | "https://nileapi.tronscan.org", 200 | 10, 201 | ) 202 | .await; 203 | println!("{:?}", info); 204 | } 205 | #[tokio::test] 206 | async fn test_tron_get_trx_transaction() { 207 | let info = super::get_trx_transaction_info( 208 | "f430e1f2bc63807319c0512b53405abb314c301e2b634509f2fdc1b1c35bca96", 209 | "https://nileapi.tronscan.org", 210 | 10, 211 | ) 212 | .await; 213 | println!("{:?}", info); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/utils/dt.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; 2 | 3 | use crate::{Error, Result}; 4 | 5 | pub fn naive_to_local(n: &NaiveDateTime) -> Result> { 6 | match Local.from_local_datetime(n) { 7 | chrono::offset::LocalResult::Single(v) => Ok(v), 8 | chrono::offset::LocalResult::Ambiguous(v, _) => Ok(v), 9 | chrono::offset::LocalResult::None => Err(Error::new("无法解析日期时间")), 10 | } 11 | } 12 | pub fn parse(dt_str: &str) -> Result> { 13 | let nd = NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S").map_err(Error::from)?; 14 | naive_to_local(&nd) 15 | } 16 | 17 | pub fn today() -> (DateTime, DateTime) { 18 | let now = Local::now(); 19 | let start = now.format("%Y-%m-%d 00:00:00").to_string(); 20 | let end = now.format("%Y-%m-%d 23:59:59").to_string(); 21 | (parse(&start).unwrap_or(now), parse(&end).unwrap_or(now)) 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/hash.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use sha2::{Digest, Sha256}; 3 | 4 | use crate::{Error, Result}; 5 | 6 | pub fn sha256_with_key(data: &str, key: &str) -> Result { 7 | let data = format!("{}{}", data, key); 8 | let mut hasher = Sha256::new(); 9 | hasher.update(data); 10 | 11 | let hash = hasher.finalize(); 12 | let mut buf = [0u8; 64]; 13 | let hash = base16ct::lower::encode_str(hash.as_slice(), &mut buf) 14 | .map_err(|e| Error::from(anyhow!("{}", e)))?; 15 | Ok(hash.to_string()) 16 | } 17 | 18 | pub fn sha256(data: &str) -> Result { 19 | sha256_with_key(data, "") 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/http.rs: -------------------------------------------------------------------------------- 1 | use axum::http::{ 2 | header::{AsHeaderName, AUTHORIZATION, USER_AGENT}, 3 | HeaderMap, 4 | }; 5 | 6 | pub fn get_header_opt(headers: &HeaderMap, key: impl AsHeaderName) -> Option<&str> { 7 | headers.get(key).and_then(|v| v.to_str().ok()) 8 | } 9 | 10 | pub fn get_user_agent_opt(headers: &HeaderMap) -> Option<&str> { 11 | get_header_opt(headers, USER_AGENT) 12 | } 13 | 14 | pub fn get_user_agent(headers: &HeaderMap) -> &str { 15 | get_user_agent_opt(headers).unwrap_or_default() 16 | } 17 | 18 | pub fn get_ip(headers: &HeaderMap) -> &str { 19 | let cf_connection_ip = get_header_opt(&headers, "CF-CONNECTING-IP").unwrap_or_default(); 20 | let forwarded_for = get_header_opt(&headers, "X-FORWARDED-FOR").unwrap_or_default(); 21 | let real_ip = get_header_opt(&headers, "X-REAL-IP").unwrap_or_default(); 22 | 23 | if !cf_connection_ip.is_empty() { 24 | return cf_connection_ip; 25 | } 26 | 27 | if !forwarded_for.is_empty() { 28 | let forwarded_for_arr = forwarded_for.split(",").collect::>(); 29 | return forwarded_for_arr.get(0).copied().unwrap_or(real_ip); 30 | } 31 | 32 | real_ip 33 | } 34 | 35 | pub fn get_cf_location(headers: &HeaderMap) -> &str { 36 | get_header_opt(headers, "CF-IPCOUNTRY").unwrap_or_default() 37 | } 38 | 39 | pub fn get_auth(headers: &HeaderMap) -> Option<&str> { 40 | get_header_opt(headers, AUTHORIZATION) 41 | } 42 | 43 | pub fn get_auth_token(headers: &HeaderMap) -> Option<&str> { 44 | let v = get_auth(headers); 45 | 46 | if let Some(v) = v { 47 | if let Some(v) = v.strip_prefix("Bearer ") { 48 | return Some(v); 49 | } 50 | } 51 | 52 | None 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/id.rs: -------------------------------------------------------------------------------- 1 | /// 生成新ID 2 | pub fn new() -> String { 3 | xid::new().to_string() 4 | } 5 | 6 | #[cfg(test)] 7 | mod tests { 8 | #[test] 9 | fn test_new_id() { 10 | let id = super::new(); 11 | println!("id: {id}"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/md.rs: -------------------------------------------------------------------------------- 1 | use pulldown_cmark::{html, Options, Parser}; 2 | 3 | fn get_parser(md: &str) -> Parser { 4 | Parser::new_ext(md, Options::all()) 5 | } 6 | pub fn to_html(md: &str) -> String { 7 | let mut out_html = String::new(); 8 | html::push_html(&mut out_html, get_parser(md)); 9 | out_html 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dt; 2 | pub mod hash; 3 | pub mod http; 4 | pub mod id; 5 | pub mod md; 6 | pub mod password; 7 | pub mod session; 8 | pub mod str; 9 | pub mod topic; 10 | pub mod user_agent; 11 | pub mod vec; 12 | -------------------------------------------------------------------------------- /src/utils/password.rs: -------------------------------------------------------------------------------- 1 | use bcrypt::DEFAULT_COST; 2 | 3 | use crate::{Error, Result}; 4 | 5 | pub fn hash(pwd: &str) -> Result { 6 | bcrypt::hash(pwd, DEFAULT_COST).map_err(Error::from) 7 | } 8 | pub fn verify(pwd: &str, hashed_pwd: &str) -> Result { 9 | bcrypt::verify(pwd, hashed_pwd).map_err(Error::from) 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/session.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Local}; 2 | 3 | use crate::Result; 4 | 5 | /// 生成令牌 6 | pub fn token(id: &str, key: &str, is_admin: bool) -> Result<(String, DateTime)> { 7 | let now = Local::now(); 8 | let data = format!("{id}-{is_admin}-{now}"); 9 | let t = super::hash::sha256_with_key(&data, key)?; 10 | Ok((t, now)) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/str.rs: -------------------------------------------------------------------------------- 1 | use bitflags::bitflags; 2 | use lazy_static::lazy_static; 3 | use rand::Rng; 4 | 5 | lazy_static! { 6 | static ref UPPERCASE_DICT: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 7 | static ref LOWERCASE_DICT: &'static str = "abcdefghijklmnopqrstuvwxyz"; 8 | static ref NUMBERS_DICT: &'static str = "0123456789"; 9 | } 10 | 11 | bitflags! { 12 | pub struct RandomType:u8 { 13 | const Uppercase = 1; 14 | const Lowercase = 2; 15 | const Number = 4; 16 | const All = 1|2|4; 17 | } 18 | 19 | } 20 | 21 | pub fn rand_dict(ty: RandomType) -> String { 22 | let mut s = String::new(); 23 | if ty.contains(RandomType::Uppercase) { 24 | s.push_str(*UPPERCASE_DICT); 25 | } 26 | if ty.contains(RandomType::Lowercase) { 27 | s.push_str(*LOWERCASE_DICT); 28 | } 29 | if ty.contains(RandomType::Number) { 30 | s.push_str(*NUMBERS_DICT); 31 | } 32 | s 33 | } 34 | 35 | pub fn rand_opt(len: usize, ty: Option) -> String { 36 | let ty = ty.unwrap_or(RandomType::All); 37 | let dict = rand_dict(ty); 38 | let mut s = String::with_capacity(len); 39 | for _ in 0..len { 40 | let idx = rand::rng().random_range(0..dict.len()); 41 | s.push(dict.chars().nth(idx).unwrap_or_default()); 42 | } 43 | s 44 | } 45 | 46 | pub fn rand(len: usize) -> String { 47 | rand_opt(len, None) 48 | } 49 | 50 | pub fn activation_code() -> String { 51 | rand_opt(20, Some(RandomType::All)) 52 | } 53 | 54 | pub fn fixlen(s: &str, len: usize) -> &str { 55 | if utf8_slice::len(s) <= len { 56 | return s; 57 | } 58 | utf8_slice::slice(s, 0, len) 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::RandomType; 64 | 65 | #[test] 66 | fn test_random_type_has() { 67 | let ty = RandomType::All - RandomType::Uppercase | RandomType::Uppercase; 68 | assert!(ty.contains(RandomType::Uppercase)); 69 | } 70 | 71 | #[test] 72 | fn test_rand_activation_code() { 73 | let s = super::activation_code(); 74 | println!("{}", s); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/topic.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use lazy_static::lazy_static; 3 | use scraper::{Html, Selector}; 4 | 5 | use crate::{model, Error, Result}; 6 | 7 | lazy_static! { 8 | static ref DOM_ROOT_ID: &'static str = "__AXUM__"; 9 | static ref ELEMENT_SELECTOR: String = "p,h1,h2,h3,h4,h5,h6,pre,table,blockquote,ul,ol,dl,div" 10 | .split(",") 11 | .collect::>() 12 | .into_iter() 13 | .map(|s| format!("#{} > {}", (*DOM_ROOT_ID), s)) 14 | .collect::>() 15 | .join(","); 16 | } 17 | 18 | pub fn sections( 19 | t: &model::topic::Topic, 20 | hash_secret_key: &str, 21 | ) -> Result> { 22 | let html = super::md::to_html(&t.md); 23 | let secs = html_sections(&html)?; 24 | 25 | let tsc = secs 26 | .into_iter() 27 | .map(|(sort, id, content)| { 28 | let hash = super::hash::sha256_with_key(&content, hash_secret_key).unwrap_or_default(); 29 | model::topic::TopicSection { 30 | id, 31 | topic_id: t.id.clone(), 32 | sort, 33 | hash, 34 | content, 35 | } 36 | }) 37 | .collect::>(); 38 | 39 | Ok(tsc) 40 | } 41 | 42 | pub fn html_sections(html: &str) -> Result> { 43 | let html = format!(r#"
{}
"#, (*DOM_ROOT_ID), html); 44 | let fragment = Html::parse_fragment(&html); 45 | let mut v = vec![]; 46 | 47 | let selector = 48 | Selector::parse(ELEMENT_SELECTOR.as_str()).map_err(|e| Error::from(anyhow!("{:?}", e)))?; 49 | for (idx, con) in fragment.select(&selector).enumerate() { 50 | let id = super::id::new(); 51 | let tag_name = con.value().name(); 52 | let inner_html = con.inner_html(); 53 | let el = format!(r#"<{tag_name} data-section="{id}">{inner_html}"#); 54 | v.push((idx as i32, id, el)); 55 | } 56 | Ok(v) 57 | } 58 | 59 | #[cfg(test)] 60 | mod test { 61 | use crate::{model, utils}; 62 | 63 | fn read_data(filename: &str) -> std::io::Result { 64 | let path = format!("test-sections-data/{}", filename); 65 | std::fs::read_to_string(&path) 66 | } 67 | #[test] 68 | fn test_utils_html_sections() { 69 | let html = read_data("b.txt").unwrap(); 70 | let ss = super::html_sections(&html).unwrap(); 71 | // for (sort, con) in ss { 72 | // println!("{}", &con); 73 | // println!("==={}==", sort); 74 | // } 75 | let data = ss.into_iter().map(|s| s.2).collect::>(); 76 | let data = data.join("\n"); 77 | std::fs::write("/tmp/b.txt", &data).unwrap(); 78 | } 79 | #[test] 80 | fn test_utils_topic_sections() { 81 | let md = read_data("b.md").unwrap(); 82 | let topic = model::topic::Topic { 83 | id: utils::id::new(), 84 | md, 85 | ..Default::default() 86 | }; 87 | let tcs = super::sections(&topic, "").unwrap(); 88 | let content = tcs.iter().map(|tc| tc.content.clone()).collect::>(); 89 | let content = content.join("\n"); 90 | std::fs::write("/tmp/b.txt", &content).unwrap(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/user_agent.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use rand::Rng; 3 | 4 | lazy_static! { 5 | static ref USER_AGENT_LIST: Vec<&'static str> = vec![ 6 | // Firefox @ win11 7 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", 8 | // Opera @ win11 9 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", 10 | // Chrome @ win11 11 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", 12 | // Vivaldi @ win11 13 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Vivaldi/6.9.3447.37" 14 | ]; 15 | } 16 | 17 | pub fn get() -> &'static str { 18 | let idx = rand::rng().random_range(0..USER_AGENT_LIST.len()); 19 | USER_AGENT_LIST[idx] 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/vec.rs: -------------------------------------------------------------------------------- 1 | pub fn is_in(v: &[T], i: &T) -> bool { 2 | v.contains(i) 3 | } 4 | --------------------------------------------------------------------------------