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
+31
View File
@@ -81,3 +81,34 @@ OTEL_ENABLED=false
OTEL_SERVICE_NAME=meida-api OTEL_SERVICE_NAME=meida-api
OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_ENDPOINT=
PROMETHEUS_ENABLED=true 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"}}
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
- name: Mypy (strict scope) - name: Mypy (strict scope)
run: uv run mypy packages/domain packages/llm run: uv run mypy packages/domain packages/llm
- name: Mypy (remaining scope) - 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) --- # --- 防线 3: 分层依赖检查 (架构文档 §4.2) ---
- name: Import contracts - name: Import contracts
Executable → Regular
+1 -1
View File
@@ -56,7 +56,7 @@ fmt: ## 自动修复与格式化
typecheck: ## mypy(domain 与 llm 为 strict) typecheck: ## mypy(domain 与 llm 为 strict)
uv run mypy packages/domain packages/llm uv run mypy packages/domain packages/llm
uv run mypy apps packages/shared packages/identity packages/billing packages/task \ 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) imports: ## 分层依赖契约(架构文档 §4.2)
uv run lint-imports uv run lint-imports
Executable → Regular
+13
View File
@@ -16,6 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from packages.agent.service import AgentService from packages.agent.service import AgentService
from packages.analytics.service import AnalyticsService
from packages.billing.service import BillingService from packages.billing.service import BillingService
from packages.identity.service import IdentityService from packages.identity.service import IdentityService
from packages.llm.budget import DailyBudgetGuard, RedisCostCounter 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.errors import AppError, ErrorCode
from packages.shared.redis_client import get_redis from packages.shared.redis_client import get_redis
from packages.shared.security import decode_access_token from packages.shared.security import decode_access_token
from packages.storage.service import StorageService
from packages.task.service import TaskService from packages.task.service import TaskService
bearer_scheme = HTTPBearer(auto_error=False) 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), daily_budget_cents=Decimal(settings.llm_daily_budget_cents),
enabled=settings.llm_daily_budget_enabled, enabled=settings.llm_daily_budget_enabled,
tz_offset_hours=settings.llm_budget_timezone_offset_hours, tz_offset_hours=settings.llm_budget_timezone_offset_hours,
tiers=list(settings.llm_budget_tiers),
) )
return LLMGateway( return LLMGateway(
@@ -180,6 +183,14 @@ def task_service_dep(session: SessionDep) -> TaskService:
return TaskService(session) 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( def agent_service_dep(
session: SessionDep, session: SessionDep,
gateway: GatewayDep, gateway: GatewayDep,
@@ -191,4 +202,6 @@ def agent_service_dep(
IdentityServiceDep = Annotated[IdentityService, Depends(identity_service_dep)] IdentityServiceDep = Annotated[IdentityService, Depends(identity_service_dep)]
BillingServiceDep = Annotated[BillingService, Depends(billing_service_dep)] BillingServiceDep = Annotated[BillingService, Depends(billing_service_dep)]
TaskServiceDep = Annotated[TaskService, Depends(task_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)] AgentServiceDep = Annotated[AgentService, Depends(agent_service_dep)]
Executable → Regular
+3 -2
View File
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from apps.api.deps import build_llm_gateway 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.config import Settings, get_settings
from packages.shared.db import dispose_engine from packages.shared.db import dispose_engine
from packages.shared.handlers import install_exception_handlers from packages.shared.handlers import install_exception_handlers
@@ -69,7 +69,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
setup_tracing(app, cfg) setup_tracing(app, cfg)
app.include_router(health.router) 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) app.include_router(router, prefix=cfg.api_prefix)
return app return app
+10
View File
@@ -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"])
+10
View File
@@ -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")
+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", key_prefix: str = "llm:cost:daily",
tz_offset_hours: int = 8, tz_offset_hours: int = 8,
clock: object | None = None, clock: object | None = None,
tiers: list[dict[str, object]] | None = None,
) -> None: ) -> None:
self._counter = counter self._counter = counter
self._budget = daily_budget_cents self._budget = daily_budget_cents
@@ -83,6 +84,9 @@ class DailyBudgetGuard:
self._key_prefix = key_prefix self._key_prefix = key_prefix
self._tz = timezone(timedelta(hours=tz_offset_hours)) self._tz = timezone(timedelta(hours=tz_offset_hours))
self._clock = clock self._clock = clock
# 四档分级降级的配置(架构 §13.3 / T-P1-25b)。T1 任务实现判定逻辑;
# 这里先接收配置,使 deps 装配层不必随后再改。None = 使用内置默认四档。
self._tiers_config = tiers
@property @property
def enabled(self) -> bool: 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_enabled: bool = True
llm_daily_budget_cents: Decimal = Decimal("100000") llm_daily_budget_cents: Decimal = Decimal("100000")
llm_budget_timezone_offset_hours: int = 8 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 ----------------------------------------------------- # --- Observability -----------------------------------------------------
otel_enabled: bool = False otel_enabled: bool = False
@@ -118,6 +179,9 @@ class Settings(BaseSettings):
"llm_cost_rates", "llm_cost_rates",
"llm_cost_rates_peak", "llm_cost_rates_peak",
"llm_default_cost_rate", "llm_default_cost_rate",
"llm_budget_tiers",
"task_bonus_rules",
"upload_scenes",
mode="before", mode="before",
) )
@classmethod @classmethod
Executable → Regular
+27 -1
View File
@@ -24,15 +24,23 @@ class ErrorCode(IntEnum):
BAD_REQUEST = 40000 BAD_REQUEST = 40000
VALIDATION_FAILED = 40001 VALIDATION_FAILED = 40001
IDEMPOTENCY_CONFLICT = 40002 IDEMPOTENCY_CONFLICT = 40002
UPLOAD_REJECTED = 40010
EVENTS_BATCH_REJECTED = 40020
UNAUTHENTICATED = 40100 UNAUTHENTICATED = 40100
TOKEN_EXPIRED = 40101 TOKEN_EXPIRED = 40101
TOKEN_INVALID = 40102 TOKEN_INVALID = 40102
REFRESH_TOKEN_INVALID = 40103
REFRESH_TOKEN_EXPIRED = 40104
SESSION_REVOKED = 40105
FORBIDDEN = 40300 FORBIDDEN = 40300
QUOTA_EXHAUSTED = 40301 QUOTA_EXHAUSTED = 40301
CONSENT_REQUIRED = 40302 CONSENT_REQUIRED = 40302
MINOR_RESTRICTED = 40303 MINOR_RESTRICTED = 40303
TASK_BONUS_CAP_REACHED = 40310
NOT_FOUND = 40400 NOT_FOUND = 40400
TASK_BONUS_UNKNOWN = 40410
STATE_CONFLICT = 40900 STATE_CONFLICT = 40900
TASK_BONUS_ALREADY_CLAIMED = 40910
RATE_LIMITED = 42900 RATE_LIMITED = 42900
# --- 5xxxx server ----------------------------------------------------- # --- 5xxxx server -----------------------------------------------------
@@ -49,6 +57,7 @@ class ErrorCode(IntEnum):
LLM_DAILY_BUDGET_EXCEEDED = 60003 LLM_DAILY_BUDGET_EXCEEDED = 60003
LLM_PROMPT_CACHE_VIOLATION = 60004 LLM_PROMPT_CACHE_VIOLATION = 60004
MODERATION_UNAVAILABLE = 60005 MODERATION_UNAVAILABLE = 60005
LLM_SCENE_DEGRADED = 60006
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -63,17 +72,31 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.BAD_REQUEST: ErrorMeta(400, "请求参数有误", "请检查后重试"), ErrorCode.BAD_REQUEST: ErrorMeta(400, "请求参数有误", "请检查后重试"),
ErrorCode.VALIDATION_FAILED: ErrorMeta(422, "请求参数校验未通过", "请按提示修改后重试"), ErrorCode.VALIDATION_FAILED: ErrorMeta(422, "请求参数校验未通过", "请按提示修改后重试"),
ErrorCode.IDEMPOTENCY_CONFLICT: ErrorMeta( 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.UNAUTHENTICATED: ErrorMeta(401, "请先登录", "登录后即可继续"),
ErrorCode.TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"), ErrorCode.TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"),
ErrorCode.TOKEN_INVALID: 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.FORBIDDEN: ErrorMeta(403, "没有访问权限"),
ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"), ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"),
ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"), ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"),
ErrorCode.MINOR_RESTRICTED: ErrorMeta(403, "该功能未向未成年人开放"), ErrorCode.MINOR_RESTRICTED: ErrorMeta(403, "该功能未向未成年人开放"),
ErrorCode.TASK_BONUS_CAP_REACHED: ErrorMeta(
403, "任务奖励已达上限", "明天再来,或等额度自然恢复"
),
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"), ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"), ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"),
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
409, "这个周期的任务奖励已领取", "下个周期再来"
),
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"), ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"), ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"), ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
@@ -94,6 +117,9 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.MODERATION_UNAVAILABLE: ErrorMeta( ErrorCode.MODERATION_UNAVAILABLE: ErrorMeta(
503, "内容安全服务不可用", "为保证安全本次请求已中止,请稍后重试" 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_scene_created", "scene", "created_at"),
Index("ix_ob_ai_call_user_created", "user_id", "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
+2 -1
View File
@@ -20,6 +20,7 @@ dependencies = [
"structlog>=24.4", "structlog>=24.4",
"orjson>=3.10", "orjson>=3.10",
"prometheus-client>=0.21", "prometheus-client>=0.21",
"cos-python-sdk-v5>=1.9.44",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -110,7 +111,7 @@ type = "layers"
layers = [ layers = [
"apps", "apps",
"packages.agent | packages.report | packages.tryon | packages.wardrobe", "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.kb | packages.retrieval | packages.rules | packages.memory | packages.profile",
"packages.llm", "packages.llm",
"packages.shared", "packages.shared",
Generated
+63
View File
@@ -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" }, { 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]] [[package]]
name = "coverage" name = "coverage"
version = "7.15.4" 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" }, { 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]] [[package]]
name = "cryptography" name = "cryptography"
version = "50.0.0" version = "50.0.0"
@@ -1150,6 +1172,7 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "celery" }, { name = "celery" },
{ name = "cos-python-sdk-v5" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "orjson" }, { name = "orjson" },
@@ -1183,6 +1206,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "asyncpg", specifier = ">=0.30" }, { name = "asyncpg", specifier = ">=0.30" },
{ name = "celery", specifier = ">=5.4" }, { name = "celery", specifier = ">=5.4" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.44" },
{ name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.5" }, { name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.5" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28" }, { 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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" 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/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" }, { 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" },
]