feat: implement Phase 2 auth and quota foundation
This commit is contained in:
parent
bed90c1c07
commit
fdcee9c349
@ -1,6 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "@/components/icons";
|
||||
import { PreviewNotice, StatusCard } from "@/components/ui";
|
||||
import { AccountPanel } from "@/components/account-panel";
|
||||
|
||||
export const metadata = { title: "账户中心" };
|
||||
|
||||
@ -8,12 +6,7 @@ export default function AccountPage() {
|
||||
return (
|
||||
<div className="container-shell py-16 sm:py-24">
|
||||
<div className="flex flex-col justify-between gap-5 border-b border-line pb-10 sm:flex-row sm:items-end"><div><p className="eyebrow eyebrow-blue">Account</p><h1 className="mt-5 text-4xl font-semibold tracking-[-0.05em]">账户中心</h1></div><span className="text-sm text-subtle">未登录状态预览</span></div>
|
||||
<div className="mt-8"><PreviewNotice>账户数据尚未接入。以下字段只用于检查信息层级,不代表真实用户状态。</PreviewNotice></div>
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description="待认证" tone="info" /><StatusCard title="会员计划" description="Free / 待启用" /><StatusCard title="本周期额度" description="待服务返回" /><StatusCard title="验证状态" description="邮箱、手机均待接入" tone="warning" /></div>
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold">基本资料</h2><dl className="mt-6 space-y-5 text-sm"><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">邮箱</dt><dd className="text-subtle">未登录</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">手机号</dt><dd className="text-subtle">未绑定</dd></div><div className="flex justify-between gap-4"><dt className="text-muted">角色</dt><dd className="text-subtle">user / 待服务返回</dd></div></dl><Link className="button-ghost mt-7 w-full" href="/login">前往登录 <ArrowRight /></Link></section>
|
||||
<section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">History</p><h2 className="mt-3 text-xl font-semibold">TTS 历史</h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts">打开工作台</Link></div><div className="mt-8 rounded-xl border border-dashed border-line p-8 text-center"><p className="font-semibold">暂无可显示的历史</p><p className="mt-2 text-sm leading-6 text-muted">登录和 TTS 任务服务完成后,历史记录将在此按用户归属显示。</p></div></section>
|
||||
</div>
|
||||
<AccountPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
60
components/account-panel.tsx
Normal file
60
components/account-panel.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { ArrowRight } from "@/components/icons";
|
||||
import { PreviewNotice, StatusCard } from "@/components/ui";
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
||||
|
||||
type User = { email: string; role: "user" | "admin"; plan: "free" | "vip"; status: string; email_verified: boolean; phone_verified: boolean };
|
||||
type Usage = { period_start: string; period_end: string; limit: number; adjustment: number; used: number; reserved: number; available: number; plan: string };
|
||||
|
||||
export function AccountPanel() {
|
||||
const [csrf, setCsrf] = useState("");
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [usage, setUsage] = useState<Usage | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [passwordMessage, setPasswordMessage] = useState("");
|
||||
|
||||
async function load() {
|
||||
const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
|
||||
const csrfBody = await csrfResponse.json();
|
||||
setCsrf(csrfBody.csrf_token ?? "");
|
||||
const [meResponse, usageResponse] = await Promise.all([
|
||||
fetch(`${apiBase}/auth/me`, { credentials: "include" }),
|
||||
fetch(`${apiBase}/account/usage`, { credentials: "include" }),
|
||||
]);
|
||||
if (!meResponse.ok) throw new Error("请先登录后查看账户");
|
||||
setUser(await meResponse.json());
|
||||
if (usageResponse.ok) setUsage(await usageResponse.json());
|
||||
}
|
||||
|
||||
useEffect(() => { load().catch((loadError) => setError(loadError instanceof Error ? loadError.message : "无法加载账户")); }, []);
|
||||
|
||||
async function logout() {
|
||||
await fetch(`${apiBase}/auth/logout`, { method: "POST", credentials: "include", headers: { "X-CSRF-Token": csrf } });
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
async function changePassword(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPasswordMessage("");
|
||||
const data = new FormData(event.currentTarget);
|
||||
const response = await fetch(`${apiBase}/auth/password/change`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ current_password: data.get("current_password"), new_password: data.get("new_password") }) });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) setPasswordMessage(body.error?.message ?? "密码修改失败");
|
||||
else { setPasswordMessage("密码已修改,其他会话已撤销"); event.currentTarget.reset(); }
|
||||
}
|
||||
|
||||
if (error) return <div className="mt-8"><PreviewNotice>{error}。<Link className="text-cyan underline" href="/login">前往登录</Link></PreviewNotice></div>;
|
||||
if (!user || !usage) return <div className="mt-8"><StatusCard title="正在加载账户" description="正在从业务 API 读取当前用户和额度。" tone="info" /></div>;
|
||||
|
||||
return <>
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div>
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold">基本资料</h2><dl className="mt-6 space-y-5 text-sm"><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">邮箱</dt><dd className="text-copy">{user.email}</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">角色</dt><dd className="text-copy">{user.role}</dd></div><div className="flex justify-between gap-4"><dt className="text-muted">额度周期</dt><dd className="text-right text-subtle">{new Date(usage.period_start).toLocaleDateString()} 至 {new Date(usage.period_end).toLocaleDateString()}</dd></div></dl><button className="button-ghost mt-7 w-full" onClick={logout} type="button">退出登录 <ArrowRight /></button></section>
|
||||
<div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold">额度明细</h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts">打开工作台</Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold">修改密码</h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password">当前密码</label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password">新密码</label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit">更新密码</button></form></section></div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@ -1,16 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { PreviewNotice } from "@/components/ui";
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
||||
|
||||
export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
const isLogin = mode === "login";
|
||||
const [csrf, setCsrf] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${apiBase}/auth/csrf`, { credentials: "include" })
|
||||
.then((response) => response.json())
|
||||
.then((body) => setCsrf(body.csrf_token ?? ""))
|
||||
.catch(() => setError("暂时无法连接认证服务"));
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
if (!isLogin && password !== confirmation) {
|
||||
setError("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/auth/${isLogin ? "login" : "register"}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(csrf ? { "X-CSRF-Token": csrf } : {}) },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试");
|
||||
if (isLogin) {
|
||||
window.location.assign("/account");
|
||||
} else {
|
||||
setMessage("注册成功,请使用新账户登录");
|
||||
window.setTimeout(() => window.location.assign("/login"), 500);
|
||||
}
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8">
|
||||
<PreviewNotice>认证服务尚未接入。当前表单仅用于检查布局,不会提交或创建账户。</PreviewNotice>
|
||||
<form className="mt-8 space-y-5">
|
||||
<div><label className="field-label" htmlFor="email">邮箱</label><input className="field-input" disabled id="email" placeholder="name@example.com" type="email" /></div>
|
||||
<div><label className="field-label" htmlFor="password">密码</label><input className="field-input" disabled id="password" placeholder="至少 8 位字符" type="password" /></div>
|
||||
{!isLogin ? <div><label className="field-label" htmlFor="password-confirm">确认密码</label><input className="field-input" disabled id="password-confirm" placeholder="再次输入密码" type="password" /></div> : null}
|
||||
<button className="button-primary w-full" disabled type="submit">{isLogin ? "登录(待接入)" : "注册(待接入)"}</button>
|
||||
<PreviewNotice>认证服务已接入测试环境。邮箱注册、登录和会话行为使用测试数据库,验证渠道仍未启用。</PreviewNotice>
|
||||
<form className="mt-8 space-y-5" onSubmit={submit}>
|
||||
<div><label className="field-label" htmlFor="email">邮箱</label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com" required type="email" value={email} /></div>
|
||||
<div><label className="field-label" htmlFor="password">密码</label><input className="field-input" id="password" minLength={8} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位字符" required type="password" value={password} /></div>
|
||||
{!isLogin ? <div><label className="field-label" htmlFor="password-confirm">确认密码</label><input className="field-input" id="password-confirm" minLength={8} onChange={(event) => setConfirmation(event.target.value)} placeholder="再次输入密码" required type="password" value={confirmation} /></div> : null}
|
||||
{error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
|
||||
{message ? <p className="rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-sm leading-6 text-success" role="status">{message}</p> : null}
|
||||
<button className="button-primary w-full" disabled={busy || !csrf} type="submit">{busy ? "处理中…" : isLogin ? "登录" : "注册"}</button>
|
||||
</form>
|
||||
<p className="mt-6 text-center text-sm text-muted">{isLogin ? "还没有账户?" : "已有账户?"}{" "}<Link className="text-cyan hover:text-copy" href={isLogin ? "/register" : "/login"}>{isLogin ? "去注册" : "去登录"}</Link></p>
|
||||
</div>
|
||||
|
||||
19
infra/README.md
Normal file
19
infra/README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Phase 2 基础设施
|
||||
|
||||
本目录只保存不含秘密的部署模板:
|
||||
|
||||
- `caddy/Caddyfile`:测试机 IP 的内部 CA HTTPS、Web/API 反向代理。
|
||||
- `systemd/kaotings-api.service`:FastAPI 自动重启服务。
|
||||
- `systemd/kaotings-web.service`:Next.js 自动重启服务。
|
||||
|
||||
数据库账号密码、`CSRF_SECRET` 和 Caddy 私有 CA 不进入 Git。服务器上的 `/etc/kaotings/api.env` 必须由 root 拥有且权限为 `0600`。
|
||||
|
||||
Phase 2 测试入口:
|
||||
|
||||
- HTTPS:`https://192.168.199.22/`
|
||||
- API:通过同源 `https://192.168.199.22/api/v1/*`
|
||||
- API 回环端口:`127.0.0.1:8000`,不对局域网开放
|
||||
- Web 回环端口:`127.0.0.1:3000`,不对局域网开放
|
||||
- PostgreSQL:`127.0.0.1:5432`,不对局域网开放
|
||||
|
||||
Caddy `tls internal` 生成的根证书需要在测试浏览器/设备中导入后,HTTPS 才会显示为受信任。根证书可以从服务器的 Caddy 数据目录受控导出;私钥不得导出或提交。
|
||||
16
infra/caddy/Caddyfile
Normal file
16
infra/caddy/Caddyfile
Normal file
@ -0,0 +1,16 @@
|
||||
https://192.168.199.22 {
|
||||
tls internal
|
||||
encode gzip
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8000
|
||||
}
|
||||
|
||||
handle /healthz {
|
||||
reverse_proxy 127.0.0.1:8000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
}
|
||||
19
infra/systemd/kaotings-api.service
Normal file
19
infra/systemd/kaotings-api.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Kaotings business API
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=flym
|
||||
Group=flym
|
||||
WorkingDirectory=/home/flym/kaotings-api
|
||||
EnvironmentFile=/etc/kaotings/api.env
|
||||
ExecStart=/home/flym/kaotings-api/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
19
infra/systemd/kaotings-web.service
Normal file
19
infra/systemd/kaotings-web.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Kaotings website skeleton
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=flym
|
||||
Group=flym
|
||||
WorkingDirectory=/home/flym/kaotings-www
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/local/bin/npm run start -- -H 127.0.0.1 -p 3000
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
11
services/api/.env.example
Normal file
11
services/api/.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
DATABASE_URL=postgresql://kaotings_app:change-me@127.0.0.1:5432/kaotings
|
||||
APP_ENV=test
|
||||
ALLOWED_ORIGINS=http://192.168.199.22:3000,https://192.168.199.22
|
||||
SESSION_COOKIE_NAME=kaotings_session
|
||||
SESSION_COOKIE_SECURE=false
|
||||
SESSION_TTL_SECONDS=2592000
|
||||
CSRF_SECRET=replace-with-a-random-secret
|
||||
EMAIL_VERIFICATION_ENABLED=false
|
||||
PHONE_VERIFICATION_ENABLED=false
|
||||
TEST_FREE_PERIOD_LIMIT=10000
|
||||
TEST_VIP_PERIOD_LIMIT=100000
|
||||
0
services/api/app/__init__.py
Normal file
0
services/api/app/__init__.py
Normal file
46
services/api/app/config.py
Normal file
46
services/api/app/config.py
Normal file
@ -0,0 +1,46 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def as_bool(value: str | None, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str
|
||||
app_env: str
|
||||
allowed_origins: tuple[str, ...]
|
||||
session_cookie_name: str
|
||||
session_cookie_secure: bool
|
||||
session_ttl_seconds: int
|
||||
csrf_secret: str
|
||||
email_verification_enabled: bool
|
||||
phone_verification_enabled: bool
|
||||
test_free_period_limit: int
|
||||
test_vip_period_limit: int
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
raise RuntimeError("DATABASE_URL is required")
|
||||
origins = tuple(item.strip().rstrip("/") for item in os.getenv("ALLOWED_ORIGINS", "").split(",") if item.strip())
|
||||
return cls(
|
||||
database_url=database_url,
|
||||
app_env=os.getenv("APP_ENV", "test"),
|
||||
allowed_origins=origins,
|
||||
session_cookie_name=os.getenv("SESSION_COOKIE_NAME", "kaotings_session"),
|
||||
session_cookie_secure=as_bool(os.getenv("SESSION_COOKIE_SECURE")),
|
||||
session_ttl_seconds=int(os.getenv("SESSION_TTL_SECONDS", "2592000")),
|
||||
csrf_secret=os.getenv("CSRF_SECRET", "development-only-change-me"),
|
||||
email_verification_enabled=as_bool(os.getenv("EMAIL_VERIFICATION_ENABLED")),
|
||||
phone_verification_enabled=as_bool(os.getenv("PHONE_VERIFICATION_ENABLED")),
|
||||
test_free_period_limit=int(os.getenv("TEST_FREE_PERIOD_LIMIT", "10000")),
|
||||
test_vip_period_limit=int(os.getenv("TEST_VIP_PERIOD_LIMIT", "100000")),
|
||||
)
|
||||
|
||||
|
||||
settings = Settings.from_env()
|
||||
14
services/api/app/db.py
Normal file
14
services/api/app/db.py
Normal file
@ -0,0 +1,14 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def get_connection() -> Generator[psycopg.Connection, None, None]:
|
||||
connection = psycopg.connect(settings.database_url, row_factory=dict_row)
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
34
services/api/app/init_admin.py
Normal file
34
services/api/app/init_admin.py
Normal file
@ -0,0 +1,34 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import psycopg
|
||||
|
||||
from .config import settings
|
||||
from .security import hash_password
|
||||
|
||||
|
||||
def main() -> None:
|
||||
email = os.getenv("ADMIN_EMAIL", "").strip().lower()
|
||||
password = os.getenv("ADMIN_PASSWORD", "")
|
||||
if not email or not password:
|
||||
raise SystemExit("ADMIN_EMAIL and ADMIN_PASSWORD must be provided through the process environment")
|
||||
if len(password) < 8:
|
||||
raise SystemExit("ADMIN_PASSWORD must be at least 8 characters")
|
||||
with psycopg.connect(settings.database_url) as connection:
|
||||
existing = connection.execute("SELECT id FROM users WHERE email = %s", (email,)).fetchone()
|
||||
if existing:
|
||||
raise SystemExit("admin email already exists; refusing to overwrite")
|
||||
row = connection.execute(
|
||||
"""
|
||||
INSERT INTO users(email, password_hash, role, plan, email_verified)
|
||||
VALUES (%s, %s, 'admin', 'free', true)
|
||||
RETURNING id
|
||||
""",
|
||||
(email, hash_password(password)),
|
||||
).fetchone()
|
||||
connection.commit()
|
||||
print(f"created admin {row[0]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
283
services/api/app/main.py
Normal file
283
services/api/app/main.py
Normal file
@ -0,0 +1,283 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from psycopg import Connection
|
||||
|
||||
from .config import settings
|
||||
from .db import get_connection
|
||||
from .schemas import (
|
||||
AuthResponse,
|
||||
LoginRequest,
|
||||
MembershipRequest,
|
||||
PasswordChangeRequest,
|
||||
QuotaAdjustmentRequest,
|
||||
RegisterRequest,
|
||||
StatusRequest,
|
||||
UserPublic,
|
||||
VerificationConfirmRequest,
|
||||
VerificationSendRequest,
|
||||
normalize_email,
|
||||
)
|
||||
from .security import (
|
||||
check_origin,
|
||||
clear_session_cookie,
|
||||
code_digest,
|
||||
current_user,
|
||||
hash_password,
|
||||
new_csrf_token,
|
||||
new_token,
|
||||
password_hasher,
|
||||
rate_limiter,
|
||||
require_admin,
|
||||
require_csrf,
|
||||
set_csrf_cookie,
|
||||
set_session_cookie,
|
||||
token_digest,
|
||||
utc_now,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
app = FastAPI(title="Kaotings Business API", version="0.2.0", docs_url=None if settings.app_env == "production" else "/docs")
|
||||
|
||||
|
||||
def error(code: str, message: str, http_status: int) -> HTTPException:
|
||||
return HTTPException(status_code=http_status, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
def period_bounds(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||
current = now or utc_now()
|
||||
start = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if start.month == 12:
|
||||
end = start.replace(year=start.year + 1, month=1)
|
||||
else:
|
||||
end = start.replace(month=start.month + 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def effective_plan(connection: Connection, user: dict) -> str:
|
||||
if user["role"] == "admin":
|
||||
return user["plan"]
|
||||
grant = connection.execute(
|
||||
"""
|
||||
SELECT plan FROM membership_grants
|
||||
WHERE user_id = %s AND revoked_at IS NULL AND starts_at <= now()
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
ORDER BY starts_at DESC LIMIT 1
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
return grant["plan"] if grant else "free"
|
||||
|
||||
|
||||
def user_public(connection: Connection, user: dict) -> UserPublic:
|
||||
return UserPublic(
|
||||
id=user["id"], email=user["email"], phone=user["phone"], role=user["role"], plan=effective_plan(connection, user),
|
||||
status=user["status"], email_verified=user["email_verified"], phone_verified=user["phone_verified"],
|
||||
created_at=user["created_at"], last_login_at=user["last_login_at"],
|
||||
)
|
||||
|
||||
|
||||
def ensure_quota(connection: Connection, user_id: UUID, plan: str) -> dict:
|
||||
start, end = period_bounds()
|
||||
policy = connection.execute("SELECT period_limit FROM plan_policies WHERE code = %s", (plan,)).fetchone()
|
||||
if not policy:
|
||||
raise error("POLICY_MISSING", "权益策略未配置", 500)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO quota_accounts(user_id, period_start, period_end, limit_snapshot)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, period_start, period_end) DO NOTHING
|
||||
""",
|
||||
(user_id, start, end, policy["period_limit"]),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM quota_accounts WHERE user_id = %s AND period_start = %s AND period_end = %s",
|
||||
(user_id, start, end),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def usage_view(quota: dict) -> dict[str, Any]:
|
||||
available = max(0, quota["limit_snapshot"] + quota["adjustment"] - quota["used"] - quota["reserved"])
|
||||
return {
|
||||
"period_start": quota["period_start"], "period_end": quota["period_end"], "limit": quota["limit_snapshot"],
|
||||
"adjustment": quota["adjustment"], "used": quota["used"], "reserved": quota["reserved"], "available": available,
|
||||
}
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error_handler(_: Request, exc: HTTPException):
|
||||
detail = exc.detail if isinstance(exc.detail, dict) else {"code": "REQUEST_FAILED", "message": str(exc.detail)}
|
||||
return JSONResponse(status_code=exc.status_code, content={"error": detail})
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def health(connection: Connection = Depends(get_connection)):
|
||||
connection.execute("SELECT 1")
|
||||
return {"status": "ok", "database": "ok", "service": "api"}
|
||||
|
||||
|
||||
@app.get("/api/v1/auth/csrf")
|
||||
def csrf(response: Response, request: Request):
|
||||
check_origin(request)
|
||||
token = request.cookies.get("kaotings_csrf") or new_csrf_token()
|
||||
set_csrf_cookie(response, token)
|
||||
return {"csrf_token": token}
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/register", response_model=AuthResponse, status_code=201)
|
||||
def register(payload: RegisterRequest, request: Request, connection: Connection = Depends(get_connection)):
|
||||
check_origin(request)
|
||||
key = f"register:{request.client.host if request.client else 'unknown'}"
|
||||
if not rate_limiter.allow(key, 5, 3600):
|
||||
raise error("RATE_LIMITED", "请求过于频繁", 429)
|
||||
email = normalize_email(str(payload.email))
|
||||
try:
|
||||
user = connection.execute(
|
||||
"""
|
||||
INSERT INTO users(email, password_hash) VALUES (%s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(email, hash_password(payload.password)),
|
||||
).fetchone()
|
||||
ensure_quota(connection, user["id"], "free")
|
||||
connection.commit()
|
||||
except psycopg.errors.UniqueViolation:
|
||||
connection.rollback()
|
||||
raise error("REGISTRATION_FAILED", "注册信息不可用", 409)
|
||||
return {"user": user_public(connection, user)}
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/login", response_model=AuthResponse)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, connection: Connection = Depends(get_connection)):
|
||||
check_origin(request)
|
||||
key = f"login:{request.client.host if request.client else 'unknown'}:{normalize_email(str(payload.email))}"
|
||||
if not rate_limiter.allow(key, 10, 300):
|
||||
raise error("RATE_LIMITED", "请求过于频繁", 429)
|
||||
user = connection.execute("SELECT * FROM users WHERE email = %s", (normalize_email(str(payload.email)),)).fetchone()
|
||||
if not user or not verify_password(payload.password, user["password_hash"]):
|
||||
raise error("LOGIN_FAILED", "邮箱或密码错误", 401)
|
||||
if user["status"] != "active":
|
||||
raise error("ACCOUNT_DISABLED", "账户不可用", 403)
|
||||
token = new_token()
|
||||
connection.execute("INSERT INTO sessions(user_id, token_hash, expires_at) VALUES (%s, %s, %s)", (user["id"], token_digest(token), utc_now() + timedelta(seconds=settings.session_ttl_seconds)))
|
||||
user = connection.execute("UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s RETURNING *", (user["id"],)).fetchone()
|
||||
ensure_quota(connection, user["id"], effective_plan(connection, user))
|
||||
connection.commit()
|
||||
set_session_cookie(response, token)
|
||||
return {"user": user_public(connection, user)}
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/logout", status_code=204, dependencies=[Depends(require_csrf)])
|
||||
def logout(response: Response, request: Request, connection: Connection = Depends(get_connection)):
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if token:
|
||||
connection.execute("UPDATE sessions SET revoked_at = now() WHERE token_hash = %s", (token_digest(token),))
|
||||
connection.commit()
|
||||
clear_session_cookie(response)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/api/v1/auth/me", response_model=UserPublic)
|
||||
def me(user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
|
||||
return user_public(connection, user)
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/password/change", dependencies=[Depends(require_csrf)])
|
||||
def change_password(payload: PasswordChangeRequest, request: Request, connection: Connection = Depends(get_connection), user: dict = Depends(current_user)):
|
||||
if not verify_password(payload.current_password, user["password_hash"]):
|
||||
raise error("PASSWORD_INVALID", "当前密码错误", 400)
|
||||
connection.execute("UPDATE users SET password_hash = %s, updated_at = now() WHERE id = %s", (hash_password(payload.new_password), user["id"]))
|
||||
connection.execute("UPDATE sessions SET revoked_at = now() WHERE user_id = %s AND id <> %s", (user["id"], user["session_id"]))
|
||||
connection.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/v1/account/usage")
|
||||
def account_usage(user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
|
||||
quota = ensure_quota(connection, user["id"], effective_plan(connection, user))
|
||||
connection.commit()
|
||||
return {"plan": effective_plan(connection, user), **usage_view(quota)}
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/verification/send")
|
||||
def verification_send(payload: VerificationSendRequest, request: Request, user: dict = Depends(current_user)):
|
||||
check_origin(request)
|
||||
enabled = settings.email_verification_enabled if payload.channel == "email" else settings.phone_verification_enabled
|
||||
if not enabled:
|
||||
raise error("VERIFICATION_NOT_ENABLED", "验证渠道尚未启用", 503)
|
||||
raise error("VERIFICATION_PROVIDER_UNAVAILABLE", "验证渠道尚未配置", 503)
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/verification/confirm", dependencies=[Depends(require_csrf)])
|
||||
def verification_confirm(payload: VerificationConfirmRequest, request: Request, connection: Connection = Depends(get_connection), user: dict = Depends(current_user)):
|
||||
check_origin(request)
|
||||
challenge = connection.execute("SELECT * FROM verification_challenges WHERE id = %s AND user_id = %s", (payload.challenge_id, user["id"])).fetchone()
|
||||
if not challenge or challenge["consumed_at"] or challenge["expires_at"] <= utc_now() or challenge["attempt_count"] >= 5:
|
||||
raise error("VERIFICATION_INVALID", "验证挑战无效或已过期", 400)
|
||||
if not __import__("hmac").compare_digest(challenge["code_digest"], code_digest(payload.code)):
|
||||
connection.execute("UPDATE verification_challenges SET attempt_count = attempt_count + 1 WHERE id = %s", (payload.challenge_id,))
|
||||
connection.commit()
|
||||
raise error("VERIFICATION_INVALID", "验证码错误", 400)
|
||||
connection.execute("UPDATE verification_challenges SET consumed_at = now() WHERE id = %s", (payload.challenge_id,))
|
||||
if challenge["channel"] == "email":
|
||||
connection.execute("UPDATE users SET email_verified = true, updated_at = now() WHERE id = %s", (user["id"],))
|
||||
else:
|
||||
connection.execute("UPDATE users SET phone_verified = true, updated_at = now() WHERE id = %s", (user["id"],))
|
||||
connection.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/users")
|
||||
def admin_users(user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
|
||||
rows = connection.execute("SELECT id, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at FROM users ORDER BY created_at DESC LIMIT 100").fetchall()
|
||||
return {"items": rows}
|
||||
|
||||
|
||||
@app.patch("/api/v1/admin/users/{user_id}/status", dependencies=[Depends(require_csrf)])
|
||||
def admin_status(user_id: UUID, payload: StatusRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
||||
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||
if not target:
|
||||
raise error("NOT_FOUND", "用户不存在", 404)
|
||||
if target["role"] == "admin" and payload.status == "disabled":
|
||||
count = connection.execute("SELECT count(*) FROM users WHERE role = 'admin' AND status = 'active'").fetchone()["count"]
|
||||
if count <= 1:
|
||||
raise error("LAST_ADMIN_PROTECTED", "不能禁用最后一个可用管理员", 409)
|
||||
updated = connection.execute("UPDATE users SET status = %s, updated_at = now() WHERE id = %s RETURNING id, status", (payload.status, user_id)).fetchone()
|
||||
connection.execute("INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_status', 'user', %s, %s, %s, %s)", (actor["id"], user_id, {"status": target["status"]}, {"status": updated["status"]}, payload.reason))
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
|
||||
@app.put("/api/v1/admin/users/{user_id}/membership", dependencies=[Depends(require_csrf)])
|
||||
def admin_membership(user_id: UUID, payload: MembershipRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
||||
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||
if not target:
|
||||
raise error("NOT_FOUND", "用户不存在", 404)
|
||||
starts_at = payload.starts_at or utc_now()
|
||||
connection.execute("UPDATE membership_grants SET revoked_at = now() WHERE user_id = %s AND revoked_at IS NULL", (user_id,))
|
||||
grant = connection.execute("INSERT INTO membership_grants(user_id, plan, starts_at, expires_at, created_by, reason) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, plan, starts_at, expires_at", (user_id, payload.plan, starts_at, payload.expires_at, actor["id"], payload.reason)).fetchone()
|
||||
connection.execute("UPDATE users SET plan = %s, updated_at = now() WHERE id = %s", (payload.plan, user_id))
|
||||
connection.execute("INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'membership', 'user', %s, %s, %s, %s)", (actor["id"], user_id, {"plan": target["plan"]}, {"plan": payload.plan, "expires_at": payload.expires_at.isoformat() if payload.expires_at else None}, payload.reason))
|
||||
connection.commit()
|
||||
return grant
|
||||
|
||||
|
||||
@app.post("/api/v1/admin/users/{user_id}/quota-adjustments", dependencies=[Depends(require_csrf)])
|
||||
def admin_quota(user_id: UUID, payload: QuotaAdjustmentRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
||||
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||
if not target:
|
||||
raise error("NOT_FOUND", "用户不存在", 404)
|
||||
quota = ensure_quota(connection, user_id, effective_plan(connection, target))
|
||||
existing = connection.execute("SELECT id FROM usage_records WHERE user_id = %s AND idempotency_key = %s", (user_id, payload.idempotency_key)).fetchone()
|
||||
if existing:
|
||||
raise error("IDEMPOTENCY_CONFLICT", "该调整已提交", 409)
|
||||
connection.execute("UPDATE quota_accounts SET adjustment = adjustment + %s, version = version + 1 WHERE id = %s", (payload.amount, quota["id"]))
|
||||
connection.execute("INSERT INTO usage_records(user_id, quota_account_id, type, amount, idempotency_key) VALUES (%s, %s, 'adjust', %s, %s)", (user_id, quota["id"], payload.amount, payload.idempotency_key))
|
||||
connection.execute("INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, after_value, reason) VALUES (%s, 'quota_adjustment', 'user', %s, %s, %s)", (actor["id"], user_id, {"amount": payload.amount}, payload.reason))
|
||||
connection.commit()
|
||||
return {"status": "ok", "amount": payload.amount}
|
||||
25
services/api/app/migrate.py
Normal file
25
services/api/app/migrate.py
Normal file
@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
migration_dir = Path(__file__).resolve().parent.parent / "migrations"
|
||||
with psycopg.connect(settings.database_url) as connection:
|
||||
connection.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())")
|
||||
connection.commit()
|
||||
for path in sorted(migration_dir.glob("*.sql")):
|
||||
version = path.name
|
||||
applied = connection.execute("SELECT 1 FROM schema_migrations WHERE version = %s", (version,)).fetchone()
|
||||
if applied:
|
||||
continue
|
||||
connection.execute(path.read_text(encoding="utf-8"))
|
||||
connection.execute("INSERT INTO schema_migrations(version) VALUES (%s)", (version,))
|
||||
connection.commit()
|
||||
print(f"applied {version}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
69
services/api/app/schemas.py
Normal file
69
services/api/app/schemas.py
Normal file
@ -0,0 +1,69 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
id: UUID
|
||||
email: str | None
|
||||
phone: str | None
|
||||
role: Literal["user", "admin"]
|
||||
plan: Literal["free", "vip"]
|
||||
status: Literal["active", "disabled"]
|
||||
email_verified: bool
|
||||
phone_verified: bool
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
user: UserPublic
|
||||
|
||||
|
||||
class MembershipRequest(BaseModel):
|
||||
plan: Literal["free", "vip"]
|
||||
starts_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class StatusRequest(BaseModel):
|
||||
status: Literal["active", "disabled"]
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class QuotaAdjustmentRequest(BaseModel):
|
||||
amount: int
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
idempotency_key: str = Field(min_length=8, max_length=120)
|
||||
|
||||
|
||||
class VerificationConfirmRequest(BaseModel):
|
||||
challenge_id: UUID
|
||||
code: str = Field(min_length=4, max_length=12)
|
||||
|
||||
|
||||
class VerificationSendRequest(BaseModel):
|
||||
channel: Literal["email", "phone"]
|
||||
purpose: Literal["registration", "contact_binding", "contact_change", "password_reset"]
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
return value.strip().lower()
|
||||
127
services/api/app/security.py
Normal file
127
services/api/app/security.py
Normal file
@ -0,0 +1,127 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from psycopg import Connection
|
||||
|
||||
from .config import settings
|
||||
from .db import get_connection
|
||||
|
||||
password_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return password_hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return password_hasher.verify(password_hash, password)
|
||||
except (InvalidHashError, VerificationError):
|
||||
return False
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def new_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def code_digest(code: str) -> str:
|
||||
return hmac.new(settings.csrf_secret.encode(), code.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def check_origin(request: Request) -> None:
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin.rstrip("/") not in settings.allowed_origins:
|
||||
raise HTTPException(status_code=403, detail={"code": "ORIGIN_NOT_ALLOWED", "message": "请求来源不被允许"})
|
||||
|
||||
|
||||
def require_csrf(request: Request) -> None:
|
||||
check_origin(request)
|
||||
header = request.headers.get("x-csrf-token")
|
||||
cookie = request.cookies.get("kaotings_csrf")
|
||||
if not header or not cookie or not hmac.compare_digest(header, cookie):
|
||||
raise HTTPException(status_code=403, detail={"code": "CSRF_FAILED", "message": "请求校验失败"})
|
||||
|
||||
|
||||
def set_session_cookie(response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response) -> None:
|
||||
response.delete_cookie(settings.session_cookie_name, path="/")
|
||||
|
||||
|
||||
def set_csrf_cookie(response, token: str) -> None:
|
||||
response.set_cookie("kaotings_csrf", token, max_age=settings.session_ttl_seconds, httponly=False, secure=settings.session_cookie_secure, samesite="lax", path="/")
|
||||
|
||||
|
||||
def current_user(request: Request, connection: Connection = Depends(get_connection)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail={"code": "AUTH_REQUIRED", "message": "请先登录"})
|
||||
user = connection.execute(
|
||||
"""
|
||||
SELECT u.*, s.id AS session_id
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = %s AND s.revoked_at IS NULL AND s.expires_at > now()
|
||||
""",
|
||||
(token_digest(token),),
|
||||
).fetchone()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail={"code": "AUTH_REQUIRED", "message": "会话已失效"})
|
||||
if user["status"] != "active":
|
||||
raise HTTPException(status_code=403, detail={"code": "ACCOUNT_DISABLED", "message": "账户不可用"})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: dict = Depends(current_user)) -> dict:
|
||||
if user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail={"code": "FORBIDDEN", "message": "无权执行此操作"})
|
||||
return user
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self) -> None:
|
||||
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str, limit: int, window_seconds: int) -> bool:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
events = self._events[key]
|
||||
while events and events[0] <= now - window_seconds:
|
||||
events.popleft()
|
||||
if len(events) >= limit:
|
||||
return False
|
||||
events.append(now)
|
||||
return True
|
||||
|
||||
|
||||
rate_limiter = RateLimiter()
|
||||
115
services/api/migrations/001_initial.sql
Normal file
115
services/api/migrations/001_initial.sql
Normal file
@ -0,0 +1,115 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE,
|
||||
phone TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'vip')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
phone_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
CHECK (email IS NOT NULL OR phone IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plan_policies (
|
||||
code TEXT PRIMARY KEY CHECK (code IN ('free', 'vip')),
|
||||
period_limit BIGINT NOT NULL CHECK (period_limit >= 0),
|
||||
max_text_length INTEGER NOT NULL CHECK (max_text_length >= 0),
|
||||
max_concurrency INTEGER NOT NULL CHECK (max_concurrency >= 0),
|
||||
rate_limit INTEGER NOT NULL CHECK (rate_limit >= 0),
|
||||
allowed_voices JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS membership_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
plan TEXT NOT NULL CHECK (plan IN ('free', 'vip')),
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id),
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (expires_at IS NULL OR expires_at > starts_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS membership_active_idx ON membership_grants(user_id, starts_at, expires_at, revoked_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quota_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
period_start TIMESTAMPTZ NOT NULL,
|
||||
period_end TIMESTAMPTZ NOT NULL,
|
||||
limit_snapshot BIGINT NOT NULL CHECK (limit_snapshot >= 0),
|
||||
adjustment BIGINT NOT NULL DEFAULT 0,
|
||||
used BIGINT NOT NULL DEFAULT 0 CHECK (used >= 0),
|
||||
reserved BIGINT NOT NULL DEFAULT 0 CHECK (reserved >= 0),
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
UNIQUE (user_id, period_start, period_end),
|
||||
CHECK (period_end > period_start)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
quota_account_id UUID NOT NULL REFERENCES quota_accounts(id) ON DELETE CASCADE,
|
||||
task_id UUID,
|
||||
type TEXT NOT NULL CHECK (type IN ('reserve', 'consume', 'release', 'adjust')),
|
||||
amount BIGINT NOT NULL,
|
||||
idempotency_key TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS usage_adjustment_idem_idx ON usage_records(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_challenges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel TEXT NOT NULL CHECK (channel IN ('email', 'phone')),
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'contact_binding', 'contact_change', 'password_reset')),
|
||||
target TEXT NOT NULL,
|
||||
code_digest TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id UUID NOT NULL REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id UUID NOT NULL,
|
||||
before_value JSONB,
|
||||
after_value JSONB,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO plan_policies(code, period_limit, max_text_length, max_concurrency, rate_limit, allowed_voices)
|
||||
VALUES
|
||||
('free', 10000, 5000, 1, 10, '[]'::jsonb),
|
||||
('vip', 100000, 10000, 3, 30, '[]'::jsonb)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
6
services/api/requirements.txt
Normal file
6
services/api/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
fastapi==0.115.14
|
||||
uvicorn[standard]==0.34.3
|
||||
psycopg[binary]==3.2.9
|
||||
argon2-cffi==25.1.0
|
||||
pydantic==2.11.7
|
||||
email-validator==2.2.0
|
||||
Loading…
Reference in New Issue
Block a user