chore(prep): shared groundwork for the parallel task batch

多 agent 并行批次的协调提交(消除全部共享文件冲突面):
- 迁移 0004:id_session + ob_event 两表(T3/T4 用,任务不再写迁移)
- ORM:UserSession / ObEvent;错误码:九个新码(40xxx/60006,含 meta)
- 配置:四档 tiers / refresh TTL / 任务奖励注册表 / 埋点上限 / COS 直传全套
- 骨架:packages/analytics + packages/storage + events/uploads 空路由(已注册)
- deps:AnalyticsServiceDep / StorageServiceDep;DailyBudgetGuard 接收 tiers
- uv add cos-python-sdk-v5;import-linter/CI/Makefile 纳入两个新包
This commit is contained in:
xsl
2026-08-14 16:02:54 +08:00
parent 2009aa938c
commit 9e7a886863
20 changed files with 380 additions and 6 deletions
+7
View File
@@ -0,0 +1,7 @@
"""analytics 域 — 客户端埋点上报(FR-SYS-06)。
分层位置:共享能力层(与 identity/billing/task 同层),
只准 import packages.shared / packages.domain。
实现任务:docs/开发任务包/T3-埋点上报.md(worktree task/t3-events)。
"""
+1
View File
@@ -0,0 +1 @@
"""analytics 域(埋点上报)。见 README.md。"""
+10
View File
@@ -0,0 +1,10 @@
"""analytics 域 service — 骨架(T3 任务填充实现)。"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
class AnalyticsService:
def __init__(self, session: AsyncSession) -> None:
self._session = session
Executable → Regular
+4
View File
@@ -76,6 +76,7 @@ class DailyBudgetGuard:
key_prefix: str = "llm:cost:daily",
tz_offset_hours: int = 8,
clock: object | None = None,
tiers: list[dict[str, object]] | None = None,
) -> None:
self._counter = counter
self._budget = daily_budget_cents
@@ -83,6 +84,9 @@ class DailyBudgetGuard:
self._key_prefix = key_prefix
self._tz = timezone(timedelta(hours=tz_offset_hours))
self._clock = clock
# 四档分级降级的配置(架构 §13.3 / T-P1-25b)。T1 任务实现判定逻辑;
# 这里先接收配置,使 deps 装配层不必随后再改。None = 使用内置默认四档。
self._tiers_config = tiers
@property
def enabled(self) -> bool:
Executable → Regular
+64
View File
@@ -106,6 +106,67 @@ class Settings(BaseSettings):
llm_daily_budget_enabled: bool = True
llm_daily_budget_cents: Decimal = Decimal("100000")
llm_budget_timezone_offset_hours: int = 8
# 四档分级降级(架构 §13.3,T-P1-25b)。JSON 为列表:
# [{"threshold":0.8,"name":"degrade_secondary","blocked":["hd_export"],...}]
# blocked 支持 "*"; allow 仅 block_all 档有意义(纯文本对话放行)。
llm_budget_tiers: list[dict[str, Any]] = Field(
default_factory=lambda: [
{"threshold": 0.6, "name": "warn", "blocked": []},
{
"threshold": 0.8,
"name": "degrade_secondary",
"blocked": ["hd_export", "report_regen"],
},
{"threshold": 0.9, "name": "degrade_generation", "blocked": ["hairstyle", "tryon"]},
{"threshold": 1.0, "name": "block_all", "blocked": "*", "allow": ["agent_chat"]},
],
alias="LLM_BUDGET_TIERS_JSON",
)
# --- Auth: refresh sessions (FR-ACC-05) --------------------------------
refresh_token_ttl_seconds: int = 2_592_000 # 30 天
# --- 任务奖励(FR-BILL-03/04,配置化)-----------------------------------
# amount 全部是占位值 1:正式数值待产品在产品方案 §3.2 定稿后改 env,不改代码。
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。
task_bonus_rules: dict[str, dict[str, Any]] = Field(
default_factory=lambda: {
"sign_in": {"quota_key": "agent_chat", "amount": 1, "claim_period": "day"},
"profile_complete": {
"quota_key": "agent_chat",
"amount": 1,
"claim_period": "lifetime",
},
"report_feedback": {"quota_key": "report_regen", "amount": 1, "claim_period": "day"},
"share_report": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
"invite_friend": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
},
alias="TASK_BONUS_RULES_JSON",
)
# --- 客户端埋点(FR-SYS-06)---------------------------------------------
events_max_batch: int = 50
events_props_max_bytes: int = 8192
# --- 腾讯云 COS 直传(FR-SYS-03 / T-P1-27)------------------------------
cos_region: str = "ap-shanghai"
cos_secret_id: str = ""
cos_secret_key: str = ""
cos_bucket_temp: str = "meida-temp" # 临时桶,2 小时生命周期(CR-02)
cos_bucket_user: str = "meida-user"
cos_bucket_kb: str = "meida-kb"
cos_bucket_report: str = "meida-report"
cos_presign_expire_seconds: int = 600
upload_max_bytes: int = 20_971_520 # 20MB
upload_allowed_types: str = "image/jpeg,image/png,image/webp"
# scene → {"bucket": "temp"|"user"|"kb"|"report"}。新增上传场景只改这里。
upload_scenes: dict[str, dict[str, Any]] = Field(
default_factory=lambda: {
"report_photo": {"bucket": "temp"},
"avatar": {"bucket": "temp"},
},
alias="UPLOAD_SCENES_JSON",
)
# --- Observability -----------------------------------------------------
otel_enabled: bool = False
@@ -118,6 +179,9 @@ class Settings(BaseSettings):
"llm_cost_rates",
"llm_cost_rates_peak",
"llm_default_cost_rate",
"llm_budget_tiers",
"task_bonus_rules",
"upload_scenes",
mode="before",
)
@classmethod
Executable → Regular
+27 -1
View File
@@ -24,15 +24,23 @@ class ErrorCode(IntEnum):
BAD_REQUEST = 40000
VALIDATION_FAILED = 40001
IDEMPOTENCY_CONFLICT = 40002
UPLOAD_REJECTED = 40010
EVENTS_BATCH_REJECTED = 40020
UNAUTHENTICATED = 40100
TOKEN_EXPIRED = 40101
TOKEN_INVALID = 40102
REFRESH_TOKEN_INVALID = 40103
REFRESH_TOKEN_EXPIRED = 40104
SESSION_REVOKED = 40105
FORBIDDEN = 40300
QUOTA_EXHAUSTED = 40301
CONSENT_REQUIRED = 40302
MINOR_RESTRICTED = 40303
TASK_BONUS_CAP_REACHED = 40310
NOT_FOUND = 40400
TASK_BONUS_UNKNOWN = 40410
STATE_CONFLICT = 40900
TASK_BONUS_ALREADY_CLAIMED = 40910
RATE_LIMITED = 42900
# --- 5xxxx server -----------------------------------------------------
@@ -49,6 +57,7 @@ class ErrorCode(IntEnum):
LLM_DAILY_BUDGET_EXCEEDED = 60003
LLM_PROMPT_CACHE_VIOLATION = 60004
MODERATION_UNAVAILABLE = 60005
LLM_SCENE_DEGRADED = 60006
@dataclass(frozen=True, slots=True)
@@ -63,17 +72,31 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.BAD_REQUEST: ErrorMeta(400, "请求参数有误", "请检查后重试"),
ErrorCode.VALIDATION_FAILED: ErrorMeta(422, "请求参数校验未通过", "请按提示修改后重试"),
ErrorCode.IDEMPOTENCY_CONFLICT: ErrorMeta(
409, "相同请求正在处理中", "请稍候查看任务中心,不要重复提交"
409, "相同请求正在处理中", "请稍后查看任务中心,不要重复提交"
),
ErrorCode.UPLOAD_REJECTED: ErrorMeta(
400, "不支持的图片类型或超出大小限制", "请上传 20MB 以内的 JPG/PNG/WebP 图片"
),
ErrorCode.EVENTS_BATCH_REJECTED: ErrorMeta(400, "埋点数据格式有误"),
ErrorCode.UNAUTHENTICATED: ErrorMeta(401, "请先登录", "登录后即可继续"),
ErrorCode.TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"),
ErrorCode.TOKEN_INVALID: ErrorMeta(401, "登录状态无效", "请重新登录"),
ErrorCode.REFRESH_TOKEN_INVALID: ErrorMeta(401, "登录状态无效", "请重新登录"),
ErrorCode.REFRESH_TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"),
ErrorCode.SESSION_REVOKED: ErrorMeta(401, "该会话已登出", "请重新登录"),
ErrorCode.FORBIDDEN: ErrorMeta(403, "没有访问权限"),
ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"),
ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"),
ErrorCode.MINOR_RESTRICTED: ErrorMeta(403, "该功能未向未成年人开放"),
ErrorCode.TASK_BONUS_CAP_REACHED: ErrorMeta(
403, "任务奖励已达上限", "明天再来,或等额度自然恢复"
),
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"),
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
409, "这个周期的任务奖励已领取", "下个周期再来"
),
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
@@ -94,6 +117,9 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.MODERATION_UNAVAILABLE: ErrorMeta(
503, "内容安全服务不可用", "为保证安全本次请求已中止,请稍后重试"
),
ErrorCode.LLM_SCENE_DEGRADED: ErrorMeta(
503, "该功能今日暂时关闭", "AI 服务今日已达服务上限,明天恢复"
),
}
Executable → Regular
+50
View File
@@ -254,3 +254,53 @@ class AiCall(Base):
Index("ix_ob_ai_call_scene_created", "scene", "created_at"),
Index("ix_ob_ai_call_user_created", "user_id", "created_at"),
)
class UserSession(Base):
"""refresh token 的服务端会话(架构文档 §6.2 id_session,FR-ACC-05).
刻意叫 UserSession 而不是 Session:避免与 SQLAlchemy 的 AsyncSession 混淆。
refresh token 本体是 opaque 随机串(不出现在任何存储里),库里只存 sha256
哈希 —— 泄漏数据库不等于泄漏令牌。轮换 = 旧行 revoked + 新行,同一事务。
"""
__tablename__ = "id_session"
session_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
refresh_token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expire_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("refresh_token_hash", name="uq_id_session_token_hash"),
Index("ix_id_session_user", "user_id"),
)
class ObEvent(Base):
"""客户端埋点事件(架构文档 §6.2 ob_event,FR-SYS-06).
架构原文「按月分区」—— V1 先普通表(与 ob_ai_call 同样处理),
数据量上来后再分区,先不预支复杂度。
"""
__tablename__ = "ob_event"
event_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
event_name: Mapped[str] = mapped_column(String(64), nullable=False)
props: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
client_ts: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
server_ts: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_ob_event_name_ts", "event_name", "server_ts"),
Index("ix_ob_event_user_ts", "user_id", "server_ts"),
)
+8
View File
@@ -0,0 +1,8 @@
"""storage 域 — COS 直传预签名(FR-SYS-03 / T-P1-27)。
分层位置:共享能力层(与 identity/billing/task 同层),
只准 import packages.shared / packages.domain 与 qcloud_cos(COS SDK)。
SDK 必须隔离在 CosSigner Protocol 后面,业务与测试都不直连。
实现任务:docs/开发任务包/T5-上传预签名.md(worktree task/t5-upload-presign)。
"""
+1
View File
@@ -0,0 +1 @@
"""storage 域(COS 直传)。见 README.md。"""
+11
View File
@@ -0,0 +1,11 @@
"""storage 域 service — 骨架(T5 任务填充实现)。"""
from __future__ import annotations
from typing import Any
class StorageService:
def __init__(self, settings: Any, signer: Any | None = None) -> None:
self._settings = settings
self._signer = signer