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:
Executable → Regular
+64
@@ -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
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user