✨ 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

+21
View File
@@ -63,6 +63,27 @@ frontend-setup: ## 安装前端依赖
frontend-dev: ## 启动前端开发服务(127.0.0.1:3000) frontend-dev: ## 启动前端开发服务(127.0.0.1:3000)
cd frontend && npm run dev 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 .PHONY: help
@@ -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 ###
+3
View File
@@ -16,6 +16,7 @@ from app.marketplace.routes import router as marketplace_router
from app.media.routes import router as media_router from app.media.routes import router as media_router
from app.memories.routes import cycle_router from app.memories.routes import cycle_router
from app.memories.routes import router as memories_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 inst_router, invoke_router
from app.plugins.routes import router as plugins_router from app.plugins.routes import router as plugins_router
from app.tools.routes import calls_router, tools_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(inst_router)
app.include_router(invoke_router) app.include_router(invoke_router)
app.include_router(media_router) app.include_router(media_router)
app.include_router(ops_router)
app.include_router(admin_router)
@app.get("/healthz", tags=["ops"]) @app.get("/healthz", tags=["ops"])
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
+28
View File
@@ -21,6 +21,7 @@ class User(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_")) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_"))
email: Mapped[str] = mapped_column(String, nullable=False, unique=True) 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) display_name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() 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() 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): class PersonalDomain(Base):
__tablename__ = "personal_domain" __tablename__ = "personal_domain"
@@ -1276,3 +1281,26 @@ class VoiceConsent(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() 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()
)
+1
View File
@@ -0,0 +1 @@
"""运营域:诊断双授权、管理面治理、支持包与观测。"""
+147
View File
@@ -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")
+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},
}
+102
View File
@@ -1703,3 +1703,105 @@ class TestMedia:
json={"kind": "tts", "input": {"text": "再见"}}, headers=headers json={"kind": "tts", "input": {"text": "再见"}}, headers=headers
) )
assert after.status_code == 403 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"
+17
View File
@@ -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"]
+37
View File
@@ -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: