feat: implement Phase 2 auth and quota foundation

This commit is contained in:
flym 2026-09-08 23:42:28 +08:00
parent bed90c1c07
commit fdcee9c349
18 changed files with 924 additions and 15 deletions

View File

@ -1,6 +1,4 @@
import Link from "next/link"; import { AccountPanel } from "@/components/account-panel";
import { ArrowRight } from "@/components/icons";
import { PreviewNotice, StatusCard } from "@/components/ui";
export const metadata = { title: "账户中心" }; export const metadata = { title: "账户中心" };
@ -8,12 +6,7 @@ export default function AccountPage() {
return ( return (
<div className="container-shell py-16 sm:py-24"> <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="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> <AccountPanel />
<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>
</div> </div>
); );
} }

View 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>
</>;
}

View File

@ -1,16 +1,69 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { FormEvent, useEffect, useState } from "react";
import { PreviewNotice } from "@/components/ui"; import { PreviewNotice } from "@/components/ui";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
export function AuthForm({ mode }: { mode: "login" | "register" }) { export function AuthForm({ mode }: { mode: "login" | "register" }) {
const isLogin = mode === "login"; 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 ( return (
<div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8"> <div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8">
<PreviewNotice></PreviewNotice> <PreviewNotice>使</PreviewNotice>
<form className="mt-8 space-y-5"> <form className="mt-8 space-y-5" onSubmit={submit}>
<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="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" disabled id="password" placeholder="至少 8 位字符" type="password" /></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" disabled id="password-confirm" placeholder="再次输入密码" type="password" /></div> : null} {!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}
<button className="button-primary w-full" disabled type="submit">{isLogin ? "登录(待接入)" : "注册(待接入)"}</button> {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> </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> <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> </div>

19
infra/README.md Normal file
View 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
View 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
}
}

View 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

View 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
View 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

View File

View 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
View 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()

View 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
View 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}

View 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()

View 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()

View 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()

View 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;

View 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