Files
meida_backend/packages/shared/models.py
T
xsl d974526904 chore(prep): shared groundwork for batch-4 (report pipeline)
第四批(T15 编排/T16 视觉诊断/T17 文案校验)协调提交:
- 迁移 0006:rp_report(result JSONB 按阶段键承载中间态)/rp_input
 (derived_features 视觉中间态)/rp_share(V1 只建结构)
- ORM 三模型;错误码 40420/40920(文案无价格字样)
- 配置:report_pipeline_stages(dotted path 动态装配)+ vision/tier
- packages/report/pipeline.py:Stage Protocol + ReportContext(三任务共同契约,
  动态 import 的动机与代价写在 docstring)
- 路由桩 reports.py 已注册
2026-08-14 21:51:05 +08:00

544 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQLAlchemy 2.0 ORM models.
Scope of this scaffold: only the tables needed to prove the vertical slice
(架构文档 §6.2 identity / billing / task / 观测与成本). All remaining domains
(pf_ / rp_ / kb_ / ag_ / tr_ / cp_) are intentionally absent — see
packages/*/README.md for what still has to be built.
Conventions enforced here (架构文档 §6.1):
1. 表名带域前缀 (`id_` / `bl_` / `tk_` / `ob_`),为将来拆库预留边界。
2. 主键统一雪花 ID (bigint),不用自增、不用 UUID。
3. 涉及配额的表必须有幂等键与流水。
4. JSONB 只用于「结构会演进且不需要索引全部字段」的场景。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
BigInteger,
Boolean,
Computed,
DateTime,
Index,
Integer,
Numeric,
PrimaryKeyConstraint,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""Declarative base; ``Base.metadata`` is Alembic's ``target_metadata``."""
# ---------------------------------------------------------------------------
# identity 域
# ---------------------------------------------------------------------------
class User(Base):
__tablename__ = "id_user"
user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
nickname: Mapped[str | None] = mapped_column(String(64))
avatar_key: Mapped[str | None] = mapped_column(String(255))
register_source: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (Index("ix_id_user_status", "status"),)
class UserIdentity(Base):
"""External identity mapping. UNIQUE(provider, union_id) per 架构文档 §6.2."""
__tablename__ = "id_user_identity"
identity_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
union_id: Mapped[str] = mapped_column(String(128), nullable=False)
open_id: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("provider", "union_id", name="uq_id_user_identity_provider_union"),
Index("ix_id_user_identity_user", "user_id"),
)
class Consent(Base):
"""CR-01 留证:人脸的单独同意必须独立成行,不得混入总协议。"""
__tablename__ = "id_consent"
consent_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
consent_type: Mapped[str] = mapped_column(String(32), nullable=False)
doc_version: Mapped[str] = mapped_column(String(32), nullable=False)
granted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
client_ip: Mapped[str | None] = mapped_column(String(64))
ua: Mapped[str | None] = mapped_column(String(512))
__table_args__ = (Index("ix_id_consent_user_type", "user_id", "consent_type"),)
# ---------------------------------------------------------------------------
# billing 域(V1:配额与计量,无支付)
# ---------------------------------------------------------------------------
class QuotaRule(Base):
"""FR-BILL-04 配额规则配置化:改配置不发版。"""
__tablename__ = "bl_quota_rule"
quota_key: Mapped[str] = mapped_column(String(64), primary_key=True)
period_type: Mapped[str] = mapped_column(String(16), nullable=False) # day/month/lifetime
base_limit: Mapped[int] = mapped_column(Integer, nullable=False)
task_bonus_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
hard_cap: Mapped[int] = mapped_column(Integer, nullable=False)
description: Mapped[str | None] = mapped_column(String(255))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Quota(Base):
"""周期配额计量:按 ``period_key`` 自然重置,无需定时任务清零。"""
__tablename__ = "bl_quota"
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
quota_key: Mapped[str] = mapped_column(String(64), nullable=False)
period_key: Mapped[str] = mapped_column(String(32), nullable=False)
used: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 本周期创建时从规则拷下的 base_limit。规则中途被改,本周期仍按快照判定。
limit_value: Mapped[int] = mapped_column(Integer, nullable=False)
bonus_granted: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
PrimaryKeyConstraint("user_id", "quota_key", "period_key", name="pk_bl_quota"),
)
class Ledger(Base):
"""流水是真相,余额是缓存 (架构文档 §6.1 原则 4 / §6.2 bl_ledger).
``tx_type`` 在 V1 只用 system_grant / consume / refund;V2 增加
purchase / gift —— 这是 V1→V2 免数据迁移的关键,不要拆表。
``status`` 承载 §7.4 的预扣三段式:reserved → committed / refunded。
"""
__tablename__ = "bl_ledger"
tx_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
delta: Mapped[int] = mapped_column(Integer, nullable=False)
tx_type: Mapped[str] = mapped_column(String(24), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="committed")
reason: Mapped[str | None] = mapped_column(String(128))
ref_type: Mapped[str | None] = mapped_column(String(32))
ref_id: Mapped[str | None] = mapped_column(String(64))
quota_key: Mapped[str | None] = mapped_column(String(64))
period_key: Mapped[str | None] = mapped_column(String(32))
balance_after: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_bl_ledger_idempotency_key"),
Index("ix_bl_ledger_user_created", "user_id", "created_at"),
)
# ---------------------------------------------------------------------------
# task 域(统一任务中心)
# ---------------------------------------------------------------------------
class Task(Base):
"""全系统唯一的长耗时任务视图,前端任务中心直接读它 (架构文档 §6.2)。"""
__tablename__ = "tk_task"
task_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
task_type: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False)
progress: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
stage_text: Mapped[str | None] = mapped_column(String(128))
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
result: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
error_code: Mapped[int | None] = mapped_column(Integer)
error_message: Mapped[str | None] = mapped_column(String(512))
retry_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
quota_reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
reserve_tx_id: Mapped[int | None] = mapped_column(BigInteger)
submitted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
eta_seconds: Mapped[int | None] = mapped_column(Integer)
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_tk_task_idempotency_key"),
Index("ix_tk_task_user_status", "user_id", "status"),
)
# ---------------------------------------------------------------------------
# 观测与成本
# ---------------------------------------------------------------------------
class AiCall(Base):
"""成本归因的唯一数据源 (架构文档 §13, FR-BILL-08).
**不允许「先上线后补埋点」** —— V1 不收费,成本是唯一财务变量,且这张表
是 V2 定价的唯一数据来源。每一次 provider 调用(含失败与结构化重试)都写
一行。生产环境按月分区。
"""
__tablename__ = "ob_ai_call"
call_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int | None] = mapped_column(BigInteger)
scene: Mapped[str] = mapped_column(String(48), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
model: Mapped[str] = mapped_column(String(64), nullable=False)
# Prompt 版本:架构文档 §8.1「可复现」要求记录,§6.2 的列清单漏了,这里补上。
prompt_id: Mapped[str | None] = mapped_column(String(64))
prompt_version: Mapped[str | None] = mapped_column(String(16))
tokens_in: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_out: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_cached: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 思考模式 token 单独记,不并入 tokens_out:它按输出价计费,混在一起就看不出
# 开关思考模式的成本差异,而这正是 T-P1-24a 要长期盯的数。
tokens_reasoning: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost_cents: Mapped[Decimal] = mapped_column(
Numeric(14, 6), nullable=False, default=Decimal("0")
)
# 该次调用适用的峰谷档位。没有它就无法事后复核 cost_cents ——
# 单价随时刻变化,事后按「当前价」回算会得出不同结果 (T-P1-25c)。
rate_tier: Mapped[str | None] = mapped_column(String(16))
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
error_code: Mapped[str | None] = mapped_column(String(64))
error_message: Mapped[str | None] = mapped_column(Text)
ref_type: Mapped[str | None] = mapped_column(String(32))
ref_id: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_ob_ai_call_created", "created_at"),
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"),
)
# ---------------------------------------------------------------------------
# kb 域(知识库,架构文档 §6.2)
# ---------------------------------------------------------------------------
class KbEntity(Base):
"""12 类实体统一表:attrs JSONB 承载类目差异化字段(架构 §6.2).
状态机 draft→reviewing→published→archived 由 service 层校验;
published 实体不可直接改(version 递增需回 draft 重走评审)。
"""
__tablename__ = "kb_entity"
entity_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_type: Mapped[str] = mapped_column(String(32), nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
attrs: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
source: Mapped[str | None] = mapped_column(String(255))
license: Mapped[str | None] = mapped_column(String(64))
updated_by: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("entity_type", "name", name="uq_kb_entity_type_name"),
Index("ix_kb_entity_type_status", "entity_type", "status"),
)
class KbRelation(Base):
"""邻接表 + 递归 CTE 做 1~3 跳(TD-09:不上 Neo4j)."""
__tablename__ = "kb_relation"
relation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
from_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
rel_type: Mapped[str] = mapped_column(String(64), nullable=False)
to_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
weight: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1"))
confidence: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1"))
evidence: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("from_id", "rel_type", "to_id", name="uq_kb_relation_triple"),
Index("ix_kb_relation_to", "to_id"),
)
class KbRule(Base):
"""决策表(TD-10 自研规则引擎)。conditions/effects 为 JSONB DSL."""
__tablename__ = "kb_rule"
rule_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
rule_key: Mapped[str] = mapped_column(String(64), nullable=False)
category: Mapped[str] = mapped_column(String(32), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
conditions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
effects: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
is_hard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
confidence: Mapped[Decimal] = mapped_column(
Numeric(4, 3), nullable=False, default=Decimal("0.9")
)
evidence: Mapped[str | None] = mapped_column(String(255))
author: Mapped[str | None] = mapped_column(String(64))
reviewer: Mapped[str | None] = mapped_column(String(64))
published_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("rule_key", name="uq_kb_rule_key"),
Index("ix_kb_rule_status_priority", "status", "priority"),
)
class KbChunk(Base):
"""向量 + 全文双索引,支撑混合检索(架构 §8.3).
embedding_model/version 必填:没有版本号的向量不可重跑(T-P2-03)。
tsv 是生成列——写入时不要手工填。
"""
__tablename__ = "kb_chunk"
chunk_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
chunk_type: Mapped[str] = mapped_column(String(32), nullable=False, default="text")
text: Mapped[str] = mapped_column(Text, nullable=False)
embedding = mapped_column(Vector(1024), nullable=False)
embedding_model: Mapped[str] = mapped_column(String(64), nullable=False)
embedding_version: Mapped[str] = mapped_column(String(16), nullable=False)
tsv = mapped_column(
TSVECTOR, Computed("to_tsvector('simple', text)", persisted=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_kb_chunk_entity", "entity_id"),)
class KbAsset(Base):
"""图像资产。tryon_ready 是换装闭环的关键标志位(架构 §6.2)."""
__tablename__ = "kb_asset"
asset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
asset_type: Mapped[str] = mapped_column(String(32), nullable=False)
cos_key: Mapped[str] = mapped_column(String(255), nullable=False)
spec_check: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
tryon_ready: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
review_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
license: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_kb_asset_entity", "entity_id"),
Index("ix_kb_asset_tryon_ready", "tryon_ready", postgresql_where=text("tryon_ready")),
)
class KbColor(Base):
"""自建色板(TD-18:规避「四季色彩/CMB」商标)。Lab 用于 ΔE 数值匹配."""
__tablename__ = "kb_color"
color_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
season_code: Mapped[str] = mapped_column(String(16), nullable=False)
role: Mapped[str] = mapped_column(String(8), nullable=False)
lab_l: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
lab_a: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
lab_b: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
hex: Mapped[str] = mapped_column(String(7), nullable=False)
name_cn: Mapped[str] = mapped_column(String(64), nullable=False)
__table_args__ = (Index("ix_kb_color_season", "season_code"),)
class KbGap(Base):
"""知识缺口埋点:无检索结果的查询,驱动内容生产(T-P2-06)."""
__tablename__ = "kb_gap"
gap_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
query_text: Mapped[str] = mapped_column(Text, nullable=False)
filter_ctx: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
hit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_kb_gap_query", "query_text"),)
# ---------------------------------------------------------------------------
# report 域(美学报告,架构文档 §6.2 / §7.1)
# ---------------------------------------------------------------------------
class RpReport(Base):
"""报告主表。result JSONB 同时承载流水线中间态与最终产物(按 stage 键)——
阶段逐段落库是 T-P3-09「失败不需重新上传」的结构基础。"""
__tablename__ = "rp_report"
report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
tier: Mapped[str] = mapped_column(String(8), nullable=False, default="basic")
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
result: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
model_info: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
content_code: Mapped[str | None] = mapped_column(String(32))
rendered_asset_key: Mapped[str | None] = mapped_column(String(255))
cost_cents: Mapped[Decimal] = mapped_column(
Numeric(14, 6), nullable=False, default=Decimal("0")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (Index("ix_rp_report_user_created", "user_id", "created_at"),)
class RpInput(Base):
"""报告输入。derived_features 是视觉分析的中间态:重试/重新生成时
已存在则跳过视觉阶段(T-P3-09)。只存派生特征与元信息,不存原图(CR-02)。"""
__tablename__ = "rp_input"
input_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
questionnaire: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
photo_meta: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
derived_features: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
__table_args__ = (UniqueConstraint("report_id", name="uq_rp_input_report"),)
class RpShare(Base):
"""分享链接:可失效、可撤回(V1 只建结构,路由后续任务)。"""
__tablename__ = "rp_share"
share_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
share_token: Mapped[str] = mapped_column(String(64), nullable=False)
expire_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
view_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (UniqueConstraint("share_token", name="uq_rp_share_token"),)