chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update) - 组织隔离目标: LICA - 端口规划: 前端 8030 / 后端 8031 / 数据库 8032 - 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本) - 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
26
backend/app/models/__init__.py
Normal file
26
backend/app/models/__init__.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""模型包 — 导入 Base 及所有模型,供 Alembic 自动发现"""
|
||||
from app.models.base import Base
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.app_version import AppVersion
|
||||
from app.models.message import ProductMessage
|
||||
from app.models.holiday import Holiday
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user_daily_seen import UserDailySeen
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ProductionOrder",
|
||||
"Product",
|
||||
"Task",
|
||||
"TaskRecord",
|
||||
"TaskLog",
|
||||
"Notification",
|
||||
"AppVersion",
|
||||
"ProductMessage",
|
||||
"Holiday",
|
||||
"AuditLog",
|
||||
"UserDailySeen",
|
||||
]
|
||||
44
backend/app/models/app_version.py
Normal file
44
backend/app/models/app_version.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""App 版本管理模型 — 用于 OTA 热更新"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, Text, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class AppVersion(Base):
|
||||
__tablename__ = "app_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
version: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, unique=True, comment="版本号,如 T1.0.1",
|
||||
)
|
||||
|
||||
version_code: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100, comment="数字版本号,用于比较",
|
||||
)
|
||||
|
||||
wgt_url: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False, comment="WGT 升级包下载地址",
|
||||
)
|
||||
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="更新说明",
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, comment="是否启用",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AppVersion {self.version}>"
|
||||
83
backend/app/models/audit_log.py
Normal file
83
backend/app/models/audit_log.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""操作审计日志模型
|
||||
|
||||
设计参考 MOM(KCGL) 的 audit_logs,但按 Track 的技术栈与诉求做了取舍:
|
||||
|
||||
- 主键用 UUID(与库内其它表一致),而非 MOM 的自增 int。
|
||||
- 增加 request_id:与 core/logging.py 的结构化日志打通 —— 凭一个 ID 就能把
|
||||
「接口访问日志」和「审计记录」对上,排障时不用再猜。MOM 无此字段。
|
||||
- 保留 module / action / target_* 的业务语义,使审计能按业务维度检索,
|
||||
而不是只能按时间翻。
|
||||
- 绝不记录请求体:登录等接口 body 含明文密码,一旦落库就成了长期泄露面。
|
||||
|
||||
与既有 task_logs 的分工:task_logs 是「任务流转轨迹」(有 task_id 非空约束,
|
||||
只能挂在任务上,供流转树渲染);本表是「操作审计」,覆盖登录、导出、
|
||||
产品增删改、权限变更等与单个任务无关的动作,且额外记录来源 IP / UA / 耗时结果。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 操作人(逻辑外键 → MOM sys_user,仅存账号,无物理约束)----
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="操作人账号(逻辑外键→MOM)",
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="操作人显示名",
|
||||
)
|
||||
role: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True, comment="操作时角色快照",
|
||||
)
|
||||
|
||||
# ---- 业务语义 ----
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True, comment="动作: create/update/delete/export/login/...",
|
||||
)
|
||||
module: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True, comment="业务模块: product/task/order/auth/print/...",
|
||||
)
|
||||
target_type: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True, comment="目标类型(表名或实体名)",
|
||||
)
|
||||
target_id: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, index=True, comment="目标ID",
|
||||
)
|
||||
target_name: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, comment="目标显示名(如产品身份证/工单号)",
|
||||
)
|
||||
details: Mapped[dict | None] = mapped_column(
|
||||
JSONB, nullable=True, comment="变更详情 {old:{}, new:{}};禁止写入密码等敏感字段",
|
||||
)
|
||||
|
||||
# ---- 请求上下文(由中间件自动填充)----
|
||||
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源IP")
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="浏览器UA")
|
||||
method: Mapped[str | None] = mapped_column(String(10), nullable=True, comment="HTTP方法")
|
||||
url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="请求路径")
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="响应状态码")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息(如有)")
|
||||
|
||||
# ---- 与结构化日志对账用 ----
|
||||
request_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="关联 core/logging 的 request_id",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, index=True, comment="操作时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog {self.action} {self.module} by {self.user_id}>"
|
||||
6
backend/app/models/base.py
Normal file
6
backend/app/models/base.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""SQLAlchemy 声明式基类"""
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
21
backend/app/models/holiday.py
Normal file
21
backend/app/models/holiday.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""节假日模型 — 放假日期配置,用于工作日时长计算"""
|
||||
from datetime import date
|
||||
from sqlalchemy import Date, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Holiday(Base):
|
||||
__tablename__ = "holidays"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
day: Mapped[date] = mapped_column(
|
||||
Date, unique=True, index=True, comment="放假日期",
|
||||
)
|
||||
name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="放假说明(如国庆节)",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Holiday {self.day}>"
|
||||
33
backend/app/models/message.py
Normal file
33
backend/app/models/message.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""产品协同留言板模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class ProductMessage(Base):
|
||||
__tablename__ = "product_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("products.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
comment="所属产品 ID",
|
||||
)
|
||||
operator_id: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, comment="留言人姓名或工号",
|
||||
)
|
||||
content: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, comment="留言内容",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="留言时间(北京时间)",
|
||||
)
|
||||
53
backend/app/models/notification.py
Normal file
53
backend/app/models/notification.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""通知模型 — 任务转交/驳回等事件的消息提醒"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
# 通知类型常量
|
||||
NOTIFY_TRANSFER = "TRANSFER" # 新任务派发/转交
|
||||
NOTIFY_REJECT = "REJECT" # 品质驳回
|
||||
NOTIFY_COMMENT = "COMMENT" # 留言提醒
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, index=True, comment="接收人ID(逻辑外键→老系统)",
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, comment="通知标题",
|
||||
)
|
||||
|
||||
content: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, comment="通知内容详情",
|
||||
)
|
||||
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)",
|
||||
)
|
||||
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID",
|
||||
)
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="是否已读",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Notification {self.type} → {self.user_id}>"
|
||||
86
backend/app/models/product.py
Normal file
86
backend/app/models/product.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""产品模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.core.lifecycle import LIFECYCLE_PRODUCTION
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
serial_number: Mapped[str] = mapped_column(
|
||||
String(16), unique=True, index=True, nullable=False, comment="产品序列号(16位)",
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 production_orders)— 可选 ----
|
||||
order_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("production_orders.id"), nullable=True, comment="所属订单ID(可选)",
|
||||
)
|
||||
|
||||
# ---- 外部序列号(用户自定义,可选) ----
|
||||
external_serial: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="用户自定义产品序列号(可选)",
|
||||
)
|
||||
|
||||
# ---- 逻辑外键(关联老系统物料表,仅存储 ID,无物理约束) ----
|
||||
material_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="物料ID(逻辑外键→老系统)",
|
||||
)
|
||||
# ---- MOM 物料快照字段(创产品时写入,防止老系统数据变动影响标签) ----
|
||||
material_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="物料名称快照",
|
||||
)
|
||||
spec_model: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="规格型号快照",
|
||||
)
|
||||
category: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="物料分类快照",
|
||||
)
|
||||
material_type: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="物料类型快照",
|
||||
)
|
||||
|
||||
# ---- 物理外键(自引用:父产品) ----
|
||||
parent_product_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"), nullable=True, comment="父产品ID",
|
||||
)
|
||||
|
||||
# ---- 当前位置追踪(逻辑外键→用户ID 或 'virtual_warehouse') ----
|
||||
current_location_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="当前持有者ID 或 'virtual_warehouse'(仓库)",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="pending", comment="产品状态",
|
||||
)
|
||||
# 宏观流转状态 — 首次扫码时强制设定:备货/生产/测试/维修/待仓库收货/已入库/已出库
|
||||
overall_status: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库",
|
||||
)
|
||||
# 生命周期阶段 — 生产制造 vs 出库后返厂售后。
|
||||
# 设备出库后再次被派发任务 = 回流返厂,此处切为 AFTER_SALES 且不再回退。
|
||||
lifecycle_phase: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=LIFECYCLE_PRODUCTION,
|
||||
server_default=LIFECYCLE_PRODUCTION, index=True,
|
||||
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
order: Mapped["ProductionOrder"] = relationship("ProductionOrder", lazy="selectin")
|
||||
parent_product: Mapped["Product | None"] = relationship(
|
||||
"Product", remote_side="Product.id", lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Product {self.serial_number}>"
|
||||
32
backend/app/models/production_order.py
Normal file
32
backend/app/models/production_order.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""生产订单模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class ProductionOrder(Base):
|
||||
__tablename__ = "production_orders"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
order_no: Mapped[str] = mapped_column(
|
||||
String(64), unique=True, index=True, nullable=False, comment="订单编号",
|
||||
)
|
||||
customer_info: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True, comment="客户信息",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="pending", comment="订单状态",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ProductionOrder {self.order_no}>"
|
||||
124
backend/app/models/task.py
Normal file
124
backend/app/models/task.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""任务模型 — 支持无限嵌套、任务裂变分支、返工闭环"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
# 任务状态常量
|
||||
TASK_STATUS_PENDING = "PENDING" # 待接收
|
||||
TASK_STATUS_WIP = "WIP" # 进行中
|
||||
TASK_STATUS_COMPLETED = "COMPLETED" # 已完成
|
||||
TASK_STATUS_REJECTED = "REJECTED" # 已驳回
|
||||
TASK_STATUS_ARCHIVED = "ARCHIVED" # 已入库
|
||||
TASK_STATUS_CANCELED = "CANCELED" # 已撤回/已作废
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 products) ----
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, comment="所属产品ID",
|
||||
)
|
||||
|
||||
# ---- 物理外键(自引用:无限嵌套父子任务 / 裂变分支) ----
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=True, index=True, comment="父任务ID(用于任务裂变树)",
|
||||
)
|
||||
|
||||
task_name: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, comment="任务名称",
|
||||
)
|
||||
|
||||
# ---- 逻辑外键(关联老系统用户表,仅存储 ID,无物理约束) ----
|
||||
assignee_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="负责人ID(逻辑外键→老系统)",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default=TASK_STATUS_PENDING,
|
||||
comment="任务状态: PENDING(待接收) | WIP(进行中) | COMPLETED(已完成) | REJECTED(已驳回) | ARCHIVED(已入库)",
|
||||
)
|
||||
|
||||
notify_parent_on_complete: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="完成后是否通知父任务",
|
||||
)
|
||||
|
||||
# ---- 时间追踪 ----
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
received_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="操作员确认接收时间",
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="任务完工转交时间",
|
||||
)
|
||||
|
||||
# ---- 驳回/返工 ----
|
||||
reject_reason: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True, comment="驳回原因",
|
||||
)
|
||||
is_rework: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="是否为返工任务",
|
||||
)
|
||||
task_type: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, comment="任务派生类型: TRANSFER/SPAWN/RECOVERY/null=历史数据",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
String(2000), nullable=True, comment="任务初始描述/交接备注",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
product: Mapped["Product"] = relationship("Product", lazy="selectin")
|
||||
parent_task: Mapped["Task | None"] = relationship(
|
||||
"Task", remote_side="Task.id", back_populates="child_tasks", lazy="selectin",
|
||||
)
|
||||
child_tasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", back_populates="parent_task", lazy="selectin",
|
||||
order_by="Task.created_at",
|
||||
)
|
||||
records: Mapped[list["TaskRecord"]] = relationship(
|
||||
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Task {self.task_name}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 任务进度记录 — 随时备注/传图
|
||||
# ============================================================
|
||||
|
||||
class TaskRecord(Base):
|
||||
__tablename__ = "task_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
primary_key=True, autoincrement=True,
|
||||
)
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False, index=True, comment="所属任务ID",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
String(2000), nullable=True, comment="备注文本",
|
||||
)
|
||||
images: Mapped[str | None] = mapped_column(
|
||||
String(4000), nullable=True, comment="图片URL列表(JSON字符串)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="记录时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
task: Mapped["Task"] = relationship("Task", back_populates="records")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TaskRecord {self.id} @ {self.created_at}>"
|
||||
45
backend/app/models/task_log.py
Normal file
45
backend/app/models/task_log.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""任务操作日志模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class TaskLog(Base):
|
||||
__tablename__ = "task_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 tasks) ----
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False, index=True, comment="所属任务ID",
|
||||
)
|
||||
|
||||
# ---- 逻辑外键(关联老系统用户表,仅存储 ID,无物理约束) ----
|
||||
operator_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="操作人ID(逻辑外键→老系统)",
|
||||
)
|
||||
|
||||
action_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, comment="操作类型",
|
||||
)
|
||||
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="备注",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
task: Mapped["Task"] = relationship("Task", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TaskLog {self.action_type} @ {self.created_at}>"
|
||||
47
backend/app/models/user_daily_seen.py
Normal file
47
backend/app/models/user_daily_seen.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""每日用户活动表 —— 一天一人一行,只记录"今天来过"这件事。
|
||||
|
||||
为什么需要它(而不是复用 audit_logs)
|
||||
--------------------------------------
|
||||
日活报表要的「上线时间 / 下线时间」,两个都不能从审计表直接得出:
|
||||
|
||||
1. **上线/下线时间不能取登录时间**:Refresh Token 有效期 7 天,用户不必每天
|
||||
重新登录。按登录算会出现「登录次数 0、上线时间空,但操作次数 35」的
|
||||
自相矛盾报表。
|
||||
|
||||
2. **也不能只取写操作时间**:审计中间件只记录写操作(及导出/打印这类敏感读),
|
||||
普通 GET 不入账。当天只翻看、没做写操作的人会被整条漏掉。
|
||||
|
||||
3. **更不能把活动记录写进 audit_logs**:
|
||||
· 「上线时间」是**事件**(INSERT 一次即可),但「下线时间」是**状态**
|
||||
(每次活动都要刷新同一个值)。往审计流水里做 UPDATE,等于承认审计记录
|
||||
可以被改写 —— 那审计本身就失去可信度了。
|
||||
· 若改为每个请求 INSERT 一条,表会随访问量线性膨胀。
|
||||
|
||||
于是单开一张"可变的小状态表":一人一天一行,首见 INSERT、其后只
|
||||
UPDATE last_seen_at。50 人 × 365 天 ≈ 1.8 万行/年,可忽略。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class UserDailySeen(Base):
|
||||
"""用户在某个北京时间自然日的首末活动时刻"""
|
||||
|
||||
__tablename__ = "user_daily_seen"
|
||||
|
||||
# 联合主键即 UPSERT 的冲突目标,也是"一天一人一行"的保证
|
||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
day: Mapped[date] = mapped_column(Date, primary_key=True, comment="北京时间自然日")
|
||||
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="当天首次活动时刻",
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="当天末次活动时刻",
|
||||
)
|
||||
Reference in New Issue
Block a user