霖雨寺

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
+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")