diff --git a/.env.example b/.env.example index 829d0d5..2b1399f 100755 --- a/.env.example +++ b/.env.example @@ -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"}} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87bf3f1..39a5feb 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Makefile b/Makefile old mode 100755 new mode 100644 index f5df8fb..59caa97 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/apps/api/deps.py b/apps/api/deps.py old mode 100755 new mode 100644 index 8789bb2..faa243f --- a/apps/api/deps.py +++ b/apps/api/deps.py @@ -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)] diff --git a/apps/api/main.py b/apps/api/main.py old mode 100755 new mode 100644 index 0d1f926..eda2efa --- a/apps/api/main.py +++ b/apps/api/main.py @@ -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 diff --git a/apps/api/routers/events.py b/apps/api/routers/events.py new file mode 100644 index 0000000..4212253 --- /dev/null +++ b/apps/api/routers/events.py @@ -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"]) diff --git a/apps/api/routers/uploads.py b/apps/api/routers/uploads.py new file mode 100644 index 0000000..9124203 --- /dev/null +++ b/apps/api/routers/uploads.py @@ -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"]) diff --git a/migrations/versions/0004_sessions_and_events.py b/migrations/versions/0004_sessions_and_events.py new file mode 100644 index 0000000..43d67ac --- /dev/null +++ b/migrations/versions/0004_sessions_and_events.py @@ -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") diff --git a/packages/analytics/README.md b/packages/analytics/README.md new file mode 100644 index 0000000..4f6efba --- /dev/null +++ b/packages/analytics/README.md @@ -0,0 +1,7 @@ +"""analytics 域 — 客户端埋点上报(FR-SYS-06)。 + +分层位置:共享能力层(与 identity/billing/task 同层), +只准 import packages.shared / packages.domain。 + +实现任务:docs/开发任务包/T3-埋点上报.md(worktree task/t3-events)。 +""" diff --git a/packages/analytics/__init__.py b/packages/analytics/__init__.py new file mode 100644 index 0000000..3731055 --- /dev/null +++ b/packages/analytics/__init__.py @@ -0,0 +1 @@ +"""analytics 域(埋点上报)。见 README.md。""" diff --git a/packages/analytics/service.py b/packages/analytics/service.py new file mode 100644 index 0000000..0ab23e1 --- /dev/null +++ b/packages/analytics/service.py @@ -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 diff --git a/packages/llm/budget.py b/packages/llm/budget.py old mode 100755 new mode 100644 index c9cb519..70e77b8 --- a/packages/llm/budget.py +++ b/packages/llm/budget.py @@ -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: diff --git a/packages/shared/config.py b/packages/shared/config.py old mode 100755 new mode 100644 index e027779..dea6034 --- a/packages/shared/config.py +++ b/packages/shared/config.py @@ -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 diff --git a/packages/shared/errors.py b/packages/shared/errors.py old mode 100755 new mode 100644 index 608567a..5ac076f --- a/packages/shared/errors.py +++ b/packages/shared/errors.py @@ -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 服务今日已达服务上限,明天恢复" + ), } diff --git a/packages/shared/models.py b/packages/shared/models.py old mode 100755 new mode 100644 index 9b7adfe..024aa93 --- a/packages/shared/models.py +++ b/packages/shared/models.py @@ -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"), + ) diff --git a/packages/storage/README.md b/packages/storage/README.md new file mode 100644 index 0000000..d591e42 --- /dev/null +++ b/packages/storage/README.md @@ -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)。 +""" diff --git a/packages/storage/__init__.py b/packages/storage/__init__.py new file mode 100644 index 0000000..b82405c --- /dev/null +++ b/packages/storage/__init__.py @@ -0,0 +1 @@ +"""storage 域(COS 直传)。见 README.md。""" diff --git a/packages/storage/service.py b/packages/storage/service.py new file mode 100644 index 0000000..456fa44 --- /dev/null +++ b/packages/storage/service.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b82cc72..1446ba0 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/uv.lock b/uv.lock index d0ab4d0..fa60890 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]