From adc2012401db2873a41d0b660c813aaa4a97d888 Mon Sep 17 00:00:00 2001 From: laogao Date: Wed, 21 Oct 2026 18:38:31 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=E5=8E=BB=E9=99=A4=20UserMixin?= =?UTF-8?q?=20=E5=AE=A1=E8=AE=A1=E5=AD=97=E6=AE=B5=E5=A4=96=E9=94=AE?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E4=BB=A5=E6=B6=88=E9=99=A4=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E5=A4=96=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sys_user.dept_id 与 sys_dept(UserMixin 审计字段)互为外键形成循环依赖。原 dept_id 用 use_alter=True 规避,但 use_alter 在 Alembic autogenerate 下会被内联进 op.create_table,PostgreSQL 编译时跳过导致外键静默丢失。 移除 created_id/updated_id/deleted_id 三个审计字段指向 sys_user 的外键约束(ondelete=SET NULL 已允许悬空,审计字段无需强引用),并为 created_by/updated_by/deleted_by 关系显式补充 primaryjoin。循环依赖消除后,dept_id 的 use_alter 亦可移除,autogenerate 可正确生成迁移。 --- backend/app/core/base_model.py | 11 ++++------- backend/app/modules/system/user/model.py | 23 +++++++++++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/backend/app/core/base_model.py b/backend/app/core/base_model.py index 339494b4c..7bfc18bb5 100644 --- a/backend/app/core/base_model.py +++ b/backend/app/core/base_model.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, MetaData, String +from sqlalchemy import Boolean, DateTime, Index, Integer, MetaData, String from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, relationship @@ -132,7 +132,6 @@ class UserMixin(MappedBase): created_id: Mapped[int | None] = mapped_column( Integer, - ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), default=None, nullable=True, index=True, @@ -140,7 +139,6 @@ class UserMixin(MappedBase): ) updated_id: Mapped[int | None] = mapped_column( Integer, - ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), default=None, nullable=True, index=True, @@ -149,7 +147,6 @@ class UserMixin(MappedBase): deleted_id: Mapped[int | None] = mapped_column( Integer, - ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), default=None, nullable=True, index=True, @@ -161,7 +158,7 @@ def created_by(self): """创建人关联关系""" return relationship( "UserModel", - foreign_keys=lambda: self.created_id, # pyright: ignore[reportArgumentType] + primaryjoin=f"foreign({self.__name__}.created_id) == remote(UserModel.id)", uselist=False, ) @@ -170,7 +167,7 @@ def updated_by(self): """更新人关联关系""" return relationship( "UserModel", - foreign_keys=lambda: self.updated_id, # pyright: ignore[reportArgumentType] + primaryjoin=f"foreign({self.__name__}.updated_id) == remote(UserModel.id)", uselist=False, ) @@ -179,6 +176,6 @@ def deleted_by(self): """删除人关联关系""" return relationship( "UserModel", - foreign_keys=lambda: self.deleted_id, # pyright: ignore[reportArgumentType] + primaryjoin=f"foreign({self.__name__}.deleted_id) == remote(UserModel.id)", uselist=False, ) diff --git a/backend/app/modules/system/user/model.py b/backend/app/modules/system/user/model.py index 4d3a7c6a2..7f94679f0 100644 --- a/backend/app/modules/system/user/model.py +++ b/backend/app/modules/system/user/model.py @@ -81,10 +81,25 @@ class UserModel(ModelMixin, UserMixin): status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)") description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注") - dept_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_dept.id", ondelete="SET NULL", onupdate="CASCADE", use_alter=True), nullable=True, index=True, comment="部门ID") + dept_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_dept.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="部门ID") dept: Mapped["DeptModel | None"] = relationship(back_populates="users", foreign_keys=[dept_id]) roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_user_roles", back_populates="users") positions: Mapped[list["PositionModel"]] = relationship(secondary="sys_user_positions", back_populates="users") - created_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.created_id", remote_side="UserModel.id", uselist=False, viewonly=True) - updated_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.updated_id", remote_side="UserModel.id", uselist=False, viewonly=True) - deleted_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.deleted_id", remote_side="UserModel.id", uselist=False, viewonly=True) + created_by: Mapped["UserModel | None"] = relationship( + "UserModel", + primaryjoin="foreign(UserModel.created_id) == remote(UserModel.id)", + uselist=False, + viewonly=True, + ) + updated_by: Mapped["UserModel | None"] = relationship( + "UserModel", + primaryjoin="foreign(UserModel.updated_id) == remote(UserModel.id)", + uselist=False, + viewonly=True, + ) + deleted_by: Mapped["UserModel | None"] = relationship( + "UserModel", + primaryjoin="foreign(UserModel.deleted_id) == remote(UserModel.id)", + uselist=False, + viewonly=True, + ) From 4e09829dcdcd6877b1d618e0d0975f9c1fdf1ff7 Mon Sep 17 00:00:00 2001 From: laogao Date: Thu, 22 Oct 2026 12:56:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20App=20=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=AD=97=E6=AE=B5=20list/items=20=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E5=AF=BC=E8=87=B4=E5=88=97=E8=A1=A8=E6=81=92=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:后端 PageResultSchema 统一返回 items 字段,但 App 端 PageResult 类型与 useListPage 等消费点硬编码读取 list,导致工单评论、AI 会话、首页公告等既有列表页数据恒为空。 影响面:工单详情评论分页、AI 助手会话列表、首页公告,以及所有复用 useListPage 的通用列表页(工单列表、公告列表等)。 修复: - global.d.ts 的 PageResult 字段 list → items,与后端对齐 - useListPage.ts 的 data 提取器改读 items - ticket-detail / chat / 首页 index 三处消费点 res.list → res.items --- frontend/app/src/composables/useListPage.ts | 4 ++-- frontend/app/src/pages/index/index.vue | 2 +- frontend/app/src/subPages/module_ai/chat/index.vue | 2 +- .../app/src/subPages/module_system/ticket-detail/index.vue | 2 +- frontend/app/src/types/global.d.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/app/src/composables/useListPage.ts b/frontend/app/src/composables/useListPage.ts index 5020e55f2..68b4895d3 100644 --- a/frontend/app/src/composables/useListPage.ts +++ b/frontend/app/src/composables/useListPage.ts @@ -48,8 +48,8 @@ export function useListPage(options watchingStates: [], preloadNextPage: false, preloadPreviousPage: false, - // 响应已由 http 层 responded 解包为业务结构 { list, total } - data: res => (res as PageResult).list ?? [], + // 响应已由 http 层 responded 解包为业务结构 { items, total } + data: res => (res as PageResult).items ?? [], total: res => (res as PageResult).total ?? 0, }, ) diff --git a/frontend/app/src/pages/index/index.vue b/frontend/app/src/pages/index/index.vue index 69d386aa0..fdf079bec 100644 --- a/frontend/app/src/pages/index/index.vue +++ b/frontend/app/src/pages/index/index.vue @@ -289,7 +289,7 @@ async function loadData() { async function loadNotices() { try { const res = await NoticeAPI.getPage({ page_no: 1, page_size: 3, status: 1 }) - recentNotices.value = res.list || [] + recentNotices.value = res.items || [] } catch { /* silent */ } } diff --git a/frontend/app/src/subPages/module_ai/chat/index.vue b/frontend/app/src/subPages/module_ai/chat/index.vue index 4b8f27209..f4b3452ba 100644 --- a/frontend/app/src/subPages/module_ai/chat/index.vue +++ b/frontend/app/src/subPages/module_ai/chat/index.vue @@ -60,7 +60,7 @@ async function loadSessions() { loading.value = true try { const res = await ChatAPI.getSessions() - sessions.value = res.list || [] + sessions.value = res.items || [] } catch { sessions.value = [] diff --git a/frontend/app/src/subPages/module_system/ticket-detail/index.vue b/frontend/app/src/subPages/module_system/ticket-detail/index.vue index 0097e3a84..31b173123 100644 --- a/frontend/app/src/subPages/module_system/ticket-detail/index.vue +++ b/frontend/app/src/subPages/module_system/ticket-detail/index.vue @@ -52,7 +52,7 @@ async function loadComments() { return try { const res = await TicketAPI.getComments(ticketId.value, { page_no: commentPage.page_no, page_size: commentPage.page_size }) - comments.value = res.list || [] + comments.value = res.items || [] commentTotal.value = res.total || 0 } catch (e) { diff --git a/frontend/app/src/types/global.d.ts b/frontend/app/src/types/global.d.ts index dfbaabb52..f12bda511 100644 --- a/frontend/app/src/types/global.d.ts +++ b/frontend/app/src/types/global.d.ts @@ -83,8 +83,8 @@ declare global { * 分页响应对象(列表接口 `data` 统一为该结构) */ interface PageResult { - /** 数据列表 */ - list: T[] + /** 数据列表(后端 PageResultSchema 统一返回 items,而非 list) */ + items: T[] /** 总数 */ total: number page_no?: number From 38f6eb5e935e22358e16467623a1c2deed1e52c0 Mon Sep 17 00:00:00 2001 From: Echo-Note Date: Thu, 22 Oct 2026 20:56:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B7=A8?= =?UTF-8?q?=E6=96=B9=E8=A8=80=20JSON=20=E7=B1=BB=E5=9E=8B=20JSONType?= =?UTF-8?q?=EF=BC=88PG=20=E7=94=A8=20JSONB=EF=BC=8CSQLite/MySQL=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=20JSON=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 PR #543 的实现,因其与 #541 同改 base_model.py 的 import 行产生冲突,此处为基于最新 dev 的 rebase 版本,改动内容与原提交一致(提交作者保留为 Echo-Note)。 - PostgreSQL:JSONB;SQLite / MySQL:通用 JSON(TypeEngine.with_variant) - 验证:三方言 DDL(JSONB / JSON / JSON)、sqlite create_all 建表 + JSON 读写、 ruff / basedpyright 0 errors / pytest 97 passed 关联 issue #542:本 PR 提供统一入口;生成器 jsonb 映射与方言类型 import 的修复留作后续。 --- backend/app/core/base_model.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/app/core/base_model.py b/backend/app/core/base_model.py index 7bfc18bb5..7ec7854b0 100644 --- a/backend/app/core/base_model.py +++ b/backend/app/core/base_model.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime -from sqlalchemy import Boolean, DateTime, Index, Integer, MetaData, String +from sqlalchemy import JSON, Boolean, DateTime, Index, Integer, MetaData, String +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, relationship @@ -18,6 +19,12 @@ "pk": "pk_%(table_name)s", } +# 跨方言 JSON 类型:PostgreSQL(生产)使用 JSONB,SQLite/MySQL(测试环境)降级为 +# 通用 JSON,保证 create_all 与 autogenerate 在三种方言下均可渲染、零漂移。 +# 各模块定义 JSON 列时统一使用 JSONType,而非直接硬编码 JSONB,避免破坏基类 +# 「兼容 SQLite、MySQL 和 PostgreSQL」的承诺。 +JSONType = JSONB().with_variant(JSON(), "sqlite", "mysql") + class MappedBase(AsyncAttrs, DeclarativeBase): """声明式基类