✨ feat: WP-14 运营收口——诊断双授权、管理面治理、支持包、观测与自托管

- 迁移 16:diagnostic_grant(token 哈希唯一、15 分钟、单次、绑定 Run)+ user.role(user/admin 检查约束)
- 诊断双授权(ADR-004):本人签发 grant(明文 token 只返回一次,库存 SHA-256);读取需登录态 + Hunjing-Diagnostic-Token + Purpose 三重匹配;单次消耗(重放 401)、用途不符 403、响应 no-store/private;返回观测摘要(usage/TTFT/工具轨迹)不含消息正文
- 管理面:pending 司议案列表与处置(content_removed 联动下架目标内容——治理闭环);非管理员 403
- 脱敏支持包:统计聚合(runs/messages/tool/media 按状态),无正文无密钥
- 观测:GET /readyz(DB 连通)与 GET /metrics(Prometheus 文本,真实 SQL 聚合)
- 自托管:docker/Dockerfile.backend(uv 构建)+ docker-compose.app.yml(hunjing-api + hunjing-postgres 健康依赖、密码经 .env);Makefile 新增 selfhost-up/down、db-dump/db-restore(备份到 backups/)
- 测试 94 项全绿(+3):诊断全路径(无令牌/用途不符/成功/单次重放)、管理面处置联动、支持包与指标
This commit is contained in:
chenxin committed 2026-09-12 11:20:15 +08:00
1 parent 701c20c783
commit 21eacc8c75
10 files changed
+643

No files matched your search

+238
View File
@@ -0,0 +1,238 @@
"""运营域:诊断双授权、管理面治理、支持包与观测(WP-14 / ADR-004)。"""
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
AuditEvent,
Comment,
CommunityContent,
DiagnosticGrant,
MediaJob,
Message,
ModelCall,
ModerationCase,
Report,
Run,
ToolCall,
Turn,
)
GRANT_TTL = timedelta(minutes=15) # ADR-004:默认 15 分钟、单次、绑定 Run
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def issue_grant(
db: AsyncSession, domain_id: str, run_id: str, purpose: str, trace_id: str | None
) -> tuple[DiagnosticGrant, str]:
"""签发诊断授权:明文 token 只返回这一次,数据库只存哈希。"""
if not purpose.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "purpose 不能为空")
run = await db.get(Run, run_id)
if run is None or run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
token = f"hjd_{secrets.token_urlsafe(32)}"
grant = DiagnosticGrant(
run_id=run_id,
domain_id=domain_id,
purpose=purpose,
token_hash=_hash_token(token),
expires_at=datetime.now(UTC) + GRANT_TTL,
)
db.add(grant)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="diagnostic.grant_issued",
aggregate_type="diagnostic_grant", aggregate_id=grant.id,
payload={"run_id": run_id, "purpose": purpose}, trace_id=trace_id,
)
)
await db.flush()
return grant, token
async def read_diagnostics(
db: AsyncSession,
domain_id: str,
run_id: str,
token: str,
purpose: str,
) -> dict[str, Any]:
"""双重授权读取:登录态 + 诊断授权(用途匹配、未过期、单次)。
返回运行观测摘要(usage/TTFT/工具轨迹);不含消息正文(结构保证)。
"""
run = await db.get(Run, run_id)
if run is None or run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
grant = (
await db.execute(
select(DiagnosticGrant).where(
DiagnosticGrant.run_id == run_id,
DiagnosticGrant.token_hash == _hash_token(token),
)
)
).scalar_one_or_none()
if grant is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权无效")
if grant.used_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权已使用")
if grant.expires_at <= datetime.now(UTC):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权已过期")
if grant.purpose != purpose:
raise HTTPException(status.HTTP_403_FORBIDDEN, "用途与授权不符")
grant.used_at = datetime.now(UTC) # 单次:读取即消耗
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="diagnostic.read",
aggregate_type="diagnostic_grant", aggregate_id=grant.id,
payload={"run_id": run_id}, trace_id=None,
)
)
turns = list(
(
await db.scalars(
select(Turn).where(Turn.run_id == run_id).order_by(Turn.index.asc())
)
).all()
)
calls = list(
(
await db.scalars(
select(ModelCall).where(ModelCall.turn_id.in_([t.id for t in turns]))
)
).all()
)
tools = list(
(
await db.scalars(select(ToolCall).where(ToolCall.run_id == run_id))
).all()
)
return {
"run": {"id": run_id, "status": run.status},
"turns": [
{
"index": t.index,
"model_calls": [
{
"status": c.status, "usage": c.usage, "ttft_ms": c.ttft_ms,
"error": c.error,
}
for c in calls if c.turn_id == t.id
],
}
for t in turns
],
"tool_calls": [
{"tool": tc.tool_name, "status": tc.status, "error": tc.error}
for tc in tools
],
}
async def require_admin(db: AsyncSession, user_id: str) -> None:
from app.models import User
user = await db.get(User, user_id)
if user is None or user.role != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "需要管理员角色")
async def list_pending_cases(db: AsyncSession) -> list[ModerationCase]:
return list(
(
await db.scalars(
select(ModerationCase)
.where(ModerationCase.status == "pending")
.order_by(ModerationCase.created_at.asc())
.limit(100)
)
).all()
)
async def resolve_case(
db: AsyncSession,
admin_domain_id: str,
case_id: str,
action: str,
trace_id: str | None,
) -> ModerationCase:
"""处置司议案:content_removed 联动下架目标内容(治理闭环)。"""
case = await db.get(ModerationCase, case_id)
if case is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "案件不存在")
if case.status != "pending":
raise HTTPException(status.HTTP_409_CONFLICT, "案件已处理")
if action == "content_removed":
report = await db.get(Report, case.report_id)
assert report is not None
if report.target_type == "content":
content = await db.get(CommunityContent, report.target_id)
if content is not None:
content.status = "removed" # 下架传播(列表/详情/评论不可见)
elif report.target_type == "comment":
comment = await db.get(Comment, report.target_id)
if comment is not None:
comment.status = "removed"
case.status = "resolved"
case.action_taken = action
db.add(
AuditEvent(
actor_type="admin", actor_id=admin_domain_id, action="moderation.resolved",
aggregate_type="moderation_case", aggregate_id=case.id,
payload={"action": action}, trace_id=trace_id,
)
)
await db.flush()
return case
async def build_support_bundle(db: AsyncSession, domain_id: str) -> dict[str, Any]:
"""脱敏支持包:统计与错误码聚合,无正文、无密钥(契约运营域)。"""
run_rows = (
await db.execute(
select(Run.status, func.count())
.where(Run.personal_domain_id == domain_id)
.group_by(Run.status)
)
).all()
message_count = (
await db.execute(
select(func.count()).select_from(Message).join(
Run, Message.run_id == Run.id
).where(Run.personal_domain_id == domain_id)
)
).scalar_one()
tool_rows = (
await db.execute(
select(ToolCall.status, func.count())
.join(Run, ToolCall.run_id == Run.id)
.where(Run.personal_domain_id == domain_id)
.group_by(ToolCall.status)
)
).all()
media_rows = (
await db.execute(
select(MediaJob.status, func.count())
.where(MediaJob.domain_id == domain_id)
.group_by(MediaJob.status)
)
).all()
return {
"generated_at": datetime.now(UTC).isoformat(),
"runs_by_status": {s: int(c) for s, c in run_rows},
"messages_total": int(message_count),
"tool_calls_by_status": {s: int(c) for s, c in tool_rows},
"media_jobs_by_status": {s: int(c) for s, c in media_rows},
}