"""叩心表模型:主动联系候选(静默/频率/预算/渠道四约束)。 设计依据 PRD §6.6 与 mandate-and-capability-model.md §5: - 主动对话先形成叩心候选,受静默、频率、渠道和预算控制; - 用户不回应不会形成情感债,也不会降低缘契(无关系副作用——服务层断言钉死); - 候选默认不自动发送:dispatch 需通过约束裁决与用户确认。 """ from datetime import datetime from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.core.ids import new_id from app.core.time import utc_now from app.db.base import Base class KnockingCandidate(Base): """叩心候选:触发原因 + 草稿 + 约束裁决记录。""" __tablename__ = "knocking_candidates" id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("knock")) character_id: Mapped[str] = mapped_column( String(80), ForeignKey("characters.id"), nullable=False, index=True ) owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True) # 触发原因:event(真实事件应感)/ time_anchor(时点,如纪念日临近)/ manual trigger: Mapped[str] = mapped_column(String(20), nullable=False, default="manual") # 草稿内容(发送前用户可改) draft: Mapped[str] = mapped_column(Text, nullable=False, default="") # 渠道:in_app(1.0 唯一渠道) channel: Mapped[str] = mapped_column(String(20), nullable=False, default="in_app") # candidate / dispatched / expired / dismissed status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True) # 约束裁决快照(发送时的开关/静默/频率/预算检查结果,审计可查) ruling_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) dispatched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))