From 7c67506474d8e624617ff1cbed980d6563126cb7 Mon Sep 17 00:00:00 2001 From: xsl Date: Fri, 14 Aug 2026 00:26:39 +0800 Subject: [PATCH] feat(domains): identity, billing, task services and domain placeholders Billing implements reserve -> commit -> refund against bl_quota and bl_ledger with row-level locking and idempotency keys; the refund commits on its own transaction so a failed request cannot silently keep a user's quota. Task implements the unified task centre over the state machine. Identity issues and resolves users. Remaining domains are placeholders whose READMEs state what belongs there and what must not. --- packages/agent/README.md | 43 +++++ packages/agent/__init__.py | 1 + packages/agent/service.py | 121 +++++++++++++++ packages/billing/README.md | 38 +++++ packages/billing/__init__.py | 1 + packages/billing/repo.py | 79 ++++++++++ packages/billing/service.py | 267 ++++++++++++++++++++++++++++++++ packages/compliance/README.md | 34 ++++ packages/compliance/__init__.py | 1 + packages/compliance/service.py | 69 +++++++++ packages/identity/README.md | 33 ++++ packages/identity/__init__.py | 1 + packages/identity/repo.py | 38 +++++ packages/identity/service.py | 100 ++++++++++++ packages/kb/README.md | 22 +++ packages/kb/__init__.py | 1 + packages/memory/README.md | 25 +++ packages/memory/__init__.py | 1 + packages/notify/README.md | 13 ++ packages/notify/__init__.py | 1 + packages/profile/README.md | 20 +++ packages/profile/__init__.py | 1 + packages/report/README.md | 19 +++ packages/report/__init__.py | 1 + packages/retrieval/README.md | 23 +++ packages/retrieval/__init__.py | 1 + packages/rules/README.md | 17 ++ packages/rules/__init__.py | 1 + packages/task/README.md | 41 +++++ packages/task/__init__.py | 1 + packages/task/repo.py | 37 +++++ packages/task/service.py | 136 ++++++++++++++++ packages/tryon/README.md | 19 +++ packages/tryon/__init__.py | 1 + packages/wardrobe/README.md | 5 + packages/wardrobe/__init__.py | 1 + 36 files changed, 1213 insertions(+) create mode 100644 packages/agent/README.md create mode 100644 packages/agent/__init__.py create mode 100755 packages/agent/service.py create mode 100644 packages/billing/README.md create mode 100644 packages/billing/__init__.py create mode 100755 packages/billing/repo.py create mode 100755 packages/billing/service.py create mode 100644 packages/compliance/README.md create mode 100644 packages/compliance/__init__.py create mode 100755 packages/compliance/service.py create mode 100644 packages/identity/README.md create mode 100644 packages/identity/__init__.py create mode 100755 packages/identity/repo.py create mode 100755 packages/identity/service.py create mode 100644 packages/kb/README.md create mode 100644 packages/kb/__init__.py create mode 100644 packages/memory/README.md create mode 100644 packages/memory/__init__.py create mode 100644 packages/notify/README.md create mode 100644 packages/notify/__init__.py create mode 100644 packages/profile/README.md create mode 100644 packages/profile/__init__.py create mode 100644 packages/report/README.md create mode 100644 packages/report/__init__.py create mode 100644 packages/retrieval/README.md create mode 100644 packages/retrieval/__init__.py create mode 100644 packages/rules/README.md create mode 100644 packages/rules/__init__.py create mode 100644 packages/task/README.md create mode 100644 packages/task/__init__.py create mode 100755 packages/task/repo.py create mode 100755 packages/task/service.py create mode 100644 packages/tryon/README.md create mode 100644 packages/tryon/__init__.py create mode 100644 packages/wardrobe/README.md create mode 100644 packages/wardrobe/__init__.py diff --git a/packages/agent/README.md b/packages/agent/README.md new file mode 100644 index 0000000..3e228a8 --- /dev/null +++ b/packages/agent/README.md @@ -0,0 +1,43 @@ +# `packages/agent/` — 穿搭 Agent + +**职责**(架构 §4.1):会话管理、意图识别、上下文组装、生成编排。 +**不负责**:知识检索实现(调 `packages/retrieval`)。 + +## 已实现 + +只有 `service.py` 里那条最小编排:配额预扣 → LLM 网关 → 成本落库 → +提交或退还。这是**垂直切片**,用来证明地基通了,不是 Agent 功能。 + +## 架构 §7.2 的完整链路(九步,目前只做了第 5 步) + +| 步骤 | 状态 | +| --- | --- | +| ① 输入审核(TMS + 备案关键词库),fail-close | ❌ CR-06 | +| ② 意图识别(含越界:医疗、身材评判) | ❌ FR-AGT-02 | +| ③ 上下文组装(token 预算严格分配) | 部分:只有固定前缀 + 当前输入 | +| ④ 混合检索 | ❌ FR-AGT-04 | +| ⑤ LLM 生成(流式 SSE) | 部分:非流式 | +| ⑥ 生成后校验(禁忌违反 = 0、色值在板内) | ❌ FR-AGT-05 | +| ⑦ 输出审核 | ❌ CR-06 | +| ⑧ 落库 message + retrieval_trace + rule_hits + 成本 | 部分:只有成本 | +| ⑨ 异步偏好抽取 → 写 L2 记忆 | ❌ FR-MEM-03 | + +表 `ag_conversation` / `ag_message` / `ag_feedback` 尚未创建。 + +## Token 预算(架构 §7.2,必须硬性限制) + +| 部分 | 预算 | +| --- | --- | +| 系统提示词(固定前缀,放最前以命中缓存) | ≤ 800 | +| 画像摘要 | ≤ 500 | +| 会话摘要 | ≤ 400 | +| 检索知识 | ≤ 1500 | +| 用户当前输入 | ≤ 500 | +| **输入合计** | **≤ 3700** | +| 输出 | ≤ 800 | + +超预算时**按优先级裁剪而不是报错**(FR-AGT-03)。 + +## Prompt 注入防护(NFR-SEC-04) + +用户输入与系统提示词严格分区;输出侧校验;**禁止用户内容影响工具调用决策**。 diff --git a/packages/agent/__init__.py b/packages/agent/__init__.py new file mode 100644 index 0000000..35e9cb7 --- /dev/null +++ b/packages/agent/__init__.py @@ -0,0 +1 @@ +"""agent 域:会话管理、上下文组装、生成编排。""" diff --git a/packages/agent/service.py b/packages/agent/service.py new file mode 100755 index 0000000..1c4df42 --- /dev/null +++ b/packages/agent/service.py @@ -0,0 +1,121 @@ +"""Agent 对话编排 — the scaffold's thin vertical slice. + +It walks the full 架构文档 §7.2 spine but only the load-bearing parts: + + ① 输入审核 ← NOT implemented (compliance.moderate, CR-06) + ② 意图识别 ← NOT implemented (FR-AGT-02) + ③ 上下文组装 ← only the fixed system prefix + current turn + ④ 混合检索 ← NOT implemented (retrieval-svc, FR-AGT-04) + ⑤ LLM 生成 ← IMPLEMENTED (non-streaming) + ⑥ 生成后校验 ← NOT implemented (FR-AGT-05) + ⑦ 输出审核 ← NOT implemented (CR-06) + ⑧ 落库 + 成本 ← cost only; ag_message is not in this scaffold + ⑨ 异步偏好抽取 ← NOT implemented (FR-MEM-03) + +What *is* fully wired is the plumbing every future feature needs: JWT identity, +the quota three-phase protocol, the LLM gateway, cost attribution, and the +unified envelope. + +Transaction discipline: the reservation is committed **before** the LLM call. +If the process dies mid-call, the reservation survives and can be reconciled; +if the call fails, the refund is committed before the error propagates, so the +API layer's rollback cannot undo it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.billing.service import BillingService, Reservation +from packages.llm.gateway import LLMGateway +from packages.llm.types import Scene +from packages.shared.logging import get_logger + +logger = get_logger(__name__) + +QUOTA_KEY_AGENT_CHAT = "agent_chat" +REF_TYPE = "agent_chat" + + +@dataclass(frozen=True, slots=True) +class ChatResult: + reply: str + provider: str + model: str + prompt_id: str | None + prompt_version: str | None + call_id: int + tokens_in: int + tokens_out: int + tokens_cached: int + latency_ms: int + cost_cents: Decimal + quota_remaining: int + reserve_tx_id: int + + +class AgentService: + def __init__( + self, + session: AsyncSession, + *, + gateway: LLMGateway, + billing: BillingService, + ) -> None: + self._session = session + self._gateway = gateway + self._billing = billing + + async def chat(self, *, user_id: int, message: str, request_key: str) -> ChatResult: + reservation = await self._billing.reserve( + user_id=user_id, + quota_key=QUOTA_KEY_AGENT_CHAT, + request_key=request_key, + amount=1, + reason="agent chat turn", + ref_type=REF_TYPE, + ref_id=request_key, + ) + await self._session.commit() + + try: + completion = await self._gateway.complete( + scene=Scene.AGENT_CHAT, + prompt_vars={"user_input": message}, + user_id=user_id, + ref_type=REF_TYPE, + ref_id=request_key, + ) + except Exception as exc: + await self._refund(reservation, reason=type(exc).__name__) + raise + + await self._billing.commit(reservation) + await self._session.commit() + + return ChatResult( + reply=completion.text, + provider=completion.provider, + model=completion.model, + prompt_id=completion.prompt_id, + prompt_version=completion.prompt_version, + call_id=completion.call_id, + tokens_in=completion.usage.tokens_in, + tokens_out=completion.usage.tokens_out, + tokens_cached=completion.usage.tokens_cached, + latency_ms=completion.latency_ms, + cost_cents=completion.cost_cents, + quota_remaining=reservation.remaining, + reserve_tx_id=reservation.tx_id, + ) + + async def _refund(self, reservation: Reservation, *, reason: str) -> None: + try: + await self._billing.refund(reservation, reason=reason) + await self._session.commit() + except Exception: + await self._session.rollback() + logger.exception("quota_refund_failed", tx_id=reservation.tx_id) diff --git a/packages/billing/README.md b/packages/billing/README.md new file mode 100644 index 0000000..4b40a5d --- /dev/null +++ b/packages/billing/README.md @@ -0,0 +1,38 @@ +# `packages/billing/` — 配额与计量(V1) + +> ⚠️ **V1 不收费**(TD-28)。本域在 V1 的职责是**配额限制 + 计量**, +> 不含任何支付、订单、购买、会员能力。 + +## 已实现 + +- `bl_quota_rule`(规则配置化,改配置不发版,FR-BILL-04) +- `bl_quota`(按 `period_key` 自然重置,无需定时任务清零) +- `bl_ledger`(流水是真相,余额是缓存) +- **预扣三段式** `reserve → commit → refund`(架构 §7.4,FR-BILL-05) +- 服务端硬校验(FR-BILL-06);退还幂等(refund 键由 reserve 键派生) + +## 架构约束(不可妥协) + +**V1 的配额账本与 V2 的点数账本必须是同一张表。** V1 只用 `system_grant` / +`consume` / `refund` 三种 `tx_type`,V2 增加 `purchase` / `gift`。这样 V2 上 +支付时不需要数据迁移,也不需要重写扣减逻辑。**不要为了"V1 更简单"把表拆开。** + +## 为什么是预扣不是直接扣 + +长耗时任务可能失败,直接扣会产生「扣了额度没拿到结果」的客诉。V1 虽不涉及 +金钱,**用户对"次数被白扣"的敏感度不比扣钱低**,而且 V1 没有客服退款兜底手段, +只能靠机制保证。 + +## 待实现 + +- FR-BILL-03 任务奖励发放(签到/完善资料/反馈/邀请/分享,同任务同周期幂等) +- **每日对账**:余额由流水重算校验,不一致则告警。V1 就要做,否则 V2 上支付时 + 对账逻辑得从零开发且无历史数据验证。 +- 设备/账号/IP 三重限流(FR-BILL-06) +- Redis 配额计数器 + PG 落账的两级结构(架构 §10) + +## 文案约束(FR-BILL-07) + +额度相关提示里**不得出现任何价格、购买、会员、升级字样**,只提供「等待恢复」 +与「做任务换额度」两条路径。`tests/unit/test_response_envelope.py` 会扫描全部 +错误码文案。 diff --git a/packages/billing/__init__.py b/packages/billing/__init__.py new file mode 100644 index 0000000..7435c0b --- /dev/null +++ b/packages/billing/__init__.py @@ -0,0 +1 @@ +"""billing 域(V1):配额账本、流水、预扣三段式。不含支付。""" diff --git a/packages/billing/repo.py b/packages/billing/repo.py new file mode 100755 index 0000000..5c97b4b --- /dev/null +++ b/packages/billing/repo.py @@ -0,0 +1,79 @@ +"""Data access for the billing domain. **Only this module may touch bl_* tables.** + +架构文档 §3.2 硬性规则 1: 每个域只能通过对方暴露的 service 接口调用,禁止直接 +查对方的表。import-linter enforces it (contract "no cross-domain repository access"). +""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.shared.models import Ledger, Quota, QuotaRule + + +async def get_rule(session: AsyncSession, quota_key: str) -> QuotaRule | None: + return await session.get(QuotaRule, quota_key) + + +async def lock_or_create_quota( + session: AsyncSession, + *, + user_id: int, + quota_key: str, + period_key: str, + limit_value: int, +) -> Quota: + """Get the period row with a row lock, creating it on first use. + + ``ON CONFLICT DO NOTHING`` + ``SELECT ... FOR UPDATE`` makes concurrent first + requests for the same period safe without an advisory lock. + """ + await session.execute( + pg_insert(Quota) + .values( + user_id=user_id, + quota_key=quota_key, + period_key=period_key, + used=0, + limit_value=limit_value, + bonus_value=0, + ) + .on_conflict_do_nothing(index_elements=["user_id", "quota_key", "period_key"]) + ) + result = await session.execute( + select(Quota) + .where( + Quota.user_id == user_id, + Quota.quota_key == quota_key, + Quota.period_key == period_key, + ) + .with_for_update() + ) + row = result.scalar_one() + return row + + +async def get_ledger_by_idempotency_key(session: AsyncSession, key: str) -> Ledger | None: + result = await session.execute(select(Ledger).where(Ledger.idempotency_key == key)) + return result.scalar_one_or_none() + + +async def get_ledger(session: AsyncSession, tx_id: int) -> Ledger | None: + return await session.get(Ledger, tx_id) + + +async def list_ledger(session: AsyncSession, user_id: int, limit: int = 50) -> list[Ledger]: + result = await session.execute( + select(Ledger) + .where(Ledger.user_id == user_id) + .order_by(Ledger.created_at.desc(), Ledger.tx_id.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + +def add_ledger(session: AsyncSession, ledger: Ledger) -> Ledger: + session.add(ledger) + return ledger diff --git a/packages/billing/service.py b/packages/billing/service.py new file mode 100755 index 0000000..c0df09c --- /dev/null +++ b/packages/billing/service.py @@ -0,0 +1,267 @@ +"""Quota three-phase protocol: reserve → commit → refund (架构文档 §7.4). + +为什么预扣而不是直接扣: 长耗时任务可能失败,直接扣会产生「扣了额度没拿到 +结果」的客诉。V1 没有客服退款兜底手段,只能靠机制保证 (FR-BILL-05). + +Nothing here decides *how much* a feature costs — that comes from +``bl_quota_rule`` so it can change without a release (FR-BILL-04). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import cast + +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.billing import repo +from packages.domain.billing import quota as quota_rules +from packages.domain.errors import QuotaExceededError +from packages.domain.ids import next_id +from packages.shared.errors import AppError, ErrorCode, QuotaExhaustedError +from packages.shared.logging import get_logger +from packages.shared.models import Ledger, QuotaRule +from packages.shared.observability import QUOTA_EVENTS + +logger = get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class Reservation: + tx_id: int + user_id: int + quota_key: str + period_key: str + amount: int + remaining: int + idempotency_key: str + + +@dataclass(frozen=True, slots=True) +class QuotaSnapshot: + quota_key: str + period_key: str + used: int + limit_value: int + bonus_value: int + remaining: int + reset_at: datetime | None + + +class BillingService: + def __init__(self, session: AsyncSession, *, tz_offset_hours: int = 8) -> None: + self._session = session + self._tz_offset = tz_offset_hours + + # -- read --------------------------------------------------------------- + + async def get_quota(self, *, user_id: int, quota_key: str) -> QuotaSnapshot: + rule = await self._require_rule(quota_key) + period_type = cast(quota_rules.PeriodType, rule.period_type) + now = datetime.now(UTC) + period_key = quota_rules.period_key(now, period_type, self._tz_offset) + row = await repo.lock_or_create_quota( + self._session, + user_id=user_id, + quota_key=quota_key, + period_key=period_key, + limit_value=rule.base_limit, + ) + state = quota_rules.QuotaState( + used=row.used, base_limit=row.limit_value, bonus=row.bonus_value + ) + return QuotaSnapshot( + quota_key=quota_key, + period_key=period_key, + used=row.used, + limit_value=row.limit_value, + bonus_value=row.bonus_value, + remaining=state.remaining, + reset_at=quota_rules.period_reset_at(now, period_type, self._tz_offset), + ) + + async def list_ledger(self, *, user_id: int, limit: int = 50) -> list[Ledger]: + return await repo.list_ledger(self._session, user_id, limit) + + # -- phase 1: reserve --------------------------------------------------- + + async def reserve( + self, + *, + user_id: int, + quota_key: str, + request_key: str, + amount: int = 1, + reason: str | None = None, + ref_type: str | None = None, + ref_id: str | None = None, + ) -> Reservation: + """Freeze ``amount`` units. Idempotent on ``request_key``.""" + idem_key = quota_rules.reserve_idempotency_key( + user_id=user_id, quota_key=quota_key, request_key=request_key + ) + existing = await repo.get_ledger_by_idempotency_key(self._session, idem_key) + if existing is not None: + QUOTA_EVENTS.labels(quota_key, "reserve", "idempotent_hit").inc() + return Reservation( + tx_id=existing.tx_id, + user_id=user_id, + quota_key=quota_key, + period_key=existing.period_key or "", + amount=abs(existing.delta), + remaining=existing.balance_after, + idempotency_key=idem_key, + ) + + rule = await self._require_rule(quota_key) + period_type = cast(quota_rules.PeriodType, rule.period_type) + now = datetime.now(UTC) + period_key = quota_rules.period_key(now, period_type, self._tz_offset) + row = await repo.lock_or_create_quota( + self._session, + user_id=user_id, + quota_key=quota_key, + period_key=period_key, + limit_value=rule.base_limit, + ) + state = quota_rules.QuotaState( + used=row.used, base_limit=row.limit_value, bonus=row.bonus_value + ) + + try: + new_state = quota_rules.reserve(state, amount) + except QuotaExceededError as exc: + QUOTA_EVENTS.labels(quota_key, "reserve", "exhausted").inc() + reset_at = quota_rules.period_reset_at(now, period_type, self._tz_offset) + raise QuotaExhaustedError( + required=exc.required, + remaining=exc.remaining, + reset_at=reset_at.isoformat() if reset_at else None, + ) from exc + + row.used = new_state.used + tx_id = next_id() + repo.add_ledger( + self._session, + Ledger( + tx_id=tx_id, + user_id=user_id, + delta=-amount, + tx_type="consume", + status="reserved", + reason=reason, + ref_type=ref_type, + ref_id=ref_id, + quota_key=quota_key, + period_key=period_key, + balance_after=quota_rules.balance_after(new_state), + idempotency_key=idem_key, + ), + ) + await self._session.flush() + QUOTA_EVENTS.labels(quota_key, "reserve", "ok").inc() + logger.info( + "quota_reserved", + tx_id=tx_id, + quota_key=quota_key, + amount=amount, + remaining=new_state.remaining, + ) + return Reservation( + tx_id=tx_id, + user_id=user_id, + quota_key=quota_key, + period_key=period_key, + amount=amount, + remaining=new_state.remaining, + idempotency_key=idem_key, + ) + + # -- phase 2: commit ---------------------------------------------------- + + async def commit(self, reservation: Reservation) -> None: + """Confirm the deduction. Safe to call twice.""" + ledger = await repo.get_ledger(self._session, reservation.tx_id) + if ledger is None: + raise AppError(ErrorCode.STATE_CONFLICT, message="配额流水不存在") + if ledger.status == "reserved": + ledger.status = "committed" + await self._session.flush() + QUOTA_EVENTS.labels(reservation.quota_key, "commit", "ok").inc() + logger.info("quota_committed", tx_id=ledger.tx_id) + else: + QUOTA_EVENTS.labels(reservation.quota_key, "commit", "noop").inc() + + # -- phase 3: refund ---------------------------------------------------- + + async def refund(self, reservation: Reservation, *, reason: str = "task_failed") -> None: + """Release a reservation and write the reverse ledger entry. + + FR-BILL-05 requires refunds to be idempotent; the refund ledger key is + derived from the reserve key so a retry can never double-refund. + """ + ledger = await repo.get_ledger(self._session, reservation.tx_id) + if ledger is None: + raise AppError(ErrorCode.STATE_CONFLICT, message="配额流水不存在") + if ledger.status != "reserved": + QUOTA_EVENTS.labels(reservation.quota_key, "refund", "noop").inc() + return + + refund_key = quota_rules.refund_idempotency_key(reservation.idempotency_key) + if await repo.get_ledger_by_idempotency_key(self._session, refund_key) is not None: + QUOTA_EVENTS.labels(reservation.quota_key, "refund", "noop").inc() + return + + rule = await self._require_rule(reservation.quota_key) + row = await repo.lock_or_create_quota( + self._session, + user_id=reservation.user_id, + quota_key=reservation.quota_key, + period_key=reservation.period_key, + limit_value=rule.base_limit, + ) + state = quota_rules.QuotaState( + used=row.used, base_limit=row.limit_value, bonus=row.bonus_value + ) + new_state = quota_rules.refund(state, reservation.amount) + row.used = new_state.used + ledger.status = "refunded" + + repo.add_ledger( + self._session, + Ledger( + tx_id=next_id(), + user_id=reservation.user_id, + delta=reservation.amount, + tx_type="refund", + status="committed", + reason=reason, + ref_type=ledger.ref_type, + ref_id=ledger.ref_id, + quota_key=reservation.quota_key, + period_key=reservation.period_key, + balance_after=quota_rules.balance_after(new_state), + idempotency_key=refund_key, + ), + ) + await self._session.flush() + QUOTA_EVENTS.labels(reservation.quota_key, "refund", "ok").inc() + logger.info( + "quota_refunded", + tx_id=ledger.tx_id, + amount=reservation.amount, + remaining=new_state.remaining, + ) + + # -- helpers ------------------------------------------------------------ + + async def _require_rule(self, quota_key: str) -> QuotaRule: + rule = await repo.get_rule(self._session, quota_key) + if rule is None: + raise AppError( + ErrorCode.CONFIG_ERROR, + message="配额规则未配置", + data={"quota_key": quota_key}, + ) + return rule diff --git a/packages/compliance/README.md b/packages/compliance/README.md new file mode 100644 index 0000000..eee1a0e --- /dev/null +++ b/packages/compliance/README.md @@ -0,0 +1,34 @@ +# `packages/compliance/` — 统一合规出口 + +> **所有对外生成物必须经过 `publish_asset()`,没有例外。**(架构 §7.5、ADR-11) + +## 铁律 + +1. **不得反向依赖任何业务域**(架构 §4.2)。它必须能被任何模块安全调用。 + import-linter 契约会在 CI 拦下违规 import。 +2. **审核是唯一不允许降级的外部依赖**(架构 §11.4)。审核服务不可用时按 + **拦截**处理(fail-close),宁可少发不可漏审。 + +## 现状:只有接口,没有实现 + +签名已经定死,业务代码可以照着写;实现落地时调用方不用改。 +半实现比不实现更危险 —— 一个"半工作"的审核调用看起来和正常的一样。 + +## 待实现(T-P1-22 / T-P1-23,CR-05 / CR-06 不可裁剪) + +1. 内容审核(腾讯云 IMS 图片 / TMS 文本 + 备案关键词库),fail-close +2. 生成 **27 位内容编号**并登记 `cp_content_registry` +3. 显式标识:图片加可见「AI 生成」水印;文本加提示语 +4. 隐式标识:写入文件元数据(属性信息 + 服务提供者编码 + 内容编号) +5. 上传目标 COS 桶,返回签名地址 +6. 表:`cp_content_registry` `cp_moderation_log` `cp_audit_log` `cp_data_request` + +## 必须专门测试的点 + +图片经过 **CDN、缩略图处理、客户端保存与再次分享**后,元数据是否仍然存在。 +很多图片处理链路会剥离 EXIF/XMP,一旦剥离就等于没做隐式标识(TD-21)。 +这一条上架被拒的概率很高,且事后补救成本极大。 + +## 六类审核对象 + +用户上传图、生成图、用户文本、模型输出、知识内容、后台录入内容 —— 全覆盖。 diff --git a/packages/compliance/__init__.py b/packages/compliance/__init__.py new file mode 100644 index 0000000..8395b73 --- /dev/null +++ b/packages/compliance/__init__.py @@ -0,0 +1 @@ +"""compliance 域:统一合规出口。不得反向依赖任何业务域。""" diff --git a/packages/compliance/service.py b/packages/compliance/service.py new file mode 100755 index 0000000..67b2407 --- /dev/null +++ b/packages/compliance/service.py @@ -0,0 +1,69 @@ +"""统一合规出口 — interface only (架构文档 §7.5, CR-05, CR-06). + +**所有对外生成物必须经过 ``publish_asset()``,没有例外。** The signature is +fixed here so that business code written against it does not have to change when +the implementation lands (T-P1-22 / T-P1-23). + +Nothing in this package may import a business domain — it must be safe to call +from anywhere (架构文档 §4.2 禁止的依赖). import-linter enforces that. + +Not implemented on purpose; implementing it half-way is worse than not having +it, because a half-working moderation call looks like a working one: + 1. 内容审核 (fail-close: 审核服务不可用则拒绝发布) — 腾讯云 IMS/TMS + 2. 27 位内容编号生成与 ``cp_content_registry`` 登记 + 3. 显式标识: 图片可见「AI 生成」水印 / 文本提示语 + 4. 隐式标识: 文件元数据 (属性信息 + 服务提供者编码 + 内容编号) + 5. 上传目标 COS 桶并返回签名地址 + +必须专门测试的点 (§7.5): 图片经 CDN、缩略图处理、客户端保存与再次分享后, +元数据是否仍然存在。很多图片处理链路会剥离 EXIF/XMP。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +ContentType = Literal["image", "text", "pdf"] +ModerationSuggestion = Literal["pass", "review", "block"] + +_NOT_IMPLEMENTED = ( + "compliance is a structural placeholder (T-P1-22 / T-P1-23). " + "No generated content may be released to users until 审核 + 水印 + 元数据 + " + "内容编号登记 are all in place — CR-05/CR-06 are 不可裁剪." +) + + +@dataclass(frozen=True, slots=True) +class ContentRef: + ref_type: str + ref_id: str + + +@dataclass(frozen=True, slots=True) +class ModelInfo: + provider: str + model: str + prompt_id: str | None = None + prompt_version: str | None = None + + +@dataclass(frozen=True, slots=True) +class PublishedAsset: + content_code: str + url: str + moderation: ModerationSuggestion + + +async def moderate(text: str) -> ModerationSuggestion: + """审核不可降级:服务不可用时按拦截处理 (fail-close, 架构文档 §11.4).""" + raise NotImplementedError(_NOT_IMPLEMENTED) + + +async def publish_asset( + raw: bytes | str, + content_type: ContentType, + ref: ContentRef, + model_info: ModelInfo, +) -> PublishedAsset: + raise NotImplementedError(_NOT_IMPLEMENTED) diff --git a/packages/identity/README.md b/packages/identity/README.md new file mode 100644 index 0000000..a57b782 --- /dev/null +++ b/packages/identity/README.md @@ -0,0 +1,33 @@ +# `packages/identity/` — 账号与会话 + +**职责**(架构 §4.1):账号、登录、会话、设备、游客迁移、同意授权记录。 +**不负责**:权益判定(归 `billing`)。 + +## 铁律 + +**`identity` 不得依赖任何其他业务域**(架构 §4.2)—— 登录链路不能被业务拖累。 +import-linter 契约 `identity must not depend on any other business domain` 会 +在 CI 拦下违规 import。 + +## 已实现 + +- `id_user` / `id_user_identity` / `id_consent` 表 +- 外部身份 → 内部 `user_id` 映射(内部主键与外部身份解耦,便于加登录通道) +- 同意记录写入 +- JWT access token(`packages/shared/security.py`) + +## 待实现 + +| 需求 | 内容 | +| --- | --- | +| FR-ACC-01 | 华为账号登录(UnionID/OpenID) | +| FR-ACC-04 | 手机号验证码登录(5 分钟有效、单号限频) | +| FR-ACC-05 | 双令牌:`id_session` + 服务端可撤销的 refresh token | +| FR-ACC-02/03 | 游客模式与数据合并(合并必须幂等) | +| FR-ACC-08 | **人脸单独同意页**(CR-01:必须独立,不得混入总协议) | +| FR-ACC-09 | 账号注销:7 日内彻底删除,含 COS 对象(CR-03) | +| FR-ACC-11 | 未成年人判定与功能限制(CR-04) | +| FR-SYS-01 | 越权防护:所有资源访问校验归属,不能只靠 ID 不可猜 | + +`POST /auth/dev-token` 只是脚手架,`DEV_AUTH_ENABLED=false` 时不可用, +生产环境必须关闭。 diff --git a/packages/identity/__init__.py b/packages/identity/__init__.py new file mode 100644 index 0000000..13e7c36 --- /dev/null +++ b/packages/identity/__init__.py @@ -0,0 +1 @@ +"""identity 域:账号、登录、会话、同意授权。不得依赖其他业务域。""" diff --git a/packages/identity/repo.py b/packages/identity/repo.py new file mode 100755 index 0000000..3f322cf --- /dev/null +++ b/packages/identity/repo.py @@ -0,0 +1,38 @@ +"""Data access for the identity domain. **Only this module may touch id_* tables.**""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.shared.models import Consent, User, UserIdentity + + +async def get_user(session: AsyncSession, user_id: int) -> User | None: + return await session.get(User, user_id) + + +async def find_identity( + session: AsyncSession, *, provider: str, union_id: str +) -> UserIdentity | None: + result = await session.execute( + select(UserIdentity).where( + UserIdentity.provider == provider, UserIdentity.union_id == union_id + ) + ) + return result.scalar_one_or_none() + + +def add_user(session: AsyncSession, user: User) -> User: + session.add(user) + return user + + +def add_identity(session: AsyncSession, identity: UserIdentity) -> UserIdentity: + session.add(identity) + return identity + + +def add_consent(session: AsyncSession, consent: Consent) -> Consent: + session.add(consent) + return consent diff --git a/packages/identity/service.py b/packages/identity/service.py new file mode 100755 index 0000000..696f80e --- /dev/null +++ b/packages/identity/service.py @@ -0,0 +1,100 @@ +"""identity 域 service. + +架构文档 §4.2: ``identity`` 不得依赖其他业务域 —— 登录链路不能被业务拖累。 +import-linter enforces this (contract "identity must not depend on any other +business domain"). + +V1 scope still missing here: 华为账号登录 (FR-ACC-01)、手机号验证码 (FR-ACC-04)、 +双令牌与撤销 (FR-ACC-05)、游客合并 (FR-ACC-03)、注销 (FR-ACC-09)、未成年判定 +(FR-ACC-11). See packages/identity/README.md. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.domain.ids import next_id +from packages.identity import repo +from packages.shared.errors import AppError, ErrorCode +from packages.shared.models import Consent, User, UserIdentity + +#: Provider used by the dev-only token endpoint. Never valid in prod. +DEV_PROVIDER = "dev" + + +class IdentityService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_user(self, user_id: int) -> User: + user = await repo.get_user(self._session, user_id) + if user is None or user.deleted_at is not None or user.status != "active": + raise AppError(ErrorCode.UNAUTHENTICATED, message="账号不存在或已注销") + return user + + async def get_or_create_by_external_identity( + self, + *, + provider: str, + union_id: str, + open_id: str | None = None, + register_source: str = "unknown", + nickname: str | None = None, + ) -> User: + """Map an external identity onto an internal ``user_id`` (FR-ACC-01). + + The internal id is deliberately decoupled from the provider id so a + second login channel can be attached later without a data migration. + """ + identity = await repo.find_identity(self._session, provider=provider, union_id=union_id) + if identity is not None: + return await self.get_user(identity.user_id) + + user = repo.add_user( + self._session, + User( + user_id=next_id(), + status="active", + nickname=nickname, + register_source=register_source, + last_active_at=datetime.now(UTC), + ), + ) + repo.add_identity( + self._session, + UserIdentity( + identity_id=next_id(), + user_id=user.user_id, + provider=provider, + union_id=union_id, + open_id=open_id, + ), + ) + await self._session.flush() + return user + + async def record_consent( + self, + *, + user_id: int, + consent_type: str, + doc_version: str, + client_ip: str | None = None, + user_agent: str | None = None, + ) -> Consent: + """CR-01 留证。人脸的单独同意 (``consent_type='face'``) 必须独立成行。""" + consent = repo.add_consent( + self._session, + Consent( + consent_id=next_id(), + user_id=user_id, + consent_type=consent_type, + doc_version=doc_version, + client_ip=client_ip, + ua=user_agent, + ), + ) + await self._session.flush() + return consent diff --git a/packages/kb/README.md b/packages/kb/README.md new file mode 100644 index 0000000..08ef25e --- /dev/null +++ b/packages/kb/README.md @@ -0,0 +1,22 @@ +# `packages/kb/` — 美学知识库(占位) + +**职责**(架构 §4.1):知识实体/关系/规则/素材的读写与发布。 +**不负责**:检索算法(调 `packages/retrieval`)。 + +## 待实现 + +- 表:`kb_entity` `kb_relation` `kb_rule` `kb_chunk`(vector 1024) `kb_asset` + `kb_color` `kb_gap` +- FR-KB-01~12:本体 Schema、自建色板、内容录入、规则库、白底图流水线、 + 草稿→审核→发布→归档流程、知识缺口埋点 + +## 为什么它是数据不是代码 + +架构 §1.1:**知识库是持续演进的资产,更新频率远高于代码**。规则、色板、单品 +必须是数据,配顾问能独立使用的运营入口(FR-ADM-02),改内容不发版。 +任何时候想把规则写成 `if` 分支,都是错的。 + +## 知识库版本号 + +所有检索缓存键都带全局 `kb_version`,发布时 +1,**旧缓存自然失效** +(架构 §10)。不要试图精确追踪哪些缓存受影响。 diff --git a/packages/kb/__init__.py b/packages/kb/__init__.py new file mode 100644 index 0000000..b8f4b54 --- /dev/null +++ b/packages/kb/__init__.py @@ -0,0 +1 @@ +"""kb 域:知识实体/关系/规则/素材的读写与发布。占位。""" diff --git a/packages/memory/README.md b/packages/memory/README.md new file mode 100644 index 0000000..d17c894 --- /dev/null +++ b/packages/memory/README.md @@ -0,0 +1,25 @@ +# `packages/memory/` — 记忆系统(占位) + +架构 §8.4。与 `packages/profile` 配合:`profile` 管表,这里管策略。 + +## 读(注入) + +``` +get_memory_snapshot(user_id, budget_tokens) + → 优先级:L0 身份 > L1 画像 > L2 显式偏好 > L5 衣橱 > L3 事件 + → 过滤 status=active 且 confidence >= 阈值 + → 裁剪到预算内(≤ 500 token,FR-MEM-04)→ 缓存(hash 变更才重建) +``` + +## 写 + +| 来源 | 置信度 | 行为 | +| --- | --- | --- | +| 显式(用户明确说的) | 0.95 | 立即生效,回执告知「已记住」 | +| 报告产出 | 诊断置信度 | 写 L1 | +| 行为推断(隐式) | 累积证据 | 达阈值生成 `pending_confirm`,确认后才 active | + +## 铁律 + +**用户在「我的记忆」页面的编辑拥有最高优先级,且不可被自动覆盖** +(FR-MEM-05/07)。所有变更写 `pf_memory_event` 留痕。 diff --git a/packages/memory/__init__.py b/packages/memory/__init__.py new file mode 100644 index 0000000..d4e43a4 --- /dev/null +++ b/packages/memory/__init__.py @@ -0,0 +1 @@ +"""记忆系统:分层记忆的读写、衰减、冲突消解。占位。""" diff --git a/packages/notify/README.md b/packages/notify/README.md new file mode 100644 index 0000000..dc9d115 --- /dev/null +++ b/packages/notify/README.md @@ -0,0 +1,13 @@ +# `packages/notify/` — 通知(占位) + +**职责**:推送(华为 Push Kit)、应用内消息、推送 Token 管理。 + +## 待实现 + +- FR-TASK-04:任务完成推送,到达率 ≥ 90% +- 表:`id_device.push_token` 的管理已在 identity 域预留 + +## 约束 + +- **推送文案不得含敏感结论**(FR-TASK-04)。不要在通知里写诊断结果。 +- 推送失败不可阻断主流程:应用内任务中心轮询是兜底(架构 §2 降级表)。 diff --git a/packages/notify/__init__.py b/packages/notify/__init__.py new file mode 100644 index 0000000..bb91055 --- /dev/null +++ b/packages/notify/__init__.py @@ -0,0 +1 @@ +"""notify 域:推送、应用内消息、Token 管理。占位。""" diff --git a/packages/profile/README.md b/packages/profile/README.md new file mode 100644 index 0000000..def77ac --- /dev/null +++ b/packages/profile/README.md @@ -0,0 +1,20 @@ +# `packages/profile/` — 画像与记忆(占位) + +**职责**(架构 §4.1):用户画像(诊断结论)+ 分层记忆系统。 +`get_profile()` `get_memory_snapshot()` `write_memory()` `resolve_conflict()` + +**不负责**:报告生成流程(归 `report`)。 + +## 待实现 + +- 表:`pf_profile` `pf_diagnosis` `pf_memory` `pf_memory_event` `pf_memory_snapshot` +- FR-MEM-01~07、FR-MEM-12:L0~L2 分层记忆、报告写入 L1、注入 ≤ 500 token、 + 冲突消解(显式 > 报告 > 隐式,新 > 旧)、变更审计 +- 「我的记忆」可见可编辑可删除(CR-02);**用户编辑不可被自动覆盖** + +## 约束 + +- V1 预留 `profile_id` 支持多档案(FR-ACC-12),但不做 UI +- 诊断结论属敏感个人信息衍生数据:数据库加密存储,**日志中禁止打印** +- 每个诊断维度必须是 `value + confidence + evidence + rule_hits` 四元组, + 不是自由文本(§6.3) diff --git a/packages/profile/__init__.py b/packages/profile/__init__.py new file mode 100644 index 0000000..3e51897 --- /dev/null +++ b/packages/profile/__init__.py @@ -0,0 +1 @@ +"""profile 域:用户画像(诊断结论)+ 分层记忆系统。占位。""" diff --git a/packages/report/README.md b/packages/report/README.md new file mode 100644 index 0000000..d321f70 --- /dev/null +++ b/packages/report/README.md @@ -0,0 +1,19 @@ +# `packages/report/` — 美学报告(占位) + +**职责**(架构 §4.1):触发判定、生成编排、版本管理、分享。 +**不负责**:具体的视觉分析与文案生成(调 AI 能力层)。 + +## 待实现 + +- 表:`rp_report` `rp_input` `rp_share` +- 架构 §7.1 的 24 步生成链路(这是全系统最复杂的一条) + +## 这条链路上不能妥协的四件事 + +1. **原图生命周期**:直传 COS 临时桶 → Worker 拉取 → 第 14 步**显式删除** → + 桶级 2 小时生命周期兜底(TD-20 / CR-02)。`rp_input` 只存派生特征与图片元信息。 +2. **失败即退款**:任何步骤失败 → `tk_task` 转 failed → `billing.refund()`。 +3. **分步可恢复**:视觉分析结果必须落中间态,LLM 或渲染失败时不重跑视觉分析 + (也就不需要用户重新上传)。 +4. **生成后校验不可跳过**:禁忌违反、色值在板内、维度引用存在、无医疗/贬损表述。 + 最多重试 2 次,仍失败降级为规则直出模板文案,**绝不放行违规内容**。 diff --git a/packages/report/__init__.py b/packages/report/__init__.py new file mode 100644 index 0000000..d2b4915 --- /dev/null +++ b/packages/report/__init__.py @@ -0,0 +1 @@ +"""report 域:报告触发判定、生成编排、版本管理、分享。占位。""" diff --git a/packages/retrieval/README.md b/packages/retrieval/README.md new file mode 100644 index 0000000..1d57fd4 --- /dev/null +++ b/packages/retrieval/README.md @@ -0,0 +1,23 @@ +# `packages/retrieval/` — 混合检索(占位) + +架构 §8.3。进程内包,CPU + PostgreSQL。 + +## 待实现 + +一条 SQL 完成硬过滤 + 向量召回(这正是选 pgvector 的收益),再做 RRF 融合: + +``` +硬过滤(SQL WHERE) → 规则命中 → 向量召回(pgvector) → 关键词召回(tsvector) +→ 融合重排 → 输出知识片段 + 命中规则 ID + 图谱路径 +``` + +重排权重(可配置,需用黄金集调参): +`最终分 = 0.45×向量 + 0.2×关键词 + 0.25×规则权重 + 0.1×新鲜度` + +## 两条不能妥协的要求 + +- **硬过滤零漏**(FR-KB-06):禁忌项不得出现在候选中。这不是排序问题, + 是过滤问题 —— 不能靠权重压低,必须在 SQL 里排除。 +- **规则命中要有明显加权**:专业规则的可信度高于语义相似度,这是自建知识库 + 的意义所在。 +- p95 < 200ms(NFR-PERF-06)。 diff --git a/packages/retrieval/__init__.py b/packages/retrieval/__init__.py new file mode 100644 index 0000000..ba170ef --- /dev/null +++ b/packages/retrieval/__init__.py @@ -0,0 +1 @@ +"""混合检索:硬过滤 → 规则命中 → 向量召回 → BM25 → 重排 → 解释路径。占位。""" diff --git a/packages/rules/README.md b/packages/rules/README.md new file mode 100644 index 0000000..5effa89 --- /dev/null +++ b/packages/rules/README.md @@ -0,0 +1,17 @@ +# `packages/rules/` — 规则引擎(占位) + +架构 §8.1 / ADR-05:自研数据驱动决策表(TD-10)。 + +## 待实现 + +- 从 `kb_rule` 读已发布规则,求值并输出**命中链** +- 硬约束(`is_hard`)校验:可拦截违规输出 +- p95 < 50ms(FR-KB-05) + +## 设计约束 + +- **规则是数据不是代码**。求值器是代码,规则本身在 `kb_rule` 表里, + 顾问在后台维护,改规则不发版(NFR-MAINT-05)。 +- 每次求值必须输出**命中了哪几条规则**,这是报告与 Agent 可解释性的来源, + 也是 `pf_diagnosis.rule_hits` 与 `ag_message.rule_hits` 的数据来源。 +- 已发布规则集缓存在进程内 + Redis,TTL 5 分钟,发布时广播失效(架构 §10)。 diff --git a/packages/rules/__init__.py b/packages/rules/__init__.py new file mode 100644 index 0000000..7188402 --- /dev/null +++ b/packages/rules/__init__.py @@ -0,0 +1 @@ +"""规则引擎:决策表求值、硬约束校验、命中链输出。占位。""" diff --git a/packages/task/README.md b/packages/task/README.md new file mode 100644 index 0000000..6e47540 --- /dev/null +++ b/packages/task/README.md @@ -0,0 +1,41 @@ +# `packages/task/` — 统一任务中心 + +**全系统唯一的长耗时任务视图**(架构 §6.2、ADR-14)。报告、换装、发型、抠图、 +渲染全部走这里,前端任务中心直接读 `tk_task`。 + +## 铁律 + +**不要在业务域里自建任务状态字段。** 每个功能各自实现一遍任务状态、进度、 +幂等、重试,集成时会全部返工 —— 这正是 M1 存在的理由。 + +## 已实现 + +- `tk_task` 表与状态机(`packages/domain/task/state.py`,纯逻辑可单测) +- 幂等提交(相同 `idempotency_key` 返回同一任务,不重复扣点,FR-TASK-05) +- 归属校验(架构 §11.1) +- Celery 四队列(gpu/cpu/io/beat)与一个贯通的 `ping` 任务 + +## 待实现 + +| 需求 | 内容 | +| --- | --- | +| FR-TASK-02 | 进度与预估时间(阶段文案 + 排队位次 + 基于历史 p50 的预估) | +| FR-TASK-06 | 任务取消:排队中可取消并退点;执行中标记取消不再返回结果 | +| FR-TASK-07 | 死信队列与告警:连续失败进死信并告警,**不静默丢弃** | +| — | beat 防重(单实例);自动重试与上限 | + +## 状态机 + +``` +pending → queued → running → succeeded + ↓ ↓ ↓ ↘ failed → queued(重试) + failed failed canceled canceled +``` + +终态是 `succeeded` / `failed` / `canceled`;只有 `failed` 可以重新入队。 +非成功终态**必须触发配额退还**(`needs_refund()`)。 + +## 排队时间与执行时间必须分开统计 + +架构 §12.1:混在一起看会得出错误结论 —— 明明是 GPU 容量不足(该扩容), +却被误判为推理慢(去优化模型)。 diff --git a/packages/task/__init__.py b/packages/task/__init__.py new file mode 100644 index 0000000..d604d13 --- /dev/null +++ b/packages/task/__init__.py @@ -0,0 +1 @@ +"""task 域:统一任务中心。""" diff --git a/packages/task/repo.py b/packages/task/repo.py new file mode 100755 index 0000000..77495f8 --- /dev/null +++ b/packages/task/repo.py @@ -0,0 +1,37 @@ +"""Data access for the task domain. **Only this module may touch tk_task.**""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.shared.models import Task + + +async def get(session: AsyncSession, task_id: int) -> Task | None: + return await session.get(Task, task_id) + + +async def get_for_update(session: AsyncSession, task_id: int) -> Task | None: + result = await session.execute(select(Task).where(Task.task_id == task_id).with_for_update()) + return result.scalar_one_or_none() + + +async def find_by_idempotency_key(session: AsyncSession, key: str) -> Task | None: + result = await session.execute(select(Task).where(Task.idempotency_key == key)) + return result.scalar_one_or_none() + + +async def list_for_user(session: AsyncSession, user_id: int, limit: int = 20) -> list[Task]: + result = await session.execute( + select(Task) + .where(Task.user_id == user_id) + .order_by(Task.submitted_at.desc(), Task.task_id.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + +def add(session: AsyncSession, task: Task) -> Task: + session.add(task) + return task diff --git a/packages/task/service.py b/packages/task/service.py new file mode 100755 index 0000000..41756f4 --- /dev/null +++ b/packages/task/service.py @@ -0,0 +1,136 @@ +"""统一任务中心 (架构文档 §6.2 task 域, FR-TASK-01/02/05). + +**全系统唯一的长耗时任务视图** —— 报告、换装、发型、抠图、渲染都走这里,前端 +任务中心直接读 ``tk_task``。State transitions are validated against the pure +state machine in :mod:`packages.domain.task.state`, so an illegal transition +fails loudly instead of corrupting the task list. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from packages.domain.errors import InvalidTaskTransitionError +from packages.domain.ids import next_id +from packages.domain.task.state import ( + TaskStatus, + TaskType, + assert_transition, + is_terminal, + progress_for, +) +from packages.shared.errors import AppError, ErrorCode, ForbiddenError, NotFoundError +from packages.shared.logging import get_logger +from packages.shared.models import Task +from packages.shared.observability import TASK_TRANSITIONS +from packages.task import repo + +logger = get_logger(__name__) + + +class TaskService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def submit( + self, + *, + user_id: int, + task_type: TaskType, + idempotency_key: str, + payload: dict[str, Any] | None = None, + points_reserved: int = 0, + reserve_tx_id: int | None = None, + eta_seconds: int | None = None, + ) -> Task: + """Create a task. FR-TASK-05: 相同幂等键返回同一任务,不重复扣点。""" + existing = await repo.find_by_idempotency_key(self._session, idempotency_key) + if existing is not None: + return existing + + task = repo.add( + self._session, + Task( + task_id=next_id(), + user_id=user_id, + task_type=task_type.value, + status=TaskStatus.PENDING.value, + progress=progress_for(TaskStatus.PENDING), + idempotency_key=idempotency_key, + payload=payload or {}, + points_reserved=points_reserved, + reserve_tx_id=reserve_tx_id, + eta_seconds=eta_seconds, + ), + ) + await self._session.flush() + TASK_TRANSITIONS.labels(task_type.value, TaskStatus.PENDING.value).inc() + return task + + async def get(self, *, task_id: int, user_id: int) -> Task: + task = await repo.get(self._session, task_id) + if task is None: + raise NotFoundError("任务不存在") + # 架构文档 §11.1: 所有资源访问必须校验归属,不能只靠 ID 不可猜。 + if task.user_id != user_id: + raise ForbiddenError() + return task + + async def list_for_user(self, *, user_id: int, limit: int = 20) -> list[Task]: + return await repo.list_for_user(self._session, user_id, limit) + + async def transition( + self, + *, + task_id: int, + target: TaskStatus, + stage_text: str | None = None, + progress: int | None = None, + result: dict[str, Any] | None = None, + error_code: int | None = None, + error_message: str | None = None, + ) -> Task: + task = await repo.get_for_update(self._session, task_id) + if task is None: + raise NotFoundError("任务不存在") + + current = TaskStatus(task.status) + try: + assert_transition(current, target) + except InvalidTaskTransitionError as exc: + raise AppError( + ErrorCode.TASK_STATE_INVALID, + data={"current": exc.current, "target": exc.target}, + ) from exc + + now = datetime.now(UTC) + task.status = target.value + task.progress = progress if progress is not None else progress_for(target) + if stage_text is not None: + task.stage_text = stage_text + if result is not None: + task.result = result + if error_code is not None: + task.error_code = error_code + if error_message is not None: + task.error_message = error_message[:512] + if target is TaskStatus.RUNNING and task.started_at is None: + task.started_at = now + if target is TaskStatus.QUEUED and current is TaskStatus.FAILED: + task.retry_count += 1 + if is_terminal(target): + task.finished_at = now + + await self._session.flush() + TASK_TRANSITIONS.labels(task.task_type, target.value).inc() + logger.info( + "task_transition", + task_id=task_id, + task_type=task.task_type, + from_status=current.value, + to_status=target.value, + ) + return task diff --git a/packages/tryon/README.md b/packages/tryon/README.md new file mode 100644 index 0000000..beac8b5 --- /dev/null +++ b/packages/tryon/README.md @@ -0,0 +1,19 @@ +# `packages/tryon/` — 智能试穿与发型(占位) + +**职责**(架构 §4.1):业务编排、素材校验、结果管理。 +**不负责**:GPU 推理(调 `services/tryon`)。 + +## 待实现 + +- 表:`tr_task`(与 `tk_task` 一对一)、`tr_favorite` +- 架构 §7.3 的八步链路;FR-TRY-01~08、FR-HAIR-01~04 + +## 关键约束 + +- **前置校验发生在预扣之前**(FR-TRY-02):单品必须 `tryon_ready`,人像质量 + 不达标要在提交前拦截,**不消耗配额**。 +- **队列即容量**(§7.3):`gpu` 队列并发上限由 GPU 容量决定,把排队位次与 + 预估等待返回给用户。超过阈值直接拒绝新任务 —— 这比让用户等 10 分钟体验更好。 +- 失败要区分可重试(网络/排队超时)与不可重试(输入非法/审核拒绝), + 后者立即退点。 +- 结果图必须经统一合规出口(水印 + 元数据 + 内容编号,CR-05)。 diff --git a/packages/tryon/__init__.py b/packages/tryon/__init__.py new file mode 100644 index 0000000..b338a96 --- /dev/null +++ b/packages/tryon/__init__.py @@ -0,0 +1 @@ +"""tryon 域:换装/发型的业务编排、素材校验、结果管理。占位。""" diff --git a/packages/wardrobe/README.md b/packages/wardrobe/README.md new file mode 100644 index 0000000..8106641 --- /dev/null +++ b/packages/wardrobe/README.md @@ -0,0 +1,5 @@ +# `packages/wardrobe/` — 我的衣橱(V2,占位) + +V1 明确不做(需求规格 §5.2):依赖属性识别与 L5 记忆,工作量大。 + +FR-WDB-01~03。V2 再来。 diff --git a/packages/wardrobe/__init__.py b/packages/wardrobe/__init__.py new file mode 100644 index 0000000..33ba5d5 --- /dev/null +++ b/packages/wardrobe/__init__.py @@ -0,0 +1 @@ +"""wardrobe 域:我的衣橱(V2)。占位。"""