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:
@@ -81,3 +81,34 @@ OTEL_ENABLED=false
|
||||
OTEL_SERVICE_NAME=meida-api
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
PROMETHEUS_ENABLED=true
|
||||
|
||||
# ── 四档分级降级(T-P1-25b,架构 §13.3)──────────────────────────────────
|
||||
# JSON 为列表;blocked 支持 "*";allow 仅 block_all 档有意义(纯文本对话放行)。
|
||||
# 留空则使用内置默认四档(与下面示例一致)。
|
||||
LLM_BUDGET_TIERS_JSON=[{"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"]}]
|
||||
|
||||
# ── 会话(FR-ACC-05)───────────────────────────────────────────────────────
|
||||
REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||
|
||||
# ── 任务奖励(FR-BILL-03/04)───────────────────────────────────────────────
|
||||
# amount 均为占位值 1:正式数值待产品在产品方案 §3.2 定稿后改此处,不改代码。
|
||||
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。
|
||||
TASK_BONUS_RULES_JSON={"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"}}
|
||||
|
||||
# ── 客户端埋点(FR-SYS-06)─────────────────────────────────────────────────
|
||||
EVENTS_MAX_BATCH=50
|
||||
EVENTS_PROPS_MAX_BYTES=8192
|
||||
|
||||
# ── 腾讯云 COS 直传(FR-SYS-03 / T-P1-27)──────────────────────────────────
|
||||
# 密钥只在此文件对应的真实 .env 里出现;四桶按《前置开通清单》创建后填名称。
|
||||
COS_REGION=ap-shanghai
|
||||
COS_SECRET_ID=
|
||||
COS_SECRET_KEY=
|
||||
COS_BUCKET_TEMP=meida-temp
|
||||
COS_BUCKET_USER=meida-user
|
||||
COS_BUCKET_KB=meida-kb
|
||||
COS_BUCKET_REPORT=meida-report
|
||||
COS_PRESIGN_EXPIRE_SECONDS=600
|
||||
UPLOAD_MAX_BYTES=20971520
|
||||
UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/webp
|
||||
UPLOAD_SCENES_JSON={"report_photo":{"bucket":"temp"},"avatar":{"bucket":"temp"}}
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Mypy (strict scope)
|
||||
run: uv run mypy packages/domain packages/llm
|
||||
- name: Mypy (remaining scope)
|
||||
run: uv run mypy apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance
|
||||
run: uv run mypy apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage
|
||||
|
||||
# --- 防线 3: 分层依赖检查 (架构文档 §4.2) ---
|
||||
- name: Import contracts
|
||||
|
||||
@@ -56,7 +56,7 @@ fmt: ## 自动修复与格式化
|
||||
typecheck: ## mypy(domain 与 llm 为 strict)
|
||||
uv run mypy packages/domain packages/llm
|
||||
uv run mypy apps packages/shared packages/identity packages/billing packages/task \
|
||||
packages/agent packages/compliance
|
||||
packages/agent packages/compliance packages/analytics packages/storage
|
||||
|
||||
imports: ## 分层依赖契约(架构文档 §4.2)
|
||||
uv run lint-imports
|
||||
|
||||
Executable → Regular
+13
@@ -16,6 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.agent.service import AgentService
|
||||
from packages.analytics.service import AnalyticsService
|
||||
from packages.billing.service import BillingService
|
||||
from packages.identity.service import IdentityService
|
||||
from packages.llm.budget import DailyBudgetGuard, RedisCostCounter
|
||||
@@ -39,6 +40,7 @@ from packages.shared.db import get_session_factory
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.redis_client import get_redis
|
||||
from packages.shared.security import decode_access_token
|
||||
from packages.storage.service import StorageService
|
||||
from packages.task.service import TaskService
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
@@ -99,6 +101,7 @@ def build_llm_gateway(settings: Settings) -> LLMGateway:
|
||||
daily_budget_cents=Decimal(settings.llm_daily_budget_cents),
|
||||
enabled=settings.llm_daily_budget_enabled,
|
||||
tz_offset_hours=settings.llm_budget_timezone_offset_hours,
|
||||
tiers=list(settings.llm_budget_tiers),
|
||||
)
|
||||
|
||||
return LLMGateway(
|
||||
@@ -180,6 +183,14 @@ def task_service_dep(session: SessionDep) -> TaskService:
|
||||
return TaskService(session)
|
||||
|
||||
|
||||
def analytics_service_dep(session: SessionDep) -> AnalyticsService:
|
||||
return AnalyticsService(session)
|
||||
|
||||
|
||||
def storage_service_dep(settings: SettingsDep) -> StorageService:
|
||||
return StorageService(settings)
|
||||
|
||||
|
||||
def agent_service_dep(
|
||||
session: SessionDep,
|
||||
gateway: GatewayDep,
|
||||
@@ -191,4 +202,6 @@ def agent_service_dep(
|
||||
IdentityServiceDep = Annotated[IdentityService, Depends(identity_service_dep)]
|
||||
BillingServiceDep = Annotated[BillingService, Depends(billing_service_dep)]
|
||||
TaskServiceDep = Annotated[TaskService, Depends(task_service_dep)]
|
||||
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(analytics_service_dep)]
|
||||
StorageServiceDep = Annotated[StorageService, Depends(storage_service_dep)]
|
||||
AgentServiceDep = Annotated[AgentService, Depends(agent_service_dep)]
|
||||
|
||||
Executable → Regular
+3
-2
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
|
||||
from apps.api.deps import build_llm_gateway
|
||||
from apps.api.routers import agent, auth, health, quota, tasks
|
||||
from apps.api.routers import agent, auth, events, health, quota, tasks, uploads
|
||||
from packages.shared.config import Settings, get_settings
|
||||
from packages.shared.db import dispose_engine
|
||||
from packages.shared.handlers import install_exception_handlers
|
||||
@@ -69,7 +69,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
setup_tracing(app, cfg)
|
||||
|
||||
app.include_router(health.router)
|
||||
for router in (auth.router, agent.router, quota.router, tasks.router):
|
||||
routers = (auth.router, agent.router, quota.router, tasks.router, events.router, uploads.router)
|
||||
for router in routers:
|
||||
app.include_router(router, prefix=cfg.api_prefix)
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""``POST /events/batch`` — 客户端埋点批量上报(骨架,T3 填充实现)。
|
||||
|
||||
FR-SYS-06 服务端入口;实现契约见 docs/开发任务包/T3-埋点上报.md。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
@@ -0,0 +1,10 @@
|
||||
"""``POST /uploads/presign`` — COS 直传预签名(骨架,T5 填充实现)。
|
||||
|
||||
FR-SYS-03:图片不经应用服务器;实现契约见 docs/开发任务包/T5-上传预签名.md。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/uploads", tags=["uploads"])
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Sessions and client events for the Sprint-0 parallel batch.
|
||||
|
||||
Two tables that the parallel task batch (T3/T4) needs, landed together in one
|
||||
revision so no task ever writes a migration:
|
||||
|
||||
1. ``id_session`` — server-revocable refresh sessions (架构文档 §6.2, FR-ACC-05).
|
||||
2. ``ob_event`` — client telemetry (架构文档 §6.2, FR-SYS-06).
|
||||
|
||||
Partitioning (架构文档 says "按月分区") is deliberately deferred for both —
|
||||
same call as ``ob_ai_call``: plain tables until volume justifies it.
|
||||
|
||||
Reversible: 是。降级直接 drop 两表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"id_session",
|
||||
sa.Column("session_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("refresh_token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expire_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("session_id"),
|
||||
sa.UniqueConstraint("refresh_token_hash", name="uq_id_session_token_hash"),
|
||||
)
|
||||
op.create_index("ix_id_session_user", "id_session", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"ob_event",
|
||||
sa.Column("event_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("event_name", sa.String(length=64), nullable=False),
|
||||
sa.Column("props", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("client_ts", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("server_ts", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("event_id"),
|
||||
)
|
||||
op.create_index("ix_ob_event_name_ts", "ob_event", ["event_name", "server_ts"])
|
||||
op.create_index("ix_ob_event_user_ts", "ob_event", ["user_id", "server_ts"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ob_event_user_ts", table_name="ob_event")
|
||||
op.drop_index("ix_ob_event_name_ts", table_name="ob_event")
|
||||
op.drop_table("ob_event")
|
||||
op.drop_index("ix_id_session_user", table_name="id_session")
|
||||
op.drop_table("id_session")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""analytics 域 — 客户端埋点上报(FR-SYS-06)。
|
||||
|
||||
分层位置:共享能力层(与 identity/billing/task 同层),
|
||||
只准 import packages.shared / packages.domain。
|
||||
|
||||
实现任务:docs/开发任务包/T3-埋点上报.md(worktree task/t3-events)。
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""analytics 域(埋点上报)。见 README.md。"""
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)。
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""storage 域(COS 直传)。见 README.md。"""
|
||||
@@ -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
|
||||
+2
-1
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"structlog>=24.4",
|
||||
"orjson>=3.10",
|
||||
"prometheus-client>=0.21",
|
||||
"cos-python-sdk-v5>=1.9.44",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -110,7 +111,7 @@ type = "layers"
|
||||
layers = [
|
||||
"apps",
|
||||
"packages.agent | packages.report | packages.tryon | packages.wardrobe",
|
||||
"packages.identity | packages.billing | packages.task | packages.compliance | packages.notify",
|
||||
"packages.identity | packages.billing | packages.task | packages.compliance | packages.notify | packages.analytics | packages.storage",
|
||||
"packages.kb | packages.retrieval | packages.rules | packages.memory | packages.profile",
|
||||
"packages.llm",
|
||||
"packages.shared",
|
||||
|
||||
@@ -488,6 +488,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cos-python-sdk-v5"
|
||||
version = "1.9.44"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "crcmod" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "requests" },
|
||||
{ name = "six" },
|
||||
{ name = "xmltodict" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/c8/5cc0d0bd4b4bd9006976408bf390927fe51138658235c512a76799932de0/cos_python_sdk_v5-1.9.44.tar.gz", hash = "sha256:2ab403f3b64efdbb9a1984ad3e4381bd7f0755d1064148aeb25269bd757b49ab", size = 103461, upload-time = "2026-05-29T03:50:19.02Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ed/4eabca16f3f9049ac0ee5bd17ef42709a6ede659e81e97f74e77a1aae30b/cos_python_sdk_v5-1.9.44-py3-none-any.whl", hash = "sha256:584a97ecd5e9b15b04a1e511e6dd0ea4eca00ee87fe50aa9a3c91ce716149bcb", size = 99127, upload-time = "2026-05-29T03:50:17.35Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.15.4"
|
||||
@@ -587,6 +603,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crcmod"
|
||||
version = "1.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" }
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.0"
|
||||
@@ -1150,6 +1172,7 @@ dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "celery" },
|
||||
{ name = "cos-python-sdk-v5" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "orjson" },
|
||||
@@ -1183,6 +1206,7 @@ requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.14" },
|
||||
{ name = "asyncpg", specifier = ">=0.30" },
|
||||
{ name = "celery", specifier = ">=5.4" },
|
||||
{ name = "cos-python-sdk-v5", specifier = ">=1.9.44" },
|
||||
{ name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.5" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
@@ -1415,6 +1439,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
@@ -2131,3 +2185,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmltodict"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user