From 21eacc8c7524238577aef4e9e2d395397fc9108b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Sat, 12 Sep 2026 11:20:15 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20WP-14=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94=E8=AF=8A=E6=96=AD=E5=8F=8C?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E3=80=81=E7=AE=A1=E7=90=86=E9=9D=A2=E6=B2=BB?= =?UTF-8?q?=E7=90=86=E3=80=81=E6=94=AF=E6=8C=81=E5=8C=85=E3=80=81=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E=E8=87=AA=E6=89=98=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 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):诊断全路径(无令牌/用途不符/成功/单次重放)、管理面处置联动、支持包与指标 --- Makefile | 21 ++ .../89a4ed9647ee_诊断授权与管理员角色.py | 49 ++++ backend/app/main.py | 3 + backend/app/models.py | 28 +++ backend/app/ops/__init__.py | 1 + backend/app/ops/routes.py | 147 +++++++++++ backend/app/ops/service.py | 238 ++++++++++++++++++ backend/tests/test_conversations.py | 102 ++++++++ docker/Dockerfile.backend | 17 ++ docker/docker-compose.app.yml | 37 +++ 10 files changed, 643 insertions(+) create mode 100644 backend/alembic/versions/89a4ed9647ee_诊断授权与管理员角色.py create mode 100644 backend/app/ops/__init__.py create mode 100644 backend/app/ops/routes.py create mode 100644 backend/app/ops/service.py create mode 100644 docker/Dockerfile.backend create mode 100644 docker/docker-compose.app.yml diff --git a/Makefile b/Makefile index 61aa680..d899649 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,27 @@ frontend-setup: ## 安装前端依赖 frontend-dev: ## 启动前端开发服务(127.0.0.1:3000) cd frontend && npm run dev +# ===== 自托管与备份(WP-14) ===== + +.PHONY: selfhost-up +selfhost-up: ## 启动自托管档(API + PostgreSQL,docker compose) + docker compose -f docker/docker-compose.app.yml up -d --build + +.PHONY: selfhost-down +selfhost-down: ## 停止自托管档 + docker compose -f docker/docker-compose.app.yml down + +.PHONY: db-dump +db-dump: ## 备份数据库到 backups/hunjing-<时间戳>.sql.gz + @mkdir -p backups + @docker exec hunjing-postgres pg_dump -U hunjing -d hunjing | gzip > backups/hunjing-$$(date +%Y%m%d-%H%M%S).sql.gz + @echo "备份完成:backups/" + +.PHONY: db-restore +db-restore: ## 从最新备份恢复(DB=备份文件路径,make db-restore FILE=backups/xxx.sql.gz) + @gzip -dc $(FILE) | docker exec -i hunjing-postgres psql -U hunjing -d hunjing + @echo "恢复完成" + # ===== 帮助 ===== .PHONY: help diff --git a/backend/alembic/versions/89a4ed9647ee_诊断授权与管理员角色.py b/backend/alembic/versions/89a4ed9647ee_诊断授权与管理员角色.py new file mode 100644 index 0000000..cefbb2b --- /dev/null +++ b/backend/alembic/versions/89a4ed9647ee_诊断授权与管理员角色.py @@ -0,0 +1,49 @@ +"""诊断授权与管理员角色 + +Revision ID: 89a4ed9647ee +Revises: 52be875c14a4 +Create Date: 2026-09-12 11:14:22.131144 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '89a4ed9647ee' +down_revision: str | Sequence[str] | None = '52be875c14a4' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('diagnostic_grant', + sa.Column('id', sa.String(), nullable=False), + sa.Column('run_id', sa.String(), nullable=False), + sa.Column('domain_id', sa.String(), nullable=False), + sa.Column('purpose', sa.String(), nullable=False), + sa.Column('token_hash', sa.String(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_diagnostic_grant_personal_domain'), ondelete='cascade'), + sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_diagnostic_grant_run'), ondelete='cascade'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_diagnostic_grant')), + sa.UniqueConstraint('token_hash', name=op.f('uq_diagnostic_grant_token_hash')) + ) + op.create_index(op.f('ix_diagnostic_grant_run_id'), 'diagnostic_grant', ['run_id'], unique=False) + op.add_column('user', sa.Column('role', sa.String(), nullable=False, server_default='user')) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'role') + op.drop_index(op.f('ix_diagnostic_grant_run_id'), table_name='diagnostic_grant') + op.drop_table('diagnostic_grant') + # ### end Alembic commands ### diff --git a/backend/app/main.py b/backend/app/main.py index fb20652..c3ee7b9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.marketplace.routes import router as marketplace_router from app.media.routes import router as media_router from app.memories.routes import cycle_router from app.memories.routes import router as memories_router +from app.ops.routes import admin_router, ops_router from app.plugins.routes import inst_router, invoke_router from app.plugins.routes import router as plugins_router from app.tools.routes import calls_router, tools_router @@ -53,6 +54,8 @@ def create_app() -> FastAPI: app.include_router(inst_router) app.include_router(invoke_router) app.include_router(media_router) + app.include_router(ops_router) + app.include_router(admin_router) @app.get("/healthz", tags=["ops"]) async def healthz() -> dict[str, str]: diff --git a/backend/app/models.py b/backend/app/models.py index b134a4c..40d5b0c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -21,6 +21,7 @@ class User(Base): id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_")) email: Mapped[str] = mapped_column(String, nullable=False, unique=True) + role: Mapped[str] = mapped_column(String, nullable=False, default="user") display_name: Mapped[str] = mapped_column(String, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() @@ -29,6 +30,10 @@ class User(Base): DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) + __table_args__ = ( + CheckConstraint("role IN ('user', 'admin')", name="role_values"), + ) + class PersonalDomain(Base): __tablename__ = "personal_domain" @@ -1276,3 +1281,26 @@ class VoiceConsent(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) + + +class DiagnosticGrant(Base): + """诊断授权(ADR-004):双重、限时;明文只签发一次,库存哈希。""" + + __tablename__ = "diagnostic_grant" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("diagnostic_grant_") + ) + run_id: Mapped[str] = mapped_column( + ForeignKey("run.id", ondelete="cascade"), nullable=False, index=True + ) + domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False + ) + purpose: Mapped[str] = mapped_column(String, nullable=False) + token_hash: Mapped[str] = mapped_column(String, nullable=False, unique=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) diff --git a/backend/app/ops/__init__.py b/backend/app/ops/__init__.py new file mode 100644 index 0000000..0785e72 --- /dev/null +++ b/backend/app/ops/__init__.py @@ -0,0 +1 @@ +"""运营域:诊断双授权、管理面治理、支持包与观测。""" diff --git a/backend/app/ops/routes.py b/backend/app/ops/routes.py new file mode 100644 index 0000000..43d29e7 --- /dev/null +++ b/backend/app/ops/routes.py @@ -0,0 +1,147 @@ +"""运营域路由:诊断、管理面、支持包与观测(WP-14)。""" + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status +from sqlalchemy import func, select +from sqlalchemy import text as sql_text + +from app.auth.dependencies import get_current_user, require_state_change +from app.conversations.routes import _domain_of +from app.db.session import DbSession +from app.models import MediaJob, Message, Run, ToolCall, User +from app.ops import service + +ops_router = APIRouter(prefix="/v1", tags=["ops-extended"]) +admin_router = APIRouter(prefix="/v1/admin", tags=["admin"]) + + +@ops_router.post("/runs/{run_id}/diagnostic-grants", status_code=201) +async def issue_grant( + run_id: str, + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, + body: dict[str, str], +) -> dict[str, Any]: + """签发诊断授权:明文 token 只返回一次;默认 15 分钟、单次、绑定 Run。""" + domain = await _domain_of(db, user) + grant, token = await service.issue_grant( + db, domain.id, run_id, body.get("purpose", ""), + getattr(request.state, "trace_id", None), + ) + return { + "grant_id": grant.id, "token": token, # 唯一一次明文返回 + "expires_at": grant.expires_at.isoformat(), + } + + +@ops_router.get("/runs/{run_id}/diagnostics") +async def read_diagnostics( + run_id: str, + db: DbSession, + user: Annotated[User, Depends(get_current_user)], + response: Response, + diag_token: Annotated[ + str | None, Header(alias="Hunjing-Diagnostic-Token") + ] = None, + diag_purpose: Annotated[ + str | None, Header(alias="Hunjing-Diagnostic-Purpose") + ] = None, +) -> dict[str, Any]: + """双重授权诊断:登录态 + 诊断令牌;响应不落缓存(ADR-004)。""" + domain = await _domain_of(db, user) + if not diag_token or not diag_purpose: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "缺少诊断令牌或用途") + result = await service.read_diagnostics( + db, domain.id, run_id, diag_token, diag_purpose + ) + response.headers["Cache-Control"] = "no-store, private" + return result + + +@ops_router.post("/support/bundles") +async def support_bundle( + db: DbSession, + user: Annotated[User, Depends(require_state_change)], +) -> dict[str, Any]: + """脱敏支持包:统计聚合,无正文与密钥。""" + domain = await _domain_of(db, user) + return await service.build_support_bundle(db, domain.id) + + +@admin_router.get("/moderation-cases") +async def list_cases( + db: DbSession, + user: Annotated[User, Depends(get_current_user)], +) -> dict[str, Any]: + await service.require_admin(db, user.id) + from app.models import Report + + cases = await service.list_pending_cases(db) + out = [] + for case in cases: + report = await db.get(Report, case.report_id) + out.append({ + "case_id": case.id, "status": case.status, + "report": { + "target_type": report.target_type if report else None, + "target_id": report.target_id if report else None, + "reason": report.reason if report else None, + } if report else None, + }) + return {"data": out} + + +@admin_router.post("/moderation-cases/{case_id}/resolutions") +async def resolve_case( + case_id: str, + db: DbSession, + user: Annotated[User, Depends(require_state_change)], + request: Request, + body: dict[str, str], +) -> dict[str, Any]: + domain = await _domain_of(db, user) + await service.require_admin(db, user.id) + case = await service.resolve_case( + db, domain.id, case_id, body.get("action", ""), + getattr(request.state, "trace_id", None), + ) + return {"case_id": case.id, "status": case.status, "action_taken": case.action_taken} + + +@ops_router.get("/readyz", tags=["ops"]) +async def readyz(db: DbSession) -> dict[str, str]: + await db.execute(sql_text("SELECT 1")) + return {"status": "ready"} + + +@ops_router.get("/metrics", tags=["ops"]) +async def metrics(db: DbSession) -> Response: + """Prometheus 文本格式:真实 SQL 聚合(无正文)。""" + run_rows = (await db.execute(select(Run.status, func.count()).group_by(Run.status))).all() + msg_total = (await db.execute(select(func.count()).select_from(Message))).scalar_one() + tool_rows = ( + await db.execute(select(ToolCall.status, func.count()).group_by(ToolCall.status)) + ).all() + media_rows = ( + await db.execute(select(MediaJob.status, func.count()).group_by(MediaJob.status)) + ).all() + lines = ["# HELP hunjing_runs_total Run 总数(按状态)", "# TYPE hunjing_runs_total counter"] + for st, cnt in run_rows: + lines.append(f'hunjing_runs_total{{status="{st}"}} {int(cnt)}') + lines += [ + "# HELP hunjing_messages_total 消息总数", + "# TYPE hunjing_messages_total counter", + f"hunjing_messages_total {int(msg_total)}", + ] + groups = [ + ("hunjing_tool_calls_total", tool_rows), + ("hunjing_media_jobs_total", media_rows), + ] + for prefix, rows in groups: + lines.append(f"# HELP {prefix} 计数(按状态)") + lines.append(f"# TYPE {prefix} counter") + for st, cnt in rows: + lines.append(f'{prefix}{{status="{st}"}} {int(cnt)}') + return Response(content="\n".join(lines) + "\n", media_type="text/plain") diff --git a/backend/app/ops/service.py b/backend/app/ops/service.py new file mode 100644 index 0000000..cb27900 --- /dev/null +++ b/backend/app/ops/service.py @@ -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}, + } diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index 9d16d19..14a9b28 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -1703,3 +1703,105 @@ class TestMedia: json={"kind": "tts", "input": {"text": "再见"}}, headers=headers ) assert after.status_code == 403 + + +@pytest.mark.usefixtures("client") +class TestOps: + async def test_diagnostic_dual_auth_flow(self, client, db) -> None: + headers = await login(client, "ops1@example.com") + cid = await _create_conversation(client, headers) + resp = await _post_message(client, headers, cid, "idem-ops1") + run_id = resp.json()["run"]["id"] + await _wait_run_terminal(db, run_id) + # 签发:明文只此一次 + grant = await client.post( + f"/v1/runs/{run_id}/diagnostic-grants", + json={"purpose": "user_inspection"}, headers=headers, + ) + assert grant.status_code == 201, grant.text + token = grant.json()["token"] + # 无令牌:401 + no_token = await client.get(f"/v1/runs/{run_id}/diagnostics", headers=headers) + assert no_token.status_code == 401 + # 用途不符:403 + wrong = await client.get( + f"/v1/runs/{run_id}/diagnostics", + headers={**headers, "Hunjing-Diagnostic-Token": token, + "Hunjing-Diagnostic-Purpose": "other"}, + ) + assert wrong.status_code == 403 + # 正确读取:观测摘要 + no-store + 单次消耗 + ok = await client.get( + f"/v1/runs/{run_id}/diagnostics", + headers={**headers, "Hunjing-Diagnostic-Token": token, + "Hunjing-Diagnostic-Purpose": "user_inspection"}, + ) + assert ok.status_code == 200 + assert ok.headers["cache-control"] == "no-store, private" + body = ok.json() + assert body["run"]["id"] == run_id and body["turns"] + # 单次:重复使用 401 + replay = await client.get( + f"/v1/runs/{run_id}/diagnostics", + headers={**headers, "Hunjing-Diagnostic-Token": token, + "Hunjing-Diagnostic-Purpose": "user_inspection"}, + ) + assert replay.status_code == 401 + + async def test_admin_moderation_resolution(self, client, db) -> None: + from app.models import User as UserModel + + # 提权管理员(开发档直改) + admin_headers = await login(client, "ops-admin@example.com") + ordinary = await login(client, "ops-ordinary@example.com") + from sqlalchemy import select as sa_sel + + async with db() as session: + admin_user = ( + await session.execute( + sa_sel(UserModel).where(UserModel.email == "ops-admin@example.com") + ) + ).scalar_one() + admin_user.role = "admin" + await session.commit() + # 普通用户访问管理面:403 + forbidden = await client.get("/v1/admin/moderation-cases", headers=ordinary) + assert forbidden.status_code == 403 + # 举报→处置→联动下架 + author = await login(client, "ops-author@example.com") + pub = await client.post( + "/v1/community/contents", + json={"kind": "post", "title": "待处置"}, headers=author, + ) + content_id = pub.json()["id"] + report = await client.post( + "/v1/community/reports", + json={"target_type": "content", "target_id": content_id, "reason": "违规"}, + headers=ordinary, + ) + case_id = report.json()["case_id"] + pending = await client.get( + "/v1/admin/moderation-cases", headers=admin_headers + ) + assert any(c["case_id"] == case_id for c in pending.json()["data"]) + resolved = await client.post( + f"/v1/admin/moderation-cases/{case_id}/resolutions", + json={"action": "content_removed"}, headers=admin_headers, + ) + assert resolved.json()["status"] == "resolved" + # 下架传播:内容 404 + gone = await client.get(f"/v1/community/contents/{content_id}", headers=ordinary) + assert gone.status_code == 404 + + async def test_support_bundle_and_metrics(self, client) -> None: + headers = await login(client, "ops3@example.com") + bundle = await client.post("/v1/support/bundles", headers=headers) + assert bundle.status_code == 200 + body = bundle.json() + assert "runs_by_status" in body and "messages_total" in body + assert "detail" not in body + metrics = await client.get("/v1/metrics") + assert metrics.status_code == 200 + assert "hunjing_runs_total" in metrics.text + ready = await client.get("/v1/readyz") + assert ready.json()["status"] == "ready" diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend new file mode 100644 index 0000000..1082700 --- /dev/null +++ b/docker/Dockerfile.backend @@ -0,0 +1,17 @@ +# Hunjing backend 镜像(uv + slim) +FROM python:3.12-slim AS base + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# 先装依赖(层缓存) +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-install-project + +# 源码与引擎绑定(绑定随源构建) +COPY . . +RUN uv sync --frozen + +EXPOSE 8000 +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/docker-compose.app.yml b/docker/docker-compose.app.yml new file mode 100644 index 0000000..96a7f4f --- /dev/null +++ b/docker/docker-compose.app.yml @@ -0,0 +1,37 @@ +# 自托管编排(标准服务器档,ADR-024 统一 PostgreSQL) +# 用法:docker compose -f docker/docker-compose.app.yml up -d --build +services: + hunjing-postgres: + image: pgvector/pgvector:pg17 + container_name: hunjing-postgres + restart: unless-stopped + environment: + POSTGRES_USER: hunjing + POSTGRES_PASSWORD: ${HUNJING_DB_PASSWORD:?请在 .env 设置 HUNJING_DB_PASSWORD} + POSTGRES_DB: hunjing + volumes: + - hunjing-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hunjing -d hunjing"] + interval: 5s + timeout: 3s + retries: 12 + + hunjing-api: + build: + context: ../backend + dockerfile: ../docker/Dockerfile.backend + container_name: hunjing-api + restart: unless-stopped + depends_on: + hunjing-postgres: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://hunjing:${HUNJING_DB_PASSWORD}@hunjing-postgres:5432/hunjing + HUNJING_ENV: production + HUNJING_LOG_LEVEL: info + ports: + - "127.0.0.1:8000:8000" + +volumes: + hunjing-pgdata: