www_site/services/api/app/main.py

1154 lines
64 KiB
Python
Raw Normal View History

2026-09-08 16:42:13 +00:00
import asyncio
import hashlib
import json
import secrets
import smtplib
import ssl
import time
import unicodedata
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
2026-09-08 16:42:13 +00:00
from pathlib import Path
from typing import Any
from uuid import UUID
import psycopg
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
2026-09-08 16:42:13 +00:00
import httpx
from fastapi.responses import FileResponse, JSONResponse
from psycopg import Connection
2026-09-08 16:00:47 +00:00
from psycopg.types.json import Json
from .config import settings
from .db import get_connection
from .schemas import (
AdminUserCreateRequest,
AdminUserUpdateRequest,
AuthResponse,
LoginRequest,
MembershipRequest,
MembershipRevokeRequest,
PasswordChangeRequest,
QuotaAdjustmentRequest,
RegisterRequest,
RegisterResponse,
StatusRequest,
TtsSettingsTest,
TtsSettingsUpdate,
UserPublic,
VerificationConfirmRequest,
VerificationSendRequest,
VerificationResendRequest,
2026-09-08 16:42:13 +00:00
TtsTaskRequest,
TtsTaskPublic,
TtsVoicePublic,
UserProfileUpdateRequest,
normalize_email,
)
from .security import (
check_origin,
clear_session_cookie,
code_digest,
consume_captcha,
current_user,
hash_password,
new_csrf_token,
new_captcha,
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 send_verification_email(target: str, code: str) -> None:
if not all((settings.smtp_host, settings.smtp_user, settings.smtp_password, settings.smtp_from)):
raise RuntimeError("SMTP_NOT_CONFIGURED")
message = EmailMessage()
message["From"] = settings.smtp_from
message["To"] = target
message["Subject"] = "考町科技邮箱验证码"
message.set_content(f"您好,您的考町科技邮箱验证码是:{code}\n\n验证码 10 分钟内有效。如非本人操作,请忽略此邮件。")
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=ssl.create_default_context(), timeout=20) as smtp:
smtp.login(settings.smtp_user, settings.smtp_password)
smtp.send_message(message)
def create_email_challenge(connection: Connection, user_id: UUID, email: str) -> tuple[UUID, str]:
code = f"{secrets.randbelow(1_000_000):06d}"
row = connection.execute(
"INSERT INTO verification_challenges(user_id, channel, purpose, target, code_digest, expires_at) VALUES (%s, 'email', 'registration', %s, %s, now() + interval '10 minutes') RETURNING id",
(user_id, email, code_digest(code)),
).fetchone()
return row["id"], code
def send_sms_verification(phone: str) -> str:
if not all((settings.aliyun_access_key_id, settings.aliyun_access_key_secret, settings.aliyun_sign_name, settings.aliyun_template_code)):
raise RuntimeError("ALIYUN_PNVS_NOT_CONFIGURED")
from alibabacloud_dypnsapi20170525.client import Client
from alibabacloud_dypnsapi20170525 import models as dypns_models
from alibabacloud_tea_openapi import models as openapi_models
from alibabacloud_tea_util import models as util_models
client_config = openapi_models.Config(
access_key_id=settings.aliyun_access_key_id,
access_key_secret=settings.aliyun_access_key_secret,
endpoint="dypnsapi.aliyuncs.com",
)
client = Client(client_config)
out_id = secrets.token_urlsafe(24)
request = dypns_models.SendSmsVerifyCodeRequest(
phone_number=phone,
sign_name=settings.aliyun_sign_name,
template_code=settings.aliyun_template_code,
scheme_name=settings.aliyun_scheme_name or None,
country_code="86",
template_param='{"code":"##code##","min":"5"}',
out_id=out_id,
return_verify_code=False,
)
response = client.send_sms_verify_code_with_options(request, util_models.RuntimeOptions())
body = getattr(response, "body", response)
if getattr(body, "code", "") != "OK" or not getattr(body, "success", False):
raise RuntimeError(f"ALIYUN_PNVS_SEND_FAILED:{getattr(body, 'code', 'UNKNOWN')}")
return out_id
def check_sms_verification(phone: str, code: str, out_id: str) -> bool:
from alibabacloud_dypnsapi20170525.client import Client
from alibabacloud_dypnsapi20170525 import models as dypns_models
from alibabacloud_tea_openapi import models as openapi_models
from alibabacloud_tea_util import models as util_models
client = Client(openapi_models.Config(
access_key_id=settings.aliyun_access_key_id,
access_key_secret=settings.aliyun_access_key_secret,
endpoint="dypnsapi.aliyuncs.com",
))
request = dypns_models.CheckSmsVerifyCodeRequest(
phone_number=phone,
verify_code=code,
country_code="86",
scheme_name=settings.aliyun_scheme_name or None,
out_id=out_id,
case_auth_policy=1,
)
response = client.check_sms_verify_code_with_options(request, util_models.RuntimeOptions())
body = getattr(response, "body", response)
model = getattr(body, "model", None)
return getattr(model, "verify_result", "") == "PASS"
def create_sms_challenge(connection: Connection, user_id: UUID, phone: str) -> UUID:
out_id = send_sms_verification(phone)
row = connection.execute(
"""
INSERT INTO verification_challenges(user_id, channel, purpose, target, code_digest, provider, provider_request_id, expires_at)
VALUES (%s, 'phone', 'registration', %s, %s, 'aliyun_pnvs', %s, now() + interval '10 minutes') RETURNING id
""",
(user_id, phone, code_digest(out_id), out_id),
).fetchone()
return row["id"]
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"], username=user["username"], 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,
}
def normalize_tts_text(value: str) -> str:
return unicodedata.normalize("NFC", value.replace("\r\n", "\n").replace("\r", "\n"))
2026-09-08 16:42:13 +00:00
def task_view(connection: Connection, task: dict) -> dict[str, Any]:
audio = connection.execute("SELECT expires_at, status FROM audio_files WHERE task_id = %s", (task["id"],)).fetchone()
return {
"id": task["id"], "status": task["status"], "text_length": task["text_length"],
"voice_id": task["provider_voice_id"], "parameters": task["parameters"],
"error_code": task["error_code"], "audio_available": bool(audio and audio["status"] == "available"),
"audio_expires_at": audio["expires_at"] if audio else None, "created_at": task["created_at"],
"started_at": task["started_at"], "finished_at": task["finished_at"],
}
def claim_next_task() -> dict | None:
with psycopg.connect(settings.database_url, row_factory=psycopg.rows.dict_row) as connection:
task = connection.execute("SELECT * FROM tts_tasks WHERE status = 'queued' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1").fetchone()
if not task:
return None
lease_token = new_token()
updated = connection.execute("UPDATE tts_tasks SET status = 'running', lease_token = %s, lease_expires_at = now() + interval '5 minutes', started_at = now(), updated_at = now(), attempt_count = attempt_count + 1 WHERE id = %s RETURNING *", (lease_token, task["id"])).fetchone()
2026-09-08 16:42:13 +00:00
connection.commit()
return updated
def finish_task_failure(task: dict, code: str) -> None:
with psycopg.connect(settings.database_url, row_factory=psycopg.rows.dict_row) as connection:
updated = connection.execute("UPDATE tts_tasks SET status = 'failed', error_code = %s, finished_at = now(), updated_at = now(), lease_token = NULL, lease_expires_at = NULL WHERE id = %s AND status = 'running' AND lease_token = %s", (code, task["id"], task["lease_token"]))
if updated.rowcount != 1:
connection.rollback()
return
connection.execute("SELECT id FROM quota_accounts WHERE id = %s FOR UPDATE", (task["quota_account_id"],))
2026-09-08 16:42:13 +00:00
connection.execute("UPDATE quota_accounts SET reserved = GREATEST(0, reserved - %s), version = version + 1 WHERE id = %s", (task["reserved_amount"], task["quota_account_id"]))
connection.execute("INSERT INTO usage_records(user_id, quota_account_id, task_id, type, amount) VALUES (%s, %s, %s, 'release', %s)", (task["user_id"], task["quota_account_id"], task["id"], task["reserved_amount"]))
connection.commit()
def finish_task_success(task: dict, audio: bytes, mime_type: str) -> None:
storage_dir = Path(settings.audio_storage_dir)
storage_dir.mkdir(parents=True, exist_ok=True)
extension = "mp3" if mime_type == "audio/mpeg" else "wav"
storage_key = f"{task['user_id']}/{task['id']}.{extension}"
target = storage_dir / storage_key
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(audio)
checksum = hashlib.sha256(audio).hexdigest()
with psycopg.connect(settings.database_url, row_factory=psycopg.rows.dict_row) as connection:
current = connection.execute("SELECT status, lease_token FROM tts_tasks WHERE id = %s FOR UPDATE", (task["id"],)).fetchone()
if not current or current["status"] != "running" or current["lease_token"] != task["lease_token"]:
target.unlink(missing_ok=True)
connection.rollback()
return
connection.execute("SELECT id FROM quota_accounts WHERE id = %s FOR UPDATE", (task["quota_account_id"],))
2026-09-08 16:42:13 +00:00
connection.execute("INSERT INTO audio_files(task_id, owner_id, storage_key, mime_type, size_bytes, checksum) VALUES (%s, %s, %s, %s, %s, %s)", (task["id"], task["user_id"], storage_key, mime_type, len(audio), checksum))
connection.execute("UPDATE tts_tasks SET status = 'succeeded', finished_at = now(), updated_at = now(), lease_token = NULL, lease_expires_at = NULL WHERE id = %s AND status = 'running' AND lease_token = %s", (task["id"], task["lease_token"]))
2026-09-08 16:42:13 +00:00
connection.execute("UPDATE quota_accounts SET reserved = GREATEST(0, reserved - %s), used = used + %s, version = version + 1 WHERE id = %s", (task["reserved_amount"], task["reserved_amount"], task["quota_account_id"]))
connection.execute("INSERT INTO usage_records(user_id, quota_account_id, task_id, type, amount) VALUES (%s, %s, %s, 'consume', %s)", (task["user_id"], task["quota_account_id"], task["id"], task["reserved_amount"]))
connection.commit()
def recover_expired_tasks() -> None:
with psycopg.connect(settings.database_url, row_factory=psycopg.rows.dict_row) as connection:
tasks = connection.execute("SELECT * FROM tts_tasks WHERE status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at < now() FOR UPDATE SKIP LOCKED").fetchall()
for task in tasks:
connection.execute("UPDATE tts_tasks SET status = 'failed', error_code = 'WORKER_LEASE_EXPIRED', finished_at = now(), updated_at = now(), lease_token = NULL, lease_expires_at = NULL WHERE id = %s AND status = 'running'", (task["id"],))
connection.execute("SELECT id FROM quota_accounts WHERE id = %s FOR UPDATE", (task["quota_account_id"],))
connection.execute("UPDATE quota_accounts SET reserved = GREATEST(0, reserved - %s), version = version + 1 WHERE id = %s", (task["reserved_amount"], task["quota_account_id"]))
connection.execute("INSERT INTO usage_records(user_id, quota_account_id, task_id, type, amount) VALUES (%s, %s, %s, 'release', %s)", (task["user_id"], task["quota_account_id"], task["id"], task["reserved_amount"]))
connection.commit()
def cleanup_orphan_audio() -> None:
storage_dir = Path(settings.audio_storage_dir)
if not storage_dir.exists():
return
with psycopg.connect(settings.database_url, row_factory=psycopg.rows.dict_row) as connection:
known = {row["storage_key"] for row in connection.execute("SELECT storage_key FROM audio_files WHERE status = 'available'").fetchall()}
now = time.time()
for path in storage_dir.rglob("*"):
if path.is_file() and path.relative_to(storage_dir).as_posix() not in known and now - path.stat().st_mtime > 3600:
path.unlink(missing_ok=True)
def effective_tts_config(connection: Connection) -> dict:
row = connection.execute("SELECT * FROM tts_settings WHERE id = 1").fetchone()
url = row["upstream_url"].rstrip("/") if row and row["upstream_url"] else settings.tts_upstream_url
api_key = row["api_key"] if row and row["api_key"] else settings.tts_api_key
timeout = row["timeout_seconds"] if row and row["timeout_seconds"] else settings.tts_timeout_seconds
model = row["default_model"] if row and row["default_model"] else "qwen3-tts"
return {
"upstream_url": url,
"api_key": api_key,
"timeout_seconds": timeout,
"default_model": model,
"source": "db" if row and (row["upstream_url"] or row["api_key"]) else "env",
"updated_at": row["updated_at"] if row else None,
"updated_by": row["updated_by"] if row else None,
}
def read_tts_config() -> dict:
with psycopg.connect(settings.database_url, row_factory=psycopg.rows.dict_row) as connection:
return effective_tts_config(connection)
def mask_secret(value: str) -> str:
if not value:
return ""
if len(value) <= 8:
return "****"
return f"{value[:4]}{value[-4:]}"
def tts_settings_view(connection: Connection) -> dict:
cfg = effective_tts_config(connection)
return {
"upstream_url": cfg["upstream_url"],
"api_key_set": bool(cfg["api_key"]),
"api_key_masked": mask_secret(cfg["api_key"]),
"timeout_seconds": cfg["timeout_seconds"],
"default_model": cfg["default_model"],
"source": cfg["source"],
"updated_at": cfg["updated_at"].isoformat() if cfg["updated_at"] else None,
"updated_by": str(cfg["updated_by"]) if cfg["updated_by"] else None,
}
2026-09-08 16:42:13 +00:00
async def process_task(task: dict) -> None:
cfg = await asyncio.to_thread(read_tts_config)
if not cfg["upstream_url"]:
2026-09-08 16:42:13 +00:00
await asyncio.to_thread(finish_task_failure, task, "UPSTREAM_NOT_CONFIGURED")
return
parameters = task["parameters"]
response_format = parameters.get("format", "wav")
payload = {"model": parameters.get("model") or cfg["default_model"], "input": task["text"], "voice": task["provider_voice_id"], "response_format": response_format, "speed": parameters.get("speed", 1.0)}
2026-09-08 16:42:13 +00:00
headers = {"Content-Type": "application/json"}
if cfg["api_key"]:
headers["Authorization"] = f"Bearer {cfg['api_key']}"
2026-09-08 16:42:13 +00:00
try:
async with httpx.AsyncClient(timeout=cfg["timeout_seconds"]) as client:
response = await client.post(f"{cfg['upstream_url']}/v1/audio/speech", headers=headers, json=payload)
if response.status_code == 401 or response.status_code == 403:
2026-09-08 16:42:13 +00:00
raise RuntimeError("UPSTREAM_UNAUTHORIZED")
if response.status_code >= 400:
raise RuntimeError(f"UPSTREAM_HTTP_{response.status_code}")
2026-09-09 09:02:46 +00:00
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
if content_type not in {"audio/wav", "audio/x-wav", "audio/mpeg", "audio/mp3"}:
2026-09-09 09:02:46 +00:00
raise RuntimeError("UPSTREAM_NOT_AUDIO")
declared_size = response.headers.get("content-length")
if declared_size and int(declared_size) > 50 * 1024 * 1024:
raise RuntimeError("UPSTREAM_RESPONSE_TOO_LARGE")
2026-09-08 16:42:13 +00:00
audio = response.content
if not audio or len(audio) > 50 * 1024 * 1024:
raise RuntimeError("UPSTREAM_AUDIO_INVALID")
2026-09-09 09:02:46 +00:00
if response_format == "wav" and (len(audio) < 12 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE"):
raise RuntimeError("UPSTREAM_AUDIO_CORRUPT")
mime_type = "audio/mpeg" if response_format == "mp3" else "audio/wav"
await asyncio.to_thread(finish_task_success, task, audio, mime_type)
2026-09-08 16:42:13 +00:00
except httpx.TimeoutException:
await asyncio.to_thread(finish_task_failure, task, "UPSTREAM_TIMEOUT")
except Exception as exc:
await asyncio.to_thread(finish_task_failure, task, str(exc)[:80])
async def task_worker() -> None:
while True:
await asyncio.to_thread(recover_expired_tasks)
2026-09-08 16:42:13 +00:00
task = await asyncio.to_thread(claim_next_task)
if task:
await process_task(task)
else:
await asyncio.sleep(1)
worker_task: asyncio.Task | None = None
@app.on_event("startup")
async def start_worker():
global worker_task
await asyncio.to_thread(cleanup_orphan_audio)
2026-09-08 16:42:13 +00:00
worker_task = asyncio.create_task(task_worker())
@app.on_event("shutdown")
async def stop_worker():
if worker_task:
worker_task.cancel()
@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}
def captcha_client_key(request: Request) -> str:
return request.client.host if request.client else "unknown"
@app.get("/api/v1/auth/captcha")
def captcha(request: Request):
check_origin(request)
captcha_id, answer = new_captcha(captcha_client_key(request))
colors = ["#58d7f2", "#f59e8b", "#a78bfa"]
lines = "".join(f'<path d="M0 {secrets.randbelow(70) + 10} Q 90 {secrets.randbelow(70) + 10} 180 {secrets.randbelow(70) + 10}" stroke="{color}" stroke-width="1.5" opacity=".6" fill="none"/>' for color in colors)
chars = "".join(f'<text x="{22 + index * 29}" y="45" transform="rotate({secrets.randbelow(25) - 12} {22 + index * 29} 45)" fill="{colors[index % len(colors)]}">{char}</text>' for index, char in enumerate(answer))
image = f'<svg xmlns="http://www.w3.org/2000/svg" width="170" height="64" viewBox="0 0 170 64"><rect width="170" height="64" rx="10" fill="#111827"/>{lines}{chars}</svg>'
return {"captcha_id": captcha_id, "image": image}
@app.post("/api/v1/auth/register", response_model=RegisterResponse, status_code=201)
def register(payload: RegisterRequest, request: Request, connection: Connection = Depends(get_connection)):
check_origin(request)
if not consume_captcha(payload.captcha_id, payload.captcha_code, captcha_client_key(request)):
raise error("CAPTCHA_INVALID", "图形验证码错误或已过期", 400)
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)) if payload.email else None
phone = payload.phone
try:
user = connection.execute(
"""
INSERT INTO users(username, email, phone, password_hash) VALUES (%s, %s, %s, %s)
RETURNING *
""",
(payload.username.strip().lower(), email, phone, hash_password(payload.password)),
).fetchone()
ensure_quota(connection, user["id"], "free")
if payload.verification_channel == "email":
challenge_id, code = create_email_challenge(connection, user["id"], email)
else:
challenge_id = create_sms_challenge(connection, user["id"], phone)
code = None
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("REGISTRATION_FAILED", "注册信息不可用", 409)
except Exception:
connection.rollback()
raise error("VERIFICATION_SEND_FAILED", "验证码发送失败,请稍后重试", 503)
try:
if payload.verification_channel == "email":
send_verification_email(email, code)
connection.commit()
except Exception:
connection.rollback()
raise error("VERIFICATION_SEND_FAILED", "验证码发送失败,请稍后重试", 503)
return {"user": user_public(connection, user), "verification_required": True, "challenge_id": challenge_id}
@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)
if not consume_captcha(payload.captcha_id, payload.captcha_code, captcha_client_key(request)):
raise error("CAPTCHA_INVALID", "图形验证码错误或已过期", 400)
identifier = payload.identifier.strip().lower()
key = f"login:{request.client.host if request.client else 'unknown'}:{identifier}"
if not rate_limiter.allow(key, 10, 300):
raise error("RATE_LIMITED", "请求过于频繁", 429)
user = connection.execute("SELECT * FROM users WHERE lower(email) = %s OR username = %s OR phone = %s", (identifier, identifier, identifier)).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)
if not (user["email_verified"] or user["phone_verified"]):
raise error("CONTACT_NOT_VERIFIED", "请先完成邮箱或手机验证", 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.patch("/api/v1/auth/profile", dependencies=[Depends(require_csrf)])
def update_profile(payload: UserProfileUpdateRequest, connection: Connection = Depends(get_connection), user: dict = Depends(current_user)):
email = normalize_email(str(payload.email)) if payload.email else None
phone = payload.phone
username = payload.username.strip().lower()
try:
updated = connection.execute(
"""
UPDATE users SET username = %s, email = %s, phone = %s,
email_verified = CASE WHEN email IS DISTINCT FROM %s THEN false ELSE email_verified END,
phone_verified = CASE WHEN phone IS DISTINCT FROM %s THEN false ELSE phone_verified END,
updated_at = now()
WHERE id = %s RETURNING *
""",
(username, email, phone, email, phone, user["id"]),
).fetchone()
connection.commit()
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("PROFILE_UPDATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
return user_public(connection, updated)
@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, connection: Connection = Depends(get_connection), 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)
if payload.channel != "email" or payload.purpose != "registration":
raise error("VERIFICATION_PROVIDER_UNAVAILABLE", "该验证流程尚未配置", 503)
code = f"{secrets.randbelow(1_000_000):06d}"
challenge = user
row = connection.execute("SELECT id FROM verification_challenges WHERE user_id = %s AND channel = 'email' AND purpose = 'registration' AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1", (challenge["id"],)).fetchone()
if not row:
raise error("VERIFICATION_INVALID", "验证挑战无效", 400)
connection.execute("UPDATE verification_challenges SET code_digest = %s, expires_at = now() + interval '10 minutes', attempt_count = 0 WHERE id = %s", (code_digest(code), row["id"]))
connection.commit()
try:
send_verification_email(challenge["email"], code)
except Exception:
raise error("VERIFICATION_EMAIL_FAILED", "验证码邮件发送失败,请稍后重试", 503)
return {"challenge_id": row["id"], "status": "sent"}
@app.post("/api/v1/auth/verification/resend")
def verification_resend(payload: VerificationResendRequest, request: Request, connection: Connection = Depends(get_connection)):
check_origin(request)
challenge = connection.execute("SELECT * FROM verification_challenges WHERE id = %s AND purpose = 'registration' AND consumed_at IS NULL", (payload.challenge_id,)).fetchone()
if not challenge:
raise error("VERIFICATION_INVALID", "验证挑战无效", 400)
if not rate_limiter.allow(f"verification:{challenge['target']}", 3, 900):
raise error("RATE_LIMITED", "验证码发送过于频繁", 429)
try:
if challenge["channel"] == "email":
code = f"{secrets.randbelow(1_000_000):06d}"
connection.execute("UPDATE verification_challenges SET code_digest = %s, expires_at = now() + interval '10 minutes', attempt_count = 0 WHERE id = %s", (code_digest(code), payload.challenge_id))
connection.commit()
send_verification_email(challenge["target"], code)
else:
out_id = send_sms_verification(challenge["target"])
connection.execute("UPDATE verification_challenges SET code_digest = %s, provider_request_id = %s, expires_at = now() + interval '10 minutes', attempt_count = 0 WHERE id = %s", (code_digest(out_id), out_id, payload.challenge_id))
connection.commit()
except Exception:
connection.rollback()
raise error("VERIFICATION_SEND_FAILED", "验证码发送失败,请稍后重试", 503)
return {"challenge_id": payload.challenge_id, "status": "sent"}
@app.post("/api/v1/auth/verification/confirm-registration", dependencies=[Depends(require_csrf)])
def verification_confirm_registration(payload: VerificationConfirmRequest, request: Request, connection: Connection = Depends(get_connection)):
check_origin(request)
challenge = connection.execute("SELECT * FROM verification_challenges WHERE id = %s AND purpose = 'registration'", (payload.challenge_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)
try:
valid = check_sms_verification(challenge["target"], payload.code, challenge["provider_request_id"]) if challenge["channel"] == "phone" else __import__("hmac").compare_digest(challenge["code_digest"], code_digest(payload.code))
except Exception:
raise error("VERIFICATION_PROVIDER_FAILED", "验证码服务暂时不可用,请稍后重试", 503)
if not valid:
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,))
column = "email_verified" if challenge["channel"] == "email" else "phone_verified"
connection.execute(f"UPDATE users SET {column} = true, updated_at = now() WHERE id = %s", (challenge["user_id"],))
connection.commit()
return {"status": "ok"}
@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"}
2026-09-08 16:42:13 +00:00
@app.get("/api/v1/tts/voices", response_model=list[TtsVoicePublic])
def tts_voices(connection: Connection = Depends(get_connection)):
return connection.execute("SELECT id, provider_voice_id, name, language, description, supported_parameters FROM tts_voices WHERE enabled = true ORDER BY name").fetchall()
@app.post("/api/v1/tts/tasks", status_code=202)
def create_tts_task(payload: TtsTaskRequest, request: Request, connection: Connection = Depends(get_connection), user: dict = Depends(current_user), _: None = Depends(require_csrf)):
idempotency_key = request.headers.get("idempotency-key", "").strip()
if len(idempotency_key) < 8 or len(idempotency_key) > 120:
raise error("IDEMPOTENCY_REQUIRED", "需要有效的 Idempotency-Key", 400)
plan = effective_plan(connection, user)
policy = connection.execute("SELECT * FROM plan_policies WHERE code = %s", (plan,)).fetchone()
text = normalize_tts_text(payload.text)
if not text.strip():
raise error("EMPTY_TEXT", "文本不能只有空白", 422)
text_length = len(text)
2026-09-08 16:42:13 +00:00
if not policy or text_length > policy["max_text_length"]:
raise error("TEXT_TOO_LONG", "文本超过当前计划限制", 422)
parameters = dict(payload.parameters)
response_format = str(parameters.get("format", "wav")).lower()
speed = float(parameters.get("speed", 1.0))
if response_format != "wav" or speed != 1:
raise error("UNVERIFIED_PARAMETERS", "V1 当前只支持 WAV 和 1.0x 语速", 422)
2026-09-08 16:42:13 +00:00
voice = connection.execute("SELECT * FROM tts_voices WHERE provider_voice_id = %s AND enabled = true", (payload.voice_id,)).fetchone()
if not voice or plan not in (voice["allowed_plans"] or ["free", "vip"]):
raise error("VOICE_NOT_ALLOWED", "音色不可用", 422)
request_hash = hashlib.sha256(json.dumps({"text": text, "voice": payload.voice_id, "parameters": parameters}, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
connection.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (str(user["id"]),))
2026-09-08 16:42:13 +00:00
existing = connection.execute("SELECT * FROM tts_tasks WHERE user_id = %s AND idempotency_key = %s", (user["id"], idempotency_key)).fetchone()
if existing:
if existing["request_hash"] != request_hash:
raise error("IDEMPOTENCY_CONFLICT", "幂等键已用于其他请求", 409)
return task_view(connection, existing)
active_count = connection.execute("SELECT count(*) FROM tts_tasks WHERE user_id = %s AND status IN ('queued', 'running')", (user["id"],)).fetchone()["count"]
if active_count >= policy["max_concurrency"]:
raise error("CONCURRENCY_LIMIT", "并发任务数已达上限", 409)
2026-09-08 16:42:13 +00:00
quota = ensure_quota(connection, user["id"], plan)
quota = connection.execute("SELECT * FROM quota_accounts WHERE id = %s FOR UPDATE", (quota["id"],)).fetchone()
available = quota["limit_snapshot"] + quota["adjustment"] - quota["used"] - quota["reserved"]
if available < text_length:
raise error("QUOTA_EXCEEDED", "额度不足", 409)
task = connection.execute(
"""
INSERT INTO tts_tasks(user_id, text, text_length, voice_id, provider_voice_id, parameters, idempotency_key, request_hash, policy_version, quota_account_id, reserved_amount)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *
""",
(user["id"], text, text_length, voice["id"], payload.voice_id, Json(parameters), idempotency_key, request_hash, policy["version"], quota["id"], text_length),
2026-09-08 16:42:13 +00:00
).fetchone()
connection.execute("UPDATE quota_accounts SET reserved = reserved + %s, version = version + 1 WHERE id = %s", (text_length, quota["id"]))
connection.execute("INSERT INTO usage_records(user_id, quota_account_id, task_id, type, amount, idempotency_key) VALUES (%s, %s, %s, 'reserve', %s, %s)", (user["id"], quota["id"], task["id"], text_length, f"reserve:{task['id']}"))
connection.commit()
return task_view(connection, task)
@app.get("/api/v1/tts/tasks", response_model=list[TtsTaskPublic])
def list_tts_tasks(user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
tasks = connection.execute("SELECT * FROM tts_tasks WHERE user_id = %s ORDER BY created_at DESC LIMIT 100", (user["id"],)).fetchall()
return [task_view(connection, task) for task in tasks]
def owned_task(task_id: UUID, user: dict, connection: Connection) -> dict:
task = connection.execute("SELECT * FROM tts_tasks WHERE id = %s", (task_id,)).fetchone()
if not task or (task["user_id"] != user["id"] and user["role"] != "admin"):
raise error("NOT_FOUND", "任务不存在", 404)
return task
@app.get("/api/v1/tts/tasks/{task_id}", response_model=TtsTaskPublic)
def get_tts_task(task_id: UUID, user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
return task_view(connection, owned_task(task_id, user, connection))
def audio_response(task_id: UUID, user: dict, connection: Connection, download: bool):
task = owned_task(task_id, user, connection)
audio = connection.execute("SELECT * FROM audio_files WHERE task_id = %s AND status = 'available'", (task_id,)).fetchone()
if task["status"] != "succeeded" or not audio:
raise error("AUDIO_NOT_AVAILABLE", "音频尚不可用", 404)
path = Path(settings.audio_storage_dir) / audio["storage_key"]
if not path.is_file():
raise error("AUDIO_NOT_AVAILABLE", "音频文件不可用", 404)
filename = f"kaotings-{task_id}.{'mp3' if audio['mime_type'] == 'audio/mpeg' else 'wav'}" if download else None
return FileResponse(path, media_type=audio["mime_type"], filename=filename)
@app.get("/api/v1/tts/tasks/{task_id}/audio")
def play_tts_audio(task_id: UUID, user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
return audio_response(task_id, user, connection, False)
@app.get("/api/v1/tts/tasks/{task_id}/download")
def download_tts_audio(task_id: UUID, user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
return audio_response(task_id, user, connection, True)
USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"
def quota_view_for(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,
}
def active_grant(connection: Connection, user_id: UUID) -> dict | None:
return connection.execute(
"""
SELECT id, plan, starts_at, expires_at, reason, created_at
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()
@app.get("/api/v1/admin/users")
def admin_users(email: str | None = None, phone: str | None = None, status: str | None = None, page: int = 1, limit: int = 20, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
page = max(1, page)
limit = min(100, max(1, limit))
clauses = []
params: list[Any] = []
if email:
clauses.append("email ILIKE %s")
params.append(f"%{email}%")
if phone:
clauses.append("phone ILIKE %s")
params.append(f"%{phone}%")
if status in ("active", "disabled"):
clauses.append("status = %s")
params.append(status)
where = " WHERE " + " AND ".join(clauses) if clauses else ""
total = connection.execute(f"SELECT count(*) AS c FROM users{where}", params).fetchone()["c"]
rows = connection.execute(
f"SELECT {USER_COLUMNS} FROM users{where} ORDER BY created_at DESC LIMIT %s OFFSET %s",
[*params, limit, (page - 1) * limit],
).fetchall()
items = []
for row in rows:
plan = effective_plan(connection, row)
item = dict(row)
item["effective_plan"] = plan
items.append(item)
return {"items": items, "total": total, "page": page, "limit": limit, "pages": (total + limit - 1) // limit}
@app.get("/api/v1/admin/users/{user_id}")
def admin_user_detail(user_id: UUID, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
target = connection.execute(f"SELECT {USER_COLUMNS} FROM users WHERE id = %s", (user_id,)).fetchone()
if not target:
raise error("NOT_FOUND", "用户不存在", 404)
plan = effective_plan(connection, target)
quota = ensure_quota(connection, user_id, plan)
grant = active_grant(connection, user_id)
grant_history = connection.execute("SELECT id, plan, starts_at, expires_at, revoked_at, reason, created_at FROM membership_grants WHERE user_id = %s ORDER BY created_at DESC", (user_id,)).fetchall()
adjustment_records = connection.execute(
"""
SELECT u.id, u.amount, u.idempotency_key, u.created_at, a.reason, a.actor_id
FROM usage_records u
LEFT JOIN admin_audit_logs a ON a.action = 'quota_adjustment' AND a.target_id = %s
WHERE u.user_id = %s AND u.type = 'adjust'
ORDER BY u.created_at DESC
""",
(user_id, user_id),
).fetchall()
connection.commit()
return {
"user": dict(target), "effective_plan": plan, "effective_grant": grant,
"quota": quota_view_for(quota), "membership_history": grant_history, "adjustment_records": adjustment_records,
}
@app.post("/api/v1/admin/users", dependencies=[Depends(require_csrf)])
def admin_create_user(payload: AdminUserCreateRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
email = normalize_email(str(payload.email)) if payload.email else None
phone = payload.phone
username = payload.username.strip().lower()
try:
created = connection.execute(
"""
INSERT INTO users(username, email, phone, password_hash, email_verified, phone_verified)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at
""",
(username, email, phone, hash_password(payload.password), payload.email_verified, payload.phone_verified),
).fetchone()
connection.execute(
"INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_create', 'user', %s, %s, %s, %s)",
(actor["id"], created["id"], Json({}), Json({"username": username, "email": email, "phone": phone, "email_verified": payload.email_verified, "phone_verified": payload.phone_verified}), payload.reason),
)
connection.commit()
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("USER_CREATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
return dict(created)
@app.patch("/api/v1/admin/users/{user_id}", dependencies=[Depends(require_csrf)])
def admin_update_user(user_id: UUID, payload: AdminUserUpdateRequest, 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)
email = normalize_email(str(payload.email)) if payload.email else None
phone = payload.phone
username = payload.username.strip().lower()
try:
updated = connection.execute(
"""
UPDATE users
SET username = %s, email = %s, phone = %s, email_verified = %s, phone_verified = %s, updated_at = now()
WHERE id = %s
RETURNING id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at
""",
(username, email, phone, payload.email_verified, payload.phone_verified, 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_update', 'user', %s, %s, %s, %s)",
(actor["id"], user_id, Json({"username": target["username"], "email": target["email"], "phone": target["phone"], "email_verified": target["email_verified"], "phone_verified": target["phone_verified"]}), Json({"username": username, "email": email, "phone": phone, "email_verified": payload.email_verified, "phone_verified": payload.phone_verified}), payload.reason),
)
connection.commit()
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("USER_UPDATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
return dict(updated)
@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 and target["status"] == "active":
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()
if payload.status == "disabled":
revoked = connection.execute("UPDATE sessions SET revoked_at = now() WHERE user_id = %s AND revoked_at IS NULL", (user_id,))
revoked_count = revoked.rowcount
else:
revoked_count = 0
2026-09-08 16:00:47 +00:00
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, Json({"status": target["status"]}), Json({"status": updated["status"]}), payload.reason))
connection.commit()
return {"id": updated["id"], "status": updated["status"], "sessions_revoked": revoked_count}
@app.get("/api/v1/admin/tts/settings")
def admin_get_tts_settings(connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
return tts_settings_view(connection)
@app.put("/api/v1/admin/tts/settings", dependencies=[Depends(require_csrf)])
def admin_update_tts_settings(payload: TtsSettingsUpdate, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
url = payload.upstream_url.strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise error("TTS_SETTINGS_INVALID", "上游地址必须以 http:// 或 https:// 开头", 422)
current = effective_tts_config(connection)
api_key = payload.api_key.strip() if payload.api_key else current["api_key"]
model = payload.default_model.strip() or "qwen3-tts"
connection.execute(
"""
INSERT INTO tts_settings(id, upstream_url, api_key, timeout_seconds, default_model, updated_by, updated_at)
VALUES (1, %s, %s, %s, %s, %s, now())
ON CONFLICT (id) DO UPDATE SET upstream_url = EXCLUDED.upstream_url, api_key = EXCLUDED.api_key, timeout_seconds = EXCLUDED.timeout_seconds, default_model = EXCLUDED.default_model, updated_by = EXCLUDED.updated_by, updated_at = now()
""",
(url, api_key, payload.timeout_seconds, model, actor["id"]),
)
connection.execute(
"INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'tts_settings_update', 'tts_settings', '00000000-0000-0000-0000-000000000001', %s, %s, %s)",
(
actor["id"],
Json({"upstream_url": current["upstream_url"], "api_key_set": bool(current["api_key"]), "timeout_seconds": current["timeout_seconds"], "default_model": current["default_model"]}),
Json({"upstream_url": url, "api_key_set": bool(api_key), "api_key_changed": bool(payload.api_key), "timeout_seconds": payload.timeout_seconds, "default_model": model}),
payload.reason,
),
)
connection.commit()
return tts_settings_view(connection)
@app.post("/api/v1/admin/tts/settings/test", dependencies=[Depends(require_csrf)])
def admin_test_tts_settings(payload: TtsSettingsTest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
current = effective_tts_config(connection)
url = (payload.upstream_url or current["upstream_url"]).strip().rstrip("/")
api_key = payload.api_key.strip() if payload.api_key else current["api_key"]
timeout = payload.timeout_seconds or min(current["timeout_seconds"], 30)
model = payload.default_model or current["default_model"]
text = (payload.text or "测试").strip()[:50] or "测试"
if not url:
return {"ok": False, "error_code": "UPSTREAM_NOT_CONFIGURED", "message": "尚未填写上游地址"}
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
started = time.perf_counter()
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(f"{url}/v1/audio/speech", headers=headers, json={"model": model, "input": text, "voice": "default", "response_format": "wav", "speed": 1.0})
except httpx.TimeoutException:
return {"ok": False, "error_code": "UPSTREAM_TIMEOUT", "message": f"请求超时({timeout}s请检查地址是否可达、端口是否正确"}
except httpx.HTTPError as exc:
return {"ok": False, "error_code": "UPSTREAM_UNREACHABLE", "message": f"无法连接上游:{exc.__class__.__name__}"}
latency_ms = int((time.perf_counter() - started) * 1000)
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if response.status_code in {401, 403}:
return {"ok": False, "status_code": response.status_code, "latency_ms": latency_ms, "error_code": "UPSTREAM_UNAUTHORIZED", "message": "鉴权失败,请检查 API Key 是否正确、是否过期"}
if response.status_code >= 400:
return {"ok": False, "status_code": response.status_code, "latency_ms": latency_ms, "error_code": f"UPSTREAM_HTTP_{response.status_code}", "message": f"上游返回错误:{response.text[:200]}"}
if content_type in {"audio/wav", "audio/x-wav"} and len(response.content) > 12 and response.content[:4] == b"RIFF":
return {"ok": True, "status_code": response.status_code, "latency_ms": latency_ms, "content_type": content_type, "size_bytes": len(response.content), "message": "探测成功:上游返回有效音频,配置可用"}
return {"ok": False, "status_code": response.status_code, "latency_ms": latency_ms, "content_type": content_type or "unknown", "error_code": "UPSTREAM_NOT_AUDIO", "message": "上游未返回有效音频,请检查地址、模型与参数(探测会消耗上游一次生成额度)"}
@app.delete("/api/v1/admin/users/{user_id}", dependencies=[Depends(require_csrf)])
def admin_delete_user(user_id: UUID, payload: MembershipRevokeRequest, 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["id"] == actor["id"]:
raise error("SELF_DELETE_FORBIDDEN", "不能删除当前登录的管理员账号", 409)
if target["role"] == "admin":
count = connection.execute("SELECT count(*) FROM users WHERE role = 'admin' AND status = 'active'").fetchone()["count"]
if count <= 1 and target["status"] == "active":
raise error("LAST_ADMIN_PROTECTED", "不能删除最后一个可用管理员", 409)
connection.execute("INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_delete', 'user', %s, %s, %s, %s)", (actor["id"], user_id, Json({"username": target["username"], "email": target["email"], "phone": target["phone"], "role": target["role"]}), Json({"deleted": True}), payload.reason))
connection.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit()
return {"id": user_id, "status": "deleted"}
@app.get("/api/v1/admin/users/{user_id}/membership")
def admin_membership_view(user_id: UUID, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
target = connection.execute("SELECT id, role, plan FROM users WHERE id = %s", (user_id,)).fetchone()
if not target:
raise error("NOT_FOUND", "用户不存在", 404)
connection.commit()
return {"user_id": user_id, "effective_plan": effective_plan(connection, target), "effective_grant": active_grant(connection, user_id)}
@app.post("/api/v1/admin/users/{user_id}/membership/revoke", dependencies=[Depends(require_csrf)])
def admin_membership_revoke(user_id: UUID, payload: MembershipRevokeRequest, 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)
grant = connection.execute("SELECT id, plan, revoked_at 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()
if not grant:
raise error("NO_ACTIVE_GRANT", "没有可撤销的有效会员", 409)
connection.execute("UPDATE membership_grants SET revoked_at = now() WHERE id = %s", (grant["id"],))
connection.execute("UPDATE users SET plan = 'free', updated_at = now() WHERE id = %s", (user_id,))
connection.execute("INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'membership_revoke', 'user', %s, %s, %s, %s)", (actor["id"], user_id, Json({"plan": grant["plan"]}), Json({"plan": "free"}), payload.reason))
connection.commit()
return {"status": "ok", "revoked_plan": grant["plan"]}
@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()
if payload.expires_at and payload.expires_at <= starts_at:
raise error("INVALID_PERIOD", "过期时间必须在开始时间之后", 422)
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()
stored_plan = "free" if payload.plan == "free" else payload.plan
connection.execute("UPDATE users SET plan = %s, updated_at = now() WHERE id = %s", (stored_plan, user_id))
2026-09-08 16:00:47 +00:00
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, Json({"plan": target["plan"]}), Json({"plan": payload.plan, "expires_at": payload.expires_at.isoformat() if payload.expires_at else None}), payload.reason))
connection.commit()
return grant
@app.get("/api/v1/admin/users/{user_id}/quota")
def admin_quota_view(user_id: UUID, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
target = connection.execute("SELECT id, role, plan 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))
adjustment_records = connection.execute(
"""
SELECT u.id, u.amount, u.idempotency_key, u.created_at, a.reason, a.actor_id
FROM usage_records u
LEFT JOIN admin_audit_logs a ON a.action = 'quota_adjustment' AND a.target_id = %s
WHERE u.user_id = %s AND u.type = 'adjust'
ORDER BY u.created_at DESC
""",
(user_id, user_id),
).fetchall()
connection.commit()
return {"quota": quota_view_for(quota), "adjustment_records": adjustment_records}
@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))
# Serialize with task freeze (create_tts_task) and settlement on the same
# user, then re-read the quota row under a row lock so the projected balance
# check is transactionally consistent. The check must cover BOTH used and
# reserved (frozen) amounts, not only used.
connection.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (str(user_id),))
quota = connection.execute("SELECT * FROM quota_accounts WHERE id = %s FOR UPDATE", (quota["id"],)).fetchone()
existing = connection.execute("SELECT id FROM usage_records WHERE user_id = %s AND idempotency_key = %s AND type = 'adjust'", (user_id, payload.idempotency_key)).fetchone()
if existing:
raise error("IDEMPOTENCY_CONFLICT", "该调整已提交", 409)
projected = quota["limit_snapshot"] + quota["adjustment"] + payload.amount - quota["used"] - quota["reserved"]
if projected < 0:
raise error("QUOTA_BALANCE_INVALID", "调整后额度不能小于已用或冻结额度", 422)
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))
2026-09-08 16:00:47 +00:00
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, Json({"amount": payload.amount}), payload.reason))
connection.commit()
return {"status": "ok", "amount": payload.amount}
@app.get("/api/v1/admin/tts/tasks")
def admin_tts_tasks(user_id: UUID | None = None, status: str | None = None, created_after: datetime | None = None, created_before: datetime | None = None, page: int = 1, limit: int = 20, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
page = max(1, page)
limit = min(100, max(1, limit))
clauses = []
params: list[Any] = []
if user_id:
clauses.append("t.user_id = %s")
params.append(user_id)
if status in ("queued", "running", "succeeded", "failed"):
clauses.append("t.status = %s")
params.append(status)
if created_after:
clauses.append("t.created_at >= %s")
params.append(created_after)
if created_before:
clauses.append("t.created_at <= %s")
params.append(created_before)
where = " WHERE " + " AND ".join(clauses) if clauses else ""
total = connection.execute(f"SELECT count(*) AS c FROM tts_tasks t{where}", params).fetchone()["c"]
rows = connection.execute(
f"""
SELECT t.id, t.user_id, u.email AS user_email, t.status, t.text_length, t.provider_voice_id,
t.error_code, t.attempt_count, t.reserved_amount, t.created_at, t.started_at, t.finished_at,
(EXTRACT(EPOCH FROM (t.finished_at - t.started_at)) * 1000)::bigint AS duration_ms,
CASE WHEN t.status = 'succeeded' THEN 'consumed' WHEN t.status = 'failed' THEN 'released'
ELSE 'pending' END AS settlement,
CASE WHEN t.status IN ('queued', 'running') THEN t.reserved_amount ELSE 0 END AS reserved,
CASE WHEN t.status = 'succeeded' THEN t.reserved_amount ELSE 0 END AS used
FROM tts_tasks t JOIN users u ON u.id = t.user_id{where}
ORDER BY t.created_at DESC LIMIT %s OFFSET %s
""",
[*params, limit, (page - 1) * limit],
).fetchall()
return {"items": rows, "total": total, "page": page, "limit": limit, "pages": (total + limit - 1) // limit}
@app.get("/api/v1/admin/usage/summary")
def admin_usage_summary(user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
total_used = connection.execute("SELECT COALESCE(sum(used), 0)::bigint AS total FROM quota_accounts").fetchone()["total"]
total_reserved = connection.execute("SELECT COALESCE(sum(reserved), 0)::bigint AS total FROM quota_accounts").fetchone()["total"]
total_adjust = connection.execute("SELECT COALESCE(sum(adjustment), 0)::bigint AS total FROM quota_accounts").fetchone()["total"]
record_total = connection.execute("SELECT COALESCE(sum(CASE WHEN type IN ('consume', 'adjust') THEN amount WHEN type = 'release' THEN -amount ELSE 0 END), 0)::bigint AS total FROM usage_records").fetchone()["total"]
task_counts = connection.execute("SELECT status, count(*) AS c FROM tts_tasks GROUP BY status").fetchall()
by_status = {row["status"]: row["c"] for row in task_counts}
failed = connection.execute("SELECT count(*) AS c FROM tts_tasks WHERE status = 'failed'").fetchone()["c"]
return {
"quota": {"used": total_used, "reserved": total_reserved, "adjustment": total_adjust},
"ledger_total": record_total,
"tasks": {"total": sum(by_status.values()), "by_status": by_status, "failed": failed},
}
@app.get("/api/v1/admin/audit-logs")
def admin_audit_logs(target_type: str | None = None, actor_id: UUID | None = None, page: int = 1, limit: int = 30, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
page = max(1, page)
limit = min(100, max(1, limit))
clauses = []
params: list[Any] = []
if target_type in ("user", "quota_account", "product", "tts_task"):
clauses.append("a.target_type = %s")
params.append(target_type)
if actor_id:
clauses.append("a.actor_id = %s")
params.append(actor_id)
where = " WHERE " + " AND ".join(clauses) if clauses else ""
total = connection.execute(f"SELECT count(*) AS c FROM admin_audit_logs a{where}", params).fetchone()["c"]
rows = connection.execute(
f"""
SELECT a.id, a.actor_id, u.email AS actor_email, a.action, a.target_type, a.target_id,
a.before_value, a.after_value, a.reason, a.created_at
FROM admin_audit_logs a LEFT JOIN users u ON u.id = a.actor_id{where}
ORDER BY a.created_at DESC LIMIT %s OFFSET %s
""",
[*params, limit, (page - 1) * limit],
).fetchall()
return {"items": rows, "total": total, "page": page, "limit": limit, "pages": (total + limit - 1) // limit}