霖雨寺
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user