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.
This commit is contained in:
@@ -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)
|
||||
|
||||
用户输入与系统提示词严格分区;输出侧校验;**禁止用户内容影响工具调用决策**。
|
||||
@@ -0,0 +1 @@
|
||||
"""agent 域:会话管理、上下文组装、生成编排。"""
|
||||
Executable
+121
@@ -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)
|
||||
@@ -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` 会扫描全部
|
||||
错误码文案。
|
||||
@@ -0,0 +1 @@
|
||||
"""billing 域(V1):配额账本、流水、预扣三段式。不含支付。"""
|
||||
Executable
+79
@@ -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
|
||||
Executable
+267
@@ -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
|
||||
@@ -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)。
|
||||
这一条上架被拒的概率很高,且事后补救成本极大。
|
||||
|
||||
## 六类审核对象
|
||||
|
||||
用户上传图、生成图、用户文本、模型输出、知识内容、后台录入内容 —— 全覆盖。
|
||||
@@ -0,0 +1 @@
|
||||
"""compliance 域:统一合规出口。不得反向依赖任何业务域。"""
|
||||
Executable
+69
@@ -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)
|
||||
@@ -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` 时不可用,
|
||||
生产环境必须关闭。
|
||||
@@ -0,0 +1 @@
|
||||
"""identity 域:账号、登录、会话、同意授权。不得依赖其他业务域。"""
|
||||
Executable
+38
@@ -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
|
||||
Executable
+100
@@ -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
|
||||
@@ -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)。不要试图精确追踪哪些缓存受影响。
|
||||
@@ -0,0 +1 @@
|
||||
"""kb 域:知识实体/关系/规则/素材的读写与发布。占位。"""
|
||||
@@ -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` 留痕。
|
||||
@@ -0,0 +1 @@
|
||||
"""记忆系统:分层记忆的读写、衰减、冲突消解。占位。"""
|
||||
@@ -0,0 +1,13 @@
|
||||
# `packages/notify/` — 通知(占位)
|
||||
|
||||
**职责**:推送(华为 Push Kit)、应用内消息、推送 Token 管理。
|
||||
|
||||
## 待实现
|
||||
|
||||
- FR-TASK-04:任务完成推送,到达率 ≥ 90%
|
||||
- 表:`id_device.push_token` 的管理已在 identity 域预留
|
||||
|
||||
## 约束
|
||||
|
||||
- **推送文案不得含敏感结论**(FR-TASK-04)。不要在通知里写诊断结果。
|
||||
- 推送失败不可阻断主流程:应用内任务中心轮询是兜底(架构 §2 降级表)。
|
||||
@@ -0,0 +1 @@
|
||||
"""notify 域:推送、应用内消息、Token 管理。占位。"""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""profile 域:用户画像(诊断结论)+ 分层记忆系统。占位。"""
|
||||
@@ -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 次,仍失败降级为规则直出模板文案,**绝不放行违规内容**。
|
||||
@@ -0,0 +1 @@
|
||||
"""report 域:报告触发判定、生成编排、版本管理、分享。占位。"""
|
||||
@@ -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)。
|
||||
@@ -0,0 +1 @@
|
||||
"""混合检索:硬过滤 → 规则命中 → 向量召回 → BM25 → 重排 → 解释路径。占位。"""
|
||||
@@ -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)。
|
||||
@@ -0,0 +1 @@
|
||||
"""规则引擎:决策表求值、硬约束校验、命中链输出。占位。"""
|
||||
@@ -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 容量不足(该扩容),
|
||||
却被误判为推理慢(去优化模型)。
|
||||
@@ -0,0 +1 @@
|
||||
"""task 域:统一任务中心。"""
|
||||
Executable
+37
@@ -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
|
||||
Executable
+136
@@ -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
|
||||
@@ -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)。
|
||||
@@ -0,0 +1 @@
|
||||
"""tryon 域:换装/发型的业务编排、素材校验、结果管理。占位。"""
|
||||
@@ -0,0 +1,5 @@
|
||||
# `packages/wardrobe/` — 我的衣橱(V2,占位)
|
||||
|
||||
V1 明确不做(需求规格 §5.2):依赖属性识别与 L5 记忆,工作量大。
|
||||
|
||||
FR-WDB-01~03。V2 再来。
|
||||
@@ -0,0 +1 @@
|
||||
"""wardrobe 域:我的衣橱(V2)。占位。"""
|
||||
Reference in New Issue
Block a user