霖雨寺

This commit is contained in:
wenfp
2026-07-27 09:57:35 +08:00
commit 6e531cf7af
57 changed files with 7529 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
# 后端模块说明
## 模块边界
- `main.py`:FastAPI 应用入口,负责注册中间件、健康检查和路由。
- `database.py`:SQLite 数据库路径、建表、轻量迁移和依赖注入。
- `schemas.py`Pydantic 数据模型。
- `routers/auth.py`:登录认证、验证码、Cookie 会话和退出登录接口。
- `routers/users.py`:用户管理接口。
- `routers/todos.py`Todo 管理接口。
- `routers/temples.py`:寺院管理接口。
- `routers/products.py`:商品管理接口,按寺院隔离。
- `routers/orders.py`:订单管理接口,按寺院隔离。
- `routers/payments.py`:微信支付接口,按寺院隔离发起 JSAPI 预下单并处理支付通知。
- `routers/rituals.py`:法事管理接口,按寺院隔离。
- `routers/uploads.py`:图片上传接口,上传到阿里云 OSS。
- `config.py`:项目运行配置,目前预留阿里云 OSS 配置。
- `security.py`:密码哈希、密钥哈希和会话令牌工具。
- `temple_scope.py`:读取 `X-Temple-Id` 并校验寺院存在。
- `wechat_pay.py`:微信支付 API v3 签名、验签、通知解密和请求工具。
## 用户模型
当前用户模型字段:
```text
id 用户 ID
registered_at 注册时间,后端创建时生成
name 姓名
phone 手机号,创建时必填并保持唯一
nickname 昵称
avatar 头像地址
is_admin 是否管理员
total_spent 总消费
```
## 数据库约定
- 开发时优先通过 `DATABASE_PATH` 指定当前用户可写的 SQLite 文件。
- 不使用 `sudo` 启动服务,避免数据库文件被 root 创建。
- 修改表结构时,在 `database.py` 中补充轻量迁移逻辑,兼容已有 SQLite 文件。
- 商品、订单、法事必须带 `temple_id`,接口查询和写入都要通过当前寺院上下文限制数据范围。
- 当前寺院上下文通过请求头 `X-Temple-Id` 传入。
## 认证安全约定
- 登录接口为 `/auth/login`,验证码接口为 `/auth/captcha`,退出接口为 `/auth/logout`,当前用户接口为 `/auth/me`
- 密码使用 bcrypt 加盐哈希存储,不保存明文密码;默认演示账号为 `admin/admin123456``demo/demo123456`
- 登录会话使用服务端 `auth_sessions` 表保存,浏览器只接收 `HttpOnly``Secure``SameSite=Lax` Cookie。
- `AUTH_REQUIRE_HTTPS` 默认开启;本地开发允许 localhost 非 HTTPS,生产环境应使用 HTTPS 或反向代理传递 `X-Forwarded-Proto: https`
- 同一账号或 IP 短时间失败 5 次后要求验证码,10 次后临时锁定账号 15 分钟。
- 登录尝试和登录事件分别写入 `login_attempts``login_events`,用于记录 IP、设备和新环境登录标记;短信/邮箱二次验证和通知通道后续接入。
## 商品模型
商品详情模型包含:
```text
商品名称、商品别称、商品描述、支付按钮名称、随喜按钮名称、排序号、分享时显示参与人数、
商品封面、商品图片、上架时间、开售时间、结束时间、下架时间、结束倒计时、
是否展示到首页、商品规格、商品详情、微信分享配置、功德证书预留、是否自动处理
```
商品规格存储为 JSON 列表,字段为:
```text
名称、单价、库存、排序、是否超度
```
## 上传与 OSS 配置
图片上传接口为 `/uploads/images`,用于商品封面、商品图片等图片资源。OSS 配置预留在 `config.py`,可直接填写或通过同名环境变量覆盖:
```text
OSS_ENDPOINT、OSS_BUCKET_NAME、OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_PUBLIC_BASE_URL、OSS_UPLOAD_PREFIX
```
## 微信支付约定
- 当前接入微信支付 API v3 的 JSAPI 预下单,接口为 `/payments/wechat/jsapi`;支付结果通知接口为 `/payments/wechat/notify`
- 业务订单仍存储在 `orders` 表,支付流水存储在 `payment_transactions` 表,使用 `out_trade_no` 关联微信支付交易。
- 预下单需要前端传入微信用户 `openid`;真正调起 `WeixinJSBridge` 通常在用户端 H5/公众号页面完成。
- 配置通过 `.env` 或环境变量提供:`WECHAT_PAY_APPID``WECHAT_PAY_MCH_ID``WECHAT_PAY_MCH_SERIAL_NO``WECHAT_PAY_API_V3_KEY``WECHAT_PAY_PRIVATE_KEY_PATH``WECHAT_PAY_PUBLIC_KEY_PATH``WECHAT_PAY_NOTIFY_URL`
- 支付通知需要验签并用 APIv3 密钥解密;生产环境必须配置微信支付公钥或平台证书,不应开启 `WECHAT_PAY_SKIP_NOTIFY_VERIFY`
## 验证
后端修改后优先执行:
```bash
.venv/bin/python -m compileall index.py app
```
接口变更后使用 FastAPI `TestClient``/docs` 做最小 CRUD 验证。
## 修改记录
- 2026-07-22:用户模型变更为注册时间、姓名、手机号、昵称、头像、是否管理员、总消费;`database.py` 增加旧表补列和默认值迁移,`schemas.py``routers/users.py` 同步新字段。验证:临时 pycache 编译通过,用户 CRUD 冒烟测试通过,旧表迁移测试通过。
- 2026-07-22`/users` 列表接口新增 `phone` 查询参数,支持按手机号模糊查询用户。验证:后端编译通过,手机号查询接口测试通过。
- 2026-07-22:新增寺院、商品、订单、法事后端模块;`products``orders``ritual_services` 通过 `X-Temple-Id` 做寺院级数据隔离。验证:后端编译通过,寺院隔离接口测试通过。
- 2026-07-23:商品详情模型扩展为完整详情字段,`products` 表增加别称、按钮名称、展示配置、图片、时间、规格 JSON、详情 HTML、微信分享、功德证书预留、自动处理、销量等字段;商品接口同步新模型并继续按 `X-Temple-Id` 隔离。验证:后端编译通过,商品详情 CRUD 测试通过,旧商品表迁移测试通过。
- 2026-07-23:新增 `/uploads/images` 图片上传接口,预留阿里云 OSS 配置并新增 `oss2``python-multipart` 依赖;OSS 未配置时接口返回明确 503 提示。验证:后端编译通过,未配置 OSS 上传接口提示测试通过。
- 2026-07-23:新增登录认证模块,支持服务端动态验证码、bcrypt 密码哈希、失败次数限制、账号临时锁定、HttpOnly/Secure Cookie 会话、退出登录、登录 IP/设备日志和新环境登录标记;`requirements.txt` 新增 `bcrypt`。验证:后端编译通过,运行态登录测试待安装 `bcrypt` 后执行。
- 2026-07-23:商品模型新增 `description` 商品描述字段,后端补充 `products.description` 轻量迁移、Pydantic 模型和 `/products` CRUD 读写。验证:后端编译通过,运行态接口测试待安装 `bcrypt` 后执行。
- 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。
+1
View File
@@ -0,0 +1 @@
+138
View File
@@ -0,0 +1,138 @@
import os
from dataclasses import dataclass
from pathlib import Path
def load_local_env() -> None:
env_path = Path(__file__).parents[1] / ".env"
if not env_path.exists():
return
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
load_local_env()
AUTH_REQUIRE_HTTPS = os.getenv("AUTH_REQUIRE_HTTPS", "1") == "1"
AUTH_ALLOW_INSECURE_LOCAL = os.getenv("AUTH_ALLOW_INSECURE_LOCAL", "1") == "1"
AUTH_COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "1") == "1"
# 阿里云 OSS 配置预留:可以直接在这里填写,也可以用同名环境变量覆盖。
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
OSS_INTERNAL_ENDPOINT = os.getenv("OSS_INTERNAL_ENDPOINT", "")
OSS_USE_INTERNAL = os.getenv("OSS_USE_INTERNAL", "0") == "1"
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
OSS_ACCESS_KEY_ID = os.getenv("OSS_ACCESS_KEY_ID", "")
OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
OSS_PUBLIC_BASE_URL = os.getenv("OSS_PUBLIC_BASE_URL", "")
OSS_UPLOAD_PREFIX = os.getenv("OSS_UPLOAD_PREFIX", "product-images")
WECHAT_PAY_APPID = os.getenv("WECHAT_PAY_APPID", "")
WECHAT_PAY_MCH_ID = os.getenv("WECHAT_PAY_MCH_ID", "")
WECHAT_PAY_MCH_SERIAL_NO = os.getenv("WECHAT_PAY_MCH_SERIAL_NO", "")
WECHAT_PAY_API_V3_KEY = os.getenv("WECHAT_PAY_API_V3_KEY", "")
WECHAT_PAY_PRIVATE_KEY_PATH = os.getenv("WECHAT_PAY_PRIVATE_KEY_PATH", "")
WECHAT_PAY_PUBLIC_KEY_PATH = os.getenv("WECHAT_PAY_PUBLIC_KEY_PATH", "")
WECHAT_PAY_NOTIFY_URL = os.getenv("WECHAT_PAY_NOTIFY_URL", "")
WECHAT_PAY_API_BASE_URL = os.getenv("WECHAT_PAY_API_BASE_URL", "https://api.mch.weixin.qq.com")
WECHAT_PAY_SKIP_NOTIFY_VERIFY = os.getenv("WECHAT_PAY_SKIP_NOTIFY_VERIFY", "0") == "1"
@dataclass(frozen=True)
class OSSSettings:
endpoint: str
internal_endpoint: str
use_internal: bool
bucket_name: str
access_key_id: str
access_key_secret: str
public_base_url: str
upload_prefix: str
@property
def is_configured(self) -> bool:
return all(
[
self.endpoint,
self.bucket_name,
self.access_key_id,
self.access_key_secret,
]
)
@property
def upload_endpoint(self) -> str:
if self.use_internal and self.internal_endpoint:
return self.internal_endpoint
return self.endpoint
oss_settings = OSSSettings(
endpoint=OSS_ENDPOINT,
internal_endpoint=OSS_INTERNAL_ENDPOINT,
use_internal=OSS_USE_INTERNAL,
bucket_name=OSS_BUCKET_NAME,
access_key_id=OSS_ACCESS_KEY_ID,
access_key_secret=OSS_ACCESS_KEY_SECRET,
public_base_url=OSS_PUBLIC_BASE_URL,
upload_prefix=OSS_UPLOAD_PREFIX,
)
@dataclass(frozen=True)
class AuthSettings:
require_https: bool
allow_insecure_local: bool
cookie_secure: bool
auth_settings = AuthSettings(
require_https=AUTH_REQUIRE_HTTPS,
allow_insecure_local=AUTH_ALLOW_INSECURE_LOCAL,
cookie_secure=AUTH_COOKIE_SECURE,
)
@dataclass(frozen=True)
class WeChatPaySettings:
appid: str
mch_id: str
mch_serial_no: str
api_v3_key: str
private_key_path: str
public_key_path: str
notify_url: str
api_base_url: str
skip_notify_verify: bool
@property
def is_configured(self) -> bool:
return all(
[
self.appid,
self.mch_id,
self.mch_serial_no,
self.api_v3_key,
self.private_key_path,
self.notify_url,
]
)
wechat_pay_settings = WeChatPaySettings(
appid=WECHAT_PAY_APPID,
mch_id=WECHAT_PAY_MCH_ID,
mch_serial_no=WECHAT_PAY_MCH_SERIAL_NO,
api_v3_key=WECHAT_PAY_API_V3_KEY,
private_key_path=WECHAT_PAY_PRIVATE_KEY_PATH,
public_key_path=WECHAT_PAY_PUBLIC_KEY_PATH,
notify_url=WECHAT_PAY_NOTIFY_URL,
api_base_url=WECHAT_PAY_API_BASE_URL,
skip_notify_verify=WECHAT_PAY_SKIP_NOTIFY_VERIFY,
)
+452
View File
@@ -0,0 +1,452 @@
import os
import sqlite3
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
from fastapi import Depends, FastAPI
from app.security import hash_password
DATABASE_PATH = Path(os.getenv("DATABASE_PATH", Path(__file__).parents[1] / "app.sqlite3"))
def ensure_user_schema(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL,
phone TEXT,
nickname TEXT,
avatar TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
total_spent REAL NOT NULL DEFAULT 0
)
"""
)
columns = {
row["name"] if isinstance(row, sqlite3.Row) else row[1]
for row in connection.execute("PRAGMA table_info(users)").fetchall()
}
additions = {
"registered_at": "TEXT",
"username": "TEXT",
"password_hash": "TEXT",
"failed_login_count": "INTEGER NOT NULL DEFAULT 0",
"locked_until": "TEXT",
"name": "TEXT",
"phone": "TEXT",
"nickname": "TEXT",
"avatar": "TEXT",
"is_admin": "INTEGER NOT NULL DEFAULT 0",
"total_spent": "REAL NOT NULL DEFAULT 0",
}
for column_name, definition in additions.items():
if column_name not in columns:
connection.execute(f"ALTER TABLE users ADD COLUMN {column_name} {definition}")
columns = {
row["name"] if isinstance(row, sqlite3.Row) else row[1]
for row in connection.execute("PRAGMA table_info(users)").fetchall()
}
connection.execute(
"UPDATE users SET registered_at = CURRENT_TIMESTAMP WHERE registered_at IS NULL OR registered_at = ''"
)
if "full_name" in columns:
connection.execute("UPDATE users SET name = full_name WHERE (name IS NULL OR name = '') AND full_name IS NOT NULL")
if "username" in columns:
connection.execute("UPDATE users SET name = username WHERE name IS NULL OR name = ''")
connection.execute("UPDATE users SET name = 'Unknown User' WHERE name IS NULL OR name = ''")
connection.execute("UPDATE users SET is_admin = 0 WHERE is_admin IS NULL")
connection.execute("UPDATE users SET total_spent = 0 WHERE total_spent IS NULL")
connection.execute("UPDATE users SET failed_login_count = 0 WHERE failed_login_count IS NULL")
connection.execute("UPDATE users SET username = lower(nickname) WHERE (username IS NULL OR username = '') AND nickname IS NOT NULL AND nickname != ''")
connection.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique
ON users(phone)
WHERE phone IS NOT NULL AND phone != ''
"""
)
connection.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_unique
ON users(username)
WHERE username IS NOT NULL AND username != ''
"""
)
def set_user_password(connection: sqlite3.Connection, username: str, password: str) -> None:
connection.execute(
"""
UPDATE users
SET username = ?, password_hash = ?
WHERE username = ? OR nickname = ? OR phone = ?
""",
(username, hash_password(password), username, username, username),
)
def ensure_default_user_credentials(connection: sqlite3.Connection) -> None:
admin = connection.execute(
"SELECT id, password_hash FROM users WHERE username = 'admin' OR nickname = 'admin' OR phone = '13800000000'"
).fetchone()
if admin is not None and not str(admin["password_hash"] or "").startswith("$2"):
set_user_password(connection, "admin", "admin123456")
demo = connection.execute(
"SELECT id, password_hash FROM users WHERE username = 'demo' OR nickname = 'demo' OR phone = '13900000000'"
).fetchone()
if demo is not None and not str(demo["password_hash"] or "").startswith("$2"):
set_user_password(connection, "demo", "demo123456")
rows = connection.execute(
"SELECT id FROM users WHERE username IS NULL OR username = '' ORDER BY id"
).fetchall()
for row in rows:
connection.execute("UPDATE users SET username = ? WHERE id = ?", (f"user{row['id']}", row["id"]))
def ensure_product_schema(connection: sqlite3.Connection) -> None:
columns = {
row["name"] if isinstance(row, sqlite3.Row) else row[1]
for row in connection.execute("PRAGMA table_info(products)").fetchall()
}
additions = {
"alias": "TEXT",
"description": "TEXT",
"payment_button_name": "TEXT NOT NULL DEFAULT '立即支付'",
"donation_button_name": "TEXT NOT NULL DEFAULT '随喜'",
"sort_order": "INTEGER NOT NULL DEFAULT 0",
"show_participant_count": "INTEGER NOT NULL DEFAULT 0",
"cover_image": "TEXT",
"images_json": "TEXT NOT NULL DEFAULT '[]'",
"listed_at": "TEXT",
"sale_starts_at": "TEXT",
"sale_ends_at": "TEXT",
"delisted_at": "TEXT",
"show_countdown": "INTEGER NOT NULL DEFAULT 0",
"show_on_home": "INTEGER NOT NULL DEFAULT 0",
"specs_json": "TEXT NOT NULL DEFAULT '[]'",
"details_html": "TEXT",
"share_title": "TEXT",
"share_description": "TEXT",
"share_image": "TEXT",
"merit_certificate_reserved": "INTEGER NOT NULL DEFAULT 0",
"auto_process": "INTEGER NOT NULL DEFAULT 0",
"sales_count": "INTEGER NOT NULL DEFAULT 0",
}
for column_name, definition in additions.items():
if column_name not in columns:
connection.execute(f"ALTER TABLE products ADD COLUMN {column_name} {definition}")
def init_db() -> None:
DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DATABASE_PATH) as connection:
connection.row_factory = sqlite3.Row
connection.execute(
"""
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
"""
)
ensure_user_schema(connection)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS temples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
location TEXT,
contact_phone TEXT,
description TEXT,
is_active INTEGER NOT NULL DEFAULT 1
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
temple_id INTEGER NOT NULL,
name TEXT NOT NULL,
price REAL NOT NULL DEFAULT 0,
stock INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (temple_id) REFERENCES temples(id)
)
"""
)
ensure_product_schema(connection)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS ritual_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
temple_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (temple_id) REFERENCES temples(id)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
temple_id INTEGER NOT NULL,
customer_name TEXT NOT NULL,
customer_phone TEXT,
order_type TEXT NOT NULL,
item_name TEXT NOT NULL,
amount REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (temple_id) REFERENCES temples(id)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS payment_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
temple_id INTEGER NOT NULL,
order_id INTEGER NOT NULL,
channel TEXT NOT NULL DEFAULT 'wechat',
out_trade_no TEXT NOT NULL UNIQUE,
amount INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'created',
prepay_id TEXT,
transaction_id TEXT,
raw_notify_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (temple_id) REFERENCES temples(id),
FOREIGN KEY (order_id) REFERENCES orders(id)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_captchas (
id TEXT PRIMARY KEY,
answer_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""
)
session_columns = {
row["name"] if isinstance(row, sqlite3.Row) else row[1]
for row in connection.execute("PRAGMA table_info(auth_sessions)").fetchall()
}
if "revoked_at" not in session_columns:
connection.execute("ALTER TABLE auth_sessions ADD COLUMN revoked_at TEXT")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
ip_address TEXT NOT NULL,
user_agent TEXT,
success INTEGER NOT NULL DEFAULT 0,
failure_reason TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ip_address TEXT NOT NULL,
user_agent TEXT,
is_new_ip INTEGER NOT NULL DEFAULT 0,
is_new_device INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""
)
todo_count = connection.execute("SELECT COUNT(*) FROM todos").fetchone()[0]
if todo_count == 0:
connection.executemany(
"INSERT INTO todos (title, done) VALUES (?, ?)",
[
("Learn FastAPI routes", 0),
("Open interactive API docs", 0),
],
)
user_count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if user_count == 0:
connection.executemany(
"""
INSERT INTO users
(name, phone, nickname, avatar, is_admin, total_spent)
VALUES (?, ?, ?, ?, ?, ?)
""",
[
("管理员", "13800000000", "admin", "", 1, 0),
("演示用户", "13900000000", "demo", "", 0, 128.5),
],
)
ensure_default_user_credentials(connection)
temple_count = connection.execute("SELECT COUNT(*) FROM temples").fetchone()[0]
if temple_count == 0:
connection.executemany(
"""
INSERT INTO temples (name, location, contact_phone, description, is_active)
VALUES (?, ?, ?, ?, ?)
""",
[
("福田寺", "深圳福田", "0755-10000001", "自在福田示例寺院", 1),
("南山寺", "深圳南山", "0755-10000002", "用于验证数据隔离的示例寺院", 1),
],
)
product_count = connection.execute("SELECT COUNT(*) FROM products").fetchone()[0]
if product_count == 0:
connection.executemany(
"""
INSERT INTO products
(
temple_id, name, alias, payment_button_name, donation_button_name,
price, stock, sort_order, cover_image, show_on_home, specs_json,
details_html, share_title, share_description, is_active, sales_count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
1,
"平安香",
"平安",
"立即结缘",
"随喜功德",
29.9,
100,
10,
"",
1,
'[{"name":"单份","price":29.9,"stock":100,"sort_order":1,"is_chaodu":false}]',
"<p>祈愿平安顺遂。</p>",
"平安香",
"为家人祈福平安",
1,
32,
),
(
1,
"祈福灯",
"供灯",
"立即供灯",
"随喜供灯",
99.0,
50,
20,
"",
1,
'[{"name":"一盏","price":99,"stock":50,"sort_order":1,"is_chaodu":false}]',
"<p>供灯祈福,愿心光明。</p>",
"祈福灯",
"供灯祈福,照亮善愿",
1,
18,
),
(
2,
"南山香礼",
"香礼",
"立即支付",
"随喜",
39.9,
80,
10,
"",
1,
'[{"name":"单份","price":39.9,"stock":80,"sort_order":1,"is_chaodu":false}]',
"<p>南山寺香礼。</p>",
"南山香礼",
"南山寺祈福香礼",
1,
11,
),
],
)
ritual_count = connection.execute("SELECT COUNT(*) FROM ritual_services").fetchone()[0]
if ritual_count == 0:
connection.executemany(
"""
INSERT INTO ritual_services (temple_id, name, description, price, is_active)
VALUES (?, ?, ?, ?, ?)
""",
[
(1, "祈福法会", "为家人祈福平安", 199.0, 1),
(1, "超度法事", "超度追思法事", 399.0, 1),
(2, "供灯祈福", "南山寺供灯祈福", 129.0, 1),
],
)
order_count = connection.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
if order_count == 0:
connection.executemany(
"""
INSERT INTO orders
(temple_id, customer_name, customer_phone, order_type, item_name, amount, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(1, "张三", "13800138000", "product", "平安香", 29.9, "paid"),
(1, "李四", "13900139000", "ritual", "祈福法会", 199.0, "pending"),
(2, "王五", "13700137000", "product", "南山香礼", 39.9, "paid"),
],
)
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
def get_db():
connection = sqlite3.connect(DATABASE_PATH, check_same_thread=False)
connection.row_factory = sqlite3.Row
try:
yield connection
finally:
connection.close()
Database = Annotated[sqlite3.Connection, Depends(get_db)]
+45
View File
@@ -0,0 +1,45 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import lifespan
from app.routers import auth, orders, payments, products, rituals, temples, todos, uploads, users
app = FastAPI(
title="Toy User API",
description="A tiny FastAPI application with Todo and User CRUD APIs.",
version="0.2.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:5173", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def read_root() -> dict[str, str]:
return {
"message": "Welcome to the Toy API.",
"docs": "/docs",
}
@app.get("/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
app.include_router(todos.router)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(temples.router)
app.include_router(products.router)
app.include_router(orders.router)
app.include_router(payments.router)
app.include_router(rituals.router)
app.include_router(uploads.router)
+1
View File
@@ -0,0 +1 @@
+339
View File
@@ -0,0 +1,339 @@
from datetime import datetime, timedelta
import secrets
from uuid import uuid4
from fastapi import APIRouter, Cookie, HTTPException, Request, Response, status
from app.config import auth_settings
from app.database import Database
from app.schemas import AuthUser, CaptchaResponse, LoginRequest, LoginResponse
from app.security import create_token, hash_secret, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
CAPTCHA_TTL_SECONDS = 300
SESSION_COOKIE_NAME = "admin_session"
SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
CAPTCHA_FAILURE_THRESHOLD = 5
LOCK_FAILURE_THRESHOLD = 10
FAILURE_WINDOW_MINUTES = 15
LOCK_MINUTES = 15
def utc_now() -> datetime:
return datetime.utcnow().replace(microsecond=0)
def to_db_time(value: datetime) -> str:
return value.isoformat()
def from_db_time(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def get_client_ip(request: Request) -> str:
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def get_user_agent(request: Request) -> str:
return request.headers.get("user-agent", "")[:300]
def is_local_request(request: Request) -> bool:
host = request.url.hostname or ""
return host in {"127.0.0.1", "localhost", "::1"}
def ensure_https(request: Request) -> None:
if not auth_settings.require_https:
return
forwarded_proto = request.headers.get("x-forwarded-proto", "")
is_https = request.url.scheme == "https" or forwarded_proto.lower() == "https"
if is_https:
return
if auth_settings.allow_insecure_local and is_local_request(request):
return
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="登录必须通过 HTTPS 连接,请使用安全地址后重试",
)
def create_auth_user(row) -> AuthUser:
return AuthUser(
id=row["id"],
username=row["username"],
name=row["name"],
is_admin=bool(row["is_admin"]),
)
def count_recent_failures(db: Database, username: str, ip_address: str) -> int:
since = to_db_time(utc_now() - timedelta(minutes=FAILURE_WINDOW_MINUTES))
row = db.execute(
"""
SELECT COUNT(*)
FROM login_attempts
WHERE success = 0
AND created_at >= ?
AND (lower(username) = ? OR ip_address = ?)
""",
(since, username, ip_address),
).fetchone()
return int(row[0])
def record_login_attempt(
db: Database,
username: str,
ip_address: str,
user_agent: str,
success: bool,
failure_reason: str | None = None,
) -> None:
db.execute(
"""
INSERT INTO login_attempts (username, ip_address, user_agent, success, failure_reason)
VALUES (?, ?, ?, ?, ?)
""",
(username, ip_address, user_agent, int(success), failure_reason),
)
db.commit()
def detect_new_login_context(db: Database, user_id: int, ip_address: str, user_agent: str) -> tuple[bool, bool]:
old_ip = db.execute(
"""
SELECT 1 FROM login_events
WHERE user_id = ? AND ip_address = ?
LIMIT 1
""",
(user_id, ip_address),
).fetchone()
old_device = db.execute(
"""
SELECT 1 FROM login_events
WHERE user_id = ? AND user_agent = ?
LIMIT 1
""",
(user_id, user_agent),
).fetchone()
had_login = db.execute("SELECT 1 FROM login_events WHERE user_id = ? LIMIT 1", (user_id,)).fetchone()
return had_login is not None and old_ip is None, had_login is not None and old_device is None
def record_login_event(db: Database, user_id: int, ip_address: str, user_agent: str, is_new_ip: bool, is_new_device: bool) -> None:
db.execute(
"""
INSERT INTO login_events (user_id, ip_address, user_agent, is_new_ip, is_new_device)
VALUES (?, ?, ?, ?, ?)
""",
(user_id, ip_address, user_agent, int(is_new_ip), int(is_new_device)),
)
db.commit()
def set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
max_age=SESSION_TTL_SECONDS,
httponly=True,
secure=auth_settings.cookie_secure,
samesite="lax",
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
@router.get("/captcha", response_model=CaptchaResponse)
def get_captcha(db: Database) -> CaptchaResponse:
left = secrets.randbelow(8) + 2
right = secrets.randbelow(8) + 2
answer = str(left + right)
captcha_id = uuid4().hex
expires_at = utc_now() + timedelta(seconds=CAPTCHA_TTL_SECONDS)
db.execute(
"""
INSERT INTO login_captchas (id, answer_hash, expires_at)
VALUES (?, ?, ?)
""",
(captcha_id, hash_secret(answer.lower()), to_db_time(expires_at)),
)
db.commit()
return CaptchaResponse(
captcha_id=captcha_id,
challenge=f"{left} + {right} = ?",
expires_in=CAPTCHA_TTL_SECONDS,
)
def verify_captcha(db: Database, captcha_id: str | None, captcha_code: str | None) -> None:
if not captcha_id or not captcha_code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入验证码后再登录")
row = db.execute(
"""
SELECT id, answer_hash, expires_at, used_at
FROM login_captchas
WHERE id = ?
""",
(captcha_id,),
).fetchone()
if row is None or row["used_at"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已失效,请刷新后重试")
expires_at = from_db_time(row["expires_at"])
if expires_at is None or utc_now() > expires_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期,请刷新后重试")
db.execute("UPDATE login_captchas SET used_at = ? WHERE id = ?", (to_db_time(utc_now()), captcha_id))
db.commit()
if hash_secret(captcha_code.strip().lower()) != row["answer_hash"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误,请重新输入")
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, db: Database) -> LoginResponse:
ensure_https(request)
username = payload.username.strip().lower()
ip_address = get_client_ip(request)
user_agent = get_user_agent(request)
recent_failures = count_recent_failures(db, username, ip_address)
try:
verify_captcha(db, payload.captcha_id, payload.captcha_code)
except HTTPException as exc:
record_login_attempt(db, username, ip_address, user_agent, False, "captcha_error")
raise exc
row = db.execute(
"""
SELECT id, username, name, is_admin, password_hash, failed_login_count, locked_until
FROM users
WHERE lower(username) = ?
""",
(username,),
).fetchone()
if row is None:
record_login_attempt(db, username, ip_address, user_agent, False, "unknown_user")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在,请检查用户名")
locked_until = from_db_time(row["locked_until"])
if locked_until is not None and utc_now() < locked_until:
record_login_attempt(db, username, ip_address, user_agent, False, "locked")
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="账号已临时锁定,请 15 分钟后再试")
password_hash = row["password_hash"]
if not password_hash:
record_login_attempt(db, username, ip_address, user_agent, False, "password_missing")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号尚未设置密码,请联系管理员")
if not verify_password(payload.password, password_hash):
failure_count = int(row["failed_login_count"] or 0) + 1
total_failures = max(failure_count, recent_failures + 1)
locked_value = None
message = "密码错误,请重新输入"
if total_failures >= LOCK_FAILURE_THRESHOLD:
locked_value = to_db_time(utc_now() + timedelta(minutes=LOCK_MINUTES))
message = "密码连续错误次数过多,账号已临时锁定 15 分钟"
elif total_failures >= CAPTCHA_FAILURE_THRESHOLD:
message = "密码错误,请输入验证码后重试"
db.execute(
"""
UPDATE users
SET failed_login_count = ?, locked_until = ?
WHERE id = ?
""",
(failure_count, locked_value, row["id"]),
)
db.commit()
record_login_attempt(db, username, ip_address, user_agent, False, "wrong_password")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=message)
token = create_token()
expires_at = utc_now() + timedelta(seconds=SESSION_TTL_SECONDS)
db.execute(
"""
INSERT INTO auth_sessions (token_hash, user_id, expires_at)
VALUES (?, ?, ?)
""",
(hash_secret(token), row["id"], to_db_time(expires_at)),
)
db.execute(
"""
UPDATE users
SET failed_login_count = 0, locked_until = NULL
WHERE id = ?
""",
(row["id"],),
)
db.commit()
set_session_cookie(response, token)
record_login_attempt(db, username, ip_address, user_agent, True)
is_new_ip, is_new_device = detect_new_login_context(db, row["id"], ip_address, user_agent)
record_login_event(db, row["id"], ip_address, user_agent, is_new_ip, is_new_device)
requires_second_verification = is_new_ip or is_new_device
security_notice = None
if requires_second_verification:
security_notice = "检测到新 IP 或新设备登录,后续应接入短信/邮箱二次验证与通知"
return LoginResponse(
expires_in=SESSION_TTL_SECONDS,
user=create_auth_user(row),
requires_second_verification=requires_second_verification,
security_notice=security_notice,
)
@router.get("/me", response_model=AuthUser)
def get_current_user(db: Database, admin_session: str | None = Cookie(default=None)) -> AuthUser:
if not admin_session:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
row = db.execute(
"""
SELECT users.id, users.username, users.name, users.is_admin, auth_sessions.expires_at, auth_sessions.revoked_at
FROM auth_sessions
JOIN users ON users.id = auth_sessions.user_id
WHERE auth_sessions.token_hash = ?
""",
(hash_secret(admin_session),),
).fetchone()
if row is None or row["revoked_at"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
expires_at = from_db_time(row["expires_at"])
if expires_at is None or utc_now() > expires_at:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
return create_auth_user(row)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response, db: Database, admin_session: str | None = Cookie(default=None)) -> None:
if admin_session:
db.execute(
"UPDATE auth_sessions SET revoked_at = ? WHERE token_hash = ?",
(to_db_time(utc_now()), hash_secret(admin_session)),
)
db.commit()
clear_session_cookie(response)
+120
View File
@@ -0,0 +1,120 @@
import sqlite3
from typing import List
from fastapi import APIRouter, HTTPException, status
from app.database import Database
from app.schemas import Order, OrderCreate, OrderUpdate
from app.temple_scope import TempleId, ensure_temple_exists
router = APIRouter(prefix="/orders", tags=["orders"])
def row_to_order(row: sqlite3.Row) -> Order:
return Order(
id=row["id"],
temple_id=row["temple_id"],
customer_name=row["customer_name"],
customer_phone=row["customer_phone"],
order_type=row["order_type"],
item_name=row["item_name"],
amount=float(row["amount"]),
status=row["status"],
created_at=row["created_at"],
)
@router.get("", response_model=List[Order])
def list_orders(db: Database, temple_id: TempleId) -> List[Order]:
ensure_temple_exists(db, temple_id)
rows = db.execute(
"""
SELECT id, temple_id, customer_name, customer_phone, order_type, item_name, amount, status, created_at
FROM orders
WHERE temple_id = ?
ORDER BY id DESC
""",
(temple_id,),
).fetchall()
return [row_to_order(row) for row in rows]
@router.post("", response_model=Order, status_code=status.HTTP_201_CREATED)
def create_order(payload: OrderCreate, db: Database, temple_id: TempleId) -> Order:
ensure_temple_exists(db, temple_id)
cursor = db.execute(
"""
INSERT INTO orders
(temple_id, customer_name, customer_phone, order_type, item_name, amount, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
temple_id,
payload.customer_name,
payload.customer_phone,
payload.order_type,
payload.item_name,
payload.amount,
payload.status,
),
)
db.commit()
return get_order(cursor.lastrowid, db, temple_id)
@router.get("/{order_id}", response_model=Order)
def get_order(order_id: int, db: Database, temple_id: TempleId) -> Order:
ensure_temple_exists(db, temple_id)
row = db.execute(
"""
SELECT id, temple_id, customer_name, customer_phone, order_type, item_name, amount, status, created_at
FROM orders
WHERE id = ? AND temple_id = ?
""",
(order_id, temple_id),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found")
return row_to_order(row)
@router.patch("/{order_id}", response_model=Order)
def update_order(order_id: int, payload: OrderUpdate, db: Database, temple_id: TempleId) -> Order:
order = get_order(order_id, db, temple_id)
updates = payload.model_dump(exclude_unset=True)
if not updates:
return order
db.execute(
"""
UPDATE orders
SET customer_name = ?, customer_phone = ?, order_type = ?, item_name = ?, amount = ?, status = ?
WHERE id = ? AND temple_id = ?
""",
(
updates.get("customer_name", order.customer_name),
updates.get("customer_phone", order.customer_phone),
updates.get("order_type", order.order_type),
updates.get("item_name", order.item_name),
updates.get("amount", order.amount),
updates.get("status", order.status),
order_id,
temple_id,
),
)
db.commit()
return get_order(order_id, db, temple_id)
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_order(order_id: int, db: Database, temple_id: TempleId) -> None:
ensure_temple_exists(db, temple_id)
cursor = db.execute(
"DELETE FROM orders WHERE id = ? AND temple_id = ?",
(order_id, temple_id),
)
db.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found")
+132
View File
@@ -0,0 +1,132 @@
import json
from decimal import Decimal, ROUND_HALF_UP
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from app.config import wechat_pay_settings
from app.database import Database
from app.schemas import WeChatJSAPIPayRequest, WeChatJSAPIPayResponse
from app.temple_scope import TempleId, ensure_temple_exists
from app.wechat_pay import (
build_jsapi_pay_params,
decrypt_notify_resource,
post_wechat_json,
verify_notify_signature,
)
router = APIRouter(prefix="/payments", tags=["payments"])
def yuan_to_fen(amount: float) -> int:
return int((Decimal(str(amount)) * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def get_order_row(db: Database, temple_id: int, order_id: int):
row = db.execute(
"""
SELECT id, temple_id, item_name, amount, status
FROM orders
WHERE id = ? AND temple_id = ?
""",
(order_id, temple_id),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在")
return row
@router.post("/wechat/jsapi", response_model=WeChatJSAPIPayResponse)
def create_wechat_jsapi_payment(payload: WeChatJSAPIPayRequest, db: Database, temple_id: TempleId) -> WeChatJSAPIPayResponse:
ensure_temple_exists(db, temple_id)
order = get_order_row(db, temple_id, payload.order_id)
if order["status"] == "paid":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="订单已支付,无需重复发起支付")
amount = yuan_to_fen(float(order["amount"]))
if amount <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="订单金额必须大于 0")
out_trade_no = f"T{temple_id}O{order['id']}{uuid4().hex[:16]}"
description = (payload.description or order["item_name"])[:127]
wechat_payload = {
"appid": wechat_pay_settings.appid,
"mchid": wechat_pay_settings.mch_id,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": wechat_pay_settings.notify_url,
"amount": {
"total": amount,
"currency": "CNY",
},
"payer": {
"openid": payload.openid,
},
}
result = post_wechat_json("/v3/pay/transactions/jsapi", wechat_payload)
prepay_id = result.get("prepay_id")
if not prepay_id:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="微信支付未返回 prepay_id")
db.execute(
"""
INSERT INTO payment_transactions
(temple_id, order_id, out_trade_no, amount, status, prepay_id)
VALUES (?, ?, ?, ?, ?, ?)
""",
(temple_id, order["id"], out_trade_no, amount, "prepay", prepay_id),
)
db.commit()
return WeChatJSAPIPayResponse(
**build_jsapi_pay_params(prepay_id),
out_trade_no=out_trade_no,
prepay_id=prepay_id,
)
@router.post("/wechat/notify")
async def handle_wechat_pay_notify(request: Request, db: Database) -> dict[str, str]:
body = (await request.body()).decode("utf-8")
headers = {key.lower(): value for key, value in request.headers.items()}
if not verify_notify_signature(headers, body):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="微信支付通知验签失败")
notify_payload = json.loads(body)
resource = notify_payload.get("resource")
if not isinstance(resource, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="微信支付通知缺少 resource")
trade = decrypt_notify_resource(resource)
out_trade_no = trade.get("out_trade_no")
trade_state = trade.get("trade_state")
transaction_id = trade.get("transaction_id")
if not out_trade_no:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="微信支付通知缺少商户订单号")
payment = db.execute(
"""
SELECT id, order_id
FROM payment_transactions
WHERE out_trade_no = ?
""",
(out_trade_no,),
).fetchone()
if payment is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付流水不存在")
status_value = "paid" if trade_state == "SUCCESS" else str(trade_state or "unknown").lower()
db.execute(
"""
UPDATE payment_transactions
SET status = ?, transaction_id = ?, raw_notify_json = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(status_value, transaction_id, json.dumps(trade, ensure_ascii=False), payment["id"]),
)
if trade_state == "SUCCESS":
db.execute("UPDATE orders SET status = 'paid' WHERE id = ?", (payment["order_id"],))
db.commit()
return {"code": "SUCCESS", "message": "成功"}
+222
View File
@@ -0,0 +1,222 @@
import json
import sqlite3
from datetime import datetime
from typing import Any, List
from fastapi import APIRouter, HTTPException, status
from app.database import Database
from app.schemas import Product, ProductCreate, ProductSpec, ProductUpdate
from app.temple_scope import TempleId, ensure_temple_exists
router = APIRouter(prefix="/products", tags=["products"])
PRODUCT_COLUMNS = """
id, temple_id, name, alias, description, payment_button_name, donation_button_name,
sort_order, show_participant_count, cover_image, images_json, listed_at,
sale_starts_at, sale_ends_at, delisted_at, show_countdown, show_on_home,
specs_json, details_html, share_title, share_description, share_image,
merit_certificate_reserved, auto_process, is_active, sales_count
"""
def parse_json_list(value: str | None) -> list[Any]:
if not value:
return []
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return []
return parsed if isinstance(parsed, list) else []
def parse_time(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
return None
def get_product_status(row: sqlite3.Row) -> str:
now = datetime.now()
listed_at = parse_time(row["listed_at"])
sale_starts_at = parse_time(row["sale_starts_at"])
sale_ends_at = parse_time(row["sale_ends_at"])
delisted_at = parse_time(row["delisted_at"])
if not bool(row["is_active"]):
return "inactive"
if delisted_at is not None and now >= delisted_at:
return "delisted"
if sale_ends_at is not None and now >= sale_ends_at:
return "ended"
if listed_at is not None and now < listed_at:
return "pending_list"
if sale_starts_at is not None and now < sale_starts_at:
return "pending_sale"
return "selling"
def row_to_product(row: sqlite3.Row) -> Product:
return Product(
id=row["id"],
temple_id=row["temple_id"],
name=row["name"],
alias=row["alias"],
description=row["description"],
payment_button_name=row["payment_button_name"],
donation_button_name=row["donation_button_name"],
sort_order=row["sort_order"],
show_participant_count=bool(row["show_participant_count"]),
cover_image=row["cover_image"],
images=parse_json_list(row["images_json"]),
listed_at=row["listed_at"],
sale_starts_at=row["sale_starts_at"],
sale_ends_at=row["sale_ends_at"],
delisted_at=row["delisted_at"],
show_countdown=bool(row["show_countdown"]),
show_on_home=bool(row["show_on_home"]),
specs=[ProductSpec(**item) for item in parse_json_list(row["specs_json"])],
details_html=row["details_html"],
share_title=row["share_title"],
share_description=row["share_description"],
share_image=row["share_image"],
merit_certificate_reserved=bool(row["merit_certificate_reserved"]),
auto_process=bool(row["auto_process"]),
is_active=bool(row["is_active"]),
sales_count=row["sales_count"],
status=get_product_status(row),
)
def product_payload_values(payload: ProductCreate | ProductUpdate, fallback: Product | None = None) -> tuple[Any, ...]:
data = payload.model_dump(exclude_unset=fallback is not None)
def get_value(name: str, default: Any) -> Any:
if name in data:
return data[name]
if fallback is not None:
return getattr(fallback, name)
return default
images = get_value("images", [])
specs = get_value("specs", [])
return (
get_value("name", ""),
get_value("alias", None),
get_value("description", None),
get_value("payment_button_name", "立即支付"),
get_value("donation_button_name", "随喜"),
get_value("sort_order", 0),
int(get_value("show_participant_count", False)),
get_value("cover_image", None),
json.dumps(images, ensure_ascii=False),
get_value("listed_at", None),
get_value("sale_starts_at", None),
get_value("sale_ends_at", None),
get_value("delisted_at", None),
int(get_value("show_countdown", False)),
int(get_value("show_on_home", False)),
json.dumps([spec.model_dump() if isinstance(spec, ProductSpec) else spec for spec in specs], ensure_ascii=False),
get_value("details_html", None),
get_value("share_title", None),
get_value("share_description", None),
get_value("share_image", None),
int(get_value("merit_certificate_reserved", False)),
int(get_value("auto_process", False)),
int(get_value("is_active", True)),
get_value("sales_count", 0),
)
@router.get("", response_model=List[Product])
def list_products(db: Database, temple_id: TempleId) -> List[Product]:
ensure_temple_exists(db, temple_id)
rows = db.execute(
f"""
SELECT {PRODUCT_COLUMNS}
FROM products
WHERE temple_id = ?
ORDER BY sort_order, id
""",
(temple_id,),
).fetchall()
return [row_to_product(row) for row in rows]
@router.post("", response_model=Product, status_code=status.HTTP_201_CREATED)
def create_product(payload: ProductCreate, db: Database, temple_id: TempleId) -> Product:
ensure_temple_exists(db, temple_id)
cursor = db.execute(
"""
INSERT INTO products
(
temple_id, name, alias, description, payment_button_name, donation_button_name,
sort_order, show_participant_count, cover_image, images_json, listed_at,
sale_starts_at, sale_ends_at, delisted_at, show_countdown, show_on_home,
specs_json, details_html, share_title, share_description, share_image,
merit_certificate_reserved, auto_process, is_active, sales_count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(temple_id, *product_payload_values(payload)),
)
db.commit()
return get_product(cursor.lastrowid, db, temple_id)
@router.get("/{product_id}", response_model=Product)
def get_product(product_id: int, db: Database, temple_id: TempleId) -> Product:
ensure_temple_exists(db, temple_id)
row = db.execute(
f"""
SELECT {PRODUCT_COLUMNS}
FROM products
WHERE id = ? AND temple_id = ?
""",
(product_id, temple_id),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
return row_to_product(row)
@router.patch("/{product_id}", response_model=Product)
def update_product(product_id: int, payload: ProductUpdate, db: Database, temple_id: TempleId) -> Product:
product = get_product(product_id, db, temple_id)
updates = payload.model_dump(exclude_unset=True)
if not updates:
return product
db.execute(
"""
UPDATE products
SET
name = ?, alias = ?, description = ?, payment_button_name = ?, donation_button_name = ?,
sort_order = ?, show_participant_count = ?, cover_image = ?, images_json = ?,
listed_at = ?, sale_starts_at = ?, sale_ends_at = ?, delisted_at = ?,
show_countdown = ?, show_on_home = ?, specs_json = ?, details_html = ?,
share_title = ?, share_description = ?, share_image = ?,
merit_certificate_reserved = ?, auto_process = ?, is_active = ?, sales_count = ?
WHERE id = ? AND temple_id = ?
""",
(*product_payload_values(payload, product), product_id, temple_id),
)
db.commit()
return get_product(product_id, db, temple_id)
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(product_id: int, db: Database, temple_id: TempleId) -> None:
ensure_temple_exists(db, temple_id)
cursor = db.execute(
"DELETE FROM products WHERE id = ? AND temple_id = ?",
(product_id, temple_id),
)
db.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
+106
View File
@@ -0,0 +1,106 @@
import sqlite3
from typing import List
from fastapi import APIRouter, HTTPException, status
from app.database import Database
from app.schemas import RitualService, RitualServiceCreate, RitualServiceUpdate
from app.temple_scope import TempleId, ensure_temple_exists
router = APIRouter(prefix="/ritual-services", tags=["ritual-services"])
def row_to_ritual(row: sqlite3.Row) -> RitualService:
return RitualService(
id=row["id"],
temple_id=row["temple_id"],
name=row["name"],
description=row["description"],
price=float(row["price"]),
is_active=bool(row["is_active"]),
)
@router.get("", response_model=List[RitualService])
def list_rituals(db: Database, temple_id: TempleId) -> List[RitualService]:
ensure_temple_exists(db, temple_id)
rows = db.execute(
"""
SELECT id, temple_id, name, description, price, is_active
FROM ritual_services
WHERE temple_id = ?
ORDER BY id
""",
(temple_id,),
).fetchall()
return [row_to_ritual(row) for row in rows]
@router.post("", response_model=RitualService, status_code=status.HTTP_201_CREATED)
def create_ritual(payload: RitualServiceCreate, db: Database, temple_id: TempleId) -> RitualService:
ensure_temple_exists(db, temple_id)
cursor = db.execute(
"""
INSERT INTO ritual_services (temple_id, name, description, price, is_active)
VALUES (?, ?, ?, ?, ?)
""",
(temple_id, payload.name, payload.description, payload.price, int(payload.is_active)),
)
db.commit()
return get_ritual(cursor.lastrowid, db, temple_id)
@router.get("/{ritual_id}", response_model=RitualService)
def get_ritual(ritual_id: int, db: Database, temple_id: TempleId) -> RitualService:
ensure_temple_exists(db, temple_id)
row = db.execute(
"""
SELECT id, temple_id, name, description, price, is_active
FROM ritual_services
WHERE id = ? AND temple_id = ?
""",
(ritual_id, temple_id),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ritual service not found")
return row_to_ritual(row)
@router.patch("/{ritual_id}", response_model=RitualService)
def update_ritual(ritual_id: int, payload: RitualServiceUpdate, db: Database, temple_id: TempleId) -> RitualService:
ritual = get_ritual(ritual_id, db, temple_id)
updates = payload.model_dump(exclude_unset=True)
if not updates:
return ritual
db.execute(
"""
UPDATE ritual_services
SET name = ?, description = ?, price = ?, is_active = ?
WHERE id = ? AND temple_id = ?
""",
(
updates.get("name", ritual.name),
updates.get("description", ritual.description),
updates.get("price", ritual.price),
int(updates.get("is_active", ritual.is_active)),
ritual_id,
temple_id,
),
)
db.commit()
return get_ritual(ritual_id, db, temple_id)
@router.delete("/{ritual_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_ritual(ritual_id: int, db: Database, temple_id: TempleId) -> None:
ensure_temple_exists(db, temple_id)
cursor = db.execute(
"DELETE FROM ritual_services WHERE id = ? AND temple_id = ?",
(ritual_id, temple_id),
)
db.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ritual service not found")
+116
View File
@@ -0,0 +1,116 @@
import sqlite3
from typing import List
from fastapi import APIRouter, HTTPException, status
from app.database import Database
from app.schemas import Temple, TempleCreate, TempleUpdate
router = APIRouter(prefix="/temples", tags=["temples"])
def row_to_temple(row: sqlite3.Row) -> Temple:
return Temple(
id=row["id"],
name=row["name"],
location=row["location"],
contact_phone=row["contact_phone"],
description=row["description"],
is_active=bool(row["is_active"]),
)
def raise_duplicate_temple_error() -> None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Temple name already exists",
)
@router.get("", response_model=List[Temple])
def list_temples(db: Database) -> List[Temple]:
rows = db.execute(
"""
SELECT id, name, location, contact_phone, description, is_active
FROM temples
ORDER BY id
"""
).fetchall()
return [row_to_temple(row) for row in rows]
@router.post("", response_model=Temple, status_code=status.HTTP_201_CREATED)
def create_temple(payload: TempleCreate, db: Database) -> Temple:
try:
cursor = db.execute(
"""
INSERT INTO temples (name, location, contact_phone, description, is_active)
VALUES (?, ?, ?, ?, ?)
""",
(
payload.name,
payload.location,
payload.contact_phone,
payload.description,
int(payload.is_active),
),
)
db.commit()
except sqlite3.IntegrityError:
raise_duplicate_temple_error()
return get_temple(cursor.lastrowid, db)
@router.get("/{temple_id}", response_model=Temple)
def get_temple(temple_id: int, db: Database) -> Temple:
row = db.execute(
"""
SELECT id, name, location, contact_phone, description, is_active
FROM temples
WHERE id = ?
""",
(temple_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Temple not found")
return row_to_temple(row)
@router.patch("/{temple_id}", response_model=Temple)
def update_temple(temple_id: int, payload: TempleUpdate, db: Database) -> Temple:
temple = get_temple(temple_id, db)
updates = payload.model_dump(exclude_unset=True)
if not updates:
return temple
name = updates.get("name", temple.name)
location = updates.get("location", temple.location)
contact_phone = updates.get("contact_phone", temple.contact_phone)
description = updates.get("description", temple.description)
is_active = updates.get("is_active", temple.is_active)
try:
db.execute(
"""
UPDATE temples
SET name = ?, location = ?, contact_phone = ?, description = ?, is_active = ?
WHERE id = ?
""",
(name, location, contact_phone, description, int(is_active), temple_id),
)
db.commit()
except sqlite3.IntegrityError:
raise_duplicate_temple_error()
return get_temple(temple_id, db)
@router.delete("/{temple_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_temple(temple_id: int, db: Database) -> None:
cursor = db.execute("DELETE FROM temples WHERE id = ?", (temple_id,))
db.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Temple not found")
+68
View File
@@ -0,0 +1,68 @@
import sqlite3
from typing import List
from fastapi import APIRouter, HTTPException, status
from app.database import Database
from app.schemas import Todo, TodoCreate, TodoUpdate
router = APIRouter(prefix="/todos", tags=["todos"])
def row_to_todo(row: sqlite3.Row) -> Todo:
return Todo(id=row["id"], title=row["title"], done=bool(row["done"]))
@router.get("", response_model=List[Todo])
def list_todos(db: Database) -> List[Todo]:
rows = db.execute("SELECT id, title, done FROM todos ORDER BY id").fetchall()
return [row_to_todo(row) for row in rows]
@router.post("", response_model=Todo, status_code=status.HTTP_201_CREATED)
def create_todo(payload: TodoCreate, db: Database) -> Todo:
cursor = db.execute(
"INSERT INTO todos (title, done) VALUES (?, ?)",
(payload.title, 0),
)
db.commit()
return Todo(id=cursor.lastrowid, title=payload.title, done=False)
@router.get("/{todo_id}", response_model=Todo)
def get_todo(todo_id: int, db: Database) -> Todo:
row = db.execute(
"SELECT id, title, done FROM todos WHERE id = ?",
(todo_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
return row_to_todo(row)
@router.patch("/{todo_id}", response_model=Todo)
def update_todo(todo_id: int, payload: TodoUpdate, db: Database) -> Todo:
todo = get_todo(todo_id, db)
updates = payload.model_dump(exclude_unset=True)
if not updates:
return todo
title = updates.get("title", todo.title)
done = updates.get("done", todo.done)
db.execute(
"UPDATE todos SET title = ?, done = ? WHERE id = ?",
(title, int(done), todo_id),
)
db.commit()
return get_todo(todo_id, db)
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int, db: Database) -> None:
cursor = db.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
db.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
+72
View File
@@ -0,0 +1,72 @@
import mimetypes
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from starlette.datastructures import UploadFile
from app.config import oss_settings
from app.schemas import UploadResponse
router = APIRouter(prefix="/uploads", tags=["uploads"])
MAX_IMAGE_SIZE = 5 * 1024 * 1024
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
def get_public_url(object_key: str) -> str:
if oss_settings.public_base_url:
return f"{oss_settings.public_base_url.rstrip('/')}/{object_key}"
endpoint = oss_settings.endpoint.replace("https://", "").replace("http://", "").rstrip("/")
return f"https://{oss_settings.bucket_name}.{endpoint}/{object_key}"
def build_object_key(filename: str | None, folder: str) -> str:
suffix = Path(filename or "").suffix.lower()
if not suffix:
suffix = mimetypes.guess_extension("image/jpeg") or ".jpg"
safe_folder = folder.strip("/ ") or oss_settings.upload_prefix
return f"{oss_settings.upload_prefix.strip('/')}/{safe_folder}/{uuid4().hex}{suffix}"
@router.post("/images", response_model=UploadResponse)
async def upload_image(request: Request) -> UploadResponse:
if not oss_settings.is_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OSS 配置未完成,请先填写 app/config.py 中的 OSS 配置。",
)
form = await request.form()
file = form.get("file")
folder_value = form.get("folder")
folder = str(folder_value) if folder_value else "products"
if not isinstance(file, UploadFile):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择要上传的图片")
content_type = file.content_type or "application/octet-stream"
if content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="只支持上传图片文件")
data = await file.read()
if len(data) > MAX_IMAGE_SIZE:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片大小不能超过 5MB")
try:
import oss2
except ImportError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="缺少 oss2 依赖,请先安装 requirements.txt 中的依赖。",
) from exc
object_key = build_object_key(file.filename, folder)
auth = oss2.Auth(oss_settings.access_key_id, oss_settings.access_key_secret)
bucket = oss2.Bucket(auth, oss_settings.upload_endpoint, oss_settings.bucket_name)
bucket.put_object(object_key, data, headers={"Content-Type": content_type})
return UploadResponse(url=get_public_url(object_key), object_key=object_key)
+129
View File
@@ -0,0 +1,129 @@
import sqlite3
from typing import List
from fastapi import APIRouter, HTTPException, status
from app.database import Database
from app.schemas import User, UserCreate, UserUpdate
router = APIRouter(prefix="/users", tags=["users"])
def row_to_user(row: sqlite3.Row) -> User:
return User(
id=row["id"],
registered_at=row["registered_at"],
name=row["name"],
phone=row["phone"],
nickname=row["nickname"],
avatar=row["avatar"],
is_admin=bool(row["is_admin"]),
total_spent=float(row["total_spent"]),
)
def raise_duplicate_user_error() -> None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Phone already exists",
)
@router.get("", response_model=List[User])
def list_users(db: Database, phone: str | None = None) -> List[User]:
params = []
where_clause = ""
if phone:
where_clause = "WHERE phone LIKE ?"
params.append(f"%{phone}%")
rows = db.execute(
f"""
SELECT id, registered_at, name, phone, nickname, avatar, is_admin, total_spent
FROM users
{where_clause}
ORDER BY id
""",
params,
).fetchall()
return [row_to_user(row) for row in rows]
@router.post("", response_model=User, status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreate, db: Database) -> User:
try:
cursor = db.execute(
"""
INSERT INTO users
(registered_at, name, phone, nickname, avatar, is_admin, total_spent)
VALUES (CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?)
""",
(
payload.name,
payload.phone,
payload.nickname,
payload.avatar,
int(payload.is_admin),
payload.total_spent,
),
)
db.commit()
except sqlite3.IntegrityError:
raise_duplicate_user_error()
return get_user(cursor.lastrowid, db)
@router.get("/{user_id}", response_model=User)
def get_user(user_id: int, db: Database) -> User:
row = db.execute(
"""
SELECT id, registered_at, name, phone, nickname, avatar, is_admin, total_spent
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return row_to_user(row)
@router.patch("/{user_id}", response_model=User)
def update_user(user_id: int, payload: UserUpdate, db: Database) -> User:
user = get_user(user_id, db)
updates = payload.model_dump(exclude_unset=True)
if not updates:
return user
name = updates.get("name", user.name)
phone = updates.get("phone", user.phone)
nickname = updates.get("nickname", user.nickname)
avatar = updates.get("avatar", user.avatar)
is_admin = updates.get("is_admin", user.is_admin)
total_spent = updates.get("total_spent", user.total_spent)
try:
db.execute(
"""
UPDATE users
SET name = ?, phone = ?, nickname = ?, avatar = ?, is_admin = ?, total_spent = ?
WHERE id = ?
""",
(name, phone, nickname, avatar, int(is_admin), total_spent, user_id),
)
db.commit()
except sqlite3.IntegrityError:
raise_duplicate_user_error()
return get_user(user_id, db)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, db: Database) -> None:
cursor = db.execute("DELETE FROM users WHERE id = ?", (user_id,))
db.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+282
View File
@@ -0,0 +1,282 @@
from pydantic import BaseModel, Field, field_validator
class TodoCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
class TodoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=100)
done: bool | None = None
class Todo(BaseModel):
id: int
title: str
done: bool = False
class UserCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
phone: str = Field(..., min_length=5, max_length=20, pattern=r"^[0-9+\-\s]+$")
nickname: str | None = Field(default=None, max_length=60)
avatar: str | None = Field(default=None, max_length=300)
is_admin: bool = False
total_spent: float = Field(default=0, ge=0)
class UserUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=80)
phone: str | None = Field(default=None, min_length=5, max_length=20, pattern=r"^[0-9+\-\s]+$")
nickname: str | None = Field(default=None, max_length=60)
avatar: str | None = Field(default=None, max_length=300)
is_admin: bool | None = None
total_spent: float | None = Field(default=None, ge=0)
class User(BaseModel):
id: int
registered_at: str
name: str
phone: str | None = None
nickname: str | None = None
avatar: str | None = None
is_admin: bool = False
total_spent: float = 0
class AuthUser(BaseModel):
id: int
username: str
name: str
is_admin: bool = False
class CaptchaResponse(BaseModel):
captcha_id: str
challenge: str
expires_in: int
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=80)
password: str = Field(..., min_length=1, max_length=128)
captcha_id: str | None = Field(default=None, max_length=80)
captcha_code: str | None = Field(default=None, max_length=20)
class LoginResponse(BaseModel):
expires_in: int
user: AuthUser
requires_second_verification: bool = False
security_notice: str | None = None
class TempleCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
location: str | None = Field(default=None, max_length=160)
contact_phone: str | None = Field(default=None, max_length=30)
description: str | None = Field(default=None, max_length=500)
is_active: bool = True
class TempleUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
location: str | None = Field(default=None, max_length=160)
contact_phone: str | None = Field(default=None, max_length=30)
description: str | None = Field(default=None, max_length=500)
is_active: bool | None = None
class Temple(BaseModel):
id: int
name: str
location: str | None = None
contact_phone: str | None = None
description: str | None = None
is_active: bool = True
class ProductSpec(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
price: float = Field(default=0, ge=0)
stock: int = Field(default=0, ge=0)
sort_order: int = Field(default=0, ge=0)
is_chaodu: bool = False
class ProductBase(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
alias: str | None = Field(default=None, max_length=80)
description: str | None = Field(default=None, max_length=500)
payment_button_name: str = Field(default="立即支付", min_length=1, max_length=30)
donation_button_name: str = Field(default="随喜", min_length=1, max_length=30)
sort_order: int = Field(default=0, ge=0)
show_participant_count: bool = False
cover_image: str | None = Field(default=None, max_length=500)
images: list[str] = Field(default_factory=list, max_length=10)
listed_at: str | None = Field(default=None, max_length=30)
sale_starts_at: str | None = Field(default=None, max_length=30)
sale_ends_at: str | None = Field(default=None, max_length=30)
delisted_at: str | None = Field(default=None, max_length=30)
show_countdown: bool = False
show_on_home: bool = False
specs: list[ProductSpec] = Field(default_factory=list)
details_html: str | None = None
share_title: str | None = Field(default=None, max_length=120)
share_description: str | None = Field(default=None, max_length=300)
share_image: str | None = Field(default=None, max_length=500)
merit_certificate_reserved: bool = False
auto_process: bool = False
is_active: bool = True
sales_count: int = Field(default=0, ge=0)
@field_validator("images")
@classmethod
def validate_images_count(cls, value: list[str]) -> list[str]:
if len(value) > 10:
raise ValueError("Product images cannot exceed 10")
return value
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
alias: str | None = Field(default=None, max_length=80)
description: str | None = Field(default=None, max_length=500)
payment_button_name: str | None = Field(default=None, min_length=1, max_length=30)
donation_button_name: str | None = Field(default=None, min_length=1, max_length=30)
sort_order: int | None = Field(default=None, ge=0)
show_participant_count: bool | None = None
cover_image: str | None = Field(default=None, max_length=500)
images: list[str] | None = Field(default=None, max_length=10)
listed_at: str | None = Field(default=None, max_length=30)
sale_starts_at: str | None = Field(default=None, max_length=30)
sale_ends_at: str | None = Field(default=None, max_length=30)
delisted_at: str | None = Field(default=None, max_length=30)
show_countdown: bool | None = None
show_on_home: bool | None = None
specs: list[ProductSpec] | None = None
details_html: str | None = None
share_title: str | None = Field(default=None, max_length=120)
share_description: str | None = Field(default=None, max_length=300)
share_image: str | None = Field(default=None, max_length=500)
merit_certificate_reserved: bool | None = None
auto_process: bool | None = None
is_active: bool | None = None
sales_count: int | None = Field(default=None, ge=0)
@field_validator("images")
@classmethod
def validate_images_count(cls, value: list[str] | None) -> list[str] | None:
if value is not None and len(value) > 10:
raise ValueError("Product images cannot exceed 10")
return value
class Product(BaseModel):
id: int
temple_id: int
name: str
alias: str | None = None
description: str | None = None
payment_button_name: str = "立即支付"
donation_button_name: str = "随喜"
sort_order: int = 0
show_participant_count: bool = False
cover_image: str | None = None
images: list[str] = Field(default_factory=list)
listed_at: str | None = None
sale_starts_at: str | None = None
sale_ends_at: str | None = None
delisted_at: str | None = None
show_countdown: bool = False
show_on_home: bool = False
specs: list[ProductSpec] = Field(default_factory=list)
details_html: str | None = None
share_title: str | None = None
share_description: str | None = None
share_image: str | None = None
merit_certificate_reserved: bool = False
auto_process: bool = False
is_active: bool = True
sales_count: int = 0
status: str
class UploadResponse(BaseModel):
url: str
object_key: str
class RitualServiceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=500)
price: float = Field(default=0, ge=0)
is_active: bool = True
class RitualServiceUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=500)
price: float | None = Field(default=None, ge=0)
is_active: bool | None = None
class RitualService(BaseModel):
id: int
temple_id: int
name: str
description: str | None = None
price: float = 0
is_active: bool = True
class OrderCreate(BaseModel):
customer_name: str = Field(..., min_length=1, max_length=80)
customer_phone: str | None = Field(default=None, max_length=30)
order_type: str = Field(default="product", pattern=r"^(product|ritual)$")
item_name: str = Field(..., min_length=1, max_length=120)
amount: float = Field(default=0, ge=0)
status: str = Field(default="pending", pattern=r"^(pending|paid|completed|cancelled)$")
class OrderUpdate(BaseModel):
customer_name: str | None = Field(default=None, min_length=1, max_length=80)
customer_phone: str | None = Field(default=None, max_length=30)
order_type: str | None = Field(default=None, pattern=r"^(product|ritual)$")
item_name: str | None = Field(default=None, min_length=1, max_length=120)
amount: float | None = Field(default=None, ge=0)
status: str | None = Field(default=None, pattern=r"^(pending|paid|completed|cancelled)$")
class Order(BaseModel):
id: int
temple_id: int
customer_name: str
customer_phone: str | None = None
order_type: str
item_name: str
amount: float = 0
status: str
created_at: str
class WeChatJSAPIPayRequest(BaseModel):
order_id: int
openid: str = Field(..., min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=127)
class WeChatJSAPIPayResponse(BaseModel):
appId: str
timeStamp: str
nonceStr: str
package: str
signType: str
paySign: str
out_trade_no: str
prepay_id: str
+22
View File
@@ -0,0 +1,22 @@
import hashlib
import secrets
def hash_password(password: str) -> str:
import bcrypt
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
import bcrypt
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
def hash_secret(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def create_token() -> str:
return secrets.token_urlsafe(32)
+20
View File
@@ -0,0 +1,20 @@
from typing import Annotated
from fastapi import Header, HTTPException, status
from app.database import Database
TempleId = Annotated[int, Header(alias="X-Temple-Id")]
def ensure_temple_exists(db: Database, temple_id: int) -> None:
row = db.execute(
"SELECT id FROM temples WHERE id = ? AND is_active = 1",
(temple_id,),
).fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Temple not found",
)
+152
View File
@@ -0,0 +1,152 @@
import base64
import json
import secrets
import time
from pathlib import Path
from typing import Any
from urllib import request as urllib_request
from urllib.error import HTTPError, URLError
from fastapi import HTTPException, status
from app.config import wechat_pay_settings
WECHAT_SIGNATURE_TYPE = "WECHATPAY2-SHA256-RSA2048"
def ensure_wechat_pay_configured() -> None:
if not wechat_pay_settings.is_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="微信支付配置未完成,请先填写 WECHAT_PAY_* 配置。",
)
def load_private_key():
from cryptography.hazmat.primitives import serialization
key_data = Path(wechat_pay_settings.private_key_path).read_bytes()
return serialization.load_pem_private_key(key_data, password=None)
def load_public_key():
from cryptography.hazmat.primitives import serialization
if not wechat_pay_settings.public_key_path:
return None
key_data = Path(wechat_pay_settings.public_key_path).read_bytes()
return serialization.load_pem_public_key(key_data)
def rsa_sign(message: str) -> str:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
signature = load_private_key().sign(
message.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(signature).decode("ascii")
def rsa_verify(message: str, signature: str) -> bool:
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
public_key = load_public_key()
if public_key is None:
return bool(wechat_pay_settings.skip_notify_verify)
try:
public_key.verify(
base64.b64decode(signature),
message.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
except InvalidSignature:
return False
return True
def build_authorization(method: str, url_path: str, body: str) -> str:
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
message = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
signature = rsa_sign(message)
return (
f'{WECHAT_SIGNATURE_TYPE} mchid="{wechat_pay_settings.mch_id}",'
f'nonce_str="{nonce}",signature="{signature}",'
f'timestamp="{timestamp}",serial_no="{wechat_pay_settings.mch_serial_no}"'
)
def post_wechat_json(url_path: str, payload: dict[str, Any]) -> dict[str, Any]:
ensure_wechat_pay_configured()
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
url = f"{wechat_pay_settings.api_base_url.rstrip('/')}{url_path}"
request = urllib_request.Request(
url,
data=body.encode("utf-8"),
method="POST",
headers={
"Accept": "application/json",
"Authorization": build_authorization("POST", url_path, body),
"Content-Type": "application/json",
"User-Agent": "py-web-wechat-pay/0.1",
},
)
try:
with urllib_request.urlopen(request, timeout=10) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
error_body = exc.read().decode("utf-8")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"微信支付请求失败:{error_body or exc.reason}",
) from exc
except URLError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"微信支付网络请求失败:{exc.reason}",
) from exc
def build_jsapi_pay_params(prepay_id: str) -> dict[str, str]:
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
package = f"prepay_id={prepay_id}"
message = f"{wechat_pay_settings.appid}\n{timestamp}\n{nonce}\n{package}\n"
return {
"appId": wechat_pay_settings.appid,
"timeStamp": timestamp,
"nonceStr": nonce,
"package": package,
"signType": "RSA",
"paySign": rsa_sign(message),
}
def decrypt_notify_resource(resource: dict[str, str]) -> dict[str, Any]:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
ciphertext = base64.b64decode(resource["ciphertext"])
nonce = resource["nonce"].encode("utf-8")
associated_data = resource.get("associated_data", "").encode("utf-8")
aesgcm = AESGCM(wechat_pay_settings.api_v3_key.encode("utf-8"))
plaintext = aesgcm.decrypt(nonce, ciphertext, associated_data)
return json.loads(plaintext.decode("utf-8"))
def verify_notify_signature(headers: dict[str, str], body: str) -> bool:
timestamp = headers.get("wechatpay-timestamp", "")
nonce = headers.get("wechatpay-nonce", "")
signature = headers.get("wechatpay-signature", "")
if not timestamp or not nonce or not signature:
return False
message = f"{timestamp}\n{nonce}\n{body}\n"
return rsa_verify(message, signature)