霖雨寺
This commit is contained in:
+105
@@ -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`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+138
@@ -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
@@ -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
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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": "成功"}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user