from datetime import datetime from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint from sqlalchemy.dialects.mysql import MEDIUMBLOB from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base class AdminUser(Base): __tablename__ = "admin_users" id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(64), unique=True, index=True) password_hash: Mapped[str] = mapped_column(String(255)) display_name: Mapped[str] = mapped_column(String(64)) role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True) dingtalk_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) dingtalk_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) dingtalk_corp_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) dingtalk_dept_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) dingtalk_dept_name: Mapped[str | None] = mapped_column(String(255), nullable=True) dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) dingtalk_title: Mapped[str | None] = mapped_column(String(255), nullable=True) is_dept_manager: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # Stable public entry point for answer-task submissions created by this # operator. The token is intentionally separate from the admin session # cookie and is safe to embed in a long-lived URL. answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True) last_answer_shop_name: Mapped[str | None] = mapped_column(String(255), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user") ask_tasks: Mapped[list["AskTask"]] = relationship(back_populates="owner") class AdminLoginLog(Base): __tablename__ = "admin_login_logs" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) username: Mapped[str] = mapped_column(String(64), index=True) success: Mapped[bool] = mapped_column(Boolean, index=True) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) user: Mapped[AdminUser | None] = relationship(back_populates="login_logs") class AdminUsageEntry(Base): """One deduplicated entry into the operations console.""" __tablename__ = "admin_usage_entries" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column(Integer, index=True) username: Mapped[str] = mapped_column(String(64), index=True) display_name: Mapped[str] = mapped_column(String(64), index=True) department_name: Mapped[str] = mapped_column(String(255), default="", index=True) entered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) class QALibraryEntry(Base): __tablename__ = "qa_library_entries" id: Mapped[int] = mapped_column(Integer, primary_key=True) fingerprint: Mapped[str] = mapped_column(String(64), unique=True, index=True) product_name: Mapped[str] = mapped_column(String(255), default="", index=True) category: Mapped[str] = mapped_column(String(100), default="", index=True) benefits: Mapped[str] = mapped_column(Text, default="") selling_points: Mapped[str] = mapped_column(Text, default="") question_text: Mapped[str] = mapped_column(Text) answers_json: Mapped[str] = mapped_column(Text) source: Mapped[str] = mapped_column(String(64), default="AI生成") created_by: Mapped[str] = mapped_column(String(64), default="运营后台") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) class QABulkGenerationJob(Base): """Persistent progress for a bulk AI-answer generation request.""" __tablename__ = "qa_bulk_generation_jobs" id: Mapped[int] = mapped_column(Integer, primary_key=True) public_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) # Keep this as an indexed ownership value without a database FK so the # table remains compatible with existing MySQL installations whose admin # primary key may use a different integer width. owner_id: Mapped[int] = mapped_column(Integer, index=True) payload_json: Mapped[str] = mapped_column(Text) answers_per_question: Mapped[int] = mapped_column(Integer) status: Mapped[str] = mapped_column(String(32), default="queued", index=True) total_count: Mapped[int] = mapped_column(Integer, default=0) completed_count: Mapped[int] = mapped_column(Integer, default=0) failed_count: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) items: Mapped[list["QABulkGenerationItem"]] = relationship( back_populates="job", cascade="all, delete-orphan", lazy="selectin", order_by="QABulkGenerationItem.sequence_no", ) class QABulkGenerationItem(Base): __tablename__ = "qa_bulk_generation_items" __table_args__ = ( UniqueConstraint("job_id", "sequence_no", name="uq_qa_bulk_generation_item_sequence"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) job_id: Mapped[int] = mapped_column(ForeignKey("qa_bulk_generation_jobs.id"), index=True) sequence_no: Mapped[int] = mapped_column(Integer) question_text: Mapped[str] = mapped_column(Text) answers_json: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(24), default="pending", index=True) attempts: Mapped[int] = mapped_column(Integer, default=0) error_message: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) job: Mapped[QABulkGenerationJob] = relationship(back_populates="items") class AnswerUser(Base): __tablename__ = "answer_users" id: Mapped[int] = mapped_column(Integer, primary_key=True) public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True) phone: Mapped[str] = mapped_column(String(20), unique=True, index=True) taobao_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) auth_token: Mapped[str] = mapped_column(String(64), unique=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) last_login_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) submissions: Mapped[list["AnswerSubmission"]] = relationship(back_populates="user") ask_assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="user") class AnswerSubmission(Base): __tablename__ = "answer_submissions" id: Mapped[int] = mapped_column(Integer, primary_key=True) submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True) owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) # Store name is entered by the operator when this answer task is # dispatched. It is kept on the submission because one operator may # handle multiple stores and the same user can submit through different # operator links. shop_name: Mapped[str] = mapped_column(String(255), default="") # 用户上传受邀截图后立即进入运营筛选队列。 status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) user: Mapped[AnswerUser] = relationship(back_populates="submissions", lazy="selectin") owner: Mapped[AdminUser | None] = relationship() questions: Mapped[list["AnswerRecognizedQuestion"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin") screenshots: Mapped[list["AnswerScreenshot"]] = relationship(back_populates="submission", cascade="all, delete-orphan") review_decisions: Mapped[list["AnswerReviewDecision"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin") class AnswerScreenshot(Base): __tablename__ = "answer_screenshots" id: Mapped[int] = mapped_column(Integer, primary_key=True) submission_id: Mapped[int] = mapped_column(ForeignKey("answer_submissions.id"), index=True) stage: Mapped[str] = mapped_column(String(20), index=True) # invited/completion filename: Mapped[str] = mapped_column(String(255)) content_type: Mapped[str] = mapped_column(String(64)) image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215)) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) submission: Mapped[AnswerSubmission] = relationship(back_populates="screenshots") class AnswerRecognizedQuestion(Base): __tablename__ = "answer_recognized_questions" id: Mapped[int] = mapped_column(Integer, primary_key=True) submission_id: Mapped[int] = mapped_column(ForeignKey("answer_submissions.id"), index=True) source_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True) sequence_no: Mapped[int] = mapped_column(Integer) product_title: Mapped[str] = mapped_column(String(255)) product_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True) operator_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True) operator_image_screenshot_ids: Mapped[str | None] = mapped_column(Text, nullable=True) question_text: Mapped[str] = mapped_column(Text) answer_text: Mapped[str] = mapped_column(Text, default="") answer_status: Mapped[str] = mapped_column(String(32), default="已生成") selected: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) submission: Mapped[AnswerSubmission] = relationship(back_populates="questions") class AnswerReviewDecision(Base): __tablename__ = "answer_review_decisions" id: Mapped[int] = mapped_column(Integer, primary_key=True) submission_id: Mapped[int] = mapped_column(ForeignKey("answer_submissions.id"), index=True) approved: Mapped[bool] = mapped_column(Boolean, index=True) reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True) reason_text: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) submission: Mapped[AnswerSubmission] = relationship(back_populates="review_decisions") class AskTask(Base): __tablename__ = "ask_tasks" id: Mapped[int] = mapped_column(Integer, primary_key=True) task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) public_token: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) product_link: Mapped[str] = mapped_column(String(1000)) # QR images are capped at 5 MB by the API. MEDIUMBLOB keeps fresh MySQL # installs above the 64 KB BLOB limit while the generic type remains # compatible with SQLite development databases. product_qr_image_data: Mapped[bytes | None] = mapped_column( LargeBinary().with_variant(MEDIUMBLOB, "mysql"), nullable=True ) product_qr_image_content_type: Mapped[str | None] = mapped_column(String(64), nullable=True) product_qr_image_name: Mapped[str | None] = mapped_column(String(255), nullable=True) shop_name: Mapped[str] = mapped_column(String(255), default="") product_name: Mapped[str] = mapped_column(String(255)) category: Mapped[str] = mapped_column(String(100), default="") benefits: Mapped[str] = mapped_column(Text, default="") selling_points: Mapped[str] = mapped_column(Text, default="") language_style: Mapped[str] = mapped_column(String(40), default="口语(亲切随性)") target_group: Mapped[str] = mapped_column(String(100), default="女性") usage_scene: Mapped[str] = mapped_column(String(100), default="居家") purchase_channel: Mapped[str] = mapped_column(String(100), default="电商平台") claim_questions_per_user: Mapped[int] = mapped_column(Integer, default=5, nullable=False) status: Mapped[str] = mapped_column(String(32), default="草稿", index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) questions: Mapped[list["AskQuestion"]] = relationship(back_populates="task", cascade="all, delete-orphan", lazy="selectin") assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="task", cascade="all, delete-orphan") owner: Mapped[AdminUser | None] = relationship(back_populates="ask_tasks") class AskQuestion(Base): __tablename__ = "ask_questions" id: Mapped[int] = mapped_column(Integer, primary_key=True) task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True) sequence_no: Mapped[int] = mapped_column(Integer) question_text: Mapped[str] = mapped_column(Text) # Keep the origin so the draft UI can distinguish AI-generated questions # from questions added manually or imported from the Q&A library. source: Mapped[str] = mapped_column(String(32), default="AI生成", nullable=False) selected: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) task: Mapped[AskTask] = relationship(back_populates="questions", lazy="selectin") assignment_items: Mapped[list["AskAssignmentQuestion"]] = relationship(back_populates="question") class AskAssignment(Base): __tablename__ = "ask_assignments" __table_args__ = (UniqueConstraint("task_id", "user_id", name="uq_ask_assignment_task_user"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True) user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True) status: Mapped[str] = mapped_column(String(32), default="待选择", index=True) # The current reservation window is independent from the assignment's # lifetime. One assignment can retain several submitted batches while a # later, unsubmitted selection expires and is released safely. reservation_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) task: Mapped[AskTask] = relationship(back_populates="assignments", lazy="selectin") user: Mapped[AnswerUser] = relationship(back_populates="ask_assignments", lazy="selectin") items: Mapped[list["AskAssignmentQuestion"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin") used_questions: Mapped[list["AskAssignmentUsedQuestion"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin") screenshots: Mapped[list["AskAssignmentScreenshot"]] = relationship(back_populates="assignment", cascade="all, delete-orphan") review_decisions: Mapped[list["AskAssignmentReview"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin") batches: Mapped[list["AskAssignmentBatch"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin") class AskAssignmentBatch(Base): """One submission/review batch for an ask-task assignment. A user may submit the five assigned questions in multiple batches. The parent assignment remains the question pool, while this table preserves each submission independently for the admin review queue. """ __tablename__ = "ask_assignment_batches" id: Mapped[int] = mapped_column(Integer, primary_key=True) assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True) status: Mapped[str] = mapped_column(String(32), default="pending", index=True) selected_question_ids: Mapped[str] = mapped_column(Text, default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True) assignment: Mapped[AskAssignment] = relationship(back_populates="batches") screenshots: Mapped[list["AskAssignmentBatchScreenshot"]] = relationship(back_populates="batch", cascade="all, delete-orphan") review_decisions: Mapped[list["AskAssignmentBatchReview"]] = relationship(back_populates="batch", cascade="all, delete-orphan", lazy="selectin") class AskAssignmentBatchScreenshot(Base): __tablename__ = "ask_assignment_batch_screenshots" id: Mapped[int] = mapped_column(Integer, primary_key=True) batch_id: Mapped[int] = mapped_column(ForeignKey("ask_assignment_batches.id"), index=True) stage: Mapped[str] = mapped_column(String(20), index=True) filename: Mapped[str] = mapped_column(String(255)) content_type: Mapped[str] = mapped_column(String(64)) image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215)) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) batch: Mapped[AskAssignmentBatch] = relationship(back_populates="screenshots") class AskAssignmentBatchReview(Base): __tablename__ = "ask_assignment_batch_reviews" id: Mapped[int] = mapped_column(Integer, primary_key=True) batch_id: Mapped[int] = mapped_column(ForeignKey("ask_assignment_batches.id"), index=True) approved: Mapped[bool] = mapped_column(Boolean, index=True) reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True) reason_text: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) batch: Mapped[AskAssignmentBatch] = relationship(back_populates="review_decisions") class AskAssignmentQuestion(Base): __tablename__ = "ask_assignment_questions" __table_args__ = ( UniqueConstraint("assignment_id", "question_id", name="uq_ask_assignment_question"), # One published question can only be allocated to one user. The # question already belongs to a single task, so a global constraint on # question_id also guarantees uniqueness within that task. UniqueConstraint("question_id", name="uq_ask_assignment_question_global"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True) question_id: Mapped[int] = mapped_column(ForeignKey("ask_questions.id"), index=True) selected: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) assignment: Mapped[AskAssignment] = relationship(back_populates="items", lazy="selectin") question: Mapped[AskQuestion] = relationship(back_populates="assignment_items", lazy="selectin") class AskAssignmentUsedQuestion(Base): """Questions already consumed by a user after an approved submission. This is kept in a separate table so existing installations do not need an ALTER TABLE on ``ask_assignment_questions`` when the feature is enabled. """ __tablename__ = "ask_assignment_used_questions" __table_args__ = (UniqueConstraint("assignment_id", "question_id", name="uq_ask_assignment_used_question"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True) question_id: Mapped[int] = mapped_column(ForeignKey("ask_questions.id"), index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) assignment: Mapped[AskAssignment] = relationship(back_populates="used_questions") question: Mapped[AskQuestion] = relationship() class AskAssignmentScreenshot(Base): __tablename__ = "ask_assignment_screenshots" id: Mapped[int] = mapped_column(Integer, primary_key=True) assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True) stage: Mapped[str] = mapped_column(String(20), index=True) # question/payment filename: Mapped[str] = mapped_column(String(255)) content_type: Mapped[str] = mapped_column(String(64)) image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215)) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) assignment: Mapped[AskAssignment] = relationship(back_populates="screenshots") class AskAssignmentReview(Base): __tablename__ = "ask_assignment_reviews" id: Mapped[int] = mapped_column(Integer, primary_key=True) assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True) approved: Mapped[bool] = mapped_column(Boolean, index=True) reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True) reason_text: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) assignment: Mapped[AskAssignment] = relationship(back_populates="review_decisions") class RefundUploadSession(Base): """Short-lived, login-free upload ticket for an operator refund proof. The token is deliberately bound to one concrete board record rather than accepting a task id from the phone. This keeps a scanned code from being reused to attach a screenshot to a different task. """ __tablename__ = "refund_upload_sessions" id: Mapped[int] = mapped_column(Integer, primary_key=True) token: Mapped[str] = mapped_column(String(96), unique=True, index=True) target_kind: Mapped[str] = mapped_column(String(32), index=True) target_id: Mapped[int] = mapped_column(Integer, index=True) # Keep this as an indexed audit value without a database FK. Existing # deployments may use a different integer width for admin_users.id, and # MySQL rejects creating the new table when FK column types differ. created_by_id: Mapped[int] = mapped_column(Integer, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime, index=True) uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)