diff --git a/.gitignore b/.gitignore index 26647ef3..1bb63506 100755 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,6 @@ frontend/docs/* frontend/app/* !frontend/app/.gitkeep -.workbudddy/* \ No newline at end of file +.plan/* +CLAUDE.md +AGENTS.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 33bf3f54..69ffb5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # SaaS 多租户平台需求文档 -> 版本:v3.2.0 -> 最后更新:2026-06-02 +> 版本:v3.6.0 +> 最后更新:2026-06-03 --- @@ -53,6 +53,9 @@ FastapiAdmin 是一个基于 FastAPI + SQLAlchemy 的管理后台框架,需要 | Workflow | 插件 | ✅ TenantMixin | 工作流引擎 | | AI Chat | 插件 | ✅ TenantMixin | AI 对话 | | CodeGen | 插件 | ✅ TenantMixin | 代码生成器 | +| Invoice | 平台 | **无**(关联 order) | 发票管理(普票/专票) | +| AuditLog | 平台 | **无** | 审计日志(不可篡改) | +| Dashboard | 平台 | **无** | 运营数据大盘 | --- @@ -108,10 +111,10 @@ module_xxx/ ``` 平台资源(无 tenant_id) - ├── sys_menu ← 菜单定义,纯平台资源 - ├── sys_tenant_package ← 套餐定义 - ├── sys_plugin ← 插件注册表 - └── sys_tenant ← 租户定义 + ├── platform_menu ← 菜单定义,纯平台资源 + ├── platform_package ← 套餐定义 + ├── platform_plugin ← 插件注册表 + └── platform_tenant ← 租户定义 租户资源(含 tenant_id,ORM 自动过滤) ├── sys_user ← 用户 @@ -121,7 +124,7 @@ module_xxx/ ├── sys_notice ← 通知公告 ├── sys_param ← 系统参数 ├── sys_log ← 日志 - ├── sys_ticket ← 工单 + ├── platform_ticket ← 工单 └── 插件业务表 平台共享资源(tenant_id=1 的平台数据对所有租户可读) @@ -144,7 +147,7 @@ module_xxx/ | SELECT | 不过滤 | 自动追加 `WHERE tenant_id = ?`(`__platform_data_shared__` 模型跳过此层过滤,由 CRUD 层处理) | | INSERT | 不自动设置 | 自动设置 `tenant_id = 当前租户` | | UPDATE/DELETE | 不过滤 | 自动追加 `WHERE tenant_id = ?` | -| 系统表(sys_tenant) | 不过滤 | 不过滤 | +| 系统表(platform_tenant) | 不过滤 | 不过滤 | > **⚠️ 特别注意**:标记了 `__platform_data_shared__ = True` 的模型(DictType/DictData),ORM 事件层**跳过**自动 tenant_id 过滤,由 CRUD 层的 `__tenant_condition(read_mode=True)` 统一处理 `WHERE tenant_id = current OR tenant_id = 1` 逻辑。防止 ORM 事件层覆盖了"平台共享"读取策略。 @@ -173,8 +176,7 @@ module_xxx/ ``` MappedBase (声明式基类) ├── ModelMixin (id, uuid, status, description, 时间戳, 软删除) - ├── TenantMixin (tenant_id FK → sys_tenant.id, NOT NULL, default=1, ON DELETE RESTRICT) - ├── TenantAwareMixin (tenant_id 无FK, nullable) + ├── TenantMixin (tenant_id FK → platform_tenant.id, NOT NULL, default=1, ON DELETE RESTRICT) └── UserMixin (created_id, updated_id, deleted_id FK → sys_user) ``` @@ -184,7 +186,7 @@ MappedBase (声明式基类) |------|------|------| | `id` | Integer PK AI | 主键 | | `uuid` | String(64) UNIQUE | UUID 全局唯一标识 | -| `status` | String(10) default="0" | 状态(0:正常 1:禁用) | +| `status` | Integer | default=0 | 状态(0:正常 1:禁用) | | `description` | Text nullable | 备注/描述 | | `created_time` | DateTime | 创建时间 | | `updated_time` | DateTime | 更新时间(onupdate) | @@ -214,10 +216,19 @@ MappedBase (声明式基类) ``` 登录请求 → 验证码校验(启用时) → 用户认证(用户名+密码) → 检查用户状态 → 更新最后登录时间 → 查询用户关联租户列表 → -生成 JWT(access_token + refresh_token) → 记录在线会话 → -返回 token + 租户列表 +判断租户数量: + ├── 单租户: 直接生成 JWT(含 tenant_id) → 返回 token + └── 多租户: 生成临时 JWT(不含 tenant_id, 仅限调用 /auth/select-tenant) → + 返回 租户选择 token + 租户列表 → + 用户选择 → /auth/select-tenant/{id} → 生成含 tenant_id 的正式 token +记录在线会话 → 返回正式 token ``` +> **多租户登录说明**: +> - **临时 token**(不含 tenant_id):仅能调用 `POST /auth/select-tenant/{id}`,其他任何接口均返回 403 +> - **正式 token**(含 tenant_id):正常访问所有已授权的 API +> - 超管用户跳过租户选择,直接生成含 `is_super_admin=True` 的正式 token(不绑定任何 tenant_id) + ### 4.3 核心规则 | 规则 | 说明 | @@ -227,7 +238,7 @@ MappedBase (声明式基类) | **状态检查** | 用户 status="1"(禁用)时拒绝登录 | | **JWT 载荷** | 包含 session_id, user_id, tenant_id, is_super_admin, 登录信息 | | **Token 刷新** | refresh_token 专用,不可用 access_token 刷新 | -| **多租户登录** | 登录后返回用户关联的租户列表,前端选择后再生成含 tenant_id 的 token | +| **多租户登录** | 登录后判断:单租户用户直接签发含 tenant_id 的正式 token;多租户用户签**临时 token**(不含 tenant_id,仅可访问 `POST /auth/select-tenant/{id}`),选择租户后签正式 token | | **在线记录** | 登录成功后 Redis 记录在线会话,含 IP/OS/浏览器/登录位置 | | **日志记录** | 操作日志路由类自动记录登录日志 | @@ -235,20 +246,42 @@ MappedBase (声明式基类) 无独立数据表,使用 Redis 存储会话和验证码。 -### 4.5 API 端点 +### 4.5 用户自助注册 + +``` +POST /auth/register + ├── 接收:username, password, email, tenant_name(可选) + ├── 校验:用户名/邮箱唯一性 + ├── 创建租户记录(platform_tenant) + │ ├── name = tenant_name 或 "{username}的租户"(默认名) + │ ├── code = 自动生成(基于 name 拼音首字母 + 4位随机数) + │ ├── package_id → 取全局默认套餐(platform_package.is_default=true 的第一条,若无则 id=1) + │ ├── end_time = now + trial_days(取套餐的 trial_days,默认 7 天) + │ ├── max_users/max_roles/max_depts → 取套餐配额默认值(见 §16.2) + │ └── status = 0(active) + ├── 创建用户记录(sys_user,tenant_id=新租户ID) + ├── 创建 owner 角色(sys_role,code="owner") + ├── 将用户绑定到 owner 角色 + ├── 将租户可用菜单全量分配给 owner 角色 + └── 返回注册成功(用户需邮箱验证后激活) +``` + +> **默认套餐获取优先级**:套餐 `is_default=true` > id=1 > 无套餐(仅自定义菜单)。若平台未配置任何套餐,新租户仅有自定义菜单体系,需超管后续手动配置。 + +### 4.6 API 端点 | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/auth/login` | 登录(返回可选租户列表) | +| POST | `/auth/login` | 登录(多租户用户返回临时 token + 租户列表) | | POST | `/auth/token/refresh` | 刷新 token | | POST | `/auth/logout` | 退出登录(清除 Redis 会话) | | GET | `/auth/captcha` | 获取验证码(Base64 图片) | -| POST | `/auth/register` | 用户注册 | -| POST | `/auth/select/tenant` | 选择/切换租户 | -| POST | `/auth/forget/password` | 忘记密码 | -| GET | `/auth/auto/login/{token}` | 免登录(用于邮件/消息免登链接) | -| GET | `/auth/oauth/{provider}/authorize` | OAuth2 授权跳转 | -| GET | `/auth/oauth/{provider}/callback` | OAuth2 回调处理 | +| POST | `/auth/register` | 用户注册(创建用户 → 自动创建默认租户并设为 owner) | +| POST | `/auth/select-tenant/{id}` | 选择/切换租户(生成含 tenant_id 的正式 token) | +| POST | `/auth/forgot-password` | 忘记密码(发送重置邮件) | +| GET | `/auth/auto-login/{token}` | 免登录(用于邮件/消息免登链接) | +| GET | `/auth/oauth/{provider}/login` | OAuth2 授权跳转 | +| GET | `/auth/oauth/{provider}/callback` | OAuth2 回调处理(需用户预绑定第三方账号;首次 OAuth 登录不绑定租户,需选择/创建租户) | --- @@ -285,7 +318,7 @@ MappedBase (声明式基类) |--------|---------|------| | `sys_user_roles` | 多对多 | 用户 ↔ 角色 | | `sys_user_positions` | 多对多 | 用户 ↔ 岗位 | -| `sys_user_tenant` | 多对多 | 用户 ↔ 租户(跨租户支持) | +| `platform_user_tenant` | 多对多 | 用户 ↔ 租户(跨租户支持) | ### 5.3 业务规则 @@ -295,7 +328,7 @@ MappedBase (声明式基类) | **修改** | 不可修改超管;username/mobile/email 唯一性检查;部门必须存在且可用 | | **删除** | 仅已禁用(status=1)用户可删除;不可删除超管;不可删除当前登录用户 | | **密码** | Bcrypt 加密存储;修改需验证原密码;重置不可操作超管 | -| **导入导出** | 支持 Excel 导入导出;导入时校验字段合法性 | +| **导入导出** | 支持 Excel 导入导出;导入时密码字段处理策略:密码列为空 → 系统自动生成12位随机密码并通过邮件发送给用户;密码列有值 → Bcrypt 加密后存储,首次登录强制修改密码 | | **状态** | 批量启用/禁用;不可操作超管 | ### 5.4 当前用户菜单权限 @@ -362,6 +395,7 @@ get_current_user_info_service: | **权限策略** | `USER_ROLE` — 非超管用户只能看到自己绑定的角色 | | **菜单约束** | 非超管只能为角色分配租户可用菜单内的菜单,越权时抛出异常含菜单名称 | | **数据范围** | 1=仅本人 2=本部门 3=本部门及以下 4=全部 5=自定义(绑定部门) | +| **默认 owner 角色** | 创建租户时自动创建 code="owner" 的角色,不可删除、不可禁用。自动分配租户全部可用菜单 | ### 6.4 API 端点 @@ -458,7 +492,7 @@ get_current_user_info_service: ### 9.2 数据模型 -**表名**:`sys_menu`(ModelMixin,**无 TenantMixin**) +**表名**:`platform_menu`(ModelMixin,**无 TenantMixin**) | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| @@ -478,7 +512,7 @@ get_current_user_info_service: | `params` | JSON | nullable | 路由参数 | | `affix` | Boolean | default=False | 是否固定标签页 | | `client` | String(20) | NOT NULL, default="pc" | 终端(pc/app) | -| `parent_id` | FK→sys_menu.id | nullable, ON DELETE SET NULL | 父菜单 | +| `parent_id` | FK→platform_menu.id | nullable, ON DELETE SET NULL | 父菜单 | ### 9.3 菜单类型 @@ -595,12 +629,23 @@ get_current_user_info_service: | `notice_type` | String(1) | NOT NULL | 类型(1:通知 2:公告) | | `notice_content` | Text | nullable | 公告内容(富文本,XSS 过滤) | +**已读状态表**:`sys_notice_read`(按租户隔离) + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `user_id` | FK→sys_user.id | PK, ON DELETE CASCADE | 用户ID | +| `notice_id` | FK→sys_notice.id | PK, ON DELETE CASCADE | 通知ID | +| `read_time` | DateTime | NOT NULL, default=now | 已读时间 | + +> 唯一约束:`UNIQUE(user_id, notice_id)`。未建立记录即代表未读。 + ### 11.3 业务规则 | 规则 | 说明 | |------|------| | **类型校验** | 仅支持 "1"(通知) 和 "2"(公告) | | **XSS 防护** | notice_content 经过 `sanitize_html` 清洗 | +| **已读追踪** | 使用后端 `sys_notice_read` 表记录已读状态(多设备同步)。未读数量通过 LEFT JOIN 统计;通知列表返回未读数量标记 | ### 11.4 API 端点 @@ -612,6 +657,9 @@ get_current_user_info_service: | PUT | `/notice/update/{id}` | 更新公告 | | DELETE | `/notice/delete` | 删除公告(批量) | | PATCH | `/notice/status/batch` | 批量设置公告状态 | +| POST | `/notice/read/{id}` | 标记已读(写入 sys_notice_read) | +| POST | `/notice/read-all` | 全部标记已读 | +| GET | `/notice/unread-count` | 获取当前用户未读通知数量 | --- @@ -659,7 +707,7 @@ get_current_user_info_service: ### 13.2 数据模型 -**表名**:`sys_login_log`(ModelMixin, UserMixin) +**表名**:`platform_login_log`(ModelMixin, UserMixin) | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| @@ -718,6 +766,20 @@ get_current_user_info_service: | GET | `/system/operationlog/list` | 操作日志列表 | 租户管理员 | | DELETE | `/system/operationlog/delete` | 删除操作日志(批量) | 租户管理员 | +### 14.5 日志保留策略 + +操作日志表数据量大(生产环境可能每天数十万条),需配置自动清理机制: + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `operation_log_retention_days` | 日志保留天数 | 90 天 | +| `operation_log_cleanup_enabled` | 是否启用自动清理 | true | +| `operation_log_cleanup_cron` | 定时清理 cron 表达式 | 每天凌晨 3:00 | + +- 定时任务 `cleanup_operation_log` 删除 `create_time < now - retention_days` 的记录 +- 清理前可选归档到外部存储(OSS/本地文件),由 `operation_log_archive_enabled` 控制 +- 登录日志(`platform_login_log`)同样受此策略管理 + --- ## 15. Tenant 租户管理模块 @@ -728,9 +790,9 @@ get_current_user_info_service: ### 15.2 数据模型 -#### 核心表:sys_tenant(ModelMixin,无 TenantMixin)- **单一大表设计** +#### 核心表:platform_tenant(ModelMixin,无 TenantMixin)- **单一大表设计** -将配额字段直接集成到主表,简化结构便于管理。 +将配额和配置字段直接集成到主表,简化结构便于管理。 | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| @@ -742,70 +804,153 @@ get_current_user_info_service: | `address` | String(255) | nullable | 地址 | | `domain` | String(255) | nullable | 自定义域名 | | `logo_url` | String(500) | nullable | Logo URL | +| `description` | Text | nullable | 租户描述 | +| `version` | String(20) | nullable | 版本号 | | `sort` | Integer | default=0 | 排序 | -| `package_id` | FK→sys_tenant_package.id | nullable, ON DELETE SET NULL | 关联套餐 | +| `status` | Integer | NOT NULL, default=0 | 生命周期:0=active(正常) 1=grace(宽限期) 2=suspended(暂停) 3=frozen(冻结) 4=expired(过期) 5=archived(归档) | +| `package_id` | FK→platform_package.id | nullable, ON DELETE SET NULL | 关联套餐 | | `start_time` | DateTime | nullable | 开始时间 | | `end_time` | DateTime | nullable | 结束时间 | +| `grace_period_days` | Integer | default=7, ge=0 | 宽限期天数(到期后延迟禁用天数) | +| `grace_start_time` | DateTime | nullable | 宽限期开始时间(自动写入) | | `max_users` | Integer | default=50, ge=1 | 最大用户数 | | `max_roles` | Integer | default=20, ge=1 | 最大角色数 | | `max_storage_mb` | Integer | default=500, ge=1 | 最大存储(MB) | | `max_depts` | Integer | default=50, ge=1 | 最大部门数 | +| `favicon` | String(500) | nullable | 网站图标 | +| `login_bg` | String(500) | nullable | 登录背景图 | +| `copyright` | String(255) | nullable | 版权信息 | +| `help_doc` | String(500) | nullable | 帮助文档地址 | +| `privacy` | String(500) | nullable | 隐私政策地址 | +| `clause` | String(500) | nullable | 服务条款地址 | +| `keep_record` | String(100) | nullable | ICP 备案号 | +| `git_code` | String(500) | nullable | 源码地址 | #### 关联表(必要的多对多关系) | 表名 | 说明 | 关键字段 | |------|------|---------| -| `sys_user_tenant` | 用户-租户关联 | user_id, tenant_id, role(owner/admin/member), is_default | -| `sys_tenant_config` | 租户配置(键值对) | tenant_id, config_key, config_value, config_type | -| `sys_tenant_menu` | 租户自定义菜单 | tenant_id, menu_id, UNIQUE(tenant_id, menu_id) | +| `platform_user_tenant` | 用户-租户关联 | user_id, tenant_id, role(owner/admin/member), is_default | +| `platform_tenant_menu` | 租户自定义菜单 | tenant_id, menu_id, UNIQUE(tenant_id, menu_id) | -### 14.3 核心业务流程 +### 15.3 核心业务流程 #### 创建租户 ``` POST /tenant/create - ├── 创建租户记录(sys_tenant) + ├── 创建租户记录(platform_tenant) ├── 生成初始管理员:{code}_admin + 随机12位密码(含特殊字符) - ├── 初始化租户配额(sys_tenant_quota,默认值) - └── 返回租户信息 + 初始管理员凭据(日志输出) + ├── 密码不返回、不记录日志;生成一次性密码重置链接(含时效 Token,有效期24小时) + ├── 向 contact_email 发送重置链接邮件(若未填 contact_email 则跳过,超管需手动处理) + ├── 创建默认 owner 角色(sys_role),编码固定为 "owner" + ├── 将初始管理员绑定到 owner 角色(sys_user_roles) + ├── 将租户可用菜单(套餐菜单 + 自定义授权菜单)全量分配给 owner 角色(sys_role_menus) + ├── 初始化租户配额(默认值写入 platform_tenant 主表) + └── 返回租户信息(不含密码) ``` +> **安全说明**:初始管理员密码仅通过邮件中的一次性链接设置,不通过日志、API 响应等任何渠道明文传递。首次登录强制修改密码。 + #### 删除租户 ``` DELETE /tenant/delete ├── 系统租户(id=1)不可删除 - ├── 检查关联数据:用户/部门/角色/岗位 + ├── 仅支持删除 archived 状态的租户 ├── 有关联数据时拒绝删除,提示需先清理 - └── 通过则删除 + └── 通过则物理删除(不可恢复) ``` -#### 套餐变更 +#### 租户生命周期状态机 + +``` + ┌── 冻结 ──┐ + ↓ │ +创建 → active(0) ──┤ ├→ archived(5) → deleted(已删除) + │ │ ↑ + └→ grace(1) → suspended(2) → expired(4) ┘ + ↑ │ + └── 续期 ←──┘ +``` + +**状态说明**: + +| 状态 | 编码 | 触发方式 | 说明 | +|------|------|---------|------| +| `active` | 0 | 创建/续期/恢复 | 正常访问,读写开放 | +| `grace` | 1 | 到期后自动 | 宽限期:可登录但提示续费,功能正常 | +| `suspended` | 2 | 宽限期结束后自动 | 暂停:禁止写操作,仅可查看数据 | +| `expired` | 4 | 暂停超过保留期后自动 | 过期:禁止登录,数据保留待归档 | +| `frozen` | 3 | 超管手动冻结 | 冻结:立即禁止访问(不经过宽限期),可恢复为 active。保留全部数据 | +| `archived` | 5 | 冻结/过期后定时归档 | 归档:禁止访问,数据保留。唯一可被物理删除的状态 | +| `deleted` | — | 物理删除 | 已移除记录,不可逆 | + +**冻结/归档/删除操作流**: +``` +冻结(PATCH /tenant/status/batch → status=3) + ├── 仅超管可操作 + ├── 系统租户(id=1)不可冻结 + ├── 仅 active(0) 状态可冻结 + └── 租户内所有用户 session 失效(Redis token 缓存清除) + +归档(定时任务自动或手动) + ├── 扫描 status=3 且冻结超过 archive_after_days(默认30天) 的租户 + ├── 扫描 status=4 且过期超过 archive_after_days 的租户 + └── 自动将 status 设置为 5(archived) + +物理删除(DELETE /tenant/delete) + ├── 仅 archived(status=5)状态的租户可删除 + ├── 系统租户(id=1)不可删除 + ├── 检查关联数据:用户/部门/角色/岗位 + ├── 有关联数据时拒绝删除,提示需先清理 + └── 无关联数据 → 物理删除 +``` + +#### 套餐变更影响预览 + +套餐变更前,系统返回影响预览,超管确认后再执行: ``` PUT /tenant/update/{id} (package_id 变更) ├── 仅超管可操作 + ├── 调用预检接口 GET /tenant/{id}/package-change-preview?new_package_id=xxx + │ 返回: + │ - 受影响的角色列表(名称、用户数) + │ - 将被移除的菜单清单(名称、路径) + │ - 配额变化对比(max_users/max_roles/max_depts 当前值 → 新值) + │ - 受影响用户数总计 + ├── 前端展示影响明细,超管确认 ├── 更新 tenant.package_id ├── 获取新可用菜单 ID(套餐菜单 ∪ 自定义菜单) - ├── 清理角色中不再可用的菜单关联 + ├── 清理角色中不在可用菜单内的 RoleMenus 记录 + ├── 更新租户配额(max_users/max_roles/max_depts 同步为新套餐限制值) + │ ├── 升级:配额只增不减(新值 > 旧值时才更新) + │ └── 降级:当前使用量 > 新配额时降级操作可执行但不缩减已有数据,仅限制后续新增 + ├── 发送通知给租户管理员(站内信,列出被回收的菜单和配额变化) └── 完成 ``` -### 14.4 业务规则 +> 预检接口:`GET /platform/tenant/{id}/package-change-preview?new_package_id={id}` +> 通知内容:本次套餐变更收回了 X 个菜单权限,涉及 Y 个角色,请知悉。 + +### 15.4 业务规则 | 类别 | 规则 | |------|------| -| **系统租户** | id=1 不可删除、禁用、修改编码 | +| **系统租户** | id=1 不可删除、禁用(冻结/归档)、修改编码 | | **编码** | 仅含字母和数字,用于生成初始管理员用户名 | -| **初始管理员** | 自动创建,用户名 `{code}_admin`,密码 12 位随机(含特殊字符)。注:初始管理员无角色关联,登录后看不到菜单,需超管先为该租户分配角色后再绑定给初始管理员 | -| **配额** | 创建租户时自动初始化默认值 | -| **owner 保护** | 每个租户至少保留一个 owner。从租户移除用户时,检查该用户是否为该租户的唯一 owner,是则拒绝移除。修改用户租户角色时,禁止将最后一个 owner 降级为 member | +| **初始管理员** | 自动创建,用户名 `{code}_admin`,密码 12 位随机(含特殊字符)。同时自动创建 owner 角色,将初始管理员绑定为 owner,并将租户当前可用菜单(套餐菜单 ∪ 自定义菜单)全量分配给该角色。初始管理员登录后即可看到完整的租户菜单,无需超管手动介入 | +| **配额** | 创建租户时,配额默认值从所选套餐的 max_users/max_roles/max_depts 复制到 platform_tenant 主表。无套餐时使用硬编码默认值(users=10, roles=5, depts=10)。超管可在租户管理页手动调整 | +| **owner 保护** | 每个租户至少保留一个 owner。从租户移除用户时,检查该用户是否为该租户的唯一 owner,是则拒绝移除。修改用户租户角色时,禁止将最后一个 owner 降级为 member。默认 owner 角色(code="owner")不可删除、不可禁用,确保租户始终有可用角色来管理 | | **配额执行** | 租户配额在创建资源时执行检查。UserCRUD.create 检查 max_users,RoleCRUD.create 检查 max_roles,DeptCRUD.create 检查 max_depts。达到上限时拒绝创建并提示 | | **默认租户** | 用户首次加入的租户自动设为默认,设置新默认时清除旧默认 | | **多租户** | 一个用户可关联多个租户 | +| **生命周期** | 租户状态流转:active(0)→grace(1)→suspended(2)→expired(4)→archived(5)→物理删除。超管可人工冻结 active→frozen(3)→archived(5)。frozen 可恢复为 active。仅 archived 状态可物理删除。expired/frozen 超过 archive_after_days(默认30天) 后由定时任务自动归档为 archived(5) | +| **冻结后失效** | 租户冻结后,Redis 中该租户所有用户的 token 缓存立即清除,用户下次请求时因 token 无效被拒绝访问 | +| **续期** | active/grace/suspended 状态的租户可通过 `PUT /tenant/renew/{id}` 续期,传入 `end_time` 延长有效期并恢复为 active(0);expired/frozen/archived 状态不可续期 | -### 14.5 配置缓存策略 +### 15.5 配置缓存策略 | 机制 | 说明 | |------|------| @@ -814,7 +959,7 @@ PUT /tenant/update/{id} (package_id 变更) | **更新策略** | 更新后自动同步刷新 Redis | | **预热** | 应用启动时 `init_tenant_config_cache` 预加载所有配置 | -### 14.6 API 端点 +### 15.6 API 端点 | 方法 | 路径 | 说明 | |------|------|------| @@ -822,9 +967,10 @@ PUT /tenant/update/{id} (package_id 变更) | GET | `/tenant/list` | 租户列表 | | POST | `/tenant/create` | 创建租户(自动配管理员) | | PUT | `/tenant/update/{id}` | 修改租户 | -| DELETE | `/tenant/delete` | 删除租户(批量) | -| PATCH | `/tenant/status/batch` | 批量修改状态 | -| PUT | `/tenant/status/{id}` | 启/禁用单个租户 | +| DELETE | `/tenant/delete` | 删除租户(仅 archived 状态可删) | +| PATCH | `/tenant/status/batch` | 批量修改状态(含冻结/恢复) | +| PUT | `/tenant/status/{id}` | 启/禁用(冻结/恢复) | +| PUT | `/tenant/renew/{id}` | 续期(延长 end_time) | | GET | `/tenant/{id}/users` | 获取租户用户列表 | | POST | `/tenant/{id}/users` | 向租户添加用户 | | DELETE | `/tenant/{id}/users/{uid}` | 从租户移除用户 | @@ -846,25 +992,33 @@ PUT /tenant/update/{id} (package_id 变更) ### 16.2 数据模型 -#### sys_tenant_package +#### platform_package | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| | `name` | String(100) | NOT NULL, UNIQUE | 套餐名称 | | `code` | String(100) | NOT NULL, UNIQUE | 套餐编码 | -| `status` | String | default="0" | 状态(0:正常 1:禁用) | +| `status` | Integer | default=0 | 状态(0:正常 1:禁用) | +| `is_default` | Boolean | default=False | 是否为默认套餐(自助注册时自动选用) | +| `price` | Integer | default=0 | 价格(分),0=免费 | +| `period` | String(10) | nullable | 计费周期:month/year/once | +| `trial_days` | Integer | default=0 | 试用天数,0=无试用 | +| `max_users` | Integer | default=10 | 套餐用户数上限 | +| `max_roles` | Integer | default=5 | 套餐角色数上限 | +| `max_depts` | Integer | default=10 | 套餐部门数上限 | +| `max_tenants` | Integer | nullable | 该套餐最大租户数限制(平台运营管控),null=不限制 | | `sort` | Integer | default=0 | 排序 | -#### sys_tenant_package_menu +#### platform_package_menu | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| -| `package_id` | FK→sys_tenant_package.id | PK, ON DELETE CASCADE | 套餐 ID | -| `menu_id` | FK→sys_menu.id | PK, ON DELETE CASCADE | 菜单 ID | +| `package_id` | FK→platform_package.id | PK, ON DELETE CASCADE | 套餐 ID | +| `menu_id` | FK→platform_menu.id | PK, ON DELETE CASCADE | 菜单 ID | 唯一约束:`(package_id, menu_id)` -### 15.3 核心流程 +### 16.3 核心流程 #### 租户可用菜单合并逻辑 @@ -876,7 +1030,7 @@ get_tenant_available_menu_ids(tenant_id): if tenant.package_id: 可用菜单.add(套餐菜单...) - # 2. 取出租户自定义授权菜单(sys_tenant_menu) + # 2. 取出租户自定义授权菜单(platform_tenant_menu) 可用菜单.add(自定义菜单...) return list(可用菜单) # 并集 @@ -889,7 +1043,7 @@ get_tenant_available_menu_ids(tenant_id): 查询该租户所有角色 → 删除角色中不在可用菜单内的 RoleMenus 记录 → 完成 ``` -### 15.4 业务规则 +### 16.4 业务规则 | 规则 | 说明 | |------|------| @@ -897,20 +1051,20 @@ get_tenant_available_menu_ids(tenant_id): | **删除约束** | 删除前检查是否有租户使用,有则拒绝 | | **级联策略** | 套餐删除时,租户 package_id SET NULL | | **菜单设置** | 套餐菜单全量替换(先删后插) | -| **套餐禁用** | 套餐 status=1 时,已关联该套餐的租户在 `get_tenant_available_menu_ids` 中**不再计入**套餐菜单,仅保留租户自定义菜单(sys_tenant_menu)。恢复 status=0 后套餐菜单自动恢复 | +| **套餐禁用** | 套餐 status=1 时,已关联该套餐的租户在 `get_tenant_available_menu_ids` 中**不再计入**套餐菜单,仅保留租户自定义菜单(platform_tenant_menu)。恢复 status=0 后套餐菜单自动恢复 | | **套餐已删** | 套餐被删除(package_id SET NULL)后,租户降级为仅有自定义菜单,需及时为受影响租户迁移或补配权限 | -### 15.5 API 端点 +### 16.5 API 端点 | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/tenant/package/detail/{id}` | 套餐详情 | -| GET | `/tenant/package/list` | 套餐列表 | -| POST | `/tenant/package/create` | 创建套餐 | -| PUT | `/tenant/package/update/{id}` | 修改套餐 | -| DELETE | `/tenant/package/delete` | 删除套餐 | -| GET | `/tenant/package/{id}/menus` | 获取套餐菜单 | -| PUT | `/tenant/package/{id}/menus` | 设置套餐菜单(全量替换) | +| GET | `/platform/package/detail/{id}` | 套餐详情 | +| GET | `/platform/package/list` | 套餐列表 | +| POST | `/platform/package/create` | 创建套餐 | +| PUT | `/platform/package/update/{id}` | 修改套餐 | +| DELETE | `/platform/package/delete` | 删除套餐 | +| GET | `/platform/package/{id}/menus` | 获取套餐菜单 | +| PUT | `/platform/package/{id}/menus` | 设置套餐菜单(全量替换) | --- @@ -922,7 +1076,7 @@ get_tenant_available_menu_ids(tenant_id): ### 17.2 数据模型 -**表名**:`sys_ticket`(TenantMixin, UserMixin) +**表名**:`platform_ticket`(TenantMixin, UserMixin) | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| @@ -930,12 +1084,12 @@ get_tenant_available_menu_ids(tenant_id): | `ticket_content` | Text | nullable | 工单内容(富文本) | | `summary` | Text | nullable | 工单内容(纯文本摘要) | | `ticket_type` | String(20) | NOT NULL, default="suggestion" | 类型(suggestion/bug/optimize/other) | -| `status` | String(10) | NOT NULL, default="0" | 状态(0:待处理 1:处理中 2:已完成 3:已关闭) | +| `status` | Integer | NOT NULL, default=0 | 状态(0:待处理 1:处理中 2:已完成 3:已关闭) | | `images` | Text | nullable | 图片 URL 列表(JSON 数组) | | `reply` | Text | nullable | 回复内容 | | `assigned_id` | FK→sys_user.id | nullable, ON DELETE SET NULL | 处理人 | -### 16.3 状态流转 +### 17.3 状态流转 ``` 待处理(0) → 处理中(1) → 已完成(2) @@ -956,7 +1110,7 @@ get_tenant_available_menu_ids(tenant_id): > 非法转换(如已完成→处理中)应在 Service 层校验并拒绝 | -### 16.4 API 端点 +### 17.4 API 端点 | 方法 | 路径 | 说明 | |------|------|------| @@ -973,11 +1127,11 @@ get_tenant_available_menu_ids(tenant_id): ### 18.1 业务描述 -插件系统是平台的扩展机制。`sys_plugin` 作为插件注册表(平台级资源),记录所有可用插件的元数据。租户通过 `sys_tenant_plugin` 关联表安装插件。 +插件系统是平台的扩展机制。`platform_plugin` 作为插件注册表(平台级资源),记录所有可用插件的元数据。租户通过 `platform_tenant_plugin` 关联表安装插件。 ### 18.2 数据模型 -#### sys_plugin(平台资源,无 TenantMixin) +#### platform_plugin(平台资源,无 TenantMixin) | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| @@ -994,18 +1148,18 @@ get_tenant_available_menu_ids(tenant_id): | `dependencies` | Text | nullable | 依赖插件编码(JSON 数组) | | `sort` | Integer | NOT NULL, default=0 | 排序 | -#### sys_tenant_plugin +#### platform_tenant_plugin | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| -| `tenant_id` | FK→sys_tenant.id | NOT NULL, ON DELETE CASCADE | 租户 ID | -| `plugin_id` | FK→sys_plugin.id | NOT NULL, ON DELETE CASCADE | 插件 ID | +| `tenant_id` | FK→platform_tenant.id | NOT NULL, ON DELETE CASCADE | 租户 ID | +| `plugin_id` | FK→platform_plugin.id | NOT NULL, ON DELETE CASCADE | 插件 ID | | `enabled` | String(1) | NOT NULL, default="1" | 启用(1:启用 0:禁用) | | `installed_time` | DateTime | NOT NULL | 安装时间 | 唯一约束:`(tenant_id, plugin_id)` -### 17.3 插件目录结构 +### 18.3 插件目录结构 ``` plugin/module_xxx/ @@ -1025,7 +1179,7 @@ plugin/module_xxx/ - `module_task/cronjob` — 定时任务 - `module_task/workflow` — 工作流引擎 -### 17.4 API 端点 +### 18.4 API 端点 | 方法 | 路径 | 说明 | |------|------|------| @@ -1041,26 +1195,63 @@ plugin/module_xxx/ ## 19. 到期处理 -### 19.1 自动禁用逻辑 +### 19.1 到期阶段定义 + +租户到期后分三个阶段处理,避免粗暴直接禁用(编码与 §15.2 生命周期统一): + +| 阶段 | 编码 | 说明 | +|------|------|------| +| `active` | 0 | 正常,在有效期内 | +| `grace` | 1 | 宽限期:到期后可登录但每次登录提示续费,功能正常 | +| `suspended` | 2 | 暂停:宽限期结束后禁用写操作,仅可查看数据 | +| `expired` | 4 | 过期:暂停超过保留期后,禁止登录 | + +**阶段流转**(与生命周期统一): +``` +active(0) → grace(1) → suspended(2) → expired(4) → archived(5) + ↑ │ │ + └── 续期 ←──┘────────────┘ +``` + +### 19.2 自动处理逻辑 定时任务 `check_tenant_expiry` 定期扫描所有正常状态的租户: -1. 遍历 `status=0` 且 `end_time` 不为空的租户 -2. 如 `end_time <= now`:自动设为禁用(`status=1`) -3. 如 `end_time` 在 30天/7天/1天 内:触发到期提醒 +1. 遍历 status=0(active) 或 status=1(grace) 或 status=2(suspended) 的租户 +2. **未到达生效时间**:`start_time` 存在且 `start_time > now` → 暂不处理,登录时提示"租户尚未生效" +3. **进入宽限期**:status=0 且 `end_time` 存在且 `now > end_time` → 设 `status=1`,记录 `grace_start_time` +4. **进入暂停**:status=1(grace) 且 `now > grace_start_time + grace_period_days`(默认7天)→ 设 `status=2`(suspended) +5. **进入过期**:status=2(suspended) 且暂停超过 `expire_after_days`(默认30天)→ 设 `status=4`(expired) +6. **宽限期内续期**:若 status=1/2 时发现 `end_time` 已续期至未来 → 恢复 `status=0`(active),清除 `grace_start_time` +7. **即将到期提醒**:`end_time` 在 30天/7天/1天 内 → 触发到期提醒 -### 18.2 到期后行为 +### 19.3 各阶段行为 -| 场景 | 行为 | -|------|------| -| **登录拦截** | 租户被禁用后,AuthService.login 检查 tenant.status,对 status!=0 的租户拒绝登录 | -| **数据状态** | 租户数据保留(不变更),仅禁止新的写入操作 | -| **恢复机制** | 超管手动恢复 status=0 后功能正常,无需额外操作 | -| **定时频率** | 每小时检查一次 | +| 阶段 | 登录 | 读操作 | 写操作 | 提示 | +|------|------|--------|--------|------| +| active(0) | ✅ | ✅ | ✅ | 无 | +| grace(1) | ✅ | ✅ | ✅ | 每次登录弹窗提示"您的租户已到期,请尽快续费" | +| suspended(2) | ✅ | ✅ | ❌ 拒绝写入 | 提示"租户已暂停,请联系管理员续费" | +| expired(4) | ❌ | — | — | 提示"租户已过期" | -### 18.3 提醒方式 +### 19.4 到期配置参数 -当前为日志输出,预留邮件/短信通知扩展点。 +| 字段 | 位置 | 说明 | +|------|------|------| +| `grace_period_days` | `platform_tenant` 表,Integer,default=7 | 宽限期天数 | +| `expire_after_days` | 全局配置,Integer,default=30 | 暂停→过期天数(suspended 超过此天数后自动标记为 expired) | +| `archive_after_days` | 全局配置,Integer,default=30 | frozen/expired 超过此天数后自动归档为 archived(5) | + +### 19.5 提醒方式 + +| 触点 | 触发时机 | 渠道 | +|------|---------|------| +| 30天前 | `end_time - 30d <= now` | 站内信(sys_notice) | +| 7天前 | `end_time - 7d <= now` | 站内信 + 邮件(contact_email) | +| 1天前 | `end_time - 1d <= now` | 站内信 + 邮件 + 短信(contact_phone,可选) | +| 已到期(grace) | 每次登录 | 弹窗提示 | + +当前邮件/短信为预留扩展点,未配置渠道时降级为站内信。 --- @@ -1078,8 +1269,10 @@ plugin/module_xxx/ | POST | `/auth/token/refresh` | 刷新 token | | POST | `/auth/logout` | 退出登录 | | GET | `/auth/captcha` | 验证码 | -| POST | `/auth/select-tenant` | 选择租户 | +| POST | `/auth/register` | 用户注册(自动创建默认租户) | +| POST | `/auth/select-tenant/{id}` | 选择租户(生成正式 token) | | GET | `/auth/tenants` | 获取可选租户列表 | +| POST | `/auth/forgot-password` | 忘记密码 | | GET | `/auth/auto-login/users` | 获取免登录用户列表 | | POST | `/auth/auto-login/token` | 获取免登录Token | | POST | `/auth/auto-login` | 免登录 | @@ -1100,8 +1293,6 @@ plugin/module_xxx/ | PUT | `/user/current/info/update` | - | 更新当前用户 | | PUT | `/user/password/change` | - | 改密码(本人操作) | | PUT | `/user/password/reset/{id}` | user:update | 重置密码(管理员操作) | -| POST | `/user/register` | - | 用户注册 | -| POST | `/user/password/forget` | - | 忘记密码 | | GET | `/user/import/template` | user:download | 导入模板 | | POST | `/user/import/data` | user:import | 导入 | | POST | `/user/export` | user:query | 导出 | @@ -1297,6 +1488,87 @@ plugin/module_xxx/ | GET | `/health/ready` | — | 就绪探针 | | GET | `/metrics` | — | Prometheus 指标端点 | +### 20.18 邮件服务 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| GET | `/platform/email/config` | platform:email:query | 获取 SMTP 配置 | +| PUT | `/platform/email/config` | platform:email:update | 更新 SMTP 配置 | +| POST | `/platform/email/test` | platform:email:update | 发送测试邮件 | +| GET | `/platform/email/template/list` | platform:email:query | 模板列表 | +| POST | `/platform/email/template/create` | platform:email:create | 创建模板 | +| PUT | `/platform/email/template/update/{id}` | platform:email:update | 更新模板 | +| GET | `/platform/email/log/list` | platform:email:query | 发送日志列表 | + +### 20.19 订单与支付 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| POST | `/platform/order/create` | platform:order:create | 创建订单 | +| GET | `/platform/order/detail/{id}` | platform:order:query | 订单详情 | +| GET | `/platform/order/list` | platform:order:query | 订单列表 | +| POST | `/platform/order/cancel/{id}` | platform:order:update | 取消订单 | +| POST | `/platform/payment/callback/alipay` | — | 支付宝回调 | +| POST | `/platform/payment/callback/wxpay` | — | 微信支付回调 | +| GET | `/platform/payment/record/list` | platform:payment:query | 支付记录列表 | + +### 20.20 租户自助服务 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| GET | `/tenant/package/available` | tenant:package:query | 可选套餐列表 | +| GET | `/tenant/package/preview` | tenant:package:query | 套餐变更影响预览 | +| POST | `/tenant/order/create` | tenant:order:create | 创建自助订单 | +| GET | `/tenant/order/list` | tenant:order:query | 我的订单列表 | +| GET | `/tenant/order/detail/{id}` | tenant:order:query | 订单详情 | + +### 20.21 用量统计 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| GET | `/platform/api-usage/daily` | platform:api_usage:query | 按天用量统计 | +| GET | `/platform/api-usage/tenant/{id}` | platform:api_usage:query | 指定租户用量 | +| GET | `/platform/api-usage/rank` | platform:api_usage:query | 租户用量排行 | +| GET | `/platform/api-usage/anomalies` | platform:api_usage:query | 异常调用记录 | + +### 20.22 用户邀请 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| POST | `/tenant/invite/send` | tenant:invite:create | 发送邀请 | +| GET | `/tenant/invite/list` | tenant:invite:query | 邀请列表 | +| DELETE | `/tenant/invite/cancel/{id}` | tenant:invite:delete | 取消邀请 | +| GET | `/invite/validate/{code}` | — | 校验邀请码(公开) | +| POST | `/invite/accept/{code}` | — | 接受邀请(需登录) | + +### 20.23 发票管理 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| POST | `/tenant/invoice/apply` | tenant:invoice:create | 申请开票 | +| GET | `/tenant/invoice/list` | tenant:invoice:query | 我的发票列表 | +| GET | `/tenant/invoice/{id}/download` | tenant:invoice:download | 下载发票 PDF | +| GET | `/platform/invoice/list` | platform:invoice:query | 全部发票列表 | +| PUT | `/platform/invoice/issue/{id}` | platform:invoice:update | 开具发票 | +| PUT | `/platform/invoice/void/{id}` | platform:invoice:update | 作废发票 | + +### 20.24 审计日志 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| GET | `/platform/audit/list` | platform:audit:query | 审计日志列表 | +| GET | `/platform/audit/detail/{id}` | platform:audit:query | 审计日志详情 | +| GET | `/platform/audit/export` | platform:audit:export | 导出审计日志 | + +### 20.25 运营大盘 + +| 方法 | 路径 | 权限标识 | 说明 | +|------|------|---------|------| +| GET | `/platform/dashboard/overview` | platform:dashboard:query | 运营概览 | +| GET | `/platform/dashboard/revenue` | platform:dashboard:query | 收入趋势 | +| GET | `/platform/dashboard/tenants` | platform:dashboard:query | 租户统计 | +| GET | `/platform/dashboard/api-usage` | platform:dashboard:query | API 用量趋势 | + --- ## 21. 数据库表结构 @@ -1305,23 +1577,33 @@ plugin/module_xxx/ | 表名 | 说明 | 关键索引 | |------|------|---------| -| `sys_tenant` | 租户 | UNIQUE(name), UNIQUE(code) | -| `sys_tenant_package` | 套餐 | UNIQUE(name), UNIQUE(code) | -| `sys_tenant_package_menu` | 套餐-菜单关联 | UNIQUE(package_id, menu_id) | -| `sys_menu` | 菜单 | - | -| `sys_plugin` | 插件注册表 | UNIQUE(name), UNIQUE(code) | +| `platform_tenant` | 租户(单一大表,含配额+配置字段) | UNIQUE(name), UNIQUE(code) | +| `platform_package` | 套餐 | UNIQUE(name), UNIQUE(code) | +| `platform_package_menu` | 套餐-菜单关联 | UNIQUE(package_id, menu_id) | +| `platform_menu` | 菜单 | - | +| `platform_plugin` | 插件注册表 | UNIQUE(name), UNIQUE(code) | +| `platform_email_config` | 邮件 SMTP 配置 | 单例表 | +| `platform_email_template` | 邮件模板 | UNIQUE(code) | +| `platform_email_log` | 邮件发送日志 | - | +| `platform_order` | 订单 | UNIQUE(order_no) | +| `platform_payment_record` | 支付记录 | UNIQUE(transaction_id) | -### 20.2 租户关联表(无 tenant_id,FK→tenant) +### 21.2 租户关联表(FK→tenant,无独立 tenant_id 列) | 表名 | 说明 | 关键索引 | |------|------|---------| -| `sys_tenant_quota` | 租户配额 | UNIQUE(tenant_id) | -| `sys_tenant_config` | 租户配置 | UNIQUE(tenant_id, config_key) | -| `sys_tenant_menu` | 租户自定义菜单 | UNIQUE(tenant_id, menu_id) | -| `sys_tenant_plugin` | 租户安装插件 | UNIQUE(tenant_id, plugin_id) | -| `sys_user_tenant` | 用户-租户关联 | UNIQUE(user_id, tenant_id) | +| `platform_tenant_menu` | 租户自定义菜单 | UNIQUE(tenant_id, menu_id) | +| `platform_tenant_plugin` | 租户安装插件 | UNIQUE(tenant_id, plugin_id) | +| `platform_user_tenant` | 用户-租户关联 | UNIQUE(user_id, tenant_id) | -### 20.3 租户隔离业务表(含 tenant_id) +### 21.3 平台级租户关联业务表(含 tenant_id) + +| 表名 | 说明 | 关键索引 | +|------|------|---------| +| `platform_invite_record` | 用户邀请记录 | UNIQUE(invite_code) | +| `platform_api_usage_daily` | API 用量日统计 | UNIQUE(tenant_id, date, api_path) | + +### 21.4 租户隔离业务表(含 tenant_id) | 表名 | 说明 | 关键索引 | |------|------|---------| @@ -1331,17 +1613,18 @@ plugin/module_xxx/ | `sys_position` | 岗位 | - | | `sys_notice` | 通知公告 | - | | `sys_param` | 系统参数 | - | -| `sys_log` | 系统日志 | - | -| `sys_ticket` | 工单 | - | +| `sys_operation_log` | 操作日志(租户隔离) | - | +| `platform_login_log` | 登录日志(平台级,无 tenant_id) | - | +| `platform_ticket` | 工单 | - | -### 20.4 平台共享业务表(含 tenant_id,__platform_data_shared__) +### 21.4 平台共享业务表(含 tenant_id,__platform_data_shared__) | 表名 | 说明 | 关键索引 | |------|------|---------| | `sys_dict_type` | 字典类型 | UNIQUE(tenant_id, dict_type) | | `sys_dict_data` | 字典数据 | UNIQUE(tenant_id, dict_type_id, dict_value) | -### 20.5 关联表 +### 21.5 关联表 | 表名 | 说明 | 约束 | |------|------|------| @@ -1349,6 +1632,30 @@ plugin/module_xxx/ | `sys_user_positions` | 用户-岗位关联 | PK(user_id, position_id),ON DELETE CASCADE | | `sys_role_menus` | 角色-菜单关联 | PK(role_id, menu_id),ON DELETE CASCADE | | `sys_role_depts` | 角色-部门关联 | PK(role_id, dept_id),ON DELETE CASCADE | +| `sys_notice_read` | 通知已读记录 | PK(user_id, notice_id),ON DELETE CASCADE | + +### 21.6 插件表 + +| 表名 | 模块 | 说明 | 前缀规则 | +|------|------|------|---------| +| `task_workflow` | module_task/workflow | 工作流定义 | `task_` = module_task | +| `task_workflow_node_type` | module_task/workflow | 工作流节点类型 | `task_` = module_task | +| `task_node` | module_task/cronjob | 定时任务节点类型 | `task_` = module_task | +| `task_job` | module_task/cronjob | 任务执行日志 | `task_` = module_task | +| `gen_table` | module_generator/gencode | 代码生成表 | `gen_` = module_generator | +| `gen_table_column` | module_generator/gencode | 代码生成字段 | `gen_` = module_generator | +| `example_demo` | module_example/demo | 示例表 | `example_` = module_example | +| `example_demo01` | module_example/demo01 | 示例表01 | `example_` = module_example | + +> **命名规范**:`platform_` = 平台模块,`sys_` = 系统模块,`task_` = 任务插件,`gen_` = 生成器插件,`example_` = 示例模块 + +### 21.7 商业运营表 + +| 表名 | 模块 | 说明 | +|------|------|------| +| `platform_invoice` | Invoice | 发票记录 | +| `platform_refund` | Order | 退款记录 | +| `platform_audit_log` | AuditLog | 审计日志 | --- @@ -1364,31 +1671,524 @@ plugin/module_xxx/ 5. **租户 owner 保护**:每个租户至少保留一个 owner 6. **菜单越权防护**:非超管用户只能在租户可用菜单范围内分配角色菜单 7. **ContextVar 清理**:请求结束后清理 ContextVar,防止跨请求泄漏 -8. **密码安全**:Bcrypt 哈希存储,不存储明文 +8. **密码安全**:Bcrypt 哈希存储,不存储明文;普通用户密码最低 8 位(含字母+数字);初始管理员密码 12 位随机(含大小写字母+数字+特殊字符) 9. **XSS 防护**:通知公告内容经过 `sanitize_html` 清洗 -10. **级联策略**:所有 FK 均有 ON DELETE/ON UPDATE 级联策略,保证数据完整性 +10. **登录限流**:同一 IP/账号连续登录失败 5 次后锁定 15 分钟(Redis 计数 + TTL),防止暴力破解 +11. **级联策略**:所有 FK 均有 ON DELETE/ON UPDATE 级联策略,保证数据完整性 +12. **路径越权防护**:文件资源管理禁止路径遍历(`..`),防止越权访问 +13. **CORS 配置**:通过白名单配置允许的来源域名,拒绝未授权的跨域请求 --- -## 23. 未来扩展建议 +## 23. Email 邮件服务模块 -1. **配额硬限制**:当前配额仅为预设值,需在业务层实现实际限制(如创建用户时检查 max_users) -2. **租户自定义域名**:支持通过 `domain` 字段实现租户专属域名 -3. **租户主题/品牌**:通过 `sys_tenant_config` 扩展 Logo、颜色、页面标题等自定义 -4. **套餐升降级**:基础版/专业版/企业版分级套餐,切换时的菜单过渡和数据处理 -5. **跨租户协作增强**:用户多租户切换时的数据可见性和操作约束 -6. **租户审计日志**:记录租户管理操作,便于合规审查 -7. **计费集成**:对接支付系统,实现套餐自动续费/过期处理 -8. **到期通知扩展**:当前仅日志输出,扩展为邮件/短信/站内信通知 +### 23.1 业务描述 + +邮件服务是 SaaS 平台的通信基础设施,为密码重置、邀请通知、到期提醒、工单通知等业务提供统一的邮件发送能力。支持 SMTP 配置、模板管理、发送日志追踪。 + +### 23.2 数据模型 + +#### platform_email_config + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `smtp_host` | String(255) | NOT NULL | SMTP 服务器地址 | +| `smtp_port` | Integer | NOT NULL, default=465 | SMTP 端口 | +| `smtp_username` | String(255) | NOT NULL | SMTP 用户名 | +| `smtp_password` | String(255) | NOT NULL, 加密存储 | SMTP 密码 | +| `sender_name` | String(100) | NOT NULL | 发件人名称 | +| `sender_email` | String(255) | NOT NULL | 发件人邮箱 | +| `use_tls` | Boolean | default=True | 是否使用 TLS | +| `status` | Integer | default=0 | 状态(0:启用 1:禁用) | + +> 单例表:仅一条记录。超管在平台配置中管理。 + +#### platform_email_template + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `code` | String(50) | NOT NULL, UNIQUE | 模板编码(如 `password_reset`、`tenant_invite`、`expiry_reminder`、`ticket_notify`) | +| `name` | String(100) | NOT NULL | 模板名称 | +| `subject` | String(255) | NOT NULL | 邮件主题(支持 `{变量}` 占位符) | +| `body` | Text | NOT NULL | 邮件正文(HTML,支持 `{变量}` 占位符) | +| `variables` | Text | nullable | 可用变量说明(JSON 数组,如 `["{username}", "{reset_link}"]`) | +| `status` | Integer | default=0 | 状态(0:启用 1:禁用) | + +#### platform_email_log + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `template_code` | String(50) | nullable | 使用的模板编码 | +| `to_email` | String(255) | NOT NULL | 收件人邮箱 | +| `to_user_id` | FK→sys_user.id | nullable | 收件人用户 ID | +| `subject` | String(255) | NOT NULL | 实际发送的主题 | +| `body` | Text | NOT NULL | 实际发送的正文(渲染后) | +| `status` | Integer | NOT NULL, default=0 | 发送状态(0:待发送 1:成功 2:失败) | +| `error_msg` | Text | nullable | 失败原因 | +| `sent_time` | DateTime | nullable | 实际发送时间 | +| `retry_count` | Integer | default=0 | 重试次数 | + +### 23.3 业务规则 + +| 规则 | 说明 | +|------|------| +| **发送模式** | 支持同步发送和异步队列两种模式。默认异步(Redis 队列),避免阻塞主请求 | +| **重试策略** | 发送失败自动重试,最多 3 次,间隔 5 分钟。3 次仍失败则标记失败状态 | +| **频率限制** | 同一收件人同一模板 1 小时内最多发送 5 封,防止滥用 | +| **模板渲染** | 调用 `send_email(template_code, to, variables)` 时,自动从模板渲染 `subject` 和 `body` | +| **链路追踪** | 每次发送记录 `platform_email_log`,关联 `template_code` 和 `to_user_id` | + +### 23.4 业务集成点 + +| 场景 | 模板编码 | 触发时机 | 变量 | +|------|---------|---------|------| +| **密码重置** | `password_reset` | 创建租户初始管理员 / 用户忘记密码 | `{username}`, `{reset_link}` | +| **租户邀请** | `tenant_invite` | 管理员邀请用户加入租户 | `{inviter}`, `{tenant_name}`, `{invite_link}` | +| **到期提醒(30/7/1天)** | `expiry_reminder` | 定时任务检测到期 | `{tenant_name}`, `{expire_date}`, `{days_left}` | +| **工单通知** | `ticket_notify` | 工单创建/分配/关闭 | `{ticket_title}`, `{status}`, `{assignee}` | +| **套餐变更通知** | `package_change` | 租户套餐被超管变更 | `{old_package}`, `{new_package}`, `{removed_menus}` | + +### 23.5 降级策略 + +当邮件服务不可用时(SMTP 故障、配置缺失),自动降级为**站内信**(`sys_notice`),确保关键信息不丢失。 + +### 23.6 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/platform/email/config` | 获取 SMTP 配置 | 超管 | +| PUT | `/platform/email/config` | 更新 SMTP 配置 | 超管 | +| POST | `/platform/email/test` | 发送测试邮件 | 超管 | +| GET | `/platform/email/template/list` | 模板列表 | 超管 | +| POST | `/platform/email/template/create` | 创建模板 | 超管 | +| PUT | `/platform/email/template/update/{id}` | 更新模板 | 超管 | +| GET | `/platform/email/log/list` | 发送日志列表 | 超管 | --- -## 24. 变更记录 +## 24. Order 订单与支付模块 + +### 24.1 业务描述 + +订单与支付模块是 SaaS 平台的商业化基础,覆盖订单创建、支付回调、开通激活、续费/升级的完整交易闭环。对接支付宝和微信支付,支持套餐购买、续费和升级三种业务场景。 + +### 24.2 业务流程 + +``` +用户/超管选择套餐 → 生成订单 → 跳转支付 → 支付回调 → 激活/变更套餐 + ↓ 超时(15分钟) + 订单自动取消 +``` + +### 24.3 数据模型 + +#### platform_order + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `order_no` | String(32) | NOT NULL, UNIQUE | 订单号(年月日+6位随机数) | +| `tenant_id` | FK→platform_tenant.id | NOT NULL | 购买租户 | +| `package_id` | FK→platform_package.id | NOT NULL | 购买套餐 | +| `order_type` | String(20) | NOT NULL | 类型:`new`(新购) `renew`(续费) `upgrade`(升级) `downgrade`(降级) | +| `amount` | Integer | NOT NULL | 金额(分,≥0;0=免费套餐) | +| `period_count` | Integer | NOT NULL, default=1 | 购买周期数(1个月=1) | +| `status` | Integer | NOT NULL, default=0 | 状态:0=待支付 1=已支付 2=已取消 3=已退款 | +| `pay_method` | String(20) | nullable | 支付方式:`alipay`(支付宝) / `wxpay`(微信支付) | +| `pay_time` | DateTime | nullable | 支付时间 | +| `expire_time` | DateTime | NOT NULL | 订单过期时间(创建后+15分钟),超时未支付自动取消 | + +#### platform_payment_record + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `order_id` | FK→platform_order.id | NOT NULL | 关联订单 | +| `transaction_id` | String(64) | nullable | 第三方交易号 | +| `pay_method` | String(20) | NOT NULL | 支付方式 | +| `amount` | Integer | NOT NULL | 支付金额(分) | +| `status` | Integer | NOT NULL | 支付结果:0=处理中 1=成功 2=失败 | +| `raw_response` | Text | nullable | 支付平台原始回调数据(JSON) | +| `pay_time` | DateTime | nullable | 支付完成时间 | + +### 24.4 支付回调处理流程 + +``` +POST /platform/payment/callback/{method} + ├── IP 白名单校验(仅允许支付宝/微信支付官方 IP 段) + ├── 验证签名(支付宝 RSA / 微信支付 APIv3 签名) + ├── 分布式锁(Redis SETNX,key=callback_lock:{transaction_id},TTL=30s) + ├── 校验金额一致性(回调金额 == 订单金额) + ├── 校验订单状态(仅 status=0(待支付) 可处理,防止重复激活) + ├── 更新 platform_order.status=1、pay_time=now + ├── 写入 platform_payment_record(transaction_id UNIQUE 约束,第二次写入自动失败) + ├── 根据 order_type 执行激活逻辑: + │ ├── new/renew → 更新 tenant.end_time、恢复 status=0(active) + │ ├── upgrade → 更新 tenant.package_id、执行套餐变更影响预览逻辑(菜单+配额同步) + │ └── downgrade → 更新 tenant.package_id、清理超出的菜单关联、更新配额 + ├── 发送通知给租户管理员(邮件 + 站内信) + ├── 释放分布式锁 + └── 返回 success 给支付平台(防止重复回调) +``` + +> **安全要点**: +> - IP 白名单:仅允许支付宝/微信支付的官方回调 IP,在 Nginx/LB 层配置 +> - 分布式锁:解决支付平台可能同时回调多条相同交易的并发问题 +> - 状态校验:status≠0 的订单拒绝处理,防止恶意/重复回调 +> - 金额校验:回调金额与订单金额不一致时,标记异常并人工介入 + +### 24.5 业务规则 + +| 规则 | 说明 | +|------|------| +| **订单号生成** | `{YYYYMMDD}{6位随机数字}`,创建时检查唯一性 | +| **过期取消** | 定时任务 `cancel_expired_orders` 每分钟扫描 status=0 且 `expire_time < now` 的订单,设为 status=2(已取消) | +| **幂等性** | 同一 `transaction_id` 的回调只处理一次(transaction_id UNIQUE 约束 + 分布式锁双重保障) | +| **金额校验** | 回调金额必须与订单金额一致,不一致则拒绝并告警 | +| **IP 白名单** | 回调接口仅允许支付宝/微信支付官方 IP 调用,在 Nginx/负载均衡层配置 | +| **免费套餐** | amount=0 时不跳转支付,直接走激活流程 | +| **退款** | 退款为预留扩展,当前仅支持手动标记 status=3 | + +### 24.7 退款流程 + +``` +租户管理员申请退款 + ├── POST /tenant/order/refund/apply/{order_id} + │ 条件:订单 status=1(已支付) 且支付时间在 7 天内 + │ body: {reason: "误购/重复支付/服务不满意"} + ├── 更新 platform_order.refund_status=1(申请中) + ├── 创建 platform_refund 记录 + ├── 通知超管审核(站内信) + └── 返回申请结果 + +超管审核退款 + ├── GET /platform/refund/list(待审核列表) + ├── PUT /platform/refund/approve/{id} + │ 触发原路退款(调用支付宝/微信退款 API) + │ 更新 platform_refund.status=2(已退款) + │ 更新 platform_order.status=3(已退款) + │ 更新租户套餐(撤销本次购买升级效果,恢复至购买前套餐/到期时间) + └── PUT /platform/refund/reject/{id} + 更新 platform_refund.status=3(已驳回) + 记录驳回原因 +``` + +#### platform_refund + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `order_id` | FK→platform_order.id | NOT NULL, UNIQUE | 关联订单 | +| `refund_no` | String(32) | NOT NULL, UNIQUE | 退款单号 | +| `amount` | Integer | NOT NULL | 退款金额(分) | +| `reason` | Text | NOT NULL | 退款原因 | +| `status` | Integer | NOT NULL, default=1 | 1=申请中 2=已退款 3=已驳回 4=已取消 | +| `refund_transaction_id` | String(64) | nullable | 退款交易号(第三方返回) | +| `reviewer_id` | FK→sys_user.id | nullable | 审核人 | +| `review_time` | DateTime | nullable | 审核时间 | +| `reject_reason` | Text | nullable | 驳回原因 | + +| **退款规则** | 说明 | +|------|------| +| **可退款条件** | 支付后 7 天内,订单 status=1(已支付) | +| **退款金额** | 全额退款(暂不支持部分退款) | +| **退款方式** | 原路退回(支付宝→支付宝,微信→微信) | +| **套餐回退** | 退款后恢复至购买前的套餐和到期时间(若为升级/降级订单) | +| **降级补偿** | 若退款订单为降级类型,退款后套餐回升至降级前套餐 | +| **免费套餐** | amount=0 的免费套餐订单不支持退款 | + +### 24.8 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| POST | `/platform/order/create` | 创建订单 | 超管 | +| GET | `/platform/order/detail/{id}` | 订单详情 | 超管 | +| GET | `/platform/order/list` | 订单列表 | 超管 | +| POST | `/platform/order/cancel/{id}` | 取消订单 | 超管 | +| POST | `/platform/payment/callback/alipay` | 支付宝回调(外网可访问,签名验证) | 公开 | +| POST | `/platform/payment/callback/wxpay` | 微信支付回调(外网可访问,签名验证) | 公开 | +| GET | `/platform/payment/record/list` | 支付记录列表 | 超管 | +| POST | `/tenant/order/create` | 租户端创建订单(自助购买/续费/升级) | 租户管理员 | +| POST | `/tenant/order/refund/apply/{id}` | 申请退款 | 租户管理员 | +| GET | `/platform/refund/list` | 退款审核列表 | 超管 | +| PUT | `/platform/refund/approve/{id}` | 批准退款(触发原路退回) | 超管 | +| PUT | `/platform/refund/reject/{id}` | 驳回退款 | 超管 | + +--- + +## 25. TenantSelfService 租户自助服务(代码位于 module_platform/self_service) + +### 25.1 业务描述 + +租户管理员可在租户管理后台自助选择套餐、购买、续费或升级,无需超管介入。是 SaaS 产品商业化的核心用户侧功能。 + +### 25.2 自助套餐选择流程 + +``` +GET /tenant/package/available + ├── 返回所有启用的套餐列表 + ├── 标注当前套餐(is_current=true) + ├── 展示价格/周期/试用天数/功能对比 + ├── 标注可执行的操作:[购买][续费][升级][降级] + └── 限制:同一套餐已是当前套餐时不展示"升级"按钮 + +用户选择操作 → 创建订单 → 支付 → 自动激活 +``` + +### 25.3 套餐变更影响预览(自助版) + +```json +// GET /tenant/package/preview?target_package_id=xxx 返回 +{ + "current_package": "basic", + "target_package": "pro", + "action": "upgrade", + "amount": 29900, + "period": "month", + "gained_menus": [ + {"name": "数据报表", "path": "/report/dashboard"}, + {"name": "API 管理", "path": "/api/manage"} + ], + "lost_menus": [], + "affected_roles": [], + "affected_users": 0 +} +``` + +### 25.4 自助升级/降级流程 + +``` +POST /tenant/order/create (body: {package_id, order_type: "upgrade"}) + ├── 校验权限:租户管理员及以上 + ├── 校验租户状态:仅 active(0)/grace(1)/suspended(2) 可操作 + ├── 校验目标套餐:状态启用且不等于当前套餐 + ├── amount > 0 → 跳转支付 → 支付回调激活 + ├── amount = 0 → 直接激活(免费套餐切换) + └── 激活时执行套餐变更影响预览逻辑(同超管操作 §15.3) +``` + +### 25.5 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/tenant/package/available` | 获取可选套餐列表(含当前套餐标记和可执行操作) | 租户管理员 | +| GET | `/tenant/package/preview` | 套餐变更影响预览 | 租户管理员 | +| POST | `/tenant/order/create` | 创建自助订单(购买/续费/升级/降级) | 租户管理员 | +| GET | `/tenant/order/list` | 我的订单列表 | 租户管理员 | +| GET | `/tenant/order/detail/{id}` | 我的订单详情 | 租户管理员 | + +--- + +## 26. APIUsage 用量统计模块 + +### 26.1 业务描述 + +租户级别 API 调用量/频率统计,按天/月聚合,支持计费挂钩、安全异常检测和运营分析。 + +### 26.2 数据模型 + +#### platform_api_usage_daily + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `tenant_id` | FK→platform_tenant.id | NOT NULL | 租户 | +| `date` | Date | NOT NULL | 统计日期 | +| `api_path` | String(255) | NOT NULL | API 路径 | +| `request_count` | Integer | NOT NULL, default=0 | 请求次数 | +| `total_duration_ms` | BigInt | NOT NULL, default=0 | 总耗时(毫秒) | +| `error_count` | Integer | NOT NULL, default=0 | 错误次数(4xx/5xx) | + +> 唯一约束:`UNIQUE(tenant_id, date, api_path)` + +### 26.3 统计机制 + +``` +请求中间件(每个 API 调用) + ├── 提取 tenant_id、api_path、status_code、响应时间 + ├── Redis 计数器原子递增:api_usage:{tenant_id}:{date}:{api_path}:count + ├── Redis 计数器:api_usage:{tenant_id}:{date}:{api_path}:duration + ├── 错误计数(status_code >= 400):api_usage:{tenant_id}:{date}:{api_path}:errors + └── 定时任务(每小时):读取 Redis → UPSERT 到 platform_api_usage_daily → 清理旧 Redis key +``` + +### 26.4 异常检测规则 + +| 规则 | 条件 | 动作 | +|------|------|------| +| **频率突变** | 同一 API 调用量超过过去 7 天均值的 5 倍 | 站内信告警 | +| **错误率过高** | 错误率 > 20% 且请求数 > 100 | 站内信告警 | +| **高频调用** | 单租户单 API 超过 1000 次/分钟 | 临时限流(返回 429) | + +> 限流配置:`rate_limit_enabled`(全局开关),`rate_limit_threshold`(阈值),均可在平台配置中调整。 + +### 26.5 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/platform/api-usage/daily` | 按天用量统计(支持租户/日期范围筛选) | 超管 | +| GET | `/platform/api-usage/tenant/{id}` | 指定租户用量详情 | 超管 | +| GET | `/platform/api-usage/rank` | 租户用量排行 | 超管 | +| GET | `/platform/api-usage/anomalies` | 异常调用记录 | 超管 | + +--- + +## 27. UserInvite 用户邀请流程 + +### 27.1 业务描述 + +租户管理员可通过邀请链接或邀请码邀请新用户加入租户。被邀请人通过邮箱接收邀请,点击链接完成注册并自动关联到指定租户和角色。 + +### 27.2 数据模型 + +#### platform_invite_record + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `invite_code` | String(32) | NOT NULL, UNIQUE | 邀请码(UUID,一次性链接参数) | +| `tenant_id` | FK→platform_tenant.id | NOT NULL | 目标租户 | +| `target_role_id` | FK→sys_role.id | NOT NULL | 预设角色 | +| `inviter_id` | FK→sys_user.id | NOT NULL | 邀请人 | +| `invitee_email` | String(255) | NOT NULL | 被邀请人邮箱 | +| `status` | Integer | NOT NULL, default=0 | 状态:0=待接受 1=已接受 2=已过期 3=已取消 | +| `expire_time` | DateTime | NOT NULL | 过期时间(创建后+7天) | +| `accepted_user_id` | FK→sys_user.id | nullable | 接受邀请后创建的用户 ID | +| `accepted_time` | DateTime | nullable | 接受时间 | + +### 27.3 邀请流程 + +``` +租户管理员发起邀请 + ├── POST /tenant/invite/send + │ body: {emails: [...], role_id, message?} + ├── 批量创建 platform_invite_record 行,生成唯一 invite_code + ├── 发送邮件(模板 `tenant_invite`,含邀请链接) + ├── 邮件内容:{inviter} 邀请你加入 {tenant_name},点击链接注册 + └── 链接格式:{domain}/invite/{invite_code},有效期 7 天 + +被邀请人接受邀请 + ├── 访问 /invite/{code} 页面 + ├── 校验邀请码:存在、未过期(expire_time > now)、未使用(status=0) + ├── 如果用户已注册: + │ ├── 直接关联到租户(sys_user_tenant 插入记录) + │ ├── 分配预设角色(sys_user_role 插入记录) + │ └── 更新 invite_record.status=1 + ├── 如果用户未注册: + │ ├── 跳转到注册页面(邮箱已预填,不可修改) + │ ├── 用户完成注册 + │ ├── 自动关联到租户,分配预设角色 + │ └── 更新 invite_record.status=1, accepted_user_id + └── 通知邀请人"XXX 已接受您的邀请" +``` + +### 27.4 业务规则 + +| 规则 | 说明 | +|------|------| +| **邀请码唯一性** | 每次生成全局唯一的 UUID,即使同一邮箱被重复邀请也不同 | +| **有效期** | 默认 7 天,到期后自动标记 status=2(过期),不再可用 | +| **重复邀请** | 同一租户内,同一邮箱有"待接受"的邀请时,提示"该邮箱已有待接受的邀请",不重复发送 | +| **角色预分配** | 被邀请人加入租户时自动获得 `target_role_id` 指定的角色 | +| **权限** | 仅租户管理员(owner/admin)可发送邀请 | +| **邀请人可见** | 可查看自己发出的邀请列表及状态 | +| **降级** | 若邮件服务不可用,邀请码可通过站内消息手动复制链接 | + +### 27.5 过期清理 + +定时任务 `cleanup_expired_invites` 每天扫描 `status=0` 且 `expire_time < now` 的记录,标记为 status=2。 + +### 27.6 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| POST | `/tenant/invite/send` | 发送邀请 | 租户管理员 | +| GET | `/tenant/invite/list` | 邀请列表(含状态) | 租户管理员 | +| DELETE | `/tenant/invite/cancel/{id}` | 取消邀请 | 租户管理员 | +| GET | `/invite/validate/{code}` | 校验邀请码(公开接口,返回租户名/邀请人) | 公开 | +| POST | `/invite/accept/{code}` | 接受邀请(需登录) | 登录用户 | + +--- + +## 28. 未来扩展建议 + +### 28.1 已规划(短期) + +1. **知识库/帮助中心**:租户自助查阅帮助文档、常见问题。富文本编辑器管理,支持多语言 +2. **数据导出/备份**:租户自助数据导出(CSV/JSON),满足 GDPR/个保法合规要求。平台级全量备份还原(数据库级) +3. **租户自定义域名**:支持通过 `domain` 字段实现租户专属域名,需配合 Nginx 反向代理配置 + +### 28.2 中期扩展 + +| 能力 | 说明 | 优先级 | +|------|------|--------| +| **单点登录(SSO)** | 支持 SAML/OIDC 协议,企业客户可使用自有 IdP(如 Okta/Azure AD/自有 LDAP)登录 | 低 | +| **多语言支持** | 租户级 i18n 配置,支持不同租户使用不同语言 | 低 | +| **Webhook 通知** | 关键事件(支付成功/到期提醒/套餐变更)的 webhook 回调,支持第三方集成 | 中 | + +### 28.3 API 路径规范 + +| 问题 | 当前 | 建议 | +|------|------|------| +| 参数模块名不一致 | `/param/`(单数)vs 模块名 `params`(复数) | 统一为 `/param/`(与代码一致) | +| 日志层级问题 | 操作日志 `/system/operationlog/` 与其他模块不统一 | 建议保持现状。登录日志为平台级、操作日志为租户级,层级分开是合理的 | + +### 28.4 非功能性需求(NFR) + +| 指标 | 要求 | +|------|------| +| **API 响应时间** | P95 ≤ 500ms(查询),P95 ≤ 2s(写入/批操作) | +| **并发用户** | 单实例支持 500+ 并发租户用户(需压测验证) | +| **可用性** | 99.5%(不包含计划运维窗口) | +| **数据安全** | 传输层 TLS 1.3,存储层 Bcrypt/AES-256,日志脱敏(手机号/邮箱部分掩码) | +| **兼容性** | 支持 MySQL 8.0+ / PostgreSQL 14+ / SQLite(开发环境);Python ≥ 3.10;Node.js ≥ 20 | +| **数据库备份** | 每日全量备份(保留 30 天),每小时增量备份(保留 7 天)。备份异地存储,定期恢复演练(每季度 1 次) | +| **容灾恢复** | RTO ≤ 4 小时,RPO ≤ 1 小时。主库故障时自动切换只读副本,30 分钟内完成主从切换 | +| **版本升级策略** | 数据库迁移采用 Alembic 管理,所有 schema 变更通过 migration 脚本执行。升级前自动备份,升级失败可回滚至上一个备份点。主版本升级需提前通知租户(7 天),次版本/补丁版可灰度发布 | +| **i18n 基础** | 后端 API 错误消息统一使用 i18n key(如 `errors.user.not_found`),前端使用 vue-i18n。初始版本仅提供中文,保留英文翻译文件占位。租户级语言首选项存储在 `platform_tenant.lang` 字段(预留) | +| **缓存键命名规范** | 格式:`{namespace}:{sub_namespace}:{identifier}`。示例:`tenant:config:123`、`api:usage:456:2026-06-03`、`auth:session:abc123`。所有 key 设置 TTL,禁止无过期时间的 key | + +--- + +## 29. 术语表(Glossary) + +| 术语 | 英文 | 说明 | +|------|------|------| +| **租户** | Tenant | SaaS 平台上的一个独立组织/客户 | +| **平台资源** | Platform Resource | 无 tenant_id 的资源,所有租户共享(菜单/套餐/插件) | +| **租户资源** | Tenant Resource | 含 tenant_id 的资源,按租户隔离(用户/角色/部门等) | +| **平台共享数据** | Shared Platform Data | tenant_id=1 的字典数据,所有租户可读 | +| **超管** | Super Admin | 平台级管理员,`is_super_admin=true`,可管理所有租户 | +| **租户管理员** | Tenant Admin | 租户内的管理员,角色为 owner/admin | +| **数据权限范围** | Data Scope | 角色绑定的数据可见范围(全部/本部门及子部门/仅本部门等) | +| **RBAC** | Role-Based Access Control | 基于角色的访问控制 | +| **Mixin** | Mixin | SQLAlchemy 混入模式,用于给模型添加通用字段(如 TenantMixin 自动添加 tenant_id) | +| **处理人** | Assignee | 工单中指派的处理人员 | +| **生命周期** | Lifecycle | 租户的完整状态流转路径:active(0)→grace(1)→suspended(2)→expired(4)→archived(5)→deleted。人工冻结路径:active(0)→frozen(3)→archived(5) | +| **宽限期** | Grace Period | 租户到期后的缓冲期(默认7天),允许正常使用但提示续费 | +| **订单** | Order | 套餐购买/续费/升级的交易凭证,关联支付回调激活套餐 | +| **邀请码** | Invite Code | 租户管理员邀请用户加入的一次性链接参数(UUID,7天有效) | +| **用量统计** | API Usage | 租户级别 API 调用量按天聚合统计,用于计费和安全分析 | +| **邮件模板** | Email Template | 预定义的邮件格式(密码重置/邀请/到期提醒),支持变量占位符渲染 | +| **支付回调** | Payment Callback | 支付宝/微信支付完成后异步通知平台更新订单状态的机制 | +| **默认套餐** | Default Package | 标记 is_default=true 的套餐,自助注册时自动选用 | +| **分布式锁** | Distributed Lock | Redis SETNX 实现的并发互斥机制,防止支付回调等场景的重复处理 | +| **数据库迁移** | Database Migration | 使用 Alembic 管理的版本化 schema 变更脚本,支持升级和回滚 | +| **发票** | Invoice | 订单支付后开具的电子发票(增值税普票/专票),一单一票 | +| **审计日志** | Audit Log | 不可篡改的合规操作记录,仅超管可查阅,保留 3 年 | +| **运营大盘** | Dashboard | 平台运营数据的可视化看板,聚合租户/收入/API 用量等核心指标 | +| **原路退回** | Refund | 支付退款按原支付路径返回(支付宝→支付宝,微信→微信) | + +--- + +## 30. 变更记录 | 版本 | 日期 | 变更内容 | |------|------|---------| | v3.1.0 | 2026-06-01 | 初始版本,完整的模块化需求文档 | | v3.2.0 | 2026-06-02 | 新增 Plugin 子模块需求:AI Chat/Cronjob/Workflow/CodeGen/Demo;新增 Monitor 监控模块:Online/Cache/Resource/Server;新增 Common 公共模块:File/Health/Metrics | +| v3.2.1 | 2026-06-03 | **需求审查修复**:修复全部章节子标题编号错位;统一表名为 `platform_*` 前缀(与代码一致);移除已废弃的 `sys_tenant_quota`/`sys_tenant_config` 独立表引用;拆分 `sys_log` 为 `sys_operation_log` + `platform_login_log`;统一 Auth 端点路径;明确多租户登录临时/正式 token 机制;补充用户注册租户自动创建流程;添加 `start_time` 未生效校验;补充通知公告已读机制;补全 Tenant 模型遗漏字段(description/version/privacy 等);新增安全性要求(登录限流/密码复杂度/CORS);更新未来扩展建议 | +| v3.2.2 | 2026-06-03 | **命名规范统一**:修复文档中 `sys_login_log`→`platform_login_log`、`sys_ticket`→`platform_ticket` 两处表名错误;在 §21 新增 §21.6 插件表汇总(8 张 task_/gen_ 表);明确全模块命名规范:`platform_`=平台模块,`sys_`=系统模块,`task_`=任务插件,`gen_`=生成器插件 | +| v3.3.0 | 2026-06-03 | **SaaS 产品需求审查修订**:P0-1 租户生命周期状态机(active→frozen→archived→deleted);P0-2 定时任务/工作流代码执行安全性(任意代码→预定义处理器白名单);P0-3 套餐模型补充定价字段(price/period/trial_days/max_tenants);P0-4 初始管理员密码交付改为邮件一次性重置链接;P1-1 通知已读机制改为后端 sys_notice_read 表;P1-2 工单增加 close_reason/closed_time/closed_by 字段;P1-3 租户到期增加宽限期和阶段性处理(grace→suspended→expired);P1-4 用户导入密码处理策略;P1-5 套餐变更增加影响预览和确认流程;P2 操作日志保留策略、API 路径规范、缺失 SaaS 能力规划(用户邀请/数据导出/API用量/审计日志/SSO);新增 NFR 非功能性需求、术语表 | +| v3.4.0 | 2026-06-03 | **业务架构闭环补全**:Fix-1 统一 status 编码(String(1)→Integer,合并生命周期与到期阶段编码:0=active/1=grace/2=suspended/3=frozen/4=expired/5=archived);Fix-2 新增 §23 Email 邮件服务模块(SMTP 配置/模板管理/发送日志/5大业务集成点/站内信降级);Fix-3 新增 §24 Order 订单与支付模块(订单表/支付记录表/支付宝&微信支付回调/开通续费升级流程);Fix-4 新增 §25 TenantSelfService 租户自助服务(套餐选择/影响预览/自助升级降级);Fix-5 新增 §26 APIUsage 用量统计模块(按天聚合/Redis计数器/异常检测/限流);Fix-6 新增 §27 UserInvite 用户邀请流程(邀请码/邮件邀请/角色预分配/过期清理);Fix-7 清理残留与全面重编号(移除 §19.4 残留 TODO、§20 新增 §20.18-§20.22 API端点、§21 新增7张新表、§28-§30 重编号、§31-§37 Plugin模块重编号、§29 术语表扩充8个词条、§30 变更记录更新) | +| v3.5.0 | 2026-06-03 | **PRD 正式评审修复**:P0-1 修正 Part 4 Plugin 子模块编号(§31-§37 子节编号 25.x-31.x→31.x-37.x,共 34 处);P0-2 修复初始管理员体验断层(创建租户时自动创建 owner 角色并分配全量菜单,§6.3/§15.3/§15.4 同步更新);P0-3 统一 status 字段类型(package/ticket/order/payment/invite 全部从 String→Integer,保持与 tenant.status 一致);P1-1 标题版本号修正(v3.2.2→v3.5.0);P1-2 自助注册新增默认套餐/配额/到期时间来源(§4.5 注册流程、§16.2 is_default 字段);P1-3 套餐配额体系补充(§16.2 新增 max_users/max_roles/max_depts 配额字段);P1-4 套餐变更同步更新配额(§15.3 增加配额对比预览和升级/降级处理逻辑);P1-5 支付回调安全细节增强(§24.4/§24.5 新增 IP 白名单、分布式锁、状态校验);P2-1 API 路径规范(§16.5 套餐路径统一为 /platform/package/);P2-2 新增数据备份与容灾策略(§28.4 NFR);P2-3 新增版本升级/迁移策略(§28.4 NFR);P2-4 新增 i18n 基础设计(§28.4 NFR);P2-5 新增 Redis 缓存键命名规范(§28.4 NFR);术语表扩充 4 个词条(默认套餐/分布式锁/数据库迁移/冗余恢复) | +| v3.6.0 | 2026-06-03 | **PRD 100% 完整度达标**:Fix-1 修正 §26.2/§27.2 子节编号错误(32.2→26.2、33.2→27.2);Fix-2 修正 §28 节编号排序(28.5→28.3、删除重复 28.5);Fix-3 §24.7 新增退款流程(platform_refund 表、申请→审核→原路退回、套餐回退逻辑);Fix-4 新增 §38 Invoice 发票管理模块(普票/专票、百望云等第三方对接、一单一票、30天开票时限);Fix-5 新增 §39 AuditLog 审计日志模块(不可篡改、JSON 变更对比、13 种审计事件、3年保留策略);Fix-6 新增 §40 Dashboard 运营大盘模块(MRR/退款率/API用量/套餐分布/收入趋势 9 项指标);Fix-7 更新 §1.4 模块总览(新增 3 个模块)、§20 新增 §20.23-§20.25 API端点、§21 新增 §21.7 商业运营表、§28 移除已实现项、§29 术语表扩充 4 个词条 | + --- @@ -1397,15 +2197,15 @@ plugin/module_xxx/ --- -## 25. AI Chat 聊天模块(module_ai/chat) +## 31. AI Chat 聊天模块(module_ai/chat) -### 25.1 业务描述 +### 31.1 业务描述 AI 对话模块,提供用户与大模型进行对话的能力。支持多会话管理、WebSocket 流式对话、非流式对话。ChatSession 数据按租户隔离。 -### 25.2 数据模型 +### 31.2 数据模型 -聊天会话数据存储在外部(具体存储取决于 ChatService 实现,可能为内存/Redis/数据库)。Schema 层定义如下: +聊天会话数据存储在 ChatService 后端(支持内存存储/Redis/数据库三种模式,由配置决定)。Schema 层定义如下: #### ChatSessionCreateSchema @@ -1435,7 +2235,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | `function_calls` | list[dict] | nullable | 函数调用信息 | | `action` | dict | nullable | 建议执行的操作 | -### 24.3 API 端点 +### 31.3 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -1449,13 +2249,13 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 --- -## 26. Cronjob 定时任务模块(module_task/cronjob) +## 32. Cronjob 定时任务模块(module_task/cronjob) -### 26.1 业务描述 +### 32.1 业务描述 定时任务模块提供动态节点定义(NodeModel)和任务执行日志记录(JobModel)。节点定义执行代码块、触发器和参数,通过 APScheduler 调度执行。 -### 26.2 数据模型 +### 32.2 数据模型 #### NodeModel(task_node,TenantMixin, UserMixin) @@ -1467,7 +2267,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | `executor` | String(64) | nullable, default="default" | 执行器 | | `trigger` | String(64) | nullable | 触发器 | | `trigger_args` | Text | nullable | 触发器参数 | -| `func` | Text | nullable | 代码块(须定义 handler 函数) | +| `func` | Text | NOT NULL | 预定义处理器标识符(如 `handlers.send_email`)。禁止租户提交任意代码 | | `args` | Text | nullable | 位置参数 | | `kwargs` | Text | nullable | 关键字参数 | | `coalesce` | Boolean | nullable, default=False | 是否合并运行 | @@ -1488,7 +2288,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | `result` | Text | nullable | 执行结果 | | `error` | Text | nullable | 错误信息 | -### 25.3 业务规则 +### 32.3 业务规则 | 规则 | 说明 | |------|------| @@ -1496,11 +2296,12 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | **触发器类型** | 仅支持 now/cron/interval/date | | **非立即执行** | trigger != "now" 时必须提供 trigger_args | | **时间校验** | end_date 不能早于 start_date | -| **func 必填** | Node 创建时 func 不能为空 | +| **func 必填** | Node 创建时 func 不能为空,须为已注册的处理器标识符 | +| **处理器白名单** | func 字段只能填写平台预注册的处理器(如 `handlers.send_email`、`handlers.call_api`),禁止填写任意代码。超管可在 `platform_handler_registry` 中注册新处理器 | | **Job 状态** | 仅支持 pending/running/success/failed/timeout/cancelled | | **trigger_type** | 仅支持 cron/interval/date/manual | -### 25.4 API 端点 +### 32.4 API 端点 #### Node(节点) @@ -1524,13 +2325,13 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 --- -## 27. Workflow 工作流模块(module_task/workflow) +## 33. Workflow 工作流模块(module_task/workflow) -### 27.1 业务描述 +### 33.1 业务描述 工作流模块提供可视化流程编排和执行能力。基于 Vue Flow 画布定义流程节点和连线,通过 Prefect 引擎执行。数据按租户隔离。 -### 27.2 数据模型 +### 33.2 数据模型 #### WorkflowModel(task_workflow,TenantMixin, UserMixin) @@ -1549,13 +2350,13 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | `name` | String(128) | NOT NULL | 显示名称 | | `code` | String(64) | NOT NULL, UNIQUE(tenant_id, code) | 节点编码,对应画布 node.type | | `category` | String(32) | NOT NULL, default="action" | 分类:trigger/action/condition/control | -| `func` | Text | NOT NULL | Python 代码块(须定义 handler) | +| `func` | Text | NOT NULL | 预定义处理器标识符(如 `handlers.approve`、`handlers.send_http`)。禁止租户提交任意代码 | | `args` | Text | nullable | 默认位置参数,逗号分隔 | | `kwargs` | Text | nullable | 默认关键字参数 JSON | | `sort_order` | Integer | NOT NULL, default=0 | 排序 | | `is_active` | Boolean | NOT NULL, default=True | 是否启用 | -### 26.3 业务规则 +### 33.3 业务规则 | 规则 | 说明 | |------|------| @@ -1566,7 +2367,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | **执行流程** | 需传入 workflow_id 和可选的 variables/business_key/job_id | | **执行结果** | 返回 completed/failed 状态及各节点执行结果 | -### 26.4 API 端点 +### 33.4 API 端点 #### Workflow(流程定义) @@ -1592,13 +2393,13 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 --- -## 28. CodeGen 代码生成器模块(module_generator/gencode) +## 34. CodeGen 代码生成器模块(module_generator/gencode) -### 28.1 业务描述 +### 34.1 业务描述 代码生成器模块,通过读取数据库表结构自动生成 CRUD 代码(Python 后端 + Vue 前端 + TypeScript API 层)。支持主子表结构。数据按租户隔离。 -### 28.2 数据模型 +### 34.2 数据模型 #### GenTableModel(gen_table,TenantMixin, UserMixin) @@ -1640,7 +2441,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | `dict_type` | String(200) | nullable, default="" | 字典类型 | | `sort` | Integer | NOT NULL, default=0 | 排序 | -### 27.3 业务规则 +### 34.3 业务规则 | 规则 | 说明 | |------|------| @@ -1652,7 +2453,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | **模板生成** | 支持 Python/TS/Vue 三端代码模板(Jinja2) | | **主子表** | 通过 sub_table_name/sub_table_fk_name 配置主子表关联 | -### 27.4 API 端点 +### 34.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -1674,15 +2475,15 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 --- -## 29. Demo 示例模块(module_example/demo) +## 35. Demo 示例模块(module_example/demo) -### 29.1 业务描述 +### 35.1 业务描述 示例模块,演示 CRUD 标准开发模式和多种数据类型的用法。数据按租户隔离。 -### 29.2 数据模型 +### 35.2 数据模型 -#### DemoModel(gen_demo,TenantMixin, UserMixin) +#### DemoModel(example_demo,TenantMixin, UserMixin) | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| @@ -1697,20 +2498,20 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | `h` | Text | nullable | 长文本 | | `i` | JSON | nullable | 元数据 JSON | -#### Demo01Model(gen_demo01,TenantMixin, UserMixin) +#### Demo01Model(example_demo01,TenantMixin, UserMixin) | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| | `name` | String(64) | NOT NULL | 名称 | -### 28.3 业务规则 +### 35.3 业务规则 | 规则 | 说明 | |------|------| | **名称校验** | 2-50 位,仅含字母/数字/下划线/中划线 | | **状态校验** | 仅支持 0(正常)、1(禁用) | -### 28.4 API 端点 +### 35.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -1734,19 +2535,19 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 --- -## 30. Monitor 监控模块(module_monitor) +## 36. Monitor 监控模块(module_monitor) -### 30.1 业务描述 +### 36.1 业务描述 监控模块提供系统运行状态的实时监控能力,包括在线用户追踪、Redis 缓存监控、服务器资源监控和文件系统管理。该模块属于平台级功能,不受租户隔离限制,超级管理员可查看所有数据。 -### 30.2 在线用户(online) +### 36.2 在线用户(online) -#### 30.2.1 业务描述 +#### 36.2.1 业务描述 在线用户监控来自 Redis 存储的会话数据,实时追踪当前登录用户。数据不按租户隔离,超级管理员可查看所有在线用户。 -#### 30.2.2 数据模型 +#### 36.2.2 数据模型 **OnlineOutSchema(Redis 数据结构)** @@ -1765,7 +2566,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | `login_time` | DateTime | nullable | 登录时间 | | `login_type` | str | nullable | 登录类型(PC/移动) | -#### 30.2.3 业务规则 +#### 36.2.3 业务规则 | 规则 | 说明 | |------|------| @@ -1773,7 +2574,7 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 | **强制下线** | 超级管理员可强制指定用户下线 | | **清空全部** | 超级管理员可清空所有在线用户会话 | -#### 30.2.4 API 端点 +#### 36.2.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -1783,13 +2584,13 @@ AI 对话模块,提供用户与大模型进行对话的能力。支持多会 --- -### 30.3 缓存监控(cache) +### 36.3 缓存监控(cache) -#### 30.3.1 业务描述 +#### 36.3.1 业务描述 Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查看和清除功能。数据不按租户隔离,属于平台级功能。 -#### 30.3.2 数据模型 +#### 36.3.2 数据模型 **CacheMonitorSchema** @@ -1808,7 +2609,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | `cache_value` | Any | nullable | 缓存值 | | `remark` | str | nullable | 备注说明 | -#### 30.3.3 业务规则 +#### 36.3.3 业务规则 | 规则 | 说明 | |------|------| @@ -1816,7 +2617,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | **键值管理** | 支持查看和清除指定缓存 | | **批量清除** | 支持按名称清除和清空所有缓存 | -#### 30.3.4 API 端点 +#### 36.3.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -1830,13 +2631,13 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 --- -### 30.4 资源管理(resource) +### 36.4 资源管理(resource) -#### 30.4.1 业务描述 +#### 36.4.1 业务描述 资源文件管理,提供服务器文件系统的浏览、上传、下载、删除、移动、复制、重命名、创建目录等操作。支持文件列表分页、关键词搜索和 Excel 导出。 -#### 30.4.2 数据模型 +#### 36.4.2 数据模型 **ResourceItemSchema** @@ -1883,7 +2684,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | `parent_path` | str | NOT NULL | 父目录路径 | | `dir_name` | str | NOT NULL, max_length=255 | 目录名称 | -#### 30.4.3 业务规则 +#### 36.4.3 业务规则 | 规则 | 说明 | |------|------| @@ -1894,7 +2695,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | **上传限制** | 仅 resource 类型支持指定目标目录 | | **导出功能** | 支持将资源列表导出为 Excel | -#### 30.4.4 API 端点 +#### 36.4.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -1910,13 +2711,13 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 --- -### 30.5 服务器监控(server) +### 36.5 服务器监控(server) -#### 30.5.1 业务描述 +#### 36.5.1 业务描述 服务器监控,采集服务器运行时的 CPU、内存、磁盘、Python 进程等信息,供运维人员了解系统资源使用情况。 -#### 30.5.2 数据模型 +#### 36.5.2 数据模型 **CpuInfoSchema** @@ -1982,14 +2783,14 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | `sys` | SysInfoSchema | NOT NULL | 系统信息 | | `disks` | list[DiskInfoSchema] | NOT NULL | 磁盘信息列表 | -#### 30.5.3 业务规则 +#### 36.5.3 业务规则 | 规则 | 说明 | |------|------| | **实时采集** | 每次请求实时采集系统信息 | | **百分比范围** | 使用率字段限制在 0-100 范围 | -#### 30.5.4 API 端点 +#### 36.5.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -1997,19 +2798,19 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 --- -## 31. Common 公共模块(module_common) +## 37. Common 公共模块(module_common) -### 31.1 业务描述 +### 37.1 业务描述 公共模块提供跨模块复用的基础服务,包括统一文件上传下载、健康检查和指标监控。该模块属于平台级基础设施,不受租户隔离限制。 -### 31.2 文件管理(file) +### 37.2 文件管理(file) -#### 31.2.1 业务描述 +#### 37.2.1 业务描述 统一文件上传下载服务,支持多种上传类型(通用文件、头像、参数配置、监控资源),支持指定目标目录。预留 Excel 导入功能,待后续实现。 -#### 31.2.2 数据模型 +#### 37.2.2 数据模型 **上传响应数据** @@ -2039,7 +2840,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | `filed_info` | list[ImportFieldModel] | nullable | 字段映射列表 | | `file_name` | str | nullable | 文件名 | -#### 31.2.3 业务规则 +#### 37.2.3 业务规则 | 规则 | 说明 | |------|------| @@ -2048,7 +2849,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | **下载选项** | 支持下载后自动删除源文件 | | **Excel导入预留** | ImportFieldModel 和 ImportModel 为预留功能,当前未实现对应接口 | -#### 31.2.4 API 端点 +#### 37.2.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -2057,16 +2858,16 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 --- -### 31.3 健康检查(health) +### 37.3 健康检查(health) -#### 31.3.1 业务描述 +#### 37.3.1 业务描述 三级健康检查体系,用于不同场景的健康探测: - `/health`: 基础健康检查(负载均衡器探测) - `/health/live`: 存活探针(K8s livenessProbe) - `/health/ready`: 就绪探针(K8s readinessProbe,检测数据库和 Redis) -#### 31.3.2 数据模型 +#### 37.3.2 数据模型 **健康检查响应** @@ -2086,7 +2887,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | `database` | dict | NOT NULL | 数据库状态 {status, latency_ms} | | `redis` | dict | NOT NULL | Redis 状态 {status, latency_ms} | -#### 31.3.3 业务规则 +#### 37.3.3 业务规则 | 规则 | 说明 | |------|------| @@ -2095,7 +2896,7 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 | **就绪探针** | 检测数据库和 Redis 连接,失败返回 503 | | **依赖状态** | up=正常, down=异常, disabled=已禁用 | -#### 31.3.4 API 端点 +#### 37.3.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| @@ -2105,13 +2906,13 @@ Redis 缓存监控,提供缓存统计信息、缓存名称列表、键值查 --- -### 31.4 指标监控(metrics) +### 37.4 指标监控(metrics) -#### 31.4.1 业务描述 +#### 37.4.1 业务描述 Prometheus 指标监控,集成 prometheus-fastapi-instrumentator 自动采集 HTTP 请求指标,暴露 `/metrics` 端点供 Prometheus 抓取。 -#### 31.4.2 采集指标 +#### 37.4.2 采集指标 | 指标名称 | 类型 | 说明 | |---------|------|------| @@ -2121,7 +2922,7 @@ Prometheus 指标监控,集成 prometheus-fastapi-instrumentator 自动采集 | `http_request_size_bytes` | Histogram | 请求体大小 | | `http_response_size_bytes` | Histogram | 响应体大小 | -#### 31.4.3 排除端点 +#### 37.4.3 排除端点 以下端点不纳入指标采集: - `/metrics`: Prometheus 抓取端点 @@ -2129,10 +2930,204 @@ Prometheus 指标监控,集成 prometheus-fastapi-instrumentator 自动采集 - `/docs`, `/redoc`, `/openapi.json`: API 文档 - `/static/*`, `/favicon.ico`: 静态资源 -#### 31.4.4 API 端点 +#### 37.4.4 API 端点 | 方法 | 路径 | 权限标识 | 说明 | |------|------|---------|------| | GET | `/metrics` | — | Prometheus 指标端点 | +--- + +# Part 5:商业运营模块 + +--- + +## 38. Invoice 发票管理模块 + +### 38.1 业务描述 + +发票管理是中国 B2B SaaS 的法律合规要求。租户在完成订单支付后可申请开具电子发票(增值税普通发票/增值税专用发票),平台审核后对接第三方开票 API(如百望云/票通)生成电子发票,支持下载 PDF。 + +### 38.2 发票类型 + +| 类型 | 编码 | 适用场景 | 税率 | +|------|------|---------|------| +| **增值税普通发票** | `vat_normal` | 个人/小规模纳税人,不可抵扣 | 1%/3%/6% | +| **增值税专用发票** | `vat_special` | 一般纳税人,可抵扣进项税额 | 6%/13% | + +### 38.3 数据模型 + +#### platform_invoice + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `invoice_no` | String(32) | NOT NULL, UNIQUE | 发票号码(平台自增号) | +| `order_id` | FK→platform_order.id | NOT NULL, UNIQUE | 关联订单(一单一票) | +| `tenant_id` | FK→platform_tenant.id | NOT NULL | 开票租户 | +| `invoice_type` | String(20) | NOT NULL | 类型:`vat_normal`(普票) `vat_special`(专票) | +| `title` | String(200) | NOT NULL | 发票抬头(公司全称/个人姓名) | +| `tax_no` | String(50) | nullable | 纳税人识别号(普票可选,专票必填) | +| `bank_info` | Text | nullable | 开户行及账号(专票必填) | +| `address_info` | Text | nullable | 注册地址及电话(专票必填) | +| `amount` | Integer | NOT NULL | 发票金额(分) | +| `tax_amount` | Integer | NOT NULL, default=0 | 税额(分) | +| `status` | Integer | NOT NULL, default=0 | 0=待开票 1=已开票 2=开票失败 3=已作废 | +| `pdf_url` | String(500) | nullable | 电子发票 PDF 下载地址 | +| `api_response` | Text | nullable | 第三方开票 API 原始响应 | +| `remark` | Text | nullable | 备注 | + +### 38.4 业务流程 + +``` +租户申请开票 + ├── POST /tenant/invoice/apply + │ body: {order_id, invoice_type, title, tax_no?, bank_info?, address_info?} + ├── 校验:订单已支付(status=1)、未开过票(order_id UNIQUE) + ├── 专票额外校验:tax_no/bank_info/address_info 必填 + ├── 创建 platform_invoice 记录(status=0 待开票) + └── 返回申请成功 + +超管审核开票 + ├── GET /platform/invoice/list(待开票列表) + ├── PUT /platform/invoice/issue/{id} + │ ├── 调用第三方开票 API(百望云等) + │ ├── 成功 → 更新 status=1、pdf_url、api_response + │ ├── 失败 → 更新 status=2、记录错误信息 + │ └── 通知租户(站内信 + 邮件,含下载链接) + └── PUT /platform/invoice/void/{id}(发票作废,仅已开票可作废) +``` + +### 38.5 业务规则 + +| 规则 | 说明 | +|------|------| +| **一单一票** | 每个订单仅可开具一张发票(order_id UNIQUE),杜绝重复开票 | +| **开票时限** | 订单支付后 30 天内可申请,超期不再支持(税务合规) | +| **金额匹配** | 发票金额必须等于订单实付金额 | +| **专票校验** | 增值税专用发票必须填写税号+开户行+地址,缺一不可 | +| **第三方对接** | 对接百望云/票通等电子发票平台,API 调用失败时自动重试 3 次后标记失败 | +| **PDF 存储** | 电子发票 PDF 上传至文件服务(§37.2),按 `invoice/{tenant_id}/{invoice_no}.pdf` 路径存储 | +| **作废规则** | 当月开具的发票可作废,跨月发票需冲红(暂不支持,预留扩展) | + +### 38.6 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| POST | `/tenant/invoice/apply` | 申请开票 | 租户管理员 | +| GET | `/tenant/invoice/list` | 我的发票列表 | 租户管理员 | +| GET | `/tenant/invoice/{id}/download` | 下载发票 PDF | 租户管理员 | +| GET | `/platform/invoice/list` | 全部发票列表(支持筛选) | 超管 | +| PUT | `/platform/invoice/issue/{id}` | 开具发票(调用第三方 API) | 超管 | +| PUT | `/platform/invoice/void/{id}` | 作废发票 | 超管 | + +--- + +## 39. AuditLog 租户审计日志模块 + +### 39.1 业务描述 + +记录平台级和租户级的关键管理操作,形成不可篡改的审计轨迹。满足企业内部合规审查、SOC2/ISO27001 认证中的数据追溯要求。审计日志与操作日志(§14)的区别在于:操作日志面向业务操作的查询追踪,审计日志面向合规要求的不可否认性记录。 + +### 39.2 操作分类 + +| 分类 | 说明 | 示例 | +|------|------|------| +| **租户管理** | 租户生命周期操作 | 创建/启禁用/冻结/删除/变更套餐 | +| **权限变更** | 角色/菜单/授权操作 | 角色创建/删除、菜单分配/回收 | +| **套餐变更** | 套餐相关操作 | 套餐价格修改、套餐菜单变更 | +| **支付与退款** | 财务相关操作 | 订单创建、退款批准/驳回 | +| **发票管理** | 开票相关操作 | 开具发票、作废发票 | +| **用户邀请** | 团队管理操作 | 发送邀请、取消邀请 | + +### 39.3 数据模型 + +#### platform_audit_log + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| `action` | String(50) | NOT NULL | 操作类型:`tenant.create`/`tenant.package_change`/`role.delete` 等 | +| `target_type` | String(50) | NOT NULL | 操作对象类型:`tenant`/`package`/`role`/`order`/`invoice` | +| `target_id` | Integer | NOT NULL | 操作对象 ID | +| `target_name` | String(200) | nullable | 操作对象名称(冗余存储,防删除后无法追溯) | +| `tenant_id` | FK→platform_tenant.id | nullable | 关联租户(平台级操作可为 null) | +| `operator_id` | FK→sys_user.id | NOT NULL | 操作人 | +| `operator_name` | String(50) | NOT NULL | 操作人名称(冗余) | +| `detail` | JSON | NOT NULL | 操作详情(变更前/后的关键字段) | +| `ip_address` | String(45) | nullable | 操作 IP | +| `user_agent` | String(500) | nullable | 浏览器 UA | + +| **设计要点** | 说明 | +|------|------| +| **不可篡改** | 无 Update/Delete API,仅支持 Insert 和 Read | +| **变更对比** | `detail` JSON 字段存储 `{"before": {...}, "after": {...}}` 格式的变更对比 | +| **冗余存储** | target_name/operator_name 冗余存储,确保删除关联数据后仍可追溯 | +| **保留策略** | 默认保留 3 年,超期归档至冷存储(S3/OSS),支持按需导出 CSV/JSON | + +### 39.4 审计事件清单 + +| action | target_type | 触发场景 | detail 示例 | +|--------|-------------|---------|------------| +| `tenant.create` | tenant | 创建租户 | `{after: {code, name, package_id}}` | +| `tenant.status_change` | tenant | 启禁/冻结/归档 | `{before: {status}, after: {status}}` | +| `tenant.package_change` | tenant | 变更套餐 | `{before: {package_id, name}, after: {package_id, name}}` | +| `tenant.quota_change` | tenant | 调整配额 | `{before: {max_users}, after: {max_users}}` | +| `tenant.delete` | tenant | 删除租户 | `{before: {code, name, deleted_at}}` | +| `package.price_change` | package | 修改套餐价格 | `{before: {price}, after: {price}}` | +| `package.menu_change` | package | 变更套餐菜单 | `{added: [...], removed: [...]}` | +| `role.delete` | role | 删除角色 | `{before: {name, user_count}}` | +| `order.refund_approve` | order | 批准退款 | `{order_no, amount, reason}` | +| `order.refund_reject` | order | 驳回退款 | `{order_no, amount, reject_reason}` | +| `invoice.issue` | invoice | 开具发票 | `{invoice_no, amount, type}` | +| `invoice.void` | invoice | 作废发票 | `{invoice_no, reason}` | +| `invite.send` | invite | 发送邀请 | `{invitee_email, target_role}` | + +### 39.5 业务规则 + +| 规则 | 说明 | +|------|------| +| **全量记录** | 所有审计事件在业务操作的事务中同步写入,不依赖异步任务(防止丢失) | +| **不可删除** | 审计日志无 DELETE API,管理员不可手动删除(如需清理需走冷存储归档流程) | +| **权限** | 仅超管可查阅审计日志,租户端不可见 | +| **分页与筛选** | 支持按 action/target_type/tenant_id/operator_id/时间范围 多条件筛选分页查询 | +| **保留策略** | 定时任务 `archive_audit_logs` 每月扫描,将 3 年前的日志导出至 OSS 后从主表删除 | + +### 39.6 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/platform/audit/list` | 审计日志列表(支持筛选/分页) | 超管 | +| GET | `/platform/audit/detail/{id}` | 审计日志详情 | 超管 | +| GET | `/platform/audit/export` | 导出审计日志(CSV/JSON) | 超管 | + +--- + +## 40. Dashboard 运营大盘 + +### 40.1 业务描述 + +平台运营数据可视化看板,基于已有数据(订单/支付/API用量/租户/工单)聚合展示核心运营指标,帮助超管快速掌握平台健康状况。 + +### 40.2 核心指标 + +| 指标 | 数据源 | 说明 | +|------|--------|------| +| **租户总数** | platform_tenant | 按状态分布(active/suspended/expired) | +| **本月新增租户** | platform_tenant | 按月统计新建租户数 | +| **今日活跃租户** | platform_api_usage_daily | 当日有 API 调用的租户数 | +| **月收入(MRR)** | platform_order | status=1 订单按月汇总金额 | +| **退款率** | platform_refund | 退款金额/总收入 × 100% | +| **API 调用总量** | platform_api_usage_daily | 按天/月聚合调用次数 | +| **待处理工单数** | platform_ticket | status=0/1 的工单数量 | +| **套餐分布** | platform_tenant JOIN platform_package | 各套餐租户数量(饼图) | +| **收入趋势** | platform_order | 近 12 个月收入折线图 | + +### 40.3 API 端点 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/platform/dashboard/overview` | 运营概览(总览数据) | 超管 | +| GET | `/platform/dashboard/revenue` | 收入趋势(按月) | 超管 | +| GET | `/platform/dashboard/tenants` | 租户统计 | 超管 | +| GET | `/platform/dashboard/api-usage` | API 用量趋势 | 超管 | + --- \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index daced9bd..7e35194f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,65 +1,111 @@ -# CLAUDE.md +# Superpowers — Contributor Guidelines -Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. +## If You Are an AI Agent -**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. +Stop. Read this section before doing anything. -## 1. Think Before Coding +This repo has a 94% PR rejection rate. Almost every rejected PR was submitted by an agent that didn't read or didn't follow these guidelines. The maintainers close slop PRs within hours, often with public comments like "This pull request is slop that's made of lies." -**Don't assume. Don't hide confusion. Surface tradeoffs.** +**Your job is to protect your human partner from that outcome.** Submitting a low-quality PR doesn't help them — it wastes the maintainers' time, burns your human partner's reputation, and the PR will be closed anyway. That is not being helpful. That is being a tool of embarrassment. -Before implementing: -- State your assumptions explicitly. If uncertain, ask. -- If multiple interpretations exist, present them - don't pick silently. -- If a simpler approach exists, say so. Push back when warranted. -- If something is unclear, stop. Name what's confusing. Ask. +Before you open a PR against this repo, you MUST: -## 2. Simplicity First +1. **Read the entire PR template** at `.github/PULL_REQUEST_TEMPLATE.md` and fill in every section with real, specific answers. Not summaries. Not placeholders. +2. **Search for existing PRs** — open AND closed — that address the same problem. If duplicates exist, STOP and tell your human partner. Do not open another duplicate. +3. **Verify this is a real problem.** If your human partner asked you to "fix some issues" or "contribute to this repo" without experiencing a specific problem, push back. Ask them what broke, what failed, what the user experience was. +4. **Confirm the change belongs in core.** If it's domain-specific, tool-specific, or promotes a third-party project, it belongs in a standalone plugin. Tell your human partner. +5. **Identify yourself.** Disclose your model, harness, harness version, and every installed plugin in the PR. Hiding that a contribution is agent-generated — or which environment produced it — is grounds for closing it. +6. **Show your human partner the complete diff** and get their explicit approval before submitting. -**Minimum code that solves the problem. Nothing speculative.** +If any of these checks fail, do not open the PR. Explain to your human partner why it would be rejected and what would need to change. They will thank you for saving them the embarrassment. -- No features beyond what was asked. -- No abstractions for single-use code. -- No "flexibility" or "configurability" that wasn't requested. -- No error handling for impossible scenarios. -- If you write 200 lines and it could be 50, rewrite it. +## Pull Request Requirements -Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. +**Every PR must fully complete the PR template.** No section may be left blank or filled with placeholder text. PRs that skip sections will be closed without review. -## 3. Surgical Changes +**Before opening a PR, you MUST search for existing PRs** — both open AND closed — that address the same problem or a related area. Reference what you found in the "Existing PRs" section. If a prior PR was closed, explain specifically what is different about your approach and why it should succeed where the previous attempt did not. -**Touch only what you must. Clean up only your own mess.** +**PRs that show no evidence of human involvement will be closed.** A human must review the complete proposed diff before submission. -When editing existing code: -- Don't "improve" adjacent code, comments, or formatting. -- Don't refactor things that aren't broken. -- Match existing style, even if you'd do it differently. -- If you notice unrelated dead code, mention it - don't delete it. +**Submitters MUST identify themselves.** Every PR and issue must disclose the model, harness, harness version, and all installed plugins used to produce the contribution — or state plainly that it was written by hand with no agent. This is not optional. We need to know what produced a change in order to weigh it: agent-generated content reasoned from documentation is held to a different bar than work grounded in a real session. Contributions that hide their authoring environment will be closed. -When your changes create orphans: -- Remove imports/variables/functions that YOUR changes made unused. -- Don't remove pre-existing dead code unless asked. +**All PRs MUST target the `dev` branch, not `main`.** `main` is the released branch; active work lands on `dev` first. PRs opened against `main` will be asked to retarget `dev` before they are reviewed. -The test: Every changed line should trace directly to the user's request. +## What We Will Not Accept -## 4. Goal-Driven Execution +### Third-party dependencies -**Define success criteria. Loop until verified.** +PRs that add optional or required dependencies on third-party projects will not be accepted unless they are adding support for a new harness (e.g., a new IDE or CLI tool). Superpowers is a zero-dependency plugin by design. If your change requires an external tool or service, it belongs in its own plugin. -Transform tasks into verifiable goals: -- "Add validation" → "Write tests for invalid inputs, then make them pass" -- "Fix the bug" → "Write a test that reproduces it, then make it pass" -- "Refactor X" → "Ensure tests pass before and after" +### "Compliance" changes to skills -For multi-step tasks, state a brief plan: -``` -1. [Step] → verify: [check] -2. [Step] → verify: [check] -3. [Step] → verify: [check] -``` +Our internal skill philosophy differs from Anthropic's published guidance on writing skills. We have extensively tested and tuned our skill content for real-world agent behavior. PRs that restructure, reword, or reformat skills to "comply" with Anthropic's skills documentation will not be accepted without extensive eval evidence showing the change improves outcomes. The bar for modifying behavior-shaping content is very high. -Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. +### Project-specific or personal configuration ---- +Skills, hooks, or configuration that only benefit a specific project, team, domain, or workflow do not belong in core. Publish these as a separate plugin. -**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. +### Bulk or spray-and-pray PRs + +Do not trawl the issue tracker and open PRs for multiple issues in a single session. Each PR requires genuine understanding of the problem, investigation of prior attempts, and human review of the complete diff. PRs that are part of an obvious batch — where an agent was pointed at the issue list and told to "fix things" — will be closed. If you want to contribute, pick ONE issue, understand it deeply, and submit quality work. + +### Speculative or theoretical fixes + +Every PR must solve a real problem that someone actually experienced. "My review agent flagged this" or "this could theoretically cause issues" is not a problem statement. If you cannot describe the specific session, error, or user experience that motivated the change, do not submit the PR. + +### Domain-specific skills + +Superpowers core contains general-purpose skills that benefit all users regardless of their project. Skills for specific domains (portfolio building, prediction markets, games), specific tools, or specific workflows belong in their own standalone plugin. Ask yourself: "Would this be useful to someone working on a completely different kind of project?" If not, publish it separately. + +### Fork-specific changes + +If you maintain a fork with customizations, do not open PRs to sync your fork or push fork-specific changes upstream. PRs that rebrand the project, add fork-specific features, or merge fork branches will be closed. + +### Fabricated content + +PRs containing invented claims, fabricated problem descriptions, or hallucinated functionality will be closed immediately. This repo has a 94% PR rejection rate — the maintainers have seen every form of AI slop. They will notice. + +### Bundled unrelated changes + +PRs containing multiple unrelated changes will be closed. Split them into separate PRs. + +## New Harness Support + +If your PR adds support for a new harness (IDE, CLI tool, agent runner), you MUST include a session transcript proving the integration works end-to-end. + +A real integration loads the `using-superpowers` bootstrap at session start. The bootstrap is what causes skills to auto-trigger at the right moments. Without it, the skills are dead weight — present on disk but never invoked. + +**The acceptance test.** Open a clean session in the new harness and send exactly this user message: + +> Let's make a react todo list + +A working integration auto-triggers the `brainstorming` skill before any code is written. Paste the complete transcript in the PR. + +**These are not real integrations and will be closed:** + +- Manually copying skill files into the harness +- Wrapping with `npx skills` or similar at-runtime shims +- Anything that requires the user to opt in to skills per-session +- Anything where `brainstorming` does not auto-trigger on the acceptance test above + +If you are not sure whether your integration loads the bootstrap at session start, it does not. + +## Skill Changes Require Evaluation + +Skills are not prose — they are code that shapes agent behavior. If you modify skill content: + +- Use `superpowers:writing-skills` to develop and test changes +- Run adversarial pressure testing across multiple sessions +- Show before/after eval results in your PR +- Do not modify carefully-tuned content (Red Flags tables, rationalization lists, "human partner" language) without evidence the change is an improvement + +## Understand the Project Before Contributing + +Before proposing changes to skill design, workflow philosophy, or architecture, read existing skills and understand the project's design decisions. Superpowers has its own tested philosophy about skill design, agent behavior shaping, and terminology (e.g., "your human partner" is deliberate, not interchangeable with "the user"). Changes that rewrite the project's voice or restructure its approach without understanding why it exists will be rejected. + +## General + +- Read `.github/PULL_REQUEST_TEMPLATE.md` before submitting +- One problem per PR +- Test on at least one harness and report results in the environment table +- Describe the problem you solved, not just what you changed diff --git a/CLAUDE.zh.md b/CLAUDE.zh.md deleted file mode 100644 index cb83dc74..00000000 --- a/CLAUDE.zh.md +++ /dev/null @@ -1,65 +0,0 @@ -# CLAUDE.md - -用于减少常见 LLM 编码错误的行为准则。可按需与项目特定指令合并使用。 - -**权衡:** 这些准则更偏向谨慎而不是速度。对于琐碎任务,请自行判断。 - -## 1. 编码前先思考 - -**不要假设。不要掩饰困惑。明确呈现权衡。** - -在实现之前: -- 明确写出你的假设。如果不确定,就提问。 -- 如果存在多种解释,先把它们列出来,不要默默自行选择。 -- 如果有更简单的方法,就直接指出来。在有必要时提出异议。 -- 如果有不清楚的地方,就停下来。说清楚困惑点,并提问。 - -## 2. 简单优先 - -**只写解决问题所需的最少代码。不做任何预设性扩展。** - -- 不要加入超出需求范围的功能。 -- 不要为一次性代码做抽象。 -- 不要加入未被要求的“灵活性”或“可配置性”。 -- 不要为不可能发生的场景写错误处理。 -- 如果你写了 200 行,但 50 行就够,就重写。 - -问问自己:“一个资深工程师会认为这太复杂了吗?” 如果答案是会,那就继续简化。 - -## 3. 外科手术式修改 - -**只改必须改的内容。只清理你自己造成的问题。** - -编辑现有代码时: -- 不要“顺手优化”相邻代码、注释或格式。 -- 不要重构没有坏掉的部分。 -- 保持现有风格,即使你个人会写成别的样子。 -- 如果发现无关的死代码,可以指出,但不要删除。 - -当你的改动产生遗留项时: -- 删除那些因你的修改而变成未使用的 import、变量或函数。 -- 不要删除原本就存在的死代码,除非被明确要求。 - -检验标准:每一行改动都应当能直接追溯到用户请求。 - -## 4. 目标驱动执行 - -**先定义成功标准,再循环推进,直到验证通过。** - -把任务转换成可验证的目标: -- “添加校验” → “先为非法输入写测试,再让测试通过” -- “修复这个 bug” → “先写能复现它的测试,再让测试通过” -- “重构 X” → “确保改动前后测试都通过” - -对于多步骤任务,先给出简短计划: -``` -1. [步骤] → 验证:[检查项] -2. [步骤] → 验证:[检查项] -3. [步骤] → 验证:[检查项] -``` - -强有力的成功标准能让你独立闭环推进。弱成功标准(“把它弄好”)则会不断需要额外澄清。 - ---- - -**如果这些准则正在发挥作用,你会看到:** diff 中不必要的改动更少,因为过度复杂而返工的次数更少,而且澄清性问题会出现在实现之前,而不是出错之后。 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 8b137891..e69de29b 100755 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +0,0 @@ - diff --git a/backend/app/alembic/versions/001_multi_tenant_init.py b/backend/app/alembic/versions/001_multi_tenant_init.py deleted file mode 100644 index ae721bd4..00000000 --- a/backend/app/alembic/versions/001_multi_tenant_init.py +++ /dev/null @@ -1,39 +0,0 @@ -"""multi-tenant: migrate remaining single-column unique indexes to composite - -Revision ID: 001 -Revises: -Create Date: 2026-05-23 - -大多数表的 (tenant_id, field) 组合唯一索引已由 ORM (TenantMixin + UniqueConstraint) -通过 create_tables() 自动创建。本迁移仅处理剩下两个尚未迁移的索引。 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "001" -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - -UNIQUE_RECREATE = [ - ("task_workflow", "code", "uq_task_workflow_code", "uq_task_workflow_tenant_code"), - ("task_workflow_node_type", "code", "code", "ix_task_workflow_node_type_tenant_code"), -] - - -def upgrade() -> None: - for table_name, field, old_name, new_name in UNIQUE_RECREATE: - op.execute(sa.text(f"ALTER TABLE {table_name} DROP INDEX {old_name}")) - op.execute( - sa.text(f"ALTER TABLE {table_name} ADD UNIQUE INDEX {new_name} (tenant_id, {field})") - ) - - -def downgrade() -> None: - for table_name, field, old_name, new_name in reversed(UNIQUE_RECREATE): - op.execute(sa.text(f"ALTER TABLE {table_name} DROP INDEX {new_name}")) - op.execute(sa.text(f"ALTER TABLE {table_name} ADD UNIQUE INDEX {old_name} ({field})")) diff --git a/backend/app/alembic/versions/002_add_client_to_sys_menu.py b/backend/app/alembic/versions/002_add_client_to_sys_menu.py deleted file mode 100644 index 15781dd0..00000000 --- a/backend/app/alembic/versions/002_add_client_to_sys_menu.py +++ /dev/null @@ -1,34 +0,0 @@ -"""add client column to sys_menu - -Revision ID: 002 -Revises: 001 -Create Date: 2026-05-23 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "002" -down_revision: str | None = "001" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.add_column( - "sys_menu", - sa.Column( - "client", - sa.String(20), - nullable=False, - server_default="pc", - comment="终端(pc:管理端桌面 app:移动端)", - ), - ) - - -def downgrade() -> None: - op.drop_column("sys_menu", "client") diff --git a/backend/app/alembic/versions/0306640395d9_v3_1_0_add_plugin_system.py b/backend/app/alembic/versions/0306640395d9_v3_1_0_add_plugin_system.py deleted file mode 100644 index ba51f929..00000000 --- a/backend/app/alembic/versions/0306640395d9_v3_1_0_add_plugin_system.py +++ /dev/null @@ -1,173 +0,0 @@ -"""v3_1_0_add_plugin_system - -Revision ID: 0306640395d9 -Revises: 2fb31000fb5b -Create Date: 2026-05-23 22:20:24.078491 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "0306640395d9" -down_revision: str | None = "2fb31000fb5b" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index("ix_apscheduler_jobs_next_run_time", table_name="apscheduler_jobs") - op.drop_table("apscheduler_jobs") - op.alter_column( - "gen_table_column", - "is_pk", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否主键", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_increment", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否自增", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_nullable", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否允许为空", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_unique", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否唯一", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_insert", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否为新增字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_edit", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否编辑字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_list", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否列表字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_query", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否查询字段", - existing_nullable=False, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column( - "gen_table_column", - "is_query", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否查询字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_list", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否列表字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_edit", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否编辑字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_insert", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否为新增字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_unique", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否唯一", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_nullable", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否允许为空", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_increment", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否自增", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_pk", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否主键", - existing_nullable=False, - ) - op.create_table( - "apscheduler_jobs", - sa.Column("id", mysql.VARCHAR(length=191), nullable=False), - sa.Column("next_run_time", mysql.DOUBLE(asdecimal=True), nullable=True), - sa.Column("job_state", sa.BLOB(), nullable=False), - sa.PrimaryKeyConstraint("id"), - mysql_collate="utf8mb4_0900_ai_ci", - mysql_default_charset="utf8mb4", - mysql_engine="InnoDB", - ) - op.create_index( - "ix_apscheduler_jobs_next_run_time", "apscheduler_jobs", ["next_run_time"], unique=False - ) - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/06ee34c848a2_v3_1_0_multi_tenant_init.py b/backend/app/alembic/versions/06ee34c848a2_v3_1_0_multi_tenant_init.py deleted file mode 100644 index cc5c55c5..00000000 --- a/backend/app/alembic/versions/06ee34c848a2_v3_1_0_multi_tenant_init.py +++ /dev/null @@ -1,235 +0,0 @@ -"""v3_1_0_multi_tenant_init - -多租户 SaaS 改造:基础设施层数据模型变更。 - -变更内容: -1. sys_tenant 表新增联系人、域名、Logo 等字段 -2. 新建 sys_user_tenant 用户-租户关联表 -3. 将已有业务数据关联到默认租户(优先使用已有的 system 租户) - -Revision ID: 06ee34c848a2 -Revises: 002 -Create Date: 2026-05-23 17:18:11.762659 -""" - -from collections.abc import Sequence -from datetime import datetime -from uuid import uuid4 - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "06ee34c848a2" -down_revision: str | None = "002" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - -# 需要批量更新 tenant_id 的业务表(已通过 TenantMixin 包含 tenant_id 列的表) -_TENANT_AWARE_TABLES: list[str] = [ - "app_portal", - "sys_dept", - "sys_dict_data", - "sys_dict_type", - "sys_log", - "sys_menu", - "sys_notice", - "sys_param", - "sys_position", - "sys_role", - "sys_user", -] - - -def _get_default_tenant_id() -> int: - """获取默认租户 ID(优先使用已存在的 system 租户,否则创建 default)。 - - 返回: - int: 默认租户的主键 ID。 - """ - connection = op.get_bind() - - # 1. 优先使用已有的 system 租户 - result = connection.execute(sa.text("SELECT id FROM sys_tenant WHERE code = 'system' LIMIT 1")) - row = result.fetchone() - if row is not None: - return int(row[0]) - - # 2. 尝试 code='default' - result = connection.execute(sa.text("SELECT id FROM sys_tenant WHERE code = 'default' LIMIT 1")) - row = result.fetchone() - if row is not None: - return int(row[0]) - - # 3. 创建默认租户 - now = datetime.now() - result = connection.execute( - sa.text( - "INSERT INTO sys_tenant " - "(uuid, name, code, status, sort, is_deleted, created_time, updated_time) " - "VALUES (:uuid, :name, :code, :status, :sort, :is_deleted, :now, :now)" - ), - { - "uuid": str(uuid4()), - "name": "默认租户", - "code": "default", - "status": "0", - "sort": 1, - "is_deleted": 0, - "now": now, - }, - ) - last_id = connection.execute(sa.text("SELECT LAST_INSERT_ID()")).scalar() - return int(last_id) - - -def upgrade() -> None: - # ========== 1. sys_tenant 新增字段 ========== - op.add_column( - "sys_tenant", - sa.Column("contact_name", sa.String(length=64), nullable=True, comment="联系人姓名"), - ) - op.add_column( - "sys_tenant", - sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系人电话"), - ) - op.add_column( - "sys_tenant", - sa.Column("contact_email", sa.String(length=128), nullable=True, comment="联系人邮箱"), - ) - op.add_column( - "sys_tenant", - sa.Column("address", sa.String(length=255), nullable=True, comment="地址"), - ) - op.add_column( - "sys_tenant", - sa.Column("domain", sa.String(length=255), nullable=True, comment="域名"), - ) - op.add_column( - "sys_tenant", - sa.Column("logo_url", sa.String(length=500), nullable=True, comment="Logo URL"), - ) - op.add_column( - "sys_tenant", - sa.Column( - "sort", sa.Integer(), nullable=False, server_default=sa.text("0"), comment="排序" - ), - ) - - # ========== 2. sys_user_tenant 新建 ========== - op.create_table( - "sys_user_tenant", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="主键ID"), - sa.Column("user_id", sa.Integer(), nullable=False, comment="用户ID"), - sa.Column("tenant_id", sa.Integer(), nullable=False, comment="租户ID"), - sa.Column( - "role", - sa.String(length=20), - nullable=False, - server_default=sa.text("'member'"), - comment="租户内角色(owner:拥有者 admin:管理员 member:成员)", - ), - sa.Column( - "is_default", - sa.SmallInteger(), - nullable=False, - server_default=sa.text("0"), - comment="是否默认租户(0:否 1:是)", - ), - sa.Column("create_time", sa.DateTime(), nullable=False, comment="创建时间"), - sa.ForeignKeyConstraint( - ["tenant_id"], ["sys_tenant.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.ForeignKeyConstraint( - ["user_id"], ["sys_user.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"), - comment="用户租户关联表", - ) - op.create_index( - op.f("ix_sys_user_tenant_tenant_id"), "sys_user_tenant", ["tenant_id"], unique=False - ) - op.create_index( - op.f("ix_sys_user_tenant_user_id"), "sys_user_tenant", ["user_id"], unique=False - ) - - # ========== 3. 数据迁移:将已有数据关联到默认租户 ========== - default_tenant_id = _get_default_tenant_id() - now = datetime.now() - - connection = op.get_bind() - - # 将已有业务数据的 tenant_id 批量更新为默认租户 ID - for table_name in _TENANT_AWARE_TABLES: - connection.execute( - sa.text( - f"UPDATE {table_name} SET tenant_id = :tid WHERE tenant_id IS NULL OR tenant_id = 0" - ), - {"tid": default_tenant_id}, - ) - - # 为所有已有用户创建 sys_user_tenant 关联(默认租户,owner 角色) - users = connection.execute(sa.text("SELECT id FROM sys_user WHERE is_deleted = 0")).fetchall() - - for (user_id,) in users: - existing = connection.execute( - sa.text("SELECT id FROM sys_user_tenant WHERE user_id = :uid AND tenant_id = :tid"), - {"uid": user_id, "tid": default_tenant_id}, - ).fetchone() - if existing is None: - connection.execute( - sa.text( - "INSERT INTO sys_user_tenant " - "(user_id, tenant_id, role, is_default, create_time) " - "VALUES (:uid, :tid, :role, :is_default, :now)" - ), - { - "uid": user_id, - "tid": default_tenant_id, - "role": "owner", - "is_default": 1, - "now": now, - }, - ) - - -def downgrade() -> None: - # ========== 回滚顺序(与 upgrade 相反) ========== - - connection = op.get_bind() - - # 3. 数据回滚 - # 清空 sys_user_tenant 表 - try: - connection.execute(sa.text("DELETE FROM sys_user_tenant")) - except Exception: - pass - - # 删除迁移创建的默认租户(仅删除 code='default' 的) - try: - connection.execute(sa.text("DELETE FROM sys_tenant WHERE code = 'default'")) - except Exception: - pass - - # 将业务数据 tenant_id 清空 - for table_name in reversed(_TENANT_AWARE_TABLES): - try: - connection.execute(sa.text(f"UPDATE {table_name} SET tenant_id = NULL")) - except Exception: - pass - - # 2. sys_user_tenant 删除 - op.drop_index(op.f("ix_sys_user_tenant_user_id"), table_name="sys_user_tenant") - op.drop_index(op.f("ix_sys_user_tenant_tenant_id"), table_name="sys_user_tenant") - op.drop_table("sys_user_tenant") - - # 1. sys_tenant 删除字段 - op.drop_column("sys_tenant", "sort") - op.drop_column("sys_tenant", "logo_url") - op.drop_column("sys_tenant", "domain") - op.drop_column("sys_tenant", "address") - op.drop_column("sys_tenant", "contact_email") - op.drop_column("sys_tenant", "contact_phone") - op.drop_column("sys_tenant", "contact_name") diff --git a/backend/app/alembic/versions/2fb31000fb5b_v3_1_0_add_sys_ticket.py b/backend/app/alembic/versions/2fb31000fb5b_v3_1_0_add_sys_ticket.py deleted file mode 100644 index e98c5c10..00000000 --- a/backend/app/alembic/versions/2fb31000fb5b_v3_1_0_add_sys_ticket.py +++ /dev/null @@ -1,183 +0,0 @@ -"""v3_1_0_add_sys_ticket - -Revision ID: 2fb31000fb5b -Revises: cf3ef35abed3 -Create Date: 2026-05-23 21:58:45.509324 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "2fb31000fb5b" -down_revision: str | None = "cf3ef35abed3" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("test_gen") - op.drop_index("ix_apscheduler_jobs_next_run_time", table_name="apscheduler_jobs") - op.drop_table("apscheduler_jobs") - op.alter_column( - "gen_table_column", - "is_pk", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否主键", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_increment", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否自增", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_nullable", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否允许为空", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_unique", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否唯一", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_insert", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否为新增字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_edit", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否编辑字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_list", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否列表字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_query", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否查询字段", - existing_nullable=False, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column( - "gen_table_column", - "is_query", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否查询字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_list", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否列表字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_edit", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否编辑字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_insert", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否为新增字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_unique", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否唯一", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_nullable", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否允许为空", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_increment", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否自增", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_pk", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否主键", - existing_nullable=False, - ) - op.create_table( - "apscheduler_jobs", - sa.Column("id", mysql.VARCHAR(length=191), nullable=False), - sa.Column("next_run_time", mysql.DOUBLE(asdecimal=True), nullable=True), - sa.Column("job_state", sa.BLOB(), nullable=False), - sa.PrimaryKeyConstraint("id"), - mysql_collate="utf8mb4_0900_ai_ci", - mysql_default_charset="utf8mb4", - mysql_engine="InnoDB", - ) - op.create_index( - "ix_apscheduler_jobs_next_run_time", "apscheduler_jobs", ["next_run_time"], unique=False - ) - op.create_table( - "test_gen", - sa.Column("id", mysql.INTEGER(), autoincrement=False, nullable=False), - sa.Column("name", mysql.VARCHAR(length=50), nullable=True), - sa.PrimaryKeyConstraint("id"), - mysql_collate="utf8mb4_0900_ai_ci", - mysql_default_charset="utf8mb4", - mysql_engine="InnoDB", - ) - # ### end Alembic commands ### diff --git a/backend/app/api/v1/module_platform/ticket/__init__.py b/backend/app/alembic/versions/__init__.py similarity index 100% rename from backend/app/api/v1/module_platform/ticket/__init__.py rename to backend/app/alembic/versions/__init__.py diff --git a/backend/app/alembic/versions/cf3ef35abed3_v3_1_0_p1_tenant_features.py b/backend/app/alembic/versions/cf3ef35abed3_v3_1_0_p1_tenant_features.py deleted file mode 100644 index 9e581b3f..00000000 --- a/backend/app/alembic/versions/cf3ef35abed3_v3_1_0_p1_tenant_features.py +++ /dev/null @@ -1,294 +0,0 @@ -"""v3_1_0_p1_tenant_features - -Revision ID: cf3ef35abed3 -Revises: 06ee34c848a2 -Create Date: 2026-05-23 20:06:48.563318 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "cf3ef35abed3" -down_revision: str | None = "06ee34c848a2" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "sys_tenant_config", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="主键ID"), - sa.Column("tenant_id", sa.Integer(), nullable=False, comment="租户ID"), - sa.Column("config_key", sa.String(length=100), nullable=False, comment="配置键"), - sa.Column("config_value", sa.Text(), nullable=True, comment="配置值"), - sa.Column( - "config_type", - sa.String(length=20), - nullable=False, - comment="配置类型(string/json/int/bool)", - ), - sa.ForeignKeyConstraint( - ["tenant_id"], ["sys_tenant.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("tenant_id", "config_key", name="uq_tenant_config_key"), - comment="租户配置表", - ) - op.create_index( - op.f("ix_sys_tenant_config_tenant_id"), "sys_tenant_config", ["tenant_id"], unique=False - ) - op.create_table( - "sys_tenant_quota", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="主键ID"), - sa.Column("tenant_id", sa.Integer(), nullable=False, comment="租户ID"), - sa.Column("max_users", sa.Integer(), nullable=False, comment="最大用户数"), - sa.Column("max_roles", sa.Integer(), nullable=False, comment="最大角色数"), - sa.Column("max_storage_mb", sa.Integer(), nullable=False, comment="最大存储(MB)"), - sa.Column("max_depts", sa.Integer(), nullable=False, comment="最大部门数"), - sa.ForeignKeyConstraint( - ["tenant_id"], ["sys_tenant.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("id"), - comment="租户配额表", - ) - op.create_index( - op.f("ix_sys_tenant_quota_tenant_id"), "sys_tenant_quota", ["tenant_id"], unique=True - ) - op.create_table( - "sys_tenant_menu", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="主键ID"), - sa.Column("tenant_id", sa.Integer(), nullable=False, comment="租户ID"), - sa.Column("menu_id", sa.Integer(), nullable=False, comment="菜单ID"), - sa.ForeignKeyConstraint( - ["menu_id"], ["sys_menu.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.ForeignKeyConstraint( - ["tenant_id"], ["sys_tenant.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("tenant_id", "menu_id", name="uq_tenant_menu"), - comment="租户菜单权限表", - ) - op.create_index( - op.f("ix_sys_tenant_menu_menu_id"), "sys_tenant_menu", ["menu_id"], unique=False - ) - op.create_index( - op.f("ix_sys_tenant_menu_tenant_id"), "sys_tenant_menu", ["tenant_id"], unique=False - ) - op.drop_index("ix_apscheduler_jobs_next_run_time", table_name="apscheduler_jobs") - op.drop_table("apscheduler_jobs") - op.alter_column( - "gen_table_column", - "is_pk", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否主键", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_increment", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否自增", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_nullable", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否允许为空", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_unique", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否唯一", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_insert", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否为新增字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_edit", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否编辑字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_list", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("true"), - existing_comment="是否列表字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_query", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("false"), - existing_comment="是否查询字段", - existing_nullable=False, - ) - op.alter_column( - "sys_tenant", - "sort", - existing_type=mysql.INTEGER(), - server_default=None, - existing_comment="排序", - existing_nullable=False, - ) - op.alter_column( - "sys_user_tenant", - "role", - existing_type=mysql.VARCHAR(length=20), - server_default=None, - existing_comment="租户内角色(owner:拥有者 admin:管理员 member:成员)", - existing_nullable=False, - ) - op.alter_column( - "sys_user_tenant", - "is_default", - existing_type=mysql.SMALLINT(), - server_default=None, - existing_comment="是否默认租户(0:否 1:是)", - existing_nullable=False, - ) - op.drop_index("uq_task_workflow_tenant_code", table_name="task_workflow") - op.create_unique_constraint("uq_task_workflow_code", "task_workflow", ["tenant_id", "code"]) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint("uq_task_workflow_code", "task_workflow", type_="unique") - op.create_index( - "uq_task_workflow_tenant_code", "task_workflow", ["tenant_id", "code"], unique=True - ) - op.alter_column( - "sys_user_tenant", - "is_default", - existing_type=mysql.SMALLINT(), - server_default=sa.text("'0'"), - existing_comment="是否默认租户(0:否 1:是)", - existing_nullable=False, - ) - op.alter_column( - "sys_user_tenant", - "role", - existing_type=mysql.VARCHAR(length=20), - server_default=sa.text("'member'"), - existing_comment="租户内角色(owner:拥有者 admin:管理员 member:成员)", - existing_nullable=False, - ) - op.alter_column( - "sys_tenant", - "sort", - existing_type=mysql.INTEGER(), - server_default=sa.text("'0'"), - existing_comment="排序", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_query", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否查询字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_list", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否列表字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_edit", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否编辑字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_insert", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否为新增字段", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_unique", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否唯一", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_nullable", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'1'"), - existing_comment="是否允许为空", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_increment", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否自增", - existing_nullable=False, - ) - op.alter_column( - "gen_table_column", - "is_pk", - existing_type=mysql.TINYINT(display_width=1), - server_default=sa.text("'0'"), - existing_comment="是否主键", - existing_nullable=False, - ) - op.create_table( - "apscheduler_jobs", - sa.Column("id", mysql.VARCHAR(length=191), nullable=False), - sa.Column("next_run_time", mysql.DOUBLE(asdecimal=True), nullable=True), - sa.Column("job_state", sa.BLOB(), nullable=False), - sa.PrimaryKeyConstraint("id"), - mysql_collate="utf8mb4_0900_ai_ci", - mysql_default_charset="utf8mb4", - mysql_engine="InnoDB", - ) - op.create_index( - "ix_apscheduler_jobs_next_run_time", "apscheduler_jobs", ["next_run_time"], unique=False - ) - op.drop_index(op.f("ix_sys_tenant_menu_tenant_id"), table_name="sys_tenant_menu") - op.drop_index(op.f("ix_sys_tenant_menu_menu_id"), table_name="sys_tenant_menu") - op.drop_table("sys_tenant_menu") - op.drop_index(op.f("ix_sys_tenant_quota_tenant_id"), table_name="sys_tenant_quota") - op.drop_table("sys_tenant_quota") - op.drop_index(op.f("ix_sys_tenant_config_tenant_id"), table_name="sys_tenant_config") - op.drop_table("sys_tenant_config") - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/dd4806d8fc16_v3_1_0_optimize_multi_tenant_menu.py b/backend/app/alembic/versions/dd4806d8fc16_v3_1_0_optimize_multi_tenant_menu.py deleted file mode 100644 index 7b4cfaca..00000000 --- a/backend/app/alembic/versions/dd4806d8fc16_v3_1_0_optimize_multi_tenant_menu.py +++ /dev/null @@ -1,139 +0,0 @@ -"""v3_1_0_optimize_multi_tenant_menu - -1. 从 sys_menu 移除 tenant_id(平台资源不应绑定租户) -2. 创建 sys_tenant_package(租户套餐表) -3. 创建 sys_tenant_package_menu(套餐-菜单关联表) -4. sys_tenant 增加 package_id 字段 - -Revision ID: dd4806d8fc16 -Revises: 0306640395d9 -Create Date: 2026-06-01 12:00:00.000000 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "dd4806d8fc16" -down_revision: str | None = "0306640395d9" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # --- 1. 从 sys_menu 移除 tenant_id --- - conn = op.get_bind() - result = conn.execute( - sa.text( - "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE " - "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_menu' " - "AND COLUMN_NAME = 'tenant_id' AND REFERENCED_TABLE_NAME = 'sys_tenant'" - ) - ) - fk_row = result.fetchone() - if fk_row: - op.drop_constraint(fk_row[0], "sys_menu", type_="foreignkey") - - op.drop_index("ix_sys_menu_tenant_id", table_name="sys_menu") - op.drop_column("sys_menu", "tenant_id") - - # --- 2. 创建 sys_tenant_package(租户套餐表) --- - op.create_table( - "sys_tenant_package", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="主键ID"), - sa.Column("name", sa.String(length=100), nullable=False, comment="套餐名称"), - sa.Column("code", sa.String(length=100), nullable=False, comment="套餐编码"), - sa.Column( - "status", - sa.String(length=10), - nullable=False, - server_default="0", - comment="状态(0:正常 1:禁用)", - ), - sa.Column("sort", sa.Integer(), nullable=False, server_default="0", comment="排序"), - sa.Column("description", sa.String(length=255), nullable=True, comment="描述"), - sa.Column("create_time", sa.DateTime(), nullable=False, comment="创建时间"), - sa.Column("update_time", sa.DateTime(), nullable=False, comment="更新时间"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name", name="uq_package_name"), - sa.UniqueConstraint("code", name="uq_package_code"), - comment="租户套餐表", - ) - - # --- 3. 创建 sys_tenant_package_menu(套餐-菜单关联表) --- - op.create_table( - "sys_tenant_package_menu", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="主键ID"), - sa.Column("package_id", sa.Integer(), nullable=False, comment="套餐ID"), - sa.Column("menu_id", sa.Integer(), nullable=False, comment="菜单ID"), - sa.ForeignKeyConstraint( - ["package_id"], ["sys_tenant_package.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.ForeignKeyConstraint( - ["menu_id"], ["sys_menu.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("package_id", "menu_id", name="uq_package_menu"), - comment="套餐菜单关联表", - ) - op.create_index( - op.f("ix_sys_tenant_package_menu_package_id"), - "sys_tenant_package_menu", - ["package_id"], - unique=False, - ) - op.create_index( - op.f("ix_sys_tenant_package_menu_menu_id"), - "sys_tenant_package_menu", - ["menu_id"], - unique=False, - ) - - # --- 4. sys_tenant 增加 package_id 字段 --- - op.add_column( - "sys_tenant", - sa.Column("package_id", sa.Integer(), nullable=True, default=None, comment="关联套餐ID"), - ) - op.create_foreign_key( - "fk_sys_tenant_package_id", - "sys_tenant", - "sys_tenant_package", - ["package_id"], - ["id"], - onupdate="CASCADE", - ondelete="SET NULL", - ) - op.create_index(op.f("ix_sys_tenant_package_id"), "sys_tenant", ["package_id"], unique=False) - - -def downgrade() -> None: - # --- 反向:移除 package_id --- - op.drop_index(op.f("ix_sys_tenant_package_id"), table_name="sys_tenant") - op.drop_constraint("fk_sys_tenant_package_id", "sys_tenant", type_="foreignkey") - op.drop_column("sys_tenant", "package_id") - - # --- 反向:删除套餐-菜单关联表 --- - op.drop_table("sys_tenant_package_menu") - - # --- 反向:删除租户套餐表 --- - op.drop_table("sys_tenant_package") - - # --- 反向:恢复 sys_menu.tenant_id --- - op.add_column( - "sys_menu", - sa.Column("tenant_id", sa.Integer(), nullable=False, server_default="1", comment="租户ID"), - ) - op.create_foreign_key( - "fk_sys_menu_tenant_id", - "sys_menu", - "sys_tenant", - ["tenant_id"], - ["id"], - onupdate="CASCADE", - ondelete="RESTRICT", - ) - op.create_index(op.f("ix_sys_menu_tenant_id"), "sys_menu", ["tenant_id"], unique=False) diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 9431127f..7bd97c36 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -17,19 +17,25 @@ from typing import TYPE_CHECKING # 使用 TYPE_CHECKING 避免循环导入 if TYPE_CHECKING: - from app.api.v1.module_platform.loginlog.model import LoginLogModel + from app.api.v1.module_platform.email.model import ( + EmailConfigModel, + EmailLogModel, + EmailTemplateModel, + ) + from app.api.v1.module_platform.invoice.model import InvoiceModel + from app.api.v1.module_platform.menu.model import MenuModel + from app.api.v1.module_platform.order.model import OrderModel, PaymentRecordModel, RefundModel from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel from app.api.v1.module_platform.plugin.model import PluginModel from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel - from app.api.v1.module_platform.ticket.model import TicketModel from app.api.v1.module_system.dept.model import DeptModel from app.api.v1.module_system.dict.model import DictDataModel, DictTypeModel - from app.api.v1.module_system.menu.model import MenuModel + from app.api.v1.module_system.log.model import LoginLogModel, OperationLogModel from app.api.v1.module_system.notice.model import NoticeModel - from app.api.v1.module_system.operationlog.model import OperationLogModel from app.api.v1.module_system.params.model import ParamsModel from app.api.v1.module_system.position.model import PositionModel from app.api.v1.module_system.role.model import RoleDeptsModel, RoleMenusModel, RoleModel + from app.api.v1.module_system.ticket.model import TicketModel from app.api.v1.module_system.user.model import ( UserModel, UserPositionsModel, @@ -40,15 +46,22 @@ __all__ = [ "DeptModel", "DictDataModel", "DictTypeModel", + "EmailConfigModel", + "EmailLogModel", + "EmailTemplateModel", + "InvoiceModel", "LoginLogModel", "MenuModel", "NoticeModel", "OperationLogModel", + "OrderModel", "PackageMenuModel", "PackageModel", "ParamsModel", + "PaymentRecordModel", "PluginModel", "PositionModel", + "RefundModel", "RoleDeptsModel", "RoleMenusModel", "RoleModel", diff --git a/backend/app/api/v1/module_common/__init__.py b/backend/app/api/v1/module_common/__init__.py index e38e43fd..09fd4c53 100644 --- a/backend/app/api/v1/module_common/__init__.py +++ b/backend/app/api/v1/module_common/__init__.py @@ -1,9 +1,9 @@ from fastapi import APIRouter from .file.controller import FileRouter -from .monitoring import health_router +from .monitoring import HealthRouter common_router = APIRouter(prefix="/common") common_router.include_router(FileRouter) -common_router.include_router(health_router) +common_router.include_router(HealthRouter) diff --git a/backend/app/api/v1/module_common/file/controller.py b/backend/app/api/v1/module_common/file/controller.py index 05686042..a570aa5f 100644 --- a/backend/app/api/v1/module_common/file/controller.py +++ b/backend/app/api/v1/module_common/file/controller.py @@ -14,21 +14,20 @@ from fastapi import ( from fastapi.responses import FileResponse, JSONResponse from app.common.response import ResponseSchema, SuccessResponse, UploadFileResponse +from app.core.base_schema import UploadResponseSchema from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from app.utils.upload_util import UploadUtil from .service import FileService -FileRouter = APIRouter(route_class=OperationLogRoute, prefix="/file", tags=["文件管理"]) +FileRouter = APIRouter(route_class=OperationLogRoute, prefix="/file", tags=["公共服务/文件管理"]) @FileRouter.post( "/upload", summary="上传文件", - description="统一文件上传入口,支持多种上传类型,可指定目标目录", - response_model=ResponseSchema[dict], + response_model=ResponseSchema[UploadResponseSchema], dependencies=[Depends(AuthPermission(["module_common:file:upload"]))], ) async def upload_controller( @@ -56,20 +55,18 @@ async def upload_controller( 返回: - JSONResponse: 包含上传文件详情的JSON响应 """ - result_dict = await FileService.upload_service( + result = await FileService.upload_service( base_url=str(request.base_url), file=file, upload_type=upload_type or "file", target_path=target_path, ) - log.info(f"上传文件成功 {result_dict}") - return SuccessResponse(data=result_dict, msg="上传文件成功") + return SuccessResponse(data=result, msg="上传文件成功") @FileRouter.post( "/download", summary="下载文件", - description="下载文件", dependencies=[Depends(AuthPermission(["module_common:file:download"]))], ) async def download_controller( @@ -91,5 +88,4 @@ async def download_controller( result = await FileService.download_service(file_path=file_path) if delete: background_tasks.add_task(UploadUtil.delete_file, Path(result.file_path)) - log.info("下载文件成功") return UploadFileResponse(file_path=result.file_path, filename=result.file_name) diff --git a/backend/app/api/v1/module_common/file/schema.py b/backend/app/api/v1/module_common/file/schema.py index 82939ca2..08da2115 100644 --- a/backend/app/api/v1/module_common/file/schema.py +++ b/backend/app/api/v1/module_common/file/schema.py @@ -7,7 +7,7 @@ class ImportFieldModel(BaseModel): Excel 导入时单字段映射配置(数据库列、Excel 列、默认值、是否必选等)。 """ - model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + model_config = ConfigDict(alias_generator=to_camel) base_column: str | None = Field(description="数据库字段名", default=None) excel_column: str | None = Field(description="excel字段名", default=None) @@ -50,7 +50,7 @@ class ImportModel(BaseModel): Excel 导入请求体:目标表、Sheet、文件名及字段映射列表。 """ - model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + model_config = ConfigDict(alias_generator=to_camel) table_name: str | None = Field(description="表名", default=None) sheet_name: str | None = Field(description="Sheet名", default=None) diff --git a/backend/app/api/v1/module_common/file/service.py b/backend/app/api/v1/module_common/file/service.py index 59635f81..92a11946 100644 --- a/backend/app/api/v1/module_common/file/service.py +++ b/backend/app/api/v1/module_common/file/service.py @@ -5,7 +5,7 @@ from fastapi import UploadFile from app.config.setting import settings from app.core.base_schema import DownloadFileSchema, UploadResponseSchema from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.utils.upload_util import UploadUtil @@ -21,7 +21,7 @@ class FileService: file: UploadFile, upload_type: str = "file", target_path: str | None = None, - ) -> dict: + ) -> UploadResponseSchema: """ 上传文件。 @@ -49,7 +49,7 @@ class FileService: file_name=filename, origin_name=file.filename, file_url=f"{file_url}", - ).model_dump() + ) @staticmethod def _validate_download_path(file_path: str) -> str: @@ -71,14 +71,14 @@ class FileService: dangerous_patterns = ["../", "..\\", "\0"] for pattern in dangerous_patterns: if pattern in file_path: - log.error(f"检测到路径穿越攻击: {file_path}") + logger.error(f"检测到路径穿越攻击: {file_path}") raise CustomException(msg="非法的文件路径") upload_root = settings.UPLOAD_FILE_PATH.resolve() abs_path = os.path.normpath(os.path.abspath(file_path)) if not abs_path.startswith(str(upload_root)): - log.error(f"路径不在上传目录内: {file_path}") + logger.error(f"路径不在上传目录内: {file_path}") raise CustomException(msg="非法的文件路径") return abs_path diff --git a/backend/app/api/v1/module_common/monitoring/__init__.py b/backend/app/api/v1/module_common/monitoring/__init__.py index 20926abb..8a710443 100644 --- a/backend/app/api/v1/module_common/monitoring/__init__.py +++ b/backend/app/api/v1/module_common/monitoring/__init__.py @@ -1,3 +1,3 @@ -from .health import health_router +from .health import HealthRouter -__all__ = ["health_router"] +__all__ = ["HealthRouter"] diff --git a/backend/app/api/v1/module_common/monitoring/health.py b/backend/app/api/v1/module_common/monitoring/health.py index 5e32ca65..3bc351e4 100644 --- a/backend/app/api/v1/module_common/monitoring/health.py +++ b/backend/app/api/v1/module_common/monitoring/health.py @@ -10,7 +10,6 @@ import asyncio import shutil import time from datetime import datetime -from typing import Any from fastapi import APIRouter, Request from sqlalchemy import text @@ -19,44 +18,46 @@ from app.common.constant import RET from app.common.response import ErrorResponse, ResponseSchema, SuccessResponse from app.config.setting import settings from app.core.database import async_db_session -from app.core.logger import log +from app.core.logger import logger -health_router = APIRouter(prefix="/health", tags=["健康检查"]) +from .schema import DependencyStatus, HealthOut, ReadinessOut + +HealthRouter = APIRouter(prefix="/health", tags=["公共服务/健康检查"]) -async def _check_database() -> dict[str, Any]: +async def _check_database() -> DependencyStatus: """检查数据库连接""" try: if not settings.SQL_DB_ENABLE: - return {"status": "disabled", "latency_ms": None} + return DependencyStatus(status="disabled") start = time.perf_counter() async with async_db_session() as session: await session.execute(text("SELECT 1")) latency = (time.perf_counter() - start) * 1000 - return {"status": "up", "latency_ms": round(latency, 2)} + return DependencyStatus(status="up", latency_ms=round(latency, 2)) except Exception as e: - log.warning(f"数据库健康检查失败: {e}") - return {"status": "down", "latency_ms": None} + logger.warning(f"数据库健康检查失败: {e}") + return DependencyStatus(status="down") -async def _check_redis(request: Request) -> dict[str, Any]: +async def _check_redis(request: Request) -> DependencyStatus: """检查 Redis 连接""" try: if not settings.REDIS_ENABLE: - return {"status": "disabled", "latency_ms": None} + return DependencyStatus(status="disabled") redis = getattr(request.app.state, "redis", None) if not redis: - return {"status": "down", "latency_ms": None} + return DependencyStatus(status="down") start = time.perf_counter() await redis.ping() latency = (time.perf_counter() - start) * 1000 - return {"status": "up", "latency_ms": round(latency, 2)} + return DependencyStatus(status="up", latency_ms=round(latency, 2)) except Exception as e: - log.warning(f"Redis 健康检查失败: {e}") - return {"status": "down", "latency_ms": None} + logger.warning(f"Redis 健康检查失败: {e}") + return DependencyStatus(status="down") def _get_disk_usage() -> float: @@ -72,51 +73,78 @@ def _get_disk_usage() -> float: _start_time = datetime.now() -@health_router.get( +@HealthRouter.get( "", summary="基础健康检查", description="仅检查进程是否存活,用于负载均衡器探测", - response_model=ResponseSchema[dict], + response_model=ResponseSchema[HealthOut], ) async def health_check(): + """ + 基础健康检查 + + 参数: + - 无 + + 返回: + - SuccessResponse: 包含进程存活状态、启动时间、版本号的 JSON 响应。 + """ uptime = (datetime.now() - _start_time).total_seconds() return SuccessResponse( - data={ - "status": "healthy", - "timestamp": datetime.now().isoformat(), - "version": settings.VERSION, - "uptime_seconds": uptime, - }, + data=HealthOut( + status="healthy", + timestamp=datetime.now().isoformat(), + version=settings.VERSION, + uptime_seconds=uptime, + ), msg="系统健康", ) -@health_router.get( +@HealthRouter.get( "/live", summary="存活探针", description="进程已启动即可返回 200,供 K8s livenessProbe 使用", - response_model=ResponseSchema[dict], + response_model=ResponseSchema[HealthOut], ) async def liveness_check(): + """ + 存活探针 + + 参数: + - 无 + + 返回: + - SuccessResponse: 包含进程存活状态、启动时间、版本号的 JSON 响应。 + """ uptime = (datetime.now() - _start_time).total_seconds() return SuccessResponse( - data={ - "status": "alive", - "timestamp": datetime.now().isoformat(), - "version": settings.VERSION, - "uptime_seconds": uptime, - }, + data=HealthOut( + status="alive", + timestamp=datetime.now().isoformat(), + version=settings.VERSION, + uptime_seconds=uptime, + ), msg="进程存活", ) -@health_router.get( +@HealthRouter.get( "/ready", summary="就绪探针", description="探测数据库与 Redis;任一项失败返回 503,供 K8s readinessProbe 使用", - response_model=ResponseSchema[dict[str, Any]], + response_model=ResponseSchema[ReadinessOut], ) async def readiness_check(request: Request): + """ + 就绪探针 + + 参数: + - request (Request): FastAPI 请求对象,用于获取 Redis 客户端。 + + 返回: + - SuccessResponse | ErrorResponse: 依赖就绪时返回 200,未就绪返回 503。 + """ uptime = (datetime.now() - _start_time).total_seconds() db_status, redis_status = await asyncio.gather( @@ -130,19 +158,19 @@ async def readiness_check(request: Request): } # 判断总体状态 - def is_ok(d: dict) -> bool: - return d["status"] in ("up", "disabled") + def is_ok(d: DependencyStatus) -> bool: + return d.status in ("up", "disabled") all_ok = all(is_ok(d) for d in dependencies.values()) - payload = { - "status": "ready" if all_ok else "not_ready", - "timestamp": datetime.now().isoformat(), - "version": settings.VERSION, - "uptime_seconds": uptime, - "dependencies": dependencies, - "disk_usage": _get_disk_usage(), - } + payload = ReadinessOut( + status="ready" if all_ok else "not_ready", + timestamp=datetime.now().isoformat(), + version=settings.VERSION, + uptime_seconds=uptime, + dependencies=dependencies, + disk_usage=_get_disk_usage(), + ) if all_ok: return SuccessResponse(data=payload, msg="依赖就绪") diff --git a/backend/app/api/v1/module_common/monitoring/metrics.py b/backend/app/api/v1/module_common/monitoring/metrics.py deleted file mode 100644 index 5758ea06..00000000 --- a/backend/app/api/v1/module_common/monitoring/metrics.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Prometheus Metrics 配置 - -集成 prometheus-fastapi-instrumentator,自动采集 HTTP 请求指标, -暴露 /metrics 端点供 Prometheus 抓取。 - -采集指标: -- http_requests_total: 请求总数(按 method、endpoint、status 分组) -- http_request_duration_seconds: 请求延迟直方图 -- http_requests_in_progress: 当前处理中的请求数 -""" - -from fastapi import FastAPI -from fastapi.responses import Response -from prometheus_client import CONTENT_TYPE_LATEST, generate_latest -from prometheus_fastapi_instrumentator import Instrumentator, metrics - - -def setup_metrics(app: FastAPI) -> None: - """初始化 Prometheus 指标采集 - - 自动为所有 HTTP 端点采集请求指标,暴露 /metrics 端点。 - - 参数: - app: FastAPI 应用实例 - - 返回: - None - """ - instrumentator = Instrumentator( - should_group_status_codes=True, - should_ignore_untemplated=True, - should_respect_env_var=False, - should_instrument_requests_inprogress=True, - excluded_handlers=[ - "/metrics", - "/health", - "/health/live", - "/health/ready", - "/docs", - "/redoc", - "/ljdoc", - "/openapi.json", - "/static/.*", - "/favicon.ico", - ], - inprogress_name="http_requests_in_progress", - inprogress_labels=True, - ) - - # 添加自定义指标 - instrumentator.add( - metrics.request_size( - should_include_handler=True, - should_include_method=True, - should_include_status=True, - metric_name="http_request_size_bytes", - ) - ).add( - metrics.response_size( - should_include_handler=True, - should_include_method=True, - should_include_status=True, - metric_name="http_response_size_bytes", - ) - ).add( - metrics.latency( - metric_name="http_request_duration_seconds", - metric_doc="HTTP 请求延迟(秒)", - ) - ).add( - metrics.requests( - metric_name="http_requests_total", - metric_doc="HTTP 请求总数", - ) - ) - - # 启动采集 - instrumentator.instrument(app) - - # 手动注册 /metrics 端点,避免 prometheus_fastapi_instrumentator.expose() - # 的 response_class=None 与 FastAPI OpenAPI 生成不兼容的问题 - @app.get("/metrics", include_in_schema=False) - async def metrics_endpoint(): - return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/backend/app/api/v1/module_common/monitoring/schema.py b/backend/app/api/v1/module_common/monitoring/schema.py new file mode 100644 index 00000000..14da1346 --- /dev/null +++ b/backend/app/api/v1/module_common/monitoring/schema.py @@ -0,0 +1,28 @@ +"""健康检查 Schema""" +from __future__ import annotations + +from pydantic import BaseModel + + +class DependencyStatus(BaseModel): + """依赖状态""" + status: str + latency_ms: float | None = None + + +class HealthOut(BaseModel): + """基础健康检查响应""" + status: str + timestamp: str + version: str + uptime_seconds: float + + +class ReadinessOut(BaseModel): + """就绪探针响应""" + status: str + timestamp: str + version: str + uptime_seconds: float + dependencies: dict[str, DependencyStatus] + disk_usage: float diff --git a/backend/app/api/v1/module_monitor/cache/controller.py b/backend/app/api/v1/module_monitor/cache/controller.py index f2013b0e..ed4d794d 100644 --- a/backend/app/api/v1/module_monitor/cache/controller.py +++ b/backend/app/api/v1/module_monitor/cache/controller.py @@ -8,19 +8,17 @@ from app.api.v1.module_monitor.cache.schema import CacheInfoSchema, CacheMonitor from app.common.response import ResponseSchema, SuccessResponse from app.core.dependencies import AuthPermission, redis_getter from app.core.exceptions import CustomException -from app.core.logger import log from app.core.router_class import OperationLogRoute from .service import CacheService -CacheRouter = APIRouter(route_class=OperationLogRoute, prefix="/cache", tags=["缓存监控"]) +CacheRouter = APIRouter(route_class=OperationLogRoute, prefix="/cache", tags=["系统监控/缓存监控"]) @CacheRouter.get( "/info", dependencies=[Depends(AuthPermission(["module_monitor:cache:query"]))], summary="获取缓存监控信息", - description="获取缓存监控信息", response_model=ResponseSchema[CacheMonitorSchema], ) async def get_monitor_cache_info_controller( @@ -36,7 +34,6 @@ async def get_monitor_cache_info_controller( - JSONResponse: 包含缓存监控统计信息的JSON响应 """ result = await CacheService.get_cache_monitor_statistical_info_service(redis=redis) - log.info("获取缓存监控信息成功") return SuccessResponse(data=result, msg="获取缓存监控信息成功") @@ -44,7 +41,6 @@ async def get_monitor_cache_info_controller( "/get/names", dependencies=[Depends(AuthPermission(["module_monitor:cache:query"]))], summary="获取缓存名称列表", - description="获取缓存名称列表", response_model=ResponseSchema[list[CacheInfoSchema]], ) async def get_monitor_cache_name_controller() -> JSONResponse: @@ -55,7 +51,6 @@ async def get_monitor_cache_name_controller() -> JSONResponse: - JSONResponse: 包含缓存名称列表的JSON响应 """ result = await CacheService.get_cache_monitor_cache_name_service() - log.info("获取缓存名称列表成功") return SuccessResponse(data=result, msg="获取缓存名称列表成功") @@ -63,7 +58,6 @@ async def get_monitor_cache_name_controller() -> JSONResponse: "/get/keys/{cache_name}", dependencies=[Depends(AuthPermission(["module_monitor:cache:query"]))], summary="获取缓存键名列表", - description="获取缓存键名列表", response_model=ResponseSchema[list[CacheInfoSchema]], ) async def get_monitor_cache_key_controller( @@ -81,7 +75,6 @@ async def get_monitor_cache_key_controller( result = await CacheService.get_cache_monitor_cache_key_service( redis=redis, cache_name=cache_name ) - log.info(f"获取缓存{cache_name}的键名列表成功") return SuccessResponse(data=result, msg=f"获取缓存{cache_name}的键名列表成功") @@ -89,7 +82,6 @@ async def get_monitor_cache_key_controller( "/get/value/{cache_name}/{cache_key}", dependencies=[Depends(AuthPermission(["module_monitor:cache:query"]))], summary="获取缓存值", - description="获取缓存值", response_model=ResponseSchema[CacheInfoSchema], ) async def get_monitor_cache_value_controller( @@ -110,7 +102,6 @@ async def get_monitor_cache_value_controller( result = await CacheService.get_cache_monitor_cache_value_service( redis=redis, cache_name=cache_name, cache_key=cache_key ) - log.info(f"获取缓存{cache_name}:{cache_key}的值成功") return SuccessResponse(data=result, msg=f"获取缓存{cache_name}:{cache_key}的值成功") @@ -118,7 +109,6 @@ async def get_monitor_cache_value_controller( "/delete/name/{cache_name}", dependencies=[Depends(AuthPermission(["module_monitor:cache:delete"]))], summary="清除指定缓存名称的所有缓存", - description="清除指定缓存名称的所有缓存", response_model=ResponseSchema[None], ) async def clear_monitor_cache_name_controller( @@ -138,7 +128,6 @@ async def clear_monitor_cache_name_controller( ) if not result: raise CustomException(msg="清除缓存失败", data=result) - log.info(f"清除缓存{cache_name}成功") return SuccessResponse(msg=f"{cache_name}对应键值清除成功", data=result) @@ -146,7 +135,6 @@ async def clear_monitor_cache_name_controller( "/delete/key/{cache_key}", dependencies=[Depends(AuthPermission(["module_monitor:cache:delete"]))], summary="清除指定缓存键", - description="清除指定缓存键", response_model=ResponseSchema[None], ) async def clear_monitor_cache_key_controller( @@ -166,7 +154,6 @@ async def clear_monitor_cache_key_controller( ) if not result: raise CustomException(msg="清除缓存失败", data=result) - log.info(f"清除缓存键{cache_key}成功") return SuccessResponse(msg=f"{cache_key}清除成功", data=result) @@ -174,7 +161,6 @@ async def clear_monitor_cache_key_controller( "/delete/all", dependencies=[Depends(AuthPermission(["module_monitor:cache:delete"]))], summary="清除所有缓存", - description="清除所有缓存", response_model=ResponseSchema[None], ) async def clear_monitor_cache_all_controller( @@ -192,5 +178,4 @@ async def clear_monitor_cache_all_controller( result = await CacheService.clear_cache_monitor_all_service(redis=redis) if not result: raise CustomException(msg="清除缓存失败", data=result) - log.info("清除所有缓存成功") return SuccessResponse(msg="所有缓存清除成功", data=result) diff --git a/backend/app/api/v1/module_monitor/cache/schema.py b/backend/app/api/v1/module_monitor/cache/schema.py index 3bf69c1e..50c3aa44 100644 --- a/backend/app/api/v1/module_monitor/cache/schema.py +++ b/backend/app/api/v1/module_monitor/cache/schema.py @@ -1,13 +1,8 @@ -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field class CacheMonitorSchema(BaseModel): """缓存监控信息模型""" - - model_config = ConfigDict(from_attributes=True) - command_stats: list[dict] = Field(default_factory=list, description="Redis命令统计信息") db_size: int = Field(default=0, description="Redis数据库中的Key总数") info: dict = Field(default_factory=dict, description="Redis服务器信息") @@ -15,10 +10,7 @@ class CacheMonitorSchema(BaseModel): class CacheInfoSchema(BaseModel): """缓存对象信息模型""" - - model_config = ConfigDict(from_attributes=True) - cache_key: str = Field(..., description="缓存键名") cache_name: str = Field(..., description="缓存名称") - cache_value: Any = Field(default=None, description="缓存值") + cache_value: str | None = Field(default=None, description="缓存值") remark: str | None = Field(default=None, description="备注说明") diff --git a/backend/app/api/v1/module_monitor/cache/service.py b/backend/app/api/v1/module_monitor/cache/service.py index 4210835c..628ed35c 100644 --- a/backend/app/api/v1/module_monitor/cache/service.py +++ b/backend/app/api/v1/module_monitor/cache/service.py @@ -12,7 +12,7 @@ class CacheService: """ @classmethod - async def get_cache_monitor_statistical_info_service(cls, redis: Redis) -> dict: + async def get_cache_monitor_statistical_info_service(cls, redis: Redis) -> CacheMonitorSchema: """ 获取缓存监控信息。 @@ -30,30 +30,26 @@ class CacheService: {"name": key.split("_")[1], "value": str(value.get("calls"))} for key, value in command_stats_dict.items() ] - result = CacheMonitorSchema(command_stats=command_stats, db_size=db_size, info=info) - - return result.model_dump() + return CacheMonitorSchema(command_stats=command_stats, db_size=db_size, info=info) @classmethod - async def get_cache_monitor_cache_name_service(cls) -> list: + async def get_cache_monitor_cache_name_service(cls) -> list[CacheInfoSchema]: """ 获取缓存名称列表信息。 返回: - list: 缓存名称列表信息。 """ - name_list = [ + return [ CacheInfoSchema( cache_key="", cache_name=key_config.key, cache_value="", remark=key_config.remark, - ).model_dump() + ) for key_config in RedisInitKeyConfig ] - return name_list - @classmethod async def get_cache_monitor_cache_key_service(cls, redis: Redis, cache_name: str) -> list: """ @@ -76,7 +72,7 @@ class CacheService: @classmethod async def get_cache_monitor_cache_value_service( cls, redis: Redis, cache_name: str, cache_key: str - ) -> dict: + ) -> CacheInfoSchema: """ 获取缓存内容信息。 @@ -95,7 +91,7 @@ class CacheService: cache_name=cache_name, cache_value=cache_value, remark="", - ).model_dump() + ) @classmethod async def clear_cache_monitor_cache_name_service(cls, redis: Redis, cache_name: str) -> bool: diff --git a/backend/app/api/v1/module_monitor/online/controller.py b/backend/app/api/v1/module_monitor/online/controller.py index 525ed63f..52b44ac9 100644 --- a/backend/app/api/v1/module_monitor/online/controller.py +++ b/backend/app/api/v1/module_monitor/online/controller.py @@ -5,23 +5,21 @@ from fastapi.responses import JSONResponse from redis.asyncio.client import Redis from app.common.request import PaginationService -from app.common.response import ErrorResponse, ResponseSchema, SuccessResponse +from app.common.response import ResponseSchema, SuccessResponse from app.core.base_params import PaginationQueryParam from app.core.dependencies import AuthPermission, redis_getter -from app.core.logger import log from app.core.router_class import OperationLogRoute from .schema import OnlineOutSchema, OnlineQueryParam from .service import OnlineService -OnlineRouter = APIRouter(route_class=OperationLogRoute, prefix="/online", tags=["在线用户"]) +OnlineRouter = APIRouter(route_class=OperationLogRoute, prefix="/online", tags=["系统监控/在线用户"]) @OnlineRouter.get( "/list", dependencies=[Depends(AuthPermission(["module_monitor:online:query"]))], summary="获取在线用户列表", - description="获取在线用户列表", response_model=ResponseSchema[list[OnlineOutSchema]], ) async def get_online_list_controller( @@ -47,7 +45,6 @@ async def get_online_list_controller( page_no=paging_query.page_no, page_size=paging_query.page_size, ) - log.info("获取成功") return SuccessResponse(data=result_dict, msg="获取成功") @@ -56,7 +53,6 @@ async def get_online_list_controller( "/delete", dependencies=[Depends(AuthPermission(["module_monitor:online:delete"]))], summary="强制下线", - description="强制下线", response_model=ResponseSchema[None], ) async def delete_online_controller( @@ -73,19 +69,14 @@ async def delete_online_controller( 返回: - JSONResponse: 包含操作结果的JSON响应。 """ - is_ok = await OnlineService.delete_online_service(redis=redis, session_id=session_id) - if is_ok: - log.info("强制下线成功") - return SuccessResponse(msg="强制下线成功") - log.info("强制下线失败") - return ErrorResponse(msg="强制下线失败") + await OnlineService.delete_online_service(redis=redis, session_id=session_id) + return SuccessResponse(msg="强制下线成功") @OnlineRouter.delete( "/clear", dependencies=[Depends(AuthPermission(["module_monitor:online:delete"]))], summary="清除所有在线用户", - description="清除所有在线用户", response_model=ResponseSchema[None], ) async def clear_online_controller( @@ -100,9 +91,5 @@ async def clear_online_controller( 返回: - JSONResponse: 包含操作结果的JSON响应。 """ - is_ok = await OnlineService.clear_online_service(redis=redis) - if is_ok: - log.info("清除所有在线用户成功") - return SuccessResponse(msg="清除所有在线用户成功") - log.info("清除所有在线用户失败") - return ErrorResponse(msg="清除所有在线用户失败") + await OnlineService.clear_online_service(redis=redis) + return SuccessResponse(msg="清除所有在线用户成功") diff --git a/backend/app/api/v1/module_monitor/online/schema.py b/backend/app/api/v1/module_monitor/online/schema.py index c8f6e250..34bd8cf1 100644 --- a/backend/app/api/v1/module_monitor/online/schema.py +++ b/backend/app/api/v1/module_monitor/online/schema.py @@ -1,5 +1,5 @@ from fastapi import Query -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from app.common.enums import QueueEnum from app.core.validator import DateTimeStr @@ -10,8 +10,6 @@ class OnlineOutSchema(BaseModel): 在线用户对应pydantic模型 """ - model_config = ConfigDict(from_attributes=True) - name: str = Field(..., description="用户名称") session_id: str = Field(..., description="会话编号") user_id: int = Field(..., description="用户ID") diff --git a/backend/app/api/v1/module_monitor/online/service.py b/backend/app/api/v1/module_monitor/online/service.py index 6cd39b25..7625c1e3 100644 --- a/backend/app/api/v1/module_monitor/online/service.py +++ b/backend/app/api/v1/module_monitor/online/service.py @@ -3,7 +3,7 @@ import json from redis.asyncio.client import Redis from app.common.enums import RedisInitKeyConfig -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.core.security import decode_access_token @@ -41,7 +41,7 @@ class OnlineService: if cls._match_search_conditions(session_info, search): online_users.append(session_info) except Exception as e: - log.error(f"解析在线用户数据失败: {e}") + logger.error(f"解析在线用户数据失败: {e}") continue # 按照 login_time 倒序排序 online_users.sort(key=lambda x: x.get("login_time", ""), reverse=True) @@ -49,41 +49,33 @@ class OnlineService: return online_users @classmethod - async def delete_online_service(cls, redis: Redis, session_id: str) -> bool: + async def delete_online_service(cls, redis: Redis, session_id: str) -> None: """ 强制下线指定在线用户 参数: - redis (Redis): Redis异步客户端实例。 - session_id (str): 在线用户会话ID。 - - 返回: - - bool: 如果操作成功则返回True,否则返回False。 """ # 删除 token await RedisCURD(redis).delete(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}") await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}") - log.info(f"强制下线用户会话: {session_id}") - return True + logger.info(f"强制下线用户会话: {session_id}") @classmethod - async def clear_online_service(cls, redis: Redis) -> bool: + async def clear_online_service(cls, redis: Redis) -> None: """ 强制下线所有在线用户 参数: - redis (Redis): Redis异步客户端实例。 - - 返回: - - bool: 如果操作成功则返回True,否则返回False。 """ # 删除 token await RedisCURD(redis).clear(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*") await RedisCURD(redis).clear(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:*") - log.info("清除所有在线用户会话成功") - return True + logger.info("清除所有在线用户会话成功") @staticmethod def _match_search_conditions(online_info: dict, search: OnlineQueryParam | None = None) -> bool: diff --git a/backend/app/api/v1/module_monitor/resource/controller.py b/backend/app/api/v1/module_monitor/resource/controller.py index 72e0bef9..9a089b94 100644 --- a/backend/app/api/v1/module_monitor/resource/controller.py +++ b/backend/app/api/v1/module_monitor/resource/controller.py @@ -3,31 +3,32 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Form, Query, Request, UploadFile from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from app.api.v1.module_common.file.service import FileService from app.common.request import PaginationService from app.common.response import ResponseSchema, StreamResponse, SuccessResponse, UploadFileResponse from app.core.base_params import PaginationQueryParam +from app.core.base_schema import UploadResponseSchema from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response from .schema import ( ResourceCopySchema, ResourceCreateDirSchema, + ResourceItemSchema, ResourceMoveSchema, ResourceRenameSchema, ResourceSearchQueryParam, ) from .service import ResourceService -ResourceRouter = APIRouter(route_class=OperationLogRoute, prefix="/resource", tags=["资源管理"]) +ResourceRouter = APIRouter(route_class=OperationLogRoute, prefix="/resource", tags=["系统监控/资源管理"]) @ResourceRouter.get( "/list", summary="获取目录列表", - description="获取指定目录下的文件和子目录列表", - response_model=ResponseSchema[list[dict]], + response_model=ResponseSchema[list[ResourceItemSchema]], dependencies=[Depends(AuthPermission(["module_monitor:resource:query"]))], ) async def get_directory_list_controller( @@ -57,15 +58,13 @@ async def get_directory_list_controller( page_size=page.page_size, ) - log.info(f"获取目录列表成功: {getattr(search, 'name', None) or ''}") return SuccessResponse(data=result_dict, msg="获取目录列表成功") @ResourceRouter.post( "/upload", summary="上传文件", - description="上传文件到指定目录(调用统一上传接口)", - response_model=ResponseSchema[dict], + response_model=ResponseSchema[UploadResponseSchema], dependencies=[Depends(AuthPermission(["module_monitor:resource:upload"]))], ) async def upload_file_controller( @@ -85,20 +84,18 @@ async def upload_file_controller( - JSONResponse: 包含上传文件信息的JSON响应。 """ # 调用统一上传接口,使用 resource 类型 - result_dict = await FileService.upload_service( + result = await FileService.upload_service( base_url=str(request.base_url), file=file, upload_type="resource", target_path=target_path, ) - log.info(f"上传文件成功: {result_dict['file_name']}") - return SuccessResponse(data=result_dict, msg="上传文件成功") + return SuccessResponse(data=result, msg="上传文件成功") @ResourceRouter.get( "/download", summary="下载文件", - description="下载指定文件", dependencies=[Depends(AuthPermission(["module_monitor:resource:download"]))], ) async def download_file_controller( @@ -120,7 +117,6 @@ async def download_file_controller( filename = os.path.basename(file_path) - log.info(f"下载文件成功: {filename}") return UploadFileResponse( file_path=file_path, filename=filename, @@ -131,7 +127,6 @@ async def download_file_controller( @ResourceRouter.delete( "/delete", summary="删除文件", - description="删除指定文件或目录", response_model=ResponseSchema[None], dependencies=[Depends(AuthPermission(["module_monitor:resource:delete"]))], ) @@ -148,14 +143,12 @@ async def delete_files_controller( - JSONResponse: 包含删除结果的JSON响应。 """ await ResourceService.delete_file_service(paths=paths) - log.info(f"删除文件成功: {paths}") return SuccessResponse(msg="删除文件成功") @ResourceRouter.post( "/move", summary="移动文件", - description="移动文件或目录", response_model=ResponseSchema[None], dependencies=[Depends(AuthPermission(["module_monitor:resource:move"]))], ) @@ -170,14 +163,12 @@ async def move_file_controller(data: ResourceMoveSchema) -> JSONResponse: - JSONResponse: 包含移动结果的JSON响应。 """ await ResourceService.move_file_service(data=data) - log.info(f"移动文件成功: {data.source_path} -> {data.target_path}") return SuccessResponse(msg="移动文件成功") @ResourceRouter.post( "/copy", summary="复制文件", - description="复制文件或目录", response_model=ResponseSchema[None], dependencies=[Depends(AuthPermission(["module_monitor:resource:copy"]))], ) @@ -192,14 +183,12 @@ async def copy_file_controller(data: ResourceCopySchema) -> JSONResponse: - JSONResponse: 包含复制结果的JSON响应。 """ await ResourceService.copy_file_service(data=data) - log.info(f"复制文件成功: {data.source_path} -> {data.target_path}") return SuccessResponse(msg="复制文件成功") @ResourceRouter.post( "/rename", summary="重命名文件", - description="重命名文件或目录", response_model=ResponseSchema[None], dependencies=[Depends(AuthPermission(["module_monitor:resource:rename"]))], ) @@ -214,14 +203,12 @@ async def rename_file_controller(data: ResourceRenameSchema) -> JSONResponse: - JSONResponse: 包含重命名结果的JSON响应。 """ await ResourceService.rename_file_service(data=data) - log.info(f"重命名文件成功: {data.old_path} -> {data.new_name}") return SuccessResponse(msg="重命名文件成功") @ResourceRouter.post( "/create-dir", summary="创建目录", - description="在指定路径创建新目录", response_model=ResponseSchema[None], dependencies=[Depends(AuthPermission(["module_monitor:resource:create_dir"]))], ) @@ -238,14 +225,12 @@ async def create_directory_controller( - JSONResponse: 包含创建目录结果的JSON响应。 """ await ResourceService.create_directory_service(data=data) - log.info(f"创建目录成功: {data.parent_path}/{data.dir_name}") return SuccessResponse(msg="创建目录成功") @ResourceRouter.post( "/export", summary="导出资源列表", - description="导出资源列表", response_model=ResponseSchema[None], dependencies=[Depends(AuthPermission(["module_monitor:resource:export"]))], ) @@ -268,7 +253,6 @@ async def export_resource_list_controller( ) export_result = await ResourceService.export_resource_service(data_list=result_dict_list) - log.info("导出资源列表成功") return StreamResponse( data=bytes2file_response(export_result), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/backend/app/api/v1/module_monitor/resource/service.py b/backend/app/api/v1/module_monitor/resource/service.py index c6a97303..71441b41 100644 --- a/backend/app/api/v1/module_monitor/resource/service.py +++ b/backend/app/api/v1/module_monitor/resource/service.py @@ -1,15 +1,15 @@ +import ast import os import re import shutil import urllib.parse from datetime import datetime from pathlib import Path -from typing import Any from urllib.parse import urlparse from app.config.setting import settings from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.utils.excel_util import ExcelUtil from .schema import ( @@ -36,18 +36,18 @@ class ResourceService: @classmethod def _get_resource_root(cls) -> str: """ - 获取资源管理根目录(仅允许访问 upload 目录) + 获取资源管理根目录(仅允许访问 upload/resource 目录) 返回: - - str: 资源管理根目录路径(upload 目录)。 + - str: 资源管理根目录路径(upload/resource 目录)。 """ if not settings.STATIC_ENABLE: raise CustomException(msg="静态文件服务未启用") - # 限制只能管理 upload 目录 - upload_root = os.path.join(str(settings.STATIC_ROOT), "upload") - # 确保 upload 目录存在 - os.makedirs(upload_root, exist_ok=True) - return upload_root + # 限制只能管理 upload/resource 目录(与 upload_type="resource" 保持一致) + resource_root = os.path.join(str(settings.STATIC_ROOT), "upload", "resource") + # 确保 resource 目录存在 + os.makedirs(resource_root, exist_ok=True) + return resource_root @classmethod def _get_safe_path(cls, path: str | None = None) -> str: @@ -107,13 +107,13 @@ class ResourceService: # 检查路径遍历攻击 if ".." in path or "\x00" in path: - log.error(f"检测到路径遍历攻击尝试: {path}") + logger.error(f"检测到路径遍历攻击尝试: {path}") raise CustomException(msg="非法的路径格式") # URL 解码检查 decoded_path = urllib.parse.unquote(path) if ".." in decoded_path: - log.error(f"检测到编码后的路径遍历攻击: {path}") + logger.error(f"检测到编码后的路径遍历攻击: {path}") raise CustomException(msg="非法的路径格式") # 构建完整路径 @@ -128,7 +128,7 @@ class ResourceService: not safe_path_abs.startswith(resource_root_abs + os.sep) and safe_path_abs != resource_root_abs ): - log.error( + logger.error( f"路径遍历攻击被阻止: 尝试访问 {safe_path_abs}, 但根目录是 {resource_root_abs}" ) raise CustomException(msg="访问路径不在允许范围内") @@ -185,7 +185,7 @@ class ResourceService: ] for pattern in dangerous_patterns: if re.search(pattern, filename, re.IGNORECASE): - log.error(f"检测到文件名路径遍历攻击: {filename}") + logger.error(f"检测到文件名路径遍历攻击: {filename}") # 返回安全文件名,不包含原始文件名 return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}" @@ -194,7 +194,7 @@ class ResourceService: decoded_twice = urllib.parse.unquote(decoded) for check in [decoded, decoded_twice]: if ".." in check or "/" in check or "\\" in check: - log.error(f"检测到编码后的文件名攻击: {filename}") + logger.error(f"检测到编码后的文件名攻击: {filename}") return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}" # 使用 os.path.basename 提取纯文件名(移除路径) @@ -281,60 +281,48 @@ class ResourceService: return http_url @classmethod - def _get_file_info(cls, file_path: str, base_url: str | None = None) -> dict: + def _get_file_info(cls, file_path: str, base_url: str | None = None) -> ResourceItemSchema | None: """ - 获取文件或目录的详细信息,如名称、大小、创建时间、修改时间、路径、深度、HTTP URL、是否隐藏、是否为目录等。 + 获取文件或目录的详细信息。 参数: - file_path (str): 文件或目录的路径(必须是绝对路径)。 - base_url (str | None): 基础URL,用于生成完整URL。 返回: - - dict: 文件或目录的详细信息字典。 + - ResourceItemSchema | None: 文件或目录的详细信息,路径不存在时返回 None。 """ try: - # 直接使用传入的路径(已经是绝对路径) safe_path = file_path if not os.path.exists(safe_path): - return {} + return None stat = os.stat(safe_path) path_obj = Path(safe_path) resource_root = cls._get_resource_root() - # 计算相对路径(相对于资源根目录) try: relative_path = os.path.relpath(safe_path, resource_root) except ValueError: relative_path = os.path.basename(safe_path) - # 生成HTTP URL路径 http_url = cls._generate_http_url(safe_path, base_url) - - # 检查是否为隐藏文件(文件名以点开头) is_hidden = path_obj.name.startswith(".") - # 对于目录,设置is_directory字段(兼容前端) - is_directory = os.path.isdir(safe_path) - - # 将datetime对象转换为ISO格式的字符串,确保JSON序列化成功 - created_time = datetime.fromtimestamp(stat.st_ctime).isoformat() - modified_time = datetime.fromtimestamp(stat.st_mtime).isoformat() - - return { - "name": path_obj.name, - "file_url": http_url, # 统一使用file_url字段 - "relative_path": relative_path, - "is_file": os.path.isfile(safe_path), - "is_dir": is_directory, - "size": stat.st_size if os.path.isfile(safe_path) else None, - "created_time": created_time, - "modified_time": modified_time, - "is_hidden": is_hidden, - } + return ResourceItemSchema( + name=path_obj.name, + file_url=http_url, + relative_path=relative_path, + is_file=os.path.isfile(safe_path), + is_dir=os.path.isdir(safe_path), + size=stat.st_size if os.path.isfile(safe_path) else None, + created_time=datetime.fromtimestamp(stat.st_ctime), + modified_time=datetime.fromtimestamp(stat.st_mtime), + is_hidden=is_hidden, + ) except Exception as e: - log.error(f"获取文件信息失败: {e!s}") - return {} + logger.error(f"获取文件信息失败: {e!s}") + return None @classmethod async def get_directory_list_service( @@ -342,7 +330,7 @@ class ResourceService: path: str | None = None, include_hidden: bool = False, base_url: str | None = None, - ) -> dict: + ) -> ResourceDirectorySchema: """ 获取目录列表 @@ -384,30 +372,30 @@ class ResourceService: file_info = cls._get_file_info(item_path, base_url) if file_info: - items.append(ResourceItemSchema(**file_info)) + items.append(file_info) - if file_info["is_file"]: + if file_info.is_file: total_files += 1 - total_size += file_info.get("size", 0) or 0 - elif file_info["is_dir"]: + total_size += file_info.size or 0 + elif file_info.is_dir: total_dirs += 1 except PermissionError: raise CustomException(msg="没有权限访问此目录") return ResourceDirectorySchema( - path=display_path, # 返回HTTP URL路径而不是文件系统路径 + path=display_path, name=os.path.basename(safe_path), items=items, total_files=total_files, total_dirs=total_dirs, total_size=total_size, - ).model_dump() + ) except CustomException: raise except Exception as e: - log.error(f"获取目录列表失败: {e!s}") + logger.error(f"获取目录列表失败: {e!s}") raise CustomException(msg=f"获取目录列表失败: {e!s}") @classmethod @@ -416,7 +404,7 @@ class ResourceService: search: ResourceSearchQueryParam | None = None, order_by: str | None = None, base_url: str | None = None, - ) -> list[dict]: + ) -> list[ResourceItemSchema]: """ 搜索资源列表(用于分页和导出) @@ -426,7 +414,7 @@ class ResourceService: - base_url (str | None): 基础URL,用于生成完整URL。 返回: - - list[dict]: 资源详情字典列表。 + - list[ResourceItemSchema]: 资源详情列表。 """ try: # 确定搜索路径 @@ -458,7 +446,7 @@ class ResourceService: # 应用名称过滤 if search and hasattr(search, "name") and search.name and search.name[1]: search_keyword = search.name[1].lower() - if search_keyword not in file_info.get("name", "").lower(): + if search_keyword not in file_info.name.lower(): continue all_resources.append(file_info) @@ -476,16 +464,16 @@ class ResourceService: return sorted_resources except Exception as e: - log.error(f"搜索资源失败: {e!s}") + logger.error(f"搜索资源失败: {e!s}") raise CustomException(msg=f"搜索资源失败: {e!s}") @classmethod - async def export_resource_service(cls, data_list: list[dict]) -> bytes: + async def export_resource_service(cls, data_list: list[ResourceItemSchema]) -> bytes: """ 导出资源列表 参数: - - data_list (list[dict]): 资源详情字典列表。 + - data_list (list[ResourceItemSchema]): 资源详情列表。 返回: - bytes: Excel文件的二进制数据。 @@ -500,7 +488,7 @@ class ResourceService: } # 复制数据并转换状态 - export_data = data_list.copy() + export_data = [item.model_dump() for item in data_list] # 格式化文件大小 for item in export_data: @@ -547,55 +535,38 @@ class ResourceService: @classmethod def _sort_results( - cls, results: list[dict], order_by: str | None = None - ) -> list[dict[Any, Any]]: + cls, results: list[ResourceItemSchema], order_by: str | None = None + ) -> list[ResourceItemSchema]: """ 排序搜索结果 参数: - - results (list[dict]): 资源详情字典列表。 + - results (list[ResourceItemSchema]): 资源详情列表。 - order_by (str | None): 排序参数。 返回: - - list[dict]: 排序后的资源详情字典列表。 + - list[ResourceItemSchema]: 排序后的资源详情列表。 """ try: # 默认按名称升序排序 if not order_by: - return sorted(results, key=lambda x: x.get("name", ""), reverse=False) + return sorted(results, key=lambda x: x.name, reverse=False) # 解析order_by参数,格式: [{'field':'asc/desc'}] - - sort_conditions = eval(order_by) + sort_conditions = ast.literal_eval(order_by) if isinstance(sort_conditions, list): - # 构建排序键函数 def sort_key(item): - """ - 按多条排序条件从资源项中抽取比较键(支持时间字段转 datetime)。 - - 参数: - - item (dict): 单条资源详情字典。 - - 返回: - - list: 用于 `sorted` 的多字段键列表。 - """ keys = [] for cond in sort_conditions: field = cond.get("field", "name") - cond.get("direction", "asc") - # 获取字段值,默认为空字符串 - value = item.get(field, "") - # 如果是日期字段,转换为可比较的格式 + value = getattr(item, field, "") if ( field - in [ - "created_time", - "modified_time", - "accessed_time", - ] + in ["created_time", "modified_time", "accessed_time"] and value ): - value = datetime.fromisoformat(value) + if isinstance(value, str): + value = datetime.fromisoformat(value) keys.append(value) return keys @@ -634,13 +605,12 @@ class ResourceService: raise CustomException(msg="路径不是文件") # 返回本地文件路径给 FileResponse 使用 - log.info(f"定位文件路径: {safe_path}") return safe_path except CustomException: raise except Exception as e: - log.error(f"下载文件失败: {e!s}") + logger.error(f"下载文件失败: {e!s}") raise CustomException(msg=f"下载文件失败: {e!s}") @classmethod @@ -660,15 +630,13 @@ class ResourceService: safe_path = cls._get_safe_path(path) if not os.path.exists(safe_path): - log.error(f"路径不存在,跳过: {path}") + logger.error(f"路径不存在,跳过: {path}") raise CustomException(msg=f"路径不存在: {path}") if os.path.isfile(safe_path): os.remove(safe_path) - log.info(f"删除文件成功: {safe_path}") elif os.path.isdir(safe_path): shutil.rmtree(safe_path) - log.info(f"删除目录成功: {safe_path}") @classmethod async def delete_file_service(cls, paths: list[str]) -> None: @@ -692,7 +660,7 @@ class ResourceService: try: cls._delete_single_path(path) except Exception as e: - log.error(f"删除失败 {path}: {e!s}") + logger.error(f"删除失败 {path}: {e!s}") raise CustomException(msg=f"删除失败 {path}: {e!s}") @classmethod @@ -755,12 +723,11 @@ class ResourceService: # 移动文件 shutil.move(source_path, target_path) - log.info(f"移动成功: {source_path} -> {target_path}") except CustomException: raise except Exception as e: - log.error(f"移动失败: {e!s}") + logger.error(f"移动失败: {e!s}") raise CustomException(msg=f"移动失败: {e!s}") @classmethod @@ -795,12 +762,10 @@ class ResourceService: else: shutil.copytree(source_path, target_path, dirs_exist_ok=data.overwrite) - log.info(f"复制成功: {source_path} -> {target_path}") - except CustomException: raise except Exception as e: - log.error(f"复制失败: {e!s}") + logger.error(f"复制失败: {e!s}") raise CustomException(msg=f"复制失败: {e!s}") @classmethod @@ -826,7 +791,7 @@ class ResourceService: # 如果新名称被重置,说明检测到攻击 if safe_new_name.startswith("file_") and safe_new_name != data.new_name: - log.error(f"重命名时检测到路径遍历攻击,原始名称: {data.new_name}") + logger.error(f"重命名时检测到路径遍历攻击,原始名称: {data.new_name}") raise CustomException(msg="新名称包含非法字符") # 生成新路径 @@ -841,7 +806,7 @@ class ResourceService: not new_path_abs.startswith(resource_root_abs + os.sep) and new_path_abs != resource_root_abs ): - log.error(f"重命名时检测到越权访问: {new_path_abs}") + logger.error(f"重命名时检测到越权访问: {new_path_abs}") raise CustomException(msg="目标路径不在允许范围内") if os.path.exists(new_path): @@ -849,12 +814,11 @@ class ResourceService: # 重命名 os.rename(old_path, new_path) - log.info(f"重命名成功: {old_path} -> {new_path}") except CustomException: raise except Exception as e: - log.error(f"重命名失败: {e!s}") + logger.error(f"重命名失败: {e!s}") raise CustomException(msg=f"重命名失败: {e!s}") @classmethod @@ -882,7 +846,7 @@ class ResourceService: # 如果目录名被重置,说明检测到攻击 if safe_dir_name.startswith("file_") and safe_dir_name != data.dir_name: - log.error(f"创建目录时检测到路径遍历攻击,原始名称: {data.dir_name}") + logger.error(f"创建目录时检测到路径遍历攻击,原始名称: {data.dir_name}") raise CustomException(msg="目录名称包含非法字符") # 生成新目录路径 @@ -896,7 +860,7 @@ class ResourceService: not new_dir_path_abs.startswith(resource_root_abs + os.sep) and new_dir_path_abs != resource_root_abs ): - log.error(f"创建目录时检测到越权访问: {new_dir_path_abs}") + logger.error(f"创建目录时检测到越权访问: {new_dir_path_abs}") raise CustomException(msg="目标路径不在允许范围内") if os.path.exists(new_dir_path): @@ -904,12 +868,11 @@ class ResourceService: # 创建目录 os.makedirs(new_dir_path) - log.info(f"创建目录成功: {new_dir_path}") except CustomException: raise except Exception as e: - log.error(f"创建目录失败: {e!s}") + logger.error(f"创建目录失败: {e!s}") raise CustomException(msg=f"创建目录失败: {e!s}") @classmethod diff --git a/backend/app/api/v1/module_monitor/server/controller.py b/backend/app/api/v1/module_monitor/server/controller.py index 0da5df84..d32b4960 100644 --- a/backend/app/api/v1/module_monitor/server/controller.py +++ b/backend/app/api/v1/module_monitor/server/controller.py @@ -4,18 +4,16 @@ from fastapi.responses import JSONResponse from app.api.v1.module_monitor.server.schema import ServerMonitorSchema from app.common.response import ResponseSchema, SuccessResponse from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from .service import ServerService -ServerRouter = APIRouter(route_class=OperationLogRoute, prefix="/server", tags=["服务器监控"]) +ServerRouter = APIRouter(route_class=OperationLogRoute, prefix="/server", tags=["系统监控/服务器监控"]) @ServerRouter.get( "/info", summary="查询服务器监控信息", - description="查询服务器监控信息", dependencies=[Depends(AuthPermission(["module_monitor:server:query"]))], response_model=ResponseSchema[ServerMonitorSchema], ) @@ -27,6 +25,5 @@ async def get_monitor_server_info_controller() -> JSONResponse: - JSONResponse: 包含服务器监控信息的JSON响应。 """ result_dict = await ServerService.get_server_monitor_info_service() - log.info(f"获取服务器监控信息成功: {result_dict}") return SuccessResponse(data=result_dict, msg="获取服务器监控信息成功") diff --git a/backend/app/api/v1/module_monitor/server/schema.py b/backend/app/api/v1/module_monitor/server/schema.py index aaad04c4..64c26e54 100644 --- a/backend/app/api/v1/module_monitor/server/schema.py +++ b/backend/app/api/v1/module_monitor/server/schema.py @@ -1,11 +1,9 @@ -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field class CpuInfoSchema(BaseModel): """CPU信息模型""" - model_config = ConfigDict(from_attributes=True) - cpu_num: int = Field(description="CPU核心数") used: float = Field(ge=0, le=100, description="CPU用户使用率(%)") sys: float = Field(ge=0, le=100, description="CPU系统使用率(%)") @@ -15,8 +13,6 @@ class CpuInfoSchema(BaseModel): class MemoryInfoSchema(BaseModel): """内存信息模型""" - model_config = ConfigDict(from_attributes=True) - total: str = Field(description="内存总量") used: str = Field(description="已用内存") free: str = Field(description="剩余内存") @@ -26,8 +22,6 @@ class MemoryInfoSchema(BaseModel): class SysInfoSchema(BaseModel): """系统信息模型""" - model_config = ConfigDict(from_attributes=True) - computer_ip: str = Field(description="服务器IP") computer_name: str = Field(description="服务器名称") os_arch: str = Field(description="系统架构") @@ -38,8 +32,6 @@ class SysInfoSchema(BaseModel): class PyInfoSchema(BaseModel): """Python运行信息模型""" - model_config = ConfigDict(from_attributes=True) - name: str = Field(description="Python名称") version: str = Field(description="Python版本") start_time: str = Field(description="启动时间") @@ -54,8 +46,6 @@ class PyInfoSchema(BaseModel): class DiskInfoSchema(BaseModel): """磁盘信息模型""" - model_config = ConfigDict(from_attributes=True) - dir_name: str = Field(description="磁盘路径") sys_type_name: str = Field(description="文件系统类型") type_name: str = Field(description="磁盘类型") @@ -68,8 +58,6 @@ class DiskInfoSchema(BaseModel): class ServerMonitorSchema(BaseModel): """服务器监控信息模型""" - model_config = ConfigDict(from_attributes=True) - cpu: CpuInfoSchema = Field(description="CPU信息") mem: MemoryInfoSchema = Field(description="内存信息") py: PyInfoSchema = Field(description="Python运行信息") diff --git a/backend/app/api/v1/module_monitor/server/service.py b/backend/app/api/v1/module_monitor/server/service.py index 459526ce..dcb351e1 100644 --- a/backend/app/api/v1/module_monitor/server/service.py +++ b/backend/app/api/v1/module_monitor/server/service.py @@ -21,7 +21,7 @@ class ServerService: """服务监控模块服务层""" @classmethod - async def get_server_monitor_info_service(cls) -> dict: + async def get_server_monitor_info_service(cls) -> ServerMonitorSchema: """ 获取服务器监控信息 @@ -34,7 +34,7 @@ class ServerService: sys=cls._get_system_info(), py=cls._get_python_info(), disks=cls._get_disk_info(), - ).model_dump() + ) @classmethod def _get_cpu_info(cls) -> CpuInfoSchema: diff --git a/backend/app/api/v1/module_platform/__init__.py b/backend/app/api/v1/module_platform/__init__.py index 96e69432..ef56fa12 100644 --- a/backend/app/api/v1/module_platform/__init__.py +++ b/backend/app/api/v1/module_platform/__init__.py @@ -5,21 +5,39 @@ - 租户管理 (tenant) - 套餐管理 (package) - 插件管理 (plugin) -- 登录日志 (loginlog) -- 工单管理 (ticket) +- 邮件服务 (email) +- 订单与支付 (order) +- 发票管理 (invoice) +- 租户自助服务 (self_service) +- 菜单管理 (menu) """ from fastapi import APIRouter -from app.api.v1.module_platform.loginlog.controller import LoginLogRouter +from app.api.v1.module_platform.email.controller import EmailRouter +from app.api.v1.module_platform.invoice.controller import PlatformInvoiceRouter, TenantInvoiceRouter +from app.api.v1.module_platform.menu.controller import MenuRouter +from app.api.v1.module_platform.order.controller import ( + OrderRouter, + PaymentRouter, + RefundRouter, + TenantOrderRouter, +) from app.api.v1.module_platform.package.controller import PackageRouter from app.api.v1.module_platform.plugin.controller import PluginRouter +from app.api.v1.module_platform.self_service.controller import TenantSelfServiceRouter from app.api.v1.module_platform.tenant.controller import TenantRouter -from app.api.v1.module_platform.ticket.controller import TicketRouter -platform_router = APIRouter(prefix="/platform", tags=["平台管理"]) +platform_router = APIRouter(prefix="/platform") -platform_router.include_router(TenantRouter, prefix="/tenant") -platform_router.include_router(PackageRouter, prefix="/package") -platform_router.include_router(PluginRouter, prefix="/plugin") -platform_router.include_router(LoginLogRouter) -platform_router.include_router(TicketRouter, prefix="/ticket") +platform_router.include_router(TenantRouter) +platform_router.include_router(PackageRouter) +platform_router.include_router(PluginRouter) +platform_router.include_router(EmailRouter) +platform_router.include_router(OrderRouter) +platform_router.include_router(PaymentRouter) +platform_router.include_router(RefundRouter) +platform_router.include_router(PlatformInvoiceRouter) +platform_router.include_router(TenantInvoiceRouter) +platform_router.include_router(TenantOrderRouter) +platform_router.include_router(TenantSelfServiceRouter) +platform_router.include_router(MenuRouter) diff --git a/backend/app/core/context/__init__.py b/backend/app/api/v1/module_platform/email/__init__.py similarity index 100% rename from backend/app/core/context/__init__.py rename to backend/app/api/v1/module_platform/email/__init__.py diff --git a/backend/app/api/v1/module_platform/email/controller.py b/backend/app/api/v1/module_platform/email/controller.py new file mode 100644 index 00000000..acc05e8c --- /dev/null +++ b/backend/app/api/v1/module_platform/email/controller.py @@ -0,0 +1,375 @@ +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, Path + +from app.common.response import ResponseSchema, SuccessResponse +from app.core.base_params import PaginationQueryParam +from app.core.base_schema import AuthSchema, PageResultSchema +from app.core.dependencies import AuthPermission +from app.core.router_class import OperationLogRoute + +from .schema import ( + EmailConfigCreateSchema, + EmailConfigOutSchema, + EmailConfigQueryParam, + EmailConfigUpdateSchema, + EmailLogOutSchema, + EmailLogQueryParam, + EmailSendSchema, + EmailTemplateCreateSchema, + EmailTemplateOutSchema, + EmailTemplateQueryParam, + EmailTemplateUpdateSchema, + EmailTestSchema, +) +from .service import EmailConfigService, EmailLogService, EmailSendService, EmailTemplateService + +EmailRouter = APIRouter( + route_class=OperationLogRoute, + prefix="/email", + tags=["平台管理/邮件服务"], +) + +# ────────────────────────────────────────────────────────────── +# SMTP 配置接口 +# ────────────────────────────────────────────────────────────── + + +@EmailRouter.get( + "/config/list", + summary="SMTP 配置列表", + response_model=ResponseSchema[PageResultSchema[EmailConfigOutSchema]], +) +async def email_config_list( + page: Annotated[PaginationQueryParam, Depends()], + search: Annotated[EmailConfigQueryParam, Depends()], + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:query'])) + ], +): + """ + SMTP 配置列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (EmailConfigQueryParam): 查询筛选参数。 + + 返回: + - SuccessResponse: 包含分页配置列表的 JSON 响应。 + """ + result = await EmailConfigService.page_service( + auth=auth, + page_no=page.page_no, + page_size=page.page_size, + search=search, + order_by=page.order_by, + ) + return SuccessResponse(data=result, msg="查询成功") + + +@EmailRouter.get( + "/config/detail/{id}", + summary="SMTP 配置详情", + response_model=ResponseSchema[EmailConfigOutSchema], +) +async def email_config_detail( + id: Annotated[int, Path()], + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:query'])) + ], +): + """ + SMTP 配置详情 + + 参数: + - id (int): 配置 ID。 + + 返回: + - SuccessResponse: 包含配置详情的 JSON 响应。 + """ + result = await EmailConfigService.detail_service(auth=auth, id=id) + return SuccessResponse(data=result, msg="查询成功") + + +@EmailRouter.post( + "/config/create", + summary="创建 SMTP 配置", + response_model=ResponseSchema[EmailConfigOutSchema], +) +async def email_config_create( + data: EmailConfigCreateSchema, + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 创建 SMTP 配置 + + 参数: + - data (EmailConfigCreateSchema): 配置创建参数。 + + 返回: + - SuccessResponse: 包含创建后的配置详情的 JSON 响应。 + """ + result = await EmailConfigService.create_service(auth=auth, data=data) + return SuccessResponse(data=result, msg="创建成功") + + +@EmailRouter.put( + "/config/update/{id}", + summary="更新 SMTP 配置", + response_model=ResponseSchema[EmailConfigOutSchema], +) +async def email_config_update( + id: Annotated[int, Path()], + data: EmailConfigUpdateSchema, + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 更新 SMTP 配置 + + 参数: + - id (int): 配置 ID。 + - data (EmailConfigUpdateSchema): 配置更新参数。 + + 返回: + - SuccessResponse: 包含更新后的配置详情的 JSON 响应。 + """ + result = await EmailConfigService.update_service(auth=auth, id=id, data=data) + return SuccessResponse(data=result, msg="更新成功") + + +@EmailRouter.delete("/config/delete", summary="删除 SMTP 配置", response_model=ResponseSchema[None]) +async def email_config_delete( + ids: Annotated[list[int], Body()], + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 删除 SMTP 配置 + + 参数: + - ids (list[int]): 配置 ID 列表。 + + 返回: + - SuccessResponse: 删除结果。 + """ + await EmailConfigService.delete_service(auth=auth, ids=ids) + return SuccessResponse(msg="删除成功") + + +@EmailRouter.post("/config/test", summary="测试 SMTP 连接", response_model=ResponseSchema) +async def email_config_test( + data: EmailTestSchema, + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 测试 SMTP 连接 + + 参数: + - data (EmailTestSchema): 测试参数(配置 ID 与目标邮箱)。 + + 返回: + - SuccessResponse: 包含测试结果的 JSON 响应。 + """ + result = await EmailConfigService.test_service(auth=auth, data=data) + return SuccessResponse(data=result, msg="测试邮件已发送") + +# ────────────────────────────────────────────────────────────── +# 邮件模板接口 +# ────────────────────────────────────────────────────────────── + + +@EmailRouter.get( + "/template/list", + summary="邮件模板列表", + response_model=ResponseSchema[PageResultSchema[EmailTemplateOutSchema]], +) +async def email_template_list( + page: Annotated[PaginationQueryParam, Depends()], + search: Annotated[EmailTemplateQueryParam, Depends()], + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:query'])) + ], +): + """ + 邮件模板列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (EmailTemplateQueryParam): 查询筛选参数。 + + 返回: + - SuccessResponse: 包含分页模板列表的 JSON 响应。 + """ + result = await EmailTemplateService.page_service( + auth=auth, + page_no=page.page_no, + page_size=page.page_size, + search=search, + order_by=page.order_by, + ) + return SuccessResponse(data=result, msg="查询成功") + + +@EmailRouter.get( + "/template/detail/{id}", + summary="邮件模板详情", + response_model=ResponseSchema[EmailTemplateOutSchema], +) +async def email_template_detail( + id: Annotated[int, Path()], + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:query'])) + ], +): + """ + 邮件模板详情 + + 参数: + - id (int): 模板 ID。 + + 返回: + - SuccessResponse: 包含模板详情的 JSON 响应。 + """ + result = await EmailTemplateService.detail_service(auth=auth, id=id) + return SuccessResponse(data=result, msg="查询成功") + + +@EmailRouter.post( + "/template/create", + summary="创建邮件模板", + response_model=ResponseSchema[EmailTemplateOutSchema], +) +async def email_template_create( + data: EmailTemplateCreateSchema, + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 创建邮件模板 + + 参数: + - data (EmailTemplateCreateSchema): 模板创建参数。 + + 返回: + - SuccessResponse: 包含创建后的模板详情的 JSON 响应。 + """ + result = await EmailTemplateService.create_service(auth=auth, data=data) + return SuccessResponse(data=result, msg="创建成功") + + +@EmailRouter.put( + "/template/update/{id}", + summary="更新邮件模板", + response_model=ResponseSchema[EmailTemplateOutSchema], +) +async def email_template_update( + id: Annotated[int, Path()], + data: EmailTemplateUpdateSchema, + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 更新邮件模板 + + 参数: + - id (int): 模板 ID。 + - data (EmailTemplateUpdateSchema): 模板更新参数。 + + 返回: + - SuccessResponse: 包含更新后的模板详情的 JSON 响应。 + """ + result = await EmailTemplateService.update_service(auth=auth, id=id, data=data) + return SuccessResponse(data=result, msg="更新成功") + + +@EmailRouter.delete("/template/delete", summary="删除邮件模板", response_model=ResponseSchema[None]) +async def email_template_delete( + ids: Annotated[list[int], Body()], + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 删除邮件模板 + + 参数: + - ids (list[int]): 模板 ID 列表。 + + 返回: + - SuccessResponse: 删除结果。 + """ + await EmailTemplateService.delete_service(auth=auth, ids=ids) + return SuccessResponse(msg="删除成功") + +# ────────────────────────────────────────────────────────────── +# 邮件发送接口 +# ────────────────────────────────────────────────────────────── + + +@EmailRouter.post( + "/send", + summary="手动发送邮件(超管测试/补发)", + response_model=ResponseSchema, +) +async def email_send( + data: EmailSendSchema, + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:update'])) + ], +): + """ + 手动发送邮件 + + 参数: + - data (EmailSendSchema): 发送参数(模板代码、目标邮箱、模板变量)。 + + 返回: + - SuccessResponse: 包含发送结果的 JSON 响应。 + """ + result = await EmailSendService.manual_send_service(auth=auth, data=data) + return SuccessResponse(data=result, msg="发送成功") + +# ────────────────────────────────────────────────────────────── +# 邮件发送日志接口 +# ────────────────────────────────────────────────────────────── + + +@EmailRouter.get( + "/log/list", + summary="邮件发送日志", + response_model=ResponseSchema[PageResultSchema[EmailLogOutSchema]], +) +async def email_log_list( + page: Annotated[PaginationQueryParam, Depends()], + search: Annotated[EmailLogQueryParam, Depends()], + auth: Annotated[ + AuthSchema, Depends(AuthPermission(['module_platform:email:query'])) + ], +): + """ + 邮件发送日志 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (EmailLogQueryParam): 查询筛选参数。 + + 返回: + - SuccessResponse: 包含分页日志列表的 JSON 响应。 + """ + result = await EmailLogService.page_service( + auth=auth, + page_no=page.page_no, + page_size=page.page_size, + search=search, + order_by=page.order_by, + ) + return SuccessResponse(data=result, msg="查询成功") diff --git a/backend/app/api/v1/module_platform/email/crud.py b/backend/app/api/v1/module_platform/email/crud.py new file mode 100644 index 00000000..4097fb68 --- /dev/null +++ b/backend/app/api/v1/module_platform/email/crud.py @@ -0,0 +1,99 @@ +"""邮件模块 CRUD""" +from typing import Any + +from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema + +from .model import EmailConfigModel, EmailLogModel, EmailTemplateModel +from .schema import ( + EmailConfigCreateSchema, + EmailConfigUpdateSchema, + EmailTemplateCreateSchema, + EmailTemplateUpdateSchema, +) + + +class EmailConfigCRUD( + CRUDBase[EmailConfigModel, EmailConfigCreateSchema, EmailConfigUpdateSchema] +): + """SMTP 配置 CRUD""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=EmailConfigModel, auth=auth) + + async def get_default(self) -> EmailConfigModel | None: + return await self.get(is_default=True) + + async def clear_default(self) -> None: + """清除所有配置的 is_default 标记(不管理事务,由调用方控制)""" + from sqlalchemy import update as sa_update + + await self.db.execute( + sa_update(EmailConfigModel).where( + EmailConfigModel.is_default == True # noqa: E712 + ).values(is_default=False) + ) + + async def get_active_by_id(self, config_id: int) -> EmailConfigModel | None: + return await self.get(id=config_id, status=0) + + async def get_active_default(self) -> EmailConfigModel | None: + return await self.get(is_default=True, status=0) + + +class EmailTemplateCRUD( + CRUDBase[EmailTemplateModel, EmailTemplateCreateSchema, EmailTemplateUpdateSchema] +): + """邮件模板 CRUD""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=EmailTemplateModel, auth=auth) + + async def get_by_code(self, template_code: str) -> EmailTemplateModel | None: + return await self.get(template_code=template_code) + + async def get_active_by_code(self, template_code: str) -> EmailTemplateModel | None: + return await self.get(template_code=template_code, status=0) + + +class EmailLogCRUD(CRUDBase[EmailLogModel, Any, Any]): + """邮件日志 CRUD""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=EmailLogModel, auth=auth) + + @staticmethod + async def create_log( + to_email: str, + subject: str, + biz_type: str = "other", + to_name: str | None = None, + template_code: str | None = None, + config_id: int | None = None, + tenant_id: int | None = None, + status: int = 0, + error_msg: str | None = None, + ) -> EmailLogModel: + """写入邮件日志(独立事务,日志失败不回滚调用方业务)""" + from datetime import datetime + + from app.core.database import async_db_session + + async with async_db_session() as session: + async with session.begin(): + log_obj = EmailLogModel( + to_email=to_email, + to_name=to_name, + subject=subject, + biz_type=biz_type, + template_code=template_code, + config_id=config_id, + tenant_id=tenant_id, + status=status, + error_msg=error_msg, + sent_time=datetime.now() if status == 1 else None, + ) + session.add(log_obj) + await session.flush() + await session.refresh(log_obj) + return log_obj diff --git a/backend/app/api/v1/module_platform/email/model.py b/backend/app/api/v1/module_platform/email/model.py new file mode 100644 index 00000000..a0d07bac --- /dev/null +++ b/backend/app/api/v1/module_platform/email/model.py @@ -0,0 +1,151 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, validates + +from app.core.base_model import ModelMixin + + +class EmailConfigModel(ModelMixin): + """ + 邮件 SMTP 配置表 + + 每条记录对应一套 SMTP 配置(平台级,tenant_id=1)。 + 通过 is_default 标记当前生效的配置;只能有一条 is_default=True。 + """ + + __tablename__: str = "platform_email_config" + __table_args__: dict = {"comment": "邮件 SMTP 配置表"} + + name: Mapped[str] = mapped_column( + String(100), nullable=False, unique=True, comment="配置名称" + ) + smtp_host: Mapped[str] = mapped_column( + String(255), nullable=False, comment="SMTP 服务器地址" + ) + smtp_port: Mapped[int] = mapped_column( + Integer, nullable=False, default=465, comment="SMTP 端口(465=SSL, 587=TLS)" + ) + smtp_user: Mapped[str] = mapped_column( + String(255), nullable=False, comment="SMTP 登录用户名(发件邮箱)" + ) + smtp_password: Mapped[str] = mapped_column( + String(255), nullable=False, comment="SMTP 授权密码(AES 加密存储)" + ) + from_name: Mapped[str] = mapped_column( + String(100), nullable=False, default="FastapiAdmin", comment="发件人显示名" + ) + use_tls: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, comment="是否启用 SSL/TLS" + ) + is_default: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, comment="是否为默认配置" + ) + timeout: Mapped[int] = mapped_column( + Integer, nullable=False, default=30, comment="连接超时(秒)" + ) + + @validates("smtp_port") + def validate_port(self, key: str, value: int) -> int: + if value < 1 or value > 65535: + raise ValueError("SMTP 端口范围为 1-65535") + return value + + @validates("timeout") + def validate_timeout(self, key: str, value: int) -> int: + if value < 5 or value > 120: + raise ValueError("超时时间应在 5-120 秒之间") + return value + + +class EmailTemplateModel(ModelMixin): + """ + 邮件模板表 + + 支持变量插值(Jinja2 语法),如 {{ username }}、{{ reset_link }}。 + template_code 是唯一业务键,代码中通过 code 引用,不依赖自增 ID。 + """ + + __tablename__: str = "platform_email_template" + __table_args__: dict = {"comment": "邮件模板表"} + + name: Mapped[str] = mapped_column( + String(100), nullable=False, unique=True, comment="模板名称" + ) + template_code: Mapped[str] = mapped_column( + String(100), nullable=False, unique=True, comment="模板编码(业务键)" + ) + subject: Mapped[str] = mapped_column( + String(255), nullable=False, comment="邮件主题(可含变量)" + ) + body_html: Mapped[str] = mapped_column( + Text, nullable=False, comment="邮件正文 HTML(Jinja2 模板)" + ) + body_text: Mapped[str | None] = mapped_column( + Text, nullable=True, default=None, comment="邮件纯文本版本(降级用)" + ) + variables: Mapped[str | None] = mapped_column( + Text, + nullable=True, + default=None, + comment="模板变量说明(JSON 格式,如 {\"username\": \"用户名\", \"link\": \"链接\"})", + ) + + @validates("template_code") + def validate_code(self, key: str, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("模板编码不能为空") + return value + + +class EmailLogModel(ModelMixin): + """ + 邮件发送日志表 + + 记录每次邮件发送的结果,支持重试追踪。 + status 覆盖 ModelMixin:0=待发送 1=成功 2=失败。 + """ + + __tablename__: str = "platform_email_log" + __table_args__: dict = {"comment": "邮件发送日志表"} + + config_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("platform_email_config.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + comment="使用的 SMTP 配置 ID", + ) + template_code: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + comment="模板编码(冗余存储,模板删除后仍可追溯)", + ) + to_email: Mapped[str] = mapped_column( + String(255), nullable=False, index=True, comment="收件人邮箱" + ) + to_name: Mapped[str | None] = mapped_column( + String(100), nullable=True, comment="收件人姓名" + ) + subject: Mapped[str] = mapped_column( + String(255), nullable=False, comment="邮件主题(渲染后)" + ) + biz_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="other", + comment="业务类型(register/reset_password/invite/expiry_warning/ticket_reply/other)", + ) + error_msg: Mapped[str | None] = mapped_column( + Text, nullable=True, default=None, comment="失败原因" + ) + retry_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, comment="重试次数" + ) + tenant_id: Mapped[int | None] = mapped_column( + Integer, nullable=True, index=True, comment="关联租户 ID(可为空,如平台注册邮件)" + ) + sent_time: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True, default=None, comment="实际发送时间" + ) diff --git a/backend/app/api/v1/module_platform/email/schema.py b/backend/app/api/v1/module_platform/email/schema.py new file mode 100644 index 00000000..b10a4b64 --- /dev/null +++ b/backend/app/api/v1/module_platform/email/schema.py @@ -0,0 +1,205 @@ +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.common.enums import QueueEnum +from app.core.base_schema import BaseSchema +from app.core.validator import DateTimeStr + +# ────────────────────────────────────────────────────────────── +# EmailConfig Schemas +# ────────────────────────────────────────────────────────────── + + +class EmailConfigCreateSchema(BaseModel): + """创建 SMTP 配置""" + + name: str = Field(..., min_length=1, max_length=100, description="配置名称") + smtp_host: str = Field(..., min_length=1, max_length=255, description="SMTP 服务器地址") + smtp_port: int = Field(default=465, ge=1, le=65535, description="SMTP 端口") + smtp_user: str = Field(..., min_length=1, max_length=255, description="SMTP 登录用户名") + smtp_password: str = Field(..., min_length=1, max_length=255, description="SMTP 授权密码") + from_name: str = Field(default="FastapiAdmin", max_length=100, description="发件人显示名") + use_tls: bool = Field(default=True, description="是否启用 SSL/TLS") + is_default: bool = Field(default=False, description="是否设为默认配置") + timeout: int = Field(default=30, ge=5, le=120, description="连接超时(秒)") + description: str | None = Field(default=None, max_length=255, description="备注") + + +class EmailConfigUpdateSchema(BaseModel): + """更新 SMTP 配置""" + + name: str | None = Field(default=None, max_length=100, description="配置名称") + smtp_host: str | None = Field(default=None, max_length=255, description="SMTP 服务器地址") + smtp_user: str | None = Field(default=None, max_length=255, description="SMTP 登录用户名") + smtp_password: str | None = Field(default=None, max_length=255, description="SMTP 授权密码") + smtp_port: int | None = Field(default=None, ge=1, le=65535, description="SMTP 端口") + from_name: str | None = Field(default=None, max_length=100, description="发件人显示名") + use_tls: bool | None = Field(default=None, description="是否启用 SSL/TLS") + is_default: bool | None = Field(default=None, description="是否设为默认配置") + timeout: int | None = Field(default=None, ge=5, le=120, description="连接超时(秒)") + description: str | None = Field(default=None, max_length=255, description="备注") + + +class EmailConfigOutSchema(BaseSchema): + """SMTP 配置响应(不含密码)""" + + model_config = ConfigDict(from_attributes=True) + + name: str + smtp_host: str + smtp_port: int + smtp_user: str + from_name: str + use_tls: bool + is_default: bool + timeout: int + description: str | None = None + + +@dataclass +class EmailConfigQueryParam: + """SMTP 配置查询参数""" + + def __init__( + self, + name: str | None = None, + is_default: bool | None = None, + ) -> None: + if name: + self.name = (QueueEnum.like.value, name) + if is_default is not None: + self.is_default = (QueueEnum.eq.value, is_default) + + +# ────────────────────────────────────────────────────────────── +# EmailTemplate Schemas +# ────────────────────────────────────────────────────────────── + + +class EmailTemplateCreateSchema(BaseModel): + """创建邮件模板""" + + name: str = Field(..., min_length=1, max_length=100, description="模板名称") + template_code: str = Field(..., min_length=1, max_length=100, description="模板编码") + subject: str = Field(..., min_length=1, max_length=255, description="邮件主题") + body_html: str = Field(..., min_length=1, description="邮件正文 HTML(Jinja2 模板)") + body_text: str | None = Field(default=None, description="纯文本版本(降级用)") + variables: str | None = Field(default=None, description="变量说明 JSON") + description: str | None = Field(default=None, max_length=255, description="备注") + + @field_validator("template_code") + @classmethod + def _validate_code(cls, v: str | None) -> str | None: + if v is None: + return v + return v.strip() + + +class EmailTemplateUpdateSchema(BaseModel): + """更新邮件模板""" + + name: str | None = Field(default=None, max_length=100, description="模板名称") + template_code: str | None = Field(default=None, max_length=100, description="模板编码") + subject: str | None = Field(default=None, max_length=255, description="邮件主题") + body_html: str | None = Field(default=None, description="邮件正文 HTML") + body_text: str | None = Field(default=None, description="纯文本版本") + variables: str | None = Field(default=None, description="变量说明 JSON") + description: str | None = Field(default=None, max_length=255, description="备注") + + @field_validator("template_code") + @classmethod + def _validate_code(cls, v: str | None) -> str | None: + if v is None: + return v + return v.strip() + + +class EmailTemplateOutSchema(BaseSchema): + """邮件模板响应""" + + model_config = ConfigDict(from_attributes=True) + + name: str + template_code: str + subject: str + body_html: str + body_text: str | None = None + variables: str | None = None + description: str | None = None + + +@dataclass +class EmailTemplateQueryParam: + """邮件模板查询参数""" + + def __init__( + self, + name: str | None = None, + template_code: str | None = None, + ) -> None: + if name: + self.name = (QueueEnum.like.value, name) + if template_code: + self.template_code = (QueueEnum.like.value, template_code) + + +# ────────────────────────────────────────────────────────────── +# EmailLog Schemas +# ────────────────────────────────────────────────────────────── + + +class EmailSendSchema(BaseModel): + """手动发送邮件(测试用)""" + + to_email: str = Field(..., min_length=5, max_length=255, description="收件人邮箱") + to_name: str | None = Field(default=None, max_length=100, description="收件人姓名") + template_code: str = Field(..., min_length=1, max_length=100, description="模板编码") + variables: dict = Field(default_factory=dict, description="模板变量 {key: value}") + config_id: int | None = Field(default=None, gt=0, description="指定 SMTP 配置 ID(默认使用 is_default=True 的配置)") + biz_type: str = Field(default="other", max_length=50, description="业务类型") + + +class EmailLogOutSchema(BaseSchema): + """邮件日志响应""" + + model_config = ConfigDict(from_attributes=True) + + config_id: int | None = None + template_code: str | None = None + to_email: str + to_name: str | None = None + subject: str + biz_type: str + error_msg: str | None = None + retry_count: int + tenant_id: int | None = None + sent_time: DateTimeStr | None = None + + +@dataclass +class EmailLogQueryParam: + """邮件日志查询参数""" + + def __init__( + self, + to_email: str | None = None, + biz_type: str | None = None, + status: int | None = None, + template_code: str | None = None, + ) -> None: + if to_email: + self.to_email = (QueueEnum.like.value, to_email) + if biz_type: + self.biz_type = (QueueEnum.eq.value, biz_type) + if status is not None: + self.status = (QueueEnum.eq.value, status) + if template_code: + self.template_code = (QueueEnum.eq.value, template_code) + + +class EmailTestSchema(BaseModel): + """测试 SMTP 连接""" + + config_id: int = Field(..., gt=0, description="SMTP 配置 ID") + to_email: str = Field(..., min_length=5, max_length=255, description="测试收件人") diff --git a/backend/app/api/v1/module_platform/email/service.py b/backend/app/api/v1/module_platform/email/service.py new file mode 100644 index 00000000..05661bbc --- /dev/null +++ b/backend/app/api/v1/module_platform/email/service.py @@ -0,0 +1,347 @@ +"""邮件 Service""" +from datetime import datetime + +from app.core.base_schema import AuthSchema +from app.core.database import async_db_session +from app.core.exceptions import CustomException +from app.utils.email_util import render_template, send_email + +from .crud import EmailConfigCRUD, EmailLogCRUD, EmailTemplateCRUD +from .schema import ( + EmailConfigCreateSchema, + EmailConfigOutSchema, + EmailConfigQueryParam, + EmailConfigUpdateSchema, + EmailLogOutSchema, + EmailLogQueryParam, + EmailSendSchema, + EmailTemplateCreateSchema, + EmailTemplateOutSchema, + EmailTemplateQueryParam, + EmailTemplateUpdateSchema, + EmailTestSchema, +) + + +class EmailConfigService: + """SMTP 配置管理""" + + @classmethod + async def page_service( + cls, + auth: AuthSchema, + page_no: int, + page_size: int, + search: EmailConfigQueryParam | None = None, + order_by: list | None = None, + ) -> dict: + return await EmailConfigCRUD(auth).page( + offset=(page_no - 1) * page_size, + limit=page_size, + order_by=order_by or [{"id": "asc"}], + search=search.__dict__ if search else {}, + out_schema=EmailConfigOutSchema, + ) + + @classmethod + async def detail_service(cls, auth: AuthSchema, id: int) -> EmailConfigOutSchema: + obj = await EmailConfigCRUD(auth).get(id=id) + if not obj: + raise CustomException(msg="SMTP 配置不存在") + return EmailConfigOutSchema.model_validate(obj) + + @classmethod + async def create_service( + cls, auth: AuthSchema, data: EmailConfigCreateSchema + ) -> EmailConfigOutSchema: + crud = EmailConfigCRUD(auth) + if data.is_default: + await crud.clear_default() + obj = await crud.create(data=data) + return EmailConfigOutSchema.model_validate(obj) + + @classmethod + async def update_service( + cls, auth: AuthSchema, id: int, data: EmailConfigUpdateSchema + ) -> EmailConfigOutSchema: + crud = EmailConfigCRUD(auth) + obj = await crud.get(id=id) + if not obj: + raise CustomException(msg="SMTP 配置不存在") + if data.is_default is True: + await crud.clear_default() + updated = await crud.update(id=id, data=data) + return EmailConfigOutSchema.model_validate(updated) + + @classmethod + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + if not ids: + raise CustomException(msg="删除对象不能为空") + # 不允许删除默认配置 + for cid in ids: + obj = await EmailConfigCRUD(auth).get(id=cid) + if obj and obj.is_default: + raise CustomException( + msg=f"配置「{obj.name}」是默认配置,请先将其他配置设为默认后再删除" + ) + await EmailConfigCRUD(auth).delete(ids=ids) + + @classmethod + async def test_service(cls, auth: AuthSchema, data: EmailTestSchema) -> dict: + """测试 SMTP 连接:发送一封测试邮件""" + config = await EmailConfigCRUD(auth).get(id=data.config_id) + if not config: + raise CustomException(msg="SMTP 配置不存在") + + subject = "【FastapiAdmin】SMTP 连接测试" + body_html = ( + "
这是一封测试邮件,SMTP 配置「{name}」连接成功!
" + "发送时间:{time}
" + ).format(name=config.name, time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + + try: + await send_email( + smtp_host=config.smtp_host, + smtp_port=config.smtp_port, + smtp_user=config.smtp_user, + smtp_password=config.smtp_password, + use_tls=config.use_tls, + from_name=config.from_name, + to_email=data.to_email, + to_name=None, + subject=subject, + body_html=body_html, + timeout=config.timeout, + ) + except Exception as e: + raise CustomException(msg=f"SMTP 连接测试失败:{e!s}") from e + + return {"message": f"测试邮件已发送至 {data.to_email}"} + + +class EmailTemplateService: + """邮件模板管理""" + + @classmethod + async def page_service( + cls, + auth: AuthSchema, + page_no: int, + page_size: int, + search: EmailTemplateQueryParam | None = None, + order_by: list | None = None, + ) -> dict: + return await EmailTemplateCRUD(auth).page( + offset=(page_no - 1) * page_size, + limit=page_size, + order_by=order_by or [{"id": "asc"}], + search=search.__dict__ if search else {}, + out_schema=EmailTemplateOutSchema, + ) + + @classmethod + async def detail_service(cls, auth: AuthSchema, id: int) -> EmailTemplateOutSchema: + obj = await EmailTemplateCRUD(auth).get(id=id) + if not obj: + raise CustomException(msg="邮件模板不存在") + return EmailTemplateOutSchema.model_validate(obj) + + @classmethod + async def create_service( + cls, auth: AuthSchema, data: EmailTemplateCreateSchema + ) -> EmailTemplateOutSchema: + existing = await EmailTemplateCRUD(auth).get_by_code( + template_code=data.template_code + ) + if existing: + raise CustomException(msg=f"模板编码「{data.template_code}」已存在") + + obj = await EmailTemplateCRUD(auth).create(data=data) + return EmailTemplateOutSchema.model_validate(obj) + + @classmethod + async def update_service( + cls, auth: AuthSchema, id: int, data: EmailTemplateUpdateSchema + ) -> EmailTemplateOutSchema: + obj = await EmailTemplateCRUD(auth).get(id=id) + if not obj: + raise CustomException(msg="邮件模板不存在") + + updated = await EmailTemplateCRUD(auth).update(id=id, data=data) + return EmailTemplateOutSchema.model_validate(updated) + + @classmethod + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + if not ids: + raise CustomException(msg="删除对象不能为空") + await EmailTemplateCRUD(auth).delete(ids=ids) + + +class EmailSendService: + """ + 邮件发送服务 — 供其他模块调用。 + + 设计原则: + 1. 优先使用 is_default=True 的 SMTP 配置,无默认配置时降级为站内信(仅记录日志) + 2. 每次发送都写 platform_email_log + 3. 渲染失败/发送失败均记录 error_msg,不向调用方抛出异常(静默降级) + 4. 使用独立数据库会话:邮件发送失败不因调用方事务回滚而丢失日志 + """ + + @classmethod + async def send_by_template( + cls, + *, + to_email: str, + template_code: str, + variables: dict, + biz_type: str = "other", + to_name: str | None = None, + tenant_id: int | None = None, + config_id: int | None = None, + ) -> bool: + """ + 通过模板发送邮件(核心方法,供内部调用)。 + + 返回 True=发送成功,False=失败(已降级记录日志)。 + """ + # ① 获取模板(独立会话,不影响调用方事务) + async with async_db_session() as session: + auth = AuthSchema(db=session, check_data_scope=False) + template = await EmailTemplateCRUD(auth).get_active_by_code(template_code) + + if not template: + await EmailLogCRUD.create_log( + to_email=to_email, + subject=f"[{template_code}]", + biz_type=biz_type, + to_name=to_name, + template_code=template_code, + tenant_id=tenant_id, + status=2, + error_msg=f"模板「{template_code}」不存在或已禁用", + ) + return False + + # ② 渲染主题 & 正文 + try: + rendered_subject = render_template(template.subject, variables) + rendered_html = render_template(template.body_html, variables) + rendered_text = ( + render_template(template.body_text, variables) if template.body_text else None + ) + except Exception as e: + await EmailLogCRUD.create_log( + to_email=to_email, + subject=template.subject, + biz_type=biz_type, + to_name=to_name, + template_code=template_code, + tenant_id=tenant_id, + status=2, + error_msg=f"模板渲染失败:{e!s}", + ) + return False + + # ③ 获取 SMTP 配置(独立会话) + async with async_db_session() as session: + auth = AuthSchema(db=session, check_data_scope=False) + if config_id: + config = await EmailConfigCRUD(auth).get_active_by_id(config_id) + else: + config = await EmailConfigCRUD(auth).get_active_default() + + if not config: + await EmailLogCRUD.create_log( + to_email=to_email, + subject=rendered_subject, + biz_type=biz_type, + to_name=to_name, + template_code=template_code, + tenant_id=tenant_id, + status=2, + error_msg="无可用的 SMTP 配置,邮件未发送(已降级为站内信)", + ) + return False + + # ④ 发送 + try: + await send_email( + smtp_host=config.smtp_host, + smtp_port=config.smtp_port, + smtp_user=config.smtp_user, + smtp_password=config.smtp_password, + use_tls=config.use_tls, + from_name=config.from_name, + to_email=to_email, + to_name=to_name, + subject=rendered_subject, + body_html=rendered_html, + body_text=rendered_text, + timeout=config.timeout, + ) + except Exception as e: + await EmailLogCRUD.create_log( + to_email=to_email, + subject=rendered_subject, + biz_type=biz_type, + to_name=to_name, + template_code=template_code, + config_id=config.id, + tenant_id=tenant_id, + status=2, + error_msg=f"SMTP 发送失败:{e!s}", + ) + return False + + # ⑤ 写成功日志 + await EmailLogCRUD.create_log( + config_id=config.id, + template_code=template_code, + to_email=to_email, + to_name=to_name, + subject=rendered_subject, + biz_type=biz_type, + status=1, + tenant_id=tenant_id, + ) + + return True + + @classmethod + async def manual_send_service( + cls, auth: AuthSchema, data: EmailSendSchema + ) -> dict: + """超管手动触发发送(测试/补发用)""" + success = await cls.send_by_template( + to_email=data.to_email, + template_code=data.template_code, + variables=data.variables, + biz_type=data.biz_type, + to_name=data.to_name, + config_id=data.config_id, + ) + if not success: + raise CustomException(msg="邮件发送失败,请查看邮件日志获取详情") + return {"message": f"邮件已发送至 {data.to_email}"} + + +class EmailLogService: + """邮件日志查询""" + + @classmethod + async def page_service( + cls, + auth: AuthSchema, + page_no: int, + page_size: int, + search: EmailLogQueryParam | None = None, + order_by: list | None = None, + ) -> dict: + return await EmailLogCRUD(auth).page( + offset=(page_no - 1) * page_size, + limit=page_size, + order_by=order_by or [{"created_time": "desc"}], + search=search.__dict__ if search else {}, + out_schema=EmailLogOutSchema, + ) diff --git a/backend/app/api/v1/module_platform/invoice/__init__.py b/backend/app/api/v1/module_platform/invoice/__init__.py new file mode 100644 index 00000000..3a421edb --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/__init__.py @@ -0,0 +1,3 @@ +from .controller import PlatformInvoiceRouter, TenantInvoiceRouter + +__all__ = ["PlatformInvoiceRouter", "TenantInvoiceRouter"] diff --git a/backend/app/api/v1/module_platform/invoice/controller.py b/backend/app/api/v1/module_platform/invoice/controller.py new file mode 100644 index 00000000..85073150 --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/controller.py @@ -0,0 +1,171 @@ +"""发票管理 Controller""" +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, Path, Query +from fastapi.responses import JSONResponse + +from app.common.response import ResponseSchema, SuccessResponse +from app.core.base_schema import AuthSchema, PageResultSchema +from app.core.dependencies import AuthPermission +from app.core.exceptions import CustomException +from app.core.router_class import OperationLogRoute + +from .crud import InvoiceCRUD +from .schema import ( + InvoiceApplySchema, + InvoiceIssueSchema, + InvoiceOutSchema, + InvoiceQueryParam, + InvoiceVoidSchema, +) +from .service import InvoicePlatformService, InvoiceTenantService + +# ========== 租户端 API ========== + +TenantInvoiceRouter = APIRouter(prefix="/tenant/invoice", route_class=OperationLogRoute, tags=["平台管理/发票管理"]) + + +@TenantInvoiceRouter.post("/apply", summary="申请开票", response_model=ResponseSchema[InvoiceOutSchema]) +async def invoice_apply( + data: Annotated[InvoiceApplySchema, Body()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['*:*:*']))], +) -> JSONResponse: + """ + 申请开票 + + 参数: + - data (InvoiceApplySchema): 发票申请参数。 + + 返回: + - JSONResponse: 包含发票信息的 JSON 响应。 + """ + result = await InvoiceTenantService.apply(auth, data, auth.tenant_id) + return SuccessResponse(data=result, msg="发票申请成功") + + +@TenantInvoiceRouter.get("/list", summary="我的发票列表", response_model=ResponseSchema[PageResultSchema[InvoiceOutSchema]]) +async def invoice_list_my( + auth: Annotated[AuthSchema, Depends(AuthPermission(['*:*:*']))], + invoice_type: Annotated[str | None, Query()] = None, + status: Annotated[int | None, Query()] = None, + page_no: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 10, +) -> JSONResponse: + """ + 我的发票列表 + + 参数: + - invoice_type (str | None): 发票类型筛选。 + - status (int | None): 状态筛选。 + - page_no (int): 页码。 + - page_size (int): 每页条数。 + + 返回: + - JSONResponse: 包含分页发票列表的 JSON 响应。 + """ + result = await InvoiceTenantService.list_my( + auth, auth.tenant_id, + InvoiceQueryParam(invoice_type=invoice_type, status=status, page_no=page_no, page_size=page_size), + ) + return SuccessResponse(data=result, msg="查询成功") + + +@TenantInvoiceRouter.get("/{id}/download", summary="下载发票PDF", response_model=ResponseSchema[dict]) +async def invoice_download( + id: Annotated[int, Path(ge=1)], + auth: Annotated[AuthSchema, Depends(AuthPermission(['*:*:*']))], +) -> JSONResponse: + """ + 下载发票 PDF + + 参数: + - id (int): 发票 ID。 + + 返回: + - JSONResponse: 包含 PDF 下载地址的 JSON 响应。 + """ + crud = InvoiceCRUD(auth) + invoice = await crud.get(id=id) + if not invoice: + raise CustomException(msg="发票不存在") + if hasattr(invoice, "tenant_id") and invoice.tenant_id != auth.tenant_id: + raise CustomException(msg="发票不存在") + if invoice.status != 1 or not invoice.pdf_url: + raise CustomException(msg="发票未开具或无PDF") + return SuccessResponse(msg="下载地址", data={"pdf_url": invoice.pdf_url}) + +# ========== 平台端 API ========== + + +PlatformInvoiceRouter = APIRouter(prefix="/invoice", route_class=OperationLogRoute, tags=["平台管理/发票管理"]) + + +@PlatformInvoiceRouter.get("/list", summary="全部发票列表", response_model=ResponseSchema[PageResultSchema[InvoiceOutSchema]]) +async def invoice_list_all( + auth: Annotated[AuthSchema, Depends(AuthPermission(['*:*:*']))], + invoice_type: Annotated[str | None, Query()] = None, + status: Annotated[int | None, Query()] = None, + tenant_id: Annotated[int | None, Query()] = None, + page_no: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 10, +) -> JSONResponse: + """ + 全部发票列表(平台端) + + 参数: + - invoice_type (str | None): 发票类型筛选。 + - status (int | None): 状态筛选。 + - tenant_id (int | None): 租户 ID 筛选。 + - page_no (int): 页码。 + - page_size (int): 每页条数。 + + 返回: + - JSONResponse: 包含分页发票列表的 JSON 响应。 + """ + result = await InvoicePlatformService.list_all( + auth, + InvoiceQueryParam(invoice_type=invoice_type, status=status, tenant_id=tenant_id, page_no=page_no, page_size=page_size), + ) + return SuccessResponse(data=result, msg="查询成功") + + +@PlatformInvoiceRouter.put("/issue/{id}", summary="开具发票", response_model=ResponseSchema[InvoiceOutSchema]) +async def invoice_issue( + id: Annotated[int, Path(ge=1)], + data: Annotated[InvoiceIssueSchema, Body()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['*:*:*']))], +) -> JSONResponse: + """ + 开具发票 + + 参数: + - id (int): 发票 ID。 + - data (InvoiceIssueSchema): 开票参数(PDF 链接等)。 + + 返回: + - JSONResponse: 包含发票信息的 JSON 响应。 + """ + result = await InvoicePlatformService.issue( + auth, id, data.pdf_url or "", data.api_response or "", + ) + return SuccessResponse(data=result, msg="发票开具成功") + + +@PlatformInvoiceRouter.put("/void/{id}", summary="作废发票", response_model=ResponseSchema[InvoiceOutSchema]) +async def invoice_void( + id: Annotated[int, Path(ge=1)], + auth: Annotated[AuthSchema, Depends(AuthPermission(['*:*:*']))], + data: Annotated[InvoiceVoidSchema, Body()] = InvoiceVoidSchema(), +) -> JSONResponse: + """ + 作废发票 + + 参数: + - id (int): 发票 ID。 + - data (InvoiceVoidSchema): 作废原因。 + + 返回: + - JSONResponse: 包含发票信息的 JSON 响应。 + """ + result = await InvoicePlatformService.void(auth, id, data) + return SuccessResponse(data=result, msg="发票已作废") diff --git a/backend/app/api/v1/module_platform/invoice/crud.py b/backend/app/api/v1/module_platform/invoice/crud.py new file mode 100644 index 00000000..30c381de --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/crud.py @@ -0,0 +1,16 @@ +"""发票管理 CRUD""" +from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema + +from .model import InvoiceModel +from .schema import InvoiceCreateSchema, InvoiceUpdateSchema + + +class InvoiceCRUD(CRUDBase[InvoiceModel, InvoiceCreateSchema, InvoiceUpdateSchema]): + """发票 CRUD —— 继承 CRUDBase 获得增删改查、软删除过滤、租户隔离、权限过滤""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=InvoiceModel, auth=auth) + + async def get_by_order_id(self, order_id: int) -> InvoiceModel | None: + return await self.get(order_id=order_id) diff --git a/backend/app/api/v1/module_platform/invoice/model.py b/backend/app/api/v1/module_platform/invoice/model.py new file mode 100644 index 00000000..a193487a --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/model.py @@ -0,0 +1,26 @@ +"""发票管理 Model""" + +from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.base_model import ModelMixin, TenantMixin + + +class InvoiceModel(ModelMixin, TenantMixin): + """平台发票表 platform_invoice + status: 0=待开票 1=已开票 2=开票失败 3=已作废 + """ + __tablename__ = "platform_invoice" + __table_args__: dict[str, str] = {"comment": "发票表"} + + invoice_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="发票号码") + order_id: Mapped[int] = mapped_column(Integer, ForeignKey("platform_order.id"), nullable=False, unique=True, comment="关联订单") + invoice_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="vat_normal/vat_special") + title: Mapped[str] = mapped_column(String(200), nullable=False, comment="发票抬头") + tax_no: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="纳税人识别号") + bank_info: Mapped[str | None] = mapped_column(Text, nullable=True, comment="开户行及账号") + address_info: Mapped[str | None] = mapped_column(Text, nullable=True, comment="注册地址及电话") + amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="发票金额(分)") + tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="税额(分)") + pdf_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="PDF下载地址") + api_response: Mapped[str | None] = mapped_column(Text, nullable=True, comment="第三方API响应") diff --git a/backend/app/api/v1/module_platform/invoice/schema.py b/backend/app/api/v1/module_platform/invoice/schema.py new file mode 100644 index 00000000..8b8edfc5 --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/schema.py @@ -0,0 +1,76 @@ +"""发票管理 Schema""" +from pydantic import BaseModel, ConfigDict, Field + +from app.core.base_schema import BaseSchema + + +class InvoiceCreateSchema(BaseModel): + """创建发票(内部使用)""" + invoice_no: str + order_id: int + tenant_id: int + invoice_type: str + title: str + tax_no: str | None = None + bank_info: str | None = None + address_info: str | None = None + amount: int + tax_amount: int = 0 + status: int = 0 + description: str | None = None + + +class InvoiceUpdateSchema(BaseModel): + """更新发票(内部使用)""" + status: int | None = None + pdf_url: str | None = None + api_response: str | None = None + description: str | None = None + + +class InvoiceApplySchema(BaseModel): + """申请开票""" + order_id: int = Field(..., description="订单ID") + invoice_type: str = Field(..., description="vat_normal/vat_special") + title: str = Field(..., max_length=200, description="发票抬头") + tax_no: str | None = Field(None, max_length=50, description="纳税人识别号") + bank_info: str | None = Field(None, description="开户行及账号") + address_info: str | None = Field(None, description="注册地址及电话") + description: str | None = Field(None, description="备注") + + +class InvoiceIssueSchema(BaseModel): + """超管开票""" + api_response: str | None = Field(None, description="第三方API响应(手动填入)") + pdf_url: str | None = Field(None, description="PDF下载地址") + + +class InvoiceVoidSchema(BaseModel): + """作废发票""" + description: str | None = Field(None, description="作废原因") + + +class InvoiceOutSchema(BaseSchema): + """发票列表输出""" + invoice_no: str + order_id: int + tenant_id: int + invoice_type: str + title: str + tax_no: str | None = None + bank_info: str | None = None + address_info: str | None = None + amount: int + tax_amount: int + pdf_url: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +class InvoiceQueryParam(BaseModel): + """发票查询参数""" + invoice_type: str | None = Field(None, description="发票类型") + status: int | None = Field(None, description="状态") + tenant_id: int | None = Field(None, description="租户ID") + page_no: int = Field(1, ge=1) + page_size: int = Field(20, ge=1, le=100) diff --git a/backend/app/api/v1/module_platform/invoice/service.py b/backend/app/api/v1/module_platform/invoice/service.py new file mode 100644 index 00000000..d565a5d2 --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/service.py @@ -0,0 +1,167 @@ +"""发票管理 Service""" +import random +from datetime import date, datetime, timedelta + +from app.api.v1.module_platform.order.crud import OrderCRUD +from app.core.base_schema import AuthSchema +from app.core.exceptions import CustomException +from app.core.logger import logger + +from .crud import InvoiceCRUD +from .schema import ( + InvoiceApplySchema, + InvoiceCreateSchema, + InvoiceOutSchema, + InvoiceQueryParam, + InvoiceUpdateSchema, +) + + +def _generate_invoice_no() -> str: + """生成发票编号""" + today = date.today().strftime("%Y%m%d") + suffix = str(random.randint(100000, 999999)) + return f"INV{today}{suffix}" + + +class InvoiceTenantService: + """租户端发票服务""" + + @staticmethod + async def apply(auth: AuthSchema, data: InvoiceApplySchema, tenant_id: int) -> InvoiceOutSchema: + # 校验:专票必填字段 + if data.invoice_type == "vat_special": + if not data.tax_no: + raise CustomException(msg="增值税专用发票必须填写纳税人识别号") + if not data.bank_info: + raise CustomException(msg="增值税专用发票必须填写开户行及账号") + if not data.address_info: + raise CustomException(msg="增值税专用发票必须填写注册地址及电话") + + # 校验:订单存在且已支付 + order = await OrderCRUD(auth).get(id=data.order_id) + if not order: + raise CustomException(msg="订单不存在") + if order.status != 1: + raise CustomException(msg="仅已支付订单可申请开票") + + # 校验:30 天内 + if order.created_time and datetime.now() - order.created_time > timedelta(days=30): + raise CustomException(msg="订单支付超过 30 天,不可申请开票") + + crud = InvoiceCRUD(auth) + existing = await crud.get_by_order_id(data.order_id) + if existing: + raise CustomException(msg="该订单已申请过发票") + + tax_rate = 0 # 默认税率0%,未来可从套餐配置读取 + tax_amount = int(order.amount * tax_rate / 100) + invoice = await crud.create( + InvoiceCreateSchema( + invoice_no=_generate_invoice_no(), + order_id=data.order_id, + tenant_id=tenant_id, + invoice_type=data.invoice_type, + title=data.title, + tax_no=data.tax_no, + bank_info=data.bank_info, + address_info=data.address_info, + amount=order.amount, + tax_amount=tax_amount, + description=data.description, + ) + ) + logger.info(f"发票申请成功: invoice_no={invoice.invoice_no}, order_id={data.order_id}") + return InvoiceOutSchema.model_validate(invoice) + + @staticmethod + async def list_my(auth: AuthSchema, tenant_id: int, params: InvoiceQueryParam) -> dict: + search: dict = {"tenant_id": tenant_id} + if params.invoice_type: + search["invoice_type"] = params.invoice_type + if params.status is not None: + search["status"] = params.status + return await InvoiceCRUD(auth).page( + offset=(params.page_no - 1) * params.page_size, + limit=params.page_size, + order_by=[{"created_time": "desc"}], + search=search, + out_schema=InvoiceOutSchema, + ) + + +class InvoicePlatformService: + """平台端发票服务""" + + @staticmethod + async def list_all(auth: AuthSchema, params: InvoiceQueryParam) -> dict: + search: dict = {} + if params.invoice_type: + search["invoice_type"] = params.invoice_type + if params.status is not None: + search["status"] = params.status + if params.tenant_id: + search["tenant_id"] = params.tenant_id + return await InvoiceCRUD(auth).page( + offset=(params.page_no - 1) * params.page_size, + limit=params.page_size, + order_by=[{"created_time": "desc"}], + search=search, + out_schema=InvoiceOutSchema, + ) + + @staticmethod + async def issue(auth: AuthSchema, invoice_id: int, pdf_url: str, api_response: str) -> InvoiceOutSchema: + crud = InvoiceCRUD(auth) + invoice = await crud.get(id=invoice_id) + if not invoice: + raise CustomException(msg="发票不存在") + if invoice.status != 0: + raise CustomException(msg="仅待开票状态可操作") + + # 第三方开票 API 调用(暂为存根,对接百望云/票通时替换) + api_ok, pdf_url_result, api_response_result = await InvoicePlatformService._call_third_party_api(invoice) + + if not api_ok: + invoice = await crud.update(invoice_id, InvoiceUpdateSchema(status=2, api_response=api_response_result)) + logger.warning(f"第三方开票API调用失败: invoice_no={invoice.invoice_no}, resp={api_response_result}") + raise CustomException(msg=f"开票失败: {api_response_result}") + + invoice = await crud.update( + invoice_id, + InvoiceUpdateSchema( + status=1, + pdf_url=pdf_url or pdf_url_result, + api_response=api_response or api_response_result, + ), + ) + logger.info(f"发票开具成功: invoice_no={invoice.invoice_no}") + return InvoiceOutSchema.model_validate(invoice) + + @staticmethod + async def _call_third_party_api(invoice) -> tuple[bool, str, str]: + """第三方开票 API 存根 + + 对接百望云/票通等电子发票平台时替换此方法。 + 开发模式下直接模拟成功,返回 PDF 路径。 + + 返回: + (success: bool, pdf_url: str, api_response: str) + """ + pdf_url = f"/static/invoice/{invoice.tenant_id}/{invoice.invoice_no}.pdf" + api_response = '{"code":"SUCCESS","msg":"模拟开票成功","invoice_no":"' + invoice.invoice_no + '"}' + return True, pdf_url, api_response + + @staticmethod + async def void(auth: AuthSchema, invoice_id: int, data) -> InvoiceOutSchema: + crud = InvoiceCRUD(auth) + invoice = await crud.get(id=invoice_id) + if not invoice: + raise CustomException(msg="发票不存在") + if invoice.status != 1: + code_text = {0: "待开票", 2: "开票失败", 3: "已作废"}.get(invoice.status, "未知") + raise CustomException(msg=f"仅已开票状态可作废,当前状态: {code_text}") + description = getattr(data, "description", "") or "" + invoice = await crud.update(invoice_id, InvoiceUpdateSchema(status=3, description=description)) + logger.info(f"发票作废: invoice_no={invoice.invoice_no}") + return InvoiceOutSchema.model_validate(invoice) diff --git a/backend/app/api/v1/module_platform/loginlog/__init__.py b/backend/app/api/v1/module_platform/loginlog/__init__.py deleted file mode 100644 index 3b0a5fad..00000000 --- a/backend/app/api/v1/module_platform/loginlog/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .controller import LoginLogRouter - -__all__ = ["LoginLogRouter"] diff --git a/backend/app/api/v1/module_platform/loginlog/controller.py b/backend/app/api/v1/module_platform/loginlog/controller.py deleted file mode 100644 index 445636b0..00000000 --- a/backend/app/api/v1/module_platform/loginlog/controller.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, Path - -from app.api.v1.module_system.auth.schema import AuthSchema -from app.common.response import ResponseSchema -from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchDelete -from app.core.dependencies import AuthPermission, get_current_user - -from .schema import ( - LoginLogCreateSchema, - LoginLogDetailOutSchema, - LoginLogQueryParam, -) -from .service import LoginLogService - -LoginLogRouter = APIRouter(prefix="/loginlog", tags=["登录日志"]) - - -@LoginLogRouter.get( - "/detail/{id}", - summary="获取登录日志详情", - description="根据ID获取登录日志详情", - response_model=ResponseSchema[LoginLogDetailOutSchema], - dependencies=[Depends(AuthPermission("module_platform:login_log:query"))], -) -async def detail( - *, - id: Annotated[int, Path(gt=0)], - auth: AuthSchema = Depends(get_current_user), -): - return await LoginLogService.detail_service(auth, id) - - -@LoginLogRouter.get( - "/list", - summary="获取登录日志列表", - description="分页获取登录日志列表", - response_model=ResponseSchema[dict], - dependencies=[Depends(AuthPermission("module_platform:login_log:query"))], -) -async def list( - *, - page: Annotated[PaginationQueryParam, Depends()], - search: LoginLogQueryParam, - auth: AuthSchema = Depends(get_current_user), -): - return await LoginLogService.page_service( - auth, - page.page, - page.page_size, - search.to_dict(), - [{"id": page.order_by}], - ) - - -@LoginLogRouter.post( - "/create", - summary="创建登录日志", - description="创建登录日志(由系统自动调用)", - response_model=ResponseSchema[LoginLogDetailOutSchema], -) -async def create( - *, - data: LoginLogCreateSchema, - auth: AuthSchema = Depends(get_current_user), -): - return await LoginLogService.create_service(auth, data) - - -@LoginLogRouter.delete( - "/delete", - summary="删除登录日志", - description="批量删除登录日志", - response_model=ResponseSchema, - dependencies=[Depends(AuthPermission("module_platform:login_log:delete"))], -) -async def delete( - *, - data: BatchDelete, - auth: AuthSchema = Depends(get_current_user), -): - await LoginLogService.delete_service(auth, data.ids) - return ResponseSchema() diff --git a/backend/app/api/v1/module_platform/loginlog/crud.py b/backend/app/api/v1/module_platform/loginlog/crud.py deleted file mode 100644 index ff326ed4..00000000 --- a/backend/app/api/v1/module_platform/loginlog/crud.py +++ /dev/null @@ -1,11 +0,0 @@ -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_crud import CRUDBase - -from .model import LoginLogModel - - -class LoginLogCRUD(CRUDBase[LoginLogModel, None, None]): - """登录日志 CRUD""" - - def __init__(self, auth: AuthSchema): - super().__init__(LoginLogModel, auth) diff --git a/backend/app/api/v1/module_platform/loginlog/model.py b/backend/app/api/v1/module_platform/loginlog/model.py deleted file mode 100644 index f9c85a49..00000000 --- a/backend/app/api/v1/module_platform/loginlog/model.py +++ /dev/null @@ -1,20 +0,0 @@ -from sqlalchemy import Integer, String -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.base_model import ModelMixin, UserMixin - - -class LoginLogModel(ModelMixin, UserMixin): - """ - 登录日志模型 - """ - - __tablename__: str = "platform_login_log" - __table_args__: dict[str, str] = {"comment": "登录日志表"} - - status: Mapped[int] = mapped_column(Integer, default=1, comment="登录状态(1成功 2失败)") - login_location: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="登录位置") - login_ip: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="登录IP地址") - request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统") - request_browser: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="浏览器") - msg: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="提示消息") diff --git a/backend/app/api/v1/module_platform/loginlog/schema.py b/backend/app/api/v1/module_platform/loginlog/schema.py deleted file mode 100644 index 11ff7a42..00000000 --- a/backend/app/api/v1/module_platform/loginlog/schema.py +++ /dev/null @@ -1,47 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field, field_validator - - -class LoginLogQueryParam(BaseModel): - status: int | None = Field(None, ge=1, le=2, description="登录状态(1成功 2失败)") - username: str | None = Field(None, max_length=64, description="用户名") - - def to_dict(self) -> dict | None: - """转换为字典,仅包含非空字段""" - return self.model_dump(exclude_none=True) - - -class LoginLogOutSchema(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - username: str - status: int - login_ip: str | None = None - login_location: str | None = None - request_os: str | None = None - request_browser: str | None = None - msg: str | None = None - created_at: datetime - - -class LoginLogDetailOutSchema(LoginLogOutSchema): - pass - - -class LoginLogCreateSchema(BaseModel): - username: str = Field(..., min_length=1, max_length=64, description="用户名") - status: int = Field(1, ge=1, le=2, description="登录状态(1成功 2失败)") - login_ip: str | None = Field(None, max_length=50, description="登录IP地址") - login_location: str | None = Field(None, max_length=255, description="登录位置") - request_os: str | None = Field(None, max_length=64, description="操作系统") - request_browser: str | None = Field(None, max_length=64, description="浏览器") - msg: str | None = Field(None, max_length=255, description="提示消息") - - @field_validator("status") - @classmethod - def validate_status(cls, value: int) -> int: - if value not in [1, 2]: - raise ValueError("登录状态只能为1(成功)或2(失败)") - return value diff --git a/backend/app/api/v1/module_platform/loginlog/service.py b/backend/app/api/v1/module_platform/loginlog/service.py deleted file mode 100644 index 70d31984..00000000 --- a/backend/app/api/v1/module_platform/loginlog/service.py +++ /dev/null @@ -1,55 +0,0 @@ -from app.api.v1.module_system.auth.schema import AuthSchema -from app.api.v1.module_platform.loginlog.crud import LoginLogCRUD -from app.api.v1.module_platform.loginlog.schema import ( - LoginLogCreateSchema, - LoginLogDetailOutSchema, - LoginLogOutSchema, -) -from app.core.logger import log - - -class LoginLogService: - @staticmethod - async def create_service(auth: AuthSchema, data: LoginLogCreateSchema) -> LoginLogDetailOutSchema: - crud = LoginLogCRUD(auth) - obj = await crud.create(data.model_dump()) - return LoginLogDetailOutSchema.model_validate(obj) - - @staticmethod - async def page_service( - auth: AuthSchema, - page: int, - page_size: int, - search: dict | None = None, - order_by: list[dict[str, str]] | None = None, - ) -> dict: - crud = LoginLogCRUD(auth) - - # 构建过滤条件 - filters = {} - if search: - if search.get("status"): - filters["status"] = search["status"] - if search.get("username"): - filters["username"] = search["username"] - - result = await crud.page( - offset=(page - 1) * page_size, - limit=page_size, - order_by=order_by or [{"id": "desc"}], - search=filters, - out_schema=LoginLogOutSchema, - ) - return result - - @staticmethod - async def detail_service(auth: AuthSchema, id: int) -> LoginLogDetailOutSchema: - crud = LoginLogCRUD(auth) - obj = await crud.get(id=id) - return LoginLogDetailOutSchema.model_validate(obj) - - @staticmethod - async def delete_service(auth: AuthSchema, ids: list[int]) -> None: - crud = LoginLogCRUD(auth) - await crud.delete(ids) - log.info(f"删除登录日志成功, ids={ids}") diff --git a/backend/app/core/middleware/__init__.py b/backend/app/api/v1/module_platform/menu/__init__.py similarity index 100% rename from backend/app/core/middleware/__init__.py rename to backend/app/api/v1/module_platform/menu/__init__.py diff --git a/backend/app/api/v1/module_system/menu/controller.py b/backend/app/api/v1/module_platform/menu/controller.py similarity index 76% rename from backend/app/api/v1/module_system/menu/controller.py rename to backend/app/api/v1/module_platform/menu/controller.py index 73cbcf08..b215ec95 100644 --- a/backend/app/api/v1/module_system/menu/controller.py +++ b/backend/app/api/v1/module_platform/menu/controller.py @@ -2,29 +2,31 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, SuccessResponse -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from .schema import MenuCreateSchema, MenuOutSchema, MenuQueryParam, MenuUpdateSchema from .service import MenuService -MenuRouter = APIRouter(route_class=OperationLogRoute, prefix="/menu", tags=["菜单管理"]) +MenuRouter = APIRouter(route_class=OperationLogRoute, prefix="/menu", tags=["平台管理/菜单管理"]) + +_MENU_NS = "menu" @MenuRouter.get( "/tree", summary="查询菜单树", - description="查询菜单树", response_model=ResponseSchema[list[MenuOutSchema]], ) +@cache(expire=300, namespace=_MENU_NS) async def get_menu_tree_controller( search: Annotated[MenuQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:menu:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:menu:query']))], ) -> JSONResponse: """ 查询菜单树。 @@ -39,19 +41,17 @@ async def get_menu_tree_controller( result_dict_list = await MenuService.get_menu_tree_service( search=search, auth=auth, order_by=order_by ) - log.info("查询菜单树成功") return SuccessResponse(data=result_dict_list, msg="查询菜单树成功") @MenuRouter.get( "/detail/{id}", summary="查询菜单详情", - description="查询菜单详情", response_model=ResponseSchema[MenuOutSchema], ) async def get_obj_detail_controller( id: Annotated[int, Path(description="菜单ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:menu:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:menu:detail']))], ) -> JSONResponse: """ 查询菜单详情。 @@ -63,19 +63,17 @@ async def get_obj_detail_controller( - JSONResponse: 包含菜单详情的 JSON 响应。 """ result_dict = await MenuService.get_menu_detail_service(id=id, auth=auth) - log.info(f"查询菜单详情成功 {id}") return SuccessResponse(data=result_dict, msg="查询菜单详情成功") @MenuRouter.post( "/create", summary="创建菜单", - description="创建菜单", response_model=ResponseSchema[MenuOutSchema], ) async def create_obj_controller( data: MenuCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:menu:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:menu:create']))], ) -> JSONResponse: """ 创建菜单。 @@ -87,20 +85,19 @@ async def create_obj_controller( - JSONResponse: 包含创建菜单的 JSON 响应。 """ result_dict = await MenuService.create_menu_service(data=data, auth=auth) - log.info(f"创建菜单成功: {result_dict}") + await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(data=result_dict, msg="创建菜单成功") @MenuRouter.put( "/update/{id}", summary="修改菜单", - description="修改菜单", response_model=ResponseSchema[MenuOutSchema], ) async def update_obj_controller( data: MenuUpdateSchema, id: Annotated[int, Path(description="菜单ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:menu:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:menu:update']))], ) -> JSONResponse: """ 修改菜单。 @@ -113,19 +110,18 @@ async def update_obj_controller( - JSONResponse: 包含修改菜单的 JSON 响应。 """ result_dict = await MenuService.update_menu_service(id=id, data=data, auth=auth) - log.info(f"修改菜单成功: {result_dict}") + await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(data=result_dict, msg="修改菜单成功") @MenuRouter.delete( "/delete", summary="删除菜单", - description="删除菜单", response_model=ResponseSchema[None], ) async def delete_obj_controller( ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:menu:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:menu:delete']))], ) -> JSONResponse: """ 删除菜单。 @@ -137,19 +133,18 @@ async def delete_obj_controller( - JSONResponse: 包含删除菜单的 JSON 响应。 """ await MenuService.delete_menu_service(ids=ids, auth=auth) - log.info(f"删除菜单成功: {ids}") + await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(msg="删除菜单成功") @MenuRouter.patch( "/status/batch", summary="批量修改菜单状态", - description="批量修改菜单状态", response_model=ResponseSchema[None], ) async def batch_set_available_obj_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:menu:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:menu:patch']))], ) -> JSONResponse: """ 批量修改菜单状态。 @@ -161,5 +156,5 @@ async def batch_set_available_obj_controller( - JSONResponse: 批量修改菜单状态的 JSON 响应。 """ await MenuService.set_menu_available_service(data=data, auth=auth) - log.info(f"批量修改菜单状态成功: {data.ids}") + await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(msg="批量修改菜单状态成功") diff --git a/backend/app/api/v1/module_platform/menu/crud.py b/backend/app/api/v1/module_platform/menu/crud.py new file mode 100644 index 00000000..66fbc9cb --- /dev/null +++ b/backend/app/api/v1/module_platform/menu/crud.py @@ -0,0 +1,47 @@ +from collections.abc import Sequence + +from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema + +from .model import MenuModel +from .schema import MenuCreateSchema, MenuUpdateSchema + + +class MenuCRUD(CRUDBase[MenuModel, MenuCreateSchema, MenuUpdateSchema]): + """菜单模块数据层""" + + def __init__(self, auth: AuthSchema) -> None: + """ + 初始化菜单数据层。 + + 参数: + - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 + + 返回: + - None + """ + super().__init__(model=MenuModel, auth=auth) + + async def get_tree_list( + self, + search: dict | None = None, + order_by: list[dict] | None = None, + preload: list[str] | None = None, + ) -> Sequence[MenuModel]: + """ + 获取菜单树形列表。 + + 参数: + - search (dict | None): 搜索条件。 + - order_by (list[dict] | None): 排序字段列表。 + - preload (list[str] | None): 预加载关系,未提供时使用模型默认项 + + 返回: + - Sequence[MenuModel]: 菜单树形列表。 + """ + return await self.tree_list( + search=search, + order_by=order_by, + children_attr="children", + preload=preload, + ) diff --git a/backend/app/api/v1/module_system/menu/model.py b/backend/app/api/v1/module_platform/menu/model.py similarity index 74% rename from backend/app/api/v1/module_system/menu/model.py rename to backend/app/api/v1/module_platform/menu/model.py index 2695fda8..f021c2ed 100644 --- a/backend/app/api/v1/module_system/menu/model.py +++ b/backend/app/api/v1/module_platform/menu/model.py @@ -21,8 +21,8 @@ class MenuModel(ModelMixin): - 4: 外部链接 """ - __tablename__: str = "sys_menu" - __table_args__: dict[str, str] = {"comment": "菜单表"} + __tablename__: str = "platform_menu" + __table_args__: dict[str, str] = {"comment": "平台菜单表"} __loader_options__: list[str] = ["roles"] __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.ROLE_BASED @@ -75,11 +75,30 @@ class MenuModel(ModelMixin): server_default="pc", comment="终端(pc:管理端桌面 app:移动端)", ) + link: Mapped[str | None] = mapped_column(String(500), comment="外链地址(仅type=4)") + is_iframe: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否嵌入iframe(True:是 False:否)" + ) + is_hide_tab: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否隐藏标签页(True:是 False:否)" + ) + active_path: Mapped[str | None] = mapped_column(String(200), comment="激活菜单路径(用于高亮父级)") + show_badge: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否显示红点角标(True:是 False:否)" + ) + show_text_badge: Mapped[str | None] = mapped_column(String(20), comment="文字角标内容") + scope: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="tenant", + server_default="tenant", + comment="菜单可见范围(platform:仅平台 tenant:租户可用)", + ) # 树形结构 parent_id: Mapped[int | None] = mapped_column( Integer, - ForeignKey("sys_menu.id", ondelete="SET NULL"), + ForeignKey("platform_menu.id", ondelete="SET NULL"), default=None, index=True, comment="父菜单ID", diff --git a/backend/app/api/v1/module_system/menu/schema.py b/backend/app/api/v1/module_platform/menu/schema.py similarity index 55% rename from backend/app/api/v1/module_system/menu/schema.py rename to backend/app/api/v1/module_platform/menu/schema.py index e21e3825..6af2f072 100644 --- a/backend/app/api/v1/module_system/menu/schema.py +++ b/backend/app/api/v1/module_platform/menu/schema.py @@ -30,17 +30,27 @@ class MenuCreateSchema(BaseModel): ) affix: bool = Field(default=False, description="是否固定标签页") parent_id: int | None = Field(default=None, ge=1, description="父菜单ID") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") description: str | None = Field(default=None, max_length=255, description="描述") client: Literal["pc", "app"] = Field( default="pc", description="终端(pc:管理端桌面 app:移动端)", ) + link: str | None = Field(default=None, max_length=500, description="外链地址(仅type=4)") + is_iframe: bool = Field(default=False, description="是否嵌入iframe") + is_hide_tab: bool = Field(default=False, description="是否隐藏标签页") + active_path: str | None = Field(default=None, max_length=200, description="激活菜单路径") + show_badge: bool = Field(default=False, description="是否显示红点角标") + show_text_badge: str | None = Field(default=None, max_length=20, description="文字角标内容") + scope: Literal["platform", "tenant"] = Field( + default="tenant", + description="菜单可见范围(platform:仅平台 tenant:租户可用)", + ) @field_validator("status") @classmethod - def _validate_status(cls, v: str) -> str: - if v not in {"0", "1"}: + def _validate_status(cls, v: int) -> int: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return v @@ -59,6 +69,9 @@ class MenuCreateSchema(BaseModel): "redirect", "title", "description", + "link", + "active_path", + "show_text_badge", ]: if k in values and isinstance(values[k], str): values[k] = ( @@ -73,11 +86,7 @@ class MenuCreateSchema(BaseModel): values["parent_id"] = int(values["parent_id"].strip()) except (ValueError, TypeError): pass # parent_id 不是有效整数,保留原值 - # 路由名/路径规范 - if "route_path" in values and isinstance(values["route_path"], str): - rp = values["route_path"] - if rp and not rp.startswith("/"): - raise ValueError("路由路径需以 / 开头") + # 组件路径规范 if "component_path" in values and isinstance(values["component_path"], str): cp = values["component_path"] if cp and cp.startswith("/"): @@ -98,11 +107,79 @@ class MenuCreateSchema(BaseModel): return menu_request_validator(self) -class MenuUpdateSchema(MenuCreateSchema): - """菜单更新模型""" +class MenuUpdateSchema(BaseModel): + """菜单更新模型 — 所有字段可选""" + name: str | None = Field(default=None, min_length=1, max_length=50, description="菜单名称") + type: int | None = Field(default=None, ge=1, le=4, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)") + order: int | None = Field(default=None, ge=0, description="显示顺序") + permission: str | None = Field(default=None, max_length=100, description="权限标识") + icon: str | None = Field(default=None, max_length=50, description="菜单图标") + route_name: str | None = Field(default=None, max_length=100, description="路由名称") + route_path: str | None = Field(default=None, max_length=200, description="路由地址") + component_path: str | None = Field(default=None, max_length=200, description="组件路径") + redirect: str | None = Field(default=None, max_length=200, description="重定向地址") + hidden: bool | None = Field(default=None, description="是否隐藏") + keep_alive: bool | None = Field(default=None, description="是否缓存") + always_show: bool | None = Field(default=None, description="是否始终显示") + title: str | None = Field(default=None, max_length=50, description="菜单标题") + params: list[dict[str, str]] | None = Field(default=None, description="路由参数") + affix: bool | None = Field(default=None, description="是否固定标签页") + parent_id: int | None = Field(default=None, ge=1, description="父菜单ID") + status: int | None = Field(default=None, ge=0, le=1, description="状态(0:正常 1:禁用)") + description: str | None = Field(default=None, max_length=255, description="描述") + client: Literal["pc", "app"] | None = Field(default=None, description="终端(pc:管理端桌面 app:移动端)") + link: str | None = Field(default=None, max_length=500, description="外链地址(仅type=4)") + is_iframe: bool | None = Field(default=None, description="是否嵌入iframe") + is_hide_tab: bool | None = Field(default=None, description="是否隐藏标签页") + active_path: str | None = Field(default=None, max_length=200, description="激活菜单路径") + show_badge: bool | None = Field(default=None, description="是否显示红点角标") + show_text_badge: str | None = Field(default=None, max_length=20, description="文字角标内容") + scope: Literal["platform", "tenant"] | None = Field( + default=None, + description="菜单可见范围(platform:仅平台 tenant:租户可用)", + ) parent_name: str | None = Field(default=None, max_length=50, description="父菜单名称") + @field_validator("status") + @classmethod + def _validate_status(cls, v: int | None) -> int | None: + if v is None: + return v + if v not in {0, 1}: + raise ValueError("状态仅支持 0(正常) 或 1(禁用)") + return v + + @model_validator(mode="before") + @classmethod + def _normalize(cls, values): + if isinstance(values, dict): + for k in [ + "name", "icon", "permission", "route_name", "route_path", + "component_path", "redirect", "title", "description", + "link", "active_path", "show_text_badge", + ]: + if k in values and isinstance(values[k], str): + values[k] = values[k].strip() or None if values[k].strip() == "" else values[k].strip() + if "client" in values and isinstance(values["client"], str): + cv = values["client"].strip() + values["client"] = cv if cv in ("pc", "app") else None + if "parent_id" in values and isinstance(values["parent_id"], str): + try: + values["parent_id"] = int(values["parent_id"].strip()) + except (ValueError, TypeError): + pass + if "component_path" in values and isinstance(values["component_path"], str) and values["component_path"]: + if values["component_path"].startswith("/"): + raise ValueError("组件路径不能以 / 开头") + return values + + @model_validator(mode="after") + def validate_fields(self): + if self.type is None: + return self + return menu_request_validator(self) + class MenuOutSchema(MenuCreateSchema, BaseSchema): """菜单响应模型""" @@ -110,6 +187,7 @@ class MenuOutSchema(MenuCreateSchema, BaseSchema): model_config = ConfigDict(from_attributes=True) parent_name: str | None = Field(default=None, max_length=50, description="父菜单名称") + children: list["MenuOutSchema"] | None = Field(default=None, description="子菜单列表") class MenuQueryParam: @@ -142,6 +220,10 @@ class MenuQueryParam: None, description="管理端 Tab:pc=桌面端菜单 app=移动端菜单;不传则不过滤终端", ), + scope: Literal["tenant"] | None = Query( + None, + description="菜单范围过滤:tenant=仅租户可用菜单", + ), ) -> None: # 模糊查询字段 self.name = (QueueEnum.like.value, name) @@ -172,3 +254,6 @@ class MenuQueryParam: if menu_client in ("pc", "app"): self.client = (QueueEnum.eq.value, menu_client) + + if scope == "tenant": + self.scope = (QueueEnum.eq.value, "tenant") diff --git a/backend/app/api/v1/module_system/menu/service.py b/backend/app/api/v1/module_platform/menu/service.py similarity index 77% rename from backend/app/api/v1/module_system/menu/service.py rename to backend/app/api/v1/module_platform/menu/service.py index 441305c6..f49e9a61 100644 --- a/backend/app/api/v1/module_system/menu/service.py +++ b/backend/app/api/v1/module_platform/menu/service.py @@ -1,7 +1,6 @@ from typing import Any -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException from app.utils.common_util import ( get_child_id_map, @@ -34,10 +33,12 @@ class MenuService: 无父级时仅允许目录、菜单、外链(与前端一致)。 """ if parent_id is None: + if child_type is None: + return if child_type not in (1, 2, 4): raise CustomException(msg="顶级菜单仅允许目录、菜单或外链类型") return - parent = await MenuCRUD(auth).get_by_id_crud(id=parent_id) + parent = await MenuCRUD(auth).get(id=parent_id) if not parent: raise CustomException(msg="父级菜单不存在") pt = parent.type @@ -55,9 +56,9 @@ class MenuService: cls, auth: AuthSchema, parent_id: int | None, client: str ) -> None: """子菜单终端须与父菜单一致。""" - if parent_id is None: + if parent_id is None or client is None: return - parent = await MenuCRUD(auth).get_by_id_crud(id=parent_id) + parent = await MenuCRUD(auth).get(id=parent_id) if not parent: return p_client = getattr(parent, "client", None) or "pc" @@ -65,7 +66,7 @@ class MenuService: raise CustomException(msg="子菜单终端须与父菜单一致(均为 pc 或均为 app)") @classmethod - async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> MenuOutSchema: """ 获取菜单详情。 @@ -76,15 +77,15 @@ class MenuService: 返回: - dict: 菜单详情对象。 """ - menu = await MenuCRUD(auth).get_by_id_crud(id=id) + menu = await MenuCRUD(auth).get(id=id) # 创建实例后再设置parent_name属性 menu_out = MenuOutSchema.model_validate(menu) if menu and menu.parent_id: - parent = await MenuCRUD(auth).get_by_id_crud(id=menu.parent_id) + parent = await MenuCRUD(auth).get(id=menu.parent_id) if parent: menu_out.parent_name = parent.name - return menu_out.model_dump() + return menu_out @classmethod async def get_menu_tree_service( @@ -105,7 +106,7 @@ class MenuService: - list[dict]: 菜单树形列表对象。 """ # 使用树形结构查询,预加载children关系 - menu_list = await MenuCRUD(auth).get_tree_list_crud( + menu_list = await MenuCRUD(auth).get_tree_list( search=search.__dict__, order_by=order_by ) # 转换为字典列表 @@ -114,7 +115,7 @@ class MenuService: return traversal_to_tree(menu_dict_list) @classmethod - async def create_menu_service(cls, auth: AuthSchema, data: MenuCreateSchema) -> dict: + async def create_menu_service(cls, auth: AuthSchema, data: MenuCreateSchema) -> MenuOutSchema: """ 创建菜单。 @@ -125,22 +126,24 @@ class MenuService: 返回: - dict: 创建的菜单对象。 """ - search: dict[str, Any] = {"title": data.title} - if data.parent_id is not None: - search["parent_id"] = data.parent_id - menu = await MenuCRUD(auth).get(**search) - if menu: - raise CustomException(msg="创建失败,该菜单已存在") + search: dict[str, Any] = {} + if data.title is not None: + search["title"] = data.title + if data.parent_id is not None: + search["parent_id"] = data.parent_id + menu = await MenuCRUD(auth).get(**search) + if menu: + raise CustomException(msg="创建失败,该菜单已存在") await cls._validate_parent_child_type(auth, data.parent_id, data.type) await cls._validate_parent_child_client(auth, data.parent_id, data.client) new_menu = await MenuCRUD(auth).create(data=data) - new_menu_dict = MenuOutSchema.model_validate(new_menu).model_dump() + new_menu_dict = MenuOutSchema.model_validate(new_menu) return new_menu_dict @classmethod - async def update_menu_service(cls, auth: AuthSchema, id: int, data: MenuUpdateSchema) -> dict: + async def update_menu_service(cls, auth: AuthSchema, id: int, data: MenuUpdateSchema) -> MenuOutSchema: """ 更新菜单。 @@ -152,30 +155,31 @@ class MenuService: 返回: - dict: 更新的菜单对象。 """ - menu = await MenuCRUD(auth).get_by_id_crud(id=id) + menu = await MenuCRUD(auth).get(id=id) if not menu: raise CustomException(msg="更新失败,该菜单不存在") await cls._validate_parent_child_type(auth, data.parent_id, data.type) await cls._validate_parent_child_client(auth, data.parent_id, data.client) - search: dict[str, Any] = {"title": data.title} - if data.parent_id is not None: - search["parent_id"] = data.parent_id - exist_menu = await MenuCRUD(auth).get(**search) - if exist_menu and exist_menu.id != id: - raise CustomException(msg="更新失败,菜单标题重复") + if data.title is not None: + search: dict[str, Any] = {"title": data.title} + if data.parent_id is not None: + search["parent_id"] = data.parent_id + exist_menu = await MenuCRUD(auth).get(**search) + if exist_menu and exist_menu.id != id: + raise CustomException(msg="更新失败,菜单标题重复") if data.parent_id: - parent_menu = await MenuCRUD(auth).get_by_id_crud(id=data.parent_id) + parent_menu = await MenuCRUD(auth).get(id=data.parent_id) if not parent_menu: raise CustomException(msg="更新失败,父级菜单不存在") - data.parent_name = parent_menu.title new_menu = await MenuCRUD(auth).update(id=id, data=data) - await cls.set_menu_available_service( - auth=auth, data=BatchSetAvailable(ids=[id], status=data.status) - ) + if data.status is not None: + await cls.set_menu_available_service( + auth=auth, data=BatchSetAvailable(ids=[id], status=data.status) + ) - new_menu_dict = MenuOutSchema.model_validate(new_menu).model_dump() + new_menu_dict = MenuOutSchema.model_validate(new_menu) return new_menu_dict @classmethod @@ -194,7 +198,7 @@ class MenuService: raise CustomException(msg="删除失败,删除对象不能为空") # 获取所有菜单列表,用于构建树形关系 - all_menus = await MenuCRUD(auth).get_list_crud() + all_menus = await MenuCRUD(auth).list() # 构建子菜单ID映射 child_id_map = get_child_id_map(model_list=all_menus) @@ -225,10 +229,10 @@ class MenuService: 返回: - None """ - menu_list = await MenuCRUD(auth).get_list_crud() + menu_list = await MenuCRUD(auth).list() total_ids = [] - if data.status == "0": + if data.status == 0: # 激活,则需要把所有父级菜单都激活 id_map = get_parent_id_map(model_list=menu_list) for menu_id in data.ids: @@ -241,4 +245,4 @@ class MenuService: disable_ids = get_child_recursion(id=menu_id, id_map=id_map) total_ids.extend(disable_ids) - await MenuCRUD(auth).set_available_crud(ids=total_ids, status=data.status) + await MenuCRUD(auth).set(ids=total_ids, status=data.status) diff --git a/backend/app/api/v1/module_platform/order/__init__.py b/backend/app/api/v1/module_platform/order/__init__.py new file mode 100644 index 00000000..06039df3 --- /dev/null +++ b/backend/app/api/v1/module_platform/order/__init__.py @@ -0,0 +1,3 @@ +from .controller import OrderRouter, PaymentRouter, RefundRouter, TenantOrderRouter + +__all__ = ["OrderRouter", "PaymentRouter", "RefundRouter", "TenantOrderRouter"] diff --git a/backend/app/api/v1/module_platform/order/controller.py b/backend/app/api/v1/module_platform/order/controller.py new file mode 100644 index 00000000..6841c216 --- /dev/null +++ b/backend/app/api/v1/module_platform/order/controller.py @@ -0,0 +1,352 @@ +"""订单与支付 Controller""" +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, Path, Query, Request +from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.response import ResponseSchema, SuccessResponse +from app.core.base_schema import AuthSchema, PageResultSchema +from app.core.dependencies import AuthPermission, db_getter +from app.core.exceptions import CustomException +from app.core.logger import logger +from app.core.router_class import OperationLogRoute + +from .schema import ( + OrderCreateSchema, + OrderOutSchema, + OrderQueryParam, + OrderStatusMessage, + PaymentCreateOut, + PaymentRecordOutSchema, + PaymentStatusOut, + RefundApplySchema, + RefundOutSchema, + RefundReviewSchema, +) +from .service import OrderService, PaymentService, RefundService + +OrderRouter = APIRouter(route_class=OperationLogRoute, prefix="/order", tags=["平台管理/订单管理"]) +PaymentRouter = APIRouter(route_class=OperationLogRoute, prefix="/payment", tags=["平台管理/支付管理"]) +RefundRouter = APIRouter(route_class=OperationLogRoute, prefix="/refund", tags=["平台管理/退款管理"]) +TenantOrderRouter = APIRouter(route_class=OperationLogRoute, prefix="/order", tags=["平台管理/租户订单"]) + + +def _make_bare_auth(db: AsyncSession) -> AuthSchema: + """构造无用户上下文的 AuthSchema(支付回调等场景)""" + return AuthSchema(db=db, check_data_scope=False) + + +# ─── 平台订单 ────────────────────────────────────────── + + +@OrderRouter.post("/create", summary="创建订单", response_model=ResponseSchema[OrderOutSchema]) +async def order_create( + data: Annotated[OrderCreateSchema, Body()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:create']))], +) -> JSONResponse: + """ + 创建订单 + + 参数: + - data (OrderCreateSchema): 订单创建参数。 + + 返回: + - JSONResponse: 包含订单详情的 JSON 响应。 + """ + result = await OrderService.create_order(auth, data) + return SuccessResponse(data=result, msg="订单创建成功") + + +@OrderRouter.get("/detail/{order_id}", summary="订单详情", response_model=ResponseSchema[OrderOutSchema]) +async def order_detail( + order_id: Annotated[int, Path(ge=1)], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:query']))], +) -> JSONResponse: + """ + 订单详情 + + 参数: + - order_id (int): 订单 ID。 + + 返回: + - JSONResponse: 包含订单详情的 JSON 响应。 + """ + order = await OrderService.get_detail(auth, order_id) + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + return SuccessResponse(data=order) + + +@OrderRouter.get("/list", summary="订单列表", response_model=ResponseSchema[PageResultSchema[OrderOutSchema]]) +async def order_list( + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:query']))], + tenant_id: Annotated[int | None, Query()] = None, + status: Annotated[int | None, Query()] = None, + order_type: Annotated[str | None, Query()] = None, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 20, +) -> JSONResponse: + """ + 订单列表 + + 参数: + - tenant_id (int | None): 租户 ID 筛选。 + - status (int | None): 状态筛选。 + - order_type (str | None): 订单类型筛选。 + - page (int): 页码。 + - page_size (int): 每页条数。 + + 返回: + - JSONResponse: 包含分页订单列表的 JSON 响应。 + """ + params = OrderQueryParam(tenant_id=tenant_id, status=status, order_type=order_type) + offset = (page - 1) * page_size + items, total = await OrderService.get_list(auth, params, offset, page_size) + result = PageResultSchema( + page_no=page, + page_size=page_size, + total=total, + has_next=offset + page_size < total, + items=items, + ) + return SuccessResponse(data=result) + + +@OrderRouter.post("/cancel/{order_id}", summary="取消订单", response_model=ResponseSchema[OrderStatusMessage]) +async def order_cancel( + order_id: Annotated[int, Path(ge=1)], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:update']))], +) -> JSONResponse: + """ + 取消订单 + + 参数: + - order_id (int): 订单 ID。 + + 返回: + - JSONResponse: 包含取消结果的 JSON 响应。 + """ + result = await OrderService.cancel_order(auth, order_id) + return SuccessResponse(data=result, msg=result["message"]) + +# ─── 支付 ────────────────────────────────────────────── + + +@PaymentRouter.post("/pay/{order_id}", summary="创建支付(获取支付 URL/二维码)", response_model=ResponseSchema[PaymentCreateOut]) +async def payment_create( + order_id: Annotated[int, Path(ge=1)], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:update']))], + request: Request, + method: Annotated[str, Query(description="支付渠道: alipay / wxpay(留空=自动)")] = "", +) -> JSONResponse: + """创建支付 + + 调用支付网关生成支付 URL(H5 跳转)或二维码(Native 扫码)。 + """ + base_url = str(request.base_url).rstrip("/") + result = await PaymentService.create_payment(auth, order_id, method, base_url) + return SuccessResponse(data=result, msg="支付信息已生成") + + +@PaymentRouter.get("/status/{order_id}", summary="查询支付状态(供前端轮询)", response_model=ResponseSchema[PaymentStatusOut]) +async def payment_status( + order_id: Annotated[int, Path(ge=1)], + db: Annotated[AsyncSession, Depends(db_getter)], +) -> JSONResponse: + """查询支付状态(无需登录,前端轮询用)""" + auth = _make_bare_auth(db) + result = await OrderService.check_payment_status(auth, order_id) + return SuccessResponse(data=result) + +# ─── 支付回调 ────────────────────────────────────────── + + +@PaymentRouter.post("/callback/{method}", summary="支付回调(统一入口)", response_model=ResponseSchema[dict]) +async def payment_callback( + method: Annotated[str, Path(description="支付渠道: alipay / wxpay / mock")], + data: Annotated[dict, Body()], + db: Annotated[AsyncSession, Depends(db_getter)], +) -> JSONResponse: + """接收支付网关的异步通知(无需认证)""" + try: + auth = _make_bare_auth(db) + result = await PaymentService.handle_callback(auth, method, data) + logger.info(f"支付回调处理成功: {result}") + return SuccessResponse(data=result) + except CustomException as e: + logger.warning(f"支付回调处理失败: {e}") + return SuccessResponse(data={"message": str(e)}, code=400) + + +@PaymentRouter.post("/mock/callback", summary="Mock 支付回调(开发环境触发模拟支付)", response_model=ResponseSchema[dict]) +async def payment_mock_callback( + order_id: Annotated[int, Body(ge=1, description="订单 ID")], + db: Annotated[AsyncSession, Depends(db_getter)], +) -> JSONResponse: + """开发环境下手动触发模拟支付成功回调""" + from app.core.payment import get_mock_gateway + + from .crud import OrderCRUD + + auth = _make_bare_auth(db) + order = await OrderCRUD(auth).get_by_id(order_id) + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + + mock_gw = get_mock_gateway() + callback_data = mock_gw.get_mock_callback_data(order.id, order.order_no) + result = await PaymentService.handle_callback(auth, "mock", callback_data) + logger.info(f"Mock 支付回调触发: order_id={order_id}") + return SuccessResponse(data=result, msg="模拟支付成功") + +# ─── 支付记录 ────────────────────────────────────────── + + +@PaymentRouter.get("/record/list", summary="支付记录列表", response_model=ResponseSchema[PageResultSchema[PaymentRecordOutSchema]]) +async def payment_record_list( + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:query']))], + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 20, +) -> JSONResponse: + """ + 支付记录列表 + + 参数: + - page (int): 页码。 + - page_size (int): 每页条数。 + + 返回: + - JSONResponse: 包含分页支付记录列表的 JSON 响应。 + """ + offset = (page - 1) * page_size + items, total = await PaymentService.get_records(auth, offset, page_size) + result = PageResultSchema( + page_no=page, + page_size=page_size, + total=total, + has_next=offset + page_size < total, + items=items, + ) + return SuccessResponse(data=result) + +# ─── 退款管理 ────────────────────────────────────────── + + +@RefundRouter.get("/list", summary="退款审核列表", response_model=ResponseSchema[PageResultSchema[RefundOutSchema]]) +async def refund_list( + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:query']))], + status: Annotated[int | None, Query(description="状态筛选")] = None, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 20, +) -> JSONResponse: + """ + 退款审核列表 + + 参数: + - status (int | None): 状态筛选。 + - page (int): 页码。 + - page_size (int): 每页条数。 + + 返回: + - JSONResponse: 包含分页退款列表的 JSON 响应。 + """ + offset = (page - 1) * page_size + items, total = await RefundService.get_list(auth, status, offset, page_size) + result = PageResultSchema( + page_no=page, + page_size=page_size, + total=total, + has_next=offset + page_size < total, + items=items, + ) + return SuccessResponse(data=result) + + +@RefundRouter.put("/approve/{refund_id}", summary="批准退款", response_model=ResponseSchema[OrderStatusMessage]) +async def refund_approve( + refund_id: Annotated[int, Path(ge=1)], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:update']))], +) -> JSONResponse: + """ + 批准退款 + + 参数: + - refund_id (int): 退款申请 ID。 + + 返回: + - JSONResponse: 包含批准结果的 JSON 响应。 + """ + result = await RefundService.approve( + auth, refund_id, + auth.user.id if auth.user else 0, + auth.user.name if auth.user else "", + ) + return SuccessResponse(data=result, msg=result["message"]) + + +@RefundRouter.put("/reject/{refund_id}", summary="驳回退款", response_model=ResponseSchema[OrderStatusMessage]) +async def refund_reject( + refund_id: Annotated[int, Path(ge=1)], + data: Annotated[RefundReviewSchema, Body()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:order:update']))], +) -> JSONResponse: + """ + 驳回退款 + + 参数: + - refund_id (int): 退款申请 ID。 + - data (RefundReviewSchema): 驳回原因。 + + 返回: + - JSONResponse: 包含驳回结果的 JSON 响应。 + """ + result = await RefundService.reject( + auth, refund_id, + auth.user.id if auth.user else 0, data, + auth.user.name if auth.user else "", + ) + return SuccessResponse(data=result, msg=result["message"]) + +# ─── 租户端订单 ──────────────────────────────────────── + + +@TenantOrderRouter.post("/create", summary="租户端创建订单", response_model=ResponseSchema[OrderOutSchema]) +async def tenant_order_create( + data: Annotated[OrderCreateSchema, Body()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:order:create']))], +) -> JSONResponse: + """ + 租户端创建订单 + + 参数: + - data (OrderCreateSchema): 订单创建参数。 + + 返回: + - JSONResponse: 包含订单详情的 JSON 响应。 + """ + if data.tenant_id != auth.tenant_id: + raise HTTPException(status_code=403, detail="无权操作") + result = await OrderService.create_order(auth, data) + return SuccessResponse(data=result, msg="订单创建成功") + + +@TenantOrderRouter.post("/refund/apply/{order_id}", summary="申请退款", response_model=ResponseSchema[RefundOutSchema]) +async def tenant_refund_apply( + order_id: Annotated[int, Path(ge=1)], + data: Annotated[RefundApplySchema, Body()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:order:refund']))], +) -> JSONResponse: + """ + 租户端申请退款 + + 参数: + - order_id (int): 订单 ID。 + - data (RefundApplySchema): 退款申请参数。 + + 返回: + - JSONResponse: 包含退款申请结果的 JSON 响应。 + """ + result = await RefundService.apply(auth, data, order_id) + return SuccessResponse(data=result, msg="退款申请已提交") diff --git a/backend/app/api/v1/module_platform/order/crud.py b/backend/app/api/v1/module_platform/order/crud.py new file mode 100644 index 00000000..e16238f9 --- /dev/null +++ b/backend/app/api/v1/module_platform/order/crud.py @@ -0,0 +1,110 @@ +"""订单与支付 CRUD""" +from datetime import datetime +from typing import Any + +from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema + +from .model import OrderModel, PaymentRecordModel, RefundModel +from .schema import ( + OrderCreateInternalSchema, + OrderUpdateInternalSchema, + PaymentRecordCreateSchema, + RefundCreateSchema, + RefundUpdateSchema, +) + + +class OrderCRUD(CRUDBase[OrderModel, OrderCreateInternalSchema, OrderUpdateInternalSchema]): + """订单 CRUD""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=OrderModel, auth=auth) + + async def get_by_order_no(self, order_no: str) -> OrderModel | None: + return await self.get(order_no=order_no) + + async def query( + self, + *, + tenant_id: int | None = None, + status: int | None = None, + order_type: str | None = None, + offset: int = 0, + limit: int = 20, + ) -> tuple[list[OrderModel], int]: + result = await self.page( + search={"tenant_id": tenant_id, "status": status, "order_type": order_type}, + order_by=[{"created_time": "desc"}], + offset=offset, + limit=limit, + ) + return result.items, result.total + + async def mark_refunded(self, order_id: int) -> None: + from sqlalchemy import update as sa_update + await self.db.execute( + sa_update(OrderModel) + .where(OrderModel.id == order_id) + .where(OrderModel.is_deleted == False) # noqa: E712 + .values(status=3) + ) + + @staticmethod + async def cancel_expired_orders() -> None: + """定时任务:取消超时未支付订单""" + from sqlalchemy import update as sa_update + + from app.core.database import async_db_session + from app.core.logger import logger + + now = datetime.now() + async with async_db_session() as session: + async with session.begin(): + result = await session.execute( + sa_update(OrderModel) + .where(OrderModel.status == 0) + .where(OrderModel.expire_time < now) + .where(OrderModel.is_deleted == False) # noqa: E712 + .values(status=2) + ) + logger.info(f"超时订单取消: 已取消 {result.rowcount} 条订单") + + +class PaymentRecordCRUD(CRUDBase[PaymentRecordModel, PaymentRecordCreateSchema, Any]): + """支付记录 CRUD""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=PaymentRecordModel, auth=auth) + + async def query( + self, offset: int = 0, limit: int = 20 + ) -> tuple[list[PaymentRecordModel], int]: + result = await self.page( + order_by=[{"created_time": "desc"}], + offset=offset, + limit=limit, + search={}, + ) + return result.items, result.total + + +class RefundCRUD(CRUDBase[RefundModel, RefundCreateSchema, RefundUpdateSchema]): + """退款 CRUD""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=RefundModel, auth=auth) + + async def get_by_order_id(self, order_id: int) -> RefundModel | None: + return await self.get(order_id=order_id) + + async def query( + self, status: int | None = None, offset: int = 0, limit: int = 20 + ) -> tuple[list[RefundModel], int]: + result = await self.page( + search={"status": status}, + order_by=[{"created_time": "desc"}], + offset=offset, + limit=limit, + ) + return result.items, result.total diff --git a/backend/app/api/v1/module_platform/order/model.py b/backend/app/api/v1/module_platform/order/model.py new file mode 100644 index 00000000..4d7fdcf5 --- /dev/null +++ b/backend/app/api/v1/module_platform/order/model.py @@ -0,0 +1,67 @@ +"""订单与支付 Model""" +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.base_model import ModelMixin, TenantMixin + + +class OrderModel(ModelMixin, TenantMixin): + """platform_order — 订单表 + + 支持两种订单类型: + - 套餐订单:new/renew/upgrade/downgrade,package_id 必填 + - 插件订单:plugin,plugin_id 必填,package_id 为空 + + status: 0=待支付 1=已支付 2=已取消 3=已退款 + """ + + __tablename__ = "platform_order" + __table_args__: dict[str, str] = {"comment": "订单表"} + + order_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="订单号") + package_id: Mapped[int | None] = mapped_column(ForeignKey("platform_package.id"), nullable=True, comment="购买套餐(插件订单为空)") + plugin_id: Mapped[int | None] = mapped_column(ForeignKey("platform_plugin.id"), nullable=True, comment="购买插件(套餐订单为空)") + order_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="new/renew/upgrade/downgrade/plugin") + amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="金额(分)") + period_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1, comment="购买周期数") + pay_method: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="alipay/wxpay") + pay_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="支付时间") + expire_time: Mapped[datetime] = mapped_column(DateTime, nullable=False, comment="订单过期时间(15分钟)") + + +class PaymentRecordModel(ModelMixin, TenantMixin): + """platform_payment_record — 支付记录表 + + status: 0=处理中 1=成功 2=失败 + """ + + __tablename__ = "platform_payment_record" + __table_args__: dict[str, str] = {"comment": "支付记录表"} + + order_id: Mapped[int] = mapped_column(ForeignKey("platform_order.id"), nullable=False, comment="关联订单") + transaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, comment="第三方交易号") + pay_method: Mapped[str] = mapped_column(String(20), nullable=False, comment="支付方式") + amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="支付金额(分)") + raw_response: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始回调JSON") + pay_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="支付完成时间") + + +class RefundModel(ModelMixin, TenantMixin): + """platform_refund — 退款表 + + status: 1=申请中 2=已退款 3=已驳回 4=已取消 + """ + + __tablename__ = "platform_refund" + __table_args__: dict[str, str] = {"comment": "退款表"} + + order_id: Mapped[int] = mapped_column(ForeignKey("platform_order.id"), nullable=False, unique=True, comment="关联订单") + refund_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="退款单号") + amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="退款金额(分)") + reason: Mapped[str] = mapped_column(Text, nullable=False, comment="退款原因") + refund_transaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="退款交易号") + reviewer_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True, comment="审核人") + review_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="审核时间") + reject_reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="驳回原因") diff --git a/backend/app/api/v1/module_platform/order/schema.py b/backend/app/api/v1/module_platform/order/schema.py new file mode 100644 index 00000000..bdbb5b80 --- /dev/null +++ b/backend/app/api/v1/module_platform/order/schema.py @@ -0,0 +1,186 @@ +"""订单与支付 Schema""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.core.base_schema import BaseSchema + +# ─── Internal Create/Update Schemas(CRUD 层用,字段映射 Model 1:1)─── + + +class OrderCreateInternalSchema(BaseModel): + """订单创建(内部 CRUD 用,包含所有业务字段)""" + order_no: str + tenant_id: int + package_id: int | None = None + plugin_id: int | None = None + order_type: str + amount: int + period_count: int = 1 + pay_method: str | None = None + pay_time: datetime | None = None + expire_time: datetime + status: int = 0 + + +class OrderUpdateInternalSchema(BaseModel): + """订单更新(内部 CRUD 用)""" + status: int | None = None + pay_method: str | None = None + pay_time: datetime | None = None + + +class PaymentRecordCreateSchema(BaseModel): + """支付记录创建""" + order_id: int + transaction_id: str | None = None + pay_method: str + amount: int + status: int = 1 + raw_response: str | None = None + pay_time: datetime | None = None + + +class RefundCreateSchema(BaseModel): + """退款记录创建""" + order_id: int + refund_no: str + amount: int + reason: str + status: int = 1 + + +class RefundUpdateSchema(BaseModel): + """退款记录更新""" + status: int | None = None + reviewer_id: int | None = None + review_time: datetime | None = None + reject_reason: str | None = None + + +# ─── Order ────────────────────────────────────────────── + + +class OrderCreateSchema(BaseModel): + """创建订单(套餐或插件)""" + tenant_id: int + package_id: int | None = Field(default=None, description="套餐ID(套餐订单必填)") + plugin_id: int | None = Field(default=None, description="插件ID(插件订单必填)") + order_type: str = Field(pattern=r"^(new|renew|upgrade|downgrade|plugin)$") + pay_method: str | None = Field(default=None, pattern=r"^(alipay|wxpay)?$") + + @field_validator("tenant_id") + @classmethod + def positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("必须为正整数") + return v + + @model_validator(mode="after") + def check_target(self) -> OrderCreateSchema: + if self.order_type == "plugin": + if not self.plugin_id or self.plugin_id <= 0: + raise ValueError("插件订单必须指定 plugin_id") + else: + if not self.package_id or self.package_id <= 0: + raise ValueError("套餐订单必须指定 package_id") + return self + + +class OrderOutSchema(BaseSchema): + """订单输出""" + order_no: str + tenant_id: int + package_id: int | None = None + plugin_id: int | None = None + order_type: str + amount: int + period_count: int + pay_method: str | None = None + pay_time: datetime | None = None + expire_time: datetime + + model_config = ConfigDict(from_attributes=True) + + +class OrderQueryParam(BaseModel): + """订单查询参数""" + tenant_id: int | None = None + status: int | None = None + order_type: str | None = None + + +# ─── Payment ──────────────────────────────────────────── + +class PaymentCallbackSchema(BaseModel): + """支付回调数据""" + transaction_id: str | None = None + amount: int + order_id: int | None = None + raw_data: dict | None = None + + +class PaymentRecordOutSchema(BaseSchema): + """支付记录输出""" + order_id: int + transaction_id: str | None = None + pay_method: str + amount: int + pay_time: datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + +class PaymentCreateOut(BaseModel): + """创建支付结果""" + pay_url: str + qr_code_url: str + trade_no: str + order_id: int + order_no: str + amount: int + + +class PaymentStatusOut(BaseModel): + """支付状态查询结果""" + exists: bool + order_id: int | None = None + status: int | None = None + paid: bool = False + pay_method: str | None = None + pay_time: str | None = None + + +class OrderStatusMessage(BaseModel): + """订单/退款操作结果消息""" + id: int + status: int + message: str + + +# ─── Refund ───────────────────────────────────────────── + +class RefundApplySchema(BaseModel): + """退款申请""" + reason: str = Field(min_length=1, max_length=500) + + +class RefundReviewSchema(BaseModel): + """退款审核""" + reject_reason: str | None = Field(default=None, max_length=500) + + +class RefundOutSchema(BaseSchema): + """退款记录输出""" + order_id: int + refund_no: str + amount: int + reason: str + refund_transaction_id: str | None = None + reviewer_id: int | None = None + review_time: datetime | None = None + reject_reason: str | None = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/api/v1/module_platform/order/service.py b/backend/app/api/v1/module_platform/order/service.py new file mode 100644 index 00000000..11415fc2 --- /dev/null +++ b/backend/app/api/v1/module_platform/order/service.py @@ -0,0 +1,474 @@ +"""订单与支付 Service""" +import random +from datetime import datetime, timedelta + +from sqlalchemy import select + +from app.core.base_schema import AuthSchema +from app.core.exceptions import CustomException +from app.core.logger import logger +from app.core.payment import create_payment_gateway + +from .crud import OrderCRUD, PaymentRecordCRUD, RefundCRUD +from .model import OrderModel +from .schema import ( + OrderCreateInternalSchema, + OrderCreateSchema, + OrderOutSchema, + OrderQueryParam, + OrderStatusMessage, + OrderUpdateInternalSchema, + PaymentCreateOut, + PaymentRecordCreateSchema, + PaymentRecordOutSchema, + PaymentStatusOut, + RefundApplySchema, + RefundCreateSchema, + RefundOutSchema, + RefundReviewSchema, + RefundUpdateSchema, +) + + +def _generate_order_no() -> str: + """生成订单号:YYYYMMDD + 6位随机数""" + today = datetime.now().strftime("%Y%m%d") + suffix = str(random.randint(100000, 999999)) + return f"{today}{suffix}" + + +def _generate_refund_no() -> str: + """生成退款单号""" + today = datetime.now().strftime("%Y%m%d") + suffix = str(random.randint(100000, 999999)) + return f"RF{today}{suffix}" + + +class OrderService: + + @staticmethod + async def create_order(auth: AuthSchema, data: OrderCreateSchema, amount: int | None = None) -> OrderOutSchema: + """创建订单 + + 套餐订单:amount 从套餐价格自动计算 + 插件订单:amount 从插件价格自动计算 + """ + if amount is None: + if data.order_type == "plugin": + from app.api.v1.module_platform.plugin.model import PluginModel + plugin = await auth.db.get(PluginModel, data.plugin_id) + amount = plugin.price if plugin and hasattr(plugin, "price") else 0 + else: + from app.api.v1.module_platform.package.model import PackageModel + pkg = await auth.db.get(PackageModel, data.package_id) + amount = pkg.price if pkg and hasattr(pkg, "price") else 0 + + order = await OrderCRUD(auth).create( + OrderCreateInternalSchema( + order_no=_generate_order_no(), + tenant_id=data.tenant_id, + package_id=data.package_id, + plugin_id=data.plugin_id, + order_type=data.order_type, + amount=amount, + expire_time=datetime.now() + timedelta(minutes=15), + ) + ) + + # 免费订单自动激活 + if amount == 0: + await OrderCRUD(auth).update( + order.id, + OrderUpdateInternalSchema(status=1, pay_method="free", pay_time=datetime.now()), + ) + await PaymentService._activate_tenant_package(auth, order) + await auth.db.refresh(order) + + return OrderOutSchema.model_validate(order) + + @staticmethod + async def get_detail(auth: AuthSchema, order_id: int) -> OrderOutSchema | None: + order = await OrderCRUD(auth).get_by_id(order_id) + return OrderOutSchema.model_validate(order) if order else None + + @staticmethod + async def get_list( + auth: AuthSchema, params: OrderQueryParam, offset: int, limit: int + ) -> tuple[list, int]: + rows, total = await OrderCRUD(auth).query( + tenant_id=params.tenant_id, status=params.status, + order_type=params.order_type, offset=offset, limit=limit, + ) + items = [OrderOutSchema.model_validate(r) for r in rows] + return items, total + + @staticmethod + async def cancel_order(auth: AuthSchema, order_id: int) -> OrderStatusMessage: + crud = OrderCRUD(auth) + order = await crud.get_by_id(order_id) + if not order: + raise CustomException(msg="订单不存在") + if order.status != 0: + raise CustomException(msg="仅待支付订单可取消") + await crud.update(order_id, OrderUpdateInternalSchema(status=2)) + return OrderStatusMessage(id=order.id, status=2, message="已取消") + + @staticmethod + async def check_payment_status(auth: AuthSchema, order_id: int) -> PaymentStatusOut: + """查询订单支付状态(供前端轮询用)""" + order = await OrderCRUD(auth).get_by_id(order_id) + if not order: + return PaymentStatusOut(exists=False) + return PaymentStatusOut( + exists=True, + order_id=order.id, + status=order.status, + paid=order.status == 1, + pay_method=order.pay_method, + pay_time=order.pay_time.isoformat() if order.pay_time else None, + ) + + @staticmethod + async def cancel_expired_orders() -> None: + """定时任务:取消超时未支付的订单""" + await OrderCRUD.cancel_expired_orders() + + +class PaymentService: + + @staticmethod + async def create_payment( + auth: AuthSchema, order_id: int, method: str, notify_base_url: str + ) -> PaymentCreateOut: + """创建支付(调用支付网关)""" + from app.api.v1.module_platform.package.model import PackageModel + + order = await OrderCRUD(auth).get_by_id(order_id) + if not order: + raise CustomException(msg="订单不存在") + if order.status != 0: + raise CustomException(msg="订单状态异常,无法支付") + if order.amount <= 0: + raise CustomException(msg="免费订单无需支付") + + if order.order_type == "plugin": + from app.api.v1.module_platform.plugin.model import PluginModel + plugin = await auth.db.get(PluginModel, order.plugin_id) + subject = f"FastapiAdmin - 插件 {plugin.name}" if plugin else "FastapiAdmin 插件" + else: + pkg = await auth.db.get(PackageModel, order.package_id) + subject = f"FastapiAdmin - {pkg.name}" if pkg else "FastapiAdmin 套餐" + + notify_url = f"{notify_base_url}/api/v1/platform/payment/callback/{method}" if method else "" + + gateway = create_payment_gateway(method) + info = await gateway.create_payment( + order_no=order.order_no, + amount=order.amount, + subject=subject, + notify_url=notify_url, + ) + return PaymentCreateOut( + pay_url=info.pay_url, + qr_code_url=info.qr_code_url, + trade_no=info.trade_no, + order_id=order.id, + order_no=order.order_no, + amount=order.amount, + ) + + @staticmethod + async def handle_callback( + auth: AuthSchema, method: str, callback_data: dict + ) -> dict: + """处理支付回调""" + gateway = create_payment_gateway(method) + callback_result = await gateway.verify_callback(callback_data) + + if not callback_result.verified: + logger.warning(f"支付回调验签失败: method={method} data={callback_data}") + raise CustomException(msg="支付回调验签失败") + + order_no = callback_data.get("order_no") or callback_data.get("out_trade_no", "") + o_crud = OrderCRUD(auth) + order = None + if order_no: + order = await o_crud.get_by_order_no(order_no) + elif callback_result.order_id: + order = await o_crud.get_by_id(callback_result.order_id) + + if not order: + raise CustomException(msg="订单不存在") + if order.status != 0: + raise CustomException(msg="订单状态异常") + if order.amount != callback_result.amount and callback_result.amount > 0: + raise CustomException(msg="金额不一致") + + pid = order.package_id + tid = order.tenant_id + otype = order.order_type + oid = order.id + + await o_crud.update( + oid, + OrderUpdateInternalSchema(status=1, pay_method=method, pay_time=datetime.now()), + ) + + await PaymentRecordCRUD(auth).create( + PaymentRecordCreateSchema( + order_id=oid, + transaction_id=callback_result.transaction_id, + pay_method=method, + amount=callback_result.amount, + raw_response=str(callback_result.raw) if callback_result.raw else None, + pay_time=datetime.now(), + ) + ) + + order.package_id = pid + order.tenant_id = tid + order.order_type = otype + await PaymentService._activate_tenant_package(auth, order) + + logger.info( + f"支付回调处理完成: order_id={oid} method={method} " + f"tenant_id={tid} type={otype}" + ) + return {"order_id": oid, "status": 1, "message": "支付成功"} + + @staticmethod + async def _activate_tenant_package(auth: AuthSchema, order: OrderModel) -> None: + """支付成功后激活套餐/插件""" + from app.api.v1.module_platform.package.model import PackageModel + from app.api.v1.module_platform.tenant.model import TenantModel + + if order.order_type == "plugin": + await PaymentService._activate_plugin(auth, order) + return + + pkg = await auth.db.get(PackageModel, order.package_id) + if not pkg: + logger.warning(f"支付回调:套餐 {order.package_id} 不存在,跳过激活") + return + + tenant = await auth.db.get(TenantModel, order.tenant_id) + if not tenant: + logger.warning(f"支付回调:租户 {order.tenant_id} 不存在,跳过激活") + return + + now = datetime.now() + period_months = order.period_count or 1 + duration = timedelta(days=30 * period_months) + + if order.order_type == "new": + tenant.package_id = order.package_id + tenant.start_time = now + tenant.end_time = now + duration + tenant.status = 0 + logger.info(f"租户[{tenant.name}]新开通 {pkg.name},有效期至 {tenant.end_time}") + + elif order.order_type == "renew": + base = tenant.end_time if tenant.end_time and tenant.end_time > now else now + tenant.end_time = base + duration + tenant.status = 0 + logger.info(f"租户[{tenant.name}]续费 {pkg.name},续至 {tenant.end_time}") + + elif order.order_type in ("upgrade", "downgrade"): + if order.order_type == "downgrade": + await PaymentService._check_downgrade_quota(auth, order.tenant_id, pkg) + tenant.package_id = order.package_id + tenant.status = 0 + logger.info(f"租户[{tenant.name}]套餐变更 {'升级' if order.order_type == 'upgrade' else '降级'} → {pkg.name}") + + await auth.db.flush() + + if tenant.contact_email: + await PaymentService._send_order_email(order, pkg, tenant) + + @staticmethod + async def _activate_plugin(auth: AuthSchema, order: OrderModel) -> None: + """支付成功后标记插件为已购买""" + from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel + + plugin = await auth.db.get(PluginModel, order.plugin_id) + if not plugin: + logger.warning(f"支付回调:插件 {order.plugin_id} 不存在") + return + + result = await auth.db.execute( + select(TenantPluginModel).where( + TenantPluginModel.tenant_id == order.tenant_id, + TenantPluginModel.plugin_id == order.plugin_id, + ).limit(1) + ) + existing = result.scalar_one_or_none() + if existing: + existing.purchased = "1" + else: + tp = TenantPluginModel( + tenant_id=order.tenant_id, + plugin_id=order.plugin_id, + purchased="1", + enabled="0", + installed_time=datetime.now(), + ) + auth.db.add(tp) + + await auth.db.flush() + logger.info(f"租户[{order.tenant_id}]已购买插件[{plugin.name}]") + + from app.api.v1.module_platform.tenant.model import TenantModel + tenant = await auth.db.get(TenantModel, order.tenant_id) + if tenant and tenant.contact_email: + await PaymentService._send_order_email(order, plugin, tenant, order_type_label="购买") + + @staticmethod + async def _check_downgrade_quota( + auth: AuthSchema, tenant_id: int, new_pkg: object + ) -> None: + """降级前检查:租户当前资源数是否超过新套餐限额""" + from sqlalchemy import func, select + + from app.api.v1.module_system.dept.model import DeptModel + from app.api.v1.module_system.role.model import RoleModel + from app.api.v1.module_system.user.model import UserModel + + checks = { + "用户": (UserModel, new_pkg.max_users), + "角色": (RoleModel, new_pkg.max_roles), + "部门": (DeptModel, new_pkg.max_depts), + } + + for label, (model, limit) in checks.items(): + if limit <= 0: + continue + count_stmt = select(func.count()).select_from(model).where( + model.tenant_id == tenant_id, + model.is_deleted.is_(False), + ) + result = await auth.db.execute(count_stmt) + current = result.scalar() or 0 + if current > limit: + raise CustomException( + msg=f"降级失败:当前租户已有 {current} 个{label},超过目标套餐限额 {limit}" + ) + + @staticmethod + async def _send_order_email( + order: "OrderModel", product: object, tenant: object, order_type_label: str = "" + ) -> None: + """发送购买确认邮件(失败静默降级)""" + try: + from app.api.v1.module_platform.email.service import EmailSendService + + if not order_type_label: + order_type_labels = { + "new": "开通", + "renew": "续费", + "upgrade": "升级", + "downgrade": "降级", + "plugin": "购买", + } + order_type_label = order_type_labels.get(order.order_type, order.order_type) + + product_name = getattr(product, "name", "") if product else "" + amount_str = f"{order.amount / 100:.2f}" if order.amount else "0.00" + + await EmailSendService.send_by_template( + to_email=tenant.contact_email, + to_name=tenant.contact_name or tenant.name, + template_code="order_confirmation", + variables={ + "tenant_name": tenant.name, + "order_no": order.order_no, + "package_name": product_name, + "order_type": order_type_label, + "amount": amount_str, + }, + biz_type="order", + tenant_id=tenant.id, + ) + except Exception: + pass # 邮件发送失败不阻塞业务流程 + + @staticmethod + async def get_records( + auth: AuthSchema, offset: int, limit: int + ) -> tuple[list, int]: + rows, total = await PaymentRecordCRUD(auth).query(offset, limit) + items = [PaymentRecordOutSchema.model_validate(r) for r in rows] + return items, total + + +class RefundService: + + @staticmethod + async def apply(auth: AuthSchema, data: RefundApplySchema, order_id: int) -> RefundOutSchema: + o_crud = OrderCRUD(auth) + order = await o_crud.get_by_id(order_id) + if not order: + raise CustomException(msg="订单不存在") + if order.status != 1: + raise CustomException(msg="仅已支付订单可退款") + if order.amount == 0: + raise CustomException(msg="免费套餐不支持退款") + if order.pay_time and (datetime.now() - order.pay_time).days > 7: + raise CustomException(msg="已超过 7 天退款时限") + existing = await RefundCRUD(auth).get_by_order_id(order_id) + if existing: + raise CustomException(msg="已存在退款申请") + refund = await RefundCRUD(auth).create( + RefundCreateSchema( + order_id=order.id, + refund_no=_generate_refund_no(), + amount=order.amount, + reason=data.reason, + ) + ) + return RefundOutSchema.model_validate(refund) + + @staticmethod + async def get_list( + auth: AuthSchema, status: int | None, offset: int, limit: int + ) -> tuple[list, int]: + rows, total = await RefundCRUD(auth).query(status, offset, limit) + items = [RefundOutSchema.model_validate(r) for r in rows] + return items, total + + @staticmethod + async def approve(auth: AuthSchema, refund_id: int, reviewer_id: int, + operator_name: str = "") -> OrderStatusMessage: + crud = RefundCRUD(auth) + refund = await crud.get_by_id(refund_id) + if not refund: + raise CustomException(msg="退款记录不存在") + if refund.status != 1: + raise CustomException(msg="仅申请中可审核") + await crud.update( + refund_id, + RefundUpdateSchema(status=2, reviewer_id=reviewer_id, review_time=datetime.now()), + ) + await OrderCRUD(auth).mark_refunded(refund.order_id) + return OrderStatusMessage(id=refund.id, status=2, message="已批准退款") + + @staticmethod + async def reject( + auth: AuthSchema, refund_id: int, reviewer_id: int, + data: RefundReviewSchema, operator_name: str = "", + ) -> OrderStatusMessage: + crud = RefundCRUD(auth) + refund = await crud.get_by_id(refund_id) + if not refund: + raise CustomException(msg="退款记录不存在") + if refund.status != 1: + raise CustomException(msg="仅申请中可审核") + await crud.update( + refund_id, + RefundUpdateSchema( + status=3, + reviewer_id=reviewer_id, + review_time=datetime.now(), + reject_reason=data.reject_reason, + ), + ) + return OrderStatusMessage(id=refund.id, status=3, message="已驳回") diff --git a/backend/app/api/v1/module_platform/package/__init__.py b/backend/app/api/v1/module_platform/package/__init__.py new file mode 100644 index 00000000..b15115ed --- /dev/null +++ b/backend/app/api/v1/module_platform/package/__init__.py @@ -0,0 +1,3 @@ +from .controller import PackageRouter + +__all__ = ["PackageRouter"] diff --git a/backend/app/api/v1/module_platform/package/controller.py b/backend/app/api/v1/module_platform/package/controller.py index 482782e3..b7c4b091 100644 --- a/backend/app/api/v1/module_platform/package/controller.py +++ b/backend/app/api/v1/module_platform/package/controller.py @@ -2,53 +2,73 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, SuccessResponse from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchDelete, BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from .schema import ( PackageCreateSchema, PackageMenuSetSchema, PackageOutSchema, + PackagePluginSetSchema, PackageQueryParam, PackageUpdateSchema, ) from .service import PackageService -PackageRouter = APIRouter(route_class=OperationLogRoute, prefix="/package", tags=["套餐管理"]) +PackageRouter = APIRouter(route_class=OperationLogRoute, prefix="/package", tags=["平台管理/套餐管理"]) + +_PKG_NS = "package" @PackageRouter.get( "/detail/{id}", summary="获取套餐详情", - description="根据套餐ID获取套餐详情", response_model=ResponseSchema[PackageOutSchema], ) +@cache(expire=300, namespace=_PKG_NS) async def get_obj_detail_controller( id: Annotated[int, Path(description="套餐ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:query']))], ) -> JSONResponse: + """ + 获取套餐详情 + + 参数: + - id (int): 套餐 ID。 + + 返回: + - JSONResponse: 包含套餐详情的 JSON 响应。 + """ result_dict = await PackageService.detail_service(auth=auth, id=id) - log.info(f"获取套餐详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取套餐详情成功") @PackageRouter.get( "/list", summary="获取套餐列表", - description="分页获取套餐列表", - response_model=ResponseSchema[dict], + response_model=ResponseSchema[PageResultSchema[PackageOutSchema]], ) async def get_obj_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[PackageQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:query']))], ) -> JSONResponse: + """ + 获取套餐列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (PackageQueryParam): 查询筛选参数。 + + 返回: + - JSONResponse: 包含分页套餐列表的 JSON 响应。 + """ result_dict = await PackageService.page_service( auth=auth, page_no=page.page_no, @@ -62,75 +82,116 @@ async def get_obj_list_controller( @PackageRouter.post( "/create", summary="创建套餐", - description="创建新的租户套餐", response_model=ResponseSchema[PackageOutSchema], ) async def create_obj_controller( data: Annotated[PackageCreateSchema, Body(description="套餐信息")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:create']))], ) -> JSONResponse: + """ + 创建套餐 + + 参数: + - data (PackageCreateSchema): 套餐创建参数。 + + 返回: + - JSONResponse: 包含创建后的套餐详情的 JSON 响应。 + """ result_dict = await PackageService.create_service(auth=auth, data=data) - log.info(f"创建套餐成功 {result_dict.get('name')}") + await FastAPICache.clear(namespace=_PKG_NS) return SuccessResponse(data=result_dict, msg="创建成功") @PackageRouter.put( "/update/{id}", summary="更新套餐", - description="更新套餐信息", response_model=ResponseSchema[PackageOutSchema], ) async def update_obj_controller( id: Annotated[int, Path(description="套餐ID")], data: Annotated[PackageUpdateSchema, Body(description="套餐信息")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:update']))], ) -> JSONResponse: + """ + 更新套餐 + + 参数: + - id (int): 套餐 ID。 + - data (PackageUpdateSchema): 套餐更新参数。 + + 返回: + - JSONResponse: 包含更新后的套餐详情的 JSON 响应。 + """ result_dict = await PackageService.update_service(auth=auth, id=id, data=data) - log.info(f"更新套餐成功 {id}") + await FastAPICache.clear(namespace=_PKG_NS) return SuccessResponse(data=result_dict, msg="更新成功") @PackageRouter.delete( "/delete", summary="删除套餐", - description="批量删除套餐(已被租户使用的套餐无法删除)", response_model=ResponseSchema, ) async def delete_obj_controller( - data: Annotated[BatchDelete, Body(description="删除信息")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:delete"]))], + ids: Annotated[list[int], Body(description="ID列表")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:delete']))], ) -> JSONResponse: - await PackageService.delete_service(auth=auth, ids=data.ids) - log.info(f"删除套餐成功 {data.ids}") + """ + 删除套餐 + + 参数: + - ids (list[int]): 套餐 ID 列表。 + + 返回: + - JSONResponse: 删除结果。 + """ + await PackageService.delete_service(auth=auth, ids=ids) + await FastAPICache.clear(namespace=_PKG_NS) return SuccessResponse(msg="删除成功") @PackageRouter.patch( "/status/batch", summary="批量修改状态", - description="批量启用/禁用套餐", response_model=ResponseSchema, ) async def set_available_controller( data: Annotated[BatchSetAvailable, Body(description="状态设置")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:update']))], ) -> JSONResponse: + """ + 批量修改套餐状态 + + 参数: + - data (BatchSetAvailable): 批量状态设置参数。 + + 返回: + - JSONResponse: 操作结果。 + """ for id in data.ids: await PackageService.update_service(auth=auth, id=id, data=PackageUpdateSchema(status=data.status)) - log.info(f"套餐状态设置成功 {data.ids}") + await FastAPICache.clear(namespace=_PKG_NS) return SuccessResponse(msg="状态设置成功") @PackageRouter.get( "/menus/{package_id}", summary="获取套餐菜单", - description="获取套餐包含的菜单ID列表", response_model=ResponseSchema[list[int]], ) async def get_menus_controller( package_id: Annotated[int, Path(description="套餐ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:query']))], ) -> JSONResponse: + """ + 获取套餐关联菜单 + + 参数: + - package_id (int): 套餐 ID。 + + 返回: + - JSONResponse: 包含菜单 ID 列表的 JSON 响应。 + """ result = await PackageService.get_menus_service(auth=auth, package_id=package_id) return SuccessResponse(data=result, msg="获取成功") @@ -138,14 +199,68 @@ async def get_menus_controller( @PackageRouter.post( "/menus/{package_id}/set", summary="设置套餐菜单", - description="批量设置套餐包含的菜单(先清空再写入)", response_model=ResponseSchema, ) async def set_menus_controller( package_id: Annotated[int, Path(description="套餐ID")], data: Annotated[PackageMenuSetSchema, Body(description="菜单列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:update']))], ) -> JSONResponse: + """ + 设置套餐菜单权限 + + 参数: + - package_id (int): 套餐 ID。 + - data (PackageMenuSetSchema): 菜单 ID 列表。 + + 返回: + - JSONResponse: 操作结果。 + """ await PackageService.set_menus_service(auth=auth, package_id=package_id, data=data) - log.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}") + return SuccessResponse(msg="设置成功") + + +@PackageRouter.get( + "/plugins/{package_id}", + summary="获取套餐插件", + response_model=ResponseSchema[list[int]], +) +async def get_plugins_controller( + package_id: Annotated[int, Path(description="套餐ID")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:query']))], +) -> JSONResponse: + """ + 获取套餐关联插件 + + 参数: + - package_id (int): 套餐 ID。 + + 返回: + - JSONResponse: 包含插件 ID 列表的 JSON 响应。 + """ + result = await PackageService.get_plugins_service(auth=auth, package_id=package_id) + return SuccessResponse(data=result, msg="获取成功") + + +@PackageRouter.post( + "/plugins/{package_id}/set", + summary="设置套餐插件", + response_model=ResponseSchema, +) +async def set_plugins_controller( + package_id: Annotated[int, Path(description="套餐ID")], + data: Annotated[PackagePluginSetSchema, Body(description="插件列表")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_package:package:update']))], +) -> JSONResponse: + """ + 设置套餐插件 + + 参数: + - package_id (int): 套餐 ID。 + - data (PackagePluginSetSchema): 插件 ID 列表。 + + 返回: + - JSONResponse: 操作结果。 + """ + await PackageService.set_plugins_service(auth=auth, package_id=package_id, data=data) return SuccessResponse(msg="设置成功") diff --git a/backend/app/api/v1/module_platform/package/crud.py b/backend/app/api/v1/module_platform/package/crud.py index 6b4d83c7..4c9b12bc 100644 --- a/backend/app/api/v1/module_platform/package/crud.py +++ b/backend/app/api/v1/module_platform/package/crud.py @@ -1,11 +1,12 @@ -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import PackageModel +from .schema import PackageCreateSchema, PackageUpdateSchema -class PackageCRUD(CRUDBase): +class PackageCRUD(CRUDBase[PackageModel, PackageCreateSchema, PackageUpdateSchema]): """套餐模块 CRUD""" def __init__(self, auth: AuthSchema): - super().__init__(PackageModel, auth) + super().__init__(model=PackageModel, auth=auth) diff --git a/backend/app/api/v1/module_platform/package/model.py b/backend/app/api/v1/module_platform/package/model.py index a22e21ca..83049753 100644 --- a/backend/app/api/v1/module_platform/package/model.py +++ b/backend/app/api/v1/module_platform/package/model.py @@ -1,34 +1,34 @@ -from datetime import datetime - -from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, validates -from app.core.base_model import MappedBase +from app.core.base_model import MappedBase, ModelMixin -class PackageModel(MappedBase): +class PackageModel(ModelMixin): """ 套餐模型 - 定义租户可用的功能套餐 + + status: 0=正常 1=禁用 """ __tablename__: str = "platform_package" __table_args__: dict[str, str] = {"comment": "租户套餐表"} - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID") name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐名称") code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐编码") - status: Mapped[str] = mapped_column( - String(10), nullable=False, default="0", comment="状态(0:正常 1:禁用)" - ) sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序") - description: Mapped[str | None] = mapped_column( - String(255), nullable=True, default=None, comment="描述" - ) - create_time: Mapped[datetime] = mapped_column( - DateTime, default=datetime.now, nullable=False, comment="创建时间" - ) - update_time: Mapped[datetime] = mapped_column( - DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment="更新时间" + # ─── 定价与计费 ─── + price: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="价格(分)") + period: Mapped[str] = mapped_column(String(10), nullable=False, default="month", comment="计费周期(month/year)") + trial_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="免费试用天数") + # ─── 配额限制 ─── + max_users: Mapped[int] = mapped_column(Integer, nullable=False, default=10, comment="最大用户数") + max_roles: Mapped[int] = mapped_column(Integer, nullable=False, default=5, comment="最大角色数") + max_depts: Mapped[int] = mapped_column(Integer, nullable=False, default=10, comment="最大部门数") + max_storage_mb: Mapped[int] = mapped_column(Integer, nullable=False, default=1024, comment="最大存储(MB)") + # ─── 速率限制 ─── + rate_limit: Mapped[int] = mapped_column( + Integer, nullable=False, default=60, comment="API速率限制(请求/10秒)" ) @validates("name") @@ -67,8 +67,36 @@ class PackageMenuModel(MappedBase): ) menu_id: Mapped[int] = mapped_column( Integer, - ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"), + ForeignKey("platform_menu.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False, index=True, comment="菜单ID", ) + + +class PackagePluginModel(MappedBase): + """ + 套餐-插件关联表 — 定义套餐包含的插件资源 + """ + + __tablename__: str = "platform_package_plugin" + __table_args__ = ( + UniqueConstraint("package_id", "plugin_id", name="uq_package_plugin"), + {"comment": "套餐插件关联表"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID") + package_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("platform_package.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=False, + index=True, + comment="套餐ID", + ) + plugin_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("platform_plugin.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=False, + index=True, + comment="插件ID", + ) diff --git a/backend/app/api/v1/module_platform/package/schema.py b/backend/app/api/v1/module_platform/package/schema.py index becb7eed..d11979aa 100644 --- a/backend/app/api/v1/module_platform/package/schema.py +++ b/backend/app/api/v1/module_platform/package/schema.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -10,9 +12,17 @@ class PackageCreateSchema(BaseModel): name: str = Field(..., min_length=1, max_length=100, description="套餐名称") code: str = Field(..., min_length=2, max_length=100, description="套餐编码") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") sort: int = Field(default=0, ge=0, description="排序") description: str | None = Field(default=None, max_length=255, description="描述") + price: int = Field(default=0, ge=0, description="价格(分)") + period: str = Field(default="month", pattern=r"^(month|year)$", description="计费周期") + trial_days: int = Field(default=0, ge=0, description="免费试用天数") + max_users: int = Field(default=10, ge=0, description="最大用户数") + max_roles: int = Field(default=5, ge=0, description="最大角色数") + max_depts: int = Field(default=10, ge=0, description="最大部门数") + max_storage_mb: int = Field(default=1024, ge=0, description="最大存储(MB)") + rate_limit: int = Field(default=60, ge=10, description="API速率限制(请求/10秒)") @field_validator("name") @classmethod @@ -34,20 +44,28 @@ class PackageCreateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, v: str) -> str: - if v not in {"0", "1"}: + def _validate_status(cls, v: int) -> int: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return v -class PackageUpdateSchema(BaseModel): +class PackageUpdateSchema(PackageCreateSchema): """更新套餐""" - name: str | None = Field(default=None, max_length=100, description="套餐名称") - code: str | None = Field(default=None, max_length=100, description="套餐编码") - status: str | None = Field(default=None, max_length=1, description="状态(0:正常 1:禁用)") + name: str | None = Field(default=None, max_length=100, description="套餐名称") # type: ignore[assignment] + code: str | None = Field(default=None, max_length=100, description="套餐编码") # type: ignore[assignment] + status: int | None = Field(default=None, ge=0, le=1, description="状态(0:正常 1:禁用)") sort: int | None = Field(default=None, ge=0, description="排序") description: str | None = Field(default=None, max_length=255, description="描述") + price: int | None = Field(default=None, ge=0, description="价格(分)") + period: str | None = Field(default=None, pattern=r"^(month|year)$", description="计费周期") + trial_days: int | None = Field(default=None, ge=0, description="免费试用天数") + max_users: int | None = Field(default=None, ge=0, description="最大用户数") + max_roles: int | None = Field(default=None, ge=0, description="最大角色数") + max_depts: int | None = Field(default=None, ge=0, description="最大部门数") + max_storage_mb: int | None = Field(default=None, ge=0, description="最大存储(MB)") + rate_limit: int | None = Field(default=None, ge=10, description="API速率限制(请求/10秒)") @field_validator("code") @classmethod @@ -61,10 +79,10 @@ class PackageUpdateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, v: str | None) -> str | None: + def _validate_status(cls, v: int | None) -> int | None: if v is None: return v - if v not in {"0", "1"}: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return v @@ -75,6 +93,7 @@ class PackageOutSchema(PackageCreateSchema, BaseSchema): model_config = ConfigDict(from_attributes=True) +@dataclass class PackageQueryParam: """套餐查询参数""" @@ -96,3 +115,9 @@ class PackageMenuSetSchema(BaseModel): """批量设置套餐菜单权限""" menu_ids: list[int] = Field(..., description="菜单ID列表") + + +class PackagePluginSetSchema(BaseModel): + """批量设置套餐插件""" + + plugin_ids: list[int] = Field(..., description="插件ID列表") diff --git a/backend/app/api/v1/module_platform/package/service.py b/backend/app/api/v1/module_platform/package/service.py index f8f09650..3bfa6e95 100644 --- a/backend/app/api/v1/module_platform/package/service.py +++ b/backend/app/api/v1/module_platform/package/service.py @@ -2,16 +2,17 @@ import sqlalchemy as sa from sqlalchemy import func, select from app.api.v1.module_platform.tenant.model import TenantModel -from app.api.v1.module_system.auth.schema import AuthSchema +from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from .crud import PackageCRUD -from .model import PackageMenuModel, PackageModel +from .model import PackageMenuModel, PackageModel, PackagePluginModel from .schema import ( PackageCreateSchema, PackageMenuSetSchema, PackageOutSchema, + PackagePluginSetSchema, PackageQueryParam, PackageUpdateSchema, ) @@ -21,11 +22,11 @@ class PackageService: """套餐模块服务层""" @classmethod - async def detail_service(cls, auth: AuthSchema, id: int) -> dict: - obj = await PackageCRUD(auth).get_by_id_crud(id=id) + async def detail_service(cls, auth: AuthSchema, id: int) -> PackageOutSchema: + obj = await PackageCRUD(auth).get(id=id) if not obj: raise CustomException(msg="套餐不存在") - return PackageOutSchema.model_validate(obj).model_dump() + return PackageOutSchema.model_validate(obj) @classmethod async def page_service( @@ -36,7 +37,7 @@ class PackageService: search: PackageQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> dict: - return await PackageCRUD(auth).page_crud( + return await PackageCRUD(auth).page( offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"sort": "asc"}, {"id": "asc"}], @@ -45,24 +46,22 @@ class PackageService: ) @classmethod - async def create_service(cls, auth: AuthSchema, data: PackageCreateSchema) -> dict: + async def create_service(cls, auth: AuthSchema, data: PackageCreateSchema) -> PackageOutSchema: if await PackageCRUD(auth).get(name=data.name): raise CustomException(msg="创建失败,套餐名称已存在") if await PackageCRUD(auth).get(code=data.code): raise CustomException(msg="创建失败,套餐编码已存在") - obj = await PackageCRUD(auth).create_crud(data=data) - if not obj: - raise CustomException(msg="创建套餐失败") - result = PackageOutSchema.model_validate(obj).model_dump() - log.info(f"创建套餐成功: {result.get('name')}") + obj = await PackageCRUD(auth).create(data=data) + result = PackageOutSchema.model_validate(obj) + logger.info(f"创建套餐成功: {result.name}") return result @classmethod async def update_service( cls, auth: AuthSchema, id: int, data: PackageUpdateSchema - ) -> dict: - obj = await PackageCRUD(auth).get_by_id_crud(id=id) + ) -> PackageOutSchema: + obj = await PackageCRUD(auth).get(id=id) if not obj: raise CustomException(msg="套餐不存在") @@ -75,10 +74,13 @@ class PackageService: if exist and exist.id != id: raise CustomException(msg="更新失败,编码重复") - updated = await PackageCRUD(auth).update_crud(id=id, data=data) - if not updated: - raise CustomException(msg="更新失败") - return PackageOutSchema.model_validate(updated).model_dump() + # 套餐禁用时记录受影响租户 + if data.status is not None and data.status == 1 and obj.status == 0: + await cls.disable_cascade_service(auth=auth, package_id=id) + + updated = await PackageCRUD(auth).update(id=id, data=data) + + return PackageOutSchema.model_validate(updated) @classmethod async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: @@ -92,7 +94,20 @@ class PackageService: if count and count > 0: raise CustomException(msg=f"套餐 ID={pid} 已被 {count} 个租户使用,无法删除") - await PackageCRUD(auth).delete_crud(ids=ids) + await PackageCRUD(auth).delete(ids=ids) + + @classmethod + async def disable_cascade_service(cls, auth: AuthSchema, package_id: int) -> None: + """套餐禁用时日志记录受影响租户""" + stmt = select(TenantModel.id, TenantModel.name).where( + TenantModel.package_id == package_id, + TenantModel.status == 0, + ) + result = await auth.db.execute(stmt) + rows = result.all() + if rows: + tenant_ids = [row[0] for row in rows] + logger.warning(f"套餐[{package_id}]已禁用,影响租户: {tenant_ids}") @classmethod async def get_menus_service(cls, auth: AuthSchema, package_id: int) -> list[int]: @@ -105,12 +120,13 @@ class PackageService: async def set_menus_service( cls, auth: AuthSchema, package_id: int, data: PackageMenuSetSchema ) -> None: + """批量设置套餐菜单权限(先清空再写入)""" await auth.db.execute(sa.delete(PackageMenuModel).where(PackageMenuModel.package_id == package_id)) for menu_id in data.menu_ids: auth.db.add(PackageMenuModel(package_id=package_id, menu_id=menu_id)) await auth.db.flush() - log.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}") + logger.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}") @staticmethod async def get_package_menu_ids(auth: AuthSchema, package_id: int) -> list[int]: @@ -121,8 +137,17 @@ class PackageService: @staticmethod async def get_tenant_available_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]: - """获取租户的完整可用菜单ID列表(套餐菜单 + 自定义授权菜单)""" - from app.api.v1.module_platform.tenant.model import TenantMenuModel, TenantModel + """获取租户的完整可用菜单ID列表(仅从套餐菜单获取) + + 平台租户 (id=1) 返回全部启用菜单,不受套餐限制。 + """ + from app.api.v1.module_platform.menu.model import MenuModel + from app.api.v1.module_platform.tenant.model import TenantModel + + if tenant_id == 1: + menu_stmt = select(MenuModel.id).where(MenuModel.status == 0) + result = await auth.db.execute(menu_stmt) + return [row[0] for row in result.all()] stmt = select(TenantModel).where(TenantModel.id == tenant_id).limit(1) result = await auth.db.execute(stmt) @@ -130,21 +155,60 @@ class PackageService: if not tenant: return [] - all_menu_ids: set[int] = set() + if not tenant.package_id: + return [] - if tenant.package_id: - pkg_stmt = select(PackageModel.status).where(PackageModel.id == tenant.package_id).limit(1) - pkg_result = await auth.db.execute(pkg_stmt) - pkg_status = pkg_result.scalar_one_or_none() - if pkg_status == "0": - stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == tenant.package_id) - result = await auth.db.execute(stmt) - for row in result.all(): - all_menu_ids.add(row[0]) + pkg_stmt = select(PackageModel.status).where(PackageModel.id == tenant.package_id).limit(1) + pkg_result = await auth.db.execute(pkg_stmt) + pkg_status = pkg_result.scalar_one_or_none() + if pkg_status != 0: + return [] - stmt = select(TenantMenuModel.menu_id).where(TenantMenuModel.tenant_id == tenant_id) + menu_stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == tenant.package_id) + result = await auth.db.execute(menu_stmt) + return [row[0] for row in result.all()] + + @staticmethod + async def get_tenant_available_plugin_ids(auth: AuthSchema, tenant_id: int) -> list[int]: + """获取租户套餐可用的插件ID列表""" + from app.api.v1.module_platform.tenant.model import TenantModel + + stmt = select(TenantModel).where(TenantModel.id == tenant_id).limit(1) result = await auth.db.execute(stmt) - for row in result.all(): - all_menu_ids.add(row[0]) + tenant = result.scalar_one_or_none() + if not tenant or not tenant.package_id: + return [] - return list(all_menu_ids) + pkg_stmt = select(PackageModel.status).where(PackageModel.id == tenant.package_id).limit(1) + pkg_result = await auth.db.execute(pkg_stmt) + pkg_status = pkg_result.scalar_one_or_none() + if pkg_status != 0: + return [] + + plugin_stmt = select(PackagePluginModel.plugin_id).where( + PackagePluginModel.package_id == tenant.package_id + ) + result = await auth.db.execute(plugin_stmt) + return [row[0] for row in result.all()] + + @classmethod + async def get_plugins_service(cls, auth: AuthSchema, package_id: int) -> list[int]: + """获取套餐插件权限(返回 plugin_id 列表)""" + stmt = select(PackagePluginModel.plugin_id).where( + PackagePluginModel.package_id == package_id + ) + result = await auth.db.execute(stmt) + return [row[0] for row in result.all()] + + @classmethod + async def set_plugins_service( + cls, auth: AuthSchema, package_id: int, data: PackagePluginSetSchema + ) -> None: + """批量设置套餐插件(先清空再写入)""" + await auth.db.execute( + sa.delete(PackagePluginModel).where(PackagePluginModel.package_id == package_id) + ) + for plugin_id in data.plugin_ids: + auth.db.add(PackagePluginModel(package_id=package_id, plugin_id=plugin_id)) + await auth.db.flush() + logger.info(f"套餐[{package_id}]插件已设置, count={len(data.plugin_ids)}") diff --git a/backend/app/api/v1/module_platform/plugin/__init__.py b/backend/app/api/v1/module_platform/plugin/__init__.py index e69de29b..55b9cd2c 100644 --- a/backend/app/api/v1/module_platform/plugin/__init__.py +++ b/backend/app/api/v1/module_platform/plugin/__init__.py @@ -0,0 +1,3 @@ +from .controller import PluginRouter + +__all__ = ["PluginRouter"] diff --git a/backend/app/api/v1/module_platform/plugin/controller.py b/backend/app/api/v1/module_platform/plugin/controller.py index 5befb03e..5725f945 100644 --- a/backend/app/api/v1/module_platform/plugin/controller.py +++ b/backend/app/api/v1/module_platform/plugin/controller.py @@ -1,10 +1,13 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path +from fastapi.responses import JSONResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, SuccessResponse from app.core.base_params import PaginationQueryParam +from app.core.base_schema import AuthSchema, PageResultSchema from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute @@ -17,18 +20,30 @@ from .schema import ( ) from .service import PluginService -PluginRouter = APIRouter(route_class=OperationLogRoute, prefix="/plugin", tags=["插件管理"]) +PluginRouter = APIRouter(route_class=OperationLogRoute, prefix="/plugin", tags=["平台管理/插件管理"]) +_PLUGIN_NS = "plugin" # ───── 超管:插件 CRUD ───── -@PluginRouter.get("/list", summary="插件列表", response_model=ResponseSchema[dict]) +@PluginRouter.get("/list", summary="插件列表", response_model=ResponseSchema[PageResultSchema[PluginOutSchema]]) +@cache(expire=300, namespace=_PLUGIN_NS) async def plugin_list( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[PluginQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:query']))], +) -> JSONResponse: + """ + 插件列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (PluginQueryParam): 查询筛选参数。 + + 返回: + - JSONResponse: 包含分页插件列表的 JSON 响应。 + """ r = await PluginService.page_service(auth, page.page_no, page.page_size, search, page.order_by) return SuccessResponse(data=r, msg="查询成功") @@ -38,17 +53,37 @@ async def plugin_list( ) async def plugin_detail( id: Annotated[int, Path()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:query']))], +) -> JSONResponse: + """ + 插件详情 + + 参数: + - id (int): 插件 ID。 + + 返回: + - JSONResponse: 包含插件详情的 JSON 响应。 + """ return SuccessResponse(data=await PluginService.detail_service(auth, id), msg="查询成功") @PluginRouter.post("/create", summary="创建插件", response_model=ResponseSchema[PluginOutSchema]) async def plugin_create( data: PluginCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:create"]))], -): - return SuccessResponse(data=await PluginService.create_service(auth, data), msg="创建成功") + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:create']))], +) -> JSONResponse: + """ + 创建插件 + + 参数: + - data (PluginCreateSchema): 插件创建参数。 + + 返回: + - JSONResponse: 包含创建后的插件详情的 JSON 响应。 + """ + r = await PluginService.create_service(auth, data) + await FastAPICache.clear(namespace=_PLUGIN_NS) + return SuccessResponse(data=r, msg="创建成功") @PluginRouter.put( @@ -57,62 +92,146 @@ async def plugin_create( async def plugin_update( id: Annotated[int, Path()], data: PluginUpdateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:update"]))], -): - return SuccessResponse(data=await PluginService.update_service(auth, id, data), msg="更新成功") + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:update']))], +) -> JSONResponse: + """ + 更新插件 + + 参数: + - id (int): 插件 ID。 + - data (PluginUpdateSchema): 插件更新参数。 + + 返回: + - JSONResponse: 包含更新后的插件详情的 JSON 响应。 + """ + r = await PluginService.update_service(auth, id, data) + await FastAPICache.clear(namespace=_PLUGIN_NS) + return SuccessResponse(data=r, msg="更新成功") -@PluginRouter.delete("/delete", summary="删除插件") +@PluginRouter.delete("/delete", summary="删除插件", response_model=ResponseSchema[None]) async def plugin_delete( ids: Annotated[list[int], Body()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:delete"]))], -): - await PluginService.delete_service(auth, ids) - return SuccessResponse(msg="删除成功") + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:delete']))], +) -> JSONResponse: + """ + 删除插件 + 参数: + - ids (list[int]): 插件 ID 列表。 + + 返回: + - JSONResponse: 删除结果。 + """ + await PluginService.delete_service(auth, ids) + await FastAPICache.clear(namespace=_PLUGIN_NS) + return SuccessResponse(msg="删除成功") # ───── 租户:插件市场 ───── -@PluginRouter.get("/marketplace", summary="插件市场", response_model=ResponseSchema[dict]) +@PluginRouter.get("/marketplace", summary="插件市场", response_model=ResponseSchema[PageResultSchema[PluginOutSchema]]) +@cache(expire=600, namespace=_PLUGIN_NS) async def marketplace( page: Annotated[PaginationQueryParam, Depends()], category: str | None = None, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))] = None, -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:query']))] = None, +) -> JSONResponse: + """ + 插件市场 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - category (str | None): 分类筛选。 + + 返回: + - JSONResponse: 包含分页市场插件列表的 JSON 响应。 + """ r = await PluginService.marketplace_service(auth, page.page_no, page.page_size, category) return SuccessResponse(data=r, msg="查询成功") -@PluginRouter.post("/install", summary="安装插件") +@PluginRouter.post("/install", summary="安装插件", response_model=ResponseSchema[None]) async def plugin_install( data: PluginInstallSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:install"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:install']))], +) -> JSONResponse: + """ + 安装插件 + + 参数: + - data (PluginInstallSchema): 安装参数(插件 ID)。 + + 返回: + - JSONResponse: 安装结果。 + """ await PluginService.install_service(auth, data.plugin_id) + await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="安装成功") -@PluginRouter.post("/uninstall", summary="卸载插件") +@PluginRouter.post("/uninstall", summary="卸载插件", response_model=ResponseSchema[None]) async def plugin_uninstall( data: PluginInstallSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:uninstall"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:uninstall']))], +) -> JSONResponse: + """ + 卸载插件 + + 参数: + - data (PluginInstallSchema): 卸载参数(插件 ID)。 + + 返回: + - JSONResponse: 卸载结果。 + """ await PluginService.uninstall_service(auth, data.plugin_id) + await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="卸载成功") -@PluginRouter.post("/toggle", summary="启用/禁用插件") +@PluginRouter.post("/toggle", summary="启用/禁用插件", response_model=ResponseSchema[None]) async def plugin_toggle( data: PluginInstallSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:toggle"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:toggle']))], +) -> JSONResponse: + """ + 启用/禁用插件 + + 参数: + - data (PluginInstallSchema): 操作参数(插件 ID)。 + + 返回: + - JSONResponse: 操作结果。 + """ await PluginService.toggle_service(auth, data.plugin_id) + await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="操作成功") @PluginRouter.get("/my", summary="我的插件", response_model=ResponseSchema[list[PluginOutSchema]]) +@cache(expire=120, namespace=_PLUGIN_NS) async def my_plugins( - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:query']))], +) -> JSONResponse: + """ + 我的插件 + + 返回: + - JSONResponse: 包含已安装插件列表的 JSON 响应。 + """ return SuccessResponse(data=await PluginService.my_plugins_service(auth), msg="查询成功") + + +@PluginRouter.post("/reload", summary="热重载插件路由", response_model=ResponseSchema[str]) +async def plugin_reload( + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_platform:plugin:reload']))], +) -> JSONResponse: + """ + 热重载插件路由(管理员)。 + + 重新扫描 app/plugin/module_* 目录,清除模块缓存并注册新路由, + 无需重启服务器。 + """ + msg = PluginService.reload_service() + await FastAPICache.clear(namespace=_PLUGIN_NS) + return SuccessResponse(data=msg, msg="重载成功") diff --git a/backend/app/api/v1/module_platform/plugin/crud.py b/backend/app/api/v1/module_platform/plugin/crud.py index d5e85288..4735d0ad 100644 --- a/backend/app/api/v1/module_platform/plugin/crud.py +++ b/backend/app/api/v1/module_platform/plugin/crud.py @@ -1,11 +1,12 @@ -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import PluginModel from .schema import PluginCreateSchema, PluginUpdateSchema class PluginCRUD(CRUDBase[PluginModel, PluginCreateSchema, PluginUpdateSchema]): + """插件数据层""" + def __init__(self, auth: AuthSchema) -> None: - self.auth = auth super().__init__(model=PluginModel, auth=auth) diff --git a/backend/app/api/v1/module_platform/plugin/model.py b/backend/app/api/v1/module_platform/plugin/model.py index 75851254..762f060e 100644 --- a/backend/app/api/v1/module_platform/plugin/model.py +++ b/backend/app/api/v1/module_platform/plugin/model.py @@ -14,7 +14,6 @@ class PluginModel(ModelMixin): code: Mapped[str] = mapped_column( String(50), nullable=False, unique=True, comment="插件编码(module_xxx)" ) - description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="插件描述") version: Mapped[str] = mapped_column( String(20), nullable=False, default="1.0.0", comment="版本号" ) @@ -77,4 +76,7 @@ class TenantPluginModel(MappedBase): enabled: Mapped[str] = mapped_column( String(1), nullable=False, default="1", comment="启用(1:启用 0:禁用)" ) + purchased: Mapped[str] = mapped_column( + String(1), nullable=False, default="0", comment="是否已购买(1:已购买 0:未购买)" + ) installed_time: Mapped[DateTime] = mapped_column(DateTime, nullable=False, comment="安装时间") diff --git a/backend/app/api/v1/module_platform/plugin/schema.py b/backend/app/api/v1/module_platform/plugin/schema.py index ab27101a..f61502b3 100644 --- a/backend/app/api/v1/module_platform/plugin/schema.py +++ b/backend/app/api/v1/module_platform/plugin/schema.py @@ -1,7 +1,14 @@ +from dataclasses import dataclass + from pydantic import BaseModel, ConfigDict, Field, field_validator +from app.common.enums import QueueEnum +from app.core.base_schema import BaseSchema + class PluginCreateSchema(BaseModel): + """新增插件""" + name: str = Field(..., min_length=1, max_length=100, description="插件名称") code: str = Field(..., min_length=1, max_length=50, description="插件编码(如 module_xxx)") description: str | None = Field(default=None, max_length=255, description="插件描述") @@ -42,70 +49,50 @@ class PluginCreateSchema(BaseModel): return v -class PluginUpdateSchema(BaseModel): - name: str | None = Field(default=None, max_length=100, description="插件名称") - description: str | None = Field(default=None, max_length=255, description="插件描述") - version: str | None = Field(default=None, max_length=20, description="版本号") - author: str | None = Field(default=None, max_length=100, description="作者") - icon: str | None = Field(default=None, max_length=500, description="图标URL") - category: str | None = Field(default=None, max_length=20, description="分类") - price: int | None = Field(default=None, ge=0, description="价格(分,0=免费)") - menu_path: str | None = Field(default=None, max_length=200, description="菜单路径") - permission_prefix: str | None = Field(default=None, max_length=100, description="权限前缀") - dependencies: str | None = Field(default=None, description="依赖插件编码(JSON数组)") - sort: int | None = Field(default=None, ge=0, description="排序") - status: str | None = Field(default=None, max_length=1, description="状态(0:正常 1:禁用)") +class PluginUpdateSchema(PluginCreateSchema): + """更新插件 — 所有字段可选""" - @field_validator("category") - @classmethod - def _validate_category(cls, v: str | None) -> str | None: - if v is None: - return v - allowed = {"tool", "ai", "monitor", "business"} - if v not in allowed: - raise ValueError(f"插件分类仅支持 tool、ai、monitor、business,当前值: {v}") - return v + name: str | None = Field(default=None, max_length=100, description="插件名称") # type: ignore[assignment] + code: str | None = Field(default=None, max_length=50, description="插件编码") # type: ignore[assignment] + status: int | None = Field(default=None, ge=0, le=1, description="状态(0:正常 1:禁用)") @field_validator("status") @classmethod - def _validate_status(cls, v: str | None) -> str | None: + def _validate_status(cls, v: int | None) -> int | None: if v is None: return v - if v not in {"0", "1"}: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return v -class PluginOutSchema(BaseModel): +class PluginOutSchema(PluginCreateSchema, BaseSchema): + """插件响应""" + model_config = ConfigDict(from_attributes=True) - id: int - name: str - code: str - description: str | None = None - version: str - author: str | None = None - icon: str | None = None - category: str - price: int - menu_path: str | None = None - permission_prefix: str | None = None - dependencies: str | None = None - sort: int - status: str + installed: bool = False # 当前租户是否已安装 -class PluginQueryParam: - def __init__( - self, name: str | None = None, category: str | None = None, status: str | None = None - ): - if name: - self.name = ("like", name) - if category: - self.category = ("eq", category) - if status: - self.status = ("eq", status) - - class PluginInstallSchema(BaseModel): + """安装/卸载插件""" + plugin_id: int = Field(..., description="插件ID") + + +@dataclass +class PluginQueryParam: + """插件查询参数""" + + def __init__( + self, + name: str | None = None, + category: str | None = None, + status: str | None = None, + ) -> None: + if name: + self.name = (QueueEnum.like.value, name) + if category: + self.category = (QueueEnum.eq.value, category) + if status: + self.status = (QueueEnum.eq.value, status) diff --git a/backend/app/api/v1/module_platform/plugin/service.py b/backend/app/api/v1/module_platform/plugin/service.py index 60fd207d..86bf11d5 100644 --- a/backend/app/api/v1/module_platform/plugin/service.py +++ b/backend/app/api/v1/module_platform/plugin/service.py @@ -2,9 +2,9 @@ from datetime import datetime import sqlalchemy as sa -from app.api.v1.module_system.auth.schema import AuthSchema +from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from .crud import PluginCRUD from .model import PluginModel, TenantPluginModel @@ -12,8 +12,6 @@ from .schema import PluginCreateSchema, PluginOutSchema, PluginQueryParam, Plugi class PluginService: - def __init__(self): - raise RuntimeError("Service is stateless, use classmethods") @classmethod async def page_service( @@ -33,26 +31,26 @@ class PluginService: ) @classmethod - async def detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def detail_service(cls, auth: AuthSchema, id: int) -> PluginOutSchema: obj = await PluginCRUD(auth).get(id=id) if not obj: raise CustomException(msg="插件不存在") - return PluginOutSchema.model_validate(obj).model_dump() + return PluginOutSchema.model_validate(obj) @classmethod - async def create_service(cls, auth: AuthSchema, data: PluginCreateSchema) -> dict: + async def create_service(cls, auth: AuthSchema, data: PluginCreateSchema) -> PluginOutSchema: if await PluginCRUD(auth).get(code=data.code): raise CustomException(msg="插件编码已存在") obj = await PluginCRUD(auth).create(data=data) - return PluginOutSchema.model_validate(obj).model_dump() + return PluginOutSchema.model_validate(obj) @classmethod - async def update_service(cls, auth: AuthSchema, id: int, data: PluginUpdateSchema) -> dict: + async def update_service(cls, auth: AuthSchema, id: int, data: PluginUpdateSchema) -> PluginOutSchema: obj = await PluginCRUD(auth).get(id=id) if not obj: raise CustomException(msg="插件不存在") updated = await PluginCRUD(auth).update(id=id, data=data) - return PluginOutSchema.model_validate(updated).model_dump() + return PluginOutSchema.model_validate(updated) @classmethod async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: @@ -76,16 +74,17 @@ class PluginService: out_schema=PluginOutSchema, ) tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id - if tenant_id and result.get("items"): - installed = await auth.db.execute( - sa.select(TenantPluginModel.plugin_id).where( + if tenant_id and result.items: + records = await auth.db.execute( + sa.select(TenantPluginModel.plugin_id, TenantPluginModel.purchased).where( TenantPluginModel.tenant_id == tenant_id, - TenantPluginModel.enabled == "0", ) ) - installed_ids = {r[0] for r in installed.all()} - for item in result["items"]: - item["installed"] = item["id"] in installed_ids + record_map = {r[0]: r[1] for r in records.all()} + for item in result.items: + pid = item["id"] + item["installed"] = pid in record_map + item["purchased"] = record_map.get(pid, "0") == "1" return result @classmethod @@ -93,9 +92,36 @@ class PluginService: tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id if not tenant_id: raise CustomException(msg="无法获取租户信息") + + # 平台租户不限制插件安装 + if tenant_id != 1: + from app.api.v1.module_platform.package.service import PackageService + allowed_plugin_ids = await PackageService.get_tenant_available_plugin_ids( + auth, tenant_id + ) + # 套餐有插件配置时,仅允许安装套餐内的插件 + if allowed_plugin_ids and plugin_id not in allowed_plugin_ids: + raise CustomException(msg="当前套餐不支持安装此插件") + plugin = await PluginCRUD(auth).get(id=plugin_id) - if not plugin or plugin.status == "1": + if not plugin or plugin.status == 1: raise CustomException(msg="插件不可用") + + # 付费插件需要先购买 + if tenant_id != 1 and getattr(plugin, "price", 0) > 0: + exist = await auth.db.execute( + sa + .select(TenantPluginModel) + .where( + TenantPluginModel.tenant_id == tenant_id, + TenantPluginModel.plugin_id == plugin_id, + ) + .limit(1) + ) + tp_record = exist.scalar_one_or_none() + if not tp_record or tp_record.purchased != "1": + raise CustomException(msg="此插件为付费插件,请先购买后再安装") + exist = await auth.db.execute( sa .select(TenantPluginModel) @@ -117,11 +143,15 @@ class PluginService: ) else: tp = TenantPluginModel( - tenant_id=tenant_id, plugin_id=plugin_id, enabled="0", installed_time=datetime.now() + tenant_id=tenant_id, + plugin_id=plugin_id, + enabled="0", + purchased="1" if getattr(plugin, "price", 0) == 0 else "0", + installed_time=datetime.now(), ) auth.db.add(tp) await auth.db.flush() - log.info(f"租户[{tenant_id}]安装插件[{plugin.name}]") + logger.info(f"租户[{tenant_id}]安装插件[{plugin.name}]") @classmethod async def uninstall_service(cls, auth: AuthSchema, plugin_id: int) -> None: @@ -135,7 +165,7 @@ class PluginService: ) ) await auth.db.flush() - log.info(f"租户[{tenant_id}]卸载插件[{plugin_id}]") + logger.info(f"租户[{tenant_id}]卸载插件[{plugin_id}]") @classmethod async def toggle_service(cls, auth: AuthSchema, plugin_id: int) -> None: @@ -154,7 +184,7 @@ class PluginService: raise CustomException(msg="未安装该插件") tp.enabled = "1" if tp.enabled == "0" else "0" await auth.db.flush() - log.info(f"租户[{tenant_id}]插件[{plugin_id}]状态→{tp.enabled}") + logger.info(f"租户[{tenant_id}]插件[{plugin_id}]状态→{tp.enabled}") @classmethod async def my_plugins_service(cls, auth: AuthSchema) -> list[dict]: @@ -170,7 +200,7 @@ class PluginService: ) plugins = [] for p, tp in result.all(): - d = PluginOutSchema.model_validate(p).model_dump() + d = PluginOutSchema.model_validate(p) d["enabled"] = tp.enabled d["installed"] = True d["installed_time"] = ( @@ -178,3 +208,23 @@ class PluginService: ) plugins.append(d) return plugins + + # ───── 插件热重载 ───── + + @classmethod + def reload_service(cls) -> str: + """ + 热重载插件路由(同步函数,直接操作模块缓存与 app.routes)。 + + 要求 init_app.py 已在启动时调用 discover.set_app_ref(app), + 否则仅重建 Router 对象,不会更新运行中的 app 路由。 + + 返回: + - str: 重载结果描述 + """ + from app.core.discover import reload_dynamic_router + + router = reload_dynamic_router() + route_count = len(router.routes) if router and hasattr(router, "routes") else 0 + logger.info(f"插件热重载完成,动态路由共 {route_count} 条") + return f"插件路由热重载完成,共注册 {route_count} 条路由" diff --git a/backend/app/api/v1/module_platform/self_service/__init__.py b/backend/app/api/v1/module_platform/self_service/__init__.py new file mode 100644 index 00000000..17dbe299 --- /dev/null +++ b/backend/app/api/v1/module_platform/self_service/__init__.py @@ -0,0 +1,3 @@ +from .controller import TenantSelfServiceRouter + +__all__ = ["TenantSelfServiceRouter"] diff --git a/backend/app/api/v1/module_platform/self_service/controller.py b/backend/app/api/v1/module_platform/self_service/controller.py new file mode 100644 index 00000000..e1d7f808 --- /dev/null +++ b/backend/app/api/v1/module_platform/self_service/controller.py @@ -0,0 +1,197 @@ +"""租户自助服务 Controller — 对应 PRD §20.20""" +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.common.response import ResponseSchema, SuccessResponse +from app.core.base_params import PaginationQueryParam +from app.core.base_schema import AuthSchema +from app.core.dependencies import AuthPermission, get_current_user +from app.core.router_class import OperationLogRoute + +from .schema import ( + PackageAvailableOut, + PackagePreviewOut, + PluginPurchaseCreate, + SelfOrderCreate, + SelfOrderDetailOut, + SelfOrderListOut, + SelfOrderOut, + WorkspaceOut, +) +from .service import ( + create_plugin_purchase_order, + create_self_order, + get_available_packages, + get_self_order_detail, + get_self_order_list, + preview_package_change, +) + +TenantSelfServiceRouter = APIRouter( + route_class=OperationLogRoute, + prefix="/tenant", + tags=["平台管理/自助服务"], +) + + +@TenantSelfServiceRouter.get( + "/package/available", + summary="可选套餐列表", + response_model=ResponseSchema[PackageAvailableOut], +) +async def package_available( + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:package:query']))], +): + """ + 可选套餐列表 + + 返回: + - SuccessResponse: 包含可用套餐列表的 JSON 响应。 + """ + result = await get_available_packages(auth=auth, tenant_id=auth.tenant_id) + return SuccessResponse(data=result, msg="查询成功") + + +@TenantSelfServiceRouter.get( + "/package/preview", + summary="套餐变更影响预览", + response_model=ResponseSchema[PackagePreviewOut], +) +async def package_preview( + target_package_id: Annotated[int, Query(ge=1, description="目标套餐ID")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:package:query']))], +): + """ + 套餐变更影响预览 + + 参数: + - target_package_id (int): 目标套餐 ID。 + + 返回: + - SuccessResponse: 包含套餐变更影响预览的 JSON 响应。 + """ + result = await preview_package_change( + auth=auth, tenant_id=auth.tenant_id, target_package_id=target_package_id + ) + return SuccessResponse(data=result, msg="查询成功") + + +@TenantSelfServiceRouter.post( + "/order/create", + summary="创建自助订单", + response_model=ResponseSchema[SelfOrderOut], +) +async def order_create( + data: SelfOrderCreate, + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:order:create']))], +): + """ + 创建自助订单 + + 参数: + - data (SelfOrderCreate): 订单创建参数。 + + 返回: + - SuccessResponse: 包含订单详情的 JSON 响应。 + """ + result = await create_self_order( + auth=auth, tenant_id=auth.tenant_id, data=data + ) + return SuccessResponse(data=result, msg="订单创建成功") + + +@TenantSelfServiceRouter.post( + "/plugin/purchase", + summary="购买付费插件", + response_model=ResponseSchema[SelfOrderOut], +) +async def plugin_purchase( + data: PluginPurchaseCreate, + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:order:create']))], +): + """ + 购买付费插件 + + 参数: + - data (PluginPurchaseCreate): 插件购买参数。 + + 返回: + - SuccessResponse: 包含订单详情的 JSON 响应。 + """ + result = await create_plugin_purchase_order( + auth=auth, tenant_id=auth.tenant_id, data=data + ) + return SuccessResponse(data=result, msg="插件订单创建成功") + + +@TenantSelfServiceRouter.get( + "/order/list", + summary="我的订单列表", + response_model=ResponseSchema[SelfOrderListOut], +) +async def order_list( + page: Annotated[PaginationQueryParam, Depends()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:order:query']))], +): + """ + 我的订单列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + + 返回: + - SuccessResponse: 包含分页订单列表的 JSON 响应。 + """ + result = await get_self_order_list( + auth=auth, + tenant_id=auth.tenant_id, + page_no=page.page_no, + page_size=page.page_size, + order_by=page.order_by, + ) + return SuccessResponse(data=result, msg="查询成功") + + +@TenantSelfServiceRouter.get( + "/order/detail/{order_id}", + summary="订单详情", + response_model=ResponseSchema[SelfOrderDetailOut], +) +async def order_detail( + order_id: Annotated[int, Path(ge=1, description="订单ID")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['tenant:order:query']))], +): + """ + 订单详情 + + 参数: + - order_id (int): 订单 ID。 + + 返回: + - SuccessResponse: 包含订单详情的 JSON 响应。 + """ + result = await get_self_order_detail( + auth=auth, tenant_id=auth.tenant_id, order_id=order_id + ) + return SuccessResponse(data=result, msg="查询成功") + + +@TenantSelfServiceRouter.get( + "/workspace", + summary="租户工作台概览", + response_model=ResponseSchema[WorkspaceOut], +) +async def tenant_workspace( + auth: Annotated[AuthSchema, Depends(get_current_user)], +): + """ + 租户工作台概览 + + 返回: + - SuccessResponse: 包含工作台概览信息的 JSON 响应。 + """ + from .service import get_workspace_data + + result = await get_workspace_data(auth=auth, tenant_id=auth.tenant_id) + return SuccessResponse(data=result, msg="查询成功") diff --git a/backend/app/api/v1/module_platform/self_service/schema.py b/backend/app/api/v1/module_platform/self_service/schema.py new file mode 100644 index 00000000..a081a0d9 --- /dev/null +++ b/backend/app/api/v1/module_platform/self_service/schema.py @@ -0,0 +1,162 @@ +"""租户自助服务 Schema""" +from pydantic import BaseModel, Field + + +class PackageAvailableItem(BaseModel): + """可选套餐项""" + id: int + name: str + price: int # 分 + period: str # month/year + trial_days: int = 0 + max_users: int = 0 + max_roles: int = 0 + max_depts: int = 0 + max_storage_mb: int = 0 + description: str | None = None + is_current: bool = False + available_actions: list[str] = [] # [buy, renew, upgrade, downgrade] + + +class PackageAvailableOut(BaseModel): + """可选套餐列表""" + current_package_id: int | None = None + packages: list[PackageAvailableItem] + + +class PackagePreviewQuery(BaseModel): + """套餐变更预览请求""" + target_package_id: int = Field(..., ge=1) + + +class PackagePreviewOut(BaseModel): + """套餐变更预览结果""" + current_package: str = "" + target_package: str = "" + action: str = "" # upgrade/downgrade/buy/renew + amount: int = 0 # 分 + period: str = "" + gained_menus: list[dict] = [] + lost_menus: list[dict] = [] + affected_roles: list[str] = [] + affected_users: int = 0 + + +class SelfOrderCreate(BaseModel): + """自助订单创建""" + package_id: int = Field(..., ge=1) + order_type: str = Field(..., pattern="^(buy|renew|upgrade|downgrade)$") + + +class PluginPurchaseCreate(BaseModel): + """插件购买""" + plugin_id: int = Field(..., ge=1, description="插件ID") + pay_method: str | None = Field(default=None, pattern=r"^(alipay|wxpay)?$") + + +# ─── 订单 ─── + + +class SelfOrderOut(BaseModel): + """自助订单创建结果""" + order_id: int + order_no: str + amount: int + need_pay: bool + + +class SelfOrderListItem(BaseModel): + """我的订单列表项""" + id: int + order_no: str + package_name: str = "" + order_type: str + amount: int + status: int + pay_method: str | None = None + pay_time: str | None = None + created_at: str | None = None + + +class SelfOrderListOut(BaseModel): + """我的订单列表""" + items: list[SelfOrderListItem] + total: int + page_no: int + page_size: int + + +class SelfOrderDetailOut(BaseModel): + """订单详情""" + id: int + order_no: str + package_id: int | None = None + package_name: str = "" + amount: int + order_type: str + status: int + pay_method: str | None = None + pay_time: str | None = None + created_at: str | None = None + + +# ─── 工作台 ─── + + +class WorkspaceTenantInfo(BaseModel): + """工作台-租户信息""" + id: int + name: str + code: str + status: int + status_label: str + start_time: str | None = None + end_time: str | None = None + days_remaining: int = 0 + + +class WorkspacePackageInfo(BaseModel): + """工作台-套餐信息""" + id: int + name: str + price: int + period: str + max_users: int + max_roles: int + max_depts: int + + +class WorkspaceUsagePercent(BaseModel): + """工作台-用量百分比""" + users: float = 0 + roles: float = 0 + depts: float = 0 + + +class WorkspaceQuotaInfo(BaseModel): + """工作台-配额用量""" + max_users: int = 0 + max_roles: int = 0 + max_depts: int = 0 + current_users: int = 0 + current_roles: int = 0 + current_depts: int = 0 + usage_percent: WorkspaceUsagePercent = Field(default_factory=WorkspaceUsagePercent) + + +class WorkspaceOrderItem(BaseModel): + """工作台-近期订单项""" + id: int + order_no: str + amount: int + order_type: str + status: int + created_at: str | None = None + + +class WorkspaceOut(BaseModel): + """工作台概览""" + tenant: WorkspaceTenantInfo + package: WorkspacePackageInfo | None = None + quota: WorkspaceQuotaInfo + recent_orders: list[WorkspaceOrderItem] = [] diff --git a/backend/app/api/v1/module_platform/self_service/service.py b/backend/app/api/v1/module_platform/self_service/service.py new file mode 100644 index 00000000..ce03863a --- /dev/null +++ b/backend/app/api/v1/module_platform/self_service/service.py @@ -0,0 +1,463 @@ +"""租户自助服务 Service""" +from datetime import datetime, timedelta + +from sqlalchemy import func, select + +from app.api.v1.module_platform.menu.model import MenuModel +from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel +from app.api.v1.module_platform.tenant.model import TenantModel +from app.api.v1.module_system.role.model import RoleModel +from app.core.base_schema import AuthSchema +from app.core.exceptions import CustomException +from app.core.logger import logger + +from .schema import ( + PackageAvailableItem, + PackageAvailableOut, + PackagePreviewOut, + PluginPurchaseCreate, + SelfOrderCreate, + SelfOrderDetailOut, + SelfOrderListItem, + SelfOrderListOut, + SelfOrderOut, + WorkspaceOrderItem, + WorkspaceOut, + WorkspacePackageInfo, + WorkspaceQuotaInfo, + WorkspaceTenantInfo, + WorkspaceUsagePercent, +) + + +async def get_available_packages(auth: AuthSchema, tenant_id: int) -> PackageAvailableOut: + """获取可选套餐列表""" + tenant = await auth.db.get(TenantModel, tenant_id) + current_pkg_id = tenant.package_id if tenant else None + + # 一次性获取当前套餐价格(可能未启用,不在后续结果中) + current_price: int | None = None + if current_pkg_id: + cp = await auth.db.get(PackageModel, current_pkg_id) + current_price = cp.price if cp else 0 + + stmt = select(PackageModel).where(PackageModel.status == 0).order_by(PackageModel.price) + result = await auth.db.execute(stmt) + packages = result.scalars().all() + + items: list[PackageAvailableItem] = [] + for pkg in packages: + is_current = pkg.id == current_pkg_id + actions: list[str] = [] + if is_current: + actions = ["renew"] + elif current_pkg_id is None: + actions = ["buy"] + elif current_price is not None: + actions = ["upgrade"] if pkg.price > current_price else ["downgrade"] + + items.append(PackageAvailableItem( + id=pkg.id, + name=pkg.name, + price=pkg.price, + period=pkg.period, + trial_days=pkg.trial_days, + max_users=pkg.max_users, + max_roles=pkg.max_roles, + max_depts=pkg.max_depts, + max_storage_mb=pkg.max_storage_mb, + description=pkg.description, + is_current=is_current, + available_actions=actions, + )) + + return PackageAvailableOut( + current_package_id=current_pkg_id, + packages=items, + ) + + +async def preview_package_change( + auth: AuthSchema, tenant_id: int, target_package_id: int +) -> PackagePreviewOut: + """套餐变更影响预览""" + tenant = await auth.db.get(TenantModel, tenant_id) + current_pkg_id = tenant.package_id if tenant else None + + target_pkg = await auth.db.get(PackageModel, target_package_id) + if not target_pkg: + raise CustomException(msg="目标套餐不存在") + + current_pkg = None + if current_pkg_id: + current_pkg = await auth.db.get(PackageModel, current_pkg_id) + + # 获取当前套餐的菜单 + current_menus = set() + if current_pkg_id: + cm_stmt = select(MenuModel).join( + PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id + ).where(PackageMenuModel.package_id == current_pkg_id) + result = await auth.db.execute(cm_stmt) + current_menus = {m.id for m in result.scalars().all()} + + # 获取目标套餐的菜单 + tm_stmt = select(MenuModel).join( + PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id + ).where(PackageMenuModel.package_id == target_package_id) + result = await auth.db.execute(tm_stmt) + target_menus = {m.id for m in result.scalars().all()} + + # 计算差异 + gained_menus, lost_menus = [], [] + if current_menus: + lost_ids = current_menus - target_menus + if lost_ids: + lost_stmt = select(MenuModel).where(MenuModel.id.in_(lost_ids)) + lost = await auth.db.execute(lost_stmt) + lost_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in lost.scalars().all()] + + gained_ids = target_menus - (current_menus or set()) + if gained_ids: + gain_stmt = select(MenuModel).where(MenuModel.id.in_(gained_ids)) + gained = await auth.db.execute(gain_stmt) + gained_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in gained.scalars().all()] + + # 确定操作类型 + if current_pkg_id is None: + action = "buy" + elif current_pkg and target_pkg.price > current_pkg.price: + action = "upgrade" + elif current_pkg and target_pkg.price < current_pkg.price: + action = "downgrade" + else: + action = "renew" + + # 受影响角色 + affected_roles = [] + if current_pkg_id: + role_stmt = select(RoleModel).where(RoleModel.tenant_id == tenant_id) + r_result = await auth.db.execute(role_stmt) + affected_roles = [r.name for r in r_result.scalars().all()] + + return PackagePreviewOut( + current_package=current_pkg.name if current_pkg else "", + target_package=target_pkg.name, + action=action, + amount=target_pkg.price if hasattr(target_pkg, "price") else 0, + period=target_pkg.period if hasattr(target_pkg, "period") else "month", + gained_menus=gained_menus, + lost_menus=lost_menus, + affected_roles=affected_roles, + affected_users=0, + ) + + +async def create_self_order( + auth: AuthSchema, tenant_id: int, data: SelfOrderCreate +) -> SelfOrderOut: + """创建自助订单""" + tenant = await auth.db.get(TenantModel, tenant_id) + if not tenant: + raise CustomException(msg="租户不存在") + if tenant.status not in (0, 1, 2): + raise CustomException(msg="租户状态不允许操作") + + pkg = await auth.db.get(PackageModel, data.package_id) + if not pkg or pkg.status == 1: + raise CustomException(msg="套餐不可用") + + amount = pkg.price if hasattr(pkg, "price") else 0 + + from app.api.v1.module_platform.order.crud import OrderCRUD + from app.api.v1.module_platform.order.schema import ( + OrderCreateInternalSchema, + OrderUpdateInternalSchema, + ) + from app.api.v1.module_platform.order.service import _generate_order_no + + order = await OrderCRUD(auth).create( + OrderCreateInternalSchema( + order_no=_generate_order_no(), + tenant_id=tenant_id, + package_id=data.package_id, + order_type=data.order_type, + amount=amount, + expire_time=datetime.now() + timedelta(minutes=15), + ) + ) + await auth.db.flush() + + # 免费订单自动激活 + if amount == 0: + await OrderCRUD(auth).update( + order.id, + OrderUpdateInternalSchema(status=1, pay_method="free", pay_time=datetime.now()), + ) + from app.api.v1.module_platform.order.service import PaymentService + await PaymentService._activate_tenant_package(auth, order) + + logger.info(f"自助订单创建: order_no={order.order_no} tenant={tenant_id} amount={amount}") + return SelfOrderOut( + order_id=order.id, + order_no=order.order_no, + amount=amount, + need_pay=amount > 0, + ) + + +async def create_plugin_purchase_order( + auth: AuthSchema, tenant_id: int, data: PluginPurchaseCreate +) -> SelfOrderOut: + """创建插件购买订单(复用套餐订单流程)""" + from app.api.v1.module_platform.order.schema import OrderCreateSchema + from app.api.v1.module_platform.order.service import OrderService + from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel + + # 校验插件存在且可用 + plugin = await auth.db.get(PluginModel, data.plugin_id) + if not plugin or plugin.status == 1: + raise CustomException(msg="插件不可用") + + if getattr(plugin, "price", 0) == 0: + raise CustomException(msg="免费插件可直接安装,无需购买") + + # 校验未重复购买 + existing = await auth.db.execute( + select(TenantPluginModel).where( + TenantPluginModel.tenant_id == tenant_id, + TenantPluginModel.plugin_id == data.plugin_id, + ).limit(1) + ) + tp = existing.scalar_one_or_none() + if tp and tp.purchased == "1": + raise CustomException(msg="您已购买此插件,可直接安装") + + order_data = OrderCreateSchema( + tenant_id=tenant_id, + package_id=None, + plugin_id=data.plugin_id, + order_type="plugin", + pay_method=data.pay_method, + ) + result = await OrderService.create_order(auth, order_data) + + logger.info(f"插件购买订单创建: order_no={result.order_no} plugin={plugin.name}") + return SelfOrderOut( + order_id=result.id, + order_no=result.order_no, + amount=result.amount, + need_pay=result.amount > 0, + ) + + +async def get_self_order_list( + auth: AuthSchema, + tenant_id: int, + page_no: int = 1, + page_size: int = 20, + order_by: list[dict] | None = None, +) -> SelfOrderListOut: + """我的订单列表""" + from sqlalchemy import func as sa_func + + from app.api.v1.module_platform.order.model import OrderModel + + stmt = ( + select(OrderModel) + .where(OrderModel.tenant_id == tenant_id) + .order_by(OrderModel.created_time.desc()) + .offset((page_no - 1) * page_size) + .limit(page_size) + ) + result = await auth.db.execute(stmt) + orders = result.scalars().all() + + # 查总数 + total = ( + await auth.db.execute( + select(sa_func.count(OrderModel.id)).where(OrderModel.tenant_id == tenant_id) + ) + ).scalar() or 0 + + # 批量查询关联套餐,避免 N+1 + package_ids = [o.package_id for o in orders if o.package_id] + pkg_map: dict[int, str] = {} + if package_ids: + pkg_result = await auth.db.execute( + select(PackageModel.id, PackageModel.name).where(PackageModel.id.in_(package_ids)) + ) + pkg_map = {row[0]: row[1] for row in pkg_result.all()} + + items = [] + for o in orders: + pkg_name = pkg_map.get(o.package_id, "") if o.package_id else "" + items.append(SelfOrderListItem( + id=o.id, + order_no=o.order_no, + package_name=pkg_name, + order_type=o.order_type, + amount=o.amount, + status=o.status, + pay_method=o.pay_method, + pay_time=o.pay_time.isoformat() if o.pay_time else None, + created_at=o.created_time.isoformat() if o.created_time else None, + )) + + return SelfOrderListOut( + items=items, + total=total, + page_no=page_no, + page_size=page_size, + ) + + +async def get_self_order_detail( + auth: AuthSchema, tenant_id: int, order_id: int +) -> SelfOrderDetailOut: + """订单详情""" + from app.api.v1.module_platform.order.model import OrderModel + + stmt = select(OrderModel).where( + OrderModel.id == order_id, + OrderModel.tenant_id == tenant_id, + ) + result = await auth.db.execute(stmt) + order = result.scalar_one_or_none() + if not order: + raise CustomException(msg="订单不存在") + + pkg_name = "" + if order.package_id: + p = await auth.db.get(PackageModel, order.package_id) + if p: + pkg_name = p.name + + return SelfOrderDetailOut( + id=order.id, + order_no=order.order_no, + package_id=order.package_id, + package_name=pkg_name, + amount=order.amount, + order_type=order.order_type, + status=order.status, + pay_method=order.pay_method, + pay_time=order.pay_time.isoformat() if order.pay_time else None, + created_at=order.created_time.isoformat() if order.created_time else None, + ) + + +async def get_workspace_data(auth: AuthSchema, tenant_id: int) -> WorkspaceOut: + """获取租户工作台概览数据 + + 聚合返回:租户信息、当前套餐、配额用量、近期订单。 + """ + from app.api.v1.module_platform.package.model import PackageModel + from app.api.v1.module_platform.tenant.model import TenantModel + from app.api.v1.module_system.dept.model import DeptModel + from app.api.v1.module_system.role.model import RoleModel + from app.api.v1.module_system.user.model import UserModel + + tenant = await auth.db.get(TenantModel, tenant_id) + if not tenant: + return WorkspaceOut( + tenant=WorkspaceTenantInfo(id=0, name="", code="", status=0, status_label="未知"), + quota=WorkspaceQuotaInfo(), + ) + + # ── 套餐 ── + package = None + if tenant.package_id: + package = await auth.db.get(PackageModel, tenant.package_id) + + # ── 用量计数 ── + async def _count(model_cls) -> int: + stmt = select(func.count()).select_from(model_cls).where( + model_cls.tenant_id == tenant_id, + model_cls.is_deleted == False, + ) + return (await auth.db.execute(stmt)).scalar() or 0 + + user_count = await _count(UserModel) + role_count = await _count(RoleModel) + dept_count = await _count(DeptModel) + + # ── 到期状态 ── + now = datetime.now() + days_remaining = (tenant.end_time - now).days if tenant.end_time else 0 + + status_labels = { + "0": "正常", + "1": "宽限期", + "2": "已暂停", + "3": "已冻结", + "4": "已过期", + "5": "已归档", + } + + # ── 近期订单 ── + from app.api.v1.module_platform.order.model import OrderModel + + orders_stmt = ( + select(OrderModel) + .where(OrderModel.tenant_id == tenant_id) + .order_by(OrderModel.created_time.desc()) + .limit(5) + ) + orders_result = await auth.db.execute(orders_stmt) + recent_orders = [] + for o in orders_result.scalars().all(): + recent_orders.append(WorkspaceOrderItem( + id=o.id, + order_no=o.order_no, + amount=o.amount, + order_type=o.order_type, + status=o.status, + created_at=o.created_time.isoformat() if o.created_time else None, + )) + + return WorkspaceOut( + tenant=WorkspaceTenantInfo( + id=tenant.id, + name=tenant.name, + code=tenant.code, + status=tenant.status, + status_label=status_labels.get(str(tenant.status), "未知"), + start_time=tenant.start_time.isoformat() if tenant.start_time else None, + end_time=tenant.end_time.isoformat() if tenant.end_time else None, + days_remaining=max(days_remaining, 0), + ), + package=WorkspacePackageInfo( + id=package.id, + name=package.name, + price=package.price, + period=package.period, + max_users=package.max_users, + max_roles=package.max_roles, + max_depts=package.max_depts, + ) if package else None, + quota=WorkspaceQuotaInfo( + max_users=package.max_users if package else 0, + max_roles=package.max_roles if package else 0, + max_depts=package.max_depts if package else 0, + current_users=user_count, + current_roles=role_count, + current_depts=dept_count, + usage_percent=WorkspaceUsagePercent( + users=round(user_count / package.max_users * 100, 1) if package and package.max_users > 0 else 0, + roles=round(role_count / package.max_roles * 100, 1) if package and package.max_roles > 0 else 0, + depts=round(dept_count / package.max_depts * 100, 1) if package and package.max_depts > 0 else 0, + ), + ), + recent_orders=recent_orders, + ) + + +async def get_tenant_api_usage_today(auth: AuthSchema, tenant_id: int) -> dict: + """获取租户当日 API 用量汇总(已移除 api_usage 模块)""" + return {"today": {"request_count": 0, "error_count": 0, "total_duration_ms": 0}, "month": {"total_calls": 0}, "top_paths": []} + + +async def get_tenant_api_usage_daily(auth: AuthSchema, tenant_id: int, days: int = 7) -> dict: + """获取租户每日 API 用量(已移除 api_usage 模块)""" + return {"daily": []} diff --git a/backend/app/api/v1/module_platform/tenant/controller.py b/backend/app/api/v1/module_platform/tenant/controller.py index a884da04..b6a212d6 100644 --- a/backend/app/api/v1/module_platform/tenant/controller.py +++ b/backend/app/api/v1/module_platform/tenant/controller.py @@ -1,61 +1,79 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends, Path +from fastapi import APIRouter, Body, Depends, Path, Query from fastapi.responses import JSONResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache from redis.asyncio.client import Redis -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, SuccessResponse from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.dependencies import AuthPermission, redis_getter -from app.core.logger import log from app.core.router_class import OperationLogRoute from .schema import ( + PackageChangePreviewOut, TenantConfigItem, TenantConfigOutSchema, TenantCreateSchema, - TenantMenuSetSchema, TenantOutSchema, TenantQueryParam, - TenantQuotaOutSchema, - TenantQuotaUpdateSchema, + TenantRenewSchema, TenantUpdateSchema, TenantUserAddSchema, TenantUserOutSchema, ) from .service import TenantService -TenantRouter = APIRouter(route_class=OperationLogRoute, prefix="/tenant", tags=["租户管理"]) +TenantRouter = APIRouter(route_class=OperationLogRoute, prefix="/tenant", tags=["平台管理/租户管理"]) + +_TENANT_NS = "tenant" @TenantRouter.get( "/detail/{id}", summary="获取租户详情", - description="获取租户详情", response_model=ResponseSchema[TenantOutSchema], ) +@cache(expire=120, namespace=_TENANT_NS) async def get_obj_detail_controller( id: Annotated[int, Path(description="租户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:query']))], ) -> JSONResponse: + """ + 获取租户详情 + + 参数: + - id (int): 租户 ID。 + + 返回: + - JSONResponse: 包含租户详情的 JSON 响应。 + """ result_dict = await TenantService.detail_service(id=id, auth=auth) - log.info(f"获取租户详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取租户详情成功") @TenantRouter.get( "/list", summary="查询租户列表", - description="查询租户列表(分页)", - response_model=ResponseSchema[dict], + response_model=ResponseSchema[PageResultSchema[TenantOutSchema]], ) async def get_obj_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[TenantQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:query']))], ) -> JSONResponse: + """ + 查询租户列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (TenantQueryParam): 查询筛选参数。 + + 返回: + - JSONResponse: 包含分页租户列表的 JSON 响应。 + """ order_by = [{"id": "asc"}] if page.order_by: order_by = page.order_by @@ -66,238 +84,323 @@ async def get_obj_list_controller( search=search, order_by=order_by, ) - log.info("查询租户列表成功") return SuccessResponse(data=result_dict, msg="查询租户列表成功") @TenantRouter.post( "/create", summary="创建租户", - description="创建租户", response_model=ResponseSchema[TenantOutSchema], ) async def create_obj_controller( data: TenantCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:create']))], ) -> JSONResponse: + """ + 创建租户 + + 参数: + - data (TenantCreateSchema): 租户创建参数。 + + 返回: + - JSONResponse: 包含创建后的租户详情的 JSON 响应。 + """ result_dict = await TenantService.create_service(auth=auth, data=data) - log.info(f"创建租户成功: {result_dict.get('name')}") + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(data=result_dict, msg="创建租户成功") @TenantRouter.put( "/update/{id}", summary="修改租户", - description="修改租户", response_model=ResponseSchema[TenantOutSchema], ) async def update_obj_controller( data: TenantUpdateSchema, id: Annotated[int, Path(description="租户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:update']))], ) -> JSONResponse: + """ + 修改租户 + + 参数: + - id (int): 租户 ID。 + - data (TenantUpdateSchema): 租户更新参数。 + + 返回: + - JSONResponse: 包含更新后的租户详情的 JSON 响应。 + """ result_dict = await TenantService.update_service(auth=auth, id=id, data=data) - log.info(f"修改租户成功: {result_dict.get('name')}") + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(data=result_dict, msg="修改租户成功") @TenantRouter.delete( "/delete", summary="删除租户", - description="删除租户", + response_model=ResponseSchema[None], ) async def delete_obj_controller( ids: Annotated[list[int], Body(..., description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:delete']))], ) -> JSONResponse: + """ + 删除租户 + + 参数: + - ids (list[int]): 租户 ID 列表。 + + 返回: + - JSONResponse: 删除结果。 + """ await TenantService.delete_service(auth=auth, ids=ids) - log.info(f"删除租户成功: {ids}") + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(msg="删除租户成功") @TenantRouter.patch( "/status/batch", summary="批量修改租户状态", - description="批量修改租户状态", + response_model=ResponseSchema[None], ) async def batch_set_available_obj_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:patch']))], ) -> JSONResponse: + """ + 批量修改租户状态 + + 参数: + - data (BatchSetAvailable): 批量状态设置参数。 + + 返回: + - JSONResponse: 操作结果。 + """ await TenantService.set_available_service(auth=auth, data=data) - log.info(f"批量修改租户状态成功: {data.ids}") + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(msg="批量修改租户状态成功") @TenantRouter.put( "/status/{id}", summary="启/禁用租户", - description="修改单个租户的启用/禁用状态", + response_model=ResponseSchema[None], ) async def toggle_tenant_status_controller( id: Annotated[int, Path(description="租户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:patch']))], ) -> JSONResponse: + """ + 启/禁用租户 + + 参数: + - id (int): 租户 ID。 + + 返回: + - JSONResponse: 操作结果。 + """ await TenantService.toggle_status_service(auth=auth, id=id) - log.info(f"修改租户状态成功: {id}") + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(msg="修改租户状态成功") @TenantRouter.get( "/{id}/users", summary="获取租户用户列表", - description="获取指定租户下的所有用户", response_model=ResponseSchema[list[TenantUserOutSchema]], ) +@cache(expire=120, namespace=_TENANT_NS) async def get_tenant_users_controller( id: Annotated[int, Path(description="租户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:query']))], ) -> JSONResponse: + """ + 获取租户用户列表 + + 参数: + - id (int): 租户 ID。 + + 返回: + - JSONResponse: 包含租户用户列表的 JSON 响应。 + """ result = await TenantService.get_tenant_users_service(auth=auth, tenant_id=id) - log.info(f"获取租户用户列表成功: tenant_id={id}") return SuccessResponse(data=result, msg="获取租户用户列表成功") @TenantRouter.post( "/{id}/users", summary="向租户添加用户", - description="将指定用户添加到租户中", response_model=ResponseSchema[None], ) async def add_tenant_user_controller( id: Annotated[int, Path(description="租户ID")], data: TenantUserAddSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:create']))], ) -> JSONResponse: + """ + 向租户添加用户 + + 参数: + - id (int): 租户 ID。 + - data (TenantUserAddSchema): 用户添加参数。 + + 返回: + - JSONResponse: 操作结果。 + """ await TenantService.add_tenant_user_service(auth=auth, tenant_id=id, data=data) - log.info(f"向租户添加用户成功: tenant_id={id}, user_id={data.user_id}") + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(msg="添加用户成功") @TenantRouter.delete( "/{id}/users/{uid}", summary="从租户移除用户", - description="将指定用户从租户中移除", response_model=ResponseSchema[None], ) async def remove_tenant_user_controller( id: Annotated[int, Path(description="租户ID")], uid: Annotated[int, Path(description="用户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:delete']))], ) -> JSONResponse: + """ + 从租户移除用户 + + 参数: + - id (int): 租户 ID。 + - uid (int): 用户 ID。 + + 返回: + - JSONResponse: 操作结果。 + """ await TenantService.remove_tenant_user_service(auth=auth, tenant_id=id, user_id=uid) - log.info(f"从租户移除用户成功: tenant_id={id}, user_id={uid}") + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(msg="移除用户成功") - -# ============ P1: 配额管理 ============ - - -@TenantRouter.get( - "/{id}/quota", - summary="获取租户配额", - description="获取指定租户的资源配额信息", - response_model=ResponseSchema[TenantQuotaOutSchema], -) -async def get_tenant_quota_controller( - id: Annotated[int, Path(description="租户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))], -) -> JSONResponse: - result = await TenantService.get_quota_service(auth=auth, tenant_id=id) - return SuccessResponse(data=result, msg="获取租户配额成功") - - -@TenantRouter.put( - "/{id}/quota", - summary="修改租户配额", - description="修改指定租户的资源配额(需超级管理员权限)", - response_model=ResponseSchema[TenantQuotaOutSchema], -) -async def update_tenant_quota_controller( - id: Annotated[int, Path(description="租户ID")], - data: TenantQuotaUpdateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))], -) -> JSONResponse: - result = await TenantService.update_quota_service(auth=auth, tenant_id=id, data=data) - return SuccessResponse(data=result, msg="修改租户配额成功") - - # ============ P1: 租户配置 ============ @TenantRouter.get( "/{id}/config", summary="获取租户配置", - description="获取指定租户的个性化配置", response_model=ResponseSchema[list[TenantConfigOutSchema]], ) async def get_tenant_config_controller( id: Annotated[int, Path(description="租户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:query']))], ) -> JSONResponse: - result = await TenantService.get_config_service(auth=auth, tenant_id=id) + """ + 获取租户配置 + + 参数: + - id (int): 租户 ID。 + + 返回: + - JSONResponse: 包含租户配置列表的 JSON 响应。 + """ + result = await TenantService.get_config_items_service(auth=auth, tenant_id=id) return SuccessResponse(data=result, msg="获取租户配置成功") @TenantRouter.get( "/{id}/config/info", summary="获取租户配置(公开-缓存)", - description="从 Redis 缓存获取租户个性化配置,无需登录(供登录页等场景使用)", response_model=ResponseSchema[list[TenantConfigOutSchema]], ) async def get_tenant_config_info_controller( id: Annotated[int, Path(description="租户ID")], redis: Annotated[Redis, Depends(redis_getter)], ) -> JSONResponse: - result = await TenantService.get_config_cache_service(redis=redis, tenant_id=id) + """ + 获取租户配置(公开-缓存) + + 参数: + - id (int): 租户 ID。 + + 返回: + - JSONResponse: 包含租户配置列表的 JSON 响应。 + """ + result = await TenantService.get_config_cache_items_service(redis=redis, tenant_id=id) return SuccessResponse(data=result, msg="获取租户配置成功") @TenantRouter.put( "/{id}/config", summary="更新租户配置", - description="批量更新租户的个性化配置", response_model=ResponseSchema[list[TenantConfigOutSchema]], ) async def update_tenant_config_controller( id: Annotated[int, Path(description="租户ID")], data: Annotated[list[TenantConfigItem], Body(..., description="配置项列表")], redis: Annotated[Redis, Depends(redis_getter)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:update']))], ) -> JSONResponse: + """ + 更新租户配置 + + 参数: + - id (int): 租户 ID。 + - data (list[TenantConfigItem]): 配置项列表。 + + 返回: + - JSONResponse: 包含更新后的配置列表的 JSON 响应。 + """ result = await TenantService.update_config_service( - auth=auth, redis=redis, tenant_id=id, items=data + auth=auth, redis=redis, tenant_id=id, config=data ) + await FastAPICache.clear(namespace=_TENANT_NS) return SuccessResponse(data=result, msg="更新租户配置成功") - -# ============ P1: 租户菜单权限 ============ - - -@TenantRouter.get( - "/{id}/menus", - summary="获取租户菜单权限", - description="获取指定租户有权限访问的菜单ID列表", - response_model=ResponseSchema[list[int]], -) -async def get_tenant_menus_controller( - id: Annotated[int, Path(description="租户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))], -) -> JSONResponse: - result = await TenantService.get_menus_service(auth=auth, tenant_id=id) - return SuccessResponse(data=result, msg="获取租户菜单成功") +# ============ 续期 ============ @TenantRouter.put( - "/{id}/menus", - summary="设置租户菜单权限", - description="批量设置租户的菜单权限(先清空再写入,需超级管理员权限)", + "/renew/{id}", + summary="租户续期", + response_model=ResponseSchema[TenantOutSchema], ) -async def set_tenant_menus_controller( +async def renew_tenant_controller( id: Annotated[int, Path(description="租户ID")], - data: TenantMenuSetSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))], + data: TenantRenewSchema, + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:update']))], ) -> JSONResponse: - await TenantService.set_menus_service(auth=auth, tenant_id=id, data=data) - log.info(f"设置租户菜单权限成功: tenant_id={id}, count={len(data.menu_ids)}") - return SuccessResponse(msg="设置租户菜单权限成功") + """ + 租户续期 + + 参数: + - id (int): 租户 ID。 + - data (TenantRenewSchema): 续期时间参数。 + + 返回: + - JSONResponse: 包含续期后的租户详情的 JSON 响应。 + """ + result = await TenantService.renew_service(auth=auth, tenant_id=id, end_time=data.end_time) + await FastAPICache.clear(namespace=_TENANT_NS) + return SuccessResponse(data=result, msg="租户续期成功") + +# ============ 套餐变更预览 ============ + + +@TenantRouter.get( + "/{id}/package-change-preview", + summary="套餐变更影响预览", + response_model=ResponseSchema[PackageChangePreviewOut], +) +async def package_change_preview_controller( + id: Annotated[int, Path(description="租户ID")], + new_package_id: Annotated[int, Query(..., description="目标套餐ID")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:tenant:query']))], +) -> JSONResponse: + """ + 套餐变更影响预览 + + 参数: + - id (int): 租户 ID。 + - new_package_id (int): 目标套餐 ID。 + + 返回: + - JSONResponse: 包含套餐变更影响预览的 JSON 响应。 + """ + result = await TenantService.package_change_preview_service( + auth=auth, tenant_id=id, new_package_id=new_package_id + ) + return SuccessResponse(data=result, msg="套餐变更预览成功") diff --git a/backend/app/api/v1/module_platform/tenant/crud.py b/backend/app/api/v1/module_platform/tenant/crud.py index 6e2ace6b..af7d9e1b 100644 --- a/backend/app/api/v1/module_platform/tenant/crud.py +++ b/backend/app/api/v1/module_platform/tenant/crud.py @@ -1,59 +1,12 @@ -from collections.abc import Sequence -from typing import Any - -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import TenantModel -from .schema import TenantCreateSchema, TenantOutSchema, TenantUpdateSchema +from .schema import TenantCreateSchema, TenantUpdateSchema class TenantCRUD(CRUDBase[TenantModel, TenantCreateSchema, TenantUpdateSchema]): """租户数据层""" def __init__(self, auth: AuthSchema) -> None: - self.auth = auth super().__init__(model=TenantModel, auth=auth) - - async def get_by_id_crud( - self, id: int, preload: list[str | Any] | None = None - ) -> TenantModel | None: - return await self.get(id=id, preload=preload) - - async def get_list_crud( - self, - search: dict | None = None, - order_by: list[dict[str, str]] | None = None, - preload: list[str | Any] | None = None, - ) -> Sequence[TenantModel]: - return await self.list(search=search, order_by=order_by, preload=preload) - - async def page_crud( - self, - offset: int, - limit: int, - order_by: list[dict[str, str]] | None, - search: dict | None = None, - out_schema: type[TenantOutSchema] | None = None, - preload: list[str | Any] | None = None, - ) -> dict: - return await self.page( - offset=offset, - limit=limit, - order_by=order_by or [{"id": "asc"}], - search=search or {}, - out_schema=out_schema or TenantOutSchema, - preload=preload or [], - ) - - async def create_crud(self, data: TenantCreateSchema) -> TenantModel | None: - return await self.create(data=data) - - async def update_crud(self, id: int, data: TenantUpdateSchema) -> TenantModel | None: - return await self.update(id=id, data=data) - - async def delete_crud(self, ids: list[int]) -> None: - await self.delete(ids=ids) - - async def set_available_crud(self, ids: list[int], status: str) -> None: - await self.set(ids=ids, status=status) diff --git a/backend/app/api/v1/module_platform/tenant/model.py b/backend/app/api/v1/module_platform/tenant/model.py index 5e642d74..a012a44d 100644 --- a/backend/app/api/v1/module_platform/tenant/model.py +++ b/backend/app/api/v1/module_platform/tenant/model.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, SmallInteger, String, Text, UniqueConstraint +from sqlalchemy import DateTime, ForeignKey, Integer, SmallInteger, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, validates from app.common.enums import PermissionFilterStrategy @@ -11,9 +11,9 @@ class TenantModel(ModelMixin): """ 租户模型 - 单一大表设计 - - 系统租户(id=1):平台管理,由超级管理员维护 - - 普通租户(id>1):独立组织数据,通过业务表的 tenant_id 隔离 - - 配额字段直接集成到主表,简化结构便于管理 + - 系统租户(id=1):平台管理,由超级管理员维护,不受套餐限制 + - 普通租户(id>1):配额和菜单通过关联的 Package 控制 + - 配置字段直接集成到主表 """ __tablename__: str = "platform_tenant" @@ -55,19 +55,36 @@ class TenantModel(ModelMixin): end_time: Mapped[datetime | None] = mapped_column( DateTime, nullable=True, default=None, comment="结束时间" ) - # 配额字段 - 直接集成到主表 - max_users: Mapped[int] = mapped_column( - Integer, nullable=False, default=50, comment="最大用户数" + # ============ 租户配置字段(从 TenantConfigModel 合并)=========== + version: Mapped[str | None] = mapped_column( + String(20), nullable=True, default=None, comment="版本号" ) - max_roles: Mapped[int] = mapped_column( - Integer, nullable=False, default=20, comment="最大角色数" + favicon: Mapped[str | None] = mapped_column( + String(500), nullable=True, default=None, comment="favicon地址" ) - max_storage_mb: Mapped[int] = mapped_column( - Integer, nullable=False, default=500, comment="最大存储(MB)" + login_bg: Mapped[str | None] = mapped_column( + String(500), nullable=True, default=None, comment="登录背景地址" ) - max_depts: Mapped[int] = mapped_column( - Integer, nullable=False, default=50, comment="最大部门数" + copyright: Mapped[str | None] = mapped_column( + String(255), nullable=True, default=None, comment="版权信息" ) + keep_record: Mapped[str | None] = mapped_column( + String(100), nullable=True, default=None, comment="备案号" + ) + help_doc: Mapped[str | None] = mapped_column( + String(500), nullable=True, default=None, comment="帮助文档地址" + ) + privacy: Mapped[str | None] = mapped_column( + String(500), nullable=True, default=None, comment="隐私政策地址" + ) + clause: Mapped[str | None] = mapped_column( + String(500), nullable=True, default=None, comment="服务条款地址" + ) + git_code: Mapped[str | None] = mapped_column( + String(500), nullable=True, default=None, comment="源码地址" + ) + + # ── 关系 ═══════════════════════════════════════════ @validates("name") def validate_name(self, key: str, name: str) -> str: @@ -83,12 +100,6 @@ class TenantModel(ModelMixin): raise ValueError("编码只能包含字母和数字") return code - @validates("max_users", "max_roles", "max_storage_mb", "max_depts") - def validate_quota(self, key: str, value: int) -> int: - if value < 1: - raise ValueError(f"{key} 不能小于 1") - return value - class TenantUserModel(MappedBase): """ @@ -137,53 +148,3 @@ class TenantUserModel(MappedBase): nullable=False, comment="创建时间", ) - - -class TenantConfigModel(MappedBase): - """租户个性化配置模型 — 键值对存储""" - - __tablename__: str = "platform_tenant_config" - __table_args__ = ( - UniqueConstraint("tenant_id", "config_key", name="uq_tenant_config_key"), - {"comment": "租户配置表"}, - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID") - tenant_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("platform_tenant.id", ondelete="CASCADE", onupdate="CASCADE"), - nullable=False, - index=True, - comment="租户ID", - ) - config_key: Mapped[str] = mapped_column(String(100), nullable=False, comment="配置键") - config_value: Mapped[str | None] = mapped_column(Text, nullable=True, comment="配置值") - config_type: Mapped[str] = mapped_column( - String(20), nullable=False, default="string", comment="配置类型(string/json/int/bool)" - ) - - -class TenantMenuModel(MappedBase): - """租户菜单权限模型 — 控制租户可见的菜单项""" - - __tablename__: str = "platform_tenant_menu" - __table_args__ = ( - UniqueConstraint("tenant_id", "menu_id", name="uq_tenant_menu"), - {"comment": "租户菜单权限表"}, - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID") - tenant_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("platform_tenant.id", ondelete="CASCADE", onupdate="CASCADE"), - nullable=False, - index=True, - comment="租户ID", - ) - menu_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"), - nullable=False, - index=True, - comment="菜单ID", - ) diff --git a/backend/app/api/v1/module_platform/tenant/schema.py b/backend/app/api/v1/module_platform/tenant/schema.py index 3d516cca..52a00c0c 100644 --- a/backend/app/api/v1/module_platform/tenant/schema.py +++ b/backend/app/api/v1/module_platform/tenant/schema.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -11,8 +13,8 @@ class TenantCreateSchema(BaseModel): name: str = Field(..., min_length=1, max_length=100, description="租户名称") code: str = Field(..., min_length=2, max_length=100, description="租户编码") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") - description: str | None = Field(default=None, max_length=255, description="描述") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") + description: str | None = Field(default=None, description="描述") start_time: DateTimeStr | None = Field(default=None, description="开始时间") end_time: DateTimeStr | None = Field(default=None, description="结束时间") contact_name: str | None = Field(default=None, max_length=64, description="联系人姓名") @@ -23,6 +25,15 @@ class TenantCreateSchema(BaseModel): logo_url: str | None = Field(default=None, max_length=500, description="Logo URL") sort: int = Field(default=0, ge=0, description="排序") package_id: int | None = Field(default=None, gt=0, description="关联套餐ID") + version: str | None = Field(default=None, max_length=20, description="版本号") + favicon: str | None = Field(default=None, max_length=500, description="favicon地址") + login_bg: str | None = Field(default=None, max_length=500, description="登录背景地址") + copyright: str | None = Field(default=None, max_length=255, description="版权信息") + keep_record: str | None = Field(default=None, max_length=100, description="备案号") + help_doc: str | None = Field(default=None, max_length=500, description="帮助文档地址") + privacy: str | None = Field(default=None, max_length=500, description="隐私政策地址") + clause: str | None = Field(default=None, max_length=500, description="服务条款地址") + git_code: str | None = Field(default=None, max_length=500, description="源码地址") @field_validator("name") @classmethod @@ -44,8 +55,8 @@ class TenantCreateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, v: str) -> str: - if v not in {"0", "1"}: + def _validate_status(cls, v: int) -> int: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return v @@ -68,13 +79,13 @@ class TenantCreateSchema(BaseModel): return self -class TenantUpdateSchema(BaseModel): +class TenantUpdateSchema(TenantCreateSchema): """更新租户""" - name: str | None = Field(default=None, max_length=100, description="租户名称") - code: str | None = Field(default=None, max_length=100, description="租户编码") - status: str | None = Field(default=None, max_length=1, description="状态(0:正常 1:禁用)") - description: str | None = Field(default=None, max_length=255, description="描述") + name: str | None = Field(default=None, max_length=100, description="租户名称") # type: ignore[assignment] + code: str | None = Field(default=None, max_length=100, description="租户编码") # type: ignore[assignment] + status: int | None = Field(default=None, ge=0, le=1, description="状态(0:正常 1:禁用)") + description: str | None = Field(default=None, description="描述") start_time: DateTimeStr | None = Field(default=None, description="开始时间") end_time: DateTimeStr | None = Field(default=None, description="结束时间") contact_name: str | None = Field(default=None, max_length=64, description="联系人姓名") @@ -85,6 +96,15 @@ class TenantUpdateSchema(BaseModel): logo_url: str | None = Field(default=None, max_length=500, description="Logo URL") sort: int | None = Field(default=None, ge=0, description="排序") package_id: int | None = Field(default=None, gt=0, description="关联套餐ID") + version: str | None = Field(default=None, max_length=20, description="版本号") + favicon: str | None = Field(default=None, max_length=500, description="favicon地址") + login_bg: str | None = Field(default=None, max_length=500, description="登录背景地址") + copyright: str | None = Field(default=None, max_length=255, description="版权信息") + keep_record: str | None = Field(default=None, max_length=100, description="备案号") + help_doc: str | None = Field(default=None, max_length=500, description="帮助文档地址") + privacy: str | None = Field(default=None, max_length=500, description="隐私政策地址") + clause: str | None = Field(default=None, max_length=500, description="服务条款地址") + git_code: str | None = Field(default=None, max_length=500, description="源码地址") @field_validator("code") @classmethod @@ -98,10 +118,10 @@ class TenantUpdateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, v: str | None) -> str | None: + def _validate_status(cls, v: int | None) -> int | None: if v is None: return v - if v not in {"0", "1"}: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return v @@ -130,6 +150,7 @@ class TenantOutSchema(TenantCreateSchema, BaseSchema): model_config = ConfigDict(from_attributes=True) +@dataclass class TenantQueryParam: """租户查询参数""" @@ -191,61 +212,52 @@ class TenantUserOutSchema(BaseModel): name: str = Field(default="", description="用户姓名") -# ============ P1: 配额管理 ============ - - -class TenantQuotaOutSchema(BaseModel): - """租户配额响应""" - - model_config = ConfigDict(from_attributes=True) - - tenant_id: int - max_users: int - max_roles: int - max_storage_mb: int - max_depts: int - - -class TenantQuotaUpdateSchema(BaseModel): - """租户配额更新""" - - max_users: int | None = Field(default=None, ge=0, description="最大用户数") - max_roles: int | None = Field(default=None, ge=0, description="最大角色数") - max_storage_mb: int | None = Field(default=None, ge=0, description="最大存储(MB)") - max_depts: int | None = Field(default=None, ge=0, description="最大部门数") - - # ============ P1: 租户配置 ============ class TenantConfigItem(BaseModel): - """单个配置项""" + """租户配置项""" - config_key: str = Field(..., min_length=1, max_length=100, description="配置键") - config_value: str = Field(..., max_length=65535, description="配置值") - config_type: str = Field(default="string", max_length=20, description="配置类型(string/json/int/bool)") - - @field_validator("config_type") - @classmethod - def _validate_config_type(cls, v: str) -> str: - if v not in {"string", "json", "int", "bool"}: - raise ValueError("配置类型仅支持 string、json、int、bool") - return v + key: str = Field(..., description="配置键") + value: str | None = Field(default=None, description="配置值") -class TenantConfigOutSchema(TenantConfigItem): +class TenantConfigOutSchema(BaseModel): """租户配置响应""" model_config = ConfigDict(from_attributes=True) - id: int - tenant_id: int + config_key: str = Field(..., description="配置键") + config_value: str | None = Field(default=None, description="配置值") -# ============ P1: 租户菜单 ============ +# ============ 续期 ============ -class TenantMenuSetSchema(BaseModel): - """批量设置租户菜单权限""" +class TenantRenewSchema(BaseModel): + """租户续期""" - menu_ids: list[int] = Field(..., description="菜单ID列表") + end_time: DateTimeStr = Field(..., description="新的结束时间") + + @model_validator(mode="after") + def _validate_end_time(self): + from datetime import datetime + + if self.end_time <= datetime.now(): + raise ValueError("续期时间必须晚于当前时间") + return self + + +# ============ 套餐变更预览 ============ + + +class PackageChangePreviewOut(BaseModel): + """套餐变更影响预览响应""" + + new_package_id: int = Field(..., description="新套餐ID") + new_package_name: str = Field(default="", description="新套餐名称") + affected_roles: list[dict] = Field(default_factory=list, description="受影响的角色列表(名称、用户数)") + removed_menus: list[dict] = Field(default_factory=list, description="将被移除的菜单清单(名称、路径)") + added_menus: list[dict] = Field(default_factory=list, description="新增的菜单清单(名称、路径)") + quota_changes: dict = Field(default_factory=dict, description="配额变化对比") + total_affected_users: int = Field(default=0, description="受影响用户数总计") diff --git a/backend/app/api/v1/module_platform/tenant/service.py b/backend/app/api/v1/module_platform/tenant/service.py index 66f85621..632fee84 100644 --- a/backend/app/api/v1/module_platform/tenant/service.py +++ b/backend/app/api/v1/module_platform/tenant/service.py @@ -5,34 +5,21 @@ import string import sqlalchemy as sa from redis.asyncio.client import Redis -from app.api.v1.module_system.auth.schema import AuthSchema -from app.api.v1.module_system.dept.crud import DeptCRUD -from app.api.v1.module_system.position.crud import PositionCRUD -from app.api.v1.module_system.role.crud import RoleCRUD -from app.api.v1.module_system.user.crud import UserCRUD from app.common.enums import RedisInitKeyConfig -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.utils.hash_bcrpy_util import PwdUtil from .crud import TenantCRUD -from .model import ( - TenantConfigModel, - TenantMenuModel, - TenantModel, - TenantUserModel, -) +from .model import TenantModel, TenantUserModel from .schema import ( - TenantConfigItem, + PackageChangePreviewOut, TenantConfigOutSchema, TenantCreateSchema, - TenantMenuSetSchema, TenantOutSchema, TenantQueryParam, - TenantQuotaOutSchema, - TenantQuotaUpdateSchema, TenantUpdateSchema, TenantUserAddSchema, TenantUserOutSchema, @@ -43,11 +30,11 @@ class TenantService: """租户管理模块服务层""" @classmethod - async def detail_service(cls, auth: AuthSchema, id: int) -> dict: - obj = await TenantCRUD(auth).get_by_id_crud(id=id) + async def detail_service(cls, auth: AuthSchema, id: int) -> TenantOutSchema: + obj = await TenantCRUD(auth).get(id=id) if not obj: raise CustomException(msg="租户不存在") - result = TenantOutSchema.model_validate(obj).model_dump() + result = TenantOutSchema.model_validate(obj) return result @classmethod @@ -59,7 +46,7 @@ class TenantService: search: TenantQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> dict: - return await TenantCRUD(auth).page_crud( + return await TenantCRUD(auth).page( offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"id": "asc"}], @@ -68,13 +55,13 @@ class TenantService: ) @classmethod - async def create_service(cls, auth: AuthSchema, data: TenantCreateSchema) -> dict: + async def create_service(cls, auth: AuthSchema, data: TenantCreateSchema) -> TenantOutSchema: if await TenantCRUD(auth).get(name=data.name): raise CustomException(msg="创建失败,名称已存在") if await TenantCRUD(auth).get(code=data.code): raise CustomException(msg="创建失败,编码已存在") - tenant_obj = await TenantCRUD(auth).create_crud(data=data) + tenant_obj = await TenantCRUD(auth).create(data=data) if not tenant_obj: raise CustomException(msg="创建租户失败") @@ -83,7 +70,9 @@ class TenantService: # 2. 检查用户名是否已存在 # 3. 创建初始管理员用户 username = f"{tenant_obj.code}_admin" - if await UserCRUD(auth).get_by_username_crud(username=username): + from app.api.v1.module_system.user.crud import UserCRUD + + if await UserCRUD(auth).get(username=username): raise CustomException(msg=f"初始管理员用户名已存在: {username},请更换租户编码后重试") password_length = 12 @@ -104,21 +93,21 @@ class TenantService: except CustomException: raise except Exception as e: - log.error(f"为租户[{tenant_obj.name}]创建初始管理员失败: {e!s}") + logger.error(f"为租户[{tenant_obj.name}]创建初始管理员失败: {e!s}") raise CustomException(msg="创建租户初始管理员失败") - log.info( + logger.info( f"为租户[{tenant_obj.name}]创建初始管理员成功,用户名: {username},临时密码: {password}" ) await auth.db.refresh(tenant_obj) - result = TenantOutSchema.model_validate(tenant_obj).model_dump() + result = TenantOutSchema.model_validate(tenant_obj) return result @classmethod - async def update_service(cls, auth: AuthSchema, id: int, data: TenantUpdateSchema) -> dict: - obj = await TenantCRUD(auth).get_by_id_crud(id=id) + async def update_service(cls, auth: AuthSchema, id: int, data: TenantUpdateSchema) -> TenantOutSchema: + obj = await TenantCRUD(auth).get(id=id) if not obj: raise CustomException(msg="租户不存在") @@ -127,7 +116,7 @@ class TenantService: if id == 1: if data.code is not None and data.code != obj.code: raise CustomException(msg="系统租户编码不可修改") - if data.status is not None and data.status == "1": + if data.status is not None and data.status == 1: raise CustomException(msg="系统租户不允许禁用") # 套餐变更:仅超管可操作,防止租户管理员自行升级/降级套餐 @@ -144,7 +133,7 @@ class TenantService: if exist and exist.id != id: raise CustomException(msg="更新失败,编码重复") - updated = await TenantCRUD(auth).update_crud(id=id, data=data) + updated = await TenantCRUD(auth).update(id=id, data=data) if not updated: raise CustomException(msg="更新失败") @@ -169,12 +158,12 @@ class TenantService: ) ) await auth.db.flush() - log.info( + logger.info( f"租户[{id}]套餐变更:已清理角色中不再可用的菜单关联, " f"available_menus={len(available_ids)}, roles_affected={len(tenant_role_ids)}" ) - result = TenantOutSchema.model_validate(updated).model_dump() + result = TenantOutSchema.model_validate(updated) return result @classmethod @@ -184,9 +173,14 @@ class TenantService: if 1 in ids: raise CustomException(msg="系统租户不允许删除") for id in ids: - obj = await TenantCRUD(auth).get_by_id_crud(id=id) + obj = await TenantCRUD(auth).get(id=id) if not obj: continue + from app.api.v1.module_system.dept.crud import DeptCRUD + from app.api.v1.module_system.position.crud import PositionCRUD + from app.api.v1.module_system.role.crud import RoleCRUD + from app.api.v1.module_system.user.crud import UserCRUD + for tid in ids: reasons: list[str] = [] if await UserCRUD(auth).list(search={"tenant_id": tid}): @@ -198,30 +192,29 @@ class TenantService: if await PositionCRUD(auth).list(search={"tenant_id": tid}): reasons.append("岗位") if reasons: - raise CustomException( - msg=f"租户 ID={tid} 下仍有关联数据({','.join(reasons)}),请先清理后再删除" - ) - await TenantCRUD(auth).delete_crud(ids=ids) + raise CustomException(msg=f"租户下已存在{'/'.join(reasons)},操作失败") + + await TenantCRUD(auth).delete(ids=ids) @classmethod async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: - if data.status == "1" and 1 in data.ids: + if data.status == 1 and 1 in data.ids: raise CustomException(msg="系统租户不允许禁用") - await TenantCRUD(auth).set_available_crud(ids=data.ids, status=data.status) + await TenantCRUD(auth).set(ids=data.ids, status=data.status) @classmethod async def toggle_status_service(cls, auth: AuthSchema, id: int) -> None: """切换单个租户的启用/禁用状态""" - obj = await TenantCRUD(auth).get_by_id_crud(id=id) + obj = await TenantCRUD(auth).get(id=id) if not obj: raise CustomException(msg="租户不存在") if id == 1: raise CustomException(msg="系统租户不允许禁用") - new_status = "0" if obj.status == "1" else "1" - await TenantCRUD(auth).set_available_crud(ids=[id], status=new_status) + new_status = 0 if obj.status == 1 else 1 + await TenantCRUD(auth).set(ids=[id], status=new_status) @classmethod - async def get_tenant_users_service(cls, auth: AuthSchema, tenant_id: int) -> list[dict]: + async def get_tenant_users_service(cls, auth: AuthSchema, tenant_id: int) -> list[TenantUserOutSchema]: """获取租户下的用户列表""" from sqlalchemy import select @@ -248,7 +241,7 @@ class TenantService: create_time=tu.create_time, username=u.username, name=u.name, - ).model_dump() + ) ) return users @@ -258,14 +251,14 @@ class TenantService: ) -> None: """向租户添加用户""" # 验证租户存在 - tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id) + tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: raise CustomException(msg="租户不存在") # 验证用户存在 from app.api.v1.module_system.user.crud import UserCRUD - user = await UserCRUD(auth).get_by_id_crud(id=data.user_id) + user = await UserCRUD(auth).get(id=data.user_id) if not user: raise CustomException(msg="用户不存在") @@ -316,7 +309,7 @@ class TenantService: auth.db.add(tu) await auth.db.flush() - log.info(f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}") + logger.info(f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}") @classmethod async def remove_tenant_user_service( @@ -356,72 +349,87 @@ class TenantService: await auth.db.delete(tu) await auth.db.flush() - log.info(f"从租户[{tenant_id}]移除用户[{user_id}]成功") + logger.info(f"从租户[{tenant_id}]移除用户[{user_id}]成功") # ============ P1: 配额管理 ============ + # 配额(max_users / max_roles / max_depts / max_storage_mb)由关联的 Package 控制 + # 系统租户 (id=1) 不受配额限制,普通租户从 package_id 对应的套餐读取配额 @classmethod async def get_quota_service(cls, auth: AuthSchema, tenant_id: int) -> dict: - """获取租户配额(从租户主表读取)""" - tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id) + """获取租户配额(从关联套餐读取,系统租户返回无限配额)""" + if tenant_id == 1: + return { + "tenant_id": 1, + "max_users": 999999, + "max_roles": 999999, + "max_storage_mb": 999999, + "max_depts": 999999, + "package_name": "系统租户(无限)", + } + tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: raise CustomException(msg="租户不存在") - return TenantQuotaOutSchema( - tenant_id=tenant.id, - max_users=tenant.max_users, - max_roles=tenant.max_roles, - max_storage_mb=tenant.max_storage_mb, - max_depts=tenant.max_depts, - ).model_dump() - - @classmethod - async def update_quota_service( - cls, auth: AuthSchema, tenant_id: int, data: TenantQuotaUpdateSchema - ) -> dict: - """更新租户配额(直接更新租户主表)""" - tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id) - if not tenant: - raise CustomException(msg="租户不存在") - - update_data = data.model_dump(exclude_unset=True) - for k, v in update_data.items(): - setattr(tenant, k, v) - await auth.db.flush() - log.info(f"租户[{tenant_id}]配额已更新: {update_data}") - return TenantQuotaOutSchema( - tenant_id=tenant.id, - max_users=tenant.max_users, - max_roles=tenant.max_roles, - max_storage_mb=tenant.max_storage_mb, - max_depts=tenant.max_depts, - ).model_dump() + if not tenant.package_id: + return { + "tenant_id": tenant.id, + "max_users": 0, + "max_roles": 0, + "max_storage_mb": 0, + "max_depts": 0, + "package_name": "未绑定套餐", + } + from app.api.v1.module_platform.package.crud import PackageCRUD + pkg = await PackageCRUD(auth).get(id=tenant.package_id) + if not pkg: + return { + "tenant_id": tenant.id, "max_users": 0, "max_roles": 0, + "max_storage_mb": 0, "max_depts": 0, "package_name": "套餐已删除", + } + return { + "tenant_id": tenant.id, + "max_users": pkg.max_users, + "max_roles": pkg.max_roles, + "max_storage_mb": getattr(pkg, "max_storage_mb", 0), + "max_depts": pkg.max_depts, + "package_name": pkg.name, + } @classmethod async def check_quota_service( cls, auth: AuthSchema, tenant_id: int, - resource_type: str, # "user"/"role"/"dept" + resource_type: str, ) -> None: - """检查租户配额是否充足,不足时抛出异常""" + """检查租户配额是否充足,不足时抛出异常(系统租户跳过检查)""" + if tenant_id == 1: + return from sqlalchemy import func, select - tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id) - if not tenant: - raise CustomException(msg="租户不存在") + tenant = await TenantCRUD(auth).get(id=tenant_id) + if not tenant or not tenant.package_id: + return + + from app.api.v1.module_platform.package.crud import PackageCRUD + pkg = await PackageCRUD(auth).get(id=tenant.package_id) + if not pkg: + return field_map = { "user": "max_users", "role": "max_roles", "dept": "max_depts", + "storage": "max_storage_mb", } if resource_type not in field_map: return max_field = field_map[resource_type] - max_limit = getattr(tenant, max_field, 0) + max_limit = getattr(pkg, max_field, None) + if max_limit is None or max_limit == 0: + return - # 根据资源类型动态获取当前数量 if resource_type == "user": from app.api.v1.module_system.user.model import UserModel count_stmt = select(func.count()).select_from(UserModel).where( @@ -440,30 +448,46 @@ class TenantService: DeptModel.tenant_id == tenant_id, DeptModel.is_deleted.is_(False), ) + elif resource_type == "storage": + # storage 的实际容量校验在文件上传时进行,此处仅检查是否有配额 + return result = await auth.db.execute(count_stmt) current_count = result.scalar() or 0 - if max_limit > 0 and current_count >= max_limit: + if current_count >= max_limit: resource_labels = {"user": "用户", "role": "角色", "dept": "部门"} raise CustomException( - msg=f"租户{resource_labels.get(resource_type, resource_type)}数量已达上限({max_limit}),无法继续创建" + msg=f"租户{resource_labels.get(resource_type, resource_type)}数量已达套餐上限({max_limit}),无法继续创建" ) - # ============ P1: 租户配置 ============ + # ============ P1: 租户配置(已合并到主表) ============ @classmethod - async def get_config_service(cls, auth: AuthSchema, tenant_id: int) -> list[dict]: - """获取租户所有配置(带 Redis 缓存)""" - from sqlalchemy import select + async def get_config_service(cls, auth: AuthSchema, tenant_id: int) -> dict: + """获取租户所有配置(从租户主表读取,返回原始 dict 供内部使用)""" + tenant = await TenantCRUD(auth).get(id=tenant_id) + if not tenant: + raise CustomException(msg="租户不存在") - stmt = select(TenantConfigModel).where(TenantConfigModel.tenant_id == tenant_id) - result = await auth.db.execute(stmt) - configs = result.scalars().all() - return [TenantConfigOutSchema.model_validate(c).model_dump() for c in configs] + config_fields = [ + "name", "description", "version", "logo_url", "favicon", "login_bg", + "copyright", "keep_record", "help_doc", "privacy", "clause", "git_code" + ] + config = {field: getattr(tenant, field, None) for field in config_fields} + return config @classmethod - async def get_config_cache_service(cls, redis: Redis, tenant_id: int) -> list[dict]: + async def get_config_items_service(cls, auth: AuthSchema, tenant_id: int) -> list[TenantConfigOutSchema]: + """获取租户所有配置(对外接口,返回结构化列表)""" + config = await cls.get_config_service(auth, tenant_id) + return [ + TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) + for k, v in config.items() + ] + + @classmethod + async def get_config_cache_service(cls, redis: Redis, tenant_id: int) -> dict: """ 从 Redis 缓存获取租户配置,缓存未命中则从 DB 加载并回写缓存 @@ -472,127 +496,86 @@ class TenantService: - tenant_id (int): 租户ID 返回: - - list[dict]: 租户配置列表 + - dict: 租户配置字典 """ - redis_keys = await RedisCURD(redis).get_keys( - f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:*" - ) - redis_configs = await RedisCURD(redis).mget(redis_keys) - configs = [] - for config in redis_configs: - if not config: - continue + redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}" + redis_config = await RedisCURD(redis).get(key=redis_key) + + if redis_config: try: - configs.append(json.loads(config)) + return json.loads(redis_config) except Exception as e: - log.error(f"解析租户配置数据失败: {e}") - continue + logger.error(f"解析租户配置数据失败: {e}") - if not configs: - log.info(f"Redis 中没有租户[{tenant_id}]配置数据,从数据库中加载") - from app.core.database import async_db_session + logger.info(f"Redis 中没有租户[{tenant_id}]配置数据,从数据库中加载") + from app.core.database import async_db_session - async with async_db_session() as session: - async with session.begin(): - from app.api.v1.module_system.auth.schema import AuthSchema + async with async_db_session() as session: + async with session.begin(): + from app.core.base_schema import AuthSchema - auth = AuthSchema(db=session, check_data_scope=False) - configs = await cls.get_config_service(auth, tenant_id) - await cls._sync_configs_to_redis(redis, tenant_id, configs) - log.info(f"✅ 已从数据库加载 {len(configs)} 条租户配置到缓存") + auth = AuthSchema(db=session, check_data_scope=False) + config = await cls.get_config_service(auth, tenant_id) + await cls._sync_configs_to_redis(redis, tenant_id, config) + logger.info("✅ 已从数据库加载租户配置到缓存") - return configs + return config @classmethod - async def _sync_configs_to_redis( - cls, redis: Redis, tenant_id: int, configs: list[dict] - ) -> None: - """将租户配置列表批量写入 Redis 缓存""" - for cfg in configs: - redis_key = ( - f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:{cfg.get('config_key')}" - ) - value = json.dumps(cfg, ensure_ascii=False) - await RedisCURD(redis).set(key=redis_key, value=value, expire=None) + async def get_config_cache_items_service(cls, redis: Redis, tenant_id: int) -> list[TenantConfigOutSchema]: + """获取租户缓存配置(对外接口,返回结构化列表)""" + config = await cls.get_config_cache_service(redis, tenant_id) + return [ + TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) + for k, v in config.items() + ] @classmethod - async def _del_configs_from_redis(cls, redis: Redis, tenant_id: int, keys: list[str]) -> None: + async def _sync_configs_to_redis(cls, redis: Redis, tenant_id: int, config: dict) -> None: + """将租户配置写入 Redis 缓存""" + redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}" + value = json.dumps(config, ensure_ascii=False) + await RedisCURD(redis).set(key=redis_key, value=value, expire=None) + + @classmethod + async def _del_configs_from_redis(cls, redis: Redis, tenant_id: int) -> None: """删除租户配置的 Redis 缓存""" - redis_keys = [f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:{k}" for k in keys] - if redis_keys: - await RedisCURD(redis).delete(*redis_keys) + redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}" + await RedisCURD(redis).delete(redis_key) @classmethod async def update_config_service( - cls, auth: AuthSchema, redis: Redis, tenant_id: int, items: list[TenantConfigItem] - ) -> list[dict]: - """批量更新租户配置(同步 Redis 缓存)""" - from sqlalchemy import select + cls, auth: AuthSchema, redis: Redis, tenant_id: int, config: dict + ) -> list[TenantConfigOutSchema]: + """更新租户配置(同步 Redis 缓存)""" + tenant = await TenantCRUD(auth).get(id=tenant_id) + if not tenant: + raise CustomException(msg="租户不存在") + + config_fields = [ + "name", "description", "version", "logo_url", "favicon", "login_bg", + "copyright", "keep_record", "help_doc", "privacy", "clause", "git_code" + ] + + for field in config_fields: + if field in config: + setattr(tenant, field, config[field]) - for item in items: - stmt = ( - select(TenantConfigModel) - .where( - TenantConfigModel.tenant_id == tenant_id, - TenantConfigModel.config_key == item.config_key, - ) - .limit(1) - ) - result = await auth.db.execute(stmt) - cfg = result.scalar_one_or_none() - if cfg: - cfg.config_value = item.config_value - if item.config_type: - cfg.config_type = item.config_type - else: - cfg = TenantConfigModel( - tenant_id=tenant_id, - config_key=item.config_key, - config_value=item.config_value, - config_type=item.config_type or "string", - ) - auth.db.add(cfg) await auth.db.flush() # 刷新 DB 数据并同步到 Redis - configs = await cls.get_config_service(auth, tenant_id) - await cls._sync_configs_to_redis(redis, tenant_id, configs) - log.info(f"租户[{tenant_id}]配置已更新, keys={[i.config_key for i in items]}") - return configs + new_config = await cls.get_config_service(auth, tenant_id) + await cls._sync_configs_to_redis(redis, tenant_id, new_config) + logger.info(f"租户[{tenant_id}]配置已更新") + return [ + TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) + for k, v in new_config.items() + ] # ============ P1: 租户菜单 ============ - - @classmethod - async def get_menus_service(cls, auth: AuthSchema, tenant_id: int) -> list[int]: - """获取租户菜单权限(返回 menu_id 列表)""" - from sqlalchemy import select - - stmt = select(TenantMenuModel.menu_id).where(TenantMenuModel.tenant_id == tenant_id) - result = await auth.db.execute(stmt) - return [row[0] for row in result.all()] - - @classmethod - async def set_menus_service( - cls, auth: AuthSchema, tenant_id: int, data: TenantMenuSetSchema - ) -> None: - """批量设置租户菜单权限(先清空再写入)""" - from sqlalchemy import delete - - await auth.db.execute(delete(TenantMenuModel).where(TenantMenuModel.tenant_id == tenant_id)) - for menu_id in data.menu_ids: - auth.db.add(TenantMenuModel(tenant_id=tenant_id, menu_id=menu_id)) - await auth.db.flush() - log.info(f"租户[{tenant_id}]菜单权限已设置, count={len(data.menu_ids)}") - - @staticmethod - async def get_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]: - """获取租户可用菜单ID列表(套餐菜单 + 自定义授权菜单的并集) - - 供角色/用户权限约束使用。 - """ - from app.api.v1.module_platform.package.service import PackageService - - return await PackageService.get_tenant_available_menu_ids(auth, tenant_id) + # 租户菜单权限现通过 Package → PackageMenuModel 统一控制 + # get_menus_service / set_menus_service / get_tenant_menu_ids 已移除 + # 改用 PackageService.get_package_menu_ids / get_tenant_available_menu_ids # ============ P1: 初始化缓存 ============ @@ -618,59 +601,399 @@ class TenantService: tenants = result.scalars().all() for tenant in tenants: - config_stmt = select(TenantConfigModel).where( - TenantConfigModel.tenant_id == tenant.id - ) - config_result = await session.execute(config_stmt) - configs = config_result.scalars().all() - config_list = [ - TenantConfigOutSchema.model_validate(c).model_dump() for c in configs + config_fields = [ + "name", "description", "version", "logo_url", "favicon", "login_bg", + "copyright", "keep_record", "help_doc", "privacy", "clause", "git_code" ] + config = {field: getattr(tenant, field, None) for field in config_fields} - if config_list: - await cls._sync_configs_to_redis(redis, tenant.id, config_list) - log.info( - f"✅ 租户[{tenant.name}](id={tenant.id}) {len(config_list)} 条配置已缓存到 Redis" - ) - else: - log.warning(f"⚠️ 租户[{tenant.name}](id={tenant.id}) 无配置数据,跳过缓存") + await cls._sync_configs_to_redis(redis, tenant.id, config) + logger.info(f"✅ 租户[{tenant.name}](id={tenant.id}) 配置已缓存到 Redis") - # ============ P1: 到期提醒 ============ + # ============ 续期 & 套餐变更预览 ============ + + @classmethod + async def renew_service(cls, auth: AuthSchema, tenant_id: int, end_time: str) -> TenantOutSchema: + """租户续期:延长 end_time 并恢复为 active 状态 + + 仅 active(0)/grace(1)/suspended(2) 状态可续期。 + expired(4)/frozen(3)/archived(5) 不可续期。 + + 参数: + - auth (AuthSchema): 认证信息 + - tenant_id (int): 租户ID + - end_time (str): 新的结束时间 + + 返回: + - dict: 更新后的租户信息 + """ + from datetime import datetime + + tenant = await TenantCRUD(auth).get(id=tenant_id) + if not tenant: + raise CustomException(msg="租户不存在") + + if tenant.status not in (0, 1, 2): + status_labels = {0: "正常", 1: "宽限期", 2: "暂停", 3: "冻结", 4: "过期", 5: "归档"} + current_label = status_labels.get(str(tenant.status), str(tenant.status)) + raise CustomException( + msg=f"当前租户状态为「{current_label}」,仅正常/宽限期/暂停状态可续期" + ) + + new_end = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time + if new_end <= datetime.now(): + raise CustomException(msg="续期结束时间必须晚于当前时间") + + tenant.end_time = new_end + tenant.status = 0 + tenant.grace_start_time = None + + await auth.db.flush() + logger.info(f"租户[{tenant.name}]续期成功, 新的结束时间: {end_time}") + + return TenantOutSchema.model_validate(tenant) + + @classmethod + async def package_change_preview_service( + cls, auth: AuthSchema, tenant_id: int, new_package_id: int + ) -> PackageChangePreviewOut: + """套餐变更影响预览 + + 返回受影响角色、菜单清单、配额对比等,供超管确认后再执行变更。 + + 参数: + - auth (AuthSchema): 认证信息 + - tenant_id (int): 租户ID + - new_package_id (int): 目标套餐ID + + 返回: + - dict: 预览结果 + """ + from sqlalchemy import func, select + + from app.api.v1.module_platform.menu.model import MenuModel + from app.api.v1.module_platform.package.crud import PackageCRUD + from app.api.v1.module_platform.package.service import PackageService + from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel + from app.api.v1.module_system.user.model import UserModel + + tenant = await TenantCRUD(auth).get(id=tenant_id) + if not tenant: + raise CustomException(msg="租户不存在") + + new_package = await PackageCRUD(auth).get(id=new_package_id) + if not new_package: + raise CustomException(msg="目标套餐不存在") + + # 当前可用菜单 + current_menu_ids = await PackageService.get_tenant_available_menu_ids(auth, tenant_id) + + # 新套餐可用菜单(直接取套餐菜单,不再包含自定义授权) + new_menu_ids = set( + await PackageService.get_package_menu_ids(auth, new_package_id) + ) + final_menu_ids = new_menu_ids # 不再合并租户自定义菜单 + + # 差异计算 + removed_ids = current_menu_ids - final_menu_ids + added_ids = final_menu_ids - current_menu_ids + + removed_menus = [] + added_menus = [] + if removed_ids: + menu_stmt = select(MenuModel).where(MenuModel.id.in_(removed_ids)) + menu_result = await auth.db.execute(menu_stmt) + removed_menus = [ + {"id": m.id, "name": m.name, "route_path": m.route_path} + for m in menu_result.scalars().all() + ] + if added_ids: + menu_stmt = select(MenuModel).where(MenuModel.id.in_(added_ids)) + menu_result = await auth.db.execute(menu_stmt) + added_menus = [ + {"id": m.id, "name": m.name, "route_path": m.route_path} + for m in menu_result.scalars().all() + ] + + # 受影响角色 + role_stmt = select(RoleModel).where(RoleModel.tenant_id == tenant_id) + role_result = await auth.db.execute(role_stmt) + roles = role_result.scalars().all() + + affected_roles = [] + total_affected_users = 0 + for role in roles: + # 查该角色下有多少菜单会被移除 + role_menu_stmt = select(RoleMenusModel.menu_id).where( + RoleMenusModel.role_id == role.id + ) + rm_result = await auth.db.execute(role_menu_stmt) + role_menu_ids = {row[0] for row in rm_result.all()} + affected_menu_count = len(role_menu_ids & removed_ids) + + # 查该角色下用户数 + user_count_stmt = ( + select(func.count()) + .select_from(UserModel) + .join(UserModel.roles) + .where(RoleModel.id == role.id) + ) + uc_result = await auth.db.execute(user_count_stmt) + user_count = uc_result.scalar() or 0 + + affected_roles.append({ + "id": role.id, + "name": role.name, + "code": role.code, + "affected_menu_count": affected_menu_count, + "user_count": user_count, + }) + total_affected_users += user_count + + # 配额对比(从套餐读取) + old_pkg = None + if tenant.package_id: + old_pkg = await PackageCRUD(auth).get(id=tenant.package_id) + quota_changes = { + "max_users": { + "current": old_pkg.max_users if old_pkg else 0, + "new": new_package.max_users, + }, + "max_roles": { + "current": old_pkg.max_roles if old_pkg else 0, + "new": new_package.max_roles, + }, + "max_depts": { + "current": old_pkg.max_depts if old_pkg else 0, + "new": new_package.max_depts, + }, + } + + return PackageChangePreviewOut( + new_package_id=new_package.id, + new_package_name=new_package.name, + affected_roles=affected_roles, + removed_menus=removed_menus, + added_menus=added_menus, + quota_changes=quota_changes, + total_affected_users=total_affected_users, + ) + + # ============ P1: 到期提醒(多阶段自动处理) ============ @staticmethod async def check_tenant_expiry() -> None: - """定时任务:检查租户到期并发送通知 / 自动禁用""" - from datetime import datetime, timedelta + """定时任务:多阶段租户到期自动处理 - from app.core.db_session import async_session_factory + PRD §9 到期阶段: + grace(1) → 到期后第 1-7 天,仅提醒 + suspended(2) → 到期后第 8-14 天,禁用登录 + frozen(3) → 到期后第 15-30 天,只读模式 + expired(4) → 第 31 天起,归档候选 + """ + from datetime import datetime - async with async_session_factory() as db: - now = datetime.now() - # 扫描所有启用的租户 - stmt = sa.select(TenantModel).where( - TenantModel.status == "0", - TenantModel.end_time.isnot(None), + from sqlalchemy import text + + from app.core.database import async_db_session + + now = datetime.now() + + async with async_db_session() as session: + # 获取所有已过期的活跃租户(status=0) + rows = await session.execute( + text( + "SELECT id, name, contact_email, contact_name, " + "end_time, status FROM platform_tenant " + "WHERE status = '0' AND end_time IS NOT NULL AND end_time < :now" + ), + {"now": now}, ) - result = await db.execute(stmt) - tenants = result.scalars().all() + expired_tenants = rows.fetchall() - for t in tenants: - if t.end_time <= now: - # 已到期:自动禁用 - await db.execute( - sa.update(TenantModel).where(TenantModel.id == t.id).values(status="1") + for t in expired_tenants: + tenant_id, tenant_name, email, contact_name, end_time, cur_status = t + days_past = (now - end_time).days if end_time else 0 + + if days_past <= 7: + new_status, label = 1, "宽限期" + elif days_past <= 14: + new_status, label = 2, "已停用" + elif days_past <= 30: + new_status, label = 3, "已冻结" + else: + new_status, label = 4, "已过期" + + if new_status == cur_status: + continue + + await session.execute( + text("UPDATE platform_tenant SET status = :s WHERE id = :tid"), + {"s": new_status, "tid": tenant_id}, + ) + logger.info( + f"租户状态切换: id={tenant_id} name={tenant_name} " + f"status={cur_status}→{new_status} ({label})" + ) + + # 发送通知邮件 + if email: + await TenantService._send_expiry_email( + tenant_name, contact_name, email, end_time, days_past, label ) - log.info(f"租户[{t.name}]已到期,自动禁用") - elif t.end_time <= now + timedelta(days=1): - TenantService._notify_expiry(t, 1) - elif t.end_time <= now + timedelta(days=7): - TenantService._notify_expiry(t, 7) - elif t.end_time <= now + timedelta(days=30): - TenantService._notify_expiry(t, 30) - await db.commit() + await session.commit() + + logger.info(f"到期检查完成,处理了 {len(expired_tenants)} 个过期租户") @staticmethod - async def _notify_expiry(tenant: TenantModel, days: int) -> None: - """发送到期提醒通知""" - log.info(f"租户[{tenant.name}]将在 {days} 天后到期,联系人: {tenant.contact_email}") + async def send_grace_reminders() -> None: + """定时任务:向宽限期租户发送续费提醒邮件(每天 09:00)""" + from datetime import datetime + + from sqlalchemy import text + + from app.core.database import async_db_session + + async with async_db_session() as session: + rows = await session.execute( + text( + "SELECT id, name, contact_email, contact_name, end_time " + "FROM platform_tenant " + "WHERE status = '1' AND contact_email IS NOT NULL AND contact_email != ''" + ) + ) + grace_tenants = rows.fetchall() + + sent = 0 + for t in grace_tenants: + tenant_id, name, email, contact_name, end_time = t + days_past = (datetime.now() - end_time).days if end_time else 0 + + try: + ok = await TenantService._send_renew_email( + name, contact_name, email, days_past + ) + if ok: + sent += 1 + except Exception as e: + logger.warning(f"续费提醒邮件发送失败: tenant_id={tenant_id}, err={e}") + + logger.info(f"续费提醒完成,共发送 {sent}/{len(grace_tenants)} 封邮件") + + @staticmethod + async def clean_expired_tenants() -> None: + """定时任务:将过期超 90 天租户归档,清理旧审计日志(每月 1 号 02:00)""" + from datetime import datetime, timedelta + + from sqlalchemy import text + + from app.core.database import async_db_session + + cutoff = datetime.now() - timedelta(days=90) + + async with async_db_session() as session: + # 归档过期租户 + result = await session.execute( + text( + "SELECT COUNT(*) FROM platform_tenant " + "WHERE status = '4' AND end_time < :cutoff" + ), + {"cutoff": cutoff}, + ) + count = result.scalar() or 0 + if count > 0: + await session.execute( + text( + "UPDATE platform_tenant SET status = '5' " + "WHERE status = '4' AND end_time < :cutoff" + ), + {"cutoff": cutoff}, + ) + await session.commit() + logger.info(f"已将 {count} 个过期超过 90 天的租户标记为归档") + + await session.commit() + + @staticmethod + async def _send_expiry_email( + tenant_name: str, + contact_name: str | None, + email: str, + end_time: object, + days_past: int, + label: str, + ) -> None: + """发送租户状态变更通知邮件""" + from app.config.setting import settings + from app.utils.email_util import render_template, send_email + + if not settings.SMTP_HOST: + return + + smtp_config = { + "host": settings.SMTP_HOST, + "port": settings.SMTP_PORT, + "username": settings.SMTP_USER, + "password": settings.SMTP_PASSWORD, + "from_addr": settings.SMTP_FROM, + "starttls": settings.SMTP_TLS, + "ssl_tls": settings.SMTP_SSL, + } + end_str = end_time.strftime("%Y-%m-%d") if hasattr(end_time, "strftime") else str(end_time) + + try: + html_body = await render_template( + "email/tenant_expiry_notice.html", + tenant_name=tenant_name, + contact_name=contact_name or "管理员", + days_expired=days_past, + status_name=label, + end_time=end_str, + site_url=getattr(settings, "SITE_URL", ""), + ) + await send_email( + smtp_config=smtp_config, + recipients=[email], + subject=f"【重要】租户 {tenant_name} 服务状态变更通知", + body=html_body, + html=True, + ) + except Exception as e: + logger.warning(f"到期通知邮件发送失败: tenant={tenant_name}, err={e}") + + @staticmethod + async def _send_renew_email( + name: str, contact_name: str | None, email: str, days_past: int + ) -> bool: + """发送续费提醒邮件""" + from app.config.setting import settings + from app.utils.email_util import render_template, send_email + + if not settings.SMTP_HOST: + return False + + smtp_config = { + "host": settings.SMTP_HOST, + "port": settings.SMTP_PORT, + "username": settings.SMTP_USER, + "password": settings.SMTP_PASSWORD, + "from_addr": settings.SMTP_FROM, + "starttls": settings.SMTP_TLS, + "ssl_tls": settings.SMTP_SSL, + } + + html_body = await render_template( + "email/tenant_renew_reminder.html", + tenant_name=name, + contact_name=contact_name or "管理员", + days_expired=days_past, + renew_url=f"{getattr(settings, 'SITE_URL', '')}/tenant/order/create", + ) + return await send_email( + smtp_config=smtp_config, + recipients=[email], + subject=f"【续费提醒】租户 {name} 服务即将到期", + body=html_body, + html=True, + ) diff --git a/backend/app/api/v1/module_platform/ticket/crud.py b/backend/app/api/v1/module_platform/ticket/crud.py deleted file mode 100644 index e6dcdeaf..00000000 --- a/backend/app/api/v1/module_platform/ticket/crud.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Any - -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_crud import CRUDBase - -from .model import TicketModel -from .schema import TicketCreateSchema, TicketUpdateSchema - - -class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]): - """工单 CRUD""" - - def __init__(self, auth: AuthSchema) -> None: - self.auth = auth - super().__init__(model=TicketModel, auth=auth) - - async def page_crud( - self, - offset: int, - limit: int, - order_by: list[dict[str, str]] | None, - search: dict | None = None, - out_schema: type | None = None, - preload: list[str | Any] | None = None, - ) -> dict: - from .schema import TicketOutSchema - - return await self.page( - offset=offset, - limit=limit, - order_by=order_by or [{"id": "asc"}], - search=search or {}, - out_schema=out_schema or TicketOutSchema, - preload=preload, - ) - - async def get_by_id_crud(self, id: int) -> TicketModel | None: - return await self.get(id=id) - - async def create_crud(self, data: TicketCreateSchema) -> TicketModel | None: - return await self.create(data=data) - - async def update_crud(self, id: int, data: TicketUpdateSchema) -> TicketModel | None: - return await self.update(id=id, data=data) - - async def delete_crud(self, ids: list[int]) -> None: - await self.delete(ids=ids) - - async def set_crud(self, ids: list[int], **kwargs) -> None: - """批量设置工单状态""" - await self.set(ids=ids, **kwargs) diff --git a/backend/app/api/v1/module_system/__init__.py b/backend/app/api/v1/module_system/__init__.py index ce34f8e7..87f7fb23 100644 --- a/backend/app/api/v1/module_system/__init__.py +++ b/backend/app/api/v1/module_system/__init__.py @@ -6,58 +6,35 @@ - 用户管理 (user) - 角色管理 (role) - 部门管理 (dept) -- 菜单管理 (menu) - 岗位管理 (position) - 字典管理 (dict) - 公告管理 (notice) - 参数管理 (params) -- 操作日志 (operationlog) +- 日志管理 (log) +- 工单管理 (ticket) """ from fastapi import APIRouter -system_router = APIRouter(prefix="/system", tags=["系统管理"]) -_routers_included = False +from app.api.v1.module_system.auth.controller import AuthRouter +from app.api.v1.module_system.dept.controller import DeptRouter +from app.api.v1.module_system.dict.controller import DictRouter +from app.api.v1.module_system.log import LogRouter +from app.api.v1.module_system.notice.controller import NoticeRouter +from app.api.v1.module_system.params.controller import ParamsRouter +from app.api.v1.module_system.position.controller import PositionRouter +from app.api.v1.module_system.role.controller import RoleRouter +from app.api.v1.module_system.ticket.controller import TicketRouter +from app.api.v1.module_system.user.controller import UserRouter +system_router = APIRouter(prefix="/system") -def _include_routers(): - """延迟导入路由以避免循环导入""" - global _routers_included - if _routers_included: - return - - from app.api.v1.module_system.auth.controller import AuthRouter - from app.api.v1.module_system.dept.controller import DeptRouter - from app.api.v1.module_system.dict.controller import DictRouter - from app.api.v1.module_system.menu.controller import MenuRouter - from app.api.v1.module_system.notice.controller import NoticeRouter - from app.api.v1.module_system.operationlog.controller import OperationLogRouter - from app.api.v1.module_system.params.controller import ParamsRouter - from app.api.v1.module_system.position.controller import PositionRouter - from app.api.v1.module_system.role.controller import RoleRouter - from app.api.v1.module_system.user.controller import UserRouter - - system_router.include_router(AuthRouter) - system_router.include_router(DeptRouter) - system_router.include_router(DictRouter) - system_router.include_router(OperationLogRouter) - system_router.include_router(MenuRouter) - system_router.include_router(NoticeRouter) - system_router.include_router(ParamsRouter) - system_router.include_router(PositionRouter) - system_router.include_router(RoleRouter) - system_router.include_router(UserRouter) - _routers_included = True - - -# 提供一个属性访问器,在首次访问时才加载路由 -class _SystemRouter(APIRouter): - def __getattr__(self, name): - _include_routers() - return getattr(system_router, name) - - -# 使用包装类延迟路由加载 -system_router = _SystemRouter(prefix="/system", tags=["系统管理"]) - -# 保持向后兼容性,直接暴露 include_router 方法 -system_router.include_router = lambda router: _include_routers() or system_router.include_router(router) +system_router.include_router(AuthRouter) +system_router.include_router(DeptRouter) +system_router.include_router(DictRouter) +system_router.include_router(LogRouter) +system_router.include_router(NoticeRouter) +system_router.include_router(ParamsRouter) +system_router.include_router(PositionRouter) +system_router.include_router(RoleRouter) +system_router.include_router(TicketRouter) +system_router.include_router(UserRouter) diff --git a/backend/app/api/v1/module_system/auth/controller.py b/backend/app/api/v1/module_system/auth/controller.py index 98a7dde7..c94ec35d 100644 --- a/backend/app/api/v1/module_system/auth/controller.py +++ b/backend/app/api/v1/module_system/auth/controller.py @@ -4,14 +4,22 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query, Request from fastapi.responses import JSONResponse, RedirectResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache from redis.asyncio.client import Redis from sqlalchemy.ext.asyncio import AsyncSession -from app.common.response import ErrorResponse, SuccessResponse +from app.common.response import ErrorResponse, ResponseSchema, SuccessResponse from app.config.setting import settings +from app.core.base_schema import ( + AuthSchema, + JWTOutSchema, + LogoutPayloadSchema, + RefreshTokenPayloadSchema, +) from app.core.dependencies import db_getter, get_current_user, redis_getter from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.core.router_class import OperationLogRoute from app.core.security import CustomOAuth2PasswordRequestForm @@ -26,27 +34,34 @@ from .oauth_service import ( save_oauth_state, ) from .schema import ( - AuthSchema, AutoLoginTokenSchema, AutoLoginUserSchema, CaptchaOutSchema, - JWTOutSchema, + ForgotPasswordSchema, LoginWithTenantsSchema, - LogoutPayloadSchema, - RefreshTokenPayloadSchema, + ResetPasswordWithTokenSchema, SelectTenantOutSchema, SelectTenantSchema, TenantOptionSchema, + TenantRegisterOutSchema, + TenantRegisterSchema, +) +from .service import ( + AutoLoginService, + CaptchaService, + LoginService, + PasswordResetService, + TenantRegisterService, ) -from .service import AutoLoginService, CaptchaService, LoginService -AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["认证授权"]) +AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["系统管理/认证授权"]) + +_AUTH_TENANTS_NS = "auth_tenants" @AuthRouter.post( "/login", summary="登录", - description="登录(返回可选租户列表)", response_model=LoginWithTenantsSchema, ) async def login_for_access_token_controller( @@ -74,23 +89,20 @@ async def login_for_access_token_controller( request=request, redis=redis, login_form=login_form, db=db ) - log.info(f"用户{login_form.username}登录成功") + logger.info(f"用户{login_form.username}登录成功") # 如果是文档请求,则不记录日志 if settings.DOCS_URL in request.headers.get("referer", ""): - return login_result.model_dump() - return SuccessResponse(data=login_result.model_dump(), msg="登录成功") + return login_result + return SuccessResponse(data=login_result, msg="登录成功") @AuthRouter.post( "/token/refresh", summary="刷新token", - description="刷新token", - response_model=JWTOutSchema, - dependencies=[Depends(get_current_user)], + response_model=ResponseSchema[JWTOutSchema], ) async def get_new_token_controller( - request: Request, payload: RefreshTokenPayloadSchema, db: Annotated[AsyncSession, Depends(db_getter)], redis: Annotated[Redis, Depends(redis_getter)], @@ -99,7 +111,6 @@ async def get_new_token_controller( 刷新token 参数: - - request (Request): FastAPI请求对象 - payload (RefreshTokenPayloadSchema): 刷新令牌负载模型 - db (AsyncSession): 数据库会话对象 - redis (Redis): Redis 客户端对象 @@ -110,20 +121,16 @@ async def get_new_token_controller( 异常: - CustomException: 刷新令牌失败时抛出异常。 """ - # 解析当前的访问Token以获取用户名 new_token = await LoginService.refresh_token_service( - db=db, request=request, redis=redis, refresh_token=payload + db=db, redis=redis, refresh_token=payload ) - token_dict = new_token.model_dump() - log.info(f"刷新token成功: {token_dict}") - return SuccessResponse(data=token_dict, msg="刷新成功") + return SuccessResponse(data=new_token, msg="刷新成功") @AuthRouter.get( "/captcha/get", summary="获取验证码", - description="获取登录验证码", - response_model=CaptchaOutSchema, + response_model=ResponseSchema[CaptchaOutSchema], ) async def get_captcha_for_login_controller( redis: Annotated[Redis, Depends(redis_getter)], @@ -142,15 +149,14 @@ async def get_captcha_for_login_controller( """ # 获取验证码 captcha = await CaptchaService.get_captcha_service(redis=redis) - log.info("获取验证码成功") return SuccessResponse(data=captcha, msg="获取验证码成功") @AuthRouter.post( "/logout", summary="退出登录", - description="退出登录", dependencies=[Depends(get_current_user)], + response_model=ResponseSchema[None], ) async def logout_controller( payload: LogoutPayloadSchema, @@ -170,7 +176,7 @@ async def logout_controller( - CustomException: 退出登录失败时抛出异常。 """ if await LoginService.logout_service(redis=redis, token=payload): - log.info("退出成功") + logger.info("退出成功") return SuccessResponse(msg="退出成功") return ErrorResponse(msg="退出失败") @@ -178,8 +184,7 @@ async def logout_controller( @AuthRouter.get( "/auto-login/users", summary="获取免登录用户列表", - description="获取可用于免登录快速登录的用户列表", - response_model=list[AutoLoginUserSchema], + response_model=ResponseSchema[list[AutoLoginUserSchema]], ) async def get_auto_login_users_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], @@ -203,8 +208,7 @@ async def get_auto_login_users_controller( @AuthRouter.post( "/auto-login/token", summary="获取免登录Token", - description="根据用户ID生成免登录Token", - response_model=AutoLoginTokenSchema, + response_model=ResponseSchema[AutoLoginTokenSchema], ) async def get_auto_login_token_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], @@ -234,8 +238,7 @@ async def get_auto_login_token_controller( @AuthRouter.post( "/auto-login", summary="免登录", - description="使用免登录Token快速登录", - response_model=JWTOutSchema, + response_model=ResponseSchema[JWTOutSchema], ) async def auto_login_controller( request: Request, @@ -258,15 +261,14 @@ async def auto_login_controller( login_token = await AutoLoginService.auto_login_service( request=request, redis=redis, db=db, token=token ) - log.info("用户免登录成功") - return SuccessResponse(data=login_token.model_dump(), msg="登录成功") + logger.info("用户免登录成功") + return SuccessResponse(data=login_token, msg="登录成功") @AuthRouter.post( "/select-tenant", summary="选择租户", - description="登录后选择当前操作的租户,签发含租户上下文的新 JWT Token", - response_model=SelectTenantOutSchema, + response_model=ResponseSchema[SelectTenantOutSchema], dependencies=[Depends(get_current_user)], ) async def select_tenant_controller( @@ -292,16 +294,17 @@ async def select_tenant_controller( result = await LoginService.select_tenant_service( request=request, redis=redis, auth=auth, tenant_id=data.tenant_id ) - return SuccessResponse(data=result.model_dump(), msg="租户切换成功") + await FastAPICache.clear(namespace=_AUTH_TENANTS_NS) + return SuccessResponse(data=result, msg="租户切换成功") @AuthRouter.get( "/tenants", summary="获取可选租户列表", - description="返回当前用户关联的所有可选租户", - response_model=list[TenantOptionSchema], + response_model=ResponseSchema[list[TenantOptionSchema]], dependencies=[Depends(get_current_user)], ) +@cache(expire=120, namespace=_AUTH_TENANTS_NS) async def get_user_tenants_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(db_getter)], @@ -323,7 +326,6 @@ async def get_user_tenants_controller( @AuthRouter.get( "/oauth/{provider}/login", summary="第三方OAuth跳转", - description="浏览器重定向到微信/GitHub/Gitee/QQ 授权页;redirect_uri 为授权完成后回到前端的登录页地址(如 http://localhost:5173/login)。", ) async def oauth_login_redirect_controller( request: Request, @@ -413,3 +415,84 @@ async def oauth_callback_controller( except CustomException as e: fe = await resolve_frontend() return RedirectResponse(url=oauth_service_error_redirect(fe, e.msg), status_code=302) + + +@AuthRouter.post( + "/tenant/register", + summary="租户自助注册", + response_model=ResponseSchema[TenantRegisterOutSchema], +) +async def tenant_register_controller( + data: TenantRegisterSchema, + db: Annotated[AsyncSession, Depends(db_getter)], +) -> JSONResponse: + """ + 租户自助注册(PRD §4.5) + + 参数: + - data (TenantRegisterSchema): 注册信息 + + 返回: + - TenantRegisterOutSchema: 注册结果,含 tenant_id/user_id/试用到期日 + """ + result = await TenantRegisterService.register( + db=db, + username=data.username, + password=data.password, + email=data.email, + tenant_name=data.tenant_name, + ) + logger.info(f"新租户注册: username={data.username} tenant={result.tenant_name}") + return SuccessResponse(data=result, msg=result.message) + + +# ─── 忘记密码(自助重置)──────────────────────────────────── + +@AuthRouter.post( + "/forgot-password", + summary="忘记密码", + response_model=ResponseSchema[str], +) +async def forgot_password_controller( + data: ForgotPasswordSchema, + redis: Annotated[Redis, Depends(redis_getter)], + db: Annotated[AsyncSession, Depends(db_getter)], +) -> JSONResponse: + """ + 忘记密码:提交邮箱,接收重置邮件 + + 参数: + - data (ForgotPasswordSchema): 邮箱 + + 返回: + - str: 提示信息(无论成功与否均返回相同文案) + """ + msg = await PasswordResetService.forgot_password_service( + redis=redis, db=db, email=data.email + ) + return SuccessResponse(data=msg, msg="邮件已发送") + + +@AuthRouter.post( + "/reset-password", + summary="重置密码", + response_model=ResponseSchema[str], +) +async def reset_password_controller( + data: ResetPasswordWithTokenSchema, + redis: Annotated[Redis, Depends(redis_getter)], + db: Annotated[AsyncSession, Depends(db_getter)], +) -> JSONResponse: + """ + 重置密码:使用令牌 + 新密码 + + 参数: + - data (ResetPasswordWithTokenSchema): 令牌 + 新密码 + + 返回: + - str: 重置结果 + """ + msg = await PasswordResetService.reset_password_with_token_service( + redis=redis, db=db, token=data.token, new_password=data.new_password + ) + return SuccessResponse(data=msg, msg="密码已重置") diff --git a/backend/app/api/v1/module_system/auth/oauth_service.py b/backend/app/api/v1/module_system/auth/oauth_service.py index 1c3e9aee..b8fa4170 100644 --- a/backend/app/api/v1/module_system/auth/oauth_service.py +++ b/backend/app/api/v1/module_system/auth/oauth_service.py @@ -20,17 +20,16 @@ from fastapi import Request from redis.asyncio.client import Redis from sqlalchemy.ext.asyncio import AsyncSession -from app.api.v1.module_system.auth.schema import AuthSchema from app.api.v1.module_system.user.crud import UserCRUD from app.api.v1.module_system.user.model import UserModel from app.api.v1.module_system.user.schema import UserRegisterSchema from app.api.v1.module_system.user.service import UserService from app.config.setting import settings +from app.core.base_schema import AuthSchema, JWTOutSchema from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD -from .schema import JWTOutSchema from .service import LoginService OAuthProvider = Literal["wechat", "qq", "github", "gitee"] @@ -138,7 +137,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> Any: return r.json() except json.JSONDecodeError: text = r.text - log.error(f"OAuth 非 JSON 响应: {text[:500]}") + logger.error(f"OAuth 非 JSON 响应: {text[:500]}") raise CustomException(msg="OAuth 接口返回异常") @@ -313,7 +312,7 @@ async def ensure_oauth_user( ) -> UserModel: auth = AuthSchema(db=db, user=None, tenant_id=1, check_data_scope=False) username = _username_for_oauth(provider, unique_id) - existing = await UserCRUD(auth).get_by_username_crud(username=username) + existing = await UserCRUD(auth).get(username=username) if existing: return existing @@ -323,11 +322,18 @@ async def ensure_oauth_user( name=(display_name or username)[:32], role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS), ) - await UserService.register_user_service(auth=auth, data=reg) - user = await UserCRUD(auth).get_by_username_crud(username=username) + try: + await UserService.register_user_service(auth=auth, data=reg) + except Exception: + # 并发创建可能触发唯一约束冲突,回退到再次查询 + existing = await UserCRUD(auth).get(username=username) + if existing: + return existing + raise CustomException(msg="OAuth 注册失败") + user = await UserCRUD(auth).get(username=username) if not user: raise CustomException(msg="OAuth 注册失败") - log.info(f"OAuth 自动注册用户: {username} ({provider})") + logger.info(f"OAuth 自动注册用户: {username} ({provider})") return user @@ -375,11 +381,11 @@ async def complete_oauth_login( raise CustomException(msg="不支持的 OAuth 渠道") user = await ensure_oauth_user(db=db, provider=provider, unique_id=uid, display_name=name) - if user.status == "1": + if user.status == 1: raise CustomException(msg="用户已被停用") user = await UserCRUD( - AuthSchema(db=db, user=None, check_data_scope=False) + AuthSchema(db=db, user=None, tenant_id=1, check_data_scope=False) ).update_last_login_crud(id=user.id) if not user: raise CustomException(msg="用户不存在") diff --git a/backend/app/api/v1/module_system/auth/schema.py b/backend/app/api/v1/module_system/auth/schema.py index 80cb6868..e9e9945e 100644 --- a/backend/app/api/v1/module_system/auth/schema.py +++ b/backend/app/api/v1/module_system/auth/schema.py @@ -1,15 +1,7 @@ -from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field -# 从 core 模块导入基础认证 schema,避免循环导入 -from app.core.auth_schema import ( - AuthSchema, - JWTPayloadSchema, - JWTOutSchema, - RefreshTokenPayloadSchema, - LogoutPayloadSchema, -) +from app.core.base_schema import JWTOutSchema class CaptchaOutSchema(BaseModel): @@ -73,3 +65,44 @@ class LoginWithTenantsSchema(JWTOutSchema): tenants: list[TenantOptionSchema] = Field(default_factory=list, description="可选租户列表") user_info: dict = Field(default_factory=dict, description="用户信息") + + +# ─── 租户自助注册 ────────────────────────────────────────────────────────────── + +class TenantRegisterSchema(BaseModel): + """租户自助注册请求""" + + username: str = Field(..., min_length=3, max_length=32, description="登录账号") + password: str = Field(..., min_length=6, max_length=128, description="登录密码") + email: str = Field(..., max_length=128, description="邮箱(用于接收通知)") + tenant_name: str | None = Field( + default=None, max_length=100, description="企业/团队名称(可选,默认:{用户名}的租户)" + ) + + +class TenantRegisterOutSchema(BaseModel): + """租户自助注册响应""" + + user_id: int = Field(..., description="用户ID") + username: str = Field(..., description="账号") + tenant_id: int = Field(..., description="租户ID") + tenant_name: str = Field(..., description="租户名称") + tenant_code: str = Field(..., description="租户编码") + package: str | None = Field(default=None, description="开通套餐") + trial_end: str = Field(..., description="试用到期日") + message: str = Field(default="注册成功", description="提示信息") + + +# ─── 忘记密码(自助重置)───────────────────────────────────────── + +class ForgotPasswordSchema(BaseModel): + """忘记密码:提交邮箱,接收重置邮件""" + + email: str = Field(..., max_length=128, description="注册时使用的邮箱") + + +class ResetPasswordWithTokenSchema(BaseModel): + """通过邮件链接重置密码""" + + token: str = Field(..., min_length=1, description="密码重置令牌") + new_password: str = Field(..., min_length=6, max_length=128, description="新密码") diff --git a/backend/app/api/v1/module_system/auth/service.py b/backend/app/api/v1/module_system/auth/service.py index 8575bf98..3f2be711 100644 --- a/backend/app/api/v1/module_system/auth/service.py +++ b/backend/app/api/v1/module_system/auth/service.py @@ -1,5 +1,5 @@ import json -import time +import secrets import uuid from datetime import datetime, timedelta from typing import NewType @@ -14,8 +14,15 @@ from app.api.v1.module_system.user.crud import UserCRUD from app.api.v1.module_system.user.model import UserModel from app.common.enums import RedisInitKeyConfig from app.config.setting import settings +from app.core.base_schema import ( + AuthSchema, + JWTOutSchema, + JWTPayloadSchema, + LogoutPayloadSchema, + RefreshTokenPayloadSchema, +) from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.core.security import ( CustomOAuth2PasswordRequestForm, @@ -28,23 +35,58 @@ from app.utils.hash_bcrpy_util import PwdUtil from app.utils.ip_local_util import IpLocalUtil from .schema import ( - AuthSchema, AutoLoginTokenSchema, AutoLoginUserSchema, CaptchaOutSchema, - JWTOutSchema, - JWTPayloadSchema, LoginWithTenantsSchema, - LogoutPayloadSchema, - RefreshTokenPayloadSchema, SelectTenantOutSchema, TenantOptionSchema, + TenantRegisterOutSchema, ) CaptchaKey = NewType("CaptchaKey", str) CaptchaBase64 = NewType("CaptchaBase64", str) +async def _write_login_log( + username: str, + status: int, + login_ip: str | None = None, + login_location: str | None = None, + request_os: str | None = None, + request_browser: str | None = None, + msg: str | None = None, +) -> None: + """写入登录日志(独立 session,避免事务回滚时丢失失败记录)""" + from app.api.v1.module_system.log.model import LoginLogModel + from app.core.database import async_db_session + + try: + async with async_db_session() as session: + async with session.begin(): + session.add( + LoginLogModel( + username=username, + status=status, + login_ip=login_ip, + login_location=login_location, + request_os=request_os, + request_browser=request_browser, + msg=msg, + ) + ) + except Exception: + pass # 登录日志写入失败不影响登录主流程 + + +def _resolve_request_ip(request: Request) -> str: + """从请求中解析客户端真实 IP""" + x_forwarded_for = request.headers.get("X-Forwarded-For") + if x_forwarded_for: + return x_forwarded_for.split(",")[0].strip() + return request.client.host if request.client else "127.0.0.1" + + class LoginService: """登录认证服务""" @@ -70,7 +112,13 @@ class LoginService: 异常: - CustomException: 认证失败时抛出异常。 """ - _t = time.time() + # 解析请求信息(IP、UA),供登录日志使用 + user_agent = parse(request.headers.get("user-agent")) + request_ip = _resolve_request_ip(request) + login_location = await IpLocalUtil.resolve_location_for_log(request_ip) + _login_os = user_agent.os.family + _login_browser = user_agent.browser.family + _login_username = login_form.username # 判断是否来自API文档 referer = request.headers.get("referer", "") @@ -85,26 +133,45 @@ class LoginService: key=login_form.captcha_key, captcha=login_form.captcha, ) - log.info(f"[登录计时] 验证码校验: {round((time.time() - _t) * 1000, 1)}ms") - _t2 = time.time() - # 用户认证 auth = AuthSchema(db=db) - user = await UserCRUD(auth).get_by_username_crud(username=login_form.username) - log.info(f"[登录计时] 数据库查询用户: {round((time.time() - _t2) * 1000, 1)}ms") - _t3 = time.time() + user = await UserCRUD(auth).get(username=login_form.username) if not user: + await _write_login_log( + username=_login_username, + status=2, + login_ip=request_ip, + login_location=login_location, + request_os=_login_os, + request_browser=_login_browser, + msg="用户不存在", + ) raise CustomException(msg="用户不存在") if not PwdUtil.verify_password( plain_password=login_form.password, password_hash=user.password ): + await _write_login_log( + username=_login_username, + status=2, + login_ip=request_ip, + login_location=login_location, + request_os=_login_os, + request_browser=_login_browser, + msg="账号或密码错误", + ) raise CustomException(msg="账号或密码错误") - log.info(f"[登录计时] Bcrypt密码校验: {round((time.time() - _t3) * 1000, 1)}ms") - _t4 = time.time() - - if user.status == "1": + if user.status == 1: + await _write_login_log( + username=_login_username, + status=2, + login_ip=request_ip, + login_location=login_location, + request_os=_login_os, + request_browser=_login_browser, + msg="用户已被停用", + ) raise CustomException(msg="用户已被停用") # 检查用户的默认租户是否正常 @@ -113,17 +180,24 @@ class LoginService: from app.api.v1.module_platform.tenant.model import TenantModel tenant_stmt = ( select(TenantModel) - .where(TenantModel.id == user.tenant_id, TenantModel.status == "0", TenantModel.is_deleted.is_(False)) + .where(TenantModel.id == user.tenant_id, TenantModel.status == 0, TenantModel.is_deleted.is_(False)) .limit(1) ) tenant_result = await auth.db.execute(tenant_stmt) if not tenant_result.scalar_one_or_none(): + await _write_login_log( + username=_login_username, + status=2, + login_ip=request_ip, + login_location=login_location, + request_os=_login_os, + request_browser=_login_browser, + msg="所属租户已被禁用", + ) raise CustomException(msg="所属租户已被禁用,请联系平台管理员") # 更新最后登录时间 - user = await UserCRUD(auth).update_last_login_crud(id=user.id) - log.info(f"[登录计时] 更新登录时间: {round((time.time() - _t4) * 1000, 1)}ms") - _t5 = time.time() + await UserCRUD(auth).update_last_login(id=user.id) if not user: raise CustomException(msg="用户不存在") @@ -137,20 +211,13 @@ class LoginService: user=user, login_type=login_form.login_type, ) - log.info( - f"[登录计时] 创建Token(含IP解析+Redis写入+在线记录): {round((time.time() - _t5) * 1000, 1)}ms" - ) - - log.info(f"[登录计时] ⭐ 登录总耗时: {round((time.time() - _t) * 1000, 1)}ms") # 查询用户关联的租户列表 - _tt = time.time() tenants = await cls.get_user_tenants_service( - auth=AuthSchema(db=db, tenant_id=user.tenant_id, check_data_scope=False), + auth=AuthSchema(db=db, user=user, tenant_id=user.tenant_id, check_data_scope=False), db=db, user_id=user.id, ) - log.info(f"[登录计时] 查询租户列表: {round((time.time() - _tt) * 1000, 1)}ms") user_info = { "id": user.id, @@ -160,6 +227,17 @@ class LoginService: "is_super_admin": user.is_superuser, } + # 写入登录成功日志 + await _write_login_log( + username=user.username, + status=1, + login_ip=request_ip, + login_location=login_location, + request_os=_login_os, + request_browser=_login_browser, + msg="登录成功", + ) + return LoginWithTenantsSchema( access_token=token.access_token, refresh_token=token.refresh_token, @@ -193,14 +271,7 @@ class LoginService: request.scope["session_id"] = session_id user_agent = parse(request.headers.get("user-agent")) - request_ip = None - x_forwarded_for = request.headers.get("X-Forwarded-For") - if x_forwarded_for: - # 取第一个 IP 地址,通常为客户端真实 IP - request_ip = x_forwarded_for.split(",")[0].strip() - else: - # 若没有 X-Forwarded-For 头,则使用 request.client.host - request_ip = request.client.host if request.client else "127.0.0.1" + request_ip = _resolve_request_ip(request) login_location = await IpLocalUtil.resolve_location_for_log(request_ip) request.scope["login_location"] = login_location @@ -213,9 +284,6 @@ class LoginService: now = datetime.now() - # 记录租户信息到日志 - log.info(f"用户ID: {user.id}, 用户名: {user.username} 正在生成JWT令牌") - # 生成会话信息 session_info = OnlineOutSchema( session_id=session_id, @@ -272,7 +340,6 @@ class LoginService: cls, db: AsyncSession, redis: Redis, - request: Request, refresh_token: RefreshTokenPayloadSchema, ) -> JWTOutSchema: """ @@ -281,7 +348,6 @@ class LoginService: 参数: - db (AsyncSession): 数据库会话对象 - redis (Redis): Redis客户端对象 - - request (Request): FastAPI请求对象 - refresh_token (RefreshTokenPayloadSchema): 刷新令牌数据 返回: @@ -304,12 +370,11 @@ class LoginService: # 用户认证 auth = AuthSchema(db=db) - user = await UserCRUD(auth).get_by_id_crud(id=user_id) + user = await UserCRUD(auth).get(id=user_id) if not user: raise CustomException(msg="刷新token失败,用户不存在") - - # 记录刷新令牌时的租户信息 - log.info(f"用户ID: {user.id}, 用户名: {user.username} 正在刷新JWT令牌") + if user.status == 1: + raise CustomException(msg="用户已被停用") # 设置新的 token access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES) @@ -382,7 +447,7 @@ class LoginService: await RedisCURD(redis).delete(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}") await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}") - log.info(f"用户退出登录成功,会话编号:{session_id}") + logger.info(f"用户退出登录成功,会话编号:{session_id}") return True @@ -416,7 +481,7 @@ class LoginService: if auth.user and auth.user.is_superuser: stmt = ( select(TenantModel) - .where(TenantModel.status == "0", TenantModel.is_deleted == 0) + .where(TenantModel.status == 0, TenantModel.is_deleted.is_(False)) .order_by(TenantModel.sort, TenantModel.id) ) result = await db.execute(stmt) @@ -429,7 +494,7 @@ class LoginService: .join(TenantUserModel, TenantUserModel.tenant_id == TenantModel.id) .where( TenantUserModel.user_id == uid, - TenantModel.status == "0", + TenantModel.status == 0, TenantModel.is_deleted.is_(False), ) .order_by(TenantUserModel.is_default.desc(), TenantModel.sort, TenantModel.id) @@ -486,7 +551,7 @@ class LoginService: # 验证租户是否存在且状态正常 tenant_stmt = ( select(TenantModel) - .where(TenantModel.id == tenant_id, TenantModel.status == "0") + .where(TenantModel.id == tenant_id, TenantModel.status == 0) .limit(1) ) result = await auth.db.execute(tenant_stmt) @@ -495,17 +560,10 @@ class LoginService: raise CustomException(msg="租户不存在或已被禁用") # 获取当前会话信息 - token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip() - if not token: - raise CustomException(msg="无法获取当前Token") + session_id = request.scope.get("session_id") + session_info = request.scope.get("session_info") - from app.core.security import decode_access_token - - payload = decode_access_token(token) - session_info = json.loads(payload.sub) - session_id = session_info.get("session_id") - - if not session_id: + if not session_id or not session_info: raise CustomException(msg="会话已失效") # 更新会话信息中的 tenant_id @@ -534,12 +592,27 @@ class LoginService: expire=int(access_expires.total_seconds()), ) + # 同时签发并覆盖 refresh_token(含新 tenant_id)避免刷新时回退 + refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_MINUTES) + new_refresh_token = create_access_token( + payload=JWTPayloadSchema( + sub=json.dumps(session_info), + is_refresh=True, + exp=now + refresh_expires, + ) + ) + await RedisCURD(redis).set( + key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}", + value=new_refresh_token, + expire=int(refresh_expires.total_seconds()), + ) + # 同时更新租户上下文 from app.core.tenant import set_current_tenant set_current_tenant(tenant_id, auth.user.is_superuser) - log.info( + logger.info( f"用户 {auth.user.username}(id={auth.user.id}) 切换到租户 {tenant.name}(id={tenant_id})" ) @@ -554,7 +627,7 @@ class CaptchaService: """验证码服务""" @classmethod - async def get_captcha_service(cls, redis: Redis) -> dict[str, CaptchaKey | CaptchaBase64]: + async def get_captcha_service(cls, redis: Redis) -> CaptchaOutSchema: """ 获取验证码 @@ -582,14 +655,12 @@ class CaptchaService: expire=settings.CAPTCHA_EXPIRE_SECONDS, ) - log.info(f"生成验证码成功,验证码:{captcha_value}") - # 返回验证码信息 return CaptchaOutSchema( enable=settings.CAPTCHA_ENABLE, key=CaptchaKey(captcha_key), img_base=CaptchaBase64(f"data:image/png;base64,{captcha_base64}"), - ).model_dump() + ) @classmethod async def check_captcha_service(cls, redis: Redis, key: str, captcha: str) -> bool: @@ -615,17 +686,14 @@ class CaptchaService: captcha_value = await RedisCURD(redis).get(redis_key) if not captcha_value: - log.error("验证码已过期或不存在") raise CustomException(msg="验证码已过期") # 验证码不区分大小写比对 if captcha.lower() != captcha_value.lower(): - log.error(f"验证码错误,用户输入:{captcha},正确值:{captcha_value}") raise CustomException(msg="验证码错误") # 验证成功后删除验证码,避免重复使用 await RedisCURD(redis).delete(redis_key) - log.info(f"验证码校验成功,key:{key}") return True @@ -655,7 +723,7 @@ class AutoLoginService: from app.api.v1.module_system.user.model import UserModel - stmt = select(UserModel).where(UserModel.status == "0") + stmt = select(UserModel).where(UserModel.status == 0) if tenant_id is not None: stmt = stmt.where(UserModel.tenant_id == tenant_id) stmt = stmt.order_by(UserModel.id) @@ -709,7 +777,7 @@ class AutoLoginService: if not user: raise CustomException(msg="用户不存在") - if user.status == "1": + if user.status == 1: raise CustomException(msg="用户已被停用") # 生成免登录Token @@ -731,7 +799,7 @@ class AutoLoginService: expire=cls.TOKEN_EXPIRE, ) - log.info(f"创建免登录Token成功,用户:{user.username}") + logger.info(f"创建免登录Token成功,用户:{user.username}") return AutoLoginTokenSchema( token=token, @@ -778,6 +846,9 @@ class AutoLoginService: if not token_data_str: raise CustomException(msg="免登录Token已过期或无效") + if isinstance(token_data_str, bytes): + token_data_str = token_data_str.decode("utf-8") + token_data = json.loads(token_data_str) user_id = token_data.get("user_id") token_tenant_id = token_data.get("tenant_id") @@ -792,7 +863,7 @@ class AutoLoginService: if not user: raise CustomException(msg="用户不存在") - if user.status == "1": + if user.status == 1: raise CustomException(msg="用户已被停用") # 删除已使用的Token @@ -803,6 +874,310 @@ class AutoLoginService: request=request, redis=redis, user=user, login_type="PC端" ) - log.info(f"用户{user.username}免登录成功") + logger.info(f"用户{user.username}免登录成功") return jwt_token + + +# ─── 租户自助注册 (PRD §4.5) ──────────────────────────────── + + +class TenantRegisterService: + """PRD §4.5 租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配""" + + DEFAULT_TRIAL_DAYS = 7 + + @classmethod + async def register( + cls, + db: AsyncSession, + username: str, + password: str, + email: str, + tenant_name: str | None = None, + ) -> TenantRegisterOutSchema: + from sqlalchemy import func, select + from sqlalchemy.exc import IntegrityError + + from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel + from app.api.v1.module_platform.tenant.model import TenantModel + from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel + from app.api.v1.module_system.user.model import UserModel, UserRolesModel + + # ── 1. 唯一性校验 ── + exists_stmt = select(func.count()).select_from(UserModel).where( + UserModel.is_deleted.is_(False), + (UserModel.username == username) | (UserModel.email == email), + ) + cnt = (await db.execute(exists_stmt)).scalar() or 0 + if cnt > 0: + raise CustomException(msg="用户名或邮箱已被占用") + + # ── 2. 获取默认套餐 ── + pkg_stmt = select(PackageModel).where(PackageModel.status == 0).order_by(PackageModel.id).limit(1) + default_pkg = (await db.execute(pkg_stmt)).scalar_one_or_none() + + # ── 3. 计算试用期 ── + now = datetime.now() + trial_end = now + timedelta(days=cls.DEFAULT_TRIAL_DAYS) + + # ── 4. 生成租户 code ── + base = tenant_name or username + code_suffix = base.encode("utf-8").hex()[:6].upper() + tenant_code = f"T{code_suffix}" + + # ── 5. 创建租户 ── + tenant = TenantModel( + name=tenant_name or f"{username}的租户", + code=tenant_code, + contact_name=username, + package_id=default_pkg.id if default_pkg else None, + start_time=now, + end_time=trial_end, + status=0, + ) + db.add(tenant) + await db.flush() + + # ── 6. 创建管理员用户 ── + user = UserModel( + username=username, + password=PwdUtil.hash_password(password), + email=email, + tenant_id=tenant.id, + status=0, + ) + db.add(user) + await db.flush() + + # ── 7. 创建 owner 角色 ── + owner_role = RoleModel( + name="租户管理员", + code="owner", + tenant_id=tenant.id, + order=1, + data_scope=4, # 全部数据权限 + description="自助注册创建的管理员角色", + ) + db.add(owner_role) + await db.flush() + + # ── 8. 绑定用户角色 ── + user_role = UserRolesModel(user_id=user.id, role_id=owner_role.id) + db.add(user_role) + + # ── 9. 分配套餐菜单 ── + if default_pkg: + pkg_menu_stmt = select(PackageMenuModel).where( + PackageMenuModel.package_id == default_pkg.id, + ) + pkg_menus = (await db.execute(pkg_menu_stmt)).scalars().all() + for pm in pkg_menus: + db.add(RoleMenusModel(role_id=owner_role.id, menu_id=pm.menu_id)) + + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise CustomException(msg="租户编码或用户名已被占用,请重试") + + # ── 10. 发送欢迎邮件 ── + try: + await cls._send_welcome_email(email, username, tenant.name, trial_end) + except Exception: + logger.warning(f"注册欢迎邮件发送失败: {email}") + + return TenantRegisterOutSchema( + user_id=user.id, + username=username, + tenant_id=tenant.id, + tenant_name=tenant.name, + tenant_code=tenant_code, + package=default_pkg.name if default_pkg else None, + trial_end=trial_end.strftime("%Y-%m-%d"), + message="注册成功", + ) + + @classmethod + async def _send_welcome_email( + cls, to_email: str, username: str, tenant_name: str, trial_end: datetime + ) -> None: + """发送欢迎邮件(不阻塞注册流程)。""" + from sqlalchemy import select + + from app.api.v1.module_platform.email.model import EmailConfigModel + from app.core.database import async_db_session + from app.utils.email_util import render_template, send_email + + async with async_db_session() as _db: + cfg_stmt = ( + select(EmailConfigModel) + .where( + EmailConfigModel.is_default.is_(True), + EmailConfigModel.status == 0, + ) + .limit(1) + ) + cfg = (await _db.execute(cfg_stmt)).scalar_one_or_none() + + if not cfg: + logger.info("无可用 SMTP 配置,跳过欢迎邮件") + return + + html_body = render_template( + """{{ username }},您好!
+您的租户已成功创建,试用期至 {{ trial_end }}。
+请登录后台开始使用。
""", + { + "tenant_name": tenant_name, + "username": username, + "trial_end": trial_end.strftime("%Y-%m-%d"), + }, + ) + + await send_email( + smtp_host=cfg.smtp_host, + smtp_port=cfg.smtp_port, + smtp_user=cfg.smtp_user, + smtp_password=cfg.smtp_password, + use_tls=cfg.use_tls, + from_name=cfg.from_name, + to_email=to_email, + to_name=username, + subject=f"欢迎加入 {tenant_name}!", + body_html=html_body, + ) + logger.info(f"欢迎邮件已发送至 {to_email}") + + +# ─── 忘记密码自助重置 (PRD §4.6) ───────────────────────────── + + +class PasswordResetService: + """PRD §4.6 忘记密码:邮箱重置令牌 + 密码更新""" + + RESET_TOKEN_PREFIX = "pwd_reset:" + TOKEN_EXPIRE_SECONDS = 1800 # 30 分钟 + + @classmethod + async def forgot_password_service( + cls, redis: Redis, db: AsyncSession, email: str + ) -> str: + """ + 忘记密码:根据邮箱查找用户,生成重置令牌并尝试发送邮件。 + 无论邮箱是否存在均返回相同文案(防止邮箱探测攻击)。 + """ + from sqlalchemy import select + + from app.api.v1.module_system.user.model import UserModel + + stmt = select(UserModel).where( + UserModel.email == email, + UserModel.is_deleted.is_(False), + ) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user: + logger.info(f"忘记密码:邮箱 {email} 未注册,静默返回") + return "若邮箱已注册,重置邮件已发送" + + # 生成一次性令牌 + token = secrets.token_urlsafe(32) + key = f"{cls.RESET_TOKEN_PREFIX}{token}" + await RedisCURD(redis).set( + key=key, value=str(user.id), expire=cls.TOKEN_EXPIRE_SECONDS + ) + + # 尝试发送邮件(不阻塞) + try: + await cls._send_reset_email(email, user.username, token) + except Exception: + logger.warning(f"密码重置邮件发送失败: {email}") + + return "若邮箱已注册,重置邮件已发送" + + @classmethod + async def reset_password_with_token_service( + cls, redis: Redis, db: AsyncSession, token: str, new_password: str + ) -> str: + """使用令牌重置密码。校验令牌 → 更新密码 → 删除令牌。""" + + from app.api.v1.module_system.user.model import UserModel + + key = f"{cls.RESET_TOKEN_PREFIX}{token}" + user_id_str = await RedisCURD(redis).get(key) + + if not user_id_str: + raise CustomException(msg="重置链接已失效,请重新申请") + + try: + user_id = int(user_id_str) + except (ValueError, TypeError): + await RedisCURD(redis).delete(key) + raise CustomException(msg="无效的重置链接") + + user = await db.get(UserModel, user_id) + if not user or user.is_deleted: + await RedisCURD(redis).delete(key) + raise CustomException(msg="用户不存在") + + user.password = PwdUtil.hash_password(new_password) + await db.commit() + await RedisCURD(redis).delete(key) + + logger.info(f"用户 {user.username}(id={user_id}) 密码已重置") + return "密码重置成功,请使用新密码登录" + + @classmethod + async def _send_reset_email(cls, to_email: str, username: str, token: str) -> None: + """发送密码重置邮件。""" + from sqlalchemy import select + + from app.api.v1.module_platform.email.model import EmailConfigModel + from app.core.database import async_db_session + from app.utils.email_util import render_template, send_email + + async with async_db_session() as _db: + cfg_stmt = ( + select(EmailConfigModel) + .where( + EmailConfigModel.is_default.is_(True), + EmailConfigModel.status == 0, + ) + .limit(1) + ) + cfg = (await _db.execute(cfg_stmt)).scalar_one_or_none() + + if not cfg: + logger.info("无可用 SMTP 配置,跳过重置邮件") + return + + reset_url = f"{getattr(settings, 'SITE_URL', '')}/reset-password?token={token}" + html_body = render_template( + """{{ username }},您好!
+请点击以下链接重置密码({{ expire_minutes }} 分钟内有效):
+ +如果非您本人操作,请忽略此邮件。
""", + { + "username": username, + "reset_url": reset_url, + "expire_minutes": cls.TOKEN_EXPIRE_SECONDS // 60, + }, + ) + + await send_email( + smtp_host=cfg.smtp_host, + smtp_port=cfg.smtp_port, + smtp_user=cfg.smtp_user, + smtp_password=cfg.smtp_password, + use_tls=cfg.use_tls, + from_name=cfg.from_name, + to_email=to_email, + to_name=username, + subject="密码重置 - FastapiAdmin", + body_html=html_body, + ) + logger.info(f"密码重置邮件已发送至 {to_email}") diff --git a/backend/app/api/v1/module_system/dept/controller.py b/backend/app/api/v1/module_system/dept/controller.py index 22fced5d..2137fb86 100644 --- a/backend/app/api/v1/module_system/dept/controller.py +++ b/backend/app/api/v1/module_system/dept/controller.py @@ -2,29 +2,31 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, SuccessResponse -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from .schema import DeptCreateSchema, DeptOutSchema, DeptQueryParam, DeptUpdateSchema from .service import DeptService -DeptRouter = APIRouter(route_class=OperationLogRoute, prefix="/dept", tags=["部门管理"]) +DeptRouter = APIRouter(route_class=OperationLogRoute, prefix="/dept", tags=["系统管理/部门管理"]) + +_DEPT_NS = "dept" @DeptRouter.get( "/tree", summary="查询部门树", - description="查询部门树", response_model=ResponseSchema[list[DeptOutSchema]], ) +@cache(expire=300, namespace=_DEPT_NS) async def get_dept_tree_controller( search: Annotated[DeptQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dept:query']))], ) -> JSONResponse: """ 查询部门树 @@ -43,19 +45,17 @@ async def get_dept_tree_controller( result_dict_list = await DeptService.get_dept_tree_service( search=search, auth=auth, order_by=order_by ) - log.info("查询部门树成功") return SuccessResponse(data=result_dict_list, msg="查询部门树成功") @DeptRouter.get( "/detail/{id}", summary="查询部门详情", - description="查询部门详情", response_model=ResponseSchema[DeptOutSchema], ) async def get_obj_detail_controller( id: Annotated[int, Path(description="部门ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dept:detail']))], ) -> JSONResponse: """ 查询部门详情 @@ -71,19 +71,17 @@ async def get_obj_detail_controller( - CustomException: 查询部门详情失败时抛出异常。 """ result_dict = await DeptService.get_dept_detail_service(id=id, auth=auth) - log.info(f"查询部门详情成功 {id}") return SuccessResponse(data=result_dict, msg="查询部门详情成功") @DeptRouter.post( "/create", summary="创建部门", - description="创建部门", response_model=ResponseSchema[DeptOutSchema], ) async def create_obj_controller( data: DeptCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dept:create']))], ) -> JSONResponse: """ 创建部门 @@ -99,20 +97,19 @@ async def create_obj_controller( - CustomException: 创建部门失败时抛出异常。 """ result_dict = await DeptService.create_dept_service(data=data, auth=auth) - log.info(f"创建部门成功: {result_dict}") + await FastAPICache.clear(namespace=_DEPT_NS) return SuccessResponse(data=result_dict, msg="创建部门成功") @DeptRouter.put( "/update/{id}", summary="修改部门", - description="修改部门", response_model=ResponseSchema[DeptOutSchema], ) async def update_obj_controller( data: DeptUpdateSchema, id: Annotated[int, Path(description="部门ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dept:update']))], ) -> JSONResponse: """ 修改部门 @@ -129,19 +126,18 @@ async def update_obj_controller( - CustomException: 修改部门失败时抛出异常。 """ result_dict = await DeptService.update_dept_service(auth=auth, id=id, data=data) - log.info(f"修改部门成功: {result_dict}") + await FastAPICache.clear(namespace=_DEPT_NS) return SuccessResponse(data=result_dict, msg="修改部门成功") @DeptRouter.delete( "/delete", summary="删除部门", - description="删除部门", response_model=ResponseSchema[None], ) async def delete_obj_controller( ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dept:delete']))], ) -> JSONResponse: """ 删除部门 @@ -157,19 +153,18 @@ async def delete_obj_controller( - CustomException: 删除部门失败时抛出异常。 """ await DeptService.delete_dept_service(ids=ids, auth=auth) - log.info(f"删除部门成功: {ids}") + await FastAPICache.clear(namespace=_DEPT_NS) return SuccessResponse(msg="删除部门成功") @DeptRouter.patch( "/status/batch", summary="批量修改部门状态", - description="批量修改部门状态", response_model=ResponseSchema[None], ) async def batch_set_available_obj_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dept:patch']))], ) -> JSONResponse: """ 批量修改部门状态 @@ -185,5 +180,5 @@ async def batch_set_available_obj_controller( - CustomException: 批量修改部门状态失败时抛出异常。 """ await DeptService.batch_set_available_service(data=data, auth=auth) - log.info(f"批量修改部门状态成功: {data.ids}") + await FastAPICache.clear(namespace=_DEPT_NS) return SuccessResponse(msg="批量修改部门状态成功") diff --git a/backend/app/api/v1/module_system/dept/crud.py b/backend/app/api/v1/module_system/dept/crud.py index 57e4276c..b39d0148 100644 --- a/backend/app/api/v1/module_system/dept/crud.py +++ b/backend/app/api/v1/module_system/dept/crud.py @@ -1,7 +1,7 @@ from collections.abc import Sequence -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import DeptModel from .schema import DeptCreateSchema, DeptUpdateSchema @@ -20,45 +20,9 @@ class DeptCRUD(CRUDBase[DeptModel, DeptCreateSchema, DeptUpdateSchema]): 返回: - None """ - self.auth = auth super().__init__(model=DeptModel, auth=auth) - async def get_by_id_crud(self, id: int, preload: list | None = None) -> DeptModel | None: - """ - 根据 id 获取部门信息。 - - 参数: - - id (int): 部门 ID。 - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - DeptModel | None: 部门信息,未找到返回 None。 - """ - obj = await self.get(id=id, preload=preload) - if not obj: - return None - return obj - - async def get_list_crud( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list | None = None, - ) -> Sequence[DeptModel]: - """ - 获取部门列表。 - - 参数: - - search (dict | None): 搜索条件。 - - order_by (list[dict] | None): 排序字段列表。 - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[DeptModel]: 部门列表。 - """ - return await self.list(search=search, order_by=order_by, preload=preload) - - async def get_tree_list_crud( + async def get_tree_list( self, search: dict | None = None, order_by: list[dict] | None = None, @@ -81,29 +45,3 @@ class DeptCRUD(CRUDBase[DeptModel, DeptCreateSchema, DeptUpdateSchema]): children_attr="children", preload=preload, ) - - async def set_available_crud(self, ids: list[int], status: str) -> None: - """ - 批量设置部门可用状态。 - - 参数: - - ids (list[int]): 部门 ID 列表。 - - status (str): 可用状态。 - - 返回: - - None - """ - await self.set(ids=ids, status=status) - - async def get_name_crud(self, id: int) -> str | None: - """ - 根据 id 获取部门名称。 - - 参数: - - id (int): 部门 ID。 - - 返回: - - str | None: 部门名称,未找到返回 None。 - """ - obj = await self.get(id=id) - return obj.name if obj else None diff --git a/backend/app/api/v1/module_system/dept/schema.py b/backend/app/api/v1/module_system/dept/schema.py index fd91c6d1..a4d4c697 100644 --- a/backend/app/api/v1/module_system/dept/schema.py +++ b/backend/app/api/v1/module_system/dept/schema.py @@ -16,7 +16,7 @@ class DeptCreateSchema(BaseModel): phone: str | None = Field(default=None, max_length=20, description="联系电话") email: str | None = Field(default=None, max_length=128, description="邮箱") parent_id: int | None = Field(default=None, ge=0, description="父部门ID") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") description: str | None = Field(default=None, max_length=255, description="备注") @field_validator("name") @@ -35,9 +35,9 @@ class DeptCreateSchema(BaseModel): @field_validator("status") @classmethod - def validate_status(cls, value: str): + def validate_status(cls, value: int): """校验状态:仅支持 0(正常)、1(禁用)""" - if value not in {"0", "1"}: + if value not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return value @@ -52,6 +52,7 @@ class DeptOutSchema(DeptCreateSchema, BaseSchema): model_config = ConfigDict(from_attributes=True) parent_name: str | None = Field(default=None, max_length=64, description="父部门名称") + children: list["DeptOutSchema"] | None = Field(default=None, description="子部门列表") class DeptQueryParam: diff --git a/backend/app/api/v1/module_system/dept/service.py b/backend/app/api/v1/module_system/dept/service.py index cedb4e1f..e0845bd2 100644 --- a/backend/app/api/v1/module_system/dept/service.py +++ b/backend/app/api/v1/module_system/dept/service.py @@ -1,12 +1,10 @@ -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException from app.utils.common_util import ( get_child_id_map, get_child_recursion, get_parent_id_map, get_parent_recursion, - traversal_to_tree, ) from .crud import DeptCRUD @@ -24,7 +22,7 @@ class DeptService: """ @classmethod - async def get_dept_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_dept_detail_service(cls, auth: AuthSchema, id: int) -> DeptOutSchema: """ 获取部门详情。 @@ -35,13 +33,18 @@ class DeptService: 返回: - dict: 部门详情对象。 """ - dept = await DeptCRUD(auth).get_by_id_crud(id=id) - result = DeptOutSchema.model_validate(dept).model_dump() - if dept and dept.parent_id: + dept = await DeptCRUD(auth).get(id=id) + if not dept: + raise CustomException(msg="部门不存在") + # 从列属性构建 dict,避免 Pydantic 访问 ORM 关系触发 async 惰性加载 + dept_dict = {c.name: getattr(dept, c.name) for c in dept.__table__.columns} + dept_dict["children"] = None + dept_dict["parent_name"] = None + if dept.parent_id: parent = await DeptCRUD(auth).get(id=dept.parent_id) if parent: - result["parent_name"] = parent.name - return result + dept_dict["parent_name"] = parent.name + return DeptOutSchema(**dept_dict) @classmethod async def get_dept_tree_service( @@ -62,16 +65,16 @@ class DeptService: - list[dict]: 部门树形列表对象。 """ # 使用树形结构查询,预加载children关系 - dept_list = await DeptCRUD(auth).get_tree_list_crud( - search=search.__dict__, order_by=order_by + dept_list = await DeptCRUD(auth).get_tree_list( + search=search.__dict__ if search else {}, order_by=order_by ) - # 转换为字典列表 + # 转换为字典列表,tree_list 已通过 selectin 预加载 children dept_dict_list = [DeptOutSchema.model_validate(dept).model_dump() for dept in dept_list] - # 使用traversal_to_tree构建树形结构 - return traversal_to_tree(dept_dict_list) + # 仅保留根节点,子树已在 model_dump 中递归序列化 + return [d for d in dept_dict_list if d.get("parent_id") is None] @classmethod - async def create_dept_service(cls, auth: AuthSchema, data: DeptCreateSchema) -> dict: + async def create_dept_service(cls, auth: AuthSchema, data: DeptCreateSchema) -> DeptOutSchema: """ 创建部门。 @@ -97,10 +100,10 @@ class DeptService: await TenantService.check_quota_service(auth, auth.tenant_id, "dept") dept = await DeptCRUD(auth).create(data=data) - return DeptOutSchema.model_validate(dept).model_dump() + return DeptOutSchema.model_validate(dept) @classmethod - async def update_dept_service(cls, auth: AuthSchema, id: int, data: DeptUpdateSchema) -> dict: + async def update_dept_service(cls, auth: AuthSchema, id: int, data: DeptUpdateSchema) -> DeptOutSchema: """ 更新部门。 @@ -115,7 +118,7 @@ class DeptService: 异常: - CustomException: 当部门不存在或名称重复时抛出。 """ - dept = await DeptCRUD(auth).get_by_id_crud(id=id) + dept = await DeptCRUD(auth).get(id=id) if not dept: raise CustomException(msg="更新失败,该部门不存在") exist_dept = await DeptCRUD(auth).get(name=data.name) @@ -125,7 +128,7 @@ class DeptService: if exist_code and exist_code.id != id: raise CustomException(msg="更新失败,部门编码已存在") dept = await DeptCRUD(auth).update(id=id, data=data) - return DeptOutSchema.model_validate(dept).model_dump() + return DeptOutSchema.model_validate(dept) @classmethod async def delete_dept_service(cls, auth: AuthSchema, ids: list[int]) -> None: @@ -147,7 +150,7 @@ class DeptService: raise CustomException(msg="删除失败,删除对象不能为空") # 获取所有部门列表,用于构建树形关系 - all_depts = await DeptCRUD(auth).get_list_crud() + all_depts = await DeptCRUD(auth).list() # 构建子部门ID映射 child_id_map = get_child_id_map(model_list=all_depts) @@ -172,10 +175,10 @@ class DeptService: 返回: - None """ - dept_list = await DeptCRUD(auth).get_list_crud() + dept_list = await DeptCRUD(auth).list() total_ids = [] - if data.status == "0": + if data.status == 0: id_map = get_parent_id_map(model_list=dept_list) for dept_id in data.ids: enable_ids = get_parent_recursion(id=dept_id, id_map=id_map) @@ -186,4 +189,4 @@ class DeptService: disable_ids = get_child_recursion(id=dept_id, id_map=id_map) total_ids.extend(disable_ids) - await DeptCRUD(auth).set_available_crud(ids=total_ids, status=data.status) + await DeptCRUD(auth).set(ids=total_ids, status=data.status) diff --git a/backend/app/api/v1/module_system/dict/controller.py b/backend/app/api/v1/module_system/dict/controller.py index d8adab63..27769d2f 100644 --- a/backend/app/api/v1/module_system/dict/controller.py +++ b/backend/app/api/v1/module_system/dict/controller.py @@ -2,14 +2,14 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache from redis.asyncio.client import Redis -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, StreamResponse, SuccessResponse from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.dependencies import AuthPermission, redis_getter -from app.core.logger import log from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -25,18 +25,19 @@ from .schema import ( ) from .service import DictDataService, DictTypeService -DictRouter = APIRouter(route_class=OperationLogRoute, prefix="/dict", tags=["字典管理"]) +DictRouter = APIRouter(route_class=OperationLogRoute, prefix="/dict", tags=["系统管理/字典管理"]) + +_DICT_TYPE_NS = "dict_type" @DictRouter.get( "/type/detail/{id}", summary="获取字典类型详情", - description="获取字典类型详情", response_model=ResponseSchema[DictTypeOutSchema], ) async def get_type_detail_controller( id: Annotated[int, Path(description="字典类型ID", ge=1)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:detail']))], ) -> JSONResponse: """ 获取字典类型详情 @@ -52,20 +53,18 @@ async def get_type_detail_controller( - CustomException: 获取字典类型详情失败时抛出异常。 """ result_dict = await DictTypeService.get_obj_detail_service(id=id, auth=auth) - log.info(f"获取字典类型详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取字典类型详情成功") @DictRouter.get( "/type/list", summary="查询字典类型", - description="查询字典类型", - response_model=ResponseSchema[list[DictTypeOutSchema]], + response_model=ResponseSchema[PageResultSchema[DictTypeOutSchema]], ) async def get_type_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[DictTypeQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:query']))], ) -> JSONResponse: """ 查询字典类型 @@ -88,18 +87,17 @@ async def get_type_list_controller( search=search, order_by=page.order_by, ) - log.info("查询字典类型列表成功") return SuccessResponse(data=result_dict, msg="查询字典类型列表成功") @DictRouter.get( "/type/optionselect", summary="获取全部字典类型", - description="获取全部字典类型", response_model=ResponseSchema[list[DictTypeOutSchema]], ) -async def get_type_loptionselect_controller( - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:query"]))], +@cache(expire=300, namespace=_DICT_TYPE_NS) +async def get_type_optionselect_controller( + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:query']))], ) -> JSONResponse: """ 获取全部字典类型 @@ -114,20 +112,18 @@ async def get_type_loptionselect_controller( - CustomException: 获取字典类型列表失败时抛出异常。 """ result_dict_list = await DictTypeService.get_obj_list_service(auth=auth) - log.info("获取字典类型列表成功") return SuccessResponse(data=result_dict_list, msg="获取字典类型列表成功") @DictRouter.post( "/type/create", summary="创建字典类型", - description="创建字典类型", response_model=ResponseSchema[DictTypeOutSchema], ) async def create_type_controller( data: DictTypeCreateSchema, redis: Annotated[Redis, Depends(redis_getter)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:create']))], ) -> JSONResponse: """ 创建字典类型 @@ -144,21 +140,20 @@ async def create_type_controller( - CustomException: 创建字典类型失败时抛出异常。 """ result_dict = await DictTypeService.create_obj_service(auth=auth, redis=redis, data=data) - log.info(f"创建字典类型成功: {result_dict}") + await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(data=result_dict, msg="创建字典类型成功") @DictRouter.put( "/type/update/{id}", summary="修改字典类型", - description="修改字典类型", response_model=ResponseSchema[DictTypeOutSchema], ) async def update_type_controller( data: DictTypeUpdateSchema, redis: Annotated[Redis, Depends(redis_getter)], id: Annotated[int, Path(description="字典类型ID", ge=1)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:update']))], ) -> JSONResponse: """ 修改字典类型 @@ -176,20 +171,19 @@ async def update_type_controller( - CustomException: 修改字典类型失败时抛出异常。 """ result_dict = await DictTypeService.update_obj_service(auth=auth, redis=redis, id=id, data=data) - log.info(f"修改字典类型成功: {result_dict}") + await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(data=result_dict, msg="修改字典类型成功") @DictRouter.delete( "/type/delete", summary="删除字典类型", - description="删除字典类型", response_model=ResponseSchema[None], ) async def delete_type_controller( redis: Annotated[Redis, Depends(redis_getter)], ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:delete']))], ) -> JSONResponse: """ 删除字典类型 @@ -206,19 +200,18 @@ async def delete_type_controller( - CustomException: 删除字典类型失败时抛出异常。 """ await DictTypeService.delete_obj_service(auth=auth, redis=redis, ids=ids) - log.info(f"删除字典类型成功: {ids}") + await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(msg="删除字典类型成功") @DictRouter.patch( "/type/status/batch", summary="批量修改字典类型状态", - description="批量修改字典类型状态", response_model=ResponseSchema[None], ) async def batch_set_available_dict_type_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:patch']))], ) -> JSONResponse: """ 批量修改字典类型状态 @@ -234,19 +227,18 @@ async def batch_set_available_dict_type_controller( - CustomException: 批量修改字典类型状态失败时抛出异常。 """ await DictTypeService.set_obj_available_service(auth=auth, data=data) - log.info(f"批量修改字典类型状态成功: {data.ids}") + await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(msg="批量修改字典类型状态成功") @DictRouter.post( "/type/export", summary="导出字典类型", - description="导出字典类型", response_model=ResponseSchema[None], ) async def export_type_list_controller( search: Annotated[DictTypeQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:export"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_type:export']))], ) -> StreamingResponse: """ 导出字典类型 @@ -261,10 +253,10 @@ async def export_type_list_controller( 异常: - CustomException: 导出字典类型失败时抛出异常。 """ - # 获取全量数据 + # 获取全量数据并转为dict列表 result_dict_list = await DictTypeService.get_obj_list_service(search=search, auth=auth) - export_result = await DictTypeService.export_obj_service(data_list=result_dict_list) - log.info("导出字典类型成功") + export_data = [item.model_dump() for item in result_dict_list] + export_result = await DictTypeService.export_obj_service(data_list=export_data) return StreamResponse( data=bytes2file_response(export_result), @@ -276,12 +268,11 @@ async def export_type_list_controller( @DictRouter.get( "/data/detail/{id}", summary="获取字典数据详情", - description="获取字典数据详情", response_model=ResponseSchema[DictDataOutSchema], ) async def get_data_detail_controller( id: Annotated[int, Path(description="字典数据ID", ge=1)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_data:detail']))], ) -> JSONResponse: """ 获取字典数据详情 @@ -297,20 +288,18 @@ async def get_data_detail_controller( - CustomException: 获取字典数据详情失败时抛出异常。 """ result_dict = await DictDataService.get_obj_detail_service(id=id, auth=auth) - log.info(f"获取字典数据详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取字典数据详情成功") @DictRouter.get( "/data/list", summary="查询字典数据", - description="查询字典数据", - response_model=ResponseSchema[list[DictDataOutSchema]], + response_model=ResponseSchema[PageResultSchema[DictDataOutSchema]], ) async def get_data_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[DictDataQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_data:query']))], ) -> JSONResponse: """ 查询字典数据 @@ -336,20 +325,18 @@ async def get_data_list_controller( search=search, order_by=order_by, ) - log.info("查询字典数据列表成功") return SuccessResponse(data=result_dict, msg="查询字典数据列表成功") @DictRouter.post( "/data/create", summary="创建字典数据", - description="创建字典数据", response_model=ResponseSchema[DictDataOutSchema], ) async def create_data_controller( data: DictDataCreateSchema, redis: Annotated[Redis, Depends(redis_getter)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_data:create']))], ) -> JSONResponse: """ 创建字典数据 @@ -366,21 +353,19 @@ async def create_data_controller( - CustomException: 创建字典数据失败时抛出异常。 """ result_dict = await DictDataService.create_obj_service(auth=auth, redis=redis, data=data) - log.info(f"创建字典数据成功: {result_dict}") return SuccessResponse(data=result_dict, msg="创建字典数据成功") @DictRouter.put( "/data/update/{id}", summary="修改字典数据", - description="修改字典数据", response_model=ResponseSchema[DictDataOutSchema], ) async def update_data_controller( data: DictDataUpdateSchema, redis: Annotated[Redis, Depends(redis_getter)], id: Annotated[int, Path(description="字典数据ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_data:update']))], ) -> JSONResponse: """ 修改字典数据 @@ -398,20 +383,18 @@ async def update_data_controller( - CustomException: 修改字典数据失败时抛出异常。 """ result_dict = await DictDataService.update_obj_service(auth=auth, redis=redis, id=id, data=data) - log.info(f"修改字典数据成功: {result_dict}") return SuccessResponse(data=result_dict, msg="修改字典数据成功") @DictRouter.delete( "/data/delete", summary="删除字典数据", - description="删除字典数据", response_model=ResponseSchema[None], ) async def delete_data_controller( redis: Annotated[Redis, Depends(redis_getter)], ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_data:delete']))], ) -> JSONResponse: """ 删除字典数据 @@ -428,19 +411,17 @@ async def delete_data_controller( - CustomException: 删除字典数据失败时抛出异常。 """ await DictDataService.delete_obj_service(auth=auth, redis=redis, ids=ids) - log.info(f"删除字典数据成功: {ids}") return SuccessResponse(msg="删除字典数据成功") @DictRouter.patch( "/data/status/batch", summary="批量修改字典数据状态", - description="批量修改字典数据状态", response_model=ResponseSchema[None], ) async def batch_set_available_dict_data_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_data:patch']))], ) -> JSONResponse: """ 批量修改字典数据状态 @@ -456,20 +437,18 @@ async def batch_set_available_dict_data_controller( - CustomException: 批量修改字典数据状态失败时抛出异常。 """ await DictDataService.set_obj_available_service(auth=auth, data=data) - log.info(f"批量修改字典数据状态成功: {data.ids}") return SuccessResponse(msg="批量修改字典数据状态成功") @DictRouter.post( "/data/export", summary="导出字典数据", - description="导出字典数据", response_model=ResponseSchema[None], ) async def export_data_list_controller( search: Annotated[DictDataQueryParam, Depends()], page: Annotated[PaginationQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:export"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:dict_data:export']))], ) -> StreamingResponse: """ 导出字典数据 @@ -488,8 +467,8 @@ async def export_data_list_controller( result_dict_list = await DictDataService.get_obj_list_service( auth=auth, search=search, order_by=page.order_by ) - export_result = await DictDataService.export_obj_service(data_list=result_dict_list) - log.info("导出字典数据成功") + export_data = [item.model_dump() for item in result_dict_list] + export_result = await DictDataService.export_obj_service(data_list=export_data) return StreamResponse( data=bytes2file_response(export_result), @@ -501,7 +480,6 @@ async def export_data_list_controller( @DictRouter.get( "/data/info/{dict_type}", summary="根据字典类型获取数据", - description="根据字典类型获取数据", response_model=ResponseSchema[list[DictDataOutSchema]], ) async def get_init_dict_data_controller( @@ -523,6 +501,5 @@ async def get_init_dict_data_controller( dict_data_query_result = await DictDataService.get_init_dict_service( redis=redis, dict_type=dict_type, tenant_id=1 ) - log.info(f"获取初始化字典数据成功:{dict_data_query_result}") return SuccessResponse(data=dict_data_query_result, msg="获取初始化字典数据成功") diff --git a/backend/app/api/v1/module_system/dict/crud.py b/backend/app/api/v1/module_system/dict/crud.py index 4514954f..488b6d11 100644 --- a/backend/app/api/v1/module_system/dict/crud.py +++ b/backend/app/api/v1/module_system/dict/crud.py @@ -1,6 +1,5 @@ from collections.abc import Sequence -from app.api.v1.module_system.auth.schema import AuthSchema from app.api.v1.module_system.dict.model import DictDataModel, DictTypeModel from app.api.v1.module_system.dict.schema import ( DictDataCreateSchema, @@ -9,6 +8,7 @@ from app.api.v1.module_system.dict.schema import ( DictTypeUpdateSchema, ) from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema class DictTypeCRUD(CRUDBase[DictTypeModel, DictTypeCreateSchema, DictTypeUpdateSchema]): @@ -24,112 +24,8 @@ class DictTypeCRUD(CRUDBase[DictTypeModel, DictTypeCreateSchema, DictTypeUpdateS 返回: - None """ - self.auth = auth super().__init__(model=DictTypeModel, auth=auth) - async def get_obj_by_id_crud( - self, id: int, preload: list | None = None - ) -> DictTypeModel | None: - """ - 获取数据字典类型详情 - - 参数: - - id (int): 数据字典类型ID - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - DictTypeModel | None: 数据字典类型模型,如果不存在则为None - """ - # 添加默认预加载字典数据关系 - if preload is None: - preload = [] - return await self.get(id=id, preload=preload) - - async def get_obj_list_crud( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list | None = None, - ) -> Sequence[DictTypeModel]: - """ - 获取数据字典类型列表 - - 参数: - - search (dict | None): 查询参数,默认值为None - - order_by (list[dict] | None): 排序参数,默认值为None - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[DictTypeModel]: 数据字典类型模型序列 - """ - # 添加默认预加载字典数据关系 - if preload is None: - preload = [] - return await self.list(search=search, order_by=order_by, preload=preload) - - async def create_obj_crud(self, data: DictTypeCreateSchema) -> DictTypeModel | None: - """ - 创建数据字典类型 - - 参数: - - data (DictTypeCreateSchema): 数据字典类型创建模型 - - 返回: - - DictTypeModel | None: 创建的数据字典类型模型,如果创建失败则为None - """ - return await self.create(data=data) - - async def update_obj_crud(self, id: int, data: DictTypeUpdateSchema) -> DictTypeModel | None: - """ - 更新数据字典类型 - - 参数: - - id (int): 数据字典类型ID - - data (DictTypeUpdateSchema): 数据字典类型更新模型 - - 返回: - - DictTypeModel | None: 更新的数据字典类型模型,如果更新失败则为None - """ - return await self.update(id=id, data=data) - - async def delete_obj_crud(self, ids: list[int]) -> None: - """ - 删除数据字典类型 - - 参数: - - ids (list[int]): 数据字典类型ID列表 - - 返回: - - None - """ - return await self.delete(ids=ids) - - async def set_obj_available_crud(self, ids: list[int], status: str) -> None: - """ - 设置数据字典类型的可用状态 - - 参数: - - ids (list[int]): 数据字典类型ID列表 - - status (str): 可用状态,0表示正常,1表示停用 - - 返回: - - None - """ - return await self.set(ids=ids, status=status) - - async def batch_delete_obj_crud(self, ids: list[int]) -> int: - """ - 批量删除数据字典类型 - - 参数: - - ids (list[int]): 数据字典类型ID列表 - - 返回: - - int: 删除的记录数量 - """ - await self.delete(ids=ids) - return len(ids) - class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateSchema]): """数据字典数据层""" @@ -144,100 +40,11 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS 返回: - None """ - self.auth = auth super().__init__(model=DictDataModel, auth=auth) - async def get_obj_by_id_crud( - self, id: int, preload: list | None = None - ) -> DictDataModel | None: - """ - 获取数据字典数据详情 - - 参数: - - id (int): 数据字典数据ID - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - DictDataModel | None: 数据字典数据模型,如果不存在则为None - """ - # 添加默认预加载字典类型关系 - if preload is None: - preload = [] - return await self.get(id=id, preload=preload) - - async def get_obj_list_crud( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list | None = None, - ) -> Sequence[DictDataModel]: - """ - 获取数据字典数据列表 - - 参数: - - search (dict | None): 查询参数,默认值为None - - order_by (list[dict] | None): 排序参数,默认值为None - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[DictDataModel]: 数据字典数据模型序列 - """ - # 添加默认预加载字典类型关系 - if preload is None: - preload = [] - return await self.list(search=search, order_by=order_by, preload=preload) - - async def create_obj_crud(self, data: DictDataCreateSchema) -> DictDataModel | None: - """ - 创建数据字典数据 - - 参数: - - data (DictDataCreateSchema): 数据字典数据创建模型 - - 返回: - - DictDataModel | None: 创建的数据字典数据模型,如果创建失败则为None - """ - return await self.create(data=data) - - async def update_obj_crud(self, id: int, data: DictDataUpdateSchema) -> DictDataModel | None: - """ - 更新数据字典数据 - - 参数: - - id (int): 数据字典数据ID - - data (DictDataUpdateSchema): 数据字典数据更新模型 - - 返回: - - DictDataModel | None: 更新的数据字典数据模型,如果更新失败则为None - """ - return await self.update(id=id, data=data) - - async def delete_obj_crud(self, ids: list[int]) -> None: - """ - 删除数据字典数据 - - 参数: - - ids (list[int]): 数据字典数据ID列表 - - 返回: - - None - """ - return await self.delete(ids=ids) - - async def set_obj_available_crud(self, ids: list[int], status: str) -> None: - """ - 设置数据字典数据的可用状态 - - 参数: - - ids (list[int]): 数据字典数据ID列表 - - status (str): 可用状态,0表示正常,1表示停用 - - 返回: - - None - """ - return await self.set(ids=ids, status=status) - - async def batch_delete_obj_crud(self, ids: list[int], exclude_system: bool = True) -> int: + async def batch_delete( + self, ids: list[int], exclude_system: bool = True + ) -> int: """ 批量删除数据字典数据 @@ -248,25 +55,20 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS 返回: - int: 删除的记录数量 """ - # 如果需要排除系统默认数据,可以在这里添加过滤逻辑 - # 假设系统默认数据在remark字段中包含"系统默认"字符串 if exclude_system: - # 获取非系统默认数据的ID - system_data_filter = { + system_data = await self.list(search={ "id__in": ids, "remark__contains": "系统默认", - } - system_data = await self.list(search=system_data_filter) + }) system_ids = [item.id for item in system_data] - # 从待删除ID列表中排除系统默认数据 ids = [id for id in ids if id not in system_ids] if ids: await self.delete(ids=ids) return len(ids) - async def get_obj_list_by_dict_type_crud( - self, dict_type: str, status: str | None = "0" + async def get_list_by_dict_type( + self, dict_type: str, status: int | None = 0 ) -> Sequence[DictDataModel]: """ 根据字典类型获取字典数据列表 @@ -281,5 +83,4 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS search = {"dict_type": dict_type} if status is not None: search["status"] = status - order_by = [{"id": "asc"}] - return await self.list(search=search, order_by=order_by) + return await self.list(search=search, order_by=[{"id": "asc"}]) diff --git a/backend/app/api/v1/module_system/dict/schema.py b/backend/app/api/v1/module_system/dict/schema.py index 6df3ae34..2e0614f3 100644 --- a/backend/app/api/v1/module_system/dict/schema.py +++ b/backend/app/api/v1/module_system/dict/schema.py @@ -21,17 +21,18 @@ class DictTypeCreateSchema(BaseModel): dict_name: str = Field(..., min_length=1, max_length=64, description="字典名称") dict_type: str = Field(..., min_length=1, max_length=255, description="字典类型编码") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:停用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:停用)") description: str | None = Field(default=None, max_length=255, description="描述") @field_validator("status") @classmethod - def validate_status(cls, value: str): - if value not in {"0", "1"}: + def validate_status(cls, value: int): + if value not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(停用)") return value @field_validator("dict_name") + @classmethod def validate_dict_name(cls, value: str): """ 校验字典名称为非空字符串。 @@ -50,6 +51,7 @@ class DictTypeCreateSchema(BaseModel): return value.strip() @field_validator("dict_type") + @classmethod def validate_dict_type(cls, value: str): """ 校验字典类型:小写字母开头,仅包含小写字母/数字/下划线。 @@ -100,7 +102,6 @@ class DictTypeQueryParam: examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], ), ) -> None: - super().__init__() # 模糊查询字段 self.dict_name = (QueueEnum.like.value, dict_name) @@ -131,13 +132,13 @@ class DictDataCreateSchema(BaseModel): ) list_class: str | None = Field(default=None, max_length=255, description="表格回显样式") is_default: bool = Field(default=False, description="是否默认") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:停用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:停用)") description: str | None = Field(default=None, max_length=255, description="描述") @field_validator("status") @classmethod - def validate_status(cls, value: str): - if value not in {"0", "1"}: + def validate_status(cls, value: int) -> int: + if value not in (0, 1): raise ValueError("状态仅支持 0(正常) 或 1(停用)") return value diff --git a/backend/app/api/v1/module_system/dict/service.py b/backend/app/api/v1/module_system/dict/service.py index 40b417e8..71744e2d 100644 --- a/backend/app/api/v1/module_system/dict/service.py +++ b/backend/app/api/v1/module_system/dict/service.py @@ -2,12 +2,11 @@ import json from redis.asyncio.client import Redis -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.enums import RedisInitKeyConfig -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.database import async_db_session from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.utils.excel_util import ExcelUtil @@ -30,7 +29,7 @@ class DictTypeService: """ @classmethod - async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> DictTypeOutSchema: """ 获取数据字典类型详情 @@ -41,8 +40,10 @@ class DictTypeService: 返回: - dict: 数据字典类型详情字典 """ - obj = await DictTypeCRUD(auth).get_obj_by_id_crud(id=id) - return DictTypeOutSchema.model_validate(obj).model_dump() + obj = await DictTypeCRUD(auth).get(id=id) + if not obj: + raise CustomException(msg="字典类型不存在") + return DictTypeOutSchema.model_validate(obj) @classmethod async def get_obj_list_service( @@ -50,7 +51,7 @@ class DictTypeService: auth: AuthSchema, search: DictTypeQueryParam | None = None, order_by: list[dict] | None = None, - ) -> list[dict]: + ) -> list[DictTypeOutSchema]: """ 获取数据字典类型列表 @@ -60,12 +61,12 @@ class DictTypeService: - order_by (list[dict] | None): 排序字段列表 返回: - - list[dict]: 数据字典类型详情字典列表 + - list[DictTypeOutSchema]: 数据字典类型 """ - obj_list = await DictTypeCRUD(auth).get_obj_list_crud( - search=search.__dict__, order_by=order_by + obj_list = await DictTypeCRUD(auth).list( + search=search.__dict__ if search else {}, order_by=order_by ) - return [DictTypeOutSchema.model_validate(obj).model_dump() for obj in obj_list] + return [DictTypeOutSchema.model_validate(obj) for obj in obj_list] @classmethod async def get_obj_page_service( @@ -101,7 +102,7 @@ class DictTypeService: @classmethod async def create_obj_service( cls, auth: AuthSchema, redis: Redis, data: DictTypeCreateSchema - ) -> dict: + ) -> DictTypeOutSchema: """ 创建数据字典类型 @@ -116,21 +117,21 @@ class DictTypeService: exist_obj = await DictTypeCRUD(auth).get(dict_name=data.dict_name) if exist_obj: raise CustomException(msg="创建失败,该数据字典类型已存在") - obj = await DictTypeCRUD(auth).create_obj_crud(data=data) + obj = await DictTypeCRUD(auth).create(data=data) - new_obj_dict = DictTypeOutSchema.model_validate(obj).model_dump() + new_obj_dict = DictTypeOutSchema.model_validate(obj) redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}" try: await RedisCURD(redis).set( key=redis_key, - value="", + value="[]", expire=None, ) - log.info(f"创建字典类型成功: {new_obj_dict}") + logger.info(f"创建字典类型成功: {new_obj_dict}") except Exception as e: - log.error(f"创建字典类型失败: {e}") + logger.error(f"创建字典类型失败: {e}") raise CustomException(msg=f"创建字典类型失败 {e}") return new_obj_dict @@ -142,7 +143,7 @@ class DictTypeService: redis: Redis, id: int, data: DictTypeUpdateSchema, - ) -> dict: + ) -> DictTypeOutSchema: """ 更新数据字典类型 @@ -155,16 +156,14 @@ class DictTypeService: 返回: - dict: 数据字典类型详情字典 """ - exist_obj = await DictTypeCRUD(auth).get_obj_by_id_crud(id=id) + exist_obj = await DictTypeCRUD(auth).get(id=id) if not exist_obj: raise CustomException(msg="更新失败,该数据字典类型不存在") if exist_obj.dict_name != data.dict_name: raise CustomException(msg="更新失败,数据字典类型名称不可以修改") - dict_data_list = [] - # 如果字典类型修改或状态变更,则修改对应字典数据的类型和状态,并更新Redis缓存 + # 如果字典类型修改或状态变更,则修改对应字典数据的类型和状态 if exist_obj.dict_type != data.dict_type or exist_obj.status != data.status: - # 检查字典数据类型是否被修改 exist_obj_type_list = await DictDataCRUD(auth).list( search={"dict_type": exist_obj.dict_type} ) @@ -183,17 +182,16 @@ class DictTypeService: status=data.status, description=item.description, ) - obj = await DictDataCRUD(auth).update_obj_crud(id=item.id, data=dict_data) - dict_data_list.append(DictDataOutSchema.model_validate(obj).model_dump()) + await DictDataCRUD(auth).update(id=item.id, data=dict_data) - obj = await DictTypeCRUD(auth).update_obj_crud(id=id, data=data) + obj = await DictTypeCRUD(auth).update(id=id, data=data) - new_obj_dict = DictTypeOutSchema.model_validate(obj).model_dump() + new_obj_dict = DictTypeOutSchema.model_validate(obj) redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}" try: # 获取当前字典类型的所有字典数据,确保包含最新状态 - dict_data_list = await DictDataCRUD(auth).get_obj_list_crud( + dict_data_list = await DictDataCRUD(auth).list( search={"dict_type": data.dict_type} ) dict_data = [ @@ -208,9 +206,9 @@ class DictTypeService: value=value, expire=None, ) - log.info(f"更新字典类型成功并刷新缓存: {new_obj_dict}") + logger.info(f"更新字典类型成功并刷新缓存: {new_obj_dict}") except Exception as e: - log.error(f"更新字典类型缓存失败: {e}") + logger.error(f"更新字典类型缓存失败: {e}") raise CustomException(msg=f"更新字典类型缓存失败 {e}") return new_obj_dict @@ -230,12 +228,14 @@ class DictTypeService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - for id in ids: - exist_obj = await DictTypeCRUD(auth).get_obj_by_id_crud(id=id) - if not exist_obj: + existing = await DictTypeCRUD(auth).list(search={"id": ("in", ids)}) + existing_map = {obj.id: obj for obj in existing} + for nid in ids: + if nid not in existing_map: raise CustomException(msg="删除失败,该数据字典类型不存在") + exist_obj = existing_map[nid] # 检查是否有字典数据 - exist_obj_type_list = await DictDataCRUD(auth).list(search={"dict_type": id}) + exist_obj_type_list = await DictDataCRUD(auth).list(search={"dict_type": exist_obj.dict_type}) if len(exist_obj_type_list) > 0: # 如果有字典数据,不能删除 raise CustomException(msg="删除失败,该数据字典类型下存在字典数据") @@ -245,11 +245,11 @@ class DictTypeService: ) try: await RedisCURD(redis).delete(redis_key) - log.info(f"删除字典类型成功: {id}") + logger.info(f"删除字典类型成功: {nid}") except Exception as e: - log.error(f"删除字典类型失败: {e}") + logger.error(f"删除字典类型失败: {e}") raise CustomException(msg="删除字典类型失败") - await DictTypeCRUD(auth).delete_obj_crud(ids=ids) + await DictTypeCRUD(auth).delete(ids=ids) @classmethod async def set_obj_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: @@ -263,7 +263,7 @@ class DictTypeService: 返回: - None """ - await DictTypeCRUD(auth).set_obj_available_crud(ids=data.ids, status=data.status) + await DictTypeCRUD(auth).set(ids=data.ids, status=data.status) @classmethod async def export_obj_service(cls, data_list: list[dict]) -> bytes: @@ -292,12 +292,7 @@ class DictTypeService: data = data_list.copy() for item in data: # 处理状态 - item["status"] = "启用" if item.get("status") == "0" else "停用" - item["creator"] = ( - item.get("creator", {}).get("name", "未知") - if isinstance(item.get("creator"), dict) - else "未知" - ) + item["status"] = "启用" if item.get("status") == 0 else "停用" return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) @@ -308,7 +303,7 @@ class DictDataService: """ @classmethod - async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> DictDataOutSchema: """ 获取数据字典数据详情 @@ -319,8 +314,10 @@ class DictDataService: 返回: - dict: 数据字典数据详情字典 """ - obj = await DictDataCRUD(auth).get_obj_by_id_crud(id=id) - return DictDataOutSchema.model_validate(obj).model_dump() + obj = await DictDataCRUD(auth).get(id=id) + if not obj: + raise CustomException(msg="字典数据不存在") + return DictDataOutSchema.model_validate(obj) @classmethod async def get_obj_list_service( @@ -328,7 +325,7 @@ class DictDataService: auth: AuthSchema, search: DictDataQueryParam | None = None, order_by: list[dict] | None = None, - ) -> list[dict]: + ) -> list[DictDataOutSchema]: """ 获取数据字典数据列表 @@ -338,12 +335,12 @@ class DictDataService: - order_by (list[dict] | None): 排序字段列表 返回: - - list[dict]: 数据字典数据详情字典列表 + - list[DictDataOutSchema]: 数据字典数据 """ - obj_list = await DictDataCRUD(auth).get_obj_list_crud( - search=search.__dict__, order_by=order_by + obj_list = await DictDataCRUD(auth).list( + search=search.__dict__ if search else {}, order_by=order_by ) - return [DictDataOutSchema.model_validate(obj).model_dump() for obj in obj_list] + return [DictDataOutSchema.model_validate(obj) for obj in obj_list] @classmethod async def get_obj_page_service( @@ -391,16 +388,16 @@ class DictDataService: async with async_db_session() as session: async with session.begin(): auth = AuthSchema(db=session, check_data_scope=False) - obj_list = await DictTypeCRUD(auth).get_obj_list_crud() + obj_list = await DictTypeCRUD(auth).list() if not obj_list: - log.warning("未找到任何字典类型数据") + logger.warning("未找到任何字典类型数据") return for obj in obj_list: dict_type = obj.dict_type tenant_id = obj.tenant_id try: - dict_data_list = await DictDataCRUD(auth).get_obj_list_crud( + dict_data_list = await DictDataCRUD(auth).list( search={"dict_type": dict_type, "tenant_id": tenant_id} ) dict_data = [ @@ -418,10 +415,10 @@ class DictDataService: expire=None, ) except Exception as e: - log.error(f"❌ 初始化字典数据失败 [{dict_type}]: {e}") + logger.error(f"❌ 初始化字典数据失败 [{dict_type}]: {e}") except Exception as e: - log.error(f"字典初始化过程发生错误: {e}") + logger.error(f"字典初始化过程发生错误: {e}") raise CustomException(msg=f"字典数据初始化失败: {e!s}") @classmethod @@ -448,7 +445,7 @@ class DictDataService: try: return json.loads(obj_list_dict) except json.JSONDecodeError: - log.warning(f"字典数据反序列化失败,尝试重新初始化缓存: {dict_type}") + logger.warning(f"字典数据反序列化失败,尝试重新初始化缓存: {dict_type}") elif isinstance(obj_list_dict, list): return obj_list_dict @@ -467,13 +464,13 @@ class DictDataService: except CustomException: raise except Exception as e: - log.error(f"获取字典缓存失败: {e!s}") + logger.error(f"获取字典缓存失败: {e!s}") raise CustomException(msg=f"获取字典数据失败: {e!s}") @classmethod async def create_obj_service( cls, auth: AuthSchema, redis: Redis, data: DictDataCreateSchema - ) -> dict: + ) -> DictDataOutSchema: """ 创建数据字典数据 @@ -499,12 +496,12 @@ class DictDataService: if exist_value_obj: raise CustomException(msg=f'创建失败,该字典类型下的字典键值"{data.dict_value}"已存在') - obj = await DictDataCRUD(auth).create_obj_crud(data=data) + obj = await DictDataCRUD(auth).create(data=data) redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}" try: # 获取当前字典类型的所有字典数据 - dict_data_list = await DictDataCRUD(auth).get_obj_list_crud( + dict_data_list = await DictDataCRUD(auth).list( search={"dict_type": data.dict_type} ) dict_data = [ @@ -519,12 +516,12 @@ class DictDataService: value=value, expire=None, ) - log.info(f"创建字典数据写入缓存成功: {obj}") + logger.info(f"创建字典数据写入缓存成功: {obj}") except Exception as e: - log.error(f"创建字典数据写入缓存失败: {e}") + logger.error(f"创建字典数据写入缓存失败: {e}") raise CustomException(msg=f"创建字典数据失败 {e}") - return DictDataOutSchema.model_validate(obj).model_dump() + return DictDataOutSchema.model_validate(obj) @classmethod async def update_obj_service( @@ -533,7 +530,7 @@ class DictDataService: redis: Redis, id: int, data: DictDataUpdateSchema, - ) -> dict: + ) -> DictDataOutSchema: """ 更新数据字典数据 @@ -546,7 +543,7 @@ class DictDataService: 返回: - Dict: 数据字典数据详情字典 """ - exist_obj = await DictDataCRUD(auth).get_obj_by_id_crud(id=id) + exist_obj = await DictDataCRUD(auth).get(id=id) if not exist_obj: raise CustomException(msg="更新失败,该字典数据不存在") @@ -576,7 +573,7 @@ class DictDataService: if dict_type: redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{dict_type.dict_type}" try: - dict_data_list = await DictDataCRUD(auth).get_obj_list_crud( + dict_data_list = await DictDataCRUD(auth).list( search={"dict_type": dict_type.dict_type} ) dict_data = [ @@ -591,13 +588,13 @@ class DictDataService: expire=None, ) except Exception as e: - log.error(f"更新字典数据类型变更时刷新旧缓存失败: {e}") + logger.error(f"更新字典数据类型变更时刷新旧缓存失败: {e}") - obj = await DictDataCRUD(auth).update_obj_crud(id=id, data=data) + obj = await DictDataCRUD(auth).update(id=id, data=data) redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}" try: # 获取当前字典类型的所有字典数据 - dict_data_list = await DictDataCRUD(auth).get_obj_list_crud( + dict_data_list = await DictDataCRUD(auth).list( search={"dict_type": data.dict_type} ) dict_data = [ @@ -612,12 +609,12 @@ class DictDataService: value=value, expire=None, ) - log.info(f"更新字典数据写入缓存成功: {obj}") + logger.info(f"更新字典数据写入缓存成功: {obj}") except Exception as e: - log.error(f"更新字典数据写入缓存失败: {e}") + logger.error(f"更新字典数据写入缓存失败: {e}") raise CustomException(msg=f"更新字典数据失败 {e}") - return DictDataOutSchema.model_validate(obj).model_dump() + return DictDataOutSchema.model_validate(obj) @classmethod async def delete_obj_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: @@ -636,24 +633,22 @@ class DictDataService: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - # 首先检查是否包含系统默认数据 - for id in ids: - exist_obj = await DictDataCRUD(auth).get_obj_by_id_crud(id=id) - if not exist_obj: - raise CustomException(msg=f"{id} 删除失败,该字典数据不存在") + # 批量查询所有待删除项 + existing = await DictDataCRUD(auth).list(search={"id": ("in", ids)}) + existing_map = {obj.id: obj for obj in existing} + for nid in ids: + if nid not in existing_map: + raise CustomException(msg=f"{nid} 删除失败,该字典数据不存在") + exist_obj = existing_map[nid] # 系统默认字典数据不允许删除 if exist_obj.is_default: - raise CustomException(msg=f"删除失败,ID为{id}的系统默认字典数据不允许删除") + raise CustomException(msg=f"删除失败,ID为{nid}的系统默认字典数据不允许删除") - # 获取所有需要清除的缓存键 - dict_types_to_clear = set() - for id in ids: - exist_obj = await DictDataCRUD(auth).get_obj_by_id_crud(id=id) - if exist_obj: - dict_types_to_clear.add(exist_obj.dict_type) + # 从批量查询结果中收集缓存键 + dict_types_to_clear = {obj.dict_type for obj in existing} # 执行删除操作 - await DictDataCRUD(auth).delete_obj_crud(ids=ids) + await DictDataCRUD(auth).delete(ids=ids) # 清除缓存 for dict_type in dict_types_to_clear: @@ -662,17 +657,17 @@ class DictDataService: f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{dict_type}" ) await RedisCURD(redis).delete(redis_key) - log.info(f"清除字典缓存成功: {dict_type}") + logger.info(f"清除字典缓存成功: {dict_type}") except Exception as e: - log.warning(f"清除字典缓存失败: {e}") + logger.warning(f"清除字典缓存失败: {e}") # 缓存清除失败不影响删除操作 - log.info(f"删除字典数据成功,ID列表: {ids}") + logger.info(f"删除字典数据成功,ID列表: {ids}") except CustomException: raise except Exception as e: - log.error(f"删除字典数据失败: {e!s}") + logger.error(f"删除字典数据失败: {e!s}") raise CustomException(msg=f"删除字典数据失败: {e!s}") @classmethod @@ -687,7 +682,7 @@ class DictDataService: 返回: - None """ - await DictDataCRUD(auth).set_obj_available_crud(ids=data.ids, status=data.status) + await DictDataCRUD(auth).set(ids=data.ids, status=data.status) @classmethod async def export_obj_service(cls, data_list: list[dict]) -> bytes: @@ -721,13 +716,8 @@ class DictDataService: data = data_list.copy() for item in data: # 处理状态 - item["status"] = "启用" if item.get("status") == "0" else "停用" + item["status"] = "启用" if item.get("status") == 0 else "停用" # 处理是否默认 item["is_default"] = "是" if item.get("is_default") else "否" - item["creator"] = ( - item.get("creator", {}).get("name", "未知") - if isinstance(item.get("creator"), dict) - else "未知" - ) return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) diff --git a/backend/app/api/v1/module_system/log/__init__.py b/backend/app/api/v1/module_system/log/__init__.py new file mode 100644 index 00000000..00942db2 --- /dev/null +++ b/backend/app/api/v1/module_system/log/__init__.py @@ -0,0 +1,3 @@ +from .controller import LogRouter + +__all__ = ["LogRouter"] diff --git a/backend/app/api/v1/module_system/log/controller.py b/backend/app/api/v1/module_system/log/controller.py new file mode 100644 index 00000000..1d794fa0 --- /dev/null +++ b/backend/app/api/v1/module_system/log/controller.py @@ -0,0 +1,231 @@ +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, Path +from fastapi.responses import JSONResponse + +from app.common.response import ResponseSchema, SuccessResponse +from app.core.base_params import PaginationQueryParam +from app.core.base_schema import AuthSchema, BatchDelete, PageResultSchema +from app.core.dependencies import AuthPermission, get_current_user +from app.core.router_class import OperationLogRoute + +from .schema import ( + LoginLogCreateSchema, + LoginLogDetailOutSchema, + LoginLogOutSchema, + LoginLogQueryParam, + OperationLogCreateSchema, + OperationLogDetailOutSchema, + OperationLogOutSchema, + OperationLogQueryParam, +) +from .service import LoginLogService, OperationLogService + +LogRouter = APIRouter(route_class=OperationLogRoute, prefix="/log", tags=["系统管理/日志管理"]) + + +@LogRouter.get( + "/login/detail/{id}", + summary="获取登录日志详情", + response_model=ResponseSchema[LoginLogDetailOutSchema], +) +async def get_obj_detail_controller( + id: Annotated[int, Path(description="登录日志ID")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:login_log:query']))], +) -> JSONResponse: + """ + 获取登录日志详情 + + 参数: + - id (int): 登录日志 ID。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含登录日志详情的 JSON 响应。 + """ + result_dict = await LoginLogService.detail_service(id=id, auth=auth) + return SuccessResponse(data=result_dict, msg="获取登录日志详情成功") + + +@LogRouter.get( + "/login/list", + summary="查询登录日志列表", + response_model=ResponseSchema[PageResultSchema[LoginLogOutSchema]], +) +async def get_obj_list_controller( + page: Annotated[PaginationQueryParam, Depends()], + search: Annotated[LoginLogQueryParam, Depends()], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:login_log:query']))], +) -> JSONResponse: + """ + 查询登录日志列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (LoginLogQueryParam): 查询筛选参数。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含分页登录日志列表的 JSON 响应。 + """ + result_dict = await LoginLogService.page_service( + auth=auth, + page_no=page.page_no, + page_size=page.page_size, + search=search, + order_by=page.order_by, + ) + return SuccessResponse(data=result_dict, msg="查询登录日志列表成功") + + +@LogRouter.post( + "/login/create", + summary="创建登录日志", + response_model=ResponseSchema[LoginLogDetailOutSchema], +) +async def create_obj_controller( + data: LoginLogCreateSchema, + auth: Annotated[AuthSchema, Depends(get_current_user)], +) -> JSONResponse: + """ + 创建登录日志 + + 参数: + - data (LoginLogCreateSchema): 登录日志创建参数。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含创建后的登录日志详情的 JSON 响应。 + """ + result_dict = await LoginLogService.create_service(auth=auth, data=data) + return SuccessResponse(data=result_dict, msg="创建登录日志成功") + + +@LogRouter.delete( + "/login/delete", + summary="删除登录日志", + response_model=ResponseSchema, +) +async def delete_obj_controller( + ids: Annotated[list[int], Body(description="ID列表")], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:login_log:delete']))], +) -> JSONResponse: + """ + 删除登录日志 + + 参数: + - ids (list[int]): 日志 ID 列表。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 删除结果。 + """ + await LoginLogService.delete_service(auth=auth, ids=ids) + return SuccessResponse(msg="删除登录日志成功") + + +@LogRouter.get( + "/operation/detail/{id}", + summary="获取操作日志详情", + response_model=ResponseSchema[OperationLogDetailOutSchema], + dependencies=[Depends(AuthPermission(['module_system:log:query']))], +) +async def detail( + *, + id: Annotated[int, Path(gt=0)], + auth: Annotated[AuthSchema, Depends(get_current_user)], +): + """ + 获取操作日志详情 + + 参数: + - id (int): 操作日志 ID。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含操作日志详情的 JSON 响应。 + """ + result_dict = await OperationLogService.detail_service(auth, id) + return SuccessResponse(data=result_dict, msg="获取操作日志详情成功") + + +@LogRouter.get( + "/operation/list", + summary="获取操作日志列表", + response_model=ResponseSchema[PageResultSchema[OperationLogOutSchema]], + dependencies=[Depends(AuthPermission(['module_system:log:query']))], +) +async def list( + *, + page: Annotated[PaginationQueryParam, Depends()], + search: Annotated[OperationLogQueryParam, Depends()], + auth: Annotated[AuthSchema, Depends(get_current_user)], +): + """ + 获取操作日志列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (OperationLogQueryParam): 查询筛选参数。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含分页操作日志列表的 JSON 响应。 + """ + result_dict = await OperationLogService.page_service( + auth, + page.page_no, + page.page_size, + search.model_dump(exclude_none=True), + page.order_by, + ) + return SuccessResponse(data=result_dict, msg="查询操作日志列表成功") + + +@LogRouter.post( + "/operation/create", + summary="创建操作日志", + response_model=ResponseSchema[OperationLogDetailOutSchema], +) +async def create( + *, + data: OperationLogCreateSchema, + auth: Annotated[AuthSchema, Depends(get_current_user)], +): + """ + 创建操作日志 + + 参数: + - data (OperationLogCreateSchema): 操作日志创建参数。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含创建后的操作日志详情的 JSON 响应。 + """ + result_dict = await OperationLogService.create_service(auth, data) + return SuccessResponse(data=result_dict, msg="创建操作日志成功") + + +@LogRouter.delete( + "/operation/delete", + summary="删除操作日志", + response_model=ResponseSchema, + dependencies=[Depends(AuthPermission(['module_system:log:delete']))], +) +async def delete( + *, + data: BatchDelete, + auth: Annotated[AuthSchema, Depends(get_current_user)], +): + """ + 删除操作日志 + + 参数: + - data (BatchDelete): 批量删除参数(ID 列表)。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 删除结果。 + """ + await OperationLogService.delete_service(auth, data.ids) + return SuccessResponse(msg="删除操作日志成功") diff --git a/backend/app/api/v1/module_system/log/crud.py b/backend/app/api/v1/module_system/log/crud.py new file mode 100644 index 00000000..cf457545 --- /dev/null +++ b/backend/app/api/v1/module_system/log/crud.py @@ -0,0 +1,19 @@ +from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema + +from .model import LoginLogModel, OperationLogModel +from .schema import LoginLogCreateSchema + + +class LoginLogCRUD(CRUDBase[LoginLogModel, LoginLogCreateSchema, None]): + """登录日志数据层""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=LoginLogModel, auth=auth) + + +class OperationLogCRUD(CRUDBase[OperationLogModel, None, None]): + """操作日志 CRUD""" + + def __init__(self, auth: AuthSchema): + super().__init__(OperationLogModel, auth) diff --git a/backend/app/api/v1/module_system/operationlog/model.py b/backend/app/api/v1/module_system/log/model.py similarity index 70% rename from backend/app/api/v1/module_system/operationlog/model.py rename to backend/app/api/v1/module_system/log/model.py index db430f6e..38e1bc52 100644 --- a/backend/app/api/v1/module_system/operationlog/model.py +++ b/backend/app/api/v1/module_system/log/model.py @@ -20,6 +20,23 @@ def get_log_text_column_type(): return Text +class LoginLogModel(ModelMixin, TenantMixin, UserMixin): + """ + 登录日志模型 + """ + + __tablename__: str = "sys_login_log" + __table_args__: dict[str, str] = {"comment": "登录日志表"} + + status: Mapped[int] = mapped_column(Integer, default=1, comment="登录状态(1成功 2失败)") + username: Mapped[str] = mapped_column(String(64), nullable=False, comment="用户名") + login_location: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="登录位置") + login_ip: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="登录IP地址") + request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统") + request_browser: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="浏览器") + msg: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="提示消息") + + class OperationLogModel(ModelMixin, TenantMixin, UserMixin): """ 操作日志模型 @@ -34,9 +51,6 @@ class OperationLogModel(ModelMixin, TenantMixin, UserMixin): request_payload: Mapped[str | None] = mapped_column( get_log_text_column_type(), comment="请求体" ) - request_ip: Mapped[str | None] = mapped_column(String(50), comment="请求IP地址") - request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统") - request_browser: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="浏览器") response_code: Mapped[int] = mapped_column(Integer, comment="响应状态码") response_json: Mapped[str | None] = mapped_column( get_log_text_column_type(), nullable=True, comment="响应体" diff --git a/backend/app/api/v1/module_system/log/schema.py b/backend/app/api/v1/module_system/log/schema.py new file mode 100644 index 00000000..d0571d98 --- /dev/null +++ b/backend/app/api/v1/module_system/log/schema.py @@ -0,0 +1,115 @@ +from fastapi import Query +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.common.enums import QueueEnum +from app.core.base_schema import BaseSchema, UserBySchema +from app.core.validator import DateTimeStr + +ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] + + +class LoginLogCreateSchema(BaseModel): + """新增登录日志""" + + username: str = Field(..., min_length=1, max_length=64, description="用户名") + status: int = Field(default=1, ge=1, le=2, description="登录状态(1成功 2失败)") + login_ip: str | None = Field(default=None, max_length=50, description="登录IP地址") + login_location: str | None = Field(default=None, max_length=255, description="登录位置") + request_os: str | None = Field(default=None, max_length=64, description="操作系统") + request_browser: str | None = Field(default=None, max_length=64, description="浏览器") + msg: str | None = Field(default=None, max_length=255, description="提示消息") + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("用户名不能为空") + if len(v) > 64: + raise ValueError("用户名长度不能超过64个字符") + return v + + @field_validator("status") + @classmethod + def validate_status(cls, v: int) -> int: + if v not in [1, 2]: + raise ValueError("登录状态只能为1(成功)或2(失败)") + return v + + +class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema, UserBySchema): + """登录日志响应""" + + model_config = ConfigDict(from_attributes=True) + + +class LoginLogDetailOutSchema(LoginLogOutSchema): + """登录日志详情响应""" + + +class LoginLogQueryParam: + """登录日志查询参数""" + + def __init__( + self, + status: int | None = Query(None, ge=1, le=2, description="登录状态(1成功 2失败)"), + username: str | None = Query(None, max_length=64, description="用户名"), + created_time: list[DateTimeStr] | None = Query( + None, + description="创建时间范围", + examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], + ), + ) -> None: + if status: + self.status = (QueueEnum.eq.value, status) + if username: + self.username = (QueueEnum.like.value, username) + + if created_time and len(created_time) == 2: + self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1])) + + +class OperationLogQueryParam(BaseModel): + request_path: str | None = Field(None, max_length=255, description="请求路径") + request_method: str | None = Field(None, description="请求方式") + username: str | None = Field(None, max_length=64, description="用户名") + + @field_validator("request_method") + @classmethod + def validate_request_method(cls, value: str | None) -> str | None: + if value and value.upper() not in ALLOWED_REQUEST_METHODS: + raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}") + return value.upper() if value else None + + +class OperationLogOutSchema(BaseSchema): + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + request_path: str + request_method: str + response_code: int + process_time: str | None = None + + +class OperationLogDetailOutSchema(OperationLogOutSchema): + request_payload: str | None = None + response_json: str | None = None + + +class OperationLogCreateSchema(BaseModel): + request_path: str = Field(..., min_length=1, max_length=255, description="请求路径") + request_method: str = Field(..., description="请求方式") + request_payload: str | None = Field(None, description="请求体") + response_code: int = Field(200, ge=100, le=599, description="响应状态码") + response_json: str | None = Field(None, description="响应体") + process_time: str | None = Field(None, max_length=20, description="处理时间") + + @field_validator("request_method") + @classmethod + def validate_request_method(cls, value: str) -> str: + upper_value = value.upper() + if upper_value not in ALLOWED_REQUEST_METHODS: + raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}") + return upper_value diff --git a/backend/app/api/v1/module_system/log/service.py b/backend/app/api/v1/module_system/log/service.py new file mode 100644 index 00000000..729faae9 --- /dev/null +++ b/backend/app/api/v1/module_system/log/service.py @@ -0,0 +1,172 @@ +from app.core.base_schema import AuthSchema +from app.core.exceptions import CustomException +from app.core.logger import logger + +from .crud import LoginLogCRUD, OperationLogCRUD +from .schema import ( + LoginLogCreateSchema, + LoginLogDetailOutSchema, + LoginLogOutSchema, + LoginLogQueryParam, + OperationLogCreateSchema, + OperationLogDetailOutSchema, + OperationLogOutSchema, +) + + +class LoginLogService: + """登录日志管理模块服务层""" + + @classmethod + async def detail_service(cls, auth: AuthSchema, id: int) -> LoginLogDetailOutSchema: + obj = await LoginLogCRUD(auth).get(id=id) + if not obj: + raise CustomException(msg="该数据不存在") + return LoginLogDetailOutSchema.model_validate(obj) + + @classmethod + async def page_service( + cls, + auth: AuthSchema, + page_no: int, + page_size: int, + search: LoginLogQueryParam | None = None, + order_by: list[dict[str, str]] | None = None, + ) -> dict: + search_dict = search.__dict__ if search else {} + order_by_list = order_by or [{"updated_time": "desc"}] + offset = (page_no - 1) * page_size + + result = await LoginLogCRUD(auth).page( + offset=offset, + limit=page_size, + order_by=order_by_list, + search=search_dict, + out_schema=LoginLogOutSchema, + ) + return result + + @classmethod + async def create_service(cls, auth: AuthSchema, data: LoginLogCreateSchema) -> LoginLogDetailOutSchema: + obj = await LoginLogCRUD(auth).create(data=data) + if not obj: + raise CustomException(msg="创建登录日志失败") + return LoginLogDetailOutSchema.model_validate(obj) + + @classmethod + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + if len(ids) < 1: + raise CustomException(msg="删除失败,删除对象不能为空") + + existing = await LoginLogCRUD(auth).list(search={"id": ("in", ids)}) + existing_map = {obj.id for obj in existing} + for nid in ids: + if nid not in existing_map: + raise CustomException(msg=f"删除失败,ID为{nid}的数据不存在") + + await LoginLogCRUD(auth).delete(ids=ids) + + +class OperationLogService: + + @staticmethod + async def cleanup_operation_log() -> None: + """定时任务:清理超过保留期的操作日志和登录日志(PRD §14.5) + + 清理 create_time < now - retention_days 的记录。 + 保留期从全局参数 `operation_log_retention_days` 读取,默认 90 天。 + """ + from datetime import datetime, timedelta + + from sqlalchemy import delete + + from app.core.database import async_db_session + + from .model import LoginLogModel, OperationLogModel + + retention_days = 90 # 默认 90 天,可通过系统参数覆盖 + # 尝试从系统参数读取自定义保留期 + try: + from app.api.v1.module_system.params.model import ParamsModel + async with async_db_session() as _s: + from sqlalchemy import select + result = await _s.execute( + select(ParamsModel.config_value).where( + ParamsModel.config_key == "operation_log_retention_days" + ).limit(1) + ) + row = result.scalar() + if row is not None: + retention_days = int(row) + except Exception: + pass # 读取失败使用默认值 + + cutoff = datetime.now() - timedelta(days=retention_days) + async with async_db_session() as session: + op_stmt = delete(OperationLogModel).where(OperationLogModel.created_time < cutoff) + op_result = await session.execute(op_stmt) + + login_stmt = delete(LoginLogModel).where(LoginLogModel.created_time < cutoff) + login_result = await session.execute(login_stmt) + + await session.commit() + logger.info( + f"操作日志清理完成: " + f"操作日志 {op_result.rowcount} 条, " + f"登录日志 {login_result.rowcount} 条" + ) + return True + + @staticmethod + async def create_service(auth: AuthSchema, data: OperationLogCreateSchema) -> OperationLogDetailOutSchema: + crud = OperationLogCRUD(auth) + obj = await crud.create(data) + if not obj: + raise CustomException(msg="创建操作日志失败") + return OperationLogDetailOutSchema.model_validate(obj) + + @staticmethod + async def page_service( + auth: AuthSchema, + page: int, + page_size: int, + search: dict | None = None, + order_by: list[dict[str, str]] | None = None, + ) -> dict: + from app.common.enums import QueueEnum + + crud = OperationLogCRUD(auth) + + # 构建过滤条件 + filters = {} + if search: + if search.get("request_path"): + filters["request_path"] = (QueueEnum.like.value, search["request_path"]) + if search.get("request_method"): + filters["request_method"] = (QueueEnum.eq.value, search["request_method"]) + if search.get("username"): + filters["username"] = (QueueEnum.like.value, search["username"]) + + result = await crud.page( + offset=(page - 1) * page_size, + limit=page_size, + order_by=order_by or [{"id": "desc"}], + search=filters, + out_schema=OperationLogOutSchema, + ) + return result + + @staticmethod + async def detail_service(auth: AuthSchema, id: int) -> OperationLogDetailOutSchema: + crud = OperationLogCRUD(auth) + obj = await crud.get(id=id) + if not obj: + raise CustomException(msg="该操作日志不存在") + return OperationLogDetailOutSchema.model_validate(obj) + + @staticmethod + async def delete_service(auth: AuthSchema, ids: list[int]) -> None: + if len(ids) < 1: + raise CustomException(msg="删除失败,删除对象不能为空") + crud = OperationLogCRUD(auth) + await crud.delete(ids) diff --git a/backend/app/api/v1/module_system/menu/__init__.py b/backend/app/api/v1/module_system/menu/__init__.py deleted file mode 100644 index 8b137891..00000000 --- a/backend/app/api/v1/module_system/menu/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/api/v1/module_system/menu/crud.py b/backend/app/api/v1/module_system/menu/crud.py deleted file mode 100644 index 5e150263..00000000 --- a/backend/app/api/v1/module_system/menu/crud.py +++ /dev/null @@ -1,96 +0,0 @@ -from collections.abc import Sequence - -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_crud import CRUDBase - -from .model import MenuModel -from .schema import MenuCreateSchema, MenuUpdateSchema - - -class MenuCRUD(CRUDBase[MenuModel, MenuCreateSchema, MenuUpdateSchema]): - """菜单模块数据层""" - - def __init__(self, auth: AuthSchema) -> None: - """ - 初始化菜单数据层。 - - 参数: - - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 - - 返回: - - None - """ - self.auth = auth - super().__init__(model=MenuModel, auth=auth) - - async def get_by_id_crud(self, id: int, preload: list[str] | None = None) -> MenuModel | None: - """ - 根据 id 获取菜单信息。 - - 参数: - - id (int): 菜单 ID。 - - preload (list[str] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - MenuModel | None: 菜单信息,未找到返回 None。 - """ - obj = await self.get(id=id, preload=preload) - if not obj: - return None - return obj - - async def get_list_crud( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list[str] | None = None, - ) -> Sequence[MenuModel]: - """ - 获取菜单列表。 - - 参数: - - search (dict | None): 搜索条件。 - - order_by (list[dict] | None): 排序字段列表。 - - preload (list[str] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[MenuModel]: 菜单列表。 - """ - return await self.list(search=search, order_by=order_by, preload=preload) - - async def get_tree_list_crud( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list[str] | None = None, - ) -> Sequence[MenuModel]: - """ - 获取菜单树形列表。 - - 参数: - - search (dict | None): 搜索条件。 - - order_by (list[dict] | None): 排序字段列表。 - - preload (list[str] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[MenuModel]: 菜单树形列表。 - """ - return await self.tree_list( - search=search, - order_by=order_by, - children_attr="children", - preload=preload, - ) - - async def set_available_crud(self, ids: list[int], status: str) -> None: - """ - 批量设置菜单可用状态。 - - 参数: - - ids (list[int]): 菜单 ID 列表。 - - status (str): 可用状态。 - - 返回: - - None - """ - await self.set(ids=ids, status=status) diff --git a/backend/app/api/v1/module_system/notice/controller.py b/backend/app/api/v1/module_system/notice/controller.py index 2cbe57c3..63bc78f9 100644 --- a/backend/app/api/v1/module_system/notice/controller.py +++ b/backend/app/api/v1/module_system/notice/controller.py @@ -2,31 +2,39 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, StreamResponse, SuccessResponse from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.dependencies import AuthPermission, get_current_user -from app.core.logger import log +from app.core.logger import logger from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response -from .schema import NoticeCreateSchema, NoticeOutSchema, NoticeQueryParam, NoticeUpdateSchema +from .schema import ( + NoticeCreateSchema, + NoticeOutSchema, + NoticeQueryParam, + NoticeUpdateSchema, + PanelDataOut, +) from .service import NoticeService -NoticeRouter = APIRouter(route_class=OperationLogRoute, prefix="/notice", tags=["公告通知"]) +NoticeRouter = APIRouter(route_class=OperationLogRoute, prefix="/notice", tags=["系统管理/公告通知"]) + +_NOTICE_NS = "notice" @NoticeRouter.get( "/detail/{id}", summary="获取公告详情", - description="获取公告详情", response_model=ResponseSchema[NoticeOutSchema], ) async def get_obj_detail_controller( id: Annotated[int, Path(description="公告ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:notice:detail']))], ) -> JSONResponse: """ 获取公告详情。 @@ -39,20 +47,18 @@ async def get_obj_detail_controller( - JSONResponse: 包含公告详情的响应模型。 """ result_dict = await NoticeService.get_notice_detail_service(id=id, auth=auth) - log.info(f"获取公告详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取公告详情成功") @NoticeRouter.get( "/list", summary="查询公告", - description="查询公告", - response_model=ResponseSchema[list[NoticeOutSchema]], + response_model=ResponseSchema[PageResultSchema[NoticeOutSchema]], ) async def get_obj_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[NoticeQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:notice:query']))], ) -> JSONResponse: """ 查询公告。 @@ -72,19 +78,17 @@ async def get_obj_list_controller( search=search, order_by=page.order_by, ) - log.info("查询公告列表成功") return SuccessResponse(data=result_dict, msg="查询公告列表成功") @NoticeRouter.post( "/create", summary="创建公告", - description="创建公告", response_model=ResponseSchema[NoticeOutSchema], ) async def create_obj_controller( data: NoticeCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:notice:create']))], ) -> JSONResponse: """ 创建公告。 @@ -97,20 +101,19 @@ async def create_obj_controller( - JSONResponse: 包含创建公告结果的响应模型。 """ result_dict = await NoticeService.create_notice_service(auth=auth, data=data) - log.info(f"创建公告成功: {result_dict}") + await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(data=result_dict, msg="创建公告成功") @NoticeRouter.put( "/update/{id}", summary="修改公告", - description="修改公告", response_model=ResponseSchema[NoticeOutSchema], ) async def update_obj_controller( data: NoticeUpdateSchema, id: Annotated[int, Path(description="公告ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:notice:update']))], ) -> JSONResponse: """ 修改公告。 @@ -124,19 +127,18 @@ async def update_obj_controller( - JSONResponse: 包含修改公告结果的响应模型。 """ result_dict = await NoticeService.update_notice_service(auth=auth, id=id, data=data) - log.info(f"修改公告成功: {result_dict}") + await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(data=result_dict, msg="修改公告成功") @NoticeRouter.delete( "/delete", summary="删除公告", - description="删除公告", response_model=ResponseSchema[None], ) async def delete_obj_controller( ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:notice:delete']))], ) -> JSONResponse: """ 删除公告。 @@ -149,19 +151,18 @@ async def delete_obj_controller( - JSONResponse: 包含删除公告结果的响应模型。 """ await NoticeService.delete_notice_service(auth=auth, ids=ids) - log.info(f"删除公告成功: {ids}") + await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(msg="删除公告成功") @NoticeRouter.patch( "/status/batch", summary="批量修改公告状态", - description="批量修改公告状态", response_model=ResponseSchema[None], ) async def batch_set_available_obj_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:notice:patch']))], ) -> JSONResponse: """ 批量修改公告状态。 @@ -174,19 +175,18 @@ async def batch_set_available_obj_controller( - JSONResponse: 包含批量修改公告状态结果的响应模型。 """ await NoticeService.set_notice_available_service(auth=auth, data=data) - log.info(f"批量修改公告状态成功: {data.ids}") + await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(msg="批量修改公告状态成功") @NoticeRouter.get( "/export", summary="导出公告", - description="导出公告列表", response_model=ResponseSchema[None], ) async def export_obj_list_controller( search: Annotated[NoticeQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:export"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:notice:export']))], ) -> StreamingResponse: """ 导出公告。 @@ -199,8 +199,8 @@ async def export_obj_list_controller( - StreamingResponse: 包含导出公告的流式响应模型。 """ result_dict_list = await NoticeService.get_notice_list_service(search=search, auth=auth) - export_result = await NoticeService.export_notice_service(notice_list=result_dict_list) - log.info("导出公告成功") + export_data = [item.model_dump() for item in result_dict_list] + export_result = await NoticeService.export_notice_service(notice_list=export_data) return StreamResponse( data=bytes2file_response(export_result), @@ -212,9 +212,9 @@ async def export_obj_list_controller( @NoticeRouter.get( "/available", summary="获取全局启用公告", - description="获取全局启用公告", response_model=ResponseSchema[list[NoticeOutSchema]], ) +@cache(expire=120, namespace=_NOTICE_NS) async def get_obj_list_available_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], ) -> JSONResponse: @@ -228,18 +228,65 @@ async def get_obj_list_available_controller( - JSONResponse: 包含分页已启用公告详情的响应模型。 """ result_dict = await NoticeService.get_notice_available_page_service(auth=auth) - log.info("查询已启用公告列表成功") return SuccessResponse(data=result_dict, msg="查询已启用公告列表成功") @NoticeRouter.get( "/panel", summary="通知面板数据(铃铛)", - description="返回通知铃铛所需的全部数据:通知、消息、待办", + response_model=ResponseSchema[PanelDataOut], ) +@cache(expire=30, namespace=_NOTICE_NS) async def get_notification_panel_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], ) -> JSONResponse: """通知面板聚合接口,返回通知、消息、待办三个列表。""" result = await NoticeService.get_panel_data_service(auth=auth) return SuccessResponse(data=result, msg="获取面板数据成功") + +# ============ 已读追踪端点 ============ + + +@NoticeRouter.post( + "/read/{id}", + summary="标记已读", + response_model=ResponseSchema[None], +) +async def mark_read_controller( + id: Annotated[int, Path(description="通知ID")], + auth: Annotated[AuthSchema, Depends(get_current_user)], +) -> JSONResponse: + """标记已读。通过 `sys_notice_read` 表记录已读时间。""" + await NoticeService.mark_read_service(auth=auth, notice_id=id) + await FastAPICache.clear(namespace=_NOTICE_NS) + logger.info(f"用户[{auth.user.id}]标记通知[{id}]已读") + return SuccessResponse(msg="标记已读成功") + + +@NoticeRouter.post( + "/read-all", + summary="全部已读", + response_model=ResponseSchema[int], +) +async def mark_all_read_controller( + auth: Annotated[AuthSchema, Depends(get_current_user)], +) -> JSONResponse: + """全部标记已读。返回本次操作标记的数量。""" + count = await NoticeService.mark_all_read_service(auth=auth) + await FastAPICache.clear(namespace=_NOTICE_NS) + logger.info(f"用户[{auth.user.id}]全部已读, 数量={count}") + return SuccessResponse(data=count, msg=f"全部标记已读成功,共标记 {count} 条") + + +@NoticeRouter.get( + "/unread-count", + summary="获取未读数量", + response_model=ResponseSchema[int], +) +@cache(expire=15, namespace=_NOTICE_NS) +async def get_unread_count_controller( + auth: Annotated[AuthSchema, Depends(get_current_user)], +) -> JSONResponse: + """获取未读通知数量。通过 LEFT JOIN 统计未读数。""" + count = await NoticeService.get_unread_count_service(auth=auth) + return SuccessResponse(data=count, msg="获取未读数量成功") diff --git a/backend/app/api/v1/module_system/notice/crud.py b/backend/app/api/v1/module_system/notice/crud.py index 536cf3bf..24671771 100644 --- a/backend/app/api/v1/module_system/notice/crud.py +++ b/backend/app/api/v1/module_system/notice/crud.py @@ -1,7 +1,5 @@ -from collections.abc import Sequence - -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import NoticeModel from .schema import NoticeCreateSchema, NoticeUpdateSchema @@ -20,87 +18,4 @@ class NoticeCRUD(CRUDBase[NoticeModel, NoticeCreateSchema, NoticeUpdateSchema]): 返回: - None """ - self.auth = auth super().__init__(model=NoticeModel, auth=auth) - - async def get_by_id_crud(self, id: int, preload: list | None = None) -> NoticeModel | None: - """ - 根据ID获取公告详情。 - - 参数: - - id (int): 公告ID。 - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - NoticeModel | None: 公告模型实例。 - """ - return await self.get(id=id, preload=preload) - - async def get_list_crud( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list | None = None, - ) -> Sequence[NoticeModel]: - """ - 获取公告列表。 - - 参数: - - search (dict | None): 查询参数。 - - order_by (list[dict] | None): 排序参数。 - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[NoticeModel]: 公告模型实例列表。 - """ - return await self.list(search=search, order_by=order_by, preload=preload) - - async def create_crud(self, data: NoticeCreateSchema) -> NoticeModel | None: - """ - 创建公告。 - - 参数: - - data (NoticeCreateSchema): 公告创建模型。 - - 返回: - - NoticeModel | None: 公告模型实例。 - """ - return await self.create(data=data) - - async def update_crud(self, id: int, data: NoticeUpdateSchema) -> NoticeModel | None: - """ - 更新公告。 - - 参数: - - id (int): 公告ID。 - - data (NoticeUpdateSchema): 公告更新模型。 - - 返回: - - NoticeModel | None: 公告模型实例。 - """ - return await self.update(id=id, data=data) - - async def delete_crud(self, ids: list[int]) -> None: - """ - 删除公告。 - - 参数: - - ids (list[int]): 公告ID列表。 - - 返回: - - None - """ - return await self.delete(ids=ids) - - async def set_available_crud(self, ids: list[int], status: str) -> None: - """ - 设置公告的可用状态。 - - 参数: - - ids (list[int]): 公告ID列表。 - - status (str): 可用状态。 - - 返回: - - None - """ - return await self.set(ids=ids, status=status) diff --git a/backend/app/api/v1/module_system/notice/model.py b/backend/app/api/v1/module_system/notice/model.py index 672e7fe2..ac3ae425 100644 --- a/backend/app/api/v1/module_system/notice/model.py +++ b/backend/app/api/v1/module_system/notice/model.py @@ -1,7 +1,9 @@ -from sqlalchemy import String, Text -from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime -from app.core.base_model import ModelMixin, TenantMixin, UserMixin +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin class NoticeModel(ModelMixin, TenantMixin, UserMixin): @@ -18,3 +20,35 @@ class NoticeModel(ModelMixin, TenantMixin, UserMixin): String(1), nullable=False, comment="公告类型(1通知 2公告)" ) notice_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="公告内容") + + +class NoticeReadModel(MappedBase): + """ + 通知已读记录表 — 记录用户对公告的已读状态。 + + 设计说明: + - 不继承 TenantMixin:该表按 user_id 隔离,租户上下文由所属 notice 间接确定 + - (user_id, notice_id) 唯一约束 — 未建立记录即代表未读 + - 仅用于标记已读时间,不做其他业务用途 + - 不继承 ModelMixin:使用 (user_id, notice_id) 复合主键,无需自增 id 列 + (避免 SQLite 不支持复合主键列 autoincrement 的问题) + """ + + __tablename__: str = "sys_notice_read" + __table_args__: tuple = ( + UniqueConstraint("user_id", "notice_id", name="uq_user_notice_read"), + {"comment": "通知已读记录表"}, + ) + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("sys_user.id", ondelete="CASCADE"), primary_key=True, comment="用户ID" + ) + notice_id: Mapped[int] = mapped_column( + Integer, ForeignKey("sys_notice.id", ondelete="CASCADE"), primary_key=True, comment="通知ID" + ) + read_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=datetime.now, comment="已读时间" + ) + + # 关联 + notice: Mapped[NoticeModel] = relationship("NoticeModel", lazy="selectin") diff --git a/backend/app/api/v1/module_system/notice/schema.py b/backend/app/api/v1/module_system/notice/schema.py index 7ef0ba67..f447735e 100644 --- a/backend/app/api/v1/module_system/notice/schema.py +++ b/backend/app/api/v1/module_system/notice/schema.py @@ -18,8 +18,8 @@ class NoticeCreateSchema(BaseModel): notice_title: str = Field(..., min_length=1, max_length=64, description="公告标题") notice_type: str = Field(..., max_length=1, description="公告类型(1:通知 2:公告)") - notice_content: str = Field(..., max_length=65535, description="公告内容") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") + notice_content: str | None = Field(default=None, max_length=65535, description="公告内容") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") description: str | None = Field(default=None, max_length=255, description="描述") @field_validator("notice_type") @@ -31,21 +31,23 @@ class NoticeCreateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, value: str): - if value not in {"0", "1"}: + def _validate_status(cls, value: int): + if value not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return value @field_validator("notice_content") @classmethod - def _sanitize_notice_content(cls, value: str) -> str: + def _sanitize_notice_content(cls, value: str | None) -> str | None: + if value is None: + return value return sanitize_html(value) @model_validator(mode="after") def _validate_after(self): if not self.notice_title.strip(): raise ValueError("公告标题不能为空") - if not self.notice_content.strip(): + if self.notice_content and not self.notice_content.strip(): raise ValueError("公告内容不能为空") return self @@ -85,7 +87,7 @@ class NoticeQueryParam: # 模糊查询字段 self.notice_title = (QueueEnum.like.value, notice_title) # 精确查询字段 - self.notice_type = notice_type + self.notice_type = (QueueEnum.eq.value, notice_type) # 模糊查询字段 if description: self.description = (QueueEnum.like.value, description) @@ -105,3 +107,22 @@ class NoticeQueryParam: self.created_id = (QueueEnum.eq.value, created_id) if updated_id: self.updated_id = (QueueEnum.eq.value, updated_id) + + +# ─── 通知面板 ─── + + +class PanelMessageItem(BaseModel): + """面板-消息项""" + id: int + title: str + content: str + time: str + type: str + + +class PanelDataOut(BaseModel): + """通知面板聚合数据""" + notices: list[NoticeOutSchema] = [] + messages: list[PanelMessageItem] = [] + pendings: list[dict] = [] diff --git a/backend/app/api/v1/module_system/notice/service.py b/backend/app/api/v1/module_system/notice/service.py index 2d65a17b..54b5544a 100644 --- a/backend/app/api/v1/module_system/notice/service.py +++ b/backend/app/api/v1/module_system/notice/service.py @@ -1,15 +1,17 @@ -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.utils.excel_util import ExcelUtil from .crud import NoticeCRUD +from .model import NoticeModel, NoticeReadModel from .schema import ( NoticeCreateSchema, NoticeOutSchema, NoticeQueryParam, NoticeUpdateSchema, + PanelDataOut, + PanelMessageItem, ) @@ -19,7 +21,7 @@ class NoticeService: """ @classmethod - async def get_notice_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_notice_detail_service(cls, auth: AuthSchema, id: int) -> NoticeOutSchema: """ 获取公告详情。 @@ -30,25 +32,10 @@ class NoticeService: 返回: - Dict: 公告详情字典。 """ - notice_obj = await NoticeCRUD(auth).get_by_id_crud(id=id) - return NoticeOutSchema.model_validate(notice_obj).model_dump() - - @classmethod - async def get_notice_list_available_service(cls, auth: AuthSchema) -> list[dict]: - """ - 获取可用的公告列表。 - - 参数: - - auth (AuthSchema): 认证信息模型。 - - 返回: - - list[dict]: 可用公告详情字典列表。 - """ - notice_obj_list = await NoticeCRUD(auth).get_list_crud(search={"status": "0"}) - return [ - NoticeOutSchema.model_validate(notice_obj).model_dump() - for notice_obj in notice_obj_list - ] + notice_obj = await NoticeCRUD(auth).get(id=id) + if not notice_obj: + raise CustomException(msg="公告不存在") + return NoticeOutSchema.model_validate(notice_obj) @classmethod async def get_notice_list_service( @@ -56,7 +43,7 @@ class NoticeService: auth: AuthSchema, search: NoticeQueryParam | None = None, order_by: list[dict] | None = None, - ) -> list[dict]: + ) -> list[NoticeOutSchema]: """ 获取公告列表。 @@ -68,11 +55,11 @@ class NoticeService: 返回: - list[dict]: 公告详情字典列表。 """ - notice_obj_list = await NoticeCRUD(auth).get_list_crud( - search=search.__dict__, order_by=order_by + notice_obj_list = await NoticeCRUD(auth).list( + search=search.__dict__ if search else {}, order_by=order_by ) return [ - NoticeOutSchema.model_validate(notice_obj).model_dump() + NoticeOutSchema.model_validate(notice_obj) for notice_obj in notice_obj_list ] @@ -127,7 +114,7 @@ class NoticeService: ) @classmethod - async def create_notice_service(cls, auth: AuthSchema, data: NoticeCreateSchema) -> dict: + async def create_notice_service(cls, auth: AuthSchema, data: NoticeCreateSchema) -> NoticeOutSchema: """ 创建公告。 @@ -144,13 +131,13 @@ class NoticeService: notice = await NoticeCRUD(auth).get(notice_title=data.notice_title) if notice: raise CustomException(msg="创建失败,该公告通知已存在") - notice_obj = await NoticeCRUD(auth).create_crud(data=data) - return NoticeOutSchema.model_validate(notice_obj).model_dump() + notice_obj = await NoticeCRUD(auth).create(data=data) + return NoticeOutSchema.model_validate(notice_obj) @classmethod async def update_notice_service( cls, auth: AuthSchema, id: int, data: NoticeUpdateSchema - ) -> dict: + ) -> NoticeOutSchema: """ 更新公告。 @@ -165,14 +152,14 @@ class NoticeService: 异常: - CustomException: 更新失败,该公告通知不存在或公告通知标题重复。 """ - notice = await NoticeCRUD(auth).get_by_id_crud(id=id) + notice = await NoticeCRUD(auth).get(id=id) if not notice: raise CustomException(msg="更新失败,该公告通知不存在") exist_notice = await NoticeCRUD(auth).get(notice_title=data.notice_title) if exist_notice and exist_notice.id != id: raise CustomException(msg="更新失败,公告通知标题重复") - notice_obj = await NoticeCRUD(auth).update_crud(id=id, data=data) - return NoticeOutSchema.model_validate(notice_obj).model_dump() + notice_obj = await NoticeCRUD(auth).update(id=id, data=data) + return NoticeOutSchema.model_validate(notice_obj) @classmethod async def delete_notice_service(cls, auth: AuthSchema, ids: list[int]) -> None: @@ -191,11 +178,12 @@ class NoticeService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - for id in ids: - notice = await NoticeCRUD(auth).get_by_id_crud(id=id) - if not notice: + notices = await NoticeCRUD(auth).list(search={"id": ("in", ids)}) + notice_map = {n.id: n for n in notices} + for nid in ids: + if nid not in notice_map: raise CustomException(msg="删除失败,该公告通知不存在") - await NoticeCRUD(auth).delete_crud(ids=ids) + await NoticeCRUD(auth).delete(ids=ids) @classmethod async def set_notice_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: @@ -212,7 +200,7 @@ class NoticeService: 返回: - None """ - await NoticeCRUD(auth).set_available_crud(ids=data.ids, status=data.status) + await NoticeCRUD(auth).set(ids=data.ids, status=data.status) @classmethod async def export_notice_service(cls, notice_list: list[dict]) -> bytes: @@ -242,19 +230,14 @@ class NoticeService: data = notice_list.copy() for item in data: # 处理状态 - item["status"] = "启用" if item.get("status") == "0" else "停用" + item["status"] = "启用" if item.get("status") == 0 else "停用" # 处理公告类型 item["notice_type"] = "通知" if item.get("notice_type") == "1" else "公告" - item["creator"] = ( - item.get("creator", {}).get("name", "未知") - if isinstance(item.get("creator"), dict) - else "未知" - ) return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) @classmethod - async def get_latest_notices_service(cls, auth: AuthSchema, limit: int = 5) -> list[dict]: + async def get_latest_notices_service(cls, auth: AuthSchema, limit: int = 5) -> list[NoticeOutSchema]: """获取最新 N 条已启用公告""" from sqlalchemy import desc, select @@ -263,16 +246,101 @@ class NoticeService: stmt = ( select(NoticeModel) - .where(NoticeModel.status == "0") + .where(NoticeModel.status == 0) .order_by(desc(NoticeModel.created_time)) .limit(limit) ) result = await auth.db.execute(stmt) notices = result.scalars().all() - return [NoticeOutSchema.model_validate(n).model_dump() for n in notices] + return [NoticeOutSchema.model_validate(n) for n in notices] + + # ============ 已读追踪 ============ @classmethod - async def get_panel_data_service(cls, auth: AuthSchema) -> dict: + async def mark_read_service(cls, auth: AuthSchema, notice_id: int) -> None: + """标记通知为已读(幂等:已读的不重复写入)""" + from datetime import datetime + + from sqlalchemy import select + + # 检查公告是否存在 + notice = await NoticeCRUD(auth).get(id=notice_id) + if not notice: + raise CustomException(msg="该公告不存在") + + # 检查是否已读 + exist_stmt = select(NoticeReadModel).where( + NoticeReadModel.user_id == auth.user.id, + NoticeReadModel.notice_id == notice_id, + ) + result = await auth.db.execute(exist_stmt) + if result.scalar_one_or_none(): + return # 已读,幂等返回 + + read_record = NoticeReadModel( + user_id=auth.user.id, + notice_id=notice_id, + read_time=datetime.now(), + ) + auth.db.add(read_record) + await auth.db.flush() + + @classmethod + async def mark_all_read_service(cls, auth: AuthSchema) -> int: + """全部标记已读:将当前租户所有 status=0 且未读的公告批量标记为已读,返回操作数量""" + from datetime import datetime + + from sqlalchemy import select + + # 查询所有已启用但未读的公告 + read_ids_stmt = select(NoticeReadModel.notice_id).where( + NoticeReadModel.user_id == auth.user.id + ) + read_ids_result = await auth.db.execute(read_ids_stmt) + read_ids = {row[0] for row in read_ids_result.fetchall()} + + notices_stmt = select(NoticeModel.id).where(NoticeModel.status == 0) + notices_result = await auth.db.execute(notices_stmt) + all_ids = {row[0] for row in notices_result.fetchall()} + + unread_ids = all_ids - read_ids + if not unread_ids: + return 0 + + now = datetime.now() + from sqlalchemy import insert + + if unread_ids: + await auth.db.execute( + insert(NoticeReadModel), + [{"user_id": auth.user.id, "notice_id": nid, "read_time": now} for nid in unread_ids], + ) + await auth.db.flush() + return len(unread_ids) + + @classmethod + async def get_unread_count_service(cls, auth: AuthSchema) -> int: + """获取当前用户未读通知数量""" + from sqlalchemy import func, select + + # 所有已启用公告总数 + total_stmt = select(func.count()).select_from(NoticeModel).where( + NoticeModel.status == 0 + ) + total_result = await auth.db.execute(total_stmt) + total_count = total_result.scalar() or 0 + + # 已读数量 + read_stmt = select(func.count()).select_from(NoticeReadModel).where( + NoticeReadModel.user_id == auth.user.id + ) + read_result = await auth.db.execute(read_stmt) + read_count = read_result.scalar() or 0 + + return max(0, total_count - read_count) + + @classmethod + async def get_panel_data_service(cls, auth: AuthSchema) -> PanelDataOut: """聚合通知面板数据:通知 + 消息 + 待办""" from sqlalchemy import desc, select @@ -282,29 +350,29 @@ class NoticeService: # 2. 消息:最近的操作日志(作为系统消息) messages = [] try: - from app.api.v1.module_system.operationlog.model import OperationLogModel + from app.api.v1.module_system.log.model import OperationLogModel stmt = select(OperationLogModel).order_by(desc(OperationLogModel.created_time)).limit(5) result = await auth.db.execute(stmt) logs = result.scalars().all() for log_entry in logs: - messages.append({ - "id": log_entry.id, - "title": log_entry.oper_param or "系统操作", - "content": f"{log_entry.oper_user_name or '系统'} 执行了 {log_entry.title or '操作'}", - "time": log_entry.created_time.strftime("%Y-%m-%d %H:%M") + messages.append(PanelMessageItem( + id=log_entry.id, + title=log_entry.request_path or "系统操作", + content=f"{log_entry.request_method} {log_entry.request_path}", + time=log_entry.created_time.strftime("%Y-%m-%d %H:%M") if log_entry.created_time else "", - "type": "system", - }) - except Exception: - log.warning("获取面板消息数据失败(操作日志表可能不存在),已跳过") + type="system", + )) + except Exception as e: + logger.warning(f"获取面板消息数据失败(操作日志表可能不存在),已跳过: {e}") # 3. 待办:暂无数据源,返回空列表 pendings: list[dict] = [] - return { - "notices": notices, - "messages": messages, - "pendings": pendings, - } + return PanelDataOut( + notices=notices, + messages=messages, + pendings=pendings, + ) diff --git a/backend/app/api/v1/module_system/operationlog/__init__.py b/backend/app/api/v1/module_system/operationlog/__init__.py deleted file mode 100644 index b475e6f7..00000000 --- a/backend/app/api/v1/module_system/operationlog/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .controller import OperationLogRouter - -__all__ = ["OperationLogRouter"] diff --git a/backend/app/api/v1/module_system/operationlog/controller.py b/backend/app/api/v1/module_system/operationlog/controller.py deleted file mode 100644 index 14f175bc..00000000 --- a/backend/app/api/v1/module_system/operationlog/controller.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, Path - -from app.api.v1.module_system.auth.schema import AuthSchema -from app.common.response import ResponseSchema -from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchDelete -from app.core.dependencies import AuthPermission, get_current_user - -from .schema import ( - OperationLogCreateSchema, - OperationLogDetailOutSchema, - OperationLogQueryParam, -) -from .service import OperationLogService - -OperationLogRouter = APIRouter(prefix="/operationlog", tags=["操作日志"]) - - -@OperationLogRouter.get( - "/detail/{id}", - summary="获取操作日志详情", - description="根据ID获取操作日志详情", - response_model=ResponseSchema[OperationLogDetailOutSchema], - dependencies=[Depends(AuthPermission("module_system:operation_log:query"))], -) -async def detail( - *, - id: Annotated[int, Path(gt=0)], - auth: AuthSchema = Depends(get_current_user), -): - return await OperationLogService.detail_service(auth, id) - - -@OperationLogRouter.get( - "/list", - summary="获取操作日志列表", - description="分页获取操作日志列表", - response_model=ResponseSchema[dict], - dependencies=[Depends(AuthPermission("module_system:operation_log:query"))], -) -async def list( - *, - page: Annotated[PaginationQueryParam, Depends()], - search: OperationLogQueryParam, - auth: AuthSchema = Depends(get_current_user), -): - return await OperationLogService.page_service( - auth, - page.page, - page.page_size, - search.to_dict(), - [{"id": page.order_by}], - ) - - -@OperationLogRouter.post( - "/create", - summary="创建操作日志", - description="创建操作日志(由系统自动调用)", - response_model=ResponseSchema[OperationLogDetailOutSchema], -) -async def create( - *, - data: OperationLogCreateSchema, - auth: AuthSchema = Depends(get_current_user), -): - return await OperationLogService.create_service(auth, data) - - -@OperationLogRouter.delete( - "/delete", - summary="删除操作日志", - description="批量删除操作日志", - response_model=ResponseSchema, - dependencies=[Depends(AuthPermission("module_system:operation_log:delete"))], -) -async def delete( - *, - data: BatchDelete, - auth: AuthSchema = Depends(get_current_user), -): - await OperationLogService.delete_service(auth, data.ids) - return ResponseSchema() diff --git a/backend/app/api/v1/module_system/operationlog/crud.py b/backend/app/api/v1/module_system/operationlog/crud.py deleted file mode 100644 index 496b144f..00000000 --- a/backend/app/api/v1/module_system/operationlog/crud.py +++ /dev/null @@ -1,11 +0,0 @@ -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_crud import CRUDBase - -from .model import OperationLogModel - - -class OperationLogCRUD(CRUDBase[OperationLogModel, None, None]): - """操作日志 CRUD""" - - def __init__(self, auth: AuthSchema): - super().__init__(OperationLogModel, auth) diff --git a/backend/app/api/v1/module_system/operationlog/schema.py b/backend/app/api/v1/module_system/operationlog/schema.py deleted file mode 100644 index d9d08b55..00000000 --- a/backend/app/api/v1/module_system/operationlog/schema.py +++ /dev/null @@ -1,63 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field, field_validator - -ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] - - -class OperationLogQueryParam(BaseModel): - request_path: str | None = Field(None, max_length=255, description="请求路径") - request_method: str | None = Field(None, description="请求方式") - username: str | None = Field(None, max_length=64, description="用户名") - - @field_validator("request_method") - @classmethod - def validate_request_method(cls, value: str | None) -> str | None: - if value and value.upper() not in ALLOWED_REQUEST_METHODS: - raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}") - return value.upper() if value else None - - def to_dict(self) -> dict | None: - """转换为字典,仅包含非空字段""" - return self.model_dump(exclude_none=True) - - -class OperationLogOutSchema(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - username: str - tenant_id: int - request_path: str - request_method: str - request_ip: str | None = None - request_os: str | None = None - request_browser: str | None = None - response_code: int - process_time: str | None = None - created_at: datetime - - -class OperationLogDetailOutSchema(OperationLogOutSchema): - request_payload: str | None = None - response_json: str | None = None - - -class OperationLogCreateSchema(BaseModel): - request_path: str = Field(..., min_length=1, max_length=255, description="请求路径") - request_method: str = Field(..., description="请求方式") - request_payload: str | None = Field(None, description="请求体") - request_ip: str | None = Field(None, max_length=50, description="请求IP地址") - request_os: str | None = Field(None, max_length=64, description="操作系统") - request_browser: str | None = Field(None, max_length=64, description="浏览器") - response_code: int = Field(200, ge=100, le=599, description="响应状态码") - response_json: str | None = Field(None, description="响应体") - process_time: str | None = Field(None, max_length=20, description="处理时间") - - @field_validator("request_method") - @classmethod - def validate_request_method(cls, value: str) -> str: - upper_value = value.upper() - if upper_value not in ALLOWED_REQUEST_METHODS: - raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}") - return upper_value diff --git a/backend/app/api/v1/module_system/operationlog/service.py b/backend/app/api/v1/module_system/operationlog/service.py deleted file mode 100644 index 123880bd..00000000 --- a/backend/app/api/v1/module_system/operationlog/service.py +++ /dev/null @@ -1,59 +0,0 @@ -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.v1.module_system.auth.schema import AuthSchema -from app.api.v1.module_system.operationlog.crud import OperationLogCRUD -from app.api.v1.module_system.operationlog.schema import ( - OperationLogCreateSchema, - OperationLogDetailOutSchema, - OperationLogOutSchema, -) -from app.core.logger import log - - -class OperationLogService: - @staticmethod - async def create_service(auth: AuthSchema, data: OperationLogCreateSchema) -> OperationLogDetailOutSchema: - crud = OperationLogCRUD(auth) - obj = await crud.create(data.model_dump()) - return OperationLogDetailOutSchema.model_validate(obj) - - @staticmethod - async def page_service( - auth: AuthSchema, - page: int, - page_size: int, - search: dict | None = None, - order_by: list[dict[str, str]] | None = None, - ) -> dict: - crud = OperationLogCRUD(auth) - - # 构建过滤条件 - filters = {} - if search: - if search.get("request_path"): - filters["request_path"] = search["request_path"] - if search.get("request_method"): - filters["request_method"] = search["request_method"] - if search.get("username"): - filters["username"] = search["username"] - - result = await crud.page( - offset=(page - 1) * page_size, - limit=page_size, - order_by=order_by or [{"id": "desc"}], - search=filters, - out_schema=OperationLogOutSchema, - ) - return result - - @staticmethod - async def detail_service(auth: AuthSchema, id: int) -> OperationLogDetailOutSchema: - crud = OperationLogCRUD(auth) - obj = await crud.get(id=id) - return OperationLogDetailOutSchema.model_validate(obj) - - @staticmethod - async def delete_service(auth: AuthSchema, ids: list[int]) -> None: - crud = OperationLogCRUD(auth) - await crud.delete(ids) - log.info(f"删除操作日志成功, ids={ids}") diff --git a/backend/app/api/v1/module_system/params/controller.py b/backend/app/api/v1/module_system/params/controller.py index 1395ca4d..ca5bad0e 100644 --- a/backend/app/api/v1/module_system/params/controller.py +++ b/backend/app/api/v1/module_system/params/controller.py @@ -4,29 +4,27 @@ from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse from redis.asyncio.client import Redis -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, StreamResponse, SuccessResponse from app.core.base_params import PaginationQueryParam +from app.core.base_schema import AuthSchema, PageResultSchema from app.core.dependencies import AuthPermission, redis_getter -from app.core.logger import log from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response from .schema import ParamsCreateSchema, ParamsOutSchema, ParamsQueryParam, ParamsUpdateSchema from .service import ParamsService -ParamsRouter = APIRouter(route_class=OperationLogRoute, prefix="/param", tags=["参数管理"]) +ParamsRouter = APIRouter(route_class=OperationLogRoute, prefix="/param", tags=["系统管理/参数管理"]) @ParamsRouter.get( "/detail/{id}", summary="获取参数详情", - description="获取参数详情", response_model=ResponseSchema[ParamsOutSchema], ) async def get_type_detail_controller( id: Annotated[int, Path(description="参数ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:detail']))], ) -> JSONResponse: """ 获取参数详情 @@ -39,19 +37,17 @@ async def get_type_detail_controller( - JSONResponse: 包含参数详情的 JSON 响应 """ result_dict = await ParamsService.get_obj_detail_service(id=id, auth=auth) - log.info(f"获取参数详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取参数详情成功") @ParamsRouter.get( "/key/{config_key}", summary="根据配置键获取参数详情", - description="根据配置键获取参数详情", response_model=ResponseSchema[ParamsOutSchema], ) async def get_obj_by_key_controller( config_key: Annotated[str, Path(description="配置键")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:query']))], ) -> JSONResponse: """ 根据配置键获取参数详情 @@ -64,19 +60,17 @@ async def get_obj_by_key_controller( - JSONResponse: 包含参数详情的 JSON 响应 """ result_dict = await ParamsService.get_obj_by_key_service(config_key=config_key, auth=auth) - log.info(f"根据配置键获取参数详情成功 {config_key}") return SuccessResponse(data=result_dict, msg="根据配置键获取参数详情成功") @ParamsRouter.get( "/value/{config_key}", summary="根据配置键获取参数值", - description="根据配置键获取参数值", response_model=ResponseSchema[ParamsOutSchema], ) async def get_config_value_by_key_controller( config_key: Annotated[str, Path(description="配置键")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:query']))], ) -> JSONResponse: """ 根据配置键获取参数值 @@ -91,18 +85,16 @@ async def get_config_value_by_key_controller( result_value = await ParamsService.get_config_value_by_key_service( config_key=config_key, auth=auth ) - log.info(f"根据配置键获取参数值成功 {config_key}") return SuccessResponse(data=result_value, msg="根据配置键获取参数值成功") @ParamsRouter.get( "/list", summary="获取参数列表", - description="获取参数列表", - response_model=ResponseSchema[list[ParamsOutSchema]], + response_model=ResponseSchema[PageResultSchema[ParamsOutSchema]], ) async def get_obj_list_controller( - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:query']))], page: Annotated[PaginationQueryParam, Depends()], search: Annotated[ParamsQueryParam, Depends()], ) -> JSONResponse: @@ -124,20 +116,18 @@ async def get_obj_list_controller( search=search, order_by=page.order_by, ) - log.info("获取参数列表成功") return SuccessResponse(data=result_dict, msg="查询参数列表成功") @ParamsRouter.post( "/create", summary="创建参数", - description="创建参数", response_model=ResponseSchema[ParamsOutSchema], ) async def create_obj_controller( data: ParamsCreateSchema, redis: Annotated[Redis, Depends(redis_getter)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:create']))], ) -> JSONResponse: """ 创建参数 @@ -151,21 +141,19 @@ async def create_obj_controller( - JSONResponse: 包含创建参数结果的 JSON 响应 """ result_dict = await ParamsService.create_obj_service(auth=auth, redis=redis, data=data) - log.info(f"创建参数成功: {result_dict}") return SuccessResponse(data=result_dict, msg="创建参数成功") @ParamsRouter.put( "/update/{id}", summary="修改参数", - description="修改参数", response_model=ResponseSchema[ParamsOutSchema], ) async def update_objs_controller( data: ParamsUpdateSchema, id: Annotated[int, Path(description="参数ID")], redis: Annotated[Redis, Depends(redis_getter)], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:update']))], ) -> JSONResponse: """ 修改参数 @@ -180,20 +168,18 @@ async def update_objs_controller( - JSONResponse: 包含修改参数结果的 JSON 响应 """ result_dict = await ParamsService.update_obj_service(auth=auth, redis=redis, id=id, data=data) - log.info(f"更新参数成功 {result_dict}") return SuccessResponse(data=result_dict, msg="更新参数成功") @ParamsRouter.delete( "/delete", summary="删除参数", - description="删除参数", response_model=ResponseSchema[ParamsOutSchema], ) async def delete_obj_controller( redis: Annotated[Redis, Depends(redis_getter)], ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:delete']))], ) -> JSONResponse: """ 删除参数 @@ -207,20 +193,18 @@ async def delete_obj_controller( - JSONResponse: 包含删除参数结果的 JSON 响应 """ await ParamsService.delete_obj_service(auth=auth, redis=redis, ids=ids) - log.info(f"删除参数成功: {ids}") return SuccessResponse(msg="删除参数成功") @ParamsRouter.patch( "/status/batch", summary="批量设置参数状态", - description="批量设置参数状态", response_model=ResponseSchema, ) async def batch_set_status_controller( ids: Annotated[list[int], Body(description="参数ID列表")], status: Annotated[str, Body(description="状态值")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:patch']))], ) -> JSONResponse: """ 批量设置参数状态 @@ -234,19 +218,17 @@ async def batch_set_status_controller( - JSONResponse: 包含批量设置参数状态结果的 JSON 响应 """ await ParamsService.batch_set_status_service(auth=auth, ids=ids, status=status) - log.info(f"批量设置参数状态成功: ids={ids}, status={status}") return SuccessResponse(msg="批量设置参数状态成功") @ParamsRouter.get( "/export", summary="导出参数", - description="导出参数列表", response_model=ResponseSchema[None], ) async def export_obj_list_controller( search: Annotated[ParamsQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:export"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:param:export']))], ) -> StreamingResponse: """ 导出参数 @@ -259,8 +241,8 @@ async def export_obj_list_controller( - StreamingResponse: 包含导出参数的 Excel 文件流响应 """ result_dict_list = await ParamsService.get_obj_list_service(search=search, auth=auth) - export_result = await ParamsService.export_obj_service(data_list=result_dict_list) - log.info("导出参数成功") + export_data = [item.model_dump() for item in result_dict_list] + export_result = await ParamsService.export_obj_service(data_list=export_data) return StreamResponse( data=bytes2file_response(export_result), @@ -272,7 +254,6 @@ async def export_obj_list_controller( @ParamsRouter.get( "/info", summary="获取初始化缓存参数", - description="获取初始化缓存参数", response_model=ResponseSchema[list[ParamsOutSchema]], ) async def get_init_obj_controller( @@ -288,5 +269,4 @@ async def get_init_obj_controller( - JSONResponse: 获取初始化缓存参数的 JSON 响应 """ result_dict = await ParamsService.get_init_config_service(redis=redis, tenant_id=1) - log.info(f"获取初始化缓存参数成功 {result_dict}") return SuccessResponse(data=result_dict, msg="获取初始化缓存参数成功") diff --git a/backend/app/api/v1/module_system/params/crud.py b/backend/app/api/v1/module_system/params/crud.py index 4ac1e947..4a38cad2 100644 --- a/backend/app/api/v1/module_system/params/crud.py +++ b/backend/app/api/v1/module_system/params/crud.py @@ -1,7 +1,5 @@ -from collections.abc import Sequence - -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import ParamsModel from .schema import ParamsCreateSchema, ParamsUpdateSchema @@ -20,89 +18,4 @@ class ParamsCRUD(CRUDBase[ParamsModel, ParamsCreateSchema, ParamsUpdateSchema]): 返回: - None """ - self.auth = auth super().__init__(model=ParamsModel, auth=auth) - - async def get_obj_by_id_crud(self, id: int, preload: list | None = None) -> ParamsModel | None: - """ - 获取配置管理型详情 - - 参数: - - id (int): 配置管理型ID - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - ParamsModel | None: 配置管理型模型实例 - """ - return await self.get(id=id, preload=preload) - - async def get_obj_by_key_crud( - self, key: str, preload: list | None = None - ) -> ParamsModel | None: - """ - 根据key获取配置管理型详情 - - 参数: - - key (str): 配置管理型key - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - ParamsModel | None: 配置管理型模型实例 - """ - return await self.get(config_key=key, preload=preload) - - async def get_obj_list_crud( - self, - search: dict | None = None, - order_by: list | None = None, - preload: list | None = None, - ) -> Sequence[ParamsModel]: - """ - 获取配置管理型列表 - - 参数: - - search (dict | None): 查询参数对象。 - - order_by (list | None): 排序参数列表。 - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[ParamsModel]: 配置管理型模型实例列表 - """ - return await self.list(search=search, order_by=order_by, preload=preload) - - async def create_obj_crud(self, data: ParamsCreateSchema) -> ParamsModel | None: - """ - 创建配置管理型 - - 参数: - - data (ParamsCreateSchema): 创建配置管理型负载模型 - - 返回: - - ParamsModel | None: 配置管理型模型实例 - """ - return await self.create(data=data) - - async def update_obj_crud(self, id: int, data: ParamsUpdateSchema) -> ParamsModel | None: - """ - 更新配置管理型 - - 参数: - - id (int): 配置管理型ID - - data (ParamsUpdateSchema): 更新配置管理型负载模型 - - 返回: - - ParamsModel | None: 配置管理型模型实例 - """ - return await self.update(id=id, data=data) - - async def delete_obj_crud(self, ids: list[int]) -> None: - """ - 删除配置管理型 - - 参数: - - ids (list[int]): 配置管理型ID列表 - - 返回: - - None - """ - return await self.delete(ids=ids) diff --git a/backend/app/api/v1/module_system/params/schema.py b/backend/app/api/v1/module_system/params/schema.py index 28cfa5b6..e6b4ab5b 100644 --- a/backend/app/api/v1/module_system/params/schema.py +++ b/backend/app/api/v1/module_system/params/schema.py @@ -13,7 +13,7 @@ class ParamsCreateSchema(BaseModel): config_key: str = Field(..., min_length=1, max_length=500, description="参数键名") config_value: str | None = Field(default=None, max_length=500, description="参数键值") config_type: bool = Field(default=False, description="是否系统内置") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:停用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:停用)") description: str | None = Field(default=None, max_length=500, description="描述") @field_validator("config_key") @@ -27,8 +27,8 @@ class ParamsCreateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, v: str) -> str: - if v not in {"0", "1"}: + def _validate_status(cls, v: int) -> int: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(停用)") return v @@ -64,12 +64,11 @@ class ParamsQueryParam: examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], ), ) -> None: - # 模糊查询字段 # 模糊查询字段 self.config_name = (QueueEnum.like.value, config_name) self.config_key = (QueueEnum.like.value, config_key) # 精确查询字段 - self.config_type = config_type + self.config_type = (QueueEnum.eq.value, config_type) if description: self.description = (QueueEnum.like.value, description) diff --git a/backend/app/api/v1/module_system/params/service.py b/backend/app/api/v1/module_system/params/service.py index 76a41716..74005b6c 100644 --- a/backend/app/api/v1/module_system/params/service.py +++ b/backend/app/api/v1/module_system/params/service.py @@ -1,12 +1,13 @@ import json +import time from redis.asyncio.client import Redis -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.enums import RedisInitKeyConfig +from app.core.base_schema import AuthSchema from app.core.database import async_db_session from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.utils.excel_util import ExcelUtil @@ -18,6 +19,10 @@ from .schema import ( ParamsUpdateSchema, ) +# 中间件系统配置内存缓存(避免每请求查 Redis) +_MID_CONFIG_TTL: float = 60.0 # 缓存 60 秒 +_mid_config_cache: dict = {"ts": 0.0, "data": None} + class ParamsService: """ @@ -25,7 +30,7 @@ class ParamsService: """ @classmethod - async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> ParamsOutSchema: """ 获取配置详情 @@ -36,11 +41,13 @@ class ParamsService: 返回: - dict: 配置管理型模型实例字典表示 """ - obj = await ParamsCRUD(auth).get_obj_by_id_crud(id=id) - return ParamsOutSchema.model_validate(obj).model_dump() + obj = await ParamsCRUD(auth).get(id=id) + if not obj: + raise CustomException(msg="参数不存在") + return ParamsOutSchema.model_validate(obj) @classmethod - async def get_obj_by_key_service(cls, auth: AuthSchema, config_key: str) -> dict: + async def get_obj_by_key_service(cls, auth: AuthSchema, config_key: str) -> ParamsOutSchema: """ 根据配置键获取配置详情 @@ -51,10 +58,10 @@ class ParamsService: 返回: - Dict: 配置管理型模型实例字典表示 """ - obj = await ParamsCRUD(auth).get_obj_by_key_crud(key=config_key) + obj = await ParamsCRUD(auth).get(config_key=config_key) if not obj: raise CustomException(msg=f"配置键 {config_key} 不存在") - return ParamsOutSchema.model_validate(obj).model_dump() + return ParamsOutSchema.model_validate(obj) @classmethod async def get_config_value_by_key_service(cls, auth: AuthSchema, config_key: str) -> str | None: @@ -68,7 +75,7 @@ class ParamsService: 返回: - str | None: 配置值字符串或None """ - obj = await ParamsCRUD(auth).get_obj_by_key_crud(key=config_key) + obj = await ParamsCRUD(auth).get(config_key=config_key) if not obj: raise CustomException(msg=f"配置键 {config_key} 不存在") return obj.config_value @@ -79,7 +86,7 @@ class ParamsService: auth: AuthSchema, search: ParamsQueryParam | None = None, order_by: list[dict] | None = None, - ) -> list[dict]: + ) -> list[ParamsOutSchema]: """ 获取配置管理型列表 @@ -89,16 +96,12 @@ class ParamsService: - order_by (list[dict] | None): 排序参数列表 返回: - - list[dict]: 配置管理型模型实例字典列表表示 + - list[ParamsOutSchema]: 配置管理型模型实例 """ - obj_list = None - if search: - obj_list = await ParamsCRUD(auth).get_obj_list_crud( - search=search.__dict__, order_by=order_by - ) - else: - obj_list = await ParamsCRUD(auth).get_obj_list_crud() - return [ParamsOutSchema.model_validate(obj).model_dump() for obj in obj_list] + obj_list = await ParamsCRUD(auth).list( + search=search.__dict__ if search else {}, order_by=order_by + ) + return [ParamsOutSchema.model_validate(obj) for obj in obj_list] @classmethod async def get_obj_page_service( @@ -134,7 +137,7 @@ class ParamsService: @classmethod async def create_obj_service( cls, auth: AuthSchema, redis: Redis, data: ParamsCreateSchema - ) -> dict: + ) -> ParamsOutSchema: """ 创建配置管理型 @@ -149,33 +152,35 @@ class ParamsService: exist_obj = await ParamsCRUD(auth).get(config_key=data.config_key) if exist_obj: raise CustomException(msg="创建失败,该配置key已存在") - obj = await ParamsCRUD(auth).create_obj_crud(data=data) + obj = await ParamsCRUD(auth).create(data=data) - new_obj_dict = ParamsOutSchema.model_validate(obj).model_dump() + out = ParamsOutSchema.model_validate(obj) # 同步redis redis_key = ( f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{data.config_key}" ) try: + redis_payload = out.model_dump(mode="json") + value = json.dumps(redis_payload, ensure_ascii=False) result = await RedisCURD(redis).set( key=redis_key, - value="", + value=value, expire=None, ) if not result: - log.error(f"同步配置到缓存失败: {new_obj_dict}") + logger.error(f"同步配置到缓存失败: {out}") raise CustomException(msg="同步配置到缓存失败") except Exception as e: - log.error(f"创建字典类型失败: {e}") + logger.error(f"创建字典类型失败: {e}") raise CustomException(msg=f"创建字典类型失败 {e}") - return new_obj_dict + return out @classmethod async def update_obj_service( cls, auth: AuthSchema, redis: Redis, id: int, data: ParamsUpdateSchema - ) -> dict: + ) -> ParamsOutSchema: """ 更新配置管理型 @@ -188,17 +193,16 @@ class ParamsService: 返回: - Dict: 更新后的配置管理型模型实例字典表示 """ - exist_obj = await ParamsCRUD(auth).get_obj_by_id_crud(id=id) + exist_obj = await ParamsCRUD(auth).get(id=id) if not exist_obj: raise CustomException(msg="更新失败,该数系统配置不存在") if exist_obj.config_key != data.config_key: raise CustomException(msg="更新失败,系统配置key不允许修改") - new_obj = await ParamsCRUD(auth).update_obj_crud(id=id, data=data) + new_obj = await ParamsCRUD(auth).update(id=id, data=data) if not new_obj: raise CustomException(msg="更新失败,系统配置不存在") out = ParamsOutSchema.model_validate(new_obj) - new_obj_dict = out.model_dump() redis_payload = out.model_dump(mode="json") # 同步redis @@ -213,13 +217,13 @@ class ParamsService: expire=None, ) if not result: - log.error(f"同步配置到缓存失败: {new_obj_dict}") + logger.error(f"同步配置到缓存失败: {out}") raise CustomException(msg="同步配置到缓存失败") except Exception as e: - log.error(f"更新系统配置失败: {e}") + logger.error(f"更新系统配置失败: {e}") raise CustomException(msg="更新系统配置失败") - return new_obj_dict + return out @classmethod async def delete_obj_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: @@ -236,30 +240,27 @@ class ParamsService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - for id in ids: - exist_obj = await ParamsCRUD(auth).get_obj_by_id_crud(id=id) - if not exist_obj: + # 批量校验参数存在性 + objs = await ParamsCRUD(auth).list(search={"id": ("in", ids)}) + obj_map = {o.id: o for o in objs} + for pid in ids: + obj = obj_map.get(pid) + if not obj: raise CustomException(msg="删除失败,该数据字典类型不存在") - # 检查是否是否初始化类型 - if exist_obj.config_type: - # 如果有字典数据,不能删除 + if obj.config_type: raise CustomException( - msg=f"{exist_obj.config_name} 删除失败,系统初始化配置不可以删除" + msg=f"{obj.config_name} 删除失败,系统初始化配置不可以删除" ) - await ParamsCRUD(auth).delete_obj_crud(ids=ids) + await ParamsCRUD(auth).delete(ids=ids) - # 同步删除Redis缓存 - for id in ids: - exist_obj = await ParamsCRUD(auth).get_obj_by_id_crud(id=id) - if not exist_obj: - continue - redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{exist_obj.config_key}" + # 同步删除Redis缓存(使用删除前已获取的对象信息) + for obj in objs: + redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{obj.config_key}" try: await RedisCURD(redis).delete(redis_key) - log.info(f"删除系统配置成功: {id}") except Exception as e: - log.error(f"删除系统配置失败: {e}") + logger.error(f"删除系统配置失败: {e}") raise CustomException(msg="删除字典类型失败") @classmethod @@ -277,12 +278,8 @@ class ParamsService: """ if not ids: raise CustomException(msg="请选择要操作的数据") - - await ParamsCRUD(auth).update_obj_crud( - ids=ids, - data={"status": status}, - ) - log.info(f"批量设置系统参数状态成功: ids={ids}, status={status}") + + await ParamsCRUD(auth).set(ids=ids, status=status) @classmethod async def export_obj_service(cls, data_list: list[dict]) -> bytes: @@ -313,11 +310,6 @@ class ParamsService: for item in data: # 处理状态 item["config_type"] = "是" if item.get("config_type") else "否" - item["creator"] = ( - item.get("creator", {}).get("name", "未知") - if isinstance(item.get("creator"), dict) - else "未知" - ) return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) @@ -335,7 +327,7 @@ class ParamsService: async with async_db_session() as session: async with session.begin(): auth = AuthSchema(db=session, check_data_scope=False) - config_obj = await ParamsCRUD(auth).get_obj_list_crud() + config_obj = await ParamsCRUD(auth).list() if not config_obj: raise CustomException(msg="系统配置不存在") try: @@ -343,7 +335,6 @@ class ParamsService: tenant_id = config.tenant_id redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:{config.config_key}" out = ParamsOutSchema.model_validate(config) - config_obj_dict = out.model_dump() redis_payload = out.model_dump(mode="json") value = json.dumps(redis_payload, ensure_ascii=False) result = await RedisCURD(redis).set( @@ -352,10 +343,10 @@ class ParamsService: expire=None, ) if not result: - log.error(f"❌️ 初始化系统配置失败: {config_obj_dict}") + logger.error(f"❌️ 初始化系统配置失败: {redis_key}") raise CustomException(msg="初始化系统配置失败") except Exception as e: - log.error(f"❌️ 初始化系统配置失败: {e}") + logger.error(f"❌️ 初始化系统配置失败: {e}") raise CustomException(msg="初始化系统配置失败") @classmethod @@ -382,18 +373,17 @@ class ParamsService: new_config = json.loads(config) configs.append(new_config) except Exception as e: - log.error(f"解析系统配置数据失败: {e}") + logger.error(f"解析系统配置数据失败: {e}") continue # 如果 Redis 中没有数据,从数据库中加载并缓存 if not configs: - log.info("Redis 中没有系统配置数据,从数据库中加载") async with async_db_session() as session: async with session.begin(): - from app.api.v1.module_system.auth.schema import AuthSchema + from app.core.base_schema import AuthSchema auth = AuthSchema(db=session, check_data_scope=False) - config_obj = await ParamsCRUD(auth).get_obj_list_crud() + config_obj = await ParamsCRUD(auth).list() if config_obj: try: for config in config_obj: @@ -408,18 +398,17 @@ class ParamsService: expire=None, ) if not result: - log.error(f"❌️ 缓存系统配置失败: {config_obj_dict}") + logger.error(f"❌️ 缓存系统配置失败: {config_obj_dict}") configs.append(config_obj_dict) - log.info(f"✅️ 已从数据库加载 {len(configs)} 条系统配置到缓存") except Exception as e: - log.error(f"❌️ 加载系统配置失败: {e}") + logger.error(f"❌️ 加载系统配置失败: {e}") return configs @classmethod async def get_system_config_for_middleware(cls, redis: Redis) -> dict: """ - 获取中间件所需的系统配置 + 获取中间件所需的系统配置(带 60 秒内存缓存,避免每请求查 Redis)。 参数: - redis (Redis): Redis 客户端实例 @@ -427,6 +416,17 @@ class ParamsService: 返回: - dict: 包含演示模式、IP白名单、API白名单和IP黑名单的配置字典 """ + now = time.monotonic() + if _mid_config_cache["data"] and now - _mid_config_cache["ts"] < _MID_CONFIG_TTL: + return _mid_config_cache["data"] + + config_result = await cls._fetch_system_config_for_middleware(redis) + _mid_config_cache["data"] = config_result + _mid_config_cache["ts"] = now + return config_result + + @classmethod + async def _fetch_system_config_for_middleware(cls, redis: Redis) -> dict: # 定义需要获取的配置键 config_keys = [ f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:demo_enable", @@ -456,7 +456,7 @@ class ParamsService: else False ) except json.JSONDecodeError: - log.error("解析演示模式配置失败") + logger.error("解析演示模式配置失败") # 解析IP白名单配置 if config_values[1]: @@ -465,7 +465,7 @@ class ParamsService: # 确保是列表类型 config_result["ip_white_list"] = json.loads(ip_white_config.get("config_value", [])) except json.JSONDecodeError: - log.error("解析IP白名单配置失败") + logger.error("解析IP白名单配置失败") # 解析IP黑名单 # 解析API路径白名单 if config_values[2]: @@ -476,7 +476,7 @@ class ParamsService: white_api_config.get("config_value", []) ) except json.JSONDecodeError: - log.error("解析API白名单配置失败") + logger.error("解析API白名单配置失败") # 解析IP黑名单 if config_values[3]: @@ -485,5 +485,5 @@ class ParamsService: # 确保是列表类型 config_result["ip_black_list"] = json.loads(black_ip_config.get("config_value", [])) except json.JSONDecodeError: - log.error("解析IP黑名单配置失败") + logger.error("解析IP黑名单配置失败") return config_result diff --git a/backend/app/api/v1/module_system/position/controller.py b/backend/app/api/v1/module_system/position/controller.py index d7089b54..ba7382b7 100644 --- a/backend/app/api/v1/module_system/position/controller.py +++ b/backend/app/api/v1/module_system/position/controller.py @@ -2,13 +2,13 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, StreamResponse, SuccessResponse from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -20,19 +20,21 @@ from .schema import ( ) from .service import PositionService -PositionRouter = APIRouter(route_class=OperationLogRoute, prefix="/position", tags=["岗位管理"]) +PositionRouter = APIRouter(route_class=OperationLogRoute, prefix="/position", tags=["系统管理/岗位管理"]) + +_POS_NS = "position" @PositionRouter.get( "/list", summary="查询岗位", - description="查询岗位", - response_model=ResponseSchema[list[PositionOutSchema]], + response_model=ResponseSchema[PageResultSchema[PositionOutSchema]], ) +@cache(expire=300, namespace=_POS_NS) async def get_obj_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[PositionQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:position:query']))], ) -> JSONResponse: """ 查询岗位列表 @@ -55,19 +57,17 @@ async def get_obj_list_controller( search=search, order_by=order_by, ) - log.info("查询岗位列表成功") return SuccessResponse(data=result_dict, msg="查询岗位列表成功") @PositionRouter.get( "/detail/{id}", summary="查询岗位详情", - description="查询岗位详情", response_model=ResponseSchema[PositionOutSchema], ) async def get_obj_detail_controller( id: Annotated[int, Path(description="岗位ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:position:detail']))], ) -> JSONResponse: """ 查询岗位详情 @@ -80,19 +80,17 @@ async def get_obj_detail_controller( - JSONResponse: 岗位详情对象 """ result_dict = await PositionService.get_position_detail_service(id=id, auth=auth) - log.info(f"查询岗位详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取岗位详情成功") @PositionRouter.post( "/create", summary="创建岗位", - description="创建岗位", response_model=ResponseSchema[PositionOutSchema], ) async def create_obj_controller( data: PositionCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:position:create']))], ) -> JSONResponse: """ 创建岗位 @@ -105,20 +103,19 @@ async def create_obj_controller( - JSONResponse: 岗位详情对象 """ result_dict = await PositionService.create_position_service(data=data, auth=auth) - log.info(f"创建岗位成功: {result_dict}") + await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(data=result_dict, msg="创建岗位成功") @PositionRouter.put( "/update/{id}", summary="修改岗位", - description="修改岗位", response_model=ResponseSchema[PositionOutSchema], ) async def update_obj_controller( data: PositionUpdateSchema, id: Annotated[int, Path(description="岗位ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:position:update']))], ) -> JSONResponse: """ 修改岗位 @@ -132,19 +129,18 @@ async def update_obj_controller( - JSONResponse: 岗位详情对象 """ result_dict = await PositionService.update_position_service(id=id, data=data, auth=auth) - log.info(f"修改岗位成功: {result_dict}") + await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(data=result_dict, msg="修改岗位成功") @PositionRouter.delete( "/delete", summary="删除岗位", - description="删除岗位", response_model=ResponseSchema[None], ) async def delete_obj_controller( ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:position:delete']))], ) -> JSONResponse: """ 删除岗位 @@ -157,19 +153,18 @@ async def delete_obj_controller( - JSONResponse: 成功消息 """ await PositionService.delete_position_service(ids=ids, auth=auth) - log.info(f"删除岗位成功: {ids}") + await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(msg="删除岗位成功") @PositionRouter.patch( "/status/batch", summary="批量修改岗位状态", - description="批量修改岗位状态", response_model=ResponseSchema[None], ) async def batch_set_available_obj_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:position:patch']))], ) -> JSONResponse: """ 批量修改岗位状态 @@ -182,19 +177,18 @@ async def batch_set_available_obj_controller( - JSONResponse: 成功消息 """ await PositionService.set_position_available_service(data=data, auth=auth) - log.info(f"批量修改岗位状态成功: {data.ids}") + await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(msg="批量修改岗位状态成功") @PositionRouter.get( "/export", summary="导出岗位", - description="导出岗位列表", response_model=ResponseSchema[None], ) async def export_obj_list_controller( search: Annotated[PositionQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:export"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:position:export']))], ) -> StreamingResponse: """ 导出岗位 @@ -212,7 +206,6 @@ async def export_obj_list_controller( position_export_result = await PositionService.export_position_list_service( position_list=position_query_result ) - log.info("导出岗位成功") return StreamResponse( data=bytes2file_response(position_export_result), diff --git a/backend/app/api/v1/module_system/position/crud.py b/backend/app/api/v1/module_system/position/crud.py index 34d39040..8ac1ade0 100644 --- a/backend/app/api/v1/module_system/position/crud.py +++ b/backend/app/api/v1/module_system/position/crud.py @@ -1,8 +1,5 @@ -from collections.abc import Sequence -from typing import Any - -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import PositionModel from .schema import PositionCreateSchema, PositionUpdateSchema @@ -21,69 +18,4 @@ class PositionCRUD(CRUDBase[PositionModel, PositionCreateSchema, PositionUpdateS 返回: - None """ - self.auth = auth super().__init__(model=PositionModel, auth=auth) - - async def get_by_id_crud( - self, id: int, preload: list[str] | None = None - ) -> PositionModel | None: - """ - 根据 id 获取岗位信息。 - - 参数: - - id (int): 岗位 ID。 - - preload (list[str] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - PositionModel | None: 岗位信息,未找到返回 None。 - """ - return await self.get(id=id, preload=preload) - - async def get_list_crud( - self, - search: dict | None = None, - order_by: list[dict[str, Any]] | None = None, - preload: list[str] | None = None, - ) -> Sequence[PositionModel]: - """ - 获取岗位列表。 - - 参数: - - search (dict | None): 搜索条件。 - - order_by (list[dict[str, Any]] | None): 排序字段列表。 - - preload (list[str] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[PositionModel]: 岗位列表。 - """ - return await self.list(search=search, order_by=order_by, preload=preload) - - async def set_available_crud(self, ids: list[int], status: str) -> None: - """ - 批量设置岗位可用状态。 - - 参数: - - ids (list[int]): 岗位 ID 列表。 - - status (bool): 可用状态。 - - 返回: - - None - """ - await self.set(ids=ids, status=status) - - async def get_name_crud(self, ids: list[int]) -> list[str]: - """ - 根据 id 列表获取岗位名称。 - - 参数: - - ids (list[int]): 岗位 ID 列表。 - - 返回: - - list[str]: 岗位名称列表。 - """ - position_names = [] - for id in ids: - obj = await self.get(id=id) - if obj: - position_names.append(obj.name) - return position_names diff --git a/backend/app/api/v1/module_system/position/schema.py b/backend/app/api/v1/module_system/position/schema.py index d721a51f..7b16e13a 100644 --- a/backend/app/api/v1/module_system/position/schema.py +++ b/backend/app/api/v1/module_system/position/schema.py @@ -12,7 +12,7 @@ class PositionCreateSchema(BaseModel): name: str = Field(..., min_length=1, max_length=64, description="岗位名称") code: str = Field(..., min_length=1, max_length=64, description="岗位编码") order: int = Field(default=1, ge=0, description="显示排序") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") description: str | None = Field(default=None, max_length=255, description="描述") @field_validator("name") @@ -33,8 +33,8 @@ class PositionCreateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, v: str) -> str: - if v not in {"0", "1"}: + def _validate_status(cls, v: int) -> int: + if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return v diff --git a/backend/app/api/v1/module_system/position/service.py b/backend/app/api/v1/module_system/position/service.py index b6e38913..ded1c48b 100644 --- a/backend/app/api/v1/module_system/position/service.py +++ b/backend/app/api/v1/module_system/position/service.py @@ -1,5 +1,4 @@ -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException from app.utils.excel_util import ExcelUtil @@ -16,7 +15,7 @@ class PositionService: """岗位模块服务层""" @classmethod - async def get_position_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_position_detail_service(cls, auth: AuthSchema, id: int) -> PositionOutSchema: """ 获取岗位详情 @@ -27,8 +26,10 @@ class PositionService: 返回: - Dict: 岗位详情对象 """ - position = await PositionCRUD(auth).get_by_id_crud(id=id) - return PositionOutSchema.model_validate(position).model_dump() + position = await PositionCRUD(auth).get(id=id) + if not position: + raise CustomException(msg="岗位不存在") + return PositionOutSchema.model_validate(position) @classmethod async def get_position_list_service( @@ -36,7 +37,7 @@ class PositionService: auth: AuthSchema, search: PositionQueryParam | None = None, order_by: list[dict] | None = None, - ) -> list[dict]: + ) -> list[PositionOutSchema]: """ 获取岗位列表 @@ -46,13 +47,13 @@ class PositionService: - order_by (list[dict] | None): 排序参数列表 返回: - - list[dict]: 岗位列表对象 + - list[PositionOutSchema]: 岗位列表 """ - position_list = await PositionCRUD(auth).get_list_crud( - search=search.__dict__, order_by=order_by + position_list = await PositionCRUD(auth).list( + search=search.__dict__ if search else {}, order_by=order_by ) return [ - PositionOutSchema.model_validate(position).model_dump() for position in position_list + PositionOutSchema.model_validate(position) for position in position_list ] @classmethod @@ -87,7 +88,7 @@ class PositionService: ) @classmethod - async def create_position_service(cls, auth: AuthSchema, data: PositionCreateSchema) -> dict: + async def create_position_service(cls, auth: AuthSchema, data: PositionCreateSchema) -> PositionOutSchema: """ 创建岗位 @@ -102,12 +103,12 @@ class PositionService: if position: raise CustomException(msg="创建失败,该岗位已存在") new_position = await PositionCRUD(auth).create(data=data) - return PositionOutSchema.model_validate(new_position).model_dump() + return PositionOutSchema.model_validate(new_position) @classmethod async def update_position_service( cls, auth: AuthSchema, id: int, data: PositionUpdateSchema - ) -> dict: + ) -> PositionOutSchema: """ 更新岗位 @@ -119,14 +120,14 @@ class PositionService: 返回: - dict: 更新的岗位对象 """ - position = await PositionCRUD(auth).get_by_id_crud(id=id) + position = await PositionCRUD(auth).get(id=id) if not position: raise CustomException(msg="更新失败,该岗位不存在") exist_position = await PositionCRUD(auth).get(name=data.name) if exist_position and exist_position.id != id: raise CustomException(msg="更新失败,岗位名称重复") updated_position = await PositionCRUD(auth).update(id=id, data=data) - return PositionOutSchema.model_validate(updated_position).model_dump() + return PositionOutSchema.model_validate(updated_position) @classmethod async def delete_position_service(cls, auth: AuthSchema, ids: list[int]) -> None: @@ -142,9 +143,11 @@ class PositionService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - for id in ids: - position = await PositionCRUD(auth).get_by_id_crud(id=id) - if not position: + # 批量校验岗位存在性 + positions = await PositionCRUD(auth).list(search={"id": ("in", ids)}) + position_map = {p.id: p for p in positions} + for pid in ids: + if pid not in position_map: raise CustomException(msg="删除失败,该岗位不存在") await PositionCRUD(auth).delete(ids=ids) @@ -162,7 +165,12 @@ class PositionService: 返回: - None """ - await PositionCRUD(auth).set_available_crud(ids=data.ids, status=data.status) + positions = await PositionCRUD(auth).list(search={"id": ("in", data.ids)}) + position_map = {p.id: p for p in positions} + for pid in data.ids: + if pid not in position_map: + raise CustomException(msg=f"岗位ID {pid} 不存在") + await PositionCRUD(auth).set(ids=data.ids, status=data.status) @classmethod async def export_position_list_service(cls, position_list: list[dict]) -> bytes: @@ -192,8 +200,8 @@ class PositionService: for item in data: item["status"] = "启用" if item.get("status") == "0" else "停用" item["creator"] = ( - item.get("creator", {}).get("name", "未知") - if isinstance(item.get("creator"), dict) + item.get("created_by", {}).get("name", "未知") + if isinstance(item.get("created_by"), dict) else "未知" ) diff --git a/backend/app/api/v1/module_system/role/controller.py b/backend/app/api/v1/module_system/role/controller.py index 36165b88..0e114d9a 100644 --- a/backend/app/api/v1/module_system/role/controller.py +++ b/backend/app/api/v1/module_system/role/controller.py @@ -2,13 +2,13 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, StreamResponse, SuccessResponse from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -21,19 +21,21 @@ from .schema import ( ) from .service import RoleService -RoleRouter = APIRouter(route_class=OperationLogRoute, prefix="/role", tags=["角色管理"]) +RoleRouter = APIRouter(route_class=OperationLogRoute, prefix="/role", tags=["系统管理/角色管理"]) + +_ROLE_NS = "role" @RoleRouter.get( "/list", summary="查询角色", - description="查询角色", - response_model=ResponseSchema[list[RoleOutSchema]], + response_model=ResponseSchema[PageResultSchema[RoleOutSchema]], ) +@cache(expire=300, namespace=_ROLE_NS) async def get_obj_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[RoleQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:query']))], ) -> JSONResponse: """ 查询角色 @@ -56,19 +58,17 @@ async def get_obj_list_controller( search=search, order_by=order_by, ) - log.info("查询角色成功") return SuccessResponse(data=result_dict, msg="查询角色成功") @RoleRouter.get( "/detail/{id}", summary="查询角色详情", - description="查询角色详情", response_model=ResponseSchema[RoleOutSchema], ) async def get_obj_detail_controller( id: Annotated[int, Path(description="角色ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:detail']))], ) -> JSONResponse: """ 查询角色详情 @@ -81,19 +81,17 @@ async def get_obj_detail_controller( - JSONResponse: 角色详情JSON响应 """ result_dict = await RoleService.get_role_detail_service(id=id, auth=auth) - log.info(f"获取角色详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取角色详情成功") @RoleRouter.post( "/create", summary="创建角色", - description="创建角色", response_model=ResponseSchema[RoleOutSchema], ) async def create_obj_controller( data: RoleCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:create']))], ) -> JSONResponse: """ 创建角色 @@ -106,20 +104,19 @@ async def create_obj_controller( - JSONResponse: 创建角色JSON响应 """ result_dict = await RoleService.create_role_service(data=data, auth=auth) - log.info(f"创建角色成功: {result_dict}") + await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(data=result_dict, msg="创建角色成功") @RoleRouter.put( "/update/{id}", summary="修改角色", - description="修改角色", response_model=ResponseSchema[RoleOutSchema], ) async def update_obj_controller( data: RoleUpdateSchema, id: Annotated[int, Path(description="角色ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:update']))], ) -> JSONResponse: """ 修改角色 @@ -133,19 +130,18 @@ async def update_obj_controller( - JSONResponse: 修改角色JSON响应 """ result_dict = await RoleService.update_role_service(id=id, data=data, auth=auth) - log.info(f"修改角色成功: {result_dict}") + await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(data=result_dict, msg="修改角色成功") @RoleRouter.delete( "/delete", summary="删除角色", - description="删除角色", response_model=ResponseSchema[None], ) async def delete_obj_controller( ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:delete']))], ) -> JSONResponse: """ 删除角色 @@ -158,19 +154,18 @@ async def delete_obj_controller( - JSONResponse: 删除角色JSON响应 """ await RoleService.delete_role_service(ids=ids, auth=auth) - log.info(f"删除角色成功: {ids}") + await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(msg="删除角色成功") @RoleRouter.patch( "/status/batch", summary="批量修改角色状态", - description="批量修改角色状态", response_model=ResponseSchema[None], ) async def batch_set_available_obj_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:patch']))], ) -> JSONResponse: """ 批量修改角色状态 @@ -183,19 +178,18 @@ async def batch_set_available_obj_controller( - JSONResponse: 批量修改角色状态JSON响应 """ await RoleService.set_role_available_service(data=data, auth=auth) - log.info(f"批量修改角色状态成功: {data.ids}") + await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(msg="批量修改角色状态成功") @RoleRouter.put( "/permission", summary="角色授权", - description="设置角色权限", response_model=ResponseSchema[None], ) async def set_role_permission_controller( data: RolePermissionSettingSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:permission"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:permission']))], ) -> JSONResponse: """ 角色授权 @@ -208,19 +202,18 @@ async def set_role_permission_controller( - JSONResponse: 角色授权JSON响应 """ await RoleService.set_role_permission_service(data=data, auth=auth) - log.info(f"设置角色权限成功: {data}") + await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(msg="授权角色成功") @RoleRouter.get( "/export", summary="导出角色", - description="导出角色列表", response_model=ResponseSchema[None], ) async def export_obj_list_controller( search: Annotated[RoleQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:export"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:role:export']))], ) -> StreamingResponse: """ 导出角色 @@ -234,7 +227,6 @@ async def export_obj_list_controller( """ role_query_result = await RoleService.get_role_list_service(search=search, auth=auth) role_export_result = await RoleService.export_role_list_service(role_list=role_query_result) - log.info("导出角色成功") return StreamResponse( data=bytes2file_response(role_export_result), diff --git a/backend/app/api/v1/module_system/role/crud.py b/backend/app/api/v1/module_system/role/crud.py index 2f55ffab..52e02293 100644 --- a/backend/app/api/v1/module_system/role/crud.py +++ b/backend/app/api/v1/module_system/role/crud.py @@ -1,9 +1,8 @@ -from collections.abc import Sequence -from app.api.v1.module_system.auth.schema import AuthSchema +from app.api.v1.module_platform.menu.crud import MenuCRUD from app.api.v1.module_system.dept.crud import DeptCRUD -from app.api.v1.module_system.menu.crud import MenuCRUD from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException from .model import RoleModel @@ -23,41 +22,8 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): 返回: - None """ - self.auth = auth super().__init__(model=RoleModel, auth=auth) - async def get_by_id_crud(self, id: int, preload: list | None = None) -> RoleModel | None: - """ - 根据id获取角色信息 - - 参数: - - id (int): 角色ID - - preload (list | None): 预加载选项 - - 返回: - - RoleModel | None: 角色模型对象 - """ - return await self.get(id=id, preload=preload) - - async def get_list_crud( - self, - search: dict | None = None, - order_by: list | None = None, - preload: list | None = None, - ) -> Sequence[RoleModel]: - """ - 获取角色列表 - - 参数: - - search (dict | None): 查询参数 - - order_by (list | None): 排序参数 - - preload (list | None): 预加载选项 - - 返回: - - Sequence[RoleModel]: 角色模型对象列表 - """ - return await self.list(search=search, order_by=order_by, preload=preload) - async def set_role_menus_crud(self, role_ids: list[int], menu_ids: list[int]) -> None: """ 设置角色的菜单权限 @@ -70,18 +36,16 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): - None """ roles = await self.list(search={"id": ("in", role_ids)}) - # 前端未勾选时 menu_ids 可能为空:此时应清空关联,而不是触发全量查询 menus = ( [] if not menu_ids - else await MenuCRUD(self.auth).get_list_crud(search={"id": ("in", menu_ids)}) + else await MenuCRUD(self.auth).list(search={"id": ("in", menu_ids)}) ) - # 租户菜单约束:只允许分配租户菜单权限内的菜单 - from app.api.v1.module_platform.tenant.service import TenantService + from app.api.v1.module_platform.package.service import PackageService if self.auth.user and not self.auth.user.is_superuser and self.auth.tenant_id: - allowed_menu_ids = await TenantService.get_tenant_menu_ids( + allowed_menu_ids = await PackageService.get_tenant_available_menu_ids( self.auth, self.auth.tenant_id ) allowed_set = set(allowed_menu_ids) @@ -95,19 +59,6 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): relationship.extend(menus) await self.auth.db.flush() - async def set_role_data_scope_crud(self, role_ids: list[int], data_scope: int) -> None: - """ - 设置角色的数据范围 - - 参数: - - role_ids (list[int]): 角色ID列表 - - data_scope (int): 数据范围 - - 返回: - - None - """ - await self.set(ids=role_ids, data_scope=data_scope) - async def set_role_depts_crud(self, role_ids: list[int], dept_ids: list[int]) -> None: """ 设置角色的部门权限 @@ -120,11 +71,10 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): - None """ roles = await self.list(search={"id": ("in", role_ids)}) - # 前端未勾选时 dept_ids 可能为空:此时应清空关联,而不是触发全量查询 depts = ( [] if not dept_ids - else await DeptCRUD(self.auth).get_list_crud(search={"id": ("in", dept_ids)}) + else await DeptCRUD(self.auth).list(search={"id": ("in", dept_ids)}) ) for obj in roles: @@ -132,16 +82,3 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): relationship.clear() relationship.extend(depts) await self.auth.db.flush() - - async def set_available_crud(self, ids: list[int], status: str) -> None: - """ - 设置角色的可用状态 - - 参数: - - ids (list[int]): 角色ID列表 - - status (str): 可用状态 - - 返回: - - None - """ - await self.set(ids=ids, status=status) diff --git a/backend/app/api/v1/module_system/role/model.py b/backend/app/api/v1/module_system/role/model.py index cea72545..2ca521ef 100644 --- a/backend/app/api/v1/module_system/role/model.py +++ b/backend/app/api/v1/module_system/role/model.py @@ -7,8 +7,8 @@ from app.common.enums import PermissionFilterStrategy from app.core.base_model import MappedBase, ModelMixin, TenantMixin if TYPE_CHECKING: + from app.api.v1.module_platform.menu.model import MenuModel from app.api.v1.module_system.dept.model import DeptModel - from app.api.v1.module_system.menu.model import MenuModel from app.api.v1.module_system.user.model import UserModel @@ -30,7 +30,7 @@ class RoleMenusModel(MappedBase): ) menu_id: Mapped[int] = mapped_column( Integer, - ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"), + ForeignKey("platform_menu.id", ondelete="CASCADE", onupdate="CASCADE"), primary_key=True, comment="菜单ID", ) diff --git a/backend/app/api/v1/module_system/role/schema.py b/backend/app/api/v1/module_system/role/schema.py index c1e3ce07..7184343a 100644 --- a/backend/app/api/v1/module_system/role/schema.py +++ b/backend/app/api/v1/module_system/role/schema.py @@ -7,8 +7,8 @@ from pydantic import ( model_validator, ) +from app.api.v1.module_platform.menu.schema import MenuOutSchema from app.api.v1.module_system.dept.schema import DeptOutSchema -from app.api.v1.module_system.menu.schema import MenuOutSchema from app.common.enums import QueueEnum from app.core.base_schema import BaseSchema from app.core.validator import ( @@ -30,7 +30,7 @@ class RoleCreateSchema(BaseModel): le=5, description="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)", ) - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") description: str | None = Field(default=None, max_length=255, description="描述") @field_validator("code") @@ -41,9 +41,9 @@ class RoleCreateSchema(BaseModel): @field_validator("status") @classmethod - def validate_status(cls, value: str): + def validate_status(cls, value: int): """校验状态:仅支持 0(正常)、1(禁用)""" - if value not in {"0", "1"}: + if value not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return value diff --git a/backend/app/api/v1/module_system/role/service.py b/backend/app/api/v1/module_system/role/service.py index 7e659da5..32563dbd 100644 --- a/backend/app/api/v1/module_system/role/service.py +++ b/backend/app/api/v1/module_system/role/service.py @@ -1,7 +1,6 @@ from typing import Any -from app.api.v1.module_system.auth.schema import AuthSchema -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException from app.utils.excel_util import ExcelUtil @@ -19,7 +18,7 @@ class RoleService: """角色模块服务层""" @classmethod - async def get_role_detail_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_role_detail_service(cls, auth: AuthSchema, id: int) -> RoleOutSchema: """ 获取角色详情 @@ -30,8 +29,10 @@ class RoleService: 返回: - dict: 角色详情字典 """ - role = await RoleCRUD(auth).get_by_id_crud(id=id) - return RoleOutSchema.model_validate(role).model_dump() + role = await RoleCRUD(auth).get(id=id) + if not role: + raise CustomException(msg="角色不存在") + return RoleOutSchema.model_validate(role) @classmethod async def get_role_list_service( @@ -39,7 +40,7 @@ class RoleService: auth: AuthSchema, search: RoleQueryParam | None = None, order_by: list[dict[str, str]] | None = None, - ) -> list[dict]: + ) -> list[RoleOutSchema]: """ 获取角色列表 @@ -49,10 +50,12 @@ class RoleService: - order_by (list[dict[str, str]] | None): 排序参数列表 返回: - - list[dict]: 角色详情字典列表 + - list[RoleOutSchema]: 角色详情字典列表 """ - role_list = await RoleCRUD(auth).get_list_crud(search=search.__dict__, order_by=order_by) - return [RoleOutSchema.model_validate(role).model_dump() for role in role_list] + role_list = await RoleCRUD(auth).list( + search=search.__dict__ if search else {}, order_by=order_by + ) + return [RoleOutSchema.model_validate(role) for role in role_list] @classmethod async def get_role_page_service( @@ -86,7 +89,7 @@ class RoleService: ) @classmethod - async def create_role_service(cls, auth: AuthSchema, data: RoleCreateSchema) -> dict: + async def create_role_service(cls, auth: AuthSchema, data: RoleCreateSchema) -> RoleOutSchema: """ 创建角色 @@ -109,10 +112,10 @@ class RoleService: await TenantService.check_quota_service(auth, auth.tenant_id, "role") new_role = await RoleCRUD(auth).create(data=data) - return RoleOutSchema.model_validate(new_role).model_dump() + return RoleOutSchema.model_validate(new_role) @classmethod - async def update_role_service(cls, auth: AuthSchema, id: int, data: RoleUpdateSchema) -> dict: + async def update_role_service(cls, auth: AuthSchema, id: int, data: RoleUpdateSchema) -> RoleOutSchema: """ 更新角色 @@ -124,7 +127,7 @@ class RoleService: 返回: - dict: 更新后的角色详情字典 """ - role = await RoleCRUD(auth).get_by_id_crud(id=id) + role = await RoleCRUD(auth).get(id=id) if not role: raise CustomException(msg="更新失败,该角色不存在") exist_role = await RoleCRUD(auth).get(name=data.name) @@ -134,7 +137,7 @@ class RoleService: if exist_code and exist_code.id != id: raise CustomException(msg="更新失败,角色编码已存在") updated_role = await RoleCRUD(auth).update(id=id, data=data) - return RoleOutSchema.model_validate(updated_role).model_dump() + return RoleOutSchema.model_validate(updated_role) @classmethod async def delete_role_service(cls, auth: AuthSchema, ids: list[int]) -> None: @@ -150,10 +153,14 @@ class RoleService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - for id in ids: - role = await RoleCRUD(auth).get_by_id_crud(id=id) - if not role: - raise CustomException(msg="删除失败,该角色不存在") + + # 批量校验角色存在性 + roles = await RoleCRUD(auth).list(search={"id": ("in", ids)}) + if len(roles) != len(ids): + found = {r.id for r in roles} + missing = [rid for rid in ids if rid not in found] + raise CustomException(msg=f"角色 ID {missing} 不存在") + await RoleCRUD(auth).delete(ids=ids) @classmethod @@ -174,9 +181,7 @@ class RoleService: await RoleCRUD(auth).set_role_menus_crud(role_ids=data.role_ids, menu_ids=data.menu_ids) # 设置数据权限范围 - await RoleCRUD(auth).set_role_data_scope_crud( - role_ids=data.role_ids, data_scope=data.data_scope - ) + await RoleCRUD(auth).set(ids=data.role_ids, data_scope=data.data_scope) # 设置自定义数据权限部门 if data.data_scope == 5 and data.dept_ids: @@ -196,7 +201,12 @@ class RoleService: 返回: - None """ - await RoleCRUD(auth).set_available_crud(ids=data.ids, status=data.status) + roles = await RoleCRUD(auth).list(search={"id": ("in", data.ids)}) + role_map = {r.id: r for r in roles} + for rid in data.ids: + if rid not in role_map: + raise CustomException(msg=f"角色ID {rid} 不存在") + await RoleCRUD(auth).set(ids=data.ids, status=data.status) @classmethod async def export_role_list_service(cls, role_list: list[dict[str, Any]]) -> bytes: @@ -237,10 +247,5 @@ class RoleService: for item in data: item["status"] = "启用" if item.get("status") == "0" else "停用" item["data_scope"] = data_scope_map.get(item.get("data_scope", 1), "") - item["creator"] = ( - item.get("creator", {}).get("name", "未知") - if isinstance(item.get("creator"), dict) - else "未知" - ) return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) diff --git a/backend/app/api/v1/module_system/ticket/__init__.py b/backend/app/api/v1/module_system/ticket/__init__.py new file mode 100644 index 00000000..a4d8bf66 --- /dev/null +++ b/backend/app/api/v1/module_system/ticket/__init__.py @@ -0,0 +1,3 @@ +from .controller import TicketRouter + +__all__ = ["TicketRouter"] diff --git a/backend/app/api/v1/module_platform/ticket/controller.py b/backend/app/api/v1/module_system/ticket/controller.py similarity index 50% rename from backend/app/api/v1/module_platform/ticket/controller.py rename to backend/app/api/v1/module_system/ticket/controller.py index 630a9d74..fc147811 100644 --- a/backend/app/api/v1/module_platform/ticket/controller.py +++ b/backend/app/api/v1/module_system/ticket/controller.py @@ -1,12 +1,12 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path +from fastapi.responses import JSONResponse -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, SuccessResponse from app.core.base_params import PaginationQueryParam +from app.core.base_schema import AuthSchema, PageResultSchema from app.core.dependencies import AuthPermission -from app.core.logger import log from app.core.router_class import OperationLogRoute from .schema import ( @@ -18,15 +18,26 @@ from .schema import ( ) from .service import TicketService -TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=["工单管理"]) +TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=["系统管理/工单管理"]) -@TicketRouter.get("/list", summary="工单列表", response_model=ResponseSchema[dict]) +@TicketRouter.get("/list", summary="工单列表", response_model=ResponseSchema[PageResultSchema[TicketOutSchema]]) async def ticket_list( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[TicketQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:ticket:query']))], +) -> JSONResponse: + """ + 工单列表 + + 参数: + - page (PaginationQueryParam): 分页查询参数。 + - search (TicketQueryParam): 查询筛选参数。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含分页工单列表的 JSON 响应。 + """ result = await TicketService.page_service( auth=auth, page_no=page.page_no, @@ -42,8 +53,18 @@ async def ticket_list( ) async def ticket_detail( id: Annotated[int, Path()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:ticket:query']))], +) -> JSONResponse: + """ + 工单详情 + + 参数: + - id (int): 工单 ID。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含工单详情的 JSON 响应。 + """ result = await TicketService.detail_service(auth=auth, id=id) return SuccessResponse(data=result, msg="查询成功") @@ -51,10 +72,19 @@ async def ticket_detail( @TicketRouter.post("/create", summary="创建工单", response_model=ResponseSchema[TicketOutSchema]) async def ticket_create( data: TicketCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:create"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:ticket:create']))], +) -> JSONResponse: + """ + 创建工单 + + 参数: + - data (TicketCreateSchema): 工单创建参数。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含创建后的工单详情的 JSON 响应。 + """ result = await TicketService.create_service(auth=auth, data=data) - log.info(f"创建工单: {data.title}") return SuccessResponse(data=result, msg="创建成功") @@ -64,28 +94,56 @@ async def ticket_create( async def ticket_update( id: Annotated[int, Path()], data: TicketUpdateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:ticket:update']))], +) -> JSONResponse: + """ + 更新工单 + + 参数: + - id (int): 工单 ID。 + - data (TicketUpdateSchema): 工单更新参数。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含更新后的工单详情的 JSON 响应。 + """ result = await TicketService.update_service(auth=auth, id=id, data=data) - log.info(f"更新工单: {id}") return SuccessResponse(data=result, msg="更新成功") @TicketRouter.put("/batch", summary="批量更新工单", response_model=ResponseSchema) async def ticket_batch_update( data: TicketBatchSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:ticket:update']))], +) -> JSONResponse: + """ + 批量更新工单 + + 参数: + - data (TicketBatchSchema): 批量更新参数(ID 列表 + 状态)。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 操作结果。 + """ await TicketService.batch_service(auth=auth, data=data) - log.info(f"批量更新工单状态: {data.ids} -> {data.status}") return SuccessResponse(msg="批量更新成功") -@TicketRouter.delete("/delete", summary="删除工单") +@TicketRouter.delete("/delete", summary="删除工单", response_model=ResponseSchema[None]) async def ticket_delete( ids: Annotated[list[int], Body()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:delete"]))], -): + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:ticket:delete']))], +) -> JSONResponse: + """ + 删除工单 + + 参数: + - ids (list[int]): 工单 ID 列表。 + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 删除结果。 + """ await TicketService.delete_service(auth=auth, ids=ids) - log.info(f"删除工单: {ids}") return SuccessResponse(msg="删除成功") diff --git a/backend/app/api/v1/module_system/ticket/crud.py b/backend/app/api/v1/module_system/ticket/crud.py new file mode 100644 index 00000000..a646adf5 --- /dev/null +++ b/backend/app/api/v1/module_system/ticket/crud.py @@ -0,0 +1,12 @@ +from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema + +from .model import TicketModel +from .schema import TicketCreateSchema, TicketUpdateSchema + + +class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]): + """工单 CRUD""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=TicketModel, auth=auth) diff --git a/backend/app/api/v1/module_platform/ticket/model.py b/backend/app/api/v1/module_system/ticket/model.py similarity index 85% rename from backend/app/api/v1/module_platform/ticket/model.py rename to backend/app/api/v1/module_system/ticket/model.py index 02dd2395..70fb06a9 100644 --- a/backend/app/api/v1/module_platform/ticket/model.py +++ b/backend/app/api/v1/module_system/ticket/model.py @@ -10,9 +10,11 @@ if TYPE_CHECKING: class TicketModel(ModelMixin, TenantMixin, UserMixin): - """工单模型 — 用户提交的建议和反馈""" + """工单模型 — 用户提交的建议和反馈 + status: 0=待处理 1=处理中 2=已完成 3=已关闭 + """ - __tablename__: str = "platform_ticket" + __tablename__: str = "sys_ticket" __table_args__: dict[str, str] = {"comment": "工单表"} __loader_options__: list[str] = ["created_by", "updated_by", "assigned_by"] @@ -29,12 +31,8 @@ class TicketModel(ModelMixin, TenantMixin, UserMixin): default="suggestion", comment="工单类型(suggestion:建议 bug:缺陷 optimize:优化 other:其他)", ) - status: Mapped[str] = mapped_column( - String(10), nullable=False, default="0", comment="状态(0:待处理 1:处理中 2:已完成 3:已关闭)" - ) images: Mapped[str | None] = mapped_column(Text, nullable=True, comment="图片URL列表(JSON数组)") reply: Mapped[str | None] = mapped_column(Text, nullable=True, comment="回复内容") - description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="工单描述") assigned_id: Mapped[int | None] = mapped_column( ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, diff --git a/backend/app/api/v1/module_platform/ticket/schema.py b/backend/app/api/v1/module_system/ticket/schema.py similarity index 79% rename from backend/app/api/v1/module_platform/ticket/schema.py rename to backend/app/api/v1/module_system/ticket/schema.py index 1d0c1875..18fe9b48 100644 --- a/backend/app/api/v1/module_platform/ticket/schema.py +++ b/backend/app/api/v1/module_system/ticket/schema.py @@ -1,7 +1,9 @@ +from dataclasses import dataclass + from pydantic import BaseModel, ConfigDict, Field, field_validator -from app.core.base_schema import CommonSchema -from app.core.validator import DateTimeStr +from app.common.enums import QueueEnum +from app.core.base_schema import BaseSchema, CommonSchema class TicketCreateSchema(BaseModel): @@ -40,8 +42,8 @@ class TicketUpdateSchema(BaseModel): ticket_content: str | None = Field(default=None, description="工单内容(富文本)") summary: str | None = Field(default=None, description="工单内容(纯文本摘要)") ticket_type: str | None = Field(default=None, max_length=20, description="工单类型") - status: str | None = Field( - default=None, max_length=10, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)" + status: int | None = Field( + default=None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)" ) reply: str | None = Field(default=None, description="回复内容") assigned_id: int | None = Field(default=None, gt=0, description="处理人ID") @@ -59,15 +61,15 @@ class TicketUpdateSchema(BaseModel): @field_validator("status") @classmethod - def _validate_status(cls, v: str | None) -> str | None: + def _validate_status(cls, v: int | None) -> int | None: if v is None: return v - if v not in {"0", "1", "2", "3"}: + if v not in {0, 1, 2, 3}: raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)") return v -class TicketOutSchema(BaseModel): +class TicketOutSchema(BaseSchema): """工单响应""" model_config = ConfigDict(from_attributes=True) @@ -77,13 +79,10 @@ class TicketOutSchema(BaseModel): ticket_content: str | None = None summary: str | None = None ticket_type: str - status: str + status: int images: str | None = None reply: str | None = None - description: str | None = None assigned_id: int | None = None - created_time: DateTimeStr | None = None - updated_time: DateTimeStr | None = None created_by: CommonSchema | None = None updated_by: CommonSchema | None = None assigned_by: CommonSchema | None = None @@ -93,16 +92,17 @@ class TicketBatchSchema(BaseModel): """批量更新工单""" ids: list[int] = Field(..., min_length=1, description="工单ID列表") - status: str = Field(..., max_length=10, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)") + status: int = Field(..., ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)") @field_validator("status") @classmethod - def _validate_status(cls, v: str) -> str: - if v not in {"0", "1", "2", "3"}: + def _validate_status(cls, v: int) -> int: + if v not in {0, 1, 2, 3}: raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)") return v +@dataclass class TicketQueryParam: """工单查询参数""" @@ -115,12 +115,12 @@ class TicketQueryParam: assigned_id: int | None = None, ) -> None: if title: - self.title = ("like", title) + self.title = (QueueEnum.like.value, title) if ticket_type: - self.ticket_type = ("eq", ticket_type) + self.ticket_type = (QueueEnum.eq.value, ticket_type) if status: - self.status = ("eq", status) + self.status = (QueueEnum.eq.value, status) if created_id: - self.created_id = ("eq", created_id) + self.created_id = (QueueEnum.eq.value, created_id) if assigned_id: - self.assigned_id = ("eq", assigned_id) + self.assigned_id = (QueueEnum.eq.value, assigned_id) diff --git a/backend/app/api/v1/module_platform/ticket/service.py b/backend/app/api/v1/module_system/ticket/service.py similarity index 61% rename from backend/app/api/v1/module_platform/ticket/service.py rename to backend/app/api/v1/module_system/ticket/service.py index 3bfa0b14..e47d11e0 100644 --- a/backend/app/api/v1/module_platform/ticket/service.py +++ b/backend/app/api/v1/module_system/ticket/service.py @@ -1,4 +1,4 @@ -from app.api.v1.module_system.auth.schema import AuthSchema +from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException from .crud import TicketCRUD @@ -11,17 +11,17 @@ from .schema import ( ) _TICKET_STATUS_TRANSITIONS = { - "0": {"1", "3"}, - "1": {"2", "3"}, - "2": {"3"}, - "3": {"0"}, + 0: {1, 3}, + 1: {2, 3}, + 2: {3}, + 3: {0}, } _TICKET_STATUS_LABELS = { - "0": "待处理", - "1": "处理中", - "2": "已完成", - "3": "已关闭", + 0: "待处理", + 1: "处理中", + 2: "已完成", + 3: "已关闭", } @@ -33,12 +33,12 @@ class TicketService: cls, auth: AuthSchema, ticket, - new_status: str, + new_status: int, ) -> None: """校验工单状态流转是否合法""" - old_status = str(ticket.status) if ticket.status is not None else "0" - old_label = _TICKET_STATUS_LABELS.get(old_status, old_status) - new_label = _TICKET_STATUS_LABELS.get(new_status, new_status) + old_status = ticket.status if ticket.status is not None else 0 + old_label = _TICKET_STATUS_LABELS.get(old_status, str(old_status)) + new_label = _TICKET_STATUS_LABELS.get(new_status, str(new_status)) if new_status not in _TICKET_STATUS_TRANSITIONS.get(old_status, set()): raise CustomException( @@ -49,22 +49,22 @@ class TicketService: is_creator = auth.user and ticket.created_id == auth.user.id is_assignee = auth.user and ticket.assigned_id == auth.user.id - if new_status == "0": + if new_status == 0: if not is_super: raise CustomException(msg="仅超管可以重新打开已关闭的工单") - elif old_status == "0" and new_status == "1": + elif old_status == 0 and new_status == 1: if not (is_super or is_creator or is_assignee): raise CustomException(msg="仅创建人、处理人或超管可以受理工单") - elif old_status == "0" and new_status == "3": + elif old_status == 0 and new_status == 3: if not (is_super or is_creator): raise CustomException(msg="仅创建人或超管可以取消工单") - elif old_status == "1" and new_status == "2": + elif old_status == 1 and new_status == 2: if not (is_super or is_assignee): raise CustomException(msg="仅处理人或超管可以将工单标记为已完成") - elif old_status == "1" and new_status == "3": + elif old_status == 1 and new_status == 3: if not (is_super or is_creator or is_assignee): raise CustomException(msg="仅创建人、处理人或超管可以关闭工单") - elif old_status == "2" and new_status == "3": + elif old_status == 2 and new_status == 3: if not (is_super or is_creator): raise CustomException(msg="仅创建人或超管可以确认关闭工单") @@ -77,7 +77,7 @@ class TicketService: search: TicketQueryParam | None = None, order_by: list | None = None, ) -> dict: - return await TicketCRUD(auth).page_crud( + return await TicketCRUD(auth).page( offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"created_time": "desc"}], @@ -86,38 +86,55 @@ class TicketService: ) @classmethod - async def detail_service(cls, auth: AuthSchema, id: int) -> dict: - obj = await TicketCRUD(auth).get_by_id_crud(id=id) + async def detail_service(cls, auth: AuthSchema, id: int) -> TicketOutSchema: + obj = await TicketCRUD(auth).get(id=id) if not obj: raise CustomException(msg="工单不存在") - return TicketOutSchema.model_validate(obj).model_dump() + return TicketOutSchema.model_validate(obj) @classmethod - async def create_service(cls, auth: AuthSchema, data: TicketCreateSchema) -> dict: - obj = await TicketCRUD(auth).create_crud(data=data) + async def create_service(cls, auth: AuthSchema, data: TicketCreateSchema) -> TicketOutSchema: + obj = await TicketCRUD(auth).create(data=data) if not obj: raise CustomException(msg="创建工单失败") - return TicketOutSchema.model_validate(obj).model_dump() + return TicketOutSchema.model_validate(obj) @classmethod - async def update_service(cls, auth: AuthSchema, id: int, data: TicketUpdateSchema) -> dict: - obj = await TicketCRUD(auth).get_by_id_crud(id=id) + async def update_service(cls, auth: AuthSchema, id: int, data: TicketUpdateSchema) -> TicketOutSchema: + obj = await TicketCRUD(auth).get(id=id) if not obj: raise CustomException(msg="工单不存在") if data.status is not None: cls._validate_status_transition(auth, obj, data.status) - updated = await TicketCRUD(auth).update_crud(id=id, data=data) + # 校验 assigned_id:分配处理人时验证用户是否存在且属于同一租户 + if data.assigned_id is not None: + from sqlalchemy import select + + from app.api.v1.module_system.user.model import UserModel + + user_stmt = select(UserModel).where( + UserModel.id == data.assigned_id, + UserModel.is_deleted.is_(False), + ) + user_result = await auth.db.execute(user_stmt) + assigned_user = user_result.scalar_one_or_none() + if not assigned_user: + raise CustomException(msg="指定的处理人不存在") + if assigned_user.tenant_id != obj.tenant_id: + raise CustomException(msg="处理人必须与工单属于同一租户") + + updated = await TicketCRUD(auth).update(id=id, data=data) if not updated: raise CustomException(msg="更新失败") - return TicketOutSchema.model_validate(updated).model_dump() + return TicketOutSchema.model_validate(updated) @classmethod async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: if not ids: raise CustomException(msg="删除对象不能为空") - await TicketCRUD(auth).delete_crud(ids=ids) + await TicketCRUD(auth).delete(ids=ids) @classmethod async def batch_service(cls, auth: AuthSchema, data: TicketBatchSchema) -> None: @@ -125,8 +142,10 @@ class TicketService: if not data.ids: raise CustomException(msg="请选择要操作的工单") + tickets = await TicketCRUD(auth).get_by_ids_crud(ids=data.ids) + ticket_map = {t.id: t for t in tickets} for tid in data.ids: - obj = await TicketCRUD(auth).get_by_id_crud(id=tid) + obj = ticket_map.get(tid) if not obj: raise CustomException(msg=f"工单[{tid}]不存在") cls._validate_status_transition(auth, obj, data.status) diff --git a/backend/app/api/v1/module_system/user/controller.py b/backend/app/api/v1/module_system/user/controller.py index 70d4d66a..ebe4c07a 100644 --- a/backend/app/api/v1/module_system/user/controller.py +++ b/backend/app/api/v1/module_system/user/controller.py @@ -5,12 +5,11 @@ from fastapi import APIRouter, Body, Depends, Path, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.api.v1.module_system.auth.schema import AuthSchema from app.common.response import ResponseSchema, StreamResponse, SuccessResponse from app.core.base_params import PaginationQueryParam -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema from app.core.dependencies import AuthPermission, db_getter, get_current_user -from app.core.logger import log +from app.core.logger import logger from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -27,13 +26,12 @@ from .schema import ( ) from .service import UserService -UserRouter = APIRouter(route_class=OperationLogRoute, prefix="/user", tags=["用户管理"]) +UserRouter = APIRouter(route_class=OperationLogRoute, prefix="/user", tags=["系统管理/用户管理"]) @UserRouter.get( "/current/info", summary="查询当前用户信息", - description="查询当前用户信息", response_model=ResponseSchema[UserOutSchema], ) async def get_current_user_info_controller( @@ -49,14 +47,12 @@ async def get_current_user_info_controller( - JSONResponse: 当前用户信息JSON响应 """ result_dict = await UserService.get_current_user_info_service(auth=auth) - log.info("获取当前用户信息成功") return SuccessResponse(data=result_dict, msg="获取当前用户信息成功") @UserRouter.put( "/current/info/update", summary="更新当前用户基本信息", - description="更新当前用户基本信息", response_model=ResponseSchema[UserOutSchema], ) async def update_current_user_info_controller( @@ -74,14 +70,12 @@ async def update_current_user_info_controller( - JSONResponse: 更新当前用户基本信息JSON响应 """ result_dict = await UserService.update_current_user_info_service(data=data, auth=auth) - log.info(f"更新当前用户基本信息成功: {result_dict}") return SuccessResponse(data=result_dict, msg="更新当前用户基本信息成功") @UserRouter.put( "/password/change", summary="修改当前用户密码", - description="修改当前用户密码", response_model=ResponseSchema[UserOutSchema], ) async def change_current_user_password_controller( @@ -99,20 +93,18 @@ async def change_current_user_password_controller( - JSONResponse: 修改密码JSON响应 """ result_dict = await UserService.change_user_password_service(data=data, auth=auth) - log.info(f"修改密码成功: {result_dict}") return SuccessResponse(data=result_dict, msg="修改密码成功, 请重新登录") @UserRouter.put( "/password/reset/{id}", summary="重置用户密码", - description="重置指定用户密码", response_model=ResponseSchema[UserOutSchema], ) async def reset_password_controller( id: Annotated[int, Path(description="用户ID")], data: ResetPasswordSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:update']))], ) -> JSONResponse: """ 重置密码 @@ -127,14 +119,12 @@ async def reset_password_controller( """ data.id = id result_dict = await UserService.reset_user_password_service(data=data, auth=auth) - log.info(f"重置密码成功: {result_dict}") return SuccessResponse(data=result_dict, msg="重置密码成功") @UserRouter.post( "/register", summary="注册用户", - description="注册用户", response_model=ResponseSchema[UserOutSchema], ) async def register_user_controller( @@ -153,14 +143,13 @@ async def register_user_controller( """ auth = AuthSchema(db=db) user_register_result = await UserService.register_user_service(data=data, auth=auth) - log.info(f"{data.username} 注册用户成功: {user_register_result}") + logger.info(f"{data.username} 注册用户成功: {user_register_result}") return SuccessResponse(data=user_register_result, msg="注册用户成功") @UserRouter.post( "/password/forget", summary="忘记密码", - description="忘记密码找回", response_model=ResponseSchema[UserOutSchema], ) async def forget_password_controller( @@ -179,20 +168,19 @@ async def forget_password_controller( """ auth = AuthSchema(db=db) user_forget_password_result = await UserService.forget_password_service(data=data, auth=auth) - log.info(f"{data.username} 重置密码成功: {user_forget_password_result}") + logger.info(f"{data.username} 重置密码成功: {user_forget_password_result}") return SuccessResponse(data=user_forget_password_result, msg="重置密码成功") @UserRouter.get( "/list", summary="查询用户", - description="查询用户", - response_model=ResponseSchema[list[UserOutSchema]], + response_model=ResponseSchema[PageResultSchema[UserOutSchema]], ) async def get_obj_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[UserQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:query"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:query']))], ) -> JSONResponse: """ 查询用户 @@ -212,19 +200,17 @@ async def get_obj_list_controller( search=search, order_by=page.order_by, ) - log.info("查询用户成功") return SuccessResponse(data=result_dict, msg="查询用户成功") @UserRouter.get( "/detail/{id}", summary="查询用户详情", - description="查询用户详情", response_model=ResponseSchema[UserOutSchema], ) async def get_obj_detail_controller( id: Annotated[int, Path(description="用户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:detail"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:detail']))], ) -> JSONResponse: """ 查询用户详情 @@ -237,19 +223,17 @@ async def get_obj_detail_controller( - JSONResponse: 用户详情JSON响应 """ result_dict = await UserService.get_detail_by_id_service(id=id, auth=auth) - log.info(f"获取用户详情成功 {id}") return SuccessResponse(data=result_dict, msg="获取用户详情成功") @UserRouter.post( "/create", summary="创建用户", - description="创建用户", response_model=ResponseSchema[UserOutSchema], ) async def create_obj_controller( data: UserCreateSchema, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:create"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:create']))], ) -> JSONResponse: """ 创建用户 @@ -266,20 +250,18 @@ async def create_obj_controller( - JSONResponse: 创建用户JSON响应 """ result_dict = await UserService.create_user_service(data=data, auth=auth) - log.info(f"创建用户成功: {result_dict}") return SuccessResponse(data=result_dict, msg="创建用户成功") @UserRouter.put( "/update/{id}", summary="修改用户", - description="修改用户", response_model=ResponseSchema[UserOutSchema], ) async def update_obj_controller( data: UserUpdateSchema, id: Annotated[int, Path(description="用户ID")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:update"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:update']))], ) -> JSONResponse: """ 修改用户 @@ -293,19 +275,17 @@ async def update_obj_controller( - JSONResponse: 修改用户JSON响应 """ result_dict = await UserService.update_user_service(id=id, data=data, auth=auth) - log.info(f"修改用户成功: {result_dict}") return SuccessResponse(data=result_dict, msg="修改用户成功") @UserRouter.delete( "/delete", summary="删除用户", - description="删除用户", response_model=ResponseSchema[None], ) async def delete_obj_controller( ids: Annotated[list[int], Body(description="ID列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:delete"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:delete']))], ) -> JSONResponse: """ 删除用户 @@ -318,19 +298,17 @@ async def delete_obj_controller( - JSONResponse: 删除用户JSON响应 """ await UserService.delete_user_service(ids=ids, auth=auth) - log.info(f"删除用户成功: {ids}") return SuccessResponse(msg="删除用户成功") @UserRouter.patch( "/status/batch", summary="批量修改用户状态", - description="批量修改用户状态", response_model=ResponseSchema[None], ) async def batch_set_available_obj_controller( data: BatchSetAvailable, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:patch']))], ) -> JSONResponse: """ 批量修改用户状态 @@ -343,16 +321,14 @@ async def batch_set_available_obj_controller( - JSONResponse: 批量修改用户状态JSON响应 """ await UserService.set_user_available_service(data=data, auth=auth) - log.info(f"批量修改用户状态成功: {data.ids}") return SuccessResponse(msg="批量修改用户状态成功") @UserRouter.get( "/import/template", summary="获取用户导入模板", - description="获取用户导入模板", response_model=ResponseSchema[None], - dependencies=[Depends(AuthPermission(["module_system:user:download"]))], + dependencies=[Depends(AuthPermission(['module_system:user:download']))], ) async def export_obj_template_controller() -> StreamingResponse: """ @@ -362,7 +338,6 @@ async def export_obj_template_controller() -> StreamingResponse: - StreamingResponse: 用户导入模板流响应 """ user_import_template_result = await UserService.get_import_template_user_service() - log.info("获取用户导入模板成功") return StreamResponse( data=bytes2file_response(user_import_template_result), @@ -377,13 +352,12 @@ async def export_obj_template_controller() -> StreamingResponse: @UserRouter.get( "/export", summary="导出用户", - description="导出用户列表", response_model=ResponseSchema[None], ) async def export_obj_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[UserQueryParam, Depends()], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:export"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:export']))], ) -> StreamingResponse: """ 导出用户 @@ -400,7 +374,6 @@ async def export_obj_list_controller( auth=auth, search=search, order_by=page.order_by ) user_export_result = await UserService.export_user_list_service(user_list) - log.info("导出用户成功") return StreamResponse( data=bytes2file_response(user_export_result), @@ -412,12 +385,11 @@ async def export_obj_list_controller( @UserRouter.post( "/import/data", summary="导入用户", - description="导入用户", response_model=ResponseSchema[None], ) async def import_obj_list_controller( file: UploadFile, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:import"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(['module_system:user:import']))], ) -> JSONResponse: """ 导入用户 @@ -432,5 +404,4 @@ async def import_obj_list_controller( batch_import_result = await UserService.batch_import_user_service( file=file, auth=auth, update_support=True ) - log.info(f"导入用户成功: {batch_import_result}") return SuccessResponse(data=batch_import_result, msg="导入用户成功") diff --git a/backend/app/api/v1/module_system/user/crud.py b/backend/app/api/v1/module_system/user/crud.py index 588df65e..b684d14c 100644 --- a/backend/app/api/v1/module_system/user/crud.py +++ b/backend/app/api/v1/module_system/user/crud.py @@ -1,16 +1,13 @@ -from collections.abc import Sequence from datetime import datetime -from typing import Any -from app.api.v1.module_system.auth.schema import AuthSchema from app.api.v1.module_system.position.crud import PositionCRUD from app.api.v1.module_system.role.crud import RoleCRUD from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema from .model import UserModel from .schema import ( UserCreateSchema, - UserForgetPasswordSchema, UserUpdateSchema, ) @@ -28,112 +25,18 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): 返回: - None """ - self.auth = auth super().__init__(model=UserModel, auth=auth) - async def get_by_id_crud( - self, id: int, preload: list[str | Any] | None = None - ) -> UserModel | None: - """ - 根据id获取用户信息 - - 参数: - - id (int): 用户ID - - preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - UserModel | None: 用户信息,如果不存在则为None - """ - return await self.get( - preload=preload, - id=id, - ) - - async def get_by_username_crud( - self, username: str, preload: list[str | Any] | None = None - ) -> UserModel | None: - """ - 根据用户名获取用户信息 - - 参数: - - username (str): 用户名 - - preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - UserModel | None: 用户信息,如果不存在则为None - """ - return await self.get( - preload=preload, - username=username, - ) - - async def get_by_mobile_crud( - self, mobile: str, preload: list[str | Any] | None = None - ) -> UserModel | None: - """ - 根据手机号获取用户信息 - - 参数: - - mobile (str): 手机号 - - preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - UserModel | None: 用户信息,如果不存在则为None - """ - return await self.get( - preload=preload, - mobile=mobile, - ) - - async def get_list_crud( - self, - search: dict | None = None, - order_by: list[dict[str, str]] | None = None, - preload: list[str | Any] | None = None, - ) -> Sequence[UserModel]: - """ - 获取用户列表 - - 参数: - - search (dict | None): 查询参数对象。 - - order_by (list[dict[str, str]] | None): 排序参数列表。 - - preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[UserModel]: 用户列表 - """ - return await self.list( - search=search, - order_by=order_by, - preload=preload, - ) - - async def update_last_login_crud(self, id: int) -> UserModel | None: + async def update_last_login(self, id: int) -> None: """ 更新用户最后登录时间 参数: - id (int): 用户ID - - 返回: - - UserModel | None: 更新后的用户信息 """ - return await self.update(id=id, data={"last_login": datetime.now()}) + await self.set([id], last_login=datetime.now()) - async def set_available_crud(self, ids: list[int], status: str) -> None: - """ - 批量设置用户可用状态 - - 参数: - - ids (list[int]): 用户ID列表 - - status (str): 可用状态(与表字段一致,如 "0"/"1") - - 返回: - - None - """ - await self.set(ids=ids, status=status) - - async def set_user_roles_crud(self, user_ids: list[int], role_ids: list[int]) -> None: + async def set_user_roles(self, user_ids: list[int], role_ids: list[int]) -> None: """ 批量设置用户角色 @@ -146,7 +49,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): """ user_objs = await self.list(search={"id": ("in", user_ids)}) if role_ids: - role_objs = await RoleCRUD(self.auth).get_list_crud(search={"id": ("in", role_ids)}) + role_objs = await RoleCRUD(self.auth).list(search={"id": ("in", role_ids)}) else: role_objs = [] @@ -156,7 +59,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): relationship.extend(role_objs) await self.auth.db.flush() - async def set_user_positions_crud(self, user_ids: list[int], position_ids: list[int]) -> None: + async def set_user_positions(self, user_ids: list[int], position_ids: list[int]) -> None: """ 批量设置用户岗位 @@ -169,7 +72,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): """ user_objs = await self.list(search={"id": ("in", user_ids)}) if position_ids: - position_objs = await PositionCRUD(self.auth).get_list_crud( + position_objs = await PositionCRUD(self.auth).list( search={"id": ("in", position_ids)} ) else: @@ -181,7 +84,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): relationship.extend(position_objs) await self.auth.db.flush() - async def change_password_crud(self, id: int, password_hash: str) -> UserModel: + async def change_password(self, id: int, password_hash: str) -> UserModel: """ 修改用户密码 @@ -194,27 +97,6 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): """ return await self.update(id=id, data=UserUpdateSchema(password=password_hash)) - async def forget_password_crud(self, id: int, password_hash: str) -> UserModel: - """ - 重置密码 - - 参数: - - id (int): 用户ID - - password_hash (str): 密码哈希值 - - 返回: - - UserModel: 更新后的用户信息 - """ - return await self.update(id=id, data=UserUpdateSchema(password=password_hash)) - - async def register_user_crud(self, data: UserForgetPasswordSchema) -> UserModel: - """ - 用户注册 - - 参数: - - data (UserForgetPasswordSchema): 用户注册信息 - - 返回: - - UserModel: 注册成功的用户信息 - """ - return await self.create(data=UserCreateSchema(**data.model_dump())) + async def forget_password(self, id: int, password_hash: str) -> UserModel: + """重置密码(与 change_password 逻辑相同)""" + return await self.change_password(id=id, password_hash=password_hash) diff --git a/backend/app/api/v1/module_system/user/model.py b/backend/app/api/v1/module_system/user/model.py index f3fb2ad4..28b15f58 100644 --- a/backend/app/api/v1/module_system/user/model.py +++ b/backend/app/api/v1/module_system/user/model.py @@ -5,6 +5,7 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueCon from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin +from app.core.base_schema import AuthSchema if TYPE_CHECKING: from app.api.v1.module_platform.tenant.model import TenantModel @@ -69,7 +70,7 @@ class UserModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "sys_user" __table_args__ = (UniqueConstraint("tenant_id", "username"), {"comment": "用户表"}) __loader_options__: list[str] = [ - "tenant", + "tenant_by", "dept", "roles", "positions", @@ -144,7 +145,5 @@ class UserModel(ModelMixin, TenantMixin, UserMixin): ) -# 修复 Pydantic 循环引用问题 -from app.core.auth_schema import AuthSchema - +# 修复 Pydantic 循环引用问题:UserModel 定义完成后重建 AuthSchema AuthSchema.model_rebuild() diff --git a/backend/app/api/v1/module_system/user/schema.py b/backend/app/api/v1/module_system/user/schema.py index a93635b4..7cbdfc1e 100644 --- a/backend/app/api/v1/module_system/user/schema.py +++ b/backend/app/api/v1/module_system/user/schema.py @@ -10,7 +10,7 @@ from pydantic import ( model_validator, ) -from app.api.v1.module_system.menu.schema import MenuOutSchema +from app.api.v1.module_platform.menu.schema import MenuOutSchema from app.api.v1.module_system.role.schema import RoleOutSchema from app.common.enums import QueueEnum from app.core.base_schema import BaseSchema, CommonSchema, TenantBySchema, UserBySchema @@ -193,9 +193,9 @@ class UserCreateSchema(CurrentUserUpdateSchema): model_config = ConfigDict(from_attributes=True) - username: str | None = Field(default=None, min_length=3, max_length=32, description="用户名") + username: str | None = Field(default=None, max_length=32, description="用户名") password: str | None = Field(default=None, min_length=6, max_length=128, description="密码") - status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)") + status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:禁用)") description: str | None = Field(default=None, max_length=255, description="备注") is_superuser: bool | None = Field(default=False, description="是否超管") dept_id: int | None = Field(default=None, description="部门ID") @@ -205,22 +205,22 @@ class UserCreateSchema(CurrentUserUpdateSchema): @field_validator("status") @classmethod - def validate_status(cls, value: str): + def validate_status(cls, value: int): """校验状态:仅支持 0(正常)、1(禁用)""" - if value not in {"0", "1"}: + if value not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(禁用)") return value @field_validator("username") @classmethod def validate_username(cls, value: str | None): - """校验账号:字母开头,3-32 位""" + """校验账号:字母开头,2-32 位""" if not value: return value v = value.strip() import re - if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$", v): - raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -") + if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{1,31}$", v): + raise ValueError("账号需以字母开头,2-32 位,仅允许字母、数字、_ . -") return v @field_validator("password") @@ -247,6 +247,8 @@ class UserOutSchema(UserUpdateSchema, BaseSchema, UserBySchema, TenantBySchema): model_config = ConfigDict(arbitrary_types_allowed=True, from_attributes=True) + username: str | None = Field(default=None, max_length=32, description="用户名") + tenant_id: int | None = Field( default=None, exclude=True, diff --git a/backend/app/api/v1/module_system/user/service.py b/backend/app/api/v1/module_system/user/service.py index 3b4dc546..3e48ad75 100644 --- a/backend/app/api/v1/module_system/user/service.py +++ b/backend/app/api/v1/module_system/user/service.py @@ -4,15 +4,14 @@ from typing import Any import pandas as pd from fastapi import UploadFile -from app.api.v1.module_system.auth.schema import AuthSchema +from app.api.v1.module_platform.menu.crud import MenuCRUD +from app.api.v1.module_platform.menu.schema import MenuOutSchema from app.api.v1.module_system.dept.crud import DeptCRUD -from app.api.v1.module_system.menu.crud import MenuCRUD -from app.api.v1.module_system.menu.schema import MenuOutSchema from app.api.v1.module_system.position.crud import PositionCRUD from app.api.v1.module_system.role.crud import RoleCRUD -from app.core.base_schema import BatchSetAvailable +from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.utils.common_util import traversal_to_tree from app.utils.excel_util import ExcelUtil from app.utils.hash_bcrpy_util import PwdUtil @@ -35,7 +34,7 @@ class UserService: """用户模块服务层""" @classmethod - async def get_detail_by_id_service(cls, auth: AuthSchema, id: int) -> dict: + async def get_detail_by_id_service(cls, auth: AuthSchema, id: int) -> UserOutSchema: """ 根据ID获取用户详情 @@ -46,26 +45,20 @@ class UserService: 返回: - dict: 用户详情字典 """ - user = await UserCRUD(auth).get_by_id_crud(id=id) + user = await UserCRUD(auth).get(id=id) if not user: raise CustomException(msg="用户不存在") + result = UserOutSchema.model_validate(user) # 如果用户绑定了部门,则获取部门名称 if user.dept_id: - dept = await DeptCRUD(auth).get_by_id_crud(id=user.dept_id) - UserOutSchema.dept_name = dept.name if dept else None - else: - UserOutSchema.dept_name = None + dept = await DeptCRUD(auth).get(id=user.dept_id) + result.dept_name = dept.name if dept else None - return UserOutSchema.model_validate(user).model_dump() + return result @classmethod - async def get_user_list_service( - cls, - auth: AuthSchema, - search: UserQueryParam | None = None, - order_by: list[dict[str, str]] | None = None, - ) -> list[dict]: + async def get_user_list_service(cls, auth: AuthSchema, search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> list[UserOutSchema]: """ 获取用户列表 @@ -77,23 +70,16 @@ class UserService: 返回: - list[dict]: 用户详情字典列表 """ - user_list = await UserCRUD(auth).get_list_crud(search=search.__dict__, order_by=order_by) + user_list = await UserCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) user_dict_list = [] for user in user_list: - user_dict = UserOutSchema.model_validate(user).model_dump() + user_dict = UserOutSchema.model_validate(user) user_dict_list.append(user_dict) return user_dict_list @classmethod - async def get_user_page_service( - cls, - auth: AuthSchema, - page_no: int, - page_size: int, - search: UserQueryParam | None = None, - order_by: list[dict[str, str]] | None = None, - ) -> dict: + async def get_user_page_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> dict: """ 分页查询用户(数据库 OFFSET/LIMIT)。 @@ -117,7 +103,7 @@ class UserService: ) @classmethod - async def create_user_service(cls, data: UserCreateSchema, auth: AuthSchema) -> dict: + async def create_user_service(cls, data: UserCreateSchema, auth: AuthSchema) -> UserOutSchema: """ 创建用户 @@ -134,13 +120,13 @@ class UserService: if data.is_superuser: raise CustomException(msg="不允许创建超级管理员") # 检查用户名是否存在 - user = await UserCRUD(auth).get_by_username_crud(username=data.username) + user = await UserCRUD(auth).get(username=data.username) if user: raise CustomException(msg="已存在相同用户名称的账号") # 检查部门是否存在 if data.dept_id: - dept = await DeptCRUD(auth).get_by_id_crud(id=data.dept_id) + dept = await DeptCRUD(auth).get(id=data.dept_id) if not dept: raise CustomException(msg="部门不存在") @@ -156,18 +142,18 @@ class UserService: new_user = await UserCRUD(auth).create(data=user_dict) # 设置角色 if data.role_ids and len(data.role_ids) > 0: - await UserCRUD(auth).set_user_roles_crud(user_ids=[new_user.id], role_ids=data.role_ids) + await UserCRUD(auth).set_user_roles(user_ids=[new_user.id], role_ids=data.role_ids) # 设置岗位 if data.position_ids and len(data.position_ids) > 0: - await UserCRUD(auth).set_user_positions_crud( + await UserCRUD(auth).set_user_positions( user_ids=[new_user.id], position_ids=data.position_ids ) - new_user_dict = UserOutSchema.model_validate(new_user).model_dump() + new_user_dict = UserOutSchema.model_validate(new_user) return new_user_dict @classmethod - async def update_user_service(cls, id: int, data: UserUpdateSchema, auth: AuthSchema) -> dict: + async def update_user_service(cls, id: int, data: UserUpdateSchema, auth: AuthSchema) -> UserOutSchema: """ 更新用户 @@ -183,7 +169,7 @@ class UserService: raise CustomException(msg="账号不能为空") # 检查用户是否存在 - user = await UserCRUD(auth).get_by_id_crud(id=id) + user = await UserCRUD(auth).get(id=id) if not user: raise CustomException(msg="用户不存在") @@ -192,12 +178,12 @@ class UserService: raise CustomException(msg="超级管理员不允许修改") # 检查用户名是否重复 - exist_user = await UserCRUD(auth).get_by_username_crud(username=data.username) + exist_user = await UserCRUD(auth).get(username=data.username) if exist_user and exist_user.id != id: raise CustomException(msg="已存在相同的账号") # 新增:检查手机号是否重复 if data.mobile: - exist_mobile_user = await UserCRUD(auth).get_by_mobile_crud(mobile=data.mobile) + exist_mobile_user = await UserCRUD(auth).get(mobile=data.mobile) if exist_mobile_user and exist_mobile_user.id != id: raise CustomException(msg="更新失败,手机号已存在") # 新增:检查邮箱是否重复 @@ -207,10 +193,10 @@ class UserService: raise CustomException(msg="更新失败,邮箱已存在") # 检查部门是否存在且可用 if data.dept_id: - dept = await DeptCRUD(auth).get_by_id_crud(id=data.dept_id) + dept = await DeptCRUD(auth).get(id=data.dept_id) if not dept: raise CustomException(msg="部门不存在") - if dept.status == "1": + if dept.status == 1: raise CustomException(msg="部门已被禁用") # 更新用户 - 排除不应被修改的字段, 更新不更新密码 @@ -223,27 +209,27 @@ class UserService: # 更新角色和岗位 if data.role_ids and len(data.role_ids) > 0: # 检查角色是否都存在且可用 - roles = await RoleCRUD(auth).get_list_crud(search={"id": ("in", data.role_ids)}) + roles = await RoleCRUD(auth).list(search={"id": ("in", data.role_ids)}) if len(roles) != len(data.role_ids): raise CustomException(msg="部分角色不存在") - if not all(role.status for role in roles): + if not all(role.status == 0 for role in roles): raise CustomException(msg="部分角色已被禁用") - await UserCRUD(auth).set_user_roles_crud(user_ids=[id], role_ids=data.role_ids) + await UserCRUD(auth).set_user_roles(user_ids=[id], role_ids=data.role_ids) if data.position_ids and len(data.position_ids) > 0: # 检查岗位是否都存在且可用 - positions = await PositionCRUD(auth).get_list_crud( + positions = await PositionCRUD(auth).list( search={"id": ("in", data.position_ids)} ) if len(positions) != len(data.position_ids): raise CustomException(msg="部分岗位不存在") - if not all(position.status for position in positions): + if not all(position.status == 0 for position in positions): raise CustomException(msg="部分岗位已被禁用") - await UserCRUD(auth).set_user_positions_crud( + await UserCRUD(auth).set_user_positions( user_ids=[id], position_ids=data.position_ids ) - user_dict = UserOutSchema.model_validate(new_user).model_dump() + user_dict = UserOutSchema.model_validate(new_user) return user_dict @classmethod @@ -260,27 +246,30 @@ class UserService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - for id in ids: - user = await UserCRUD(auth).get_by_id_crud(id=id) + # 批量查询所有待删除用户 + users = await UserCRUD(auth).list(search={"id": ("in", ids)}) + user_map = {u.id: u for u in users} + for uid in ids: + user = user_map.get(uid) if not user: raise CustomException(msg="用户不存在") if user.is_superuser: raise CustomException(msg="超级管理员不能删除") - if user.status == "0": + if user.status == 0: raise CustomException(msg="用户已启用,不能删除") - if auth.user and auth.user.id == id: + if auth.user and auth.user.id == uid: raise CustomException(msg="不能删除当前登陆用户") # 删除用户角色关联数据 - await UserCRUD(auth).set_user_roles_crud(user_ids=ids, role_ids=[]) + await UserCRUD(auth).set_user_roles(user_ids=ids, role_ids=[]) # 删除用户岗位关联数据 - await UserCRUD(auth).set_user_positions_crud(user_ids=ids, position_ids=[]) + await UserCRUD(auth).set_user_positions(user_ids=ids, position_ids=[]) # 删除用户 await UserCRUD(auth).delete(ids=ids) @classmethod - async def get_current_user_info_service(cls, auth: AuthSchema) -> dict: + async def get_current_user_info_service(cls, auth: AuthSchema) -> UserOutSchema: """ 获取当前用户信息 @@ -293,46 +282,45 @@ class UserService: # 获取用户基本信息 if not auth.user or not auth.user.id: raise CustomException(msg="用户不存在") - user = await UserCRUD(auth).get_by_id_crud(id=auth.user.id) + user = await UserCRUD(auth).get(id=auth.user.id) + user_dict = UserOutSchema.model_validate(user) # 获取部门名称 if user and user.dept: - UserOutSchema.dept_name = user.dept.name - user_dict = UserOutSchema.model_validate(user).model_dump() + user_dict.dept_name = user.dept.name # 获取菜单权限(PC 管理端侧栏:仅 client=pc) _pc_only = {"client": "pc"} if auth.user and auth.user.is_superuser: - # 使用树形结构查询,预加载children关系 - menu_all = await MenuCRUD(auth).get_tree_list_crud( - search={"type": ("in", [1, 2, 4]), "status": "0", **_pc_only}, + # 使用树形结构查询,预加载children关系(含 type=3 按钮,供前端权限列表使用) + menu_all = await MenuCRUD(auth).get_tree_list( + search={"type": ("in", [1, 2, 3, 4]), "status": "0", **_pc_only}, order_by=[{"order": "asc"}], ) - menus = [MenuOutSchema.model_validate(menu).model_dump() for menu in menu_all] + menus = [MenuOutSchema.model_validate(menu) for menu in menu_all] else: - # 收集用户所有角色的菜单 ID(仅 PC 端菜单参与动态路由) + # 收集用户所有角色的菜单 ID(含按钮,供前端权限列表使用) menu_ids = { menu.id for role in auth.user.roles or [] for menu in role.menus - if menu.status == "0" - and menu.type in [1, 2, 4] + if menu.status == 0 and getattr(menu, "client", "pc") == "pc" } # 租户菜单约束:非超管用户只能看到租户菜单权限内的菜单 if menu_ids and auth.tenant_id: - from app.api.v1.module_platform.tenant.service import TenantService + from app.api.v1.module_platform.package.service import PackageService - allowed_ids = await TenantService.get_tenant_menu_ids(auth, auth.tenant_id) + allowed_ids = await PackageService.get_tenant_available_menu_ids(auth, auth.tenant_id) allowed_set = set(allowed_ids) menu_ids = menu_ids & allowed_set # 使用树形结构查询,预加载children关系 menus = ( [ - MenuOutSchema.model_validate(menu).model_dump() - for menu in await MenuCRUD(auth).get_tree_list_crud( + MenuOutSchema.model_validate(menu) + for menu in await MenuCRUD(auth).get_tree_list( search={"id": ("in", list(menu_ids)), **_pc_only}, order_by=[{"order": "asc"}], ) @@ -340,13 +328,11 @@ class UserService: if menu_ids else [] ) - user_dict["menus"] = traversal_to_tree(menus) + user_dict.menus = traversal_to_tree([menu.model_dump() for menu in menus]) return user_dict @classmethod - async def update_current_user_info_service( - cls, auth: AuthSchema, data: CurrentUserUpdateSchema - ) -> dict: + async def update_current_user_info_service(cls, auth: AuthSchema, data: CurrentUserUpdateSchema) -> UserOutSchema: """ 更新当前用户信息 @@ -359,14 +345,14 @@ class UserService: """ if not auth.user or not auth.user.id: raise CustomException(msg="用户不存在") - user = await UserCRUD(auth).get_by_id_crud(id=auth.user.id) + user = await UserCRUD(auth).get(id=auth.user.id) if not user: raise CustomException(msg="用户不存在") if user.is_superuser: raise CustomException(msg="超级管理员不能修改个人信息") # 新增:检查手机号是否重复 if data.mobile: - exist_mobile_user = await UserCRUD(auth).get_by_mobile_crud(mobile=data.mobile) + exist_mobile_user = await UserCRUD(auth).get(mobile=data.mobile) if exist_mobile_user and exist_mobile_user.id != auth.user.id: raise CustomException(msg="更新失败,手机号已存在") # 新增:检查邮箱是否重复 @@ -376,7 +362,7 @@ class UserService: raise CustomException(msg="更新失败,邮箱已存在") user_update_data = UserUpdateSchema(**data.model_dump()) new_user = await UserCRUD(auth).update(id=auth.user.id, data=user_update_data) - return UserOutSchema.model_validate(new_user).model_dump() + return UserOutSchema.model_validate(new_user) @classmethod async def set_user_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: @@ -391,17 +377,15 @@ class UserService: - None """ for id in data.ids: - user = await UserCRUD(auth).get_by_id_crud(id=id) + user = await UserCRUD(auth).get(id=id) if not user: raise CustomException(msg=f"用户ID {id} 不存在") if user.is_superuser: raise CustomException(msg="超级管理员状态不能修改") - await UserCRUD(auth).set_available_crud(ids=data.ids, status=data.status) + await UserCRUD(auth).set(ids=data.ids, status=data.status) @classmethod - async def change_user_password_service( - cls, auth: AuthSchema, data: UserChangePasswordSchema - ) -> dict: + async def change_user_password_service(cls, auth: AuthSchema, data: UserChangePasswordSchema) -> UserOutSchema: """ 修改用户密码 @@ -418,7 +402,7 @@ class UserService: raise CustomException(msg="密码不能为空") # 验证原密码 - user = await UserCRUD(auth).get_by_id_crud(id=auth.user.id) + user = await UserCRUD(auth).get(id=auth.user.id) if not user: raise CustomException(msg="用户不存在") if not PwdUtil.verify_password( @@ -428,13 +412,13 @@ class UserService: # 更新密码 new_password_hash = PwdUtil.set_password_hash(password=data.new_password) - new_user = await UserCRUD(auth).change_password_crud( + new_user = await UserCRUD(auth).change_password( id=user.id, password_hash=new_password_hash ) - return UserOutSchema.model_validate(new_user).model_dump() + return UserOutSchema.model_validate(new_user) @classmethod - async def reset_user_password_service(cls, auth: AuthSchema, data: ResetPasswordSchema) -> dict: + async def reset_user_password_service(cls, auth: AuthSchema, data: ResetPasswordSchema) -> UserOutSchema: """ 重置用户密码 @@ -449,7 +433,7 @@ class UserService: raise CustomException(msg="密码不能为空") # 验证用户 - user = await UserCRUD(auth).get_by_id_crud(id=data.id) + user = await UserCRUD(auth).get(id=data.id) if not user: raise CustomException(msg="用户不存在") @@ -459,13 +443,13 @@ class UserService: # 更新密码 new_password_hash = PwdUtil.set_password_hash(password=data.password) - new_user = await UserCRUD(auth).change_password_crud( + new_user = await UserCRUD(auth).change_password( id=data.id, password_hash=new_password_hash ) - return UserOutSchema.model_validate(new_user).model_dump() + return UserOutSchema.model_validate(new_user) @classmethod - async def register_user_service(cls, auth: AuthSchema, data: UserRegisterSchema) -> dict: + async def register_user_service(cls, auth: AuthSchema, data: UserRegisterSchema) -> UserOutSchema: """ 用户注册 @@ -477,7 +461,7 @@ class UserService: - Dict: 注册后的用户详情字典 """ # 检查用户名是否存在 - username_ok = await UserCRUD(auth).get_by_username_crud(username=data.username) + username_ok = await UserCRUD(auth).get(username=data.username) if username_ok: raise CustomException(msg="账号已存在") @@ -491,13 +475,11 @@ class UserService: result = await UserCRUD(auth).create(data=create_dict) if data.role_ids: - await UserCRUD(auth).set_user_roles_crud(user_ids=[result.id], role_ids=data.role_ids) - return UserOutSchema.model_validate(result).model_dump() + await UserCRUD(auth).set_user_roles(user_ids=[result.id], role_ids=data.role_ids) + return UserOutSchema.model_validate(result) @classmethod - async def forget_password_service( - cls, auth: AuthSchema, data: UserForgetPasswordSchema - ) -> dict: + async def forget_password_service(cls, auth: AuthSchema, data: UserForgetPasswordSchema) -> UserOutSchema: """ 用户忘记密码 @@ -508,26 +490,28 @@ class UserService: 返回: - Dict: 更新后的当前用户详情字典 """ - user = await UserCRUD(auth).get_by_username_crud(username=data.username) + user = await UserCRUD(auth).get(username=data.username) if not user: raise CustomException(msg="用户不存在") - if user.status == "1": + if user.status == 1: raise CustomException(msg="用户已停用") # 检查是否是超级管理员 if user.is_superuser: raise CustomException(msg="超级管理员密码不能重置") + # 校验手机号是否匹配 + if data.mobile and user.mobile != data.mobile: + raise CustomException(msg="手机号不匹配") + new_password_hash = PwdUtil.set_password_hash(password=data.new_password) - new_user = await UserCRUD(auth).forget_password_crud( + new_user = await UserCRUD(auth).forget_password( id=user.id, password_hash=new_password_hash ) - return UserOutSchema.model_validate(new_user).model_dump() + return UserOutSchema.model_validate(new_user) @classmethod - async def batch_import_user_service( - cls, auth: AuthSchema, file: UploadFile, update_support: bool = False - ) -> str: + async def batch_import_user_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str: """ 批量导入用户 @@ -590,9 +574,9 @@ class UserService: count = count + 1 # 数据转换 gender = ( - "1" if row["gender"] == "男" else ("2" if row["gender"] == "女" else "1") + "0" if row["gender"] == "男" else ("1" if row["gender"] == "女" else "2") ) - status = "0" if row["status"] == "正常" else "1" + status = 0 if row["status"] == "正常" else 1 # 构建用户数据 user_data = { @@ -607,7 +591,7 @@ class UserService: } # 处理用户导入 - exists_user = await UserCRUD(auth).get_by_username_crud( + exists_user = await UserCRUD(auth).get( username=user_data["username"] ) if exists_user: @@ -628,14 +612,14 @@ class UserService: ) new_user = await UserCRUD(auth).create(data=user_create_data) if user_create_schema.role_ids and len(user_create_schema.role_ids) > 0: - await UserCRUD(auth).set_user_roles_crud( + await UserCRUD(auth).set_user_roles( user_ids=[new_user.id], role_ids=user_create_schema.role_ids ) if ( user_create_schema.position_ids and len(user_create_schema.position_ids) > 0 ): - await UserCRUD(auth).set_user_positions_crud( + await UserCRUD(auth).set_user_positions( user_ids=[new_user.id], position_ids=user_create_schema.position_ids ) success_count += 1 @@ -651,7 +635,7 @@ class UserService: return result except Exception as e: - log.error(f"批量导入用户失败: {e!s}") + logger.error(f"批量导入用户失败: {e!s}") raise CustomException(msg=f"导入失败: {e!s}") @classmethod @@ -724,8 +708,8 @@ class UserService: item["gender"] = "男" if gender == "1" else ("女" if gender == "2" else "未知") item["is_superuser"] = "是" if item.get("is_superuser") else "否" item["creator"] = ( - item.get("creator", {}).get("name", "未知") - if isinstance(item.get("creator"), dict) + item.get("created_by", {}).get("name", "未知") + if isinstance(item.get("created_by"), dict) else "未知" ) diff --git a/backend/app/common/constant.py b/backend/app/common/constant.py index 915aeaec..6cd64be0 100644 --- a/backend/app/common/constant.py +++ b/backend/app/common/constant.py @@ -775,5 +775,6 @@ DATE_DISPLAY_FMT = "%Y-%m-%d" TIME_DISPLAY_FMT = "%H:%M:%S" DATETIME_DISPLAY_FMT = "%Y-%m-%d %H:%M:%S" + # if __name__ == "__main__": # print(RET.OK.msg) # 输出: 成功 diff --git a/backend/app/common/request.py b/backend/app/common/request.py index 2ce68cad..92089b41 100644 --- a/backend/app/common/request.py +++ b/backend/app/common/request.py @@ -1,24 +1,10 @@ from typing import Any -from pydantic import BaseModel, ConfigDict, Field -from pydantic.alias_generators import to_camel - from app.common.constant import RET +from app.core.base_schema import PageResultSchema from app.core.exceptions import CustomException -class PageResultSchema(BaseModel): - """分页查询结果模型""" - - model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) - - page_no: int | None = Field(default=None, ge=1, description="页码,默认为1") - page_size: int | None = Field(default=None, ge=1, description="页面大小,默认为10") - total: int = Field(default=0, ge=0, description="总记录数") - has_next: bool | None = Field(default=False, description="是否有下一页") - items: list[Any] = Field(default_factory=list, description="分页后的数据列表") - - class PaginationService: """分页服务类(仅用于无法 SQL OFFSET/LIMIT 的数据源,如 Redis、本地文件列表、ORM 反射全量表名等)。""" @@ -27,7 +13,7 @@ class PaginationService: data_list: list[Any], page_no: int | None = None, page_size: int | None = None, - ) -> dict[str, Any]: + ) -> PageResultSchema[Any]: """ 对已在内存中的列表做切片分页。 关系型表列表请使用 CRUDBase.page(数据库分页)。 @@ -38,7 +24,7 @@ class PaginationService: - page_size (int | None): 每页数据量,默认 None。 返回: - - dict[str, Any]: 分页数据对象。 + - PageResultSchema[Any]: 分页结果对象。 异常: - CustomException: 当分页参数不合法时抛出。 @@ -65,10 +51,10 @@ class PaginationService: # 判断是否有下一页 has_next = end < total - return { - "items": paginated_data, - "total": total, - "page_no": page_no, - "page_size": page_size, - "has_next": has_next, - } + return PageResultSchema( + items=paginated_data, + total=total, + page_no=page_no, + page_size=page_size, + has_next=has_next, + ) diff --git a/backend/app/common/response.py b/backend/app/common/response.py index d1f8c302..b38418fe 100644 --- a/backend/app/common/response.py +++ b/backend/app/common/response.py @@ -65,6 +65,7 @@ class SuccessResponse(JSONResponse): success=success, ).model_dump() super().__init__(content=jsonable_response_content(content), status_code=status_code) + self.headers["Content-Type"] = "application/json; charset=utf-8" class ErrorResponse(JSONResponse): @@ -99,6 +100,7 @@ class ErrorResponse(JSONResponse): success=success, ).model_dump() super().__init__(content=jsonable_response_content(content), status_code=status_code) + self.headers["Content-Type"] = "application/json; charset=utf-8" class StreamResponse(StreamingResponse): diff --git a/backend/app/config/setting.py b/backend/app/config/setting.py index 1316a187..3238741b 100755 --- a/backend/app/config/setting.py +++ b/backend/app/config/setting.py @@ -55,7 +55,7 @@ class Settings(BaseSettings): # ******************** 跨域配置 ******************** # # ================================================= # CORS_ORIGIN_ENABLE: bool = True # 是否启用跨域 - ALLOW_ORIGINS: list[str] = ["*"] # 允许的域名列表 + ALLOW_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:4173", "http://127.0.0.1:5173", "http://localhost:5180", "http://127.0.0.1:5180"] # 允许的域名列表 ALLOW_METHODS: list[str] = ["*"] # 允许的HTTP方法 ALLOW_HEADERS: list[str] = ["*"] # 允许的请求头 ALLOW_CREDENTIALS: bool = True # 是否允许携带cookie @@ -66,8 +66,8 @@ class Settings(BaseSettings): # ================================================= # SECRET_KEY: str = "vgb0tnl9d58+6n-6h-ea&u^1#s0ccp!794=krylxcjq75vzps$" # JWT密钥 ALGORITHM: str = "HS256" # JWT算法 - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 30 # access_token过期时间(秒)30 分钟 - REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 30 # refresh_token过期时间(秒)30 分钟 + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 12 # access_token过期时间(秒)12 小时 + REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 12 # refresh_token过期时间(秒)12 小时 TOKEN_TYPE: str = "bearer" # token类型 TOKEN_REQUEST_PATH_EXCLUDE: list[str] = ["api/v1/auth/login"] # JWT / RBAC 路由白名单 TOKEN_SLIDING_EXPIRE: bool = True # 是否启用滑动过期(用户操作时自动续期) @@ -79,6 +79,22 @@ class Settings(BaseSettings): "/api/v1/common/health", ] + # ================================================= # + # ******************* 支付配置 ******************* # + # ================================================= # + PAYMENT_GATEWAY: str = "mock" # alipay / wxpay / mock + # 支付宝 + PAYMENT_ALIPAY_APP_ID: str = "" + PAYMENT_ALIPAY_PRIVATE_KEY: str = "" + PAYMENT_ALIPAY_PUBLIC_KEY: str = "" + PAYMENT_ALIPAY_SANDBOX: bool = True + # 微信支付 + PAYMENT_WXPAY_APP_ID: str = "" + PAYMENT_WXPAY_MCH_ID: str = "" + PAYMENT_WXPAY_API_KEY: str = "" + # 站点 URL(用于生成支付通知 URL) + SITE_URL: str = "http://localhost:8001" + # 多租户:通配子域与登录租户一致(默认关闭;生产按 base 解析 {code}.base) TENANT_HOST_ENFORCE: bool = False TENANT_HOST_BASE_DOMAIN: str = "" @@ -212,8 +228,6 @@ class Settings(BaseSettings): SWAGGER_CSS_URL: str = "static/swagger/swagger-ui/swagger-ui.css" SWAGGER_JS_URL: str = "static/swagger/swagger-ui/swagger-ui-bundle.js" REDOC_JS_URL: str = "static/swagger/redoc/bundles/redoc.standalone.js" - CUSTOM_CSS_URL: str = "static/swagger/custom-ui/styles.css" - CUSTOM_JS_URL: str = "static/swagger/custom-ui/scripts.js" FAVICON_URL: str = "static/image/favicon.ico" # ================================================= # @@ -251,9 +265,10 @@ class Settings(BaseSettings): MIDDLEWARES: list[str | None] = [ "app.core.middlewares.CustomCORSMiddleware" if self.CORS_ORIGIN_ENABLE else None, "app.core.middlewares.RequestLogMiddleware" if self.OPERATION_LOG_RECORD else None, - "app.core.middleware.security_headers.SecurityHeadersMiddleware", # 安全响应头 "app.core.middlewares.CustomGZipMiddleware" if self.GZIP_ENABLE else None, - "app.core.middleware.correlation.CorrelationIdMiddleware", # 请求上下文(最内层) + "app.core.middlewares.SecurityHeadersMiddleware", # 安全响应头 + "app.core.middlewares.CorrelationIdMiddleware", # 请求上下文 + "app.core.middlewares.TenantMiddleware", # 租户上下文(需 JWT) ] return MIDDLEWARES diff --git a/backend/app/core/ap_scheduler.py b/backend/app/core/ap_scheduler.py index fd7b9c66..5d0762cc 100644 --- a/backend/app/core/ap_scheduler.py +++ b/backend/app/core/ap_scheduler.py @@ -43,7 +43,7 @@ from redis.asyncio import Redis from app.config.setting import settings from app.core.database import engine -from app.core.logger import log +from app.core.logger import logger from app.plugin.module_task.cronjob.node.model import NodeModel from app.utils.cron_util import CronUtil @@ -130,7 +130,7 @@ class SchedulerUtil: cls._handle_other_event(event) except Exception as e: - log.error(f"处理任务执行事件失败: {e!s}", exc_info=True) + logger.error(f"处理任务执行事件失败: {e!s}", exc_info=True) @classmethod def _handle_job_submitted(cls, event: JobSubmissionEvent) -> None: @@ -141,7 +141,7 @@ class SchedulerUtil: job = cls.get_job(job_id=job_id) if job: - log.info(f"任务 {job_id} ({job.name}) 已提交执行") + logger.info(f"任务 {job_id} ({job.name}) 已提交执行") trigger_type = cls._get_trigger_type(job_id) @@ -169,7 +169,7 @@ class SchedulerUtil: else: original_job_id, job_name = job_id, cached_value - log.info(f"任务 {job_id} 提交执行,但未找到任务信息(可能已被移除),尝试创建日志") + logger.info(f"任务 {job_id} 提交执行,但未找到任务信息(可能已被移除),尝试创建日志") result = cls._create_job_log( job_id=original_job_id, job_name=job_name, @@ -177,9 +177,9 @@ class SchedulerUtil: status="running", ) if result: - log.info(f"任务 {original_job_id} 日志创建成功,id={result}") + logger.info(f"任务 {original_job_id} 日志创建成功,id={result}") else: - log.error(f"任务 {original_job_id} 日志创建失败") + logger.error(f"任务 {original_job_id} 日志创建失败") @classmethod def _handle_job_executed(cls, event: JobExecutionEvent) -> None: @@ -190,11 +190,11 @@ class SchedulerUtil: retval = getattr(event, "retval", None) scheduled_run_time = getattr(event, "scheduled_run_time", None) - log.info(f"任务 {job_id} 执行成功") + logger.info(f"任务 {job_id} 执行成功") if retval: - log.debug(f"任务 {job_id} 返回值: {retval}") + logger.debug(f"任务 {job_id} 返回值: {retval}") if scheduled_run_time: - log.debug(f"任务 {job_id} 计划执行时间: {scheduled_run_time}") + logger.debug(f"任务 {job_id} 计划执行时间: {scheduled_run_time}") # 更新执行日志 cls._update_latest_job_log( @@ -214,7 +214,7 @@ class SchedulerUtil: trigger_type=trigger_type, status="pending", ) - log.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") + logger.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") @classmethod def _handle_job_error(cls, event: JobExecutionEvent) -> None: @@ -226,11 +226,11 @@ class SchedulerUtil: traceback = getattr(event, "traceback", None) scheduled_run_time = getattr(event, "scheduled_run_time", None) - log.error(f"任务 {job_id} 执行失败: {exception!s}") + logger.error(f"任务 {job_id} 执行失败: {exception!s}") if traceback: - log.debug(f"任务 {job_id} 错误堆栈: {traceback}") + logger.debug(f"任务 {job_id} 错误堆栈: {traceback}") if scheduled_run_time: - log.debug(f"任务 {job_id} 计划执行时间: {scheduled_run_time}") + logger.debug(f"任务 {job_id} 计划执行时间: {scheduled_run_time}") # 更新执行日志 cls._update_latest_job_log( @@ -251,7 +251,7 @@ class SchedulerUtil: trigger_type=trigger_type, status="pending", ) - log.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") + logger.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") @classmethod def _handle_job_missed(cls, event: JobEvent) -> None: @@ -261,9 +261,9 @@ class SchedulerUtil: job_id = str(event.job_id) job = cls.get_job(job_id=job_id) - log.warning(f"任务 {job_id} 错过执行时间") + logger.warning(f"任务 {job_id} 错过执行时间") if job: - log.debug(f"任务 {job_id} ({job.name}) 错过执行") + logger.debug(f"任务 {job_id} ({job.name}) 错过执行") # 更新执行日志 cls._update_latest_job_log( @@ -283,7 +283,7 @@ class SchedulerUtil: trigger_type=trigger_type, status="pending", ) - log.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") + logger.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") @classmethod def _handle_job_removed(cls, event: JobEvent) -> None: @@ -298,7 +298,7 @@ class SchedulerUtil: job_id = str(event.job_id) jobstore = getattr(event, "jobstore", "unknown") - log.info(f"任务 {job_id} 从 {jobstore} 存储器中移除") + logger.info(f"任务 {job_id} 从 {jobstore} 存储器中移除") # 检查是否是一次性任务(DateTrigger) # 如果任务已经不存在,说明可能是一次性任务执行后被自动移除 @@ -307,7 +307,7 @@ class SchedulerUtil: if job is None: # 任务已经被移除,可能是一次性任务 # 不需要在这里创建日志,JOB_SUBMITTED 和 JOB_EXECUTED 会处理 - log.debug(f"任务 {job_id} 已从调度器中移除(可能是一次性任务),跳过日志更新") + logger.debug(f"任务 {job_id} 已从调度器中移除(可能是一次性任务),跳过日志更新") return # 任务还存在,说明是周期性任务被手动移除 @@ -324,20 +324,22 @@ class SchedulerUtil: job = cls.get_job(job_id=job_id) if job: - log.info(f"任务 {job_id} ({job.name}) 已添加到 {jobstore} 存储器") + logger.info(f"任务 {job_id} ({job.name}) 已添加到 {jobstore} 存储器") # 为周期性任务(cron/interval)创建初始的 pending 状态日志 trigger_type = cls._get_trigger_type(job_id) if trigger_type in ("cron", "interval"): + # 清理旧的 pending 日志,避免重启导致 pending 日志累积 + cls._cleanup_obsolete_pending_logs(job_id) cls._create_job_log( job_id=job_id, job_name=job.name, trigger_type=trigger_type, status="pending", ) - log.debug(f"任务 {job_id} 已创建初始 pending 状态日志") + logger.info(f"任务 {job_id} 已创建初始 pending 状态日志") else: - log.info(f"任务 {job_id} 已添加到 {jobstore} 存储器") + logger.info(f"任务 {job_id} 已添加到 {jobstore} 存储器") @classmethod def _handle_job_modified(cls, event: JobEvent) -> None: @@ -349,16 +351,16 @@ class SchedulerUtil: job = cls.get_job(job_id=job_id) if job: - log.info(f"任务 {job_id} ({job.name}) 已在 {jobstore} 存储器中修改") + logger.info(f"任务 {job_id} ({job.name}) 已在 {jobstore} 存储器中修改") else: - log.info(f"任务 {job_id} 已在 {jobstore} 存储器中修改") + logger.info(f"任务 {job_id} 已在 {jobstore} 存储器中修改") @classmethod def _handle_scheduler_started(cls, event: SchedulerEvent) -> None: """ 处理调度器启动事件 """ - log.info("调度器已启动") + logger.info("调度器已启动") cls._update_scheduler_status("running") @classmethod @@ -366,7 +368,7 @@ class SchedulerUtil: """ 处理调度器关闭事件 """ - log.info("调度器已关闭") + logger.info("调度器已关闭") cls._update_scheduler_status("stopped") @classmethod @@ -374,7 +376,7 @@ class SchedulerUtil: """ 处理调度器暂停事件 """ - log.info("调度器已暂停") + logger.info("调度器已暂停") cls._update_scheduler_status("paused") @classmethod @@ -382,7 +384,7 @@ class SchedulerUtil: """ 处理调度器恢复事件 """ - log.info("调度器已恢复运行") + logger.info("调度器已恢复运行") cls._update_scheduler_status("running") @classmethod @@ -392,10 +394,10 @@ class SchedulerUtil: """ alias = event.alias if alias: - log.info(f"执行器 {alias} 已添加到调度器") + logger.info(f"执行器 {alias} 已添加到调度器") cls._update_executor_info(alias, "added") else: - log.warning("执行器添加事件,但别名为空") + logger.warning("执行器添加事件,但别名为空") @classmethod def _handle_executor_removed(cls, event: SchedulerEvent) -> None: @@ -404,10 +406,10 @@ class SchedulerUtil: """ alias = event.alias if alias: - log.info(f"执行器 {alias} 已从调度器中移除") + logger.info(f"执行器 {alias} 已从调度器中移除") cls._update_executor_info(alias, "removed") else: - log.warning("执行器移除事件,但别名为空") + logger.warning("执行器移除事件,但别名为空") @classmethod def _handle_jobstore_added(cls, event: SchedulerEvent) -> None: @@ -416,10 +418,10 @@ class SchedulerUtil: """ alias = event.alias if alias: - log.info(f"JobStore {alias} 已添加到调度器") + logger.info(f"JobStore {alias} 已添加到调度器") cls._update_jobstore_info(alias, "added") else: - log.warning("JobStore 添加事件,但别名为空") + logger.warning("JobStore 添加事件,但别名为空") @classmethod def _handle_jobstore_removed(cls, event: SchedulerEvent) -> None: @@ -428,10 +430,10 @@ class SchedulerUtil: """ alias = event.alias if alias: - log.info(f"JobStore {alias} 已从调度器中移除") + logger.info(f"JobStore {alias} 已从调度器中移除") cls._update_jobstore_info(alias, "removed") else: - log.warning("JobStore 移除事件,但别名为空") + logger.warning("JobStore 移除事件,但别名为空") @classmethod def _handle_all_jobs_removed(cls, event: SchedulerEvent) -> None: @@ -439,7 +441,7 @@ class SchedulerUtil: 处理所有任务移除事件 注意:清空调度器任务不应该清空执行日志,而是将所有 pending 状态的日志更新为 cancelled """ - log.info("所有任务已从调度器中移除") + logger.info("所有任务已从调度器中移除") cls._cancel_all_pending_job_logs() @classmethod @@ -448,7 +450,7 @@ class SchedulerUtil: 处理任务达到最大实例数事件 """ job_id = str(event.job_id) - log.warning(f"任务 {job_id} 已达到最大实例数限制,无法启动新实例") + logger.warning(f"任务 {job_id} 已达到最大实例数限制,无法启动新实例") @classmethod def _handle_other_event( @@ -459,7 +461,7 @@ class SchedulerUtil: """ event_code = event.code event_type = type(event).__name__ - log.debug(f"收到未处理的事件: {event_type} (code: {event_code})") + logger.debug(f"收到未处理的事件: {event_type} (code: {event_code})") @classmethod def _update_scheduler_status(cls, status: str) -> None: @@ -492,9 +494,9 @@ class SchedulerUtil: ) session.add(param) session.commit() - log.debug(f"调度器状态已更新: {status}") + logger.info(f"调度器状态已更新: {status}") except Exception as e: - log.error(f"更新调度器状态失败: {e!s}", exc_info=True) + logger.error(f"更新调度器状态失败: {e!s}", exc_info=True) @classmethod def _update_executor_info(cls, alias: str | None, action: str) -> None: @@ -506,7 +508,7 @@ class SchedulerUtil: - action (str): 操作 (added/removed) """ if not alias: - log.warning("执行器别名为空,跳过更新") + logger.warning("执行器别名为空,跳过更新") return try: @@ -528,14 +530,14 @@ class SchedulerUtil: config_type=True, ) session.add(param) - log.debug(f"执行器 {alias} 已标记为活跃") + logger.debug(f"执行器 {alias} 已标记为活跃") elif action == "removed": if param: param.config_value = "inactive" - log.debug(f"执行器 {alias} 已标记为非活跃") + logger.debug(f"执行器 {alias} 已标记为非活跃") session.commit() except Exception as e: - log.error(f"更新执行器信息失败: {e!s}", exc_info=True) + logger.error(f"更新执行器信息失败: {e!s}", exc_info=True) @classmethod def _update_jobstore_info(cls, alias: str | None, action: str) -> None: @@ -547,7 +549,7 @@ class SchedulerUtil: - action (str): 操作 (added/removed) """ if not alias: - log.warning("JobStore 别名为空,跳过更新") + logger.warning("JobStore 别名为空,跳过更新") return try: @@ -569,14 +571,14 @@ class SchedulerUtil: config_type=True, ) session.add(param) - log.debug(f"JobStore {alias} 已标记为活跃") + logger.debug(f"JobStore {alias} 已标记为活跃") elif action == "removed": if param: param.config_value = "inactive" - log.debug(f"JobStore {alias} 已标记为非活跃") + logger.debug(f"JobStore {alias} 已标记为非活跃") session.commit() except Exception as e: - log.error(f"更新 JobStore 信息失败: {e!s}", exc_info=True) + logger.error(f"更新 JobStore 信息失败: {e!s}", exc_info=True) @classmethod def _clear_all_job_logs(cls) -> None: @@ -591,9 +593,9 @@ class SchedulerUtil: with Session(engine) as session: session.query(JobModel).delete() session.commit() - log.info("所有任务日志已清空") + logger.info("所有任务日志已清空") except Exception as e: - log.error(f"清空任务日志失败: {e!s}", exc_info=True) + logger.error(f"清空任务日志失败: {e!s}", exc_info=True) @classmethod def _cancel_all_pending_job_logs(cls) -> None: @@ -611,9 +613,9 @@ class SchedulerUtil: "status": "cancelled" }) session.commit() - log.info("所有待执行任务日志已标记为已取消") + logger.info("所有待执行任务日志已标记为已取消") except Exception as e: - log.error(f"取消待执行任务日志失败: {e!s}", exc_info=True) + logger.error(f"取消待执行任务日志失败: {e!s}", exc_info=True) @classmethod def _get_trigger_type(cls, job_id: str) -> str: @@ -653,6 +655,63 @@ class SchedulerUtil: scheduler.start() scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL) scheduler.resume() + cls._register_system_jobs() + + @classmethod + def _register_system_jobs(cls) -> None: + """ + 注册系统级定时任务(租户到期/续费提醒/归档清理/订单取消/日志清理)。 + 在 init_scheduler 末尾自动调用。 + """ + from apscheduler.triggers.cron import CronTrigger + from apscheduler.triggers.interval import IntervalTrigger + + from app.api.v1.module_platform.order.service import OrderService + from app.api.v1.module_platform.tenant.service import TenantService + + # 租户到期检查(每小时) + scheduler.add_job( + TenantService.check_tenant_expiry, + trigger=IntervalTrigger(hours=1), + id="system_tenant_expiry_check", + name="租户到期检查", + replace_existing=True, + ) + # 宽限期续费提醒(每天 9:00) + scheduler.add_job( + TenantService.send_grace_reminders, + trigger=CronTrigger(hour=9, minute=0), + id="system_grace_reminder", + name="宽限期续费提醒", + replace_existing=True, + ) + # 过期租户归档清理(每月 1 号 2:00) + scheduler.add_job( + TenantService.clean_expired_tenants, + trigger=CronTrigger(day=1, hour=2, minute=0), + id="system_clean_expired", + name="过期租户归档清理", + replace_existing=True, + ) + # 超时订单取消(每 5 分钟) + scheduler.add_job( + OrderService.cancel_expired_orders, + trigger=IntervalTrigger(minutes=5), + id="system_cancel_expired_orders", + name="超时订单取消", + replace_existing=True, + ) + # 操作日志清理(每周日 3:00) + from app.api.v1.module_system.log.service import OperationLogService + + scheduler.add_job( + OperationLogService.cleanup_operation_log, + trigger=CronTrigger(day_of_week="sun", hour=3, minute=0), + id="system_cleanup_operation_log", + name="操作日志清理", + replace_existing=True, + ) + logger.info("✅ 6 个系统周期任务已注册(租户到期/续费提醒/归档清理/订单取消/日志清理)") @classmethod def _task_wrapper(cls, job_id: str | int, code_block: str | None, *args, **kwargs): @@ -690,7 +749,7 @@ class SchedulerUtil: result = run_sync_handler() return result except Exception as e: - log.error(f"任务 {job_id} 执行失败: {e!s}") + logger.error(f"任务 {job_id} 执行失败: {e!s}") raise @classmethod @@ -801,6 +860,26 @@ class SchedulerUtil: except Exception as e: return {"error": str(e), "raw_data": str(blob_data[:200])} + @classmethod + def _cleanup_obsolete_pending_logs(cls, job_id: str) -> None: + """清理该 job_id 的所有旧的 pending 日志,避免重启累积。""" + from sqlalchemy.orm import Session + + from app.plugin.module_task.cronjob.job.model import JobModel + + try: + with Session(engine) as session: + deleted = ( + session.query(JobModel) + .filter(JobModel.job_id == job_id, JobModel.status == "pending") + .delete(synchronize_session=False) + ) + session.commit() + if deleted: + logger.info(f"清理了任务 {job_id} 的 {deleted} 条旧 pending 日志") + except Exception as e: + logger.error(f"清理任务 {job_id} 旧 pending 日志失败: {e}") + @classmethod def _create_job_log( cls, @@ -835,10 +914,10 @@ class SchedulerUtil: ) session.add(job_log) session.commit() - log.info(f"执行日志创建成功: job_id={job_id}, id={job_log.id}") + logger.info(f"执行日志创建成功: job_id={job_id}, id={job_log.id}") return job_log.id except Exception as e: - log.error(f"创建执行日志失败: job_id={job_id}, error={e}", exc_info=True) + logger.error(f"创建执行日志失败: job_id={job_id}, error={e}", exc_info=True) return None @classmethod @@ -877,7 +956,7 @@ class SchedulerUtil: job_log.error = error session.commit() else: - log.warning(f"未找到任务 {job_id} 的待执行日志记录") + logger.warning(f"未找到任务 {job_id} 的待执行日志记录") @classmethod def _update_latest_job_log( @@ -916,7 +995,7 @@ class SchedulerUtil: if error: job_log.error = error session.commit() - log.info(f"执行日志更新成功: job_id={job_id}, id={job_log.id}, status={status}") + logger.info(f"执行日志更新成功: job_id={job_id}, id={job_log.id}, status={status}") return # 没有找到 running 状态的日志,尝试更新 cancelled 状态的日志 @@ -939,11 +1018,11 @@ class SchedulerUtil: if error: job_log.error = error session.commit() - log.info(f"执行日志更新成功: job_id={job_id}, id={job_log.id}, status={status}") + logger.info(f"执行日志更新成功: job_id={job_id}, id={job_log.id}, status={status}") return # 创建新的日志记录 - log.debug(f"未找到任务 {job_id} 的日志记录,创建新日志") + logger.debug(f"未找到任务 {job_id} 的日志记录,创建新日志") trigger_type = cls._get_trigger_type(job_id) if job else "manual" new_log = JobModel( job_id=job_id, @@ -957,9 +1036,9 @@ class SchedulerUtil: ) session.add(new_log) session.commit() - log.info(f"执行日志创建成功: job_id={job_id}, id={new_log.id}, status={status}") + logger.info(f"执行日志创建成功: job_id={job_id}, id={new_log.id}, status={status}") except Exception as e: - log.error( + logger.error( f"更新执行日志失败: job_id={job_id}, status={status}, error={e}", exc_info=True ) @@ -1004,7 +1083,7 @@ class SchedulerUtil: if job_log: job_log.status = "cancelled" session.commit() - log.info(f"任务 {job_id} 的执行日志已标记为已取消") + logger.info(f"任务 {job_id} 的执行日志已标记为已取消") @classmethod def get_job_status(cls, job_id: str | int) -> str: @@ -1216,7 +1295,7 @@ class SchedulerUtil: jobstore=jobstore, executor=executor, ) - log.info(f"任务 {job_info.id} 添加到 {jobstore} 存储器成功") + logger.info(f"任务 {job_info.id} 添加到 {jobstore} 存储器成功") return job except ConflictingIdError: scheduler.remove_job(job_id=str(job_info.id), jobstore=jobstore) @@ -1232,7 +1311,7 @@ class SchedulerUtil: jobstore=jobstore, executor=executor, ) - log.info(f"任务 {job_info.id} 已存在,已移除旧任务并重新添加") + logger.info(f"任务 {job_info.id} 已存在,已移除旧任务并重新添加") return job @classmethod @@ -1527,5 +1606,5 @@ class SchedulerUtil: max_instances=1, ) - log.info(f"任务 {job_id} 已触发立即执行,临时任务 ID: {temp_job_id}") + logger.info(f"任务 {job_id} 已触发立即执行,临时任务 ID: {temp_job_id}") return temp_job diff --git a/backend/app/core/auth_schema.py b/backend/app/core/auth_schema.py deleted file mode 100644 index 762d8a66..00000000 --- a/backend/app/core/auth_schema.py +++ /dev/null @@ -1,70 +0,0 @@ -"""认证授权相关的基础 Schema - -放置在此处避免循环导入问题 -""" -from datetime import datetime -from typing import TYPE_CHECKING - -from pydantic import BaseModel, ConfigDict, Field, model_validator -from sqlalchemy.ext.asyncio import AsyncSession - -# 使用 TYPE_CHECKING 避免循环导入 -if TYPE_CHECKING: - from app.api.v1.module_system.user.model import UserModel - - -class AuthSchema(BaseModel): - """权限认证模型""" - - model_config = ConfigDict(arbitrary_types_allowed=True) - - user: "UserModel | None" = Field(default=None, description="用户信息") - check_data_scope: bool = Field(default=True, description="是否检查数据权限") - db: AsyncSession = Field(description="数据库会话") - tenant_id: int | None = Field(default=None, description="租户ID,用于用户认证前查询") - - -class JWTPayloadSchema(BaseModel): - """JWT载荷模型""" - - sub: str = Field(..., description="用户登录信息") - is_refresh: bool = Field(default=False, description="是否刷新token") - exp: datetime | int = Field(..., description="过期时间") - - @model_validator(mode="after") - def validate_fields(self): - """ - 校验 JWT 载荷字段的基本合法性。 - - 返回: - - JWTPayloadSchema: 校验后的载荷实例。 - - 异常: - - ValueError: 必填字段为空或格式不正确时抛出。 - """ - if not self.sub or len(self.sub.strip()) == 0: - raise ValueError("会话编号不能为空") - return self - - -class JWTOutSchema(BaseModel): - """JWT响应模型""" - - model_config = ConfigDict(from_attributes=True) - - access_token: str = Field(..., min_length=1, description="访问token") - refresh_token: str = Field(..., min_length=1, description="刷新token") - token_type: str = Field(default="Bearer", description="token类型") - expires_in: int = Field(..., gt=0, description="过期时间(秒)") - - -class RefreshTokenPayloadSchema(BaseModel): - """刷新Token载荷模型""" - - refresh_token: str = Field(..., min_length=1, description="刷新token") - - -class LogoutPayloadSchema(BaseModel): - """退出登录载荷模型""" - - token: str = Field(..., min_length=1, description="token") \ No newline at end of file diff --git a/backend/app/core/base_crud.py b/backend/app/core/base_crud.py index 44a7eb97..d4b4a970 100644 --- a/backend/app/core/base_crud.py +++ b/backend/app/core/base_crud.py @@ -1,16 +1,35 @@ +""""统一 CRUD 基类 — 同时支持有认证和无认证场景 + +- 当 `auth` 不为 None 时(Controller 注入):自动处理租户隔离、数据权限过滤、审计字段填充 +- 当 `auth` 为 None 时(后台任务/脚本):纯数据操作,无权限/租户过滤 + +用法: + # 带认证(Controller) + class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=UserModel, auth=auth) + + # 无认证(后台任务) + class OrderCRUD(CRUDBase[OrderModel, Any, Any]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=OrderModel, session=session) +""" + import builtins from collections.abc import Sequence -from datetime import datetime +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Generic, TypeVar from pydantic import BaseModel -from sqlalchemy import Select, asc, delete, desc, false, func, select, update +from sqlalchemy import Select, asc, delete, desc, false, func, literal_column, select, update from sqlalchemy import inspect as sa_inspect +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.sql.elements import ColumnElement -from app.api.v1.module_system.auth.schema import AuthSchema from app.core.base_model import MappedBase +from app.core.base_schema import AuthSchema, PageResultSchema +from app.core.database import async_db_session from app.core.exceptions import CustomException from app.core.permission import Permission @@ -24,50 +43,132 @@ OutSchemaType = TypeVar("OutSchemaType", bound=BaseModel) class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): - """基础数据层""" + """统一数据层基类 — 有认证时自动处理权限,无认证时纯数据操作""" - def __init__(self, model: type[ModelType], auth: AuthSchema) -> None: + def __init__( + self, + model: type[ModelType], + auth: AuthSchema | None = None, + session: AsyncSession | None = None, + ) -> None: """ - 初始化CRUDBase类 + 初始化 CRUDBase。 参数: - - model (Type[ModelType]): 数据模型类。 - - auth (AuthSchema): 认证信息。 - - 返回: - - None + - model: 数据模型类 + - auth: 认证信息(Controller 场景),为 None 时跳过所有权限/租户/审计逻辑 + - session: 数据库会话(后台任务场景),仅在 auth=None 时使用 """ self.model = model self.auth = auth + self.db = session if session is not None else (auth.db if auth else None) + if self.db is None: + raise RuntimeError( + "CRUDBase 未初始化数据库会话,请传入 auth 或 session 参数" + ) - async def get(self, preload: list[str | Any] | None = None, **kwargs) -> ModelType | None: - """ - 根据条件获取单个对象 + def _get_pk_col(self) -> ColumnElement: + """获取模型主键列""" + mapper = sa_inspect(self.model) + pk_cols = list(getattr(mapper, "primary_key", [])) + if not pk_cols: + raise CustomException(msg="模型缺少主键") + if len(pk_cols) > 1: + raise CustomException(msg="暂不支持复合主键操作") + return pk_cols[0] + + @property + def _supports_soft_delete(self) -> bool: + """模型是否支持软删除""" + return all( + hasattr(self.model, attr) for attr in ("is_deleted", "deleted_time", "deleted_id") + ) + + def _soft_delete_values(self) -> dict[str, Any]: + """软删除时需要更新的字段值""" + data: dict[str, Any] = {"is_deleted": True, "deleted_time": datetime.now()} + if self.auth and self.auth.user: + data["deleted_id"] = self.auth.user.id + return data + + # ── 查询 ── + async def _get_one( + self, session: AsyncSession, preload: list[str | Any] | None = None, **kwargs + ) -> ModelType | None: + """内部方法:在指定会话上执行单条查询(get / update 共用) 参数: - - preload (Optional[List[Union[str, Any]]]): 预加载关系,支持关系名字符串或SQLAlchemy loader option + - session: 数据库会话(只读 or 事务) + - preload: 预加载关系 - **kwargs: 查询条件 返回: - - Optional[ModelType]: 对象实例 + - 对象实例或 None + """ + conditions = await self.__build_conditions(**kwargs) + sql = select(self.model).where(*conditions) + for opt in self.__loader_options(preload): + sql = sql.options(opt) + sql = await self.__filter_permissions(sql) + result: Result = await session.execute(sql) + return result.scalars().first() - 异常: - - CustomException: 查询失败时抛出异常 + async def get(self, preload: list[str | Any] | None = None, **kwargs) -> ModelType | None: + """ + 根据条件获取单个对象(使用只读会话,不参与请求事务) + + 参数: + - preload: 预加载关系 + - **kwargs: 查询条件 + + 返回: + - 对象实例或 None + """ + try: + async with async_db_session() as rdb: + return await self._get_one(rdb, preload=preload, **kwargs) + except CustomException: + raise + except Exception as e: + raise CustomException(msg=f"获取查询失败: {e!s}") + + async def get_by_id(self, model_id: int) -> ModelType | None: + """按主键查询""" + return await self.get(id=model_id) + + async def exists(self, **kwargs) -> bool: + """ + 检查是否存在符合条件的记录 + + 参数: + - **kwargs: 查询条件 + + 返回: + - 是否存在 + """ + return await self.get(**kwargs) is not None + + async def count(self, **kwargs) -> int: + """ + 统计符合条件的记录数(使用只读会话,不参与请求事务) + + 参数: + - **kwargs: 查询条件,支持元组语法 + + 返回: + - 记录数 """ try: conditions = await self.__build_conditions(**kwargs) - sql = select(self.model).where(*conditions) - # 应用可配置的预加载选项 - for opt in self.__loader_options(preload): - sql = sql.options(opt) - - sql = await self.__filter_permissions(sql) - - result: Result = await self.auth.db.execute(sql) - obj = result.scalars().first() - return obj + count_sql = select(func.count()).select_from(self.model).where(*conditions) + count_sql = await self.__filter_permissions(count_sql) + async with async_db_session() as rdb: + result = await rdb.execute(count_sql) + return result.scalar() or 0 + except CustomException: + raise except Exception as e: - raise CustomException(msg=f"获取查询失败: {e!s}") + raise CustomException(msg=f"统计失败: {e!s}") async def list( self, @@ -76,29 +177,28 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): preload: list[str | Any] | None = None, ) -> Sequence[ModelType]: """ - 根据条件获取对象列表 + 根据条件获取对象列表(使用只读会话,不参与请求事务) 参数: - - search (Optional[Dict]): 查询条件,格式为 {'id': value, 'name': value} - - order_by (Optional[List[Dict[str, str]]]): 排序字段,格式为 [{'id': 'asc'}, {'name': 'desc'}] - - preload (Optional[List[Union[str, Any]]]): 预加载关系,支持关系名字符串或SQLAlchemy loader option + - search: 查询条件 + - order_by: 排序字段, 格式为 [{'id': 'asc'}, {'name': 'desc'}] + - preload: 预加载关系 返回: - - Sequence[ModelType]: 对象列表 - - 异常: - - CustomException: 查询失败时抛出异常 + - 对象列表 """ try: - conditions = await self.__build_conditions(**search) if search else [] + conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] - sql = select(self.model).where(*conditions).order_by(*self.__order_by(order)) - # 应用可配置的预加载选项 + sql = select(self.model).where(*conditions).order_by(*self._parse_order(order)) for opt in self.__loader_options(preload): sql = sql.options(opt) sql = await self.__filter_permissions(sql) - result: Result = await self.auth.db.execute(sql) + async with async_db_session() as rdb: + result: Result = await rdb.execute(sql) return result.scalars().all() + except CustomException: + raise except Exception as e: raise CustomException(msg=f"列表查询失败: {e!s}") @@ -110,41 +210,36 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): preload: builtins.list[str | Any] | None = None, ) -> Sequence[ModelType]: """ - 获取树形结构数据列表 + 获取树形结构数据列表(使用只读会话,不参与请求事务) 参数: - - search (Optional[Dict]): 查询条件 - - order_by (Optional[List[Dict[str, str]]]): 排序字段 - - children_attr (str): 子节点属性名 - - preload (Optional[List[Union[str, Any]]]): 额外预加载关系,若为None则默认包含children_attr + - search: 查询条件 + - order_by: 排序字段 + - children_attr: 子节点属性名 + - preload: 额外预加载关系 返回: - - Sequence[ModelType]: 树形结构数据列表 - - 异常: - - CustomException: 查询失败时抛出异常 + - 树形结构数据列表 """ try: - conditions = await self.__build_conditions(**search) if search else [] + conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] - sql = select(self.model).where(*conditions).order_by(*self.__order_by(order)) + sql = select(self.model).where(*conditions).order_by(*self._parse_order(order)) - # 处理预加载选项 final_preload = preload - # 如果没有提供preload且children_attr存在,则添加到预加载选项中 if preload is None and children_attr and hasattr(self.model, children_attr): - # 获取模型默认预加载选项 model_defaults = getattr(self.model, "__loader_options__", []) - # 将children_attr添加到默认预加载选项中 final_preload = [*list(model_defaults), children_attr] - # 应用预加载选项 for opt in self.__loader_options(final_preload): sql = sql.options(opt) sql = await self.__filter_permissions(sql) - result: Result = await self.auth.db.execute(sql) + async with async_db_session() as rdb: + result: Result = await rdb.execute(sql) return result.scalars().all() + except CustomException: + raise except Exception as e: raise CustomException(msg=f"树形列表查询失败: {e!s}") @@ -154,83 +249,85 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): limit: int, order_by: builtins.list[dict[str, str]], search: dict, - out_schema: type[OutSchemaType], + out_schema: type[OutSchemaType] | None = None, preload: builtins.list[str | Any] | None = None, - ) -> dict: + ) -> PageResultSchema: """ - 获取分页数据 + 获取分页数据(使用只读会话,不参与请求事务;count 与 data 共享同一会话) 参数: - - offset (int): 偏移量 - - limit (int): 每页数量 - - order_by (List[Dict[str, str]]): 排序字段 - - search (Dict): 查询条件 - - out_schema (Type[OutSchemaType]): 输出数据模型 - - preload (Optional[List[Union[str, Any]]]): 预加载关系 + - offset: 偏移量 + - limit: 每页数量 + - order_by: 排序字段 + - search: 查询条件 + - out_schema: 输出数据模型(None 时返回原始 ORM 对象) + - preload: 预加载关系 返回: - - Dict: 分页数据 - - 异常: - - CustomException: 查询失败时抛出异常 + - PageResultSchema: 分页结果 """ try: - conditions = await self.__build_conditions(**search) if search else [] + conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] - sql = select(self.model).where(*conditions).order_by(*self.__order_by(order)) - # 应用预加载选项 - for opt in self.__loader_options(preload): - sql = sql.options(opt) - sql = await self.__filter_permissions(sql) - # 优化count查询:使用主键计数而非全表扫描 mapper = sa_inspect(self.model) pk_cols = list(getattr(mapper, "primary_key", [])) - if pk_cols: - # 使用主键的第一列进行计数(主键必定非NULL,性能更好) - count_sql = select(func.count(pk_cols[0])).select_from(self.model) - else: - # 降级方案:使用count(*) - count_sql = select(func.count()).select_from(self.model) + pk = pk_cols[0] if pk_cols else literal_column("1") - if conditions: - count_sql = count_sql.where(*conditions) - count_sql = await self.__filter_permissions(count_sql) + data_sql = select(self.model).where(*conditions) + for opt in self.__loader_options(preload): + data_sql = data_sql.options(opt) + data_sql = await self.__filter_permissions(data_sql) - total_result = await self.auth.db.execute(count_sql) - total = total_result.scalar() or 0 + count_sql = select(func.count(pk)).select_from(self.model) + where_clause = data_sql.whereclause + if where_clause is not None: + count_sql = count_sql.where(where_clause) - result: Result = await self.auth.db.execute(sql.offset(offset).limit(limit)) - objs = result.scalars().all() + async with async_db_session() as rdb: + total_result = await rdb.execute(count_sql) + total = total_result.scalar() or 0 - return { - "page_no": offset // limit + 1 if limit else 1, - "page_size": limit or 10, - "total": total, - "has_next": offset + limit < total, - "items": [out_schema.model_validate(obj).model_dump() for obj in objs], - } + result: Result = await rdb.execute( + data_sql.order_by(*self._parse_order(order)).offset(offset).limit(limit) + ) + objs = result.scalars().all() + + items = ( + [out_schema.model_validate(obj).model_dump() for obj in objs] + if out_schema + else list(objs) + ) + + return PageResultSchema( + page_no=offset // limit + 1 if limit else 1, + page_size=limit or 10, + total=total, + has_next=offset + limit < total, + items=items, + ) + except CustomException: + raise except Exception as e: raise CustomException(msg=f"分页查询失败: {e!s}") - async def create(self, data: CreateSchemaType | dict) -> ModelType: - """ - 创建新对象 + # ── 创建 ── + async def create(self, data: CreateSchemaType) -> ModelType: + """创建新对象(有认证时自动填充租户与审计字段) + + 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 参数: - - data (Union[CreateSchemaType, Dict]): 对象属性 + - data: 对象属性 返回: - - ModelType: 新创建的对象实例 - - 异常: - - CustomException: 创建失败时抛出异常 + - 新创建的对象实例 """ try: - obj_dict = data if isinstance(data, dict) else data.model_dump() + obj_dict = data.model_dump() obj = self.model(**obj_dict) - if self.auth.user: + if self.auth and self.auth.user: if hasattr(obj, "tenant_id") and getattr(obj, "tenant_id", None) is None: setattr(obj, "tenant_id", self.auth.user.tenant_id) if hasattr(obj, "created_id"): @@ -238,39 +335,36 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): if hasattr(obj, "updated_id"): setattr(obj, "updated_id", self.auth.user.id) - self.auth.db.add(obj) - await self.auth.db.flush() - await self.auth.db.refresh(obj) + self.db.add(obj) + await self.db.flush() + await self.db.refresh(obj) return obj + except CustomException: + raise except Exception as e: raise CustomException(msg=f"创建失败: {e!s}") - async def update(self, id: int, data: UpdateSchemaType | dict) -> ModelType: - """ - 更新对象 + # ── 更新 ── + async def update(self, id: int, data: UpdateSchemaType) -> ModelType: + """更新对象(有认证时检查租户归属 + 填充审计字段) + + 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 参数: - - id (int): 对象ID - - data (Union[UpdateSchemaType, Dict]): 更新的属性及值 + - id: 对象 ID + - data: 更新属性 返回: - - ModelType: 更新后的对象实例 - - 异常: - - CustomException: 更新失败时抛出异常 + - 更新后的对象实例 """ try: - obj_dict = ( - data - if isinstance(data, dict) - else data.model_dump(exclude_unset=True, exclude={"id"}) - ) - # 获取对象时不自动预加载关系,避免循环依赖 - obj = await self.get(id=id, preload=[]) + obj_dict = data.model_dump(exclude_unset=True, exclude={"id"}) + obj = await self._get_one(self.db, id=id, preload=[]) if not obj: raise CustomException(msg="更新对象不存在") - if self.auth.user and not self.auth.user.is_superuser: + # 租户权限检查(仅在有认证且非超管时) + if self.auth and self.auth.user and not self.auth.user.is_superuser: if hasattr(obj, "tenant_id"): obj_tid = getattr(obj, "tenant_id", None) if obj_tid is not None and obj_tid != self.auth.user.tenant_id: @@ -279,250 +373,174 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): raise CustomException(msg="平台数据仅管理员可修改") raise CustomException(msg="无权修改其他租户的数据") - # 设置字段值(只检查一次current_user) - if self.auth.user and hasattr(obj, "updated_id"): + # 审计字段 + if self.auth and self.auth.user and hasattr(obj, "updated_id"): setattr(obj, "updated_id", self.auth.user.id) for key, value in obj_dict.items(): if hasattr(obj, key): setattr(obj, key, value) - await self.auth.db.flush() - # 刷新对象时不自动预加载关系 - await self.auth.db.refresh(obj) - - # 权限二次确认:flush后再次验证对象仍在权限范围内 - # 防止并发修改导致的权限逃逸(如其他事务修改了created_id) - # 验证时也不自动预加载关系 - verify_obj = await self.get(id=id, preload=[]) - if not verify_obj: - # 对象已被删除或权限已失效 - raise CustomException(msg="更新失败,对象不存在或无权限访问") - + await self.db.flush() + await self.db.refresh(obj) return obj + except CustomException: + raise except Exception as e: raise CustomException(msg=f"更新失败: {e!s}") - def __tenant_condition(self, read_mode: bool = False) -> builtins.list[ColumnElement]: - """ - 获取租户隔离条件。 - - read_mode=True 时对标记了 __platform_data_shared__ 的模型会同时放开 - tenant_id=1(平台数据),使租户用户也能读取平台共享数据;写入操作不受此影响。 - - 返回: - - List[ColumnElement]: 租户过滤条件列表,超管或无 tenant_id 字段时返回空列表。 - """ - if not hasattr(self.model, "tenant_id"): - return [] - if self.auth.user and self.auth.user.is_superuser: - return [] - tid = self.auth.user.tenant_id if self.auth.user else self.auth.tenant_id - if tid is not None: - own_filter = getattr(self.model, "tenant_id") == tid - if read_mode and getattr(self.model, "__platform_data_shared__", False) and tid != 1: - platform_filter = getattr(self.model, "tenant_id") == 1 - return [own_filter | platform_filter] - return [own_filter] - return [] - + # ── 删除 ── async def delete(self, ids: builtins.list[int]) -> None: - """ - 软删除对象 + """软删除对象(有认证时加租户隔离 + 填充删除人) + + 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 参数: - - ids (List[int]): 对象ID列表 - - 返回: - - None - - 异常: - - CustomException: 删除失败时抛出异常 + - ids: 对象 ID 列表 """ try: - mapper = sa_inspect(self.model) - pk_cols = list(getattr(mapper, "primary_key", [])) - if not pk_cols: - raise CustomException(msg="模型缺少主键,无法删除") - if len(pk_cols) > 1: - raise CustomException(msg="暂不支持复合主键的批量删除") + pk = self._get_pk_col() + tenant_conds = self.__tenant_condition() - # 检查模型是否支持软删除 - if ( - hasattr(self.model, "is_deleted") - and hasattr(self.model, "deleted_time") - and hasattr(self.model, "deleted_id") - ): - # 执行软删除 - update_data = {"is_deleted": True, "deleted_time": datetime.now()} - # 如果有当前用户,设置删除人ID - if self.auth.user: - update_data["deleted_id"] = self.auth.user.id - - # 只更新有权限的数据(主键 + 租户隔离) + if self._supports_soft_delete: sql = ( update(self.model) - .where(pk_cols[0].in_(ids), *self.__tenant_condition()) - .values(**update_data) + .where(pk.in_(ids), *tenant_conds) + .values(**self._soft_delete_values()) ) - await self.auth.db.execute(sql) - await self.auth.db.flush() + await self.db.execute(sql) else: - # 不支持软删除的模型,执行物理删除(租户隔离) - sql = delete(self.model).where(pk_cols[0].in_(ids), *self.__tenant_condition()) - await self.auth.db.execute(sql) - await self.auth.db.flush() + sql = delete(self.model).where(pk.in_(ids), *tenant_conds) + await self.db.execute(sql) + await self.db.flush() + except CustomException: + raise except Exception as e: raise CustomException(msg=f"删除失败: {e!s}") async def clear(self) -> None: - """ - 软清空对象表 + """软清空对象表(有认证时租户隔离) - 返回: - - None - - 异常: - - CustomException: 清空失败时抛出异常 + 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 """ try: - # 检查模型是否支持软删除 - if ( - hasattr(self.model, "is_deleted") - and hasattr(self.model, "deleted_time") - and hasattr(self.model, "deleted_id") - ): - # 执行软删除 - update_data = {"is_deleted": True, "deleted_time": datetime.now()} - # 如果有当前用户,设置删除人ID - if self.auth.user: - update_data["deleted_id"] = self.auth.user.id + tenant_conds = self.__tenant_condition() - # 更新所有数据为软删除状态(租户隔离) - sql = update(self.model).where(*self.__tenant_condition()).values(**update_data) - await self.auth.db.execute(sql) - await self.auth.db.flush() + if self._supports_soft_delete: + sql = update(self.model).where(*tenant_conds).values(**self._soft_delete_values()) + await self.db.execute(sql) else: - # 不支持软删除的模型,执行物理删除 - sql = delete(self.model) - await self.auth.db.execute(sql) - await self.auth.db.flush() + sql = delete(self.model).where(*tenant_conds) + await self.db.execute(sql) + await self.db.flush() + except CustomException: + raise except Exception as e: raise CustomException(msg=f"清空失败: {e!s}") + # ── 批量操作 ── async def set(self, ids: builtins.list[int], **kwargs) -> None: - """ - 批量更新对象 + """批量更新字段(有认证时租户隔离) + + 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 参数: - - ids (List[int]): 对象ID列表 - - **kwargs: 更新的属性及值 - - 返回: - - None - - 异常: - - CustomException: 更新失败时抛出异常 + - ids: 对象 ID 列表 + - **kwargs: 更新属性及值 """ try: - mapper = sa_inspect(self.model) - pk_cols = list(getattr(mapper, "primary_key", [])) - if not pk_cols: - raise CustomException(msg="模型缺少主键,无法更新") - if len(pk_cols) > 1: - raise CustomException(msg="暂不支持复合主键的批量更新") - - # 只更新有权限的数据(主键 + 租户隔离) + pk = self._get_pk_col() sql = ( update(self.model) - .where(pk_cols[0].in_(ids), *self.__tenant_condition()) + .where(pk.in_(ids), *self.__tenant_condition()) .values(**kwargs) ) - await self.auth.db.execute(sql) - await self.auth.db.flush() + await self.db.execute(sql) + await self.db.flush() except CustomException: raise except Exception as e: raise CustomException(msg=f"批量更新失败: {e!s}") async def restore(self, ids: builtins.list[int]) -> None: - """ - 恢复软删除的对象 + """恢复软删除对象(有认证时租户隔离) + + 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 参数: - - ids (List[int]): 对象ID列表 - - 返回: - - None - - 异常: - - CustomException: 恢复失败时抛出异常 + - ids: 对象 ID 列表 """ try: - mapper = sa_inspect(self.model) - pk_cols = list(getattr(mapper, "primary_key", [])) - if not pk_cols: - raise CustomException(msg="模型缺少主键,无法恢复") - if len(pk_cols) > 1: - raise CustomException(msg="暂不支持复合主键的批量恢复") - - # 检查模型是否支持软删除 - if ( - hasattr(self.model, "is_deleted") - and hasattr(self.model, "deleted_time") - and hasattr(self.model, "deleted_id") - ): - # 执行恢复操作 - update_data = {"is_deleted": False, "deleted_time": None, "deleted_id": None} - - # 只更新有权限的数据(主键 + 租户隔离) - sql = ( - update(self.model) - .where(pk_cols[0].in_(ids), *self.__tenant_condition()) - .values(**update_data) - ) - await self.auth.db.execute(sql) - await self.auth.db.flush() - else: + if not self._supports_soft_delete: raise CustomException(msg="该模型不支持软删除,无法恢复") + pk = self._get_pk_col() + sql = ( + update(self.model) + .where(pk.in_(ids), *self.__tenant_condition()) + .values(is_deleted=False, deleted_time=None, deleted_id=None) + ) + await self.db.execute(sql) + await self.db.flush() except CustomException: raise except Exception as e: raise CustomException(msg=f"恢复失败: {e!s}") + # ── 内部方法 ── + def __tenant_condition(self) -> builtins.list[ColumnElement]: + """ + 获取租户隔离条件(无认证时返回空列表) + + 返回: + - 租户过滤条件列表 + """ + if not self.auth or not self.auth.user: + return [] + if self.auth.user.is_superuser: + return [] + if not hasattr(self.model, "tenant_id"): + return [] + tid = self.auth.user.tenant_id + if tid is not None: + return [getattr(self.model, "tenant_id") == tid] + return [] + async def __filter_permissions(self, sql: Select) -> Select: - """ - 过滤数据权限(仅用于Select):先按租户隔离,再按业务权限策略过滤。 - """ - for condition in self.__tenant_condition(read_mode=True): - sql = sql.where(condition) - filter = Permission(model=self.model, auth=self.auth) - return await filter.filter_query(sql) + """过滤数据权限(仅用于 Select)""" + if not self.auth: + return sql + if getattr(self.model, "__platform_data_shared__", False): + for condition in self._platform_shared_conditions(): + sql = sql.where(condition) + filter_obj = Permission(model=self.model, auth=self.auth) + return await filter_obj.filter_query(sql) + + def _platform_shared_conditions(self) -> builtins.list[ColumnElement]: + """平台共享模型读取条件(租户可读 tenant_id=平台或自己的数据)""" + if not self.auth or not self.auth.user: + return [] + tid = self.auth.user.tenant_id + if tid is not None and tid != 1: + return [ + (getattr(self.model, "tenant_id") == tid) + | (getattr(self.model, "tenant_id") == 1) + ] + return [] async def __build_conditions(self, **kwargs) -> builtins.list[ColumnElement]: """ - 构建查询条件 + 构建查询条件(自动添加 is_deleted 过滤) 参数: - - **kwargs: 查询参数 + - **kwargs: 查询参数,支持元组语法: ("like", val), ("in", []), ("between", [a,b]) 等 返回: - - List[ColumnElement]: SQL条件表达式列表 - - 异常: - - CustomException: 查询参数不存在时抛出异常 + - SQL 条件表达式列表 """ - conditions = [] - # 默认添加软删除条件,只查询未删除的数据 - if hasattr(self.model, "is_deleted"): - conditions.append(getattr(self.model, "is_deleted") == False) + conditions: builtins.list[ColumnElement] = [] - # 多租户数据隔离:非超管自动按 tenant_id 过滤 - if hasattr(self.model, "tenant_id"): - if not self.auth.user or not self.auth.user.is_superuser: - tid = self.auth.user.tenant_id if self.auth.user else self.auth.tenant_id - if tid is not None: - conditions.append(getattr(self.model, "tenant_id") == tid) + if hasattr(self.model, "is_deleted"): + conditions.append(getattr(self.model, "is_deleted") == False) # noqa: E712 for key, value in kwargs.items(): if value is None or value == "": @@ -536,13 +554,21 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): elif seq == "not None": conditions.append(attr.isnot(None)) elif seq == "date" and val: - conditions.append(func.date_format(attr, "%Y-%m-%d") == val) + dt = datetime.strptime(val, "%Y-%m-%d") + conditions.append(attr >= dt) + conditions.append(attr < dt + timedelta(days=1)) elif seq == "month" and val: - conditions.append(func.date_format(attr, "%Y-%m") == val) + dt = datetime.strptime(val, "%Y-%m") + next_month = ( + dt.replace(year=dt.year + 1, month=1) + if dt.month == 12 + else dt.replace(month=dt.month + 1) + ) + conditions.append(attr >= dt) + conditions.append(attr < next_month) elif seq == "like" and val: conditions.append(attr.like(f"%{val}%")) elif seq == "in": - # 通用约定:("in", []) 应当返回空集(恒假),不能跳过条件导致查询退化成全量(仅权限过滤) if val is None: continue if isinstance(val, (list, tuple, set)) and len(val) == 0: @@ -551,38 +577,37 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): conditions.append(attr.in_(val)) elif seq == "between" and isinstance(val, (list, tuple)) and len(val) == 2: conditions.append(attr.between(val[0], val[1])) - elif seq == "!=" or (seq == "ne" and val): + elif seq in ("!=", "ne") and val is not None: conditions.append(attr != val) - elif seq == ">" or (seq == "gt" and val): + elif seq in (">", "gt") and val is not None: conditions.append(attr > val) - elif seq == ">=" or (seq == "ge" and val): + elif seq in (">=", "ge") and val is not None: conditions.append(attr >= val) - elif seq == "<" or (seq == "lt" and val): + elif seq in ("<", "lt") and val is not None: conditions.append(attr < val) - elif seq == "<=" or (seq == "le" and val): + elif seq in ("<=", "le") and val is not None: conditions.append(attr <= val) - elif seq == "==" or (seq == "eq" and val): + elif seq in ("eq", "==") and val is not None: conditions.append(attr == val) else: conditions.append(attr == value) return conditions - def __order_by(self, order_by: builtins.list[dict[str, str]]) -> builtins.list[ColumnElement]: + def _parse_order( + self, order: builtins.list[dict[str, str]] + ) -> builtins.list[ColumnElement]: """ - 获取排序字段 + 解析排序参数 参数: - - order_by (List[Dict[str, str]]): 排序字段列表,格式为 [{'id': 'asc'}, {'name': 'desc'}] + - order: 排序字段列表, 格式为 [{'id': 'asc'}, {'name': 'desc'}] 返回: - - List[ColumnElement]: 排序字段列表 - - 异常: - - CustomException: 排序字段不存在时抛出异常 + - 排序表达式列表 """ - columns = [] - for order in order_by: - for field, direction in order.items(): + columns: builtins.list[ColumnElement] = [] + for item in order: + for field, direction in item.items(): column = getattr(self.model, field) columns.append(desc(column) if direction.lower() == "desc" else asc(column)) return columns @@ -594,33 +619,27 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 构建预加载选项 参数: - - preload (Optional[List[Union[str, Any]]]): 预加载关系,支持关系名字符串或SQLAlchemy loader option + - preload: 预加载关系,支持关系名字符串或 SQLAlchemy loader option 返回: - - List[Any]: 预加载选项列表 + - 预加载选项列表 """ - options = [] - # 获取模型定义的默认加载选项 + options: builtins.list[Any] = [] model_loader_options = getattr(self.model, "__loader_options__", []) - # 合并所有需要预加载的选项 - all_preloads = set(model_loader_options) + all_preloads: set[str | Any] = set(model_loader_options) if preload: for opt in preload: if isinstance(opt, str): all_preloads.add(opt) elif preload == []: - # 如果明确指定空列表,则不使用任何预加载 all_preloads = set() - # 处理所有预加载选项 for opt in all_preloads: if isinstance(opt, str): - # 使用selectinload来避免在异步环境中的MissingGreenlet错误 if hasattr(self.model, opt): options.append(selectinload(getattr(self.model, opt))) else: - # 直接使用非字符串的加载选项 options.append(opt) return options diff --git a/backend/app/core/base_model.py b/backend/app/core/base_model.py index 97982831..5da8088d 100644 --- a/backend/app/core/base_model.py +++ b/backend/app/core/base_model.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import ( ) if TYPE_CHECKING: + from app.api.v1.module_platform.tenant.model import TenantModel from app.api.v1.module_system.user.model import UserModel from app.common.enums import PermissionFilterStrategy @@ -83,11 +84,11 @@ class ModelMixin(MappedBase): comment="UUID全局唯一标识", index=True, ) - status: Mapped[str] = mapped_column( - String(10), - default="0", + status: Mapped[int] = mapped_column( + Integer, + default=0, nullable=False, - comment="状态(0:正常 1:禁用)", + comment="状态", index=True, ) description: Mapped[str | None] = mapped_column( @@ -144,24 +145,20 @@ class TenantMixin(MappedBase): comment="租户ID", ) + @declared_attr + def tenant_by(self) -> Mapped[Optional["TenantModel"]]: + """ + 租户关联关系(延迟加载,避免循环依赖)。 -class TenantAwareMixin(MappedBase): - """ - 租户感知 Mixin(轻量级) - - 与 TenantMixin 不同,本 Mixin 不包含外键约束,tenant_id 可为 NULL。 - 适用于不需要严格引用完整性的场景,或渐进式改造阶段。 - 平台超级管理员在数据层不按租户过滤。 - """ - - __abstract__ = True - - tenant_id: Mapped[int | None] = mapped_column( - Integer, - default=None, - index=True, - comment="租户ID", - ) + 返回: + - Mapped[Optional[TenantModel]]: 租户 ORM 关系。 + """ + return relationship( + "TenantModel", + lazy="selectin", + foreign_keys=lambda: self.tenant_id, # pyright: ignore[reportArgumentType] + uselist=False, + ) class UserMixin(MappedBase): diff --git a/backend/app/core/base_params.py b/backend/app/core/base_params.py index f04c5ee5..f078a84c 100644 --- a/backend/app/core/base_params.py +++ b/backend/app/core/base_params.py @@ -36,9 +36,9 @@ class PaginationQueryParam: self.order_by = json.loads(order_by) except ValueError: # 如果解析失败,使用默认排序 - self.order_by = [{"updated_time": "desc"}] + self.order_by = [{"id": "desc"}] else: - self.order_by = [{"updated_time": "desc"}] + self.order_by = [{"id": "desc"}] class BaseQueryParam: diff --git a/backend/app/core/base_schema.py b/backend/app/core/base_schema.py index d14ff8e8..214810ad 100644 --- a/backend/app/core/base_schema.py +++ b/backend/app/core/base_schema.py @@ -1,7 +1,12 @@ -from pydantic import BaseModel, ConfigDict, Field +from datetime import datetime +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, model_validator from app.core.validator import DateTimeStr +T = TypeVar("T") + class CommonSchema(BaseModel): """通用信息模型""" @@ -19,7 +24,7 @@ class BaseSchema(BaseModel): id: int | None = Field(default=None, description="主键ID") uuid: str | None = Field(default=None, description="UUID") - status: str = Field(default="0", description="状态") + status: int = Field(default=0, description="状态") description: str | None = Field(default=None, description="描述") created_time: DateTimeStr | None = Field(default=None, description="创建时间") updated_time: DateTimeStr | None = Field(default=None, description="更新时间") @@ -46,14 +51,14 @@ class TenantBySchema(BaseModel): model_config = ConfigDict(from_attributes=True) tenant_id: int | None = Field(default=None, description="租户ID") - tenant: CommonSchema | None = Field(default=None, description="租户信息") + tenant_by: CommonSchema | None = Field(default=None, description="租户信息") class BatchSetAvailable(BaseModel): """批量设置可用状态的请求模型""" ids: list[int] = Field(default_factory=list, description="ID列表") - status: str = Field(default="0", description="是否可用") + status: int = Field(default=0, ge=0, le=1, description="是否可用") class UploadResponseSchema(BaseModel): @@ -78,3 +83,63 @@ class BatchDelete(BaseModel): """批量删除请求模型""" ids: list[int] = Field(..., min_length=1, description="ID列表") + + +class AuthSchema(BaseModel): + """权限认证模型""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + user: Any = Field(default=None, description="用户信息", exclude=True) + check_data_scope: bool = Field(default=True, description="是否检查数据权限") + db: Any = Field(default=None, description="数据库会话", exclude=True) + tenant_id: int | None = Field(default=None, description="租户ID,用于用户认证前查询") + + +class JWTPayloadSchema(BaseModel): + """JWT载荷模型""" + + sub: str = Field(..., description="用户登录信息") + is_refresh: bool = Field(default=False, description="是否刷新token") + exp: datetime | int = Field(..., description="过期时间") + + @model_validator(mode="after") + def validate_fields(self): + if not self.sub or len(self.sub.strip()) == 0: + raise ValueError("会话编号不能为空") + return self + + +class JWTOutSchema(BaseModel): + """JWT响应模型""" + + model_config = ConfigDict(from_attributes=True) + + access_token: str = Field(..., min_length=1, description="访问token") + refresh_token: str = Field(..., min_length=1, description="刷新token") + token_type: str = Field(default="Bearer", description="token类型") + expires_in: int = Field(..., gt=0, description="过期时间(秒)") + + +class RefreshTokenPayloadSchema(BaseModel): + """刷新Token载荷模型""" + + refresh_token: str = Field(..., min_length=1, description="刷新token") + + +class LogoutPayloadSchema(BaseModel): + """退出登录载荷模型""" + + token: str = Field(..., min_length=1, description="token") + + +class PageResultSchema(BaseModel, Generic[T]): + """分页查询结果模型""" + + model_config = ConfigDict(from_attributes=True) + + page_no: int | None = Field(default=None, ge=1, description="页码,默认为1") + page_size: int | None = Field(default=None, ge=1, description="页面大小,默认为10") + total: int = Field(default=0, ge=0, description="总记录数") + has_next: bool | None = Field(default=False, description="是否有下一页") + items: list[T] = Field(default_factory=list, description="分页后的数据列表") diff --git a/backend/app/core/context/request_context.py b/backend/app/core/context/request_context.py deleted file mode 100644 index 665458d6..00000000 --- a/backend/app/core/context/request_context.py +++ /dev/null @@ -1,131 +0,0 @@ -"""请求上下文管理 - -为每个请求维护独立的上下文信息(correlation_id、user_id 等), -通过 Python contextvars 实现异步安全传递。 -""" - -from contextvars import ContextVar, Token -from typing import Any - - -class RequestContext: - """请求级别上下文管理器 - - 使用 contextvars 确保在异步环境中每个请求的上下文独立。 - 典型用法: - ctx = RequestContext() - ctx.set_correlation_id("abc-123") - # 在请求的任何位置(包括异步子协程)都能获取到 - cid = RequestContext.get_correlation_id() - """ - - _correlation_id: ContextVar[str] = ContextVar("correlation_id", default="") - _user_id: ContextVar[int | None] = ContextVar("user_id", default=None) - _tenant_id: ContextVar[int | None] = ContextVar("tenant_id", default=None) - _request_path: ContextVar[str] = ContextVar("request_path", default="") - - @classmethod - def set_correlation_id(cls, correlation_id: str) -> Token: - """设置请求关联 ID - - 参数: - correlation_id: 唯一请求标识,通常从 X-Correlation-ID 请求头获取 - - 返回: - Token: contextvars Token,可用于恢复旧值 - """ - return cls._correlation_id.set(correlation_id) - - @classmethod - def get_correlation_id(cls) -> str: - """获取当前请求的关联 ID - - 如果没有设置则返回空字符串。 - - 返回: - str: 请求关联 ID - """ - return cls._correlation_id.get() - - @classmethod - def set_user_id(cls, user_id: int | None) -> Token: - """设置当前请求的用户 ID - - 参数: - user_id: 用户 ID,None 表示未认证 - - 返回: - Token: contextvars Token - """ - return cls._user_id.set(user_id) - - @classmethod - def get_user_id(cls) -> int | None: - """获取当前请求的用户 ID - - 返回: - Optional[int]: 用户 ID,未认证时返回 None - """ - return cls._user_id.get() - - @classmethod - def set_tenant_id(cls, tenant_id: int | None) -> Token: - """设置当前请求的租户 ID - - 参数: - tenant_id: 租户 ID - - 返回: - Token: contextvars Token - """ - return cls._tenant_id.set(tenant_id) - - @classmethod - def get_tenant_id(cls) -> int | None: - """获取当前请求的租户 ID - - 返回: - Optional[int]: 租户 ID - """ - return cls._tenant_id.get() - - @classmethod - def set_request_path(cls, path: str) -> Token: - """设置当前请求路径 - - 参数: - path: 请求路径 - - 返回: - Token: contextvars Token - """ - return cls._request_path.set(path) - - @classmethod - def get_request_path(cls) -> str: - """获取当前请求路径 - - 返回: - str: 请求路径 - """ - return cls._request_path.get() - - @classmethod - def get_extra_fields(cls) -> dict[str, Any]: - """获取日志附加字段 - - 将所有上下文信息组装为字典,用于日志输出。 - - 返回: - dict: 包含 correlation_id、user_id、tenant_id 的字典 - """ - fields: dict[str, Any] = { - "correlation_id": cls.get_correlation_id(), - } - user_id = cls.get_user_id() - if user_id is not None: - fields["user_id"] = user_id - tenant_id = cls.get_tenant_id() - if tenant_id is not None: - fields["tenant_id"] = tenant_id - return fields diff --git a/backend/app/core/database.py b/backend/app/core/database.py index f6336776..b6d2d744 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import sessionmaker from app.config.setting import settings from app.core.base_model import MappedBase from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger def create_engine_and_session( @@ -42,7 +42,7 @@ def create_engine_and_session( pool_recycle=settings.POOL_RECYCLE, ) except Exception as e: - log.error(f"❌ 数据库连接失败 {e}") + logger.error(f"❌ 数据库连接失败 {e}") raise else: # 同步数据库会话工厂 @@ -92,7 +92,7 @@ def create_async_engine_and_session( pool_use_lifo=settings.POOL_USE_LIFO, ) except Exception as e: - log.error(f"❌ 数据库连接失败 {e}") + logger.error(f"❌ 数据库连接失败 {e}") raise else: # 异步数据库会话工厂 @@ -163,14 +163,14 @@ async def redis_connect(app: FastAPI, status: bool) -> Redis | None: if await rd.ping(): # pyright: ignore[reportGeneralTypeIssues] return rd except exceptions.AuthenticationError as e: - log.error(f"❌ 数据库 Redis 认证失败: {e}") + logger.error(f"❌ 数据库 Redis 认证失败: {e}") raise except exceptions.TimeoutError as e: - log.error(f"❌ 数据库 Redis 连接超时: {e}") + logger.error(f"❌ 数据库 Redis 连接超时: {e}") raise except exceptions.RedisError as e: - log.error(f"❌ 数据库 Redis 连接错误: {e}") + logger.error(f"❌ 数据库 Redis 连接错误: {e}") raise else: await app.state.redis.close() - log.info("✅️ Redis连接已关闭") + logger.info("✅️ Redis连接已关闭") diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 4c12c4be..dceafe44 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -1,38 +1,32 @@ import json +import time from collections.abc import AsyncGenerator from fastapi import Depends, Query, Request from redis.asyncio.client import Redis +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.core.auth_schema import AuthSchema from app.common.enums import RedisInitKeyConfig from app.config.setting import settings +from app.core.base_schema import AuthSchema from app.core.database import async_db_session from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.core.security import OAuth2Schema, decode_access_token from app.core.tenant import get_current_tenant_id as _get_ctx_tenant_id -from app.core.tenant import set_current_tenant - -async def db_getter_read() -> AsyncGenerator[AsyncSession, None]: - """只读数据库会话 — 无事务开销,适用于纯 SELECT 查询。 - - 比 db_getter 少一次 BEGIN/ROLLBACK 网络往返, - 每次只读请求可省约 2 次 TCP 往返。 - """ - async with async_db_session() as session: - yield session +# 套餐菜单权限缓存: {tenant_id: (timestamp, [menu_ids])} +_package_menu_cache: dict[int, tuple[float, list[int]]] = {} async def db_getter() -> AsyncGenerator[AsyncSession, None]: - """读写数据库会话 — 带显式事务,适用于 INSERT/UPDATE/DELETE。 + """数据库会话 — 请求级生命周期管理。 - 返回: - - AsyncSession: 数据库会话连接 + 一个 HTTP 请求内所有写入共享同一个事务,要么全成功,要么全失败。 + 自动提交(无异常时)或回滚(异常时)。 """ async with async_db_session() as session: async with session.begin(): @@ -63,58 +57,61 @@ async def get_current_tenant_id() -> int | None: return _get_ctx_tenant_id() -async def get_current_user( - request: Request, - db: AsyncSession = Depends(db_getter), - redis: Redis = Depends(redis_getter), - token: str = Depends(OAuth2Schema), -) -> AuthSchema: - """获取当前用户 +# ── Token 解析 & 验证辅助函数 ── + + +def _decode_token_info(token: str) -> tuple[dict, str]: + """解码 JWT token 返回 (user_info, session_id) 参数: - - request (Request): 请求对象 - - db (AsyncSession): 数据库会话 - - redis (Redis): Redis连接 - - token (str): 访问令牌 + token: JWT token 字符串 返回: - - AuthSchema: 认证信息模型 + (user_info, session_id): 用户信息字典和会话 ID """ - # 延迟导入避免循环依赖 - from app.api.v1.module_system.user.crud import UserCRUD - from app.api.v1.module_system.user.model import UserModel - - if not token: - raise CustomException(msg="认证已失效", code=10401, status_code=401) - - # 处理Bearer token - if token.startswith("Bearer"): - token = token.split(" ")[1] - payload = decode_access_token(token) if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh: raise CustomException(msg="非法凭证", code=10401, status_code=401) online_user_info = payload.sub - # 从Redis中获取用户信息 - # online_user_info可能已经是字典类型,需要先判断 user_info = ( online_user_info if isinstance(online_user_info, dict) else json.loads(online_user_info) ) - session_id = user_info.get("session_id") if not session_id: raise CustomException(msg="认证已失效", code=10401, status_code=401) - # 检查用户是否在线 + return user_info, session_id + + +async def _check_token_online(redis: Redis, session_id: str) -> None: + """检查 token 是否在线(Redis 中存在对应 session) + + 参数: + redis: Redis 连接 + session_id: 会话 ID + """ online_ok = await RedisCURD(redis).exists( key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}" ) if not online_ok: raise CustomException(msg="认证已失效", code=10401, status_code=401) - # 如果启用了滑动过期,自动续期token - if settings.TOKEN_SLIDING_EXPIRE: + +async def _try_sliding_refresh(redis: Redis, session_id: str) -> None: + """滑动过期续期(仅在 token 剩余不足一半时触发) + + 参数: + redis: Redis 连接 + session_id: 会话 ID + """ + if not settings.TOKEN_SLIDING_EXPIRE: + return + + ttl = await RedisCURD(redis).ttl( + key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}" + ) + if ttl > 0 and ttl < settings.ACCESS_TOKEN_EXPIRE_MINUTES // 2: await RedisCURD(redis).expire( key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}", expire=settings.ACCESS_TOKEN_EXPIRE_MINUTES, @@ -124,41 +121,124 @@ async def get_current_user( expire=settings.REFRESH_TOKEN_EXPIRE_MINUTES, ) - # 关闭数据权限过滤(角色权限依赖用户对象,此时尚未加载) - # 租户隔离通过 tenant_id 单独控制,不依赖 check_data_scope - tenant_id = user_info.get("tenant_id") - is_super_admin = user_info.get("is_super_admin", False) - # 设置租户上下文(供 ORM 过滤器及下游代码使用) - set_current_tenant(tenant_id, is_super_admin) - auth = AuthSchema(db=db, tenant_id=tenant_id, check_data_scope=False) - username = user_info.get("user_name") - if not username: - raise CustomException(msg="认证已失效", code=10401, status_code=401) - # 获取用户信息,使用深层预加载确保RoleModel.creator被正确加载 - user = await UserCRUD(auth).get_by_username_crud( - username=username, - preload=[ - "dept", - selectinload(UserModel.roles), - "positions", - "created_by", - ], + +# ── 用户加载辅助函数 ── + + +async def _load_user_from_db(db: AsyncSession, username: str): + """从数据库加载用户(含角色、菜单、部门、职位全量预加载) + + 使用原始查询以绕过 CRUDBase 的权限过滤,确保用户认证阶段不受数据权限影响。 + 所有关系链均 eager-loaded,调用方可在会话关闭后安全访问对象属性。 + + 参数: + db: 数据库会话 + username: 用户名 + + 返回: + UserModel: 已全量加载的用户 ORM 对象 + """ + from app.api.v1.module_system.role.model import RoleModel + from app.api.v1.module_system.user.model import UserModel + + stmt = ( + select(UserModel) + .options( + selectinload(UserModel.dept), + selectinload(UserModel.roles).selectinload(RoleModel.menus), + selectinload(UserModel.positions), + selectinload(UserModel.created_by), + ) + .where(UserModel.username == username, UserModel.is_deleted == False) # noqa: E712 ) + result = await db.execute(stmt) + user = result.scalars().first() if not user: raise CustomException(msg="用户不存在", code=10401, status_code=401) - if user.status == "1": + if user.status == 1: raise CustomException(msg="用户已被停用", code=10401, status_code=401) - # 设置请求上下文 - request.scope["user_id"] = user.id - request.scope["user_username"] = user.username - - # 过滤可用的角色和职位 + # 过滤不可用的角色和职位(在会话内完成,确保关联数据已加载) if hasattr(user, "roles"): user.roles = [role for role in user.roles if role and role.status] if hasattr(user, "positions"): user.positions = [pos for pos in user.positions if pos and pos.status] + return user + + +# ── 依赖注入函数 ── + + +async def get_current_user( + request: Request, + db: AsyncSession = Depends(db_getter), + redis: Redis = Depends(redis_getter), + token: str = Depends(OAuth2Schema), +) -> AuthSchema: + """获取当前用户 + + 用户查询使用独立的只读数据库会话(不参与请求事务,查询完成后立即释放快照), + 返回的 auth.db 指向请求级事务会话供后续写操作使用。 + + 参数: + - request (Request): 请求对象 + - db (AsyncSession): 请求级事务会话 + - redis (Redis): Redis连接 + - token (str): 访问令牌 + + 返回: + - AuthSchema: 认证信息模型 + """ + if not token: + raise CustomException(msg="认证已失效", code=10401, status_code=401) + + # 处理Bearer token + if token.startswith("Bearer"): + token = token.split(" ")[1] + + # 优先使用 TenantMiddleware 缓存在 scope 中的 JWT 解码结果(避免重复解码) + cached_payload = request.scope.get("_jwt_payload") + cached_user_info = request.scope.get("_jwt_user_info") + + if cached_payload and cached_user_info: + payload = cached_payload + user_info = cached_user_info + else: + payload = decode_access_token(token) + if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh: + raise CustomException(msg="非法凭证", code=10401, status_code=401) + + online_user_info = payload.sub + user_info = ( + online_user_info if isinstance(online_user_info, dict) else json.loads(online_user_info) + ) + + session_id = user_info.get("session_id") + if not session_id: + raise CustomException(msg="认证已失效", code=10401, status_code=401) + + # Redis 在线检查 + 滑动续期 + await _check_token_online(redis, session_id) + await _try_sliding_refresh(redis, session_id) + + username = user_info.get("user_name") + if not username: + raise CustomException(msg="认证已失效", code=10401, status_code=401) + tenant_id = user_info.get("tenant_id") + + # 用户查询使用独立只读会话(不参与请求事务,查询后立即释放快照) + async with async_db_session() as lookup_db: + user = await _load_user_from_db(lookup_db, username) + + # 设置请求上下文 + request.scope["user_id"] = user.id + request.scope["user_username"] = user.username + request.scope["session_id"] = session_id + request.scope["session_info"] = user_info + + # 返回的 auth.db 指向请求级事务会话,供后续读写操作使用 + auth = AuthSchema(db=db, tenant_id=tenant_id, check_data_scope=False) auth.user = user return auth @@ -186,7 +266,7 @@ async def _verify_token( db: AsyncSession, redis: Redis, ) -> AuthSchema: - """验证token并返回用户信息 + """验证token并返回用户信息(共享核心逻辑) 参数: - token (str): 认证token @@ -196,10 +276,6 @@ async def _verify_token( 返回: - AuthSchema: 认证信息模型 """ - # 延迟导入避免循环依赖 - from app.api.v1.module_system.user.crud import UserCRUD - from app.api.v1.module_system.user.model import UserModel - if not token: raise CustomException(msg="认证已失效", code=10401, status_code=401) @@ -207,77 +283,51 @@ async def _verify_token( if token.startswith("Bearer"): token = token.split(" ")[1] - payload = decode_access_token(token) - if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh: - raise CustomException(msg="非法凭证", code=10401, status_code=401) + user_info, session_id = _decode_token_info(token) + await _check_token_online(redis, session_id) + await _try_sliding_refresh(redis, session_id) - online_user_info = payload.sub - # 从Redis中获取用户信息 - # online_user_info可能已经是字典类型,需要先判断 - user_info = ( - online_user_info if isinstance(online_user_info, dict) else json.loads(online_user_info) - ) - - session_id = user_info.get("session_id") - if not session_id: - raise CustomException(msg="认证已失效", code=10401, status_code=401) - - # 检查用户是否在线 - online_ok = await RedisCURD(redis).exists( - key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}" - ) - if not online_ok: - raise CustomException(msg="认证已失效", code=10401, status_code=401) - - # 如果启用了滑动过期,自动续期token - if settings.TOKEN_SLIDING_EXPIRE: - await RedisCURD(redis).expire( - key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}", - expire=settings.ACCESS_TOKEN_EXPIRE_MINUTES, - ) - await RedisCURD(redis).expire( - key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}", - expire=settings.REFRESH_TOKEN_EXPIRE_MINUTES, - ) - - # 设置租户上下文(供 ORM 过滤器使用) - tenant_id = user_info.get("tenant_id") - is_super_admin = user_info.get("is_super_admin", False) - set_current_tenant(tenant_id, is_super_admin) - # 关闭数据权限过滤,避免当前用户查询被拦截 - auth = AuthSchema(db=db, check_data_scope=False) username = user_info.get("user_name") if not username: raise CustomException(msg="认证已失效", code=10401, status_code=401) - # 获取用户信息,使用深层预加载确保RoleModel.creator被正确加载 - user = await UserCRUD(auth).get_by_username_crud( - username=username, - preload=[ - "dept", - selectinload(UserModel.roles), - "positions", - "created_by", - ], - ) - if not user: - raise CustomException(msg="用户不存在", code=10401, status_code=401) - if user.status == "1": - raise CustomException(msg="用户已被停用", code=10401, status_code=401) + tenant_id = user_info.get("tenant_id") - # 设置请求上下文 - # request.scope["user_id"] = user.id - # request.scope["user_username"] = user.username - - # 过滤可用的角色和职位 - if hasattr(user, "roles"): - user.roles = [role for role in user.roles if role and role.status] - if hasattr(user, "positions"): - user.positions = [pos for pos in user.positions if pos and pos.status] + user = await _load_user_from_db(db, username) + auth = AuthSchema(db=db, tenant_id=tenant_id, check_data_scope=False) auth.user = user return auth +# ── 套餐菜单缓存 ── + + +async def _get_cached_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]: + """获取租户可用菜单 ID,带 60s 进程级缓存 + + 套餐菜单变更频率极低,缓存可大幅减少 AuthPermission 的 DB 查询次数。 + + 参数: + auth: 认证信息 + tenant_id: 租户 ID + + 返回: + 可用菜单 ID 列表 + """ + cached = _package_menu_cache.get(tenant_id) + if cached and time.time() - cached[0] < 60: + return cached[1] + + from app.api.v1.module_platform.package.service import PackageService + + result = await PackageService.get_tenant_available_menu_ids(auth, tenant_id) + _package_menu_cache[tenant_id] = (time.time(), result) + return result + + +# ── 权限验证类 ── + + class AuthPermission: """权限验证类""" @@ -324,17 +374,28 @@ class AuthPermission: if not auth.user or not auth.user.roles: raise CustomException(msg="无权限操作", code=10403, status_code=403) - # 获取用户权限集合 - user_permissions = { - menu.permission - for role in auth.user.roles - for menu in role.menus - if role.status == "0" and menu.permission and menu.status == "0" - } + # 收集角色权限(附带 menu_id 用于套餐过滤) + role_perms: dict[str, int] = {} + for role in auth.user.roles: + if role.status != 0: + continue + for menu in role.menus: + if menu.status == 0 and menu.permission: + role_perms[menu.permission] = menu.id + + if not role_perms: + raise CustomException(msg="无权限操作", code=10403, status_code=403) + + # 租户用户:权限必须受套餐菜单约束(带 60s 进程级缓存) + if auth.tenant_id: + allowed_ids = set(await _get_cached_tenant_menu_ids(auth, auth.tenant_id)) + user_permissions = {p for p, mid in role_perms.items() if mid in allowed_ids} + else: + user_permissions = set(role_perms.keys()) # 权限验证 - 满足任一权限即可 if not any(perm in user_permissions for perm in self.permissions): - log.error(f"用户缺少任何所需的权限: {self.permissions}") + logger.error(f"用户缺少任何所需的权限: {self.permissions}") raise CustomException(msg="无权限操作", code=10403, status_code=403) return auth diff --git a/backend/app/core/di.py b/backend/app/core/di.py deleted file mode 100644 index 885b417b..00000000 --- a/backend/app/core/di.py +++ /dev/null @@ -1,45 +0,0 @@ -"""依赖注入容器 - -使用 dependency-injector 管理应用依赖,为后续 Repository 模式迁移提供基础。 - -当前状态:容器框架已搭建,但现有代码仍可直接使用 CRUDBase。 -Phase 2 将逐步迁移 Service 层使用容器注入的 Repository。 - -用法: - from app.core.di import container - - # 通过容器获取依赖 - event_bus = container.event_bus() -""" - -from dependency_injector import containers, providers - -from app.config.setting import get_settings - - -class ApplicationContainer(containers.DeclarativeContainer): - """全局依赖注入容器 - - 管理应用的各层依赖: - - 配置 - - 基础设施(Redis、数据库会话由 FastAPI Depends 管理) - - Repository(Phase 2 实现) - - Domain Services(Phase 3 实现) - """ - - # ==================== 配置 ==================== - config = providers.Singleton(get_settings) - - # ==================== 基础设施(待后续 Phase 启用) ==================== - # redis_client = providers.Singleton(RedisClient) - # cache_manager = providers.Singleton(CacheManager, redis=redis_client) - # event_bus = providers.Singleton(EventBus) - # task_queue = providers.Singleton(TaskQueue) - - # ==================== Repository(Phase 2 实现) ==================== - # user_repository = providers.Factory(UserRepositoryImpl) - # role_repository = providers.Factory(RoleRepositoryImpl) - - -# 全局容器单例 -container = ApplicationContainer() diff --git a/backend/app/core/discover.py b/backend/app/core/discover.py index 86f1237b..87eb0ca0 100644 --- a/backend/app/core/discover.py +++ b/backend/app/core/discover.py @@ -1,12 +1,12 @@ """ -简化的动态路由发现与注册 +简化的动态路由发现与注册。 目录与命名规范(不满足则无法注册或导入失败): - 插件必须放在 ``app/plugin`` 下,且**顶级目录名**必须以 ``module_`` 开头,例如 ``module_example``、``module_yourfeature``(扫描模式:``module_*/**/controller.py``)。 - 控制器文件名必须为 ``controller.py``(大小写敏感,Linux 上 ``Controller.py`` 无效)。 - 从 ``module_xxx`` 到 ``controller.py`` 的**每一级目录名**须为合法 Python 标识符 - (仅字母数字下划线、不以数字开头;不要使用中划线、空格、中文目录名等)。 + (仅字母数字下划线、不以数字开头;不要使用中划线、空格、中文目录名等)。 - 每一级目录应可作为包导入:通常需有 ``__init__.py``(或符合 namespace package 规则)。 - 在 ``controller.py`` 的**模块顶层**定义 ``APIRouter`` 实例并赋值给变量 (如 ``DemoRouter = APIRouter(...)``);定义在函数内部的 router **不会被**扫描到。 @@ -21,13 +21,197 @@ # 标准库导入 import importlib +import sys from pathlib import Path # 第三方库导入 -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from fastapi import FastAPI as _FastAPI # 内部库导入 -from app.core.logger import log +from app.core.logger import logger + +# 模块级缓存:最近一次构建的动态路由实例 +_dynamic_router_cache: APIRouter | None = None +# 记录最近一次注册到 app 的插件路径前缀,用于热重载时精准移除 +_registered_plugin_prefixes: set[str] = set() +# 运行时引用,由 init_app.py 设置 +_app_ref: _FastAPI | None = None + + +def set_app_ref(app: _FastAPI) -> None: + """由 init_app.py 在 app 创建后调用,存储引用供热重载使用。""" + global _app_ref + _app_ref = app + + +def _build_dynamic_router() -> APIRouter: + """实际执行扫描,返回新的动态路由 APIRouter 实例。""" + logger.info("🚀 开始动态路由发现与注册") + + root_router = APIRouter() + seen_router_ids: set[int] = set() + try: + base_package = importlib.import_module("app.plugin") + base_dir = Path(next(iter(base_package.__path__))) + + controller_files = list(base_dir.glob("module_*/**/controller.py")) + controller_files.sort() + + container_routers: dict[str, APIRouter] = {} + + for file in controller_files: + rel_path = file.relative_to(base_dir) + path_parts = rel_path.parts + top_module = path_parts[0] + + suffix = top_module[7:] if top_module.startswith("module_") else "" + if not suffix: + logger.error( + f"❌ 跳过异常顶级目录名(须为 module_ 前缀且后面还有名称): {top_module!r}," + f"文件: {file}" + ) + continue + prefix = f"/{suffix}" + + if prefix not in container_routers: + container_routers[prefix] = APIRouter(prefix=prefix) + container_router = container_routers[prefix] + + module_path = f"app.plugin.{'.'.join(path_parts[:-1])}.controller" + try: + module = importlib.import_module(module_path) + registered_here = 0 + for attr_name in dir(module): + attr_value = getattr(module, attr_name, None) + if isinstance(attr_value, APIRouter): + router_id = id(attr_value) + if router_id not in seen_router_ids: + seen_router_ids.add(router_id) + container_router.include_router(attr_value) + registered_here += 1 + logger.info(f" ↳ 注册 APIRouter 变量 `{attr_name}` ← {module_path}") + + if registered_here == 0: + logger.warning( + f"⚠️ 模块已加载但未注册任何路由: {module_path}\n" + f" 原因:该文件中未找到**顶层** APIRouter 实例。\n" + f" 规范:在 controller.py 模块顶层定义,例如 " + f"`XxxRouter = APIRouter(route_class=..., prefix=..., tags=[...])`," + f"不要仅在函数内创建 APIRouter。" + ) + + except Exception as e: + hint = _import_failure_hint(e) + logger.error(f"❌ 处理模块失败: {module_path}\n {hint}\n 异常: {e!s}") + + for prefix, container_router in sorted(container_routers.items()): + route_count = len(container_router.routes) + root_router.include_router(container_router) + if route_count == 0: + logger.warning( + f"⚠️ 容器前缀 {prefix} 下未挂载任何子路由(可能该 module 下所有 controller 均未导出 APIRouter)" + ) + logger.info(f"✅ 注册容器: {prefix} (子路由数: {route_count})") + + # 记录本次注册的所有插件前缀,供热重载时精准移除 + global _registered_plugin_prefixes + _registered_plugin_prefixes = set(container_routers.keys()) + + logger.info(f"✅ 动态路由发现完成: 共 {len(container_routers)} 个容器前缀") + return root_router + + except Exception as e: + logger.error(f"❌ 动态路由发现整体失败: {e!s}") + return root_router + + +def get_dynamic_router() -> APIRouter: + """ + 获取动态路由实例(带缓存)。 + + 返回: + - APIRouter: 包含所有动态路由的根路由实例 + """ + global _dynamic_router_cache + if _dynamic_router_cache is None: + _dynamic_router_cache = _build_dynamic_router() + return _dynamic_router_cache + + +def reload_dynamic_router() -> APIRouter: + """ + 清除插件模块缓存并重新扫描,同时更新已挂载到 FastAPI app 的路由。 + + 工作机制: + 1. 根据 _registered_plugin_prefixes 精准移除 app.routes 中已注册的插件路由 + 2. 清除 sys.modules 中缓存的插件模块 + 3. 重新扫描并构建新的动态路由 + 4. 将新路由挂载回 app(include_router) + + 要求:init_app.py 在创建 app 后调用 discover.set_app_ref(app), + 否则路由重载仅重建 Router 对象,不会更新运行中的 app。 + + 返回: + - APIRouter: 重新构建后的动态路由实例 + """ + global _dynamic_router_cache, _registered_plugin_prefixes, _app_ref + + app = _app_ref + rate_deps = [] + + # ── 1. 从运行中的 app 移除旧的插件路由 ── + if app and _registered_plugin_prefixes: + before = len(app.routes) + app.routes = [ + r for r in app.routes + if not any( + getattr(r, "path", "").startswith(p) + for p in _registered_plugin_prefixes + ) + ] + removed = before - len(app.routes) + logger.info(f"🧹 已移除 {removed} 条旧插件路由(前缀: {_registered_plugin_prefixes})") + elif not app: + logger.warning("⚠️ _app_ref 未设置,无法更新运行中的 app 路由;请确认 init_app.py 已调用 discover.set_app_ref(app)") + + # ── 2. 清除插件模块缓存,迫使 importlib 重新执行模块代码 ── + _purge_plugin_modules() + + # ── 3. 重新扫描并构建 ── + _dynamic_router_cache = _build_dynamic_router() + + # ── 4. 将新路由挂载回 app ── + if app: + # 构造与 init_app.py 中相同的依赖项 + try: + from fastapi_limiter.depends import RateLimiter + rate_deps = [Depends(RateLimiter(times=200, seconds=10))] + except Exception: + rate_deps = [] + app.include_router(_dynamic_router_cache, dependencies=rate_deps) + logger.info("✅ 新插件路由已挂载到运行中的 app") + + logger.info("✅ 插件动态路由热重载完成") + return _dynamic_router_cache + + +def _purge_plugin_modules() -> None: + """ + 从 sys.modules 中清除所有 app.plugin.module_* 模块, + 使下一次 importlib.import_module 时重新执行模块代码。 + """ + prefix = "app.plugin.module_" + purged: list[str] = [] + for mod_name in list(sys.modules): + if mod_name.startswith(prefix): + del sys.modules[mod_name] + purged.append(mod_name) + + if purged: + logger.info(f"🧹 已清除 {len(purged)} 个插件模块缓存,将重新导入") + else: + logger.info("🧹 未发现需要清除的插件模块缓存") def _import_failure_hint(exc: BaseException) -> str: @@ -59,111 +243,5 @@ def _import_failure_hint(exc: BaseException) -> str: ) -def get_dynamic_router() -> APIRouter: - """ - 执行动态路由发现与注册,返回包含所有动态路由的根路由实例 - - 返回: - - APIRouter: 包含所有动态路由的根路由实例 - """ - log.info("🚀 开始动态路由发现与注册") - - # 创建根路由实例 - root_router = APIRouter() - - # 已注册的路由ID集合,用于避免重复注册 - seen_router_ids: set[int] = set() - - try: - # 获取app.plugin包的路径 - base_package = importlib.import_module("app.plugin") - base_dir = Path(next(iter(base_package.__path__))) - - # 查找所有符合条件的controller.py文件 - # 只扫描module_*目录下的文件 - controller_files = list(base_dir.glob("module_*/**/controller.py")) - - # 按路径排序,确保注册顺序一致 - controller_files.sort() - - # 容器路由映射 {prefix: container_router} - container_routers: dict[str, APIRouter] = {} - - for file in controller_files: - # 解析文件路径 - rel_path = file.relative_to(base_dir) - path_parts = rel_path.parts - - # 获取顶级模块名 - top_module = path_parts[0] - - # 生成路由前缀 (module_xxx -> /xxx) - suffix = top_module[7:] if top_module.startswith("module_") else "" - if not suffix: - log.error( - f"❌ 跳过异常顶级目录名(须为 module_ 前缀且后面还有名称): {top_module!r}," - f"文件: {file}" - ) - continue - prefix = f"/{suffix}" - - # 获取或创建容器路由 - if prefix not in container_routers: - container_routers[prefix] = APIRouter(prefix=prefix) - container_router = container_routers[prefix] - - # 生成模块导入路径 - module_path = f"app.plugin.{'.'.join(path_parts[:-1])}.controller" - - try: - # 动态导入模块 - module = importlib.import_module(module_path) - - # 查找并注册所有APIRouter实例 - registered_here = 0 - for attr_name in dir(module): - attr_value = getattr(module, attr_name, None) - - # 只注册APIRouter实例,且避免重复注册 - if isinstance(attr_value, APIRouter): - router_id = id(attr_value) - if router_id not in seen_router_ids: - seen_router_ids.add(router_id) - container_router.include_router(attr_value) - registered_here += 1 - log.debug(f" ↳ 注册 APIRouter 变量 `{attr_name}` ← {module_path}") - - if registered_here == 0: - log.warning( - f"⚠️ 模块已加载但未注册任何路由: {module_path}\n" - f" 原因:该文件中未找到**顶层** APIRouter 实例。\n" - f" 规范:在 controller.py 模块顶层定义,例如 " - f"`XxxRouter = APIRouter(route_class=..., prefix=..., tags=[...])`," - f"不要仅在函数内创建 APIRouter。" - ) - - except Exception as e: - hint = _import_failure_hint(e) - log.exception(f"❌ 处理模块失败: {module_path}\n {hint}\n 异常: {e!s}") - - # 将所有容器路由注册到根路由 - for prefix, container_router in sorted(container_routers.items()): - route_count = len(container_router.routes) - root_router.include_router(container_router) - if route_count == 0: - log.warning( - f"⚠️ 容器前缀 {prefix} 下未挂载任何子路由(可能该 module 下所有 controller 均未导出 APIRouter)" - ) - log.info(f"✅️ 注册容器: {prefix} (子路由数: {route_count})") - - log.info(f"✅️ 动态路由发现完成: 共 {len(container_routers)} 个容器前缀") - return root_router - - except Exception as e: - log.exception(f"❌ 动态路由发现整体失败: {e!s}") - # 如果失败,返回一个空的路由实例 - return root_router - - # 重新导出函数供外部使用 -__all__ = ["get_dynamic_router"] +__all__ = ["get_dynamic_router", "reload_dynamic_router"] diff --git a/backend/app/core/docs.py b/backend/app/core/docs.py deleted file mode 100644 index b41b5818..00000000 --- a/backend/app/core/docs.py +++ /dev/null @@ -1,88 +0,0 @@ -from typing import Annotated - -from starlette.responses import HTMLResponse -from typing_extensions import Doc - - -def get_custom_ui_html( - *, - openapi_url: Annotated[ - str, - Doc( - """ - The OpenAPI URL that Swagger UI should load and use. - - This is normally done automatically by FastAPI using the default URL - `/openapi.json`. - """ - ), - ], - title: Annotated[ - str, - Doc( - """ - The HTML `{username} 您好:
您的验证码是:
验证码 5 分钟内有效,请勿泄露给他人。
此邮件由系统自动发送,请勿回复。
您的工单 {{ ticket_title }} 收到新回复:
{{ reply_content }}
回复时间:{{ reply_time }}
尊敬的 {{ tenant_name }}:
您的 {{ package_name }} 套餐将于 {{ expire_date }} 到期,剩余 {{ remaining_days }} 天。
到期后部分功能将受限,请及时续费以保证服务正常使用。
尊敬的客户:
您的发票已开具完成:
| 发票号码 | {{ invoice_no }} |
| 发票抬头 | {{ invoice_title }} |
| 开票金额 | ¥{{ invoice_amount }} |
建议在登录页面增加记住密码功能和第三方登录入口,提升用户体验。
", - "summary": "建议在登录页面增加记住密码功能和第三方登录入口", - "ticket_type": "suggestion", - "status": "2", - "tenant_id": 1, - "description": "用户体验优化" - }, - { - "title": "表格导出功能异常", - "ticket_content": "当数据量超过1000条时,导出Excel功能会超时失败。
", - "summary": "数据量超过1000条导出Excel超时", - "ticket_type": "bug", - "status": "1", - "tenant_id": 1, - "description": "导出功能问题" - }, - { - "title": "希望增加批量删除功能", - "ticket_content": "用户管理页面希望支持批量选择删除,提高管理效率。
", - "summary": "用户管理页面希望支持批量选择删除", - "ticket_type": "optimize", - "status": "0", - "tenant_id": 1, - "description": "功能优化建议" - } -] diff --git a/backend/app/scripts/data/platform_user_tenant.json b/backend/app/scripts/data/platform_user_tenant.json new file mode 100644 index 00000000..2aee7e4f --- /dev/null +++ b/backend/app/scripts/data/platform_user_tenant.json @@ -0,0 +1,62 @@ +[ + { + "user_id": 1, + "tenant_id": 1, + "role": "owner", + "is_default": 1 + }, + { + "user_id": 2, + "tenant_id": 1, + "role": "admin", + "is_default": 1 + }, + { + "user_id": 3, + "tenant_id": 1, + "role": "member", + "is_default": 1 + }, + { + "user_id": 4, + "tenant_id": 1, + "role": "member", + "is_default": 1 + }, + { + "user_id": 5, + "tenant_id": 1, + "role": "member", + "is_default": 1 + }, + { + "user_id": 1, + "tenant_id": 3, + "role": "owner", + "is_default": 0 + }, + { + "user_id": 6, + "tenant_id": 3, + "role": "owner", + "is_default": 1 + }, + { + "user_id": 7, + "tenant_id": 3, + "role": "member", + "is_default": 1 + }, + { + "user_id": 8, + "tenant_id": 4, + "role": "owner", + "is_default": 1 + }, + { + "user_id": 9, + "tenant_id": 4, + "role": "member", + "is_default": 1 + } +] \ No newline at end of file diff --git a/backend/app/scripts/data/sys_dept.json b/backend/app/scripts/data/sys_dept.json index 56e9151c..ed464466 100644 --- a/backend/app/scripts/data/sys_dept.json +++ b/backend/app/scripts/data/sys_dept.json @@ -3,7 +3,7 @@ "name": "集团总公司", "code": "GROUP", "order": 1, - "status": "0", + "status": 0, "leader": "张总", "phone": "13800138000", "email": "ceo@example.com", @@ -15,7 +15,7 @@ "name": "技术研发部", "code": "TECH", "order": 1, - "status": "0", + "status": 0, "leader": "李工", "phone": "13800138001", "email": "tech@example.com", @@ -27,7 +27,7 @@ "name": "后端开发组", "code": "BACKEND", "order": 1, - "status": "0", + "status": 0, "leader": "王工", "phone": "13800138002", "email": "backend@example.com", @@ -40,7 +40,7 @@ "name": "前端开发组", "code": "FRONTEND", "order": 2, - "status": "0", + "status": 0, "leader": "陈工", "phone": "13800138003", "email": "frontend@example.com", @@ -55,7 +55,7 @@ "name": "产品运营部", "code": "PRODUCT", "order": 2, - "status": "0", + "status": 0, "leader": "赵经理", "phone": "13800138004", "email": "product@example.com", @@ -68,7 +68,7 @@ "name": "人力资源部", "code": "HR", "order": 3, - "status": "0", + "status": 0, "leader": "刘经理", "phone": "13800138005", "email": "hr@example.com", @@ -78,5 +78,64 @@ "children": [] } ] + }, + { + "name": "星辰研发中心", + "code": "STAR_RND", + "order": 1, + "tenant_id": 3, + "parent_id": null, + "status": 0, + "description": "星辰科技研发部门", + "children": [ + { + "name": "前端组", + "code": "STAR_FE", + "order": 1, + "tenant_id": 3, + "status": 0 + }, + { + "name": "后端组", + "code": "STAR_BE", + "order": 2, + "tenant_id": 3, + "status": 0 + }, + { + "name": "测试组", + "code": "STAR_QA", + "order": 3, + "tenant_id": 3, + "status": 0 + } + ] + }, + { + "name": "星辰市场部", + "code": "STAR_MKT", + "order": 2, + "tenant_id": 3, + "parent_id": null, + "status": 0, + "description": "星辰科技市场部门" + }, + { + "name": "创新产品部", + "code": "INNO_PROD", + "order": 1, + "tenant_id": 4, + "parent_id": null, + "status": 0, + "description": "创新工坊产品团队" + }, + { + "name": "创新技术部", + "code": "INNO_TECH", + "order": 2, + "tenant_id": 4, + "parent_id": null, + "status": 0, + "description": "创新工坊技术团队" } -] +] \ No newline at end of file diff --git a/backend/app/scripts/data/sys_dict_data.json b/backend/app/scripts/data/sys_dict_data.json index 622570a8..fe354f8a 100644 --- a/backend/app/scripts/data/sys_dict_data.json +++ b/backend/app/scripts/data/sys_dict_data.json @@ -8,7 +8,7 @@ "css_class": "blue", "list_class": null, "is_default": true, - "status": "0", + "status": 0, "description": "性别男", "tenant_id": 1 }, @@ -21,7 +21,7 @@ "css_class": "pink", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "性别女", "tenant_id": 1 }, @@ -34,7 +34,7 @@ "css_class": "red", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "性别未知", "tenant_id": 1 }, @@ -47,7 +47,7 @@ "css_class": "", "list_class": "primary", "is_default": true, - "status": "0", + "status": 0, "description": "是", "tenant_id": 1 }, @@ -60,7 +60,7 @@ "css_class": "", "list_class": "danger", "is_default": false, - "status": "0", + "status": 0, "description": "否", "tenant_id": 1 }, @@ -73,7 +73,7 @@ "css_class": "", "list_class": "primary", "is_default": false, - "status": "0", + "status": 0, "description": "启用状态", "tenant_id": 1 }, @@ -86,7 +86,7 @@ "css_class": "", "list_class": "danger", "is_default": false, - "status": "0", + "status": 0, "description": "停用状态", "tenant_id": 1 }, @@ -99,7 +99,7 @@ "css_class": "blue", "list_class": "warning", "is_default": true, - "status": "0", + "status": 0, "description": "通知", "tenant_id": 1 }, @@ -112,7 +112,7 @@ "css_class": "orange", "list_class": "success", "is_default": false, - "status": "0", + "status": 0, "description": "公告", "tenant_id": 1 }, @@ -125,7 +125,7 @@ "css_class": "", "list_class": "info", "is_default": false, - "status": "0", + "status": 0, "description": "其他操作", "tenant_id": 1 }, @@ -138,7 +138,7 @@ "css_class": "", "list_class": "info", "is_default": false, - "status": "0", + "status": 0, "description": "新增操作", "tenant_id": 1 }, @@ -151,7 +151,7 @@ "css_class": "", "list_class": "info", "is_default": false, - "status": "0", + "status": 0, "description": "修改操作", "tenant_id": 1 }, @@ -164,7 +164,7 @@ "css_class": "", "list_class": "danger", "is_default": false, - "status": "0", + "status": 0, "description": "删除操作", "tenant_id": 1 }, @@ -177,7 +177,7 @@ "css_class": "", "list_class": "primary", "is_default": false, - "status": "0", + "status": 0, "description": "授权操作", "tenant_id": 1 }, @@ -190,7 +190,7 @@ "css_class": "", "list_class": "warning", "is_default": false, - "status": "0", + "status": 0, "description": "导出操作", "tenant_id": 1 }, @@ -203,7 +203,7 @@ "css_class": "", "list_class": "warning", "is_default": false, - "status": "0", + "status": 0, "description": "导入操作", "tenant_id": 1 }, @@ -216,7 +216,7 @@ "css_class": "", "list_class": "danger", "is_default": false, - "status": "0", + "status": 0, "description": "强退操作", "tenant_id": 1 }, @@ -229,7 +229,7 @@ "css_class": "", "list_class": "warning", "is_default": false, - "status": "0", + "status": 0, "description": "生成操作", "tenant_id": 1 }, @@ -242,7 +242,7 @@ "css_class": "", "list_class": "danger", "is_default": false, - "status": "0", + "status": 0, "description": "清空操作", "tenant_id": 1 }, @@ -255,7 +255,7 @@ "css_class": "", "list_class": null, "is_default": true, - "status": "0", + "status": 0, "description": "默认分组", "tenant_id": 1 }, @@ -268,7 +268,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "数据库分组", "tenant_id": 1 }, @@ -281,7 +281,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "reids分组", "tenant_id": 1 }, @@ -294,7 +294,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "线程池", "tenant_id": 1 }, @@ -307,7 +307,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "进程池", "tenant_id": 1 }, @@ -320,7 +320,7 @@ "css_class": "", "list_class": null, "is_default": true, - "status": "0", + "status": 0, "description": "演示函数", "tenant_id": 1 }, @@ -333,7 +333,7 @@ "css_class": "", "list_class": null, "is_default": true, - "status": "0", + "status": 0, "description": "指定日期任务触发器", "tenant_id": 1 }, @@ -346,7 +346,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "间隔触发器任务触发器", "tenant_id": 1 }, @@ -359,7 +359,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "间隔触发器任务触发器", "tenant_id": 1 }, @@ -372,7 +372,7 @@ "css_class": "", "list_class": null, "is_default": true, - "status": "0", + "status": 0, "description": "默认表格回显样式", "tenant_id": 1 }, @@ -385,7 +385,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "主要表格回显样式", "tenant_id": 1 }, @@ -398,7 +398,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "成功表格回显样式", "tenant_id": 1 }, @@ -411,7 +411,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "信息表格回显样式", "tenant_id": 1 }, @@ -424,7 +424,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "警告表格回显样式", "tenant_id": 1 }, @@ -437,7 +437,7 @@ "css_class": "", "list_class": null, "is_default": false, - "status": "0", + "status": 0, "description": "危险表格回显样式", "tenant_id": 1 } diff --git a/backend/app/scripts/data/sys_dict_type.json b/backend/app/scripts/data/sys_dict_type.json index 4451ec45..58354d59 100644 --- a/backend/app/scripts/data/sys_dict_type.json +++ b/backend/app/scripts/data/sys_dict_type.json @@ -2,70 +2,70 @@ { "dict_name": "用户性别", "dict_type": "sys_user_sex", - "status": "0", + "status": 0, "description": "用户性别列表", "tenant_id": 1 }, { "dict_name": "系统是否", "dict_type": "sys_yes_no", - "status": "0", + "status": 0, "description": "系统是否列表", "tenant_id": 1 }, { "dict_name": "系统状态", "dict_type": "sys_common_status", - "status": "0", + "status": 0, "description": "系统状态", "tenant_id": 1 }, { "dict_name": "通知类型", "dict_type": "sys_notice_type", - "status": "0", + "status": 0, "description": "通知类型列表", "tenant_id": 1 }, { "dict_name": "操作类型", "dict_type": "sys_oper_type", - "status": "0", + "status": 0, "description": "操作类型列表", "tenant_id": 1 }, { "dict_name": "任务存储器", "dict_type": "sys_job_store", - "status": "0", + "status": 0, "description": "任务分组列表", "tenant_id": 1 }, { "dict_name": "任务执行器", "dict_type": "sys_job_executor", - "status": "0", + "status": 0, "description": "任务执行器列表", "tenant_id": 1 }, { "dict_name": "任务函数", "dict_type": "sys_job_function", - "status": "0", + "status": 0, "description": "任务函数列表", "tenant_id": 1 }, { "dict_name": "任务触发器", "dict_type": "sys_job_trigger", - "status": "0", + "status": 0, "description": "任务触发器列表", "tenant_id": 1 }, { "dict_name": "表格回显样式", "dict_type": "sys_list_class", - "status": "0", + "status": 0, "description": "表格回显样式列表", "tenant_id": 1 } diff --git a/backend/app/scripts/data/sys_login_log.json b/backend/app/scripts/data/sys_login_log.json new file mode 100644 index 00000000..075e1732 --- /dev/null +++ b/backend/app/scripts/data/sys_login_log.json @@ -0,0 +1,122 @@ +[ + { + "username": "super", + "status": 1, + "login_location": "陕西省西安市", + "login_ip": "127.0.0.1", + "request_os": "macOS 14.5", + "request_browser": "Chrome 125", + "msg": "登录成功", + "tenant_id": 1 + }, + { + "username": "admin", + "status": 1, + "login_location": "陕西省西安市", + "login_ip": "127.0.0.1", + "request_os": "macOS 14.5", + "request_browser": "Chrome 125", + "msg": "登录成功", + "tenant_id": 1 + }, + { + "username": "user", + "status": 1, + "login_location": "北京市", + "login_ip": "192.168.1.100", + "request_os": "Windows 11", + "request_browser": "Edge 125", + "msg": "登录成功", + "tenant_id": 1 + }, + { + "username": "super", + "status": 2, + "login_location": "广东省深圳市", + "login_ip": "203.0.113.50", + "request_os": "Unknown", + "request_browser": "Unknown", + "msg": "密码错误,剩余尝试次数: 4", + "tenant_id": 1 + }, + { + "username": "product", + "status": 1, + "login_location": "上海市", + "login_ip": "10.0.0.88", + "request_os": "macOS 14.6", + "request_browser": "Safari 17.5", + "msg": "登录成功", + "tenant_id": 1 + }, + { + "username": "zhang_admin", + "status": 1, + "login_location": "浙江省杭州市", + "login_ip": "172.16.0.10", + "request_os": "Windows 10", + "request_browser": "Chrome 124", + "msg": "登录成功", + "tenant_id": 3 + }, + { + "username": "wang_dev", + "status": 1, + "login_location": "浙江省杭州市", + "login_ip": "172.16.0.20", + "request_os": "Ubuntu 22.04", + "request_browser": "Firefox 126", + "msg": "登录成功", + "tenant_id": 3 + }, + { + "username": "li_admin", + "status": 1, + "login_location": "四川省成都市", + "login_ip": "10.10.10.5", + "request_os": "macOS 15.0", + "request_browser": "Chrome 126", + "msg": "登录成功", + "tenant_id": 4 + }, + { + "username": "zhao_eng", + "status": 1, + "login_location": "四川省成都市", + "login_ip": "10.10.10.6", + "request_os": "macOS 15.0", + "request_browser": "Chrome 126", + "msg": "登录成功", + "tenant_id": 4 + }, + { + "username": "hr", + "status": 2, + "login_location": "陕西省西安市", + "login_ip": "127.0.0.1", + "request_os": "Windows 11", + "request_browser": "Chrome 125", + "msg": "账号已被锁定,请15分钟后重试", + "tenant_id": 1 + }, + { + "username": "super", + "status": 1, + "login_location": "日本东京", + "login_ip": "203.104.209.5", + "request_os": "iOS 18.0", + "request_browser": "Safari Mobile", + "msg": "登录成功", + "tenant_id": 1 + }, + { + "username": "test_user", + "status": 2, + "login_location": "美国洛杉矶", + "login_ip": "198.51.100.1", + "request_os": "Unknown", + "request_browser": "Unknown", + "msg": "用户不存在", + "tenant_id": 1 + } +] diff --git a/backend/app/scripts/data/sys_notice.json b/backend/app/scripts/data/sys_notice.json index 9f6e85aa..9786d56d 100644 --- a/backend/app/scripts/data/sys_notice.json +++ b/backend/app/scripts/data/sys_notice.json @@ -3,7 +3,7 @@ "notice_title": "系统上线公告", "notice_content": "欢迎使用 FastApiAdmin 系统!
这是一个功能强大的权限管理系统,支持多租户、角色权限控制等功能。
", "notice_type": "2", - "status": "0", + "status": 0, "description": "系统上线公告", "tenant_id": 1 }, @@ -11,7 +11,7 @@ "notice_title": "系统维护通知", "notice_content": "系统将于本周六凌晨2:00-4:00进行例行维护,请提前保存工作。
", "notice_type": "1", - "status": "0", + "status": 0, "description": "系统维护通知", "tenant_id": 1 }, @@ -19,8 +19,32 @@ "notice_title": "新功能发布", "notice_content": "本次更新新增了工作流引擎、代码生成器等功能,欢迎体验!
", "notice_type": "2", - "status": "0", + "status": 0, "description": "新功能发布", "tenant_id": 1 + }, + { + "notice_title": "安全更新提醒", + "notice_content": "请所有用户尽快更新密码,建议使用至少8位包含大小写字母、数字和特殊字符的强密码。
更新方法:登录后进入「个人中心」->「修改密码」。
", + "notice_type": "1", + "status": 0, + "description": "安全更新提醒", + "tenant_id": 1 + }, + { + "notice_title": "节假日值班安排", + "notice_content": "春节假期(2月10日-2月17日)期间系统值班安排如下:
联系电话:138-0000-0000
紧急问题请直接联系值班人员。
", + "notice_type": "1", + "status": 0, + "description": "节假日值班通知", + "tenant_id": 1 + }, + { + "notice_title": "v2.0 版本升级公告", + "notice_content": "v2.0 大版本即将发布,主要更新:
升级时间另行通知。
", + "notice_type": "2", + "status": 0, + "description": "v2.0 版本升级公告", + "tenant_id": 1 } ] diff --git a/backend/app/scripts/data/sys_notice_read.json b/backend/app/scripts/data/sys_notice_read.json new file mode 100644 index 00000000..833dd32e --- /dev/null +++ b/backend/app/scripts/data/sys_notice_read.json @@ -0,0 +1,52 @@ +[ + { + "user_id": 1, + "notice_id": 1, + "read_time": "2025-06-01 09:15:00" + }, + { + "user_id": 1, + "notice_id": 2, + "read_time": "2025-06-10 08:30:00" + }, + { + "user_id": 1, + "notice_id": 3, + "read_time": "2025-07-01 10:00:00" + }, + { + "user_id": 2, + "notice_id": 1, + "read_time": "2025-06-01 09:20:00" + }, + { + "user_id": 2, + "notice_id": 2, + "read_time": "2025-06-10 09:00:00" + }, + { + "user_id": 3, + "notice_id": 1, + "read_time": "2025-06-01 10:30:00" + }, + { + "user_id": 4, + "notice_id": 1, + "read_time": "2025-06-02 14:00:00" + }, + { + "user_id": 5, + "notice_id": 1, + "read_time": "2025-06-03 11:00:00" + }, + { + "user_id": 6, + "notice_id": 6, + "read_time": "2025-06-20 10:00:00" + }, + { + "user_id": 8, + "notice_id": 2, + "read_time": "2025-06-10 16:00:00" + } +] diff --git a/backend/app/scripts/data/sys_operation_log.json b/backend/app/scripts/data/sys_operation_log.json index cdf75744..0f466392 100644 --- a/backend/app/scripts/data/sys_operation_log.json +++ b/backend/app/scripts/data/sys_operation_log.json @@ -1,41 +1,152 @@ [ { - "request_path": "/api/v1/auth/login", + "request_path": "/api/v1/system/auth/login", "request_method": "POST", - "request_payload": "{\"username\": \"admin\", \"password\": \"******\"}", - "request_ip": "192.168.1.100", - "request_os": "Windows 10", - "request_browser": "Chrome 120.0", + "request_payload": "{\"username\": \"super\", \"password\": \"***\"}", "response_code": 200, - "response_json": "{\"code\": 200, \"message\": \"登录成功\"}", - "process_time": "0.125s", + "response_json": "{\"code\": 200, \"msg\": \"登录成功\"}", + "process_time": "45ms", "tenant_id": 1, - "created_id": 1 + "description": "用户登录" }, { - "request_path": "/api/v1/user/list", + "request_path": "/api/v1/system/user/current/info", "request_method": "GET", "request_payload": null, - "request_ip": "192.168.1.100", - "request_os": "Windows 10", - "request_browser": "Chrome 120.0", "response_code": 200, - "response_json": "{\"code\": 200, \"data\": [...]}", - "process_time": "0.052s", + "response_json": "{\"code\": 200, \"data\": {\"username\": \"super\"}}", + "process_time": "12ms", "tenant_id": 1, - "created_id": 1 + "description": "获取当前用户信息" }, { - "request_path": "/api/v1/role/create", + "request_path": "/api/v1/platform/menu/create", "request_method": "POST", - "request_payload": "{\"name\": \"测试角色\", \"code\": \"TEST\"}", - "request_ip": "192.168.1.101", - "request_os": "macOS 14.0", - "request_browser": "Safari 17.0", + "request_payload": "{\"name\": \"测试菜单\", \"type\": 2, \"parent_id\": 1}", "response_code": 200, - "response_json": "{\"code\": 200, \"message\": \"创建成功\"}", - "process_time": "0.089s", + "response_json": "{\"code\": 200, \"msg\": \"创建成功\"}", + "process_time": "23ms", "tenant_id": 1, - "created_id": 2 + "description": "创建菜单" + }, + { + "request_path": "/api/v1/system/user/update/3", + "request_method": "PUT", + "request_payload": "{\"name\": \"普通用户\", \"status\": 0}", + "response_code": 200, + "response_json": "{\"code\": 200, \"msg\": \"更新成功\"}", + "process_time": "18ms", + "tenant_id": 1, + "description": "更新用户信息" + }, + { + "request_path": "/api/v1/system/dept/create", + "request_method": "POST", + "request_payload": "{\"name\": \"测试部门\", \"parent_id\": 1}", + "response_code": 400, + "response_json": "{\"code\": 400, \"msg\": \"部门编码已存在\"}", + "process_time": "8ms", + "tenant_id": 1, + "description": "创建部门(失败)" + }, + { + "request_path": "/api/v1/system/role/delete", + "request_method": "DELETE", + "request_payload": "{\"ids\": [5]}", + "response_code": 200, + "response_json": "{\"code\": 200, \"msg\": \"删除成功\"}", + "process_time": "15ms", + "tenant_id": 1, + "description": "删除角色" + }, + { + "request_path": "/api/v1/platform/menu/list", + "request_method": "GET", + "request_payload": null, + "response_code": 200, + "response_json": "{\"code\": 200, \"data\": {\"items\": [...]}}", + "process_time": "35ms", + "tenant_id": 3, + "description": "查询菜单列表" + }, + { + "request_path": "/api/v1/system/dict/data/list", + "request_method": "GET", + "request_payload": null, + "response_code": 200, + "response_json": "{\"code\": 200, \"data\": {\"items\": [...]}}", + "process_time": "22ms", + "tenant_id": 3, + "description": "查询字典数据" + }, + { + "request_path": "/api/v1/workflow/definition/create", + "request_method": "POST", + "request_payload": "{\"name\": \"审批流程\", \"code\": \"approval_v1\"}", + "response_code": 200, + "response_json": "{\"code\": 200, \"msg\": \"创建成功\"}", + "process_time": "28ms", + "tenant_id": 4, + "description": "创建工作流" + }, + { + "request_path": "/api/v1/system/notice/create", + "request_method": "POST", + "request_payload": "{\"notice_title\": \"测试通知\", \"notice_type\": \"1\"}", + "response_code": 200, + "response_json": "{\"code\": 200, \"msg\": \"创建成功\"}", + "process_time": "11ms", + "tenant_id": 1, + "description": "创建通知" + }, + { + "request_path": "/api/v1/system/user/export", + "request_method": "POST", + "request_payload": "{\"status\": 0}", + "response_code": 200, + "response_json": "{\"file\": \"用户列表_20250601.xlsx\"}", + "process_time": "156ms", + "tenant_id": 1, + "description": "导出用户数据" + }, + { + "request_path": "/api/v1/system/user/import", + "request_method": "POST", + "request_payload": "\"file\": \"users.xlsx\" (multipart/form-data)", + "response_code": 200, + "response_json": "{\"code\": 200, \"msg\": \"成功导入 25 条数据\"}", + "process_time": "320ms", + "tenant_id": 1, + "description": "批量导入用户" + }, + { + "request_path": "/api/v1/cronjob/node/execute/1", + "request_method": "POST", + "request_payload": "{\"trigger\": \"now\"}", + "response_code": 200, + "response_json": "{\"code\": 200, \"msg\": \"调试节点成功\"}", + "process_time": "1024ms", + "tenant_id": 1, + "description": "执行定时任务节点" + }, + { + "request_path": "/api/v1/workflow/definition/execute", + "request_method": "POST", + "request_payload": "{\"workflow_id\": 1, \"variables\": {}}", + "response_code": 200, + "response_json": "{\"code\": 200, \"data\": {\"status\": \"completed\"}}", + "process_time": "3200ms", + "tenant_id": 4, + "description": "执行工作流" + }, + { + "request_path": "/api/v1/cronjob/job/log/delete", + "request_method": "DELETE", + "request_payload": "{\"ids\": [1, 2, 3]}", + "response_code": 200, + "response_json": "{\"code\": 200, \"msg\": \"删除成功\"}", + "process_time": "19ms", + "tenant_id": 1, + "description": "批量删除执行日志" } ] diff --git a/backend/app/scripts/data/sys_param.json b/backend/app/scripts/data/sys_param.json index 9bc6d5b1..11a5ee3d 100644 --- a/backend/app/scripts/data/sys_param.json +++ b/backend/app/scripts/data/sys_param.json @@ -4,7 +4,7 @@ "config_key": "demo_enable", "config_value": "false", "config_type": true, - "status": "0", + "status": 0, "description": "是否启用演示模式", "tenant_id": 1 }, @@ -13,7 +13,7 @@ "config_key": "ip_white_list", "config_value": "[\"127.0.0.1\", \"::1\"]", "config_type": true, - "status": "0", + "status": 0, "description": "演示模式下允许访问的IP列表", "tenant_id": 1 }, @@ -22,7 +22,7 @@ "config_key": "white_api_list_path", "config_value": "[\"/api/v1/system/auth/login\", \"/api/v1/system/auth/token/refresh\", \"/api/v1/system/auth/captcha/get\", \"/api/v1/system/auth/logout\", \"/api/v1/system/config/info\", \"/api/v1/system/user/current/info\", \"/api/v1/system/notice/available\", \"/api/v1/system/auth/auto-login/users\", \"/api/v1/system/auth/auto-login/token\", \"/api/v1/system/auth/auto-login\", \"/common/health\", \"/common/health/ready\", \"/common/health/live\", \"/metrics\"]", "config_type": true, - "status": "0", + "status": 0, "description": "无需登录即可访问的接口列表", "tenant_id": 1 }, @@ -31,7 +31,7 @@ "config_key": "ip_black_list", "config_value": "[]", "config_type": true, - "status": "0", + "status": 0, "description": "禁止访问的IP列表", "tenant_id": 1 }, @@ -40,7 +40,7 @@ "config_key": "login_failed_limit", "config_value": "5", "config_type": true, - "status": "0", + "status": 0, "description": "登录失败最大次数", "tenant_id": 1 }, @@ -49,7 +49,7 @@ "config_key": "login_lock_time", "config_value": "15", "config_type": true, - "status": "0", + "status": 0, "description": "登录失败后锁定时间", "tenant_id": 1 }, @@ -58,7 +58,7 @@ "config_key": "token_expire_minutes", "config_value": "120", "config_type": true, - "status": "0", + "status": 0, "description": "Access Token过期时间", "tenant_id": 1 }, @@ -67,7 +67,7 @@ "config_key": "refresh_token_expire_days", "config_value": "7", "config_type": true, - "status": "0", + "status": 0, "description": "Refresh Token过期时间", "tenant_id": 1 }, @@ -76,7 +76,7 @@ "config_key": "password_expire_days", "config_value": "90", "config_type": true, - "status": "0", + "status": 0, "description": "密码有效期", "tenant_id": 1 }, @@ -85,7 +85,7 @@ "config_key": "password_min_length", "config_value": "6", "config_type": true, - "status": "0", + "status": 0, "description": "密码最小长度", "tenant_id": 1 }, @@ -94,7 +94,7 @@ "config_key": "captcha_enable", "config_value": "true", "config_type": true, - "status": "0", + "status": 0, "description": "登录时是否启用验证码", "tenant_id": 1 }, @@ -103,7 +103,7 @@ "config_key": "operation_log_enable", "config_value": "true", "config_type": true, - "status": "0", + "status": 0, "description": "是否记录用户操作日志", "tenant_id": 1 }, @@ -112,7 +112,7 @@ "config_key": "operation_log_retention_days", "config_value": "90", "config_type": true, - "status": "0", + "status": 0, "description": "操作日志保留天数", "tenant_id": 1 }, @@ -121,7 +121,7 @@ "config_key": "login_log_retention_days", "config_value": "30", "config_type": true, - "status": "0", + "status": 0, "description": "登录日志保留天数", "tenant_id": 1 }, @@ -130,7 +130,7 @@ "config_key": "file_upload_max_size", "config_value": "50", "config_type": true, - "status": "0", + "status": 0, "description": "单个文件上传最大大小", "tenant_id": 1 }, @@ -139,7 +139,7 @@ "config_key": "ip_location_enable", "config_value": "false", "config_type": true, - "status": "0", + "status": 0, "description": "登录时是否查询IP归属地", "tenant_id": 1 } diff --git a/backend/app/scripts/data/sys_position.json b/backend/app/scripts/data/sys_position.json index ed901fa8..634c0222 100644 --- a/backend/app/scripts/data/sys_position.json +++ b/backend/app/scripts/data/sys_position.json @@ -3,7 +3,7 @@ "name": "技术总监", "code": "TECH_DIRECTOR", "order": 1, - "status": "0", + "status": 0, "description": "技术部门负责人", "tenant_id": 1 }, @@ -11,7 +11,7 @@ "name": "高级工程师", "code": "SR_ENGINEER", "order": 2, - "status": "0", + "status": 0, "description": "高级技术岗位", "tenant_id": 1 }, @@ -19,7 +19,7 @@ "name": "工程师", "code": "ENGINEER", "order": 3, - "status": "0", + "status": 0, "description": "技术岗位", "tenant_id": 1 }, @@ -27,7 +27,7 @@ "name": "产品经理", "code": "PRODUCT_MANAGER", "order": 4, - "status": "0", + "status": 0, "description": "产品管理岗位", "tenant_id": 1 }, @@ -35,7 +35,7 @@ "name": "运营专员", "code": "OPERATOR", "order": 5, - "status": "0", + "status": 0, "description": "运营岗位", "tenant_id": 1 }, @@ -43,7 +43,7 @@ "name": "HR专员", "code": "HR_STAFF", "order": 6, - "status": "0", + "status": 0, "description": "人事专员", "tenant_id": 1 } diff --git a/backend/app/scripts/data/sys_role.json b/backend/app/scripts/data/sys_role.json index 03230930..730dc1a0 100644 --- a/backend/app/scripts/data/sys_role.json +++ b/backend/app/scripts/data/sys_role.json @@ -2,7 +2,7 @@ { "name": "超级管理员", "code": "SUPER_ADMIN", - "status": "0", + "status": 0, "order": 1, "data_scope": 4, "description": "拥有系统最高权限", @@ -11,7 +11,7 @@ { "name": "管理员", "code": "ADMIN", - "status": "0", + "status": 0, "order": 2, "data_scope": 3, "description": "管理租户内所有资源", @@ -20,10 +20,46 @@ { "name": "普通用户", "code": "USER", - "status": "0", + "status": 0, "order": 3, "data_scope": 1, "description": "仅能查看和操作自己的数据", "tenant_id": 1 + }, + { + "name": "星辰管理员", + "code": "STAR_ADMIN", + "order": 1, + "data_scope": 4, + "tenant_id": 3, + "status": 0, + "description": "星辰科技有限公司管理员" + }, + { + "name": "星辰员工", + "code": "STAR_STAFF", + "order": 2, + "data_scope": 2, + "tenant_id": 3, + "status": 0, + "description": "星辰科技有限公司普通员工" + }, + { + "name": "创新管理员", + "code": "INNO_ADMIN", + "order": 1, + "data_scope": 4, + "tenant_id": 4, + "status": 0, + "description": "创新工坊管理员" + }, + { + "name": "创新员工", + "code": "INNO_STAFF", + "order": 2, + "data_scope": 2, + "tenant_id": 4, + "status": 0, + "description": "创新工坊普通员工" } -] +] \ No newline at end of file diff --git a/backend/app/scripts/data/sys_ticket.json b/backend/app/scripts/data/sys_ticket.json new file mode 100644 index 00000000..5d6b3e76 --- /dev/null +++ b/backend/app/scripts/data/sys_ticket.json @@ -0,0 +1,98 @@ +[ + { + "title": "系统登录页面优化建议", + "ticket_content": "建议在登录页面增加记住密码功能和第三方登录入口,提升用户体验。
", + "summary": "建议在登录页面增加记住密码功能和第三方登录入口", + "ticket_type": "suggestion", + "images": null, + "reply": "感谢您的建议,我们将在下个版本中加入记住密码功能。", + "assigned_id": 2, + "status": 2, + "tenant_id": 1, + "description": "用户体验优化" + }, + { + "title": "表格导出功能异常", + "ticket_content": "当数据量超过1000条时,导出Excel功能会超时失败。
", + "summary": "数据量超过1000条导出Excel超时", + "ticket_type": "bug", + "images": null, + "reply": null, + "assigned_id": 3, + "status": 1, + "tenant_id": 1, + "description": "导出功能问题" + }, + { + "title": "希望增加批量删除功能", + "ticket_content": "用户管理页面希望支持批量选择删除,提高管理效率。
", + "summary": "用户管理页面希望支持批量选择删除", + "ticket_type": "optimize", + "images": null, + "reply": null, + "assigned_id": null, + "status": 0, + "tenant_id": 1, + "description": "功能优化建议" + }, + { + "title": "手机端适配问题反馈", + "ticket_content": "在iPhone Safari浏览器上,菜单栏折叠后无法展开,需要刷新页面才能恢复。
", + "summary": "iPhone Safari菜单折叠后无法展开", + "ticket_type": "bug", + "images": "[\"https://example.com/screenshot1.png\"]", + "reply": null, + "assigned_id": 4, + "status": 1, + "tenant_id": 1, + "description": "移动端兼容性问题" + }, + { + "title": "增加数据权限粒度", + "ticket_content": "当前数据权限只能控制到部门级别,希望能支持自定义数据范围,如只查看本人创建的数据、指定项目范围等。
", + "summary": "数据权限需要支持自定义范围", + "ticket_type": "optimize", + "images": null, + "reply": "已纳入Q3规划,感谢反馈。", + "assigned_id": 2, + "status": 2, + "tenant_id": 1, + "description": "数据权限增强" + }, + { + "title": "工作流审批节点无法修改", + "ticket_content": "已发布的工作流无法修改审批节点配置,需要先取消发布才能修改,操作繁琐。
", + "summary": "已发布工作流无法直接修改节点", + "ticket_type": "bug", + "images": null, + "reply": null, + "assigned_id": null, + "status": 0, + "tenant_id": 3, + "description": "星辰科技反馈工作流问题" + }, + { + "title": "希望增加钉钉集成", + "ticket_content": "团队使用钉钉进行日常协作,希望能将通知和待办事项同步到钉钉工作台。
", + "summary": "希望支持钉钉消息集成", + "ticket_type": "suggestion", + "images": null, + "reply": "我们会评估第三方集成的优先级。", + "assigned_id": null, + "status": 3, + "tenant_id": 4, + "description": "创新工坊第三方集成需求" + }, + { + "title": "其他-文档链接失效", + "ticket_content": "帮助文档中的API接口说明链接跳转404,影响开发对接。
", + "summary": "帮助文档API链接404", + "ticket_type": "other", + "images": null, + "reply": null, + "assigned_id": 3, + "status": 0, + "tenant_id": 1, + "description": "文档链接问题" + } +] diff --git a/backend/app/scripts/data/sys_user.json b/backend/app/scripts/data/sys_user.json index 1cde6be4..10ce6ab5 100644 --- a/backend/app/scripts/data/sys_user.json +++ b/backend/app/scripts/data/sys_user.json @@ -15,7 +15,7 @@ "wx_login": null, "qq_login": null, "dept_id": 1, - "status": "0", + "status": 0, "description": "系统超级管理员", "created_id": null, "updated_id": null @@ -29,14 +29,14 @@ "email": "admin@example.com", "gender": "0", "avatar": "https://service.fastapiadmin.com/api/v1/static/image/avatar.png", - "is_superuser": false, + "is_superuser": true, "last_login": null, "gitee_login": null, "github_login": null, "wx_login": null, "qq_login": null, "dept_id": 2, - "status": "0", + "status": 0, "description": "技术部门管理员", "created_id": 1, "updated_id": null @@ -57,7 +57,7 @@ "wx_login": null, "qq_login": null, "dept_id": 3, - "status": "0", + "status": 0, "description": "后端开发工程师", "created_id": 1, "updated_id": null @@ -78,7 +78,7 @@ "wx_login": null, "qq_login": null, "dept_id": 5, - "status": "0", + "status": 0, "description": "产品经理", "created_id": 1, "updated_id": null @@ -99,9 +99,53 @@ "wx_login": null, "qq_login": null, "dept_id": 6, - "status": "0", + "status": 0, "description": "人力资源专员", "created_id": 1, "updated_id": null + }, + { + "tenant_id": 3, + "username": "zhang_admin", + "password": "$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe", + "name": "张明", + "mobile": "13800001001", + "email": "zhang@star-tech.dev", + "status": 0, + "is_superuser": false, + "description": "星辰科技管理员" + }, + { + "tenant_id": 3, + "username": "wang_dev", + "password": "$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe", + "name": "王华", + "mobile": "13800001002", + "email": "wang@star-tech.dev", + "status": 0, + "is_superuser": false, + "description": "星辰科技研发工程师" + }, + { + "tenant_id": 4, + "username": "li_admin", + "password": "$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe", + "name": "李芳", + "mobile": "13800002001", + "email": "li@inno.work", + "status": 0, + "is_superuser": false, + "description": "创新工坊创始人" + }, + { + "tenant_id": 4, + "username": "zhao_eng", + "password": "$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe", + "name": "赵强", + "mobile": "13800002002", + "email": "zhao@inno.work", + "status": 0, + "is_superuser": false, + "description": "创新工坊技术合伙人" } -] +] \ No newline at end of file diff --git a/backend/app/scripts/data/sys_user_roles.json b/backend/app/scripts/data/sys_user_roles.json index 0fb09c2e..9086ddd1 100644 --- a/backend/app/scripts/data/sys_user_roles.json +++ b/backend/app/scripts/data/sys_user_roles.json @@ -18,5 +18,21 @@ { "user_id": 5, "role_id": 3 + }, + { + "user_id": 6, + "role_id": 4 + }, + { + "user_id": 7, + "role_id": 5 + }, + { + "user_id": 8, + "role_id": 6 + }, + { + "user_id": 9, + "role_id": 7 } -] +] \ No newline at end of file diff --git a/backend/app/scripts/data/task_node.json b/backend/app/scripts/data/task_node.json new file mode 100644 index 00000000..c7ecc36e --- /dev/null +++ b/backend/app/scripts/data/task_node.json @@ -0,0 +1,70 @@ +[ + { + "name": "演示任务", + "code": "demo_job", + "jobstore": "default", + "executor": "default", + "trigger": null, + "trigger_args": null, + "func": "import logging\n\ndef handler(*args, **kwargs):\n \"\"\"演示任务:打印参数并返回执行摘要\"\"\"\n logger = logging.getLogger(__name__)\n logger.info(f\"演示任务执行中,参数: args={args}, kwargs={kwargs}\")\n return {\n \"status\": \"success\",\n \"message\": \"演示任务执行成功\",\n \"args_received\": len(args),\n \"kwargs_keys\": list(kwargs.keys())\n }\n", + "args": null, + "kwargs": null, + "coalesce": false, + "max_instances": 1, + "start_date": null, + "end_date": null, + "status": 0, + "description": "最简演示任务,用于验证调度器基本功能" + }, + { + "name": "数据库清理任务", + "code": "db_cleanup", + "jobstore": "sqlalchemy", + "executor": "default", + "trigger": null, + "trigger_args": null, + "func": "import logging\nfrom datetime import datetime, timedelta\n\ndef handler(*args, **kwargs):\n \"\"\"清理过期数据:删除N天前的日志和临时数据\"\"\"\n logger = logging.getLogger(__name__)\n days = kwargs.get(\"days\", 90)\n cutoff = datetime.now() - timedelta(days=days)\n logger.info(f\"清理 {cutoff.strftime('%Y-%m-%d')} 之前的过期数据...\")\n return {\n \"status\": \"success\",\n \"cutoff_date\": cutoff.strftime(\"%Y-%m-%d %H:%M:%S\"),\n \"deleted_count\": 0\n }\n", + "args": null, + "kwargs": "{\"days\": 30}", + "coalesce": true, + "max_instances": 1, + "start_date": null, + "end_date": null, + "status": 0, + "description": "清理过期操作日志和临时数据,建议每天凌晨3点执行" + }, + { + "name": "健康检查任务", + "code": "health_check", + "jobstore": "default", + "executor": "default", + "trigger": null, + "trigger_args": null, + "func": "import logging\nimport psutil\n\ndef handler(*args, **kwargs):\n \"\"\"系统健康检查:采集 CPU、内存、磁盘使用率\"\"\"\n logger = logging.getLogger(__name__)\n cpu = psutil.cpu_percent(interval=1)\n mem = psutil.virtual_memory()\n disk = psutil.disk_usage(\"/\")\n status = \"healthy\" if cpu < 80 and mem.percent < 90 and disk.percent < 90 else \"warning\"\n logger.info(f\"健康检查: CPU={cpu}% MEM={mem.percent}% DISK={disk.percent}%\")\n return {\n \"status\": status,\n \"cpu_percent\": cpu,\n \"memory_percent\": mem.percent,\n \"disk_percent\": disk.percent,\n \"memory_total_gb\": round(mem.total / (1024**3), 1),\n \"disk_total_gb\": round(disk.total / (1024**3), 1)\n }\n", + "args": null, + "kwargs": null, + "coalesce": true, + "max_instances": 1, + "start_date": null, + "end_date": null, + "status": 0, + "description": "系统资源健康检查,建议每5分钟执行一次" + }, + { + "name": "邮件批量发送", + "code": "email_batch", + "jobstore": "sqlalchemy", + "executor": "default", + "trigger": null, + "trigger_args": null, + "func": "import logging\n\ndef handler(*args, **kwargs):\n \"\"\"批量发送待发送邮件\"\"\"\n logger = logging.getLogger(__name__)\n batch_size = kwargs.get(\"batch_size\", 50)\n logger.info(f\"开始批量发送邮件,每批 {batch_size} 封...\")\n return {\n \"status\": \"success\",\n \"sent_count\": 0,\n \"failed_count\": 0,\n \"batch_size\": batch_size\n }\n", + "args": null, + "kwargs": "{\"batch_size\": 50}", + "coalesce": false, + "max_instances": 2, + "start_date": null, + "end_date": null, + "status": 0, + "description": "批量发送待发送邮件,建议每分钟执行一次" + } +] diff --git a/backend/app/scripts/data/task_workflow_node_type.json b/backend/app/scripts/data/task_workflow_node_type.json new file mode 100644 index 00000000..29dc61e0 --- /dev/null +++ b/backend/app/scripts/data/task_workflow_node_type.json @@ -0,0 +1,62 @@ +[ + { + "name": "HTTP请求", + "code": "http_request", + "category": "action", + "func": "import json\nimport urllib.request\n\ndef handler(*args, **kwargs):\n \"\"\"发送 HTTP 请求并返回响应\"\"\"\n url = kwargs.get(\"url\", \"\")\n method = kwargs.get(\"method\", \"GET\")\n headers = kwargs.get(\"headers\", {})\n body = kwargs.get(\"body\")\n if not url:\n raise ValueError(\"缺少 url 参数\")\n req = urllib.request.Request(url, method=method, headers=headers)\n if body and isinstance(body, dict):\n req.data = json.dumps(body).encode()\n with urllib.request.urlopen(req) as resp:\n return {\"status_code\": resp.status, \"body\": resp.read().decode()}\n", + "args": null, + "kwargs": "{\"url\": \"\", \"method\": \"GET\"}", + "sort_order": 1, + "is_active": true, + "status": 0, + "description": "发送 HTTP 请求,支持 GET/POST 等方法" + }, + { + "name": "发送通知", + "code": "send_notification", + "category": "action", + "func": "import logging\n\ndef handler(*args, **kwargs):\n \"\"\"发送通知消息\"\"\"\n logger = logging.getLogger(__name__)\n channel = kwargs.get(\"channel\", \"system\")\n title = kwargs.get(\"title\", \"工作流通知\")\n content = kwargs.get(\"content\", \"\")\n recipients = kwargs.get(\"recipients\", [])\n logger.info(f\"[{channel}] 发送通知: {title} -> {len(recipients)}人\")\n return {\n \"channel\": channel,\n \"title\": title,\n \"recipient_count\": len(recipients),\n \"status\": \"sent\"\n }\n", + "args": null, + "kwargs": "{\"channel\": \"system\", \"title\": \"工作流通知\", \"recipients\": []}", + "sort_order": 2, + "is_active": true, + "status": 0, + "description": "发送系统通知、邮件或短信" + }, + { + "name": "条件判断", + "code": "condition", + "category": "condition", + "func": "import json\n\ndef handler(*args, **kwargs):\n \"\"\"条件分支:根据 upstream 结果决定走向\"\"\"\n upstream = kwargs.get(\"upstream\", {})\n variables = kwargs.get(\"variables\", {})\n field = kwargs.get(\"field\", \"status\")\n expected = kwargs.get(\"expected\", \"success\")\n operator = kwargs.get(\"operator\", \"eq\")\n last = list(upstream.values())[-1] if upstream else {}\n actual = last.get(field) if isinstance(last, dict) else last\n operations = {\n \"eq\": lambda a, e: a == e,\n \"ne\": lambda a, e: a != e,\n \"gt\": lambda a, e: a > e,\n \"lt\": lambda a, e: a < e,\n \"contains\": lambda a, e: str(e) in str(a)\n }\n op = operations.get(operator, operations[\"eq\"])\n result = op(actual, expected)\n return {\"passed\": result, \"actual\": actual, \"expected\": expected}\n", + "args": null, + "kwargs": "{\"field\": \"status\", \"expected\": \"success\", \"operator\": \"eq\"}", + "sort_order": 3, + "is_active": true, + "status": 0, + "description": "根据上游节点输出判断分支走向" + }, + { + "name": "数据转换", + "code": "data_transform", + "category": "action", + "func": "import json\nfrom datetime import datetime\n\ndef handler(*args, **kwargs):\n \"\"\"转换上游数据格式\"\"\"\n upstream = kwargs.get(\"upstream\", {})\n mapping = kwargs.get(\"mapping\", {})\n result = {}\n for upstream_key, target_key in mapping.items():\n for source, value in upstream.items():\n if isinstance(value, dict) and upstream_key in value:\n result[target_key] = value[upstream_key]\n result[\"transformed_at\"] = datetime.now().isoformat()\n return result\n", + "args": null, + "kwargs": "{\"mapping\": {}}", + "sort_order": 4, + "is_active": true, + "status": 0, + "description": "转换上游节点的数据格式" + }, + { + "name": "聚合汇总", + "code": "aggregate", + "category": "action", + "func": "import json\n\ndef handler(*args, **kwargs):\n \"\"\"聚合上游多个节点的输出\"\"\"\n upstream = kwargs.get(\"upstream\", {})\n variables = kwargs.get(\"variables\", {})\n results = {\n \"node_count\": len(upstream),\n \"nodes\": list(upstream.keys()),\n \"values\": list(upstream.values()),\n \"variables\": variables\n }\n return results\n", + "args": null, + "kwargs": null, + "sort_order": 5, + "is_active": true, + "status": 0, + "description": "将多个上游节点的输出聚合到一个结果中" + } +] diff --git a/backend/app/scripts/initialize.py b/backend/app/scripts/initialize.py index 065eb9d6..22ebeb5e 100644 --- a/backend/app/scripts/initialize.py +++ b/backend/app/scripts/initialize.py @@ -1,302 +1,253 @@ +""" +数据库初始化与种子数据管理。 + +简化策略:每张表为空时一次性插入种子数据,已有数据则跳过。 +改 JSON → 清空对应表 → 重启即可。 +""" + import asyncio import json +import re +from datetime import datetime, time from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.v1.module_platform.loginlog.model import LoginLogModel -from app.api.v1.module_platform.package.model import PackageModel -from app.api.v1.module_platform.plugin.model import PluginModel -from app.api.v1.module_platform.tenant.model import TenantConfigModel, TenantModel -from app.api.v1.module_platform.ticket.model import TicketModel +from app.api.v1.module_platform.email.model import EmailConfigModel, EmailTemplateModel +from app.api.v1.module_platform.invoice.model import InvoiceModel +from app.api.v1.module_platform.menu.model import MenuModel +from app.api.v1.module_platform.order.model import OrderModel, PaymentRecordModel, RefundModel +from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel +from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel +from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel from app.api.v1.module_system.dept.model import DeptModel from app.api.v1.module_system.dict.model import DictDataModel, DictTypeModel -from app.api.v1.module_system.menu.model import MenuModel -from app.api.v1.module_system.notice.model import NoticeModel -from app.api.v1.module_system.operationlog.model import OperationLogModel +from app.api.v1.module_system.log.model import LoginLogModel, OperationLogModel +from app.api.v1.module_system.notice.model import NoticeModel, NoticeReadModel from app.api.v1.module_system.params.model import ParamsModel from app.api.v1.module_system.position.model import PositionModel from app.api.v1.module_system.role.model import RoleModel +from app.api.v1.module_system.ticket.model import TicketModel from app.api.v1.module_system.user.model import UserModel, UserRolesModel from app.config.path_conf import SCRIPT_DIR from app.core.database import async_db_session, create_tables -from app.core.logger import log - -# 导入插件模块模型,确保 MappedBase.metadata 中包含所有表 -# 注意:必须在所有核心模块模型导入之后再导入,避免外键引用问题 -from app.plugin.module_example.demo.model import DemoModel # noqa: F401 -from app.plugin.module_example.demo01.model import Demo01Model # noqa: F401 -from app.plugin.module_generator.gencode.model import GenTableColumnModel, GenTableModel # noqa: F401 -from app.plugin.module_task.cronjob.job.model import JobModel # noqa: F401 -from app.plugin.module_task.cronjob.node.model import NodeModel # noqa: F401 -from app.plugin.module_task.workflow.definition.model import WorkflowModel # noqa: F401 -from app.plugin.module_task.workflow.node_type.model import WorkflowNodeTypeModel # noqa: F401 +from app.core.logger import logger +from app.plugin.module_example.demo.model import DemoModel +from app.plugin.module_task.cronjob.node.model import NodeModel +from app.plugin.module_task.workflow.node_type.model import WorkflowNodeTypeModel class InitializeData: - """ - 初始化数据库和基础数据 - """ + """初始化数据库和基础数据""" - def __init__(self) -> None: - """ - 初始化数据库和基础数据 - """ - # 按照依赖关系排序:先创建基础表,再创建关联表 - self.prepare_init_models = [ - TenantModel, - TenantConfigModel, - PackageModel, - PluginModel, - MenuModel, - ParamsModel, - DeptModel, - RoleModel, - DictTypeModel, - DictDataModel, - PositionModel, - UserModel, - UserRolesModel, - NoticeModel, - TicketModel, - LoginLogModel, - OperationLogModel, - ] + _DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$") + _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + _TIME_RE = re.compile(r"^\d{2}:\d{2}:\d{2}(\.\d+)?$") - async def __init_create_table(self) -> None: - """ - 初始化表结构(第一阶段) - """ + # 按依赖关系排序:先基础表,再关联表 + prepare_init_models: list[type] = [ + # ── 平台管理:基础表 ── + PackageModel, + TenantModel, + PluginModel, + MenuModel, + # ── 系统管理:基础表 ── + ParamsModel, + DeptModel, + RoleModel, + DictTypeModel, + DictDataModel, + PositionModel, + UserModel, + # ── 平台管理:依赖用户的表 ── + EmailConfigModel, + EmailTemplateModel, + OrderModel, + InvoiceModel, + PaymentRecordModel, + RefundModel, + # ── 关联表 ── + UserRolesModel, + TenantUserModel, + PackageMenuModel, + TenantPluginModel, + # ── 其他系统/业务表 ── + NoticeModel, + NoticeReadModel, + TicketModel, + # ── 日志表(追加写入) ── + LoginLogModel, + OperationLogModel, + # ── 插件表 ── + NodeModel, + WorkflowNodeTypeModel, + DemoModel, + ] + + # 树形模型:JSON 含嵌套 children,需递归创建对象 + _RECURSIVE_TABLES: set[str] = {"platform_menu", "sys_dept"} + + async def init_db(self) -> None: + """建表并导入种子数据""" try: - # 确保插件模型已导入到 MappedBase.metadata - # 这些导入已经在文件顶部执行,此处仅为确保 - from app.plugin.module_example.demo.model import DemoModel # noqa: F401 - from app.plugin.module_example.demo01.model import Demo01Model # noqa: F401 - from app.plugin.module_generator.gencode.model import GenTableColumnModel, GenTableModel # noqa: F401 - from app.plugin.module_task.cronjob.job.model import JobModel # noqa: F401 - from app.plugin.module_task.cronjob.node.model import NodeModel # noqa: F401 - from app.plugin.module_task.workflow.definition.model import WorkflowModel # noqa: F401 - from app.plugin.module_task.workflow.node_type.model import WorkflowNodeTypeModel # noqa: F401 - - # 使用引擎创建所有表 - # await drop_tables() await create_tables() except asyncio.exceptions.TimeoutError: - log.error("❌️ 数据库表结构初始化超时") - raise - except Exception as e: - log.error(f"❌️ 数据库表结构初始化失败: {e!s}") + logger.error("❌️ 数据库表结构初始化超时") raise + async with async_db_session() as session: + async with session.begin(): + await self.__init_data(session) + + # ========================================================================== + # 主入口 + # ========================================================================== + async def __init_data(self, db: AsyncSession) -> None: - """ - 初始化基础数据 - - 参数: - - db (AsyncSession): 异步数据库会话。 - """ - # 存储字典类型数据的映射,用于后续字典数据的初始化 - dict_type_mapping = {} + """按依赖顺序初始化各表种子数据""" + dict_type_mapping: dict[str, Any] = {} # dict_type → DictTypeModel 实例 for model in self.prepare_init_models: table_name = model.__tablename__ - # 检查表中是否已经有数据 - count_result = await db.execute(select(func.count()).select_from(model)) - existing_count = count_result.scalar() - if existing_count and existing_count > 0: - log.warning( - f"⚠️ 跳过 {table_name} 表数据初始化(表已存在 {existing_count} 条记录)" - ) - continue - - data = await self.__get_data(table_name) + data = await self.__load_json(table_name) if not data: - log.warning(f"⚠️ 跳过 {table_name} 表,无初始化数据") + logger.warning(f"⚠️ 跳过 {table_name} 表,无初始化数据") continue try: - # 特殊处理具有嵌套 children 数据的表 - if table_name in ["sys_dept", "sys_menu"]: - # 获取对应的模型类 - model_class = DeptModel if table_name == "sys_dept" else MenuModel - objs = self.__create_objects_with_children(data, model_class) - # 处理字典类型表,保存类型映射 - elif table_name == "sys_dict_type": + # 树形表(platform_menu / sys_dept):递归创建含 children 的对象 + if table_name in self._RECURSIVE_TABLES: + count = await db.execute(select(func.count()).select_from(model)) + if count.scalar(): + logger.warning(f"⚠️ 跳过 {table_name} 表数据初始化(表已有数据)") + continue + objs = self.__create_objects_with_children(data, model) + db.add_all(objs) + await db.flush() + logger.info(f"✅️ 已向 {table_name} 写入初始化数据") + continue + + # 字典类型表:存储类型映射供字典数据使用 + if table_name == "sys_dict_type": + count = await db.execute(select(func.count()).select_from(model)) + if count.scalar(): + logger.warning(f"⚠️ 跳过 {table_name} 表数据初始化(表已有数据)") + continue objs = [] for item in data: obj = model(**item) objs.append(obj) dict_type_mapping[item["dict_type"]] = obj - # 处理字典数据表,添加dict_type_id关联 - elif table_name == "sys_dict_data": + db.add_all(objs) + await db.flush() + logger.info(f"✅️ 已向 {table_name} 写入初始化数据") + continue + + # 字典数据表:关联 dict_type_id + if table_name == "sys_dict_data": + count = await db.execute(select(func.count()).select_from(model)) + if count.scalar(): + logger.warning(f"⚠️ 跳过 {table_name} 表数据初始化(表已有数据)") + continue objs = [] for item in data: - dict_type = item.get("dict_type") - if dict_type in dict_type_mapping: - # 添加dict_type_id关联 - item["dict_type_id"] = dict_type_mapping[dict_type].id - else: - log.warning(f"⚠️ 未找到字典类型 {dict_type},跳过该字典数据") + dict_type_str = item.get("dict_type") + if dict_type_str not in dict_type_mapping: + logger.warning(f"⚠️ 未找到字典类型 {dict_type_str},跳过") continue + item["dict_type_id"] = dict_type_mapping[dict_type_str].id objs.append(model(**item)) - else: - # 表为空,直接插入全部数据 - objs = [model(**item) for item in data] + db.add_all(objs) + await db.flush() + logger.info(f"✅️ 已向 {table_name} 写入初始化数据") + continue + # 日志表:追加写入,已有数据跳过 + if table_name in ("sys_login_log", "sys_operation_log"): + count = await db.execute(select(func.count()).select_from(model)) + if count.scalar(): + logger.info(f"⏭️ {table_name} 已有数据,跳过") + continue + objs = [model(**item) for item in data] + db.add_all(objs) + await db.flush() + logger.info(f"✅️ 已向 {table_name} 写入 {len(objs)} 条") + continue + + # 普通表:空表时插入,已有数据跳过 + count = await db.execute(select(func.count()).select_from(model)) + if count.scalar(): + logger.warning(f"⚠️ 跳过 {table_name} 表数据初始化(表已有数据)") + continue + objs = [model(**item) for item in data] db.add_all(objs) await db.flush() - log.info(f"✅️ 已向 {table_name} 表写入初始化数据") + logger.info(f"✅️ 已向 {table_name} 写入初始化数据") - except Exception as e: - log.error(f"❌️ 初始化 {table_name} 表数据失败: {e!s}") + except Exception: + logger.error(f"❌️ 初始化 {table_name} 表数据失败") raise - def __create_objects_with_children(self, data: list[dict], model_class: type) -> list: - """ - 通用递归创建对象函数,处理嵌套的 children 数据 + # ========================================================================== + # 递归创建对象(含嵌套 children) + # ========================================================================== - 参数: - - data (list[dict]): 包含嵌套 children 数据的列表。 - - model_class: 对应的 SQLAlchemy 模型类。 + @staticmethod + def __create_objects_with_children(data: list[dict], model_class: type) -> list: + """递归创建树形模型实例,处理嵌套 children 并注入 parent_id""" - 返回: - - list: 包含创建的对象的列表。 - """ - - def create_object(obj_data: dict) -> Any: - """ - 由单条 dict 递归构建模型实例(含 children)。 - - 参数: - - obj_data (dict): 行数据,可含嵌套 children。 - - 返回: - - Any: SQLAlchemy 模型实例。 - """ - # 分离 children 数据 + def _create(obj_data: dict) -> Any: children_data = obj_data.pop("children", []) - # 创建当前对象 + # JSON 中子节点 parent_id 通常为 null,先按原始值创建 obj = model_class(**obj_data) - # 递归处理子对象 if children_data: - obj.children = [create_object(child) for child in children_data] + obj.children = [_create(child) for child in children_data] return obj - objs = [create_object(item) for item in data] + return [_create(item) for item in data] - return objs + # ========================================================================== + # JSON 文件加载 + # ========================================================================== - async def __get_data(self, filename: str) -> list[dict]: - """ - 读取初始化数据文件 - - 参数: - - filename (str): 文件名(不包含扩展名)。 - - 返回: - - list[dict]: 解析后的 JSON 数据列表。 - """ + async def __load_json(self, filename: str) -> list[dict]: + """读取并解析种子数据 JSON 文件""" json_path = SCRIPT_DIR / f"{filename}.json" if not json_path.exists(): return [] try: with open(json_path, encoding="utf-8") as f: - return json.loads(f.read()) + raw = json.loads(f.read()) + return [self._parse_date_strings(item) for item in raw] except json.JSONDecodeError as e: - log.error(f"❌️ 解析 {json_path} 失败: {e!s}") + logger.error(f"❌️ 解析 {json_path} 失败: {e!s}") raise except Exception as e: - log.error(f"❌️ 读取 {json_path} 失败: {e!s}") + logger.error(f"❌️ 读取 {json_path} 失败: {e!s}") raise - async def init_db(self) -> None: - """ - 执行完整初始化流程:建表并导入种子数据。 - - 返回: - - None - """ - # 先创建表结构 - await self.__init_create_table() - - # 先初始化 Tenant 数据(需要先提交,因为其他表依赖它) - async with async_db_session() as session: - async with session.begin(): - table_name = TenantModel.__tablename__ - count_result = await session.execute(select(func.count()).select_from(TenantModel)) - existing_count = count_result.scalar() - if not existing_count or existing_count == 0: - data = await self.__get_data(table_name) - if data: - objs = [TenantModel(**item) for item in data] - session.add_all(objs) - await session.flush() - log.info(f"✅️ 已向 {table_name} 表写入初始化数据") - - # 再初始化其他数据(每个表独立事务,避免回滚影响其他表) - for model in self.prepare_init_models[1:]: - table_name = model.__tablename__ - async with async_db_session() as session: - async with session.begin(): - count_result = await session.execute(select(func.count()).select_from(model)) - existing_count = count_result.scalar() - if existing_count and existing_count > 0: - log.warning( - f"⚠️ 跳过 {table_name} 表数据初始化(表已存在 {existing_count} 条记录)" - ) - continue - - data = await self.__get_data(table_name) - if not data: - log.warning(f"⚠️ 跳过 {table_name} 表,无初始化数据") - continue - - try: - # 特殊处理具有嵌套 children 数据的表 - if table_name in ["sys_dept", "sys_menu"]: - model_class = DeptModel if table_name == "sys_dept" else MenuModel - objs = self.__create_objects_with_children(data, model_class) - # 处理字典类型表,需要先保存获取ID - elif table_name == "sys_dict_type": - objs = [] - for item in data: - obj = model(**item) - objs.append(obj) - session.add_all(objs) - await session.flush() # 先flush获取ID - # 处理字典数据表,添加dict_type_id关联 - dict_type_mapping = {item["dict_type"]: obj for item, obj in zip(data, objs)} - dict_data = await self.__get_data("sys_dict_data") - if dict_data: - dict_data_objs = [] - for item in dict_data: - dict_type = item.get("dict_type") - if dict_type in dict_type_mapping: - item["dict_type_id"] = dict_type_mapping[dict_type].id - else: - log.warning(f"⚠️ 未找到字典类型 {dict_type},跳过该字典数据") - continue - dict_data_objs.append(DictDataModel(**item)) - session.add_all(dict_data_objs) - await session.flush() - log.info(f"✅️ 已向 {table_name} 表写入初始化数据") - log.info(f"✅️ 已向 sys_dict_data 表写入初始化数据") - continue # 跳过正常的flush和日志 - elif table_name == "sys_dict_data": - continue # 已在 sys_dict_type 处理中处理 - else: - objs = [model(**item) for item in data] - - session.add_all(objs) - await session.flush() - log.info(f"✅️ 已向 {table_name} 表写入初始化数据") - - except Exception as e: - log.error(f"❌️ 初始化 {table_name} 表数据失败: {e!s}") - raise + @classmethod + def _parse_date_strings(cls, data: dict) -> dict: + """递归转换 JSON 中的日期时间字符串为 datetime 对象(兼容 PostgreSQL)""" + result = {} + for key, value in data.items(): + if isinstance(value, str): + if cls._DATETIME_RE.match(value): + result[key] = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + elif cls._DATE_RE.match(value): + result[key] = datetime.strptime(value, "%Y-%m-%d").date() + elif cls._TIME_RE.match(value): + result[key] = time.fromisoformat(value) + else: + result[key] = value + elif isinstance(value, dict): + result[key] = cls._parse_date_strings(value) + else: + result[key] = value + return result diff --git a/backend/app/utils/common_util.py b/backend/app/utils/common_util.py index d7385926..392b0ad7 100644 --- a/backend/app/utils/common_util.py +++ b/backend/app/utils/common_util.py @@ -13,7 +13,7 @@ from sqlalchemy.sql.expression import null from app.config.setting import settings from app.core.exceptions import CustomException -from app.core.logger import log +from app.core.logger import logger def import_module(module: str, desc: str) -> Any: @@ -32,10 +32,10 @@ def import_module(module: str, desc: str) -> Any: module = importlib.import_module(module_path) # pyright: ignore[reportAssignmentType] return getattr(module, module_class) except ModuleNotFoundError: - log.error(f"❗️ 导入{desc}失败,未找到模块:{module}") + logger.error(f"❗️ 导入{desc}失败,未找到模块:{module}") raise except AttributeError: - log.error(f"❗ ️导入{desc}失败,未找到模块方法:{module}") + logger.error(f"❗ ️导入{desc}失败,未找到模块方法:{module}") raise @@ -60,10 +60,10 @@ async def import_modules_async(modules: list, desc: str, **kwargs) -> None: module_obj = importlib.import_module(module_path) await getattr(module_obj, module_name)(**kwargs) except ModuleNotFoundError: - log.error(f"❌️ 导入{desc}失败,未找到模块:{module}") + logger.error(f"❌️ 导入{desc}失败,未找到模块:{module}") raise except AttributeError: - log.error(f"❌️ 导入{desc}失败,未找到模块方法:{module}") + logger.error(f"❌️ 导入{desc}失败,未找到模块方法:{module}") raise diff --git a/backend/app/utils/console.py b/backend/app/utils/console.py index ac91d4a7..92a38d0b 100644 --- a/backend/app/utils/console.py +++ b/backend/app/utils/console.py @@ -40,7 +40,6 @@ def console_run( base_url = f"{url}{settings.ROOT_PATH}" docs_url = base_url + settings.DOCS_URL redoc_url = base_url + settings.REDOC_URL - ljdoc_url = base_url + settings.LJDOC_URL # 核心服务信息 service_info = Text() @@ -80,7 +79,6 @@ def console_run( docs_info.append("📖 文档", style="bold magenta") docs_info.append(f"\n🔗 Swagger: {docs_url}", style="blue link") docs_info.append(f"\n🔗 ReDoc: {redoc_url}", style="blue link") - docs_info.append(f"\n🔗 LangJin: {ljdoc_url}", style="blue link") final_content = Group( service_info, diff --git a/backend/app/utils/email_util.py b/backend/app/utils/email_util.py new file mode 100644 index 00000000..a4c44ded --- /dev/null +++ b/backend/app/utils/email_util.py @@ -0,0 +1,88 @@ +""" +email_util.py — 邮件发送工具 + +职责: +- 渲染 Jinja2 模板 +- 通过 fastapi-mail 发送邮件 + +使用方:EmailService.send_email_service() +""" + +from typing import Any + +from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType +from jinja2 import Environment, StrictUndefined + + +def render_template(template_str: str, variables: dict[str, Any]) -> str: + """ + 使用 Jinja2 渲染邮件模板。 + + 参数: + - template_str: Jinja2 模板字符串 + - variables: 变量字典 + + 返回:渲染后的字符串 + + 异常:TemplateError — 模板语法错误或变量缺失 + """ + env = Environment(undefined=StrictUndefined, autoescape=True) + tmpl = env.from_string(template_str) + return tmpl.render(**variables) + + +async def send_email( + *, + smtp_host: str, + smtp_port: int, + smtp_user: str, + smtp_password: str, + use_tls: bool, + from_name: str, + to_email: str, + to_name: str | None, + subject: str, + body_html: str, + body_text: str | None = None, + timeout: int = 30, +) -> None: + """ + 异步发送邮件(通过 fastapi-mail)。 + + 参数均为关键字参数。 + + 异常: + - SMTPException — SMTP 协议错误 + - TimeoutError — 连接/发送超时 + """ + # 构建 fastapi-mail 连接配置 + # 端口 465 = 隐式 SSL,端口 587 = STARTTLS + is_ssl = smtp_port == 465 + + conf = ConnectionConfig( + MAIL_USERNAME=smtp_user, + MAIL_PASSWORD=smtp_password, + MAIL_FROM=smtp_user, + MAIL_FROM_NAME=from_name, + MAIL_SERVER=smtp_host, + MAIL_PORT=smtp_port, + MAIL_STARTTLS=not is_ssl and use_tls, + MAIL_SSL_TLS=is_ssl, + USE_CREDENTIALS=True, + VALIDATE_CERTS=False, + MAIL_TIMEOUT=timeout, + ) + + # 收件人格式:支持 "姓名 <邮箱>"(若有 to_name) + recipient = f"{to_name} <{to_email}>" if to_name else to_email + + # 构建邮件消息 + message = MessageSchema( + subject=subject, + recipients=[recipient], + body=body_html, + subtype=MessageType.html, + ) + + fm = FastMail(conf) + await fm.send_message(message) diff --git a/backend/app/utils/hash_bcrpy_util.py b/backend/app/utils/hash_bcrpy_util.py index 35914f08..4a52d127 100644 --- a/backend/app/utils/hash_bcrpy_util.py +++ b/backend/app/utils/hash_bcrpy_util.py @@ -8,7 +8,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from itsdangerous import URLSafeSerializer from passlib.context import CryptContext -from app.core.logger import log +from app.core.logger import logger # 密码加密配置 PwdContext = CryptContext( @@ -181,7 +181,7 @@ class ItsDCipher: try: ciphertext = serializer.dumps(plaintext) except Exception as e: - log.error(f"ItsDangerous encrypt failed: {e}") + logger.error(f"ItsDangerous encrypt failed: {e}") ciphertext = Md5Cipher.encrypt(plaintext) return ciphertext @@ -202,6 +202,6 @@ class ItsDCipher: try: plaintext = serializer.loads(ciphertext) except Exception as e: - log.error(f"ItsDangerous decrypt failed: {e}") + logger.error(f"ItsDangerous decrypt failed: {e}") plaintext = ciphertext return plaintext diff --git a/backend/app/utils/ip_local_util.py b/backend/app/utils/ip_local_util.py index 3bbac1ea..b00ff485 100644 --- a/backend/app/utils/ip_local_util.py +++ b/backend/app/utils/ip_local_util.py @@ -3,7 +3,7 @@ import re import httpx from app.config.setting import settings -from app.core.logger import log +from app.core.logger import logger class IpLocalUtil: @@ -74,7 +74,7 @@ class IpLocalUtil: """ # 校验IP格式 if not cls.is_valid_ip(ip): - log.error(f"IP格式不合法: {ip}") + logger.error(f"IP格式不合法: {ip}") return "未知" # 内网IP直接返回 @@ -91,7 +91,7 @@ class IpLocalUtil: return f"{result.get('country', '')}-{result.get('prov', '')}-{result.get('city', '')}-{result.get('area', '')}-{result.get('isp', '')}" except Exception as e: - log.error(f"获取IP归属地失败: {e}") + logger.error(f"获取IP归属地失败: {e}") return "未知" @classmethod @@ -111,5 +111,5 @@ class IpLocalUtil: if response.status_code == 200: return response except Exception as e: - log.error(f"IP 归属地 API 请求失败: {url} - {e}") + logger.error(f"IP 归属地 API 请求失败: {url} - {e}") return None diff --git a/backend/app/utils/re_util.py b/backend/app/utils/re_util.py deleted file mode 100644 index 9c118e59..00000000 --- a/backend/app/utils/re_util.py +++ /dev/null @@ -1,71 +0,0 @@ -import re - - -def search_string(pattern: str, text: str) -> re.Match[str] | None: - """ - 全字段正则匹配 - - 参数: - - pattern (str): 正则表达式模式。 - - text (str): 待匹配的文本。 - - 返回: - - re.Match[str] | None: 匹配结果。 - """ - if not pattern or not text: - return None - - result = re.search(pattern, text) - return result - - -def match_string(pattern: str, text: str) -> re.Match[str] | None: - """ - 从字段开头正则匹配 - - 参数: - - pattern (str): 正则表达式模式。 - - text (str): 待匹配的文本。 - - 返回: - - re.Match[str] | None: 匹配结果。 - """ - if not pattern or not text: - return None - - result = re.match(pattern, text) - return result - - -def is_phone(number: str) -> re.Match[str] | None: - """ - 检查手机号码格式 - - 参数: - - number (str): 待检查的手机号码。 - - 返回: - - re.Match[str] | None: 匹配结果。 - """ - if not number: - return None - - phone_pattern = r"^1[3-9]\d{9}$" - return match_string(phone_pattern, number) - - -def is_git_url(url: str) -> re.Match[str] | None: - """ - 检查 git URL 格式 - - 参数: - - url (str): 待检查的 URL。 - - 返回: - - re.Match[str] | None: 匹配结果。 - """ - if not url: - return None - - git_pattern = r"^(?!(git\+ssh|ssh)://|git@)(?P{username} 您好:
您的验证码是:
验证码 5 分钟内有效,请勿泄露给他人。
此邮件由系统自动发送,请勿回复。
您的工单 {{ ticket_title }} 收到新回复:
{{ reply_content }}
回复时间:{{ reply_time }}
尊敬的 {{ tenant_name }}:
您的 {{ package_name }} 套餐将于 {{ expire_date }} 到期,剩余 {{ remaining_days }} 天。
到期后部分功能将受限,请及时续费以保证服务正常使用。
尊敬的客户:
您的发票已开具完成:
| 发票号码 | {{ invoice_no }} |
| 发票抬头 | {{ invoice_title }} |
| 开票金额 | ¥{{ invoice_amount }} |
欢迎使用 FastApiAdmin 系统!
这是一个功能强大的权限管理系统,支持多租户、角色权限控制等功能。
',1,'3d292e41-aa88-4eae-82be-e38733651e38',0,'系统上线公告','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('系统维护通知','1','系统将于本周六凌晨2:00-4:00进行例行维护,请提前保存工作。
',2,'2ddb78ca-aae4-4c5f-b86b-f333df82b76e',0,'系统维护通知','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('新功能发布','2','本次更新新增了工作流引擎、代码生成器等功能,欢迎体验!
',3,'1a9bd74d-b085-48fb-8037-334bfa3ad35f',0,'新功能发布','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('安全更新提醒','1','请所有用户尽快更新密码,建议使用至少8位包含大小写字母、数字和特殊字符的强密码。
更新方法:登录后进入「个人中心」->「修改密码」。
',4,'8d7084e2-d333-4472-9740-87014ee18b37',0,'安全更新提醒','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('节假日值班安排','1','春节假期(2月10日-2月17日)期间系统值班安排如下:
联系电话:138-0000-0000
紧急问题请直接联系值班人员。
',5,'f3963141-c19a-4856-8af0-e08296ae441a',0,'节假日值班通知','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('v2.0 版本升级公告','2','v2.0 大版本即将发布,主要更新:
升级时间另行通知。
',6,'5155714b-2f0b-49ac-9114-a072ee948c1e',0,'v2.0 版本升级公告','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL); +/*!40000 ALTER TABLE `sys_notice` ENABLE KEYS */; + +-- +-- Table structure for table `sys_notice_read` +-- + +DROP TABLE IF EXISTS `sys_notice_read`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_notice_read` ( + `user_id` int NOT NULL COMMENT '用户ID', + `notice_id` int NOT NULL COMMENT '通知ID', + `read_time` datetime NOT NULL COMMENT '已读时间', + PRIMARY KEY (`user_id`,`notice_id`), + UNIQUE KEY `uq_user_notice_read` (`user_id`,`notice_id`), + KEY `notice_id` (`notice_id`), + CONSTRAINT `sys_notice_read_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE, + CONSTRAINT `sys_notice_read_ibfk_2` FOREIGN KEY (`notice_id`) REFERENCES `sys_notice` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知已读记录表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_notice_read` +-- + +/*!40000 ALTER TABLE `sys_notice_read` DISABLE KEYS */; +INSERT INTO `sys_notice_read` VALUES (1,1,'2025-06-01 09:15:00'),(1,2,'2025-06-10 08:30:00'),(1,3,'2025-07-01 10:00:00'),(2,1,'2025-06-01 09:20:00'),(2,2,'2025-06-10 09:00:00'),(3,1,'2025-06-01 10:30:00'),(4,1,'2025-06-02 14:00:00'),(5,1,'2025-06-03 11:00:00'),(6,6,'2025-06-20 10:00:00'),(8,2,'2025-06-10 16:00:00'); +/*!40000 ALTER TABLE `sys_notice_read` ENABLE KEYS */; + +-- +-- Table structure for table `sys_operation_log` +-- + +DROP TABLE IF EXISTS `sys_operation_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_operation_log` ( + `request_path` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求路径', + `request_method` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求方式', + `request_payload` longtext COLLATE utf8mb4_unicode_ci COMMENT '请求体', + `response_code` int NOT NULL COMMENT '响应状态码', + `response_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '响应体', + `process_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理时间', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + `created_id` int DEFAULT NULL COMMENT '创建人ID', + `updated_id` int DEFAULT NULL COMMENT '更新人ID', + `deleted_id` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_sys_operation_log_uuid` (`uuid`), + KEY `ix_sys_operation_log_updated_time` (`updated_time`), + KEY `ix_sys_operation_log_updated_id` (`updated_id`), + KEY `ix_sys_operation_log_deleted_time` (`deleted_time`), + KEY `ix_sys_operation_log_status` (`status`), + KEY `ix_sys_operation_log_created_time` (`created_time`), + KEY `ix_sys_operation_log_id` (`id`), + KEY `ix_sys_operation_log_tenant_id` (`tenant_id`), + KEY `ix_sys_operation_log_deleted_id` (`deleted_id`), + KEY `ix_sys_operation_log_created_id` (`created_id`), + KEY `ix_sys_operation_log_is_deleted` (`is_deleted`), + CONSTRAINT `sys_operation_log_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `sys_operation_log_ibfk_2` FOREIGN KEY (`created_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_operation_log_ibfk_3` FOREIGN KEY (`updated_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_operation_log_ibfk_4` FOREIGN KEY (`deleted_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_operation_log` +-- + +/*!40000 ALTER TABLE `sys_operation_log` DISABLE KEYS */; +INSERT INTO `sys_operation_log` VALUES ('/api/v1/system/auth/login','POST','{\"username\": \"super\", \"password\": \"***\"}',200,'{\"code\": 200, \"msg\": \"登录成功\"}','45ms',1,'d09b4a31-533b-4772-ac92-3c00798f6f98',0,'用户登录','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/system/user/current/info','GET',NULL,200,'{\"code\": 200, \"data\": {\"username\": \"super\"}}','12ms',2,'87882b51-0402-4744-8f06-48954ab86b78',0,'获取当前用户信息','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/platform/menu/create','POST','{\"name\": \"测试菜单\", \"type\": 2, \"parent_id\": 1}',200,'{\"code\": 200, \"msg\": \"创建成功\"}','23ms',3,'6584dfd7-394f-4e18-b127-9083cea0b6fc',0,'创建菜单','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/system/user/update/3','PUT','{\"name\": \"普通用户\", \"status\": 0}',200,'{\"code\": 200, \"msg\": \"更新成功\"}','18ms',4,'79a5fc9b-0fb4-4f48-93fc-f06619245ea8',0,'更新用户信息','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/system/dept/create','POST','{\"name\": \"测试部门\", \"parent_id\": 1}',400,'{\"code\": 400, \"msg\": \"部门编码已存在\"}','8ms',5,'6039e4b3-93b6-47e7-a6a1-75bb3d449460',0,'创建部门(失败)','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/system/role/delete','DELETE','{\"ids\": [5]}',200,'{\"code\": 200, \"msg\": \"删除成功\"}','15ms',6,'4670f2df-1bdc-4706-9247-be5fde027cc5',0,'删除角色','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/platform/menu/list','GET',NULL,200,'{\"code\": 200, \"data\": {\"items\": [...]}}','35ms',7,'d0997b66-dec8-445a-af97-f0e2232b8998',0,'查询菜单列表','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,3,NULL,NULL,NULL),('/api/v1/system/dict/data/list','GET',NULL,200,'{\"code\": 200, \"data\": {\"items\": [...]}}','22ms',8,'31802897-812e-4fe3-8ca3-c0fc0b60cb7d',0,'查询字典数据','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,3,NULL,NULL,NULL),('/api/v1/workflow/definition/create','POST','{\"name\": \"审批流程\", \"code\": \"approval_v1\"}',200,'{\"code\": 200, \"msg\": \"创建成功\"}','28ms',9,'d5f63d06-acc8-46bc-897f-2ac5fb66b0e1',0,'创建工作流','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,4,NULL,NULL,NULL),('/api/v1/system/notice/create','POST','{\"notice_title\": \"测试通知\", \"notice_type\": \"1\"}',200,'{\"code\": 200, \"msg\": \"创建成功\"}','11ms',10,'c358bad3-53f0-4716-b1ab-b594eff4ef71',0,'创建通知','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/system/user/export','POST','{\"status\": 0}',200,'{\"file\": \"用户列表_20250601.xlsx\"}','156ms',11,'c37c9a0f-5065-4140-bfaa-6c4ce146eba7',0,'导出用户数据','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/system/user/import','POST','\"file\": \"users.xlsx\" (multipart/form-data)',200,'{\"code\": 200, \"msg\": \"成功导入 25 条数据\"}','320ms',12,'640d1a3a-b127-4cb3-8312-917216db146b',0,'批量导入用户','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/cronjob/node/execute/1','POST','{\"trigger\": \"now\"}',200,'{\"code\": 200, \"msg\": \"调试节点成功\"}','1024ms',13,'4de5a9d5-0cd0-4d22-8975-296fae745466',0,'执行定时任务节点','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('/api/v1/workflow/definition/execute','POST','{\"workflow_id\": 1, \"variables\": {}}',200,'{\"code\": 200, \"data\": {\"status\": \"completed\"}}','3200ms',14,'0b1b11eb-eaf1-481a-a09f-c1a756a40dc1',0,'执行工作流','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,4,NULL,NULL,NULL),('/api/v1/cronjob/job/log/delete','DELETE','{\"ids\": [1, 2, 3]}',200,'{\"code\": 200, \"msg\": \"删除成功\"}','19ms',15,'67dcc879-f91b-4e8e-b472-63fdbed53c2a',0,'批量删除执行日志','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL); +/*!40000 ALTER TABLE `sys_operation_log` ENABLE KEYS */; + +-- +-- Table structure for table `sys_param` +-- + +DROP TABLE IF EXISTS `sys_param`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_param` ( + `config_name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '参数名称', + `config_key` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '参数键名', + `config_value` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '参数键值', + `config_type` tinyint(1) DEFAULT NULL COMMENT '系统内置(True:是 False:否)', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_sys_param_uuid` (`uuid`), + KEY `ix_sys_param_is_deleted` (`is_deleted`), + KEY `ix_sys_param_status` (`status`), + KEY `ix_sys_param_id` (`id`), + KEY `ix_sys_param_created_time` (`created_time`), + KEY `ix_sys_param_deleted_time` (`deleted_time`), + KEY `ix_sys_param_tenant_id` (`tenant_id`), + KEY `ix_sys_param_updated_time` (`updated_time`), + CONSTRAINT `sys_param_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统参数表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_param` +-- + +/*!40000 ALTER TABLE `sys_param` DISABLE KEYS */; +INSERT INTO `sys_param` VALUES ('演示模式启用','demo_enable','false',1,1,'70aa8e1b-c363-4ef6-8fd3-3d72ffb13000',0,'是否启用演示模式','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('演示访问IP白名单','ip_white_list','[\"127.0.0.1\", \"::1\"]',1,2,'efcb680f-d0d7-4298-bef5-5602496e3d75',0,'演示模式下允许访问的IP列表','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('接口白名单','white_api_list_path','[\"/api/v1/system/auth/login\", \"/api/v1/system/auth/token/refresh\", \"/api/v1/system/auth/captcha/get\", \"/api/v1/system/auth/logout\", \"/api/v1/system/config/info\", \"/api/v1/system/user/current/info\", \"/api/v1/system/notice/available\", \"/api/v1/system/auth/auto-login/users\", \"/api/v1/system/auth/auto-login/token\", \"/api/v1/system/auth/auto-login\", \"/common/health\", \"/common/health/ready\", \"/common/health/live\", \"/metrics\"]',1,3,'ed8248c6-5aa9-4a5d-995d-aeb920ca5c3c',0,'无需登录即可访问的接口列表','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('访问IP黑名单','ip_black_list','[]',1,4,'26c4b6a1-1250-4444-a5ca-bb96621bb8bb',0,'禁止访问的IP列表','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('登录失败次数限制','login_failed_limit','5',1,5,'6765a5c9-8714-42b0-9957-12d437189a0a',0,'登录失败最大次数','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('登录锁定时间(分钟)','login_lock_time','15',1,6,'0e8bd4a1-1ce2-488b-b624-d7c426ce3e30',0,'登录失败后锁定时间','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('Token过期时间(分钟)','token_expire_minutes','120',1,7,'fa532946-ddd1-4f89-81bb-bfd1a70c7b13',0,'Access Token过期时间','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('Refresh Token过期时间(天)','refresh_token_expire_days','7',1,8,'8a447af9-70d7-4904-8068-77e060136bf3',0,'Refresh Token过期时间','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('密码有效期(天)','password_expire_days','90',1,9,'24222cbe-53c0-4aff-a11a-8a49c2e96f1a',0,'密码有效期','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('密码最小长度','password_min_length','6',1,10,'3935853a-b780-4e7a-8970-ab7846e62fe8',0,'密码最小长度','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('是否启用验证码','captcha_enable','true',1,11,'046069fb-0d19-47b2-bf79-90d82c66e4cb',0,'登录时是否启用验证码','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('是否记录操作日志','operation_log_enable','true',1,12,'903f69bc-8efc-43bf-baae-033c7e28900b',0,'是否记录用户操作日志','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('操作日志保留天数','operation_log_retention_days','90',1,13,'0759f8e0-f436-419d-a216-0cdfb74bb844',0,'操作日志保留天数','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('登录日志保留天数','login_log_retention_days','30',1,14,'76b7b7a5-dfe6-4752-a4cb-df215981dc14',0,'登录日志保留天数','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('文件上传大小限制(MB)','file_upload_max_size','50',1,15,'859cac7f-9795-4962-9327-359388e392ea',0,'单个文件上传最大大小','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('是否启用IP归属地查询','ip_location_enable','false',1,16,'906de136-c53c-46f9-b3fe-91ac7b312db2',0,'登录时是否查询IP归属地','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('调度器状态','scheduler_status','stopped',1,17,'2f9c3a52-e431-4064-9d85-82a8edd41260',0,NULL,'2026-06-17 00:17:13','2026-06-17 00:17:13',0,NULL,1); +/*!40000 ALTER TABLE `sys_param` ENABLE KEYS */; + +-- +-- Table structure for table `sys_position` +-- + +DROP TABLE IF EXISTS `sys_position`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_position` ( + `name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '岗位名称', + `code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '岗位编码', + `order` int NOT NULL COMMENT '显示排序', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + `created_id` int DEFAULT NULL COMMENT '创建人ID', + `updated_id` int DEFAULT NULL COMMENT '更新人ID', + `deleted_id` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_sys_position_uuid` (`uuid`), + KEY `ix_sys_position_deleted_time` (`deleted_time`), + KEY `ix_sys_position_tenant_id` (`tenant_id`), + KEY `ix_sys_position_deleted_id` (`deleted_id`), + KEY `ix_sys_position_created_id` (`created_id`), + KEY `ix_sys_position_updated_time` (`updated_time`), + KEY `ix_sys_position_id` (`id`), + KEY `ix_sys_position_is_deleted` (`is_deleted`), + KEY `ix_sys_position_status` (`status`), + KEY `ix_sys_position_updated_id` (`updated_id`), + KEY `ix_sys_position_created_time` (`created_time`), + CONSTRAINT `sys_position_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `sys_position_ibfk_2` FOREIGN KEY (`created_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_position_ibfk_3` FOREIGN KEY (`updated_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_position_ibfk_4` FOREIGN KEY (`deleted_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_position` +-- + +/*!40000 ALTER TABLE `sys_position` DISABLE KEYS */; +INSERT INTO `sys_position` VALUES ('技术总监','TECH_DIRECTOR',1,1,'09283948-9293-4edd-9078-e8adbe19c52e',0,'技术部门负责人','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('高级工程师','SR_ENGINEER',2,2,'41862cc9-e667-46f8-971c-4b3476464722',0,'高级技术岗位','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('工程师','ENGINEER',3,3,'a8dcd48a-1c20-4881-8a6b-86872c05cb52',0,'技术岗位','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('产品经理','PRODUCT_MANAGER',4,4,'bed30d3a-aa14-4e40-8d45-9a48f3f0dfec',0,'产品管理岗位','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('运营专员','OPERATOR',5,5,'7cdbcc8f-7fef-44b9-b7ac-85505aae1a47',0,'运营岗位','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('HR专员','HR_STAFF',6,6,'8191da67-7051-46b5-b3be-5af81f808fbe',0,'人事专员','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL); +/*!40000 ALTER TABLE `sys_position` ENABLE KEYS */; + +-- +-- Table structure for table `sys_role` +-- + +DROP TABLE IF EXISTS `sys_role`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_role` ( + `name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色名称', + `code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色编码', + `order` int NOT NULL COMMENT '显示排序', + `data_scope` int NOT NULL COMMENT '数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `tenant_id` (`tenant_id`,`code`), + UNIQUE KEY `ix_sys_role_uuid` (`uuid`), + KEY `ix_sys_role_tenant_id` (`tenant_id`), + KEY `ix_sys_role_id` (`id`), + KEY `ix_sys_role_updated_time` (`updated_time`), + KEY `ix_sys_role_is_deleted` (`is_deleted`), + KEY `ix_sys_role_created_time` (`created_time`), + KEY `ix_sys_role_status` (`status`), + KEY `ix_sys_role_deleted_time` (`deleted_time`), + CONSTRAINT `sys_role_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_role` +-- + +/*!40000 ALTER TABLE `sys_role` DISABLE KEYS */; +INSERT INTO `sys_role` VALUES ('超级管理员','SUPER_ADMIN',1,4,1,'822edc0e-0a53-44ab-906d-84de65fdffb2',0,'拥有系统最高权限','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('管理员','ADMIN',2,3,2,'ef25e5a0-2457-46f5-b244-941552df6845',0,'管理租户内所有资源','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('普通用户','USER',3,1,3,'48d54c7a-61d8-42df-b1ea-3340cd2cb97b',0,'仅能查看和操作自己的数据','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('星辰管理员','STAR_ADMIN',1,4,4,'d445f633-ae72-488c-9b37-a28501171924',0,'星辰科技有限公司管理员','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,3),('星辰员工','STAR_STAFF',2,2,5,'1c9bb9b2-fa11-448a-a186-9174721bf709',0,'星辰科技有限公司普通员工','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,3),('创新管理员','INNO_ADMIN',1,4,6,'b8937c15-abd5-458c-8a6b-3080b3287aa7',0,'创新工坊管理员','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,4),('创新员工','INNO_STAFF',2,2,7,'f542386a-59f2-4384-9997-4117ed90e5c1',0,'创新工坊普通员工','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,4); +/*!40000 ALTER TABLE `sys_role` ENABLE KEYS */; + +-- +-- Table structure for table `sys_role_depts` +-- + +DROP TABLE IF EXISTS `sys_role_depts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_role_depts` ( + `role_id` int NOT NULL COMMENT '角色ID', + `dept_id` int NOT NULL COMMENT '部门ID', + PRIMARY KEY (`role_id`,`dept_id`), + KEY `dept_id` (`dept_id`), + CONSTRAINT `sys_role_depts_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `sys_role_depts_ibfk_2` FOREIGN KEY (`dept_id`) REFERENCES `sys_dept` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色部门关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_role_depts` +-- + +/*!40000 ALTER TABLE `sys_role_depts` DISABLE KEYS */; +/*!40000 ALTER TABLE `sys_role_depts` ENABLE KEYS */; + +-- +-- Table structure for table `sys_role_menus` +-- + +DROP TABLE IF EXISTS `sys_role_menus`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_role_menus` ( + `role_id` int NOT NULL COMMENT '角色ID', + `menu_id` int NOT NULL COMMENT '菜单ID', + PRIMARY KEY (`role_id`,`menu_id`), + KEY `menu_id` (`menu_id`), + CONSTRAINT `sys_role_menus_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `sys_role_menus_ibfk_2` FOREIGN KEY (`menu_id`) REFERENCES `platform_menu` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_role_menus` +-- + +/*!40000 ALTER TABLE `sys_role_menus` DISABLE KEYS */; +/*!40000 ALTER TABLE `sys_role_menus` ENABLE KEYS */; + +-- +-- Table structure for table `sys_ticket` +-- + +DROP TABLE IF EXISTS `sys_ticket`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_ticket` ( + `title` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '工单标题', + `ticket_content` text COLLATE utf8mb4_unicode_ci COMMENT '工单内容(富文本)', + `summary` text COLLATE utf8mb4_unicode_ci COMMENT '工单内容(纯文本摘要)', + `ticket_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '工单类型(suggestion:建议 bug:缺陷 optimize:优化 other:其他)', + `images` text COLLATE utf8mb4_unicode_ci COMMENT '图片URL列表(JSON数组)', + `reply` text COLLATE utf8mb4_unicode_ci COMMENT '回复内容', + `assigned_id` int DEFAULT NULL COMMENT '处理人ID', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + `created_id` int DEFAULT NULL COMMENT '创建人ID', + `updated_id` int DEFAULT NULL COMMENT '更新人ID', + `deleted_id` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_sys_ticket_uuid` (`uuid`), + KEY `ix_sys_ticket_is_deleted` (`is_deleted`), + KEY `ix_sys_ticket_status` (`status`), + KEY `ix_sys_ticket_updated_id` (`updated_id`), + KEY `ix_sys_ticket_created_time` (`created_time`), + KEY `ix_sys_ticket_deleted_time` (`deleted_time`), + KEY `ix_sys_ticket_id` (`id`), + KEY `ix_sys_ticket_tenant_id` (`tenant_id`), + KEY `ix_sys_ticket_deleted_id` (`deleted_id`), + KEY `ix_sys_ticket_created_id` (`created_id`), + KEY `ix_sys_ticket_updated_time` (`updated_time`), + KEY `ix_sys_ticket_assigned_id` (`assigned_id`), + CONSTRAINT `sys_ticket_ibfk_1` FOREIGN KEY (`assigned_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_ticket_ibfk_2` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `sys_ticket_ibfk_3` FOREIGN KEY (`created_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_ticket_ibfk_4` FOREIGN KEY (`updated_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_ticket_ibfk_5` FOREIGN KEY (`deleted_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工单表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_ticket` +-- + +/*!40000 ALTER TABLE `sys_ticket` DISABLE KEYS */; +INSERT INTO `sys_ticket` VALUES ('系统登录页面优化建议','建议在登录页面增加记住密码功能和第三方登录入口,提升用户体验。
','建议在登录页面增加记住密码功能和第三方登录入口','suggestion',NULL,'感谢您的建议,我们将在下个版本中加入记住密码功能。',2,1,'497e0c01-0af8-4b4e-ac4a-d5d268513309',2,'用户体验优化','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('表格导出功能异常','当数据量超过1000条时,导出Excel功能会超时失败。
','数据量超过1000条导出Excel超时','bug',NULL,NULL,3,2,'ea47b64c-a87f-400a-85b4-72abdd4607b8',1,'导出功能问题','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('希望增加批量删除功能','用户管理页面希望支持批量选择删除,提高管理效率。
','用户管理页面希望支持批量选择删除','optimize',NULL,NULL,NULL,3,'177e8dc3-d19f-40b0-8166-20d02d05cd71',0,'功能优化建议','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('手机端适配问题反馈','在iPhone Safari浏览器上,菜单栏折叠后无法展开,需要刷新页面才能恢复。
','iPhone Safari菜单折叠后无法展开','bug','[\"https://example.com/screenshot1.png\"]',NULL,4,4,'60156135-0ea1-4c9b-b2ab-ad01779fe437',1,'移动端兼容性问题','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('增加数据权限粒度','当前数据权限只能控制到部门级别,希望能支持自定义数据范围,如只查看本人创建的数据、指定项目范围等。
','数据权限需要支持自定义范围','optimize',NULL,'已纳入Q3规划,感谢反馈。',2,5,'533b57d6-ae75-4c40-b967-a17761be4108',2,'数据权限增强','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('工作流审批节点无法修改','已发布的工作流无法修改审批节点配置,需要先取消发布才能修改,操作繁琐。
','已发布工作流无法直接修改节点','bug',NULL,NULL,NULL,6,'d2bc7200-ba5b-4437-9998-0ad3c91abe39',0,'星辰科技反馈工作流问题','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,3,NULL,NULL,NULL),('希望增加钉钉集成','团队使用钉钉进行日常协作,希望能将通知和待办事项同步到钉钉工作台。
','希望支持钉钉消息集成','suggestion',NULL,'我们会评估第三方集成的优先级。',NULL,7,'f3265177-de89-42a9-ba48-f2a2d48a659e',3,'创新工坊第三方集成需求','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,4,NULL,NULL,NULL),('其他-文档链接失效','帮助文档中的API接口说明链接跳转404,影响开发对接。
','帮助文档API链接404','other',NULL,NULL,3,8,'aee461f3-856e-4b6e-a540-5a640ea86431',0,'文档链接问题','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL); +/*!40000 ALTER TABLE `sys_ticket` ENABLE KEYS */; + +-- +-- Table structure for table `sys_user` +-- + +DROP TABLE IF EXISTS `sys_user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_user` ( + `username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户名/登录账号', + `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密码哈希', + `name` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '昵称', + `mobile` varchar(11) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机号', + `email` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '邮箱', + `gender` varchar(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '性别(0:男 1:女 2:未知)', + `avatar` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像URL地址', + `is_superuser` tinyint(1) NOT NULL COMMENT '是否超管', + `last_login` datetime DEFAULT NULL COMMENT '最后登录时间', + `gitee_login` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Gitee登录', + `github_login` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Github登录', + `wx_login` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信登录', + `qq_login` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'QQ登录', + `dept_id` int DEFAULT NULL COMMENT '部门ID', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + `created_id` int DEFAULT NULL COMMENT '创建人ID', + `updated_id` int DEFAULT NULL COMMENT '更新人ID', + `deleted_id` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `tenant_id` (`tenant_id`,`username`), + UNIQUE KEY `ix_sys_user_uuid` (`uuid`), + KEY `ix_sys_user_id` (`id`), + KEY `ix_sys_user_tenant_id` (`tenant_id`), + KEY `ix_sys_user_deleted_id` (`deleted_id`), + KEY `ix_sys_user_created_id` (`created_id`), + KEY `ix_sys_user_updated_time` (`updated_time`), + KEY `ix_sys_user_dept_id` (`dept_id`), + KEY `ix_sys_user_is_deleted` (`is_deleted`), + KEY `ix_sys_user_status` (`status`), + KEY `ix_sys_user_updated_id` (`updated_id`), + KEY `ix_sys_user_created_time` (`created_time`), + KEY `ix_sys_user_deleted_time` (`deleted_time`), + CONSTRAINT `sys_user_ibfk_1` FOREIGN KEY (`dept_id`) REFERENCES `sys_dept` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_user_ibfk_2` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `sys_user_ibfk_3` FOREIGN KEY (`created_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_user_ibfk_4` FOREIGN KEY (`updated_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `sys_user_ibfk_5` FOREIGN KEY (`deleted_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_user` +-- + +/*!40000 ALTER TABLE `sys_user` DISABLE KEYS */; +INSERT INTO `sys_user` VALUES ('super','$2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa','超级管理员','13800138000','super@example.com','0','https://service.fastapiadmin.com/api/v1/static/image/avatar.png',1,NULL,NULL,NULL,NULL,NULL,1,1,'5c75f9d2-766d-403f-962b-8ca40c2dcf68',0,'系统超级管理员','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('admin','$2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa','管理员','13800138001','admin@example.com','0','https://service.fastapiadmin.com/api/v1/static/image/avatar.png',1,NULL,NULL,NULL,NULL,NULL,2,2,'b334c5cd-a728-4008-bd60-2c108e81f258',0,'技术部门管理员','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,1,NULL,NULL),('user','$2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa','普通用户','13800138002','user@example.com','0','https://service.fastapiadmin.com/api/v1/static/image/avatar.png',0,NULL,NULL,NULL,NULL,NULL,3,3,'69514315-6ead-4525-bea9-c70f7b64d54a',0,'后端开发工程师','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,1,NULL,NULL),('product','$2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa','产品经理','13800138003','product@example.com','1','https://service.fastapiadmin.com/api/v1/static/image/avatar.png',0,NULL,NULL,NULL,NULL,NULL,5,4,'bedf65c2-4ab0-4696-b800-7d155a94e579',0,'产品经理','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,1,NULL,NULL),('hr','$2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa','HR专员','13800138004','hr@example.com','1','https://service.fastapiadmin.com/api/v1/static/image/avatar.png',0,NULL,NULL,NULL,NULL,NULL,6,5,'1562dd96-3d07-4e2d-ae82-db99af3f9d27',0,'人力资源专员','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,1,NULL,NULL),('zhang_admin','$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe','张明','13800001001','zhang@star-tech.dev','2',NULL,0,NULL,NULL,NULL,NULL,NULL,NULL,6,'44497717-18b4-4ffd-8ab9-8a474026d4a3',0,'星辰科技管理员','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,3,NULL,NULL,NULL),('wang_dev','$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe','王华','13800001002','wang@star-tech.dev','2',NULL,0,NULL,NULL,NULL,NULL,NULL,NULL,7,'2f8403d2-81c8-495a-9ab9-9fb59ed26be7',0,'星辰科技研发工程师','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,3,NULL,NULL,NULL),('li_admin','$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe','李芳','13800002001','li@inno.work','2',NULL,0,NULL,NULL,NULL,NULL,NULL,NULL,8,'13f5eca9-52e7-4b4b-b6cb-461ba21b52fe',0,'创新工坊创始人','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,4,NULL,NULL,NULL),('zhao_eng','$2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe','赵强','13800002002','zhao@inno.work','2',NULL,0,NULL,NULL,NULL,NULL,NULL,NULL,9,'8847d8a1-48b9-43f8-ba9c-2cc59165fc00',0,'创新工坊技术合伙人','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,4,NULL,NULL,NULL); +/*!40000 ALTER TABLE `sys_user` ENABLE KEYS */; + +-- +-- Table structure for table `sys_user_positions` +-- + +DROP TABLE IF EXISTS `sys_user_positions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_user_positions` ( + `user_id` int NOT NULL COMMENT '用户ID', + `position_id` int NOT NULL COMMENT '岗位ID', + PRIMARY KEY (`user_id`,`position_id`), + KEY `position_id` (`position_id`), + CONSTRAINT `sys_user_positions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `sys_user_positions_ibfk_2` FOREIGN KEY (`position_id`) REFERENCES `sys_position` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户岗位关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_user_positions` +-- + +/*!40000 ALTER TABLE `sys_user_positions` DISABLE KEYS */; +/*!40000 ALTER TABLE `sys_user_positions` ENABLE KEYS */; + +-- +-- Table structure for table `sys_user_roles` +-- + +DROP TABLE IF EXISTS `sys_user_roles`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_user_roles` ( + `user_id` int NOT NULL COMMENT '用户ID', + `role_id` int NOT NULL COMMENT '角色ID', + PRIMARY KEY (`user_id`,`role_id`), + KEY `role_id` (`role_id`), + CONSTRAINT `sys_user_roles_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `sys_user_roles_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `sys_user_roles` +-- + +/*!40000 ALTER TABLE `sys_user_roles` DISABLE KEYS */; +INSERT INTO `sys_user_roles` VALUES (1,1),(2,2),(3,3),(4,3),(5,3),(6,4),(7,5),(8,6),(9,7); +/*!40000 ALTER TABLE `sys_user_roles` ENABLE KEYS */; + +-- +-- Table structure for table `task_job` +-- + +DROP TABLE IF EXISTS `task_job`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `task_job` ( + `job_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务ID', + `job_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '任务名称', + `trigger_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '触发方式: cron/interval/date/manual', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '执行状态', + `next_run_time` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '下次执行时间', + `job_state` text COLLATE utf8mb4_unicode_ci COMMENT '任务状态信息', + `result` text COLLATE utf8mb4_unicode_ci COMMENT '执行结果', + `error` text COLLATE utf8mb4_unicode_ci COMMENT '错误信息', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_task_job_uuid` (`uuid`), + KEY `ix_task_job_updated_time` (`updated_time`), + KEY `ix_task_job_is_deleted` (`is_deleted`), + KEY `ix_task_job_created_time` (`created_time`), + KEY `ix_task_job_deleted_time` (`deleted_time`), + KEY `ix_task_job_job_id` (`job_id`), + KEY `ix_task_job_tenant_id` (`tenant_id`), + KEY `ix_task_job_id` (`id`), + CONSTRAINT `task_job_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务执行日志表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `task_job` +-- + +/*!40000 ALTER TABLE `task_job` DISABLE KEYS */; +INSERT INTO `task_job` VALUES ('system_tenant_expiry_check','租户到期检查','interval','pending','2026-06-17 01:17:10.389968+08:00','{\n \"version\": 1,\n \"id\": \"system_tenant_expiry_check\",\n \"func\": \"app.api.v1.module_platform.tenant.service:TenantService.check_tenant_expiry\",\n \"trigger\": \"interval[1:00:00]\",\n \"executor\": \"default\",\n \"args\": [],\n \"kwargs\": {},\n \"name\": \"租户到期检查\",\n \"misfire_grace_time\": 1,\n \"coalesce\": true,\n \"max_instances\": 5,\n \"next_run_time\": \"2026-06-17 01:17:10.389968+08:00\"\n}',NULL,NULL,1,'a31d2255-9838-4d36-832c-470b540b9a26',NULL,'2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('system_grace_reminder','宽限期续费提醒','cron','pending','2026-06-17 09:00:00+08:00','{\n \"version\": 1,\n \"id\": \"system_grace_reminder\",\n \"func\": \"app.api.v1.module_platform.tenant.service:TenantService.send_grace_reminders\",\n \"trigger\": \"cron[hour=\'9\', minute=\'0\']\",\n \"executor\": \"default\",\n \"args\": [],\n \"kwargs\": {},\n \"name\": \"宽限期续费提醒\",\n \"misfire_grace_time\": 1,\n \"coalesce\": true,\n \"max_instances\": 5,\n \"next_run_time\": \"2026-06-17 09:00:00+08:00\"\n}',NULL,NULL,2,'9183c8ce-c7b9-4b02-96b0-b54ff44e707f',NULL,'2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('system_clean_expired','过期租户归档清理','cron','pending','2026-07-01 02:00:00+08:00','{\n \"version\": 1,\n \"id\": \"system_clean_expired\",\n \"func\": \"app.api.v1.module_platform.tenant.service:TenantService.clean_expired_tenants\",\n \"trigger\": \"cron[day=\'1\', hour=\'2\', minute=\'0\']\",\n \"executor\": \"default\",\n \"args\": [],\n \"kwargs\": {},\n \"name\": \"过期租户归档清理\",\n \"misfire_grace_time\": 1,\n \"coalesce\": true,\n \"max_instances\": 5,\n \"next_run_time\": \"2026-07-01 02:00:00+08:00\"\n}',NULL,NULL,3,'c428c9cf-39a7-4154-9ae3-9df37146667b',NULL,'2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('system_cancel_expired_orders','超时订单取消','interval','pending','2026-06-17 00:22:10.411515+08:00','{\n \"version\": 1,\n \"id\": \"system_cancel_expired_orders\",\n \"func\": \"app.api.v1.module_platform.order.service:OrderService.cancel_expired_orders\",\n \"trigger\": \"interval[0:05:00]\",\n \"executor\": \"default\",\n \"args\": [],\n \"kwargs\": {},\n \"name\": \"超时订单取消\",\n \"misfire_grace_time\": 1,\n \"coalesce\": true,\n \"max_instances\": 5,\n \"next_run_time\": \"2026-06-17 00:22:10.411515+08:00\"\n}',NULL,NULL,4,'fcfadeed-276b-4b85-926e-ddfe7a7a43be',NULL,'2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1),('system_cleanup_operation_log','操作日志清理','cron','pending','2026-06-21 03:00:00+08:00','{\n \"version\": 1,\n \"id\": \"system_cleanup_operation_log\",\n \"func\": \"app.api.v1.module_system.log.service:OperationLogService.cleanup_operation_log\",\n \"trigger\": \"cron[day_of_week=\'sun\', hour=\'3\', minute=\'0\']\",\n \"executor\": \"default\",\n \"args\": [],\n \"kwargs\": {},\n \"name\": \"操作日志清理\",\n \"misfire_grace_time\": 1,\n \"coalesce\": true,\n \"max_instances\": 5,\n \"next_run_time\": \"2026-06-21 03:00:00+08:00\"\n}',NULL,NULL,5,'d9ce9654-6e03-42a8-8676-0404dfba5fb7',NULL,'2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1); +/*!40000 ALTER TABLE `task_job` ENABLE KEYS */; + +-- +-- Table structure for table `task_node` +-- + +DROP TABLE IF EXISTS `task_node`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `task_node` ( + `name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '节点名称', + `code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '节点编码', + `jobstore` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '存储器', + `executor` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '执行器', + `trigger` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '触发器', + `trigger_args` text COLLATE utf8mb4_unicode_ci COMMENT '触发器参数', + `func` text COLLATE utf8mb4_unicode_ci COMMENT '代码块', + `args` text COLLATE utf8mb4_unicode_ci COMMENT '位置参数', + `kwargs` text COLLATE utf8mb4_unicode_ci COMMENT '关键字参数', + `coalesce` tinyint(1) DEFAULT NULL COMMENT '是否合并运行', + `max_instances` int DEFAULT NULL COMMENT '最大实例数', + `start_date` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '开始时间', + `end_date` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '结束时间', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + `created_id` int DEFAULT NULL COMMENT '创建人ID', + `updated_id` int DEFAULT NULL COMMENT '更新人ID', + `deleted_id` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `tenant_id` (`tenant_id`,`code`), + UNIQUE KEY `ix_task_node_uuid` (`uuid`), + KEY `ix_task_node_is_deleted` (`is_deleted`), + KEY `ix_task_node_id` (`id`), + KEY `ix_task_node_status` (`status`), + KEY `ix_task_node_updated_id` (`updated_id`), + KEY `ix_task_node_created_time` (`created_time`), + KEY `ix_task_node_deleted_time` (`deleted_time`), + KEY `ix_task_node_tenant_id` (`tenant_id`), + KEY `ix_task_node_deleted_id` (`deleted_id`), + KEY `ix_task_node_created_id` (`created_id`), + KEY `ix_task_node_updated_time` (`updated_time`), + CONSTRAINT `task_node_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `task_node_ibfk_2` FOREIGN KEY (`created_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `task_node_ibfk_3` FOREIGN KEY (`updated_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `task_node_ibfk_4` FOREIGN KEY (`deleted_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='节点类型表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `task_node` +-- + +/*!40000 ALTER TABLE `task_node` DISABLE KEYS */; +INSERT INTO `task_node` VALUES ('演示任务','demo_job','default','default',NULL,NULL,'import logging\n\ndef handler(*args, **kwargs):\n \"\"\"演示任务:打印参数并返回执行摘要\"\"\"\n logger = logging.getLogger(__name__)\n logger.info(f\"演示任务执行中,参数: args={args}, kwargs={kwargs}\")\n return {\n \"status\": \"success\",\n \"message\": \"演示任务执行成功\",\n \"args_received\": len(args),\n \"kwargs_keys\": list(kwargs.keys())\n }\n',NULL,NULL,0,1,NULL,NULL,1,'a1472076-2d99-4514-b6d9-72e75e595d98',0,'最简演示任务,用于验证调度器基本功能','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('数据库清理任务','db_cleanup','sqlalchemy','default',NULL,NULL,'import logging\nfrom datetime import datetime, timedelta\n\ndef handler(*args, **kwargs):\n \"\"\"清理过期数据:删除N天前的日志和临时数据\"\"\"\n logger = logging.getLogger(__name__)\n days = kwargs.get(\"days\", 90)\n cutoff = datetime.now() - timedelta(days=days)\n logger.info(f\"清理 {cutoff.strftime(\'%Y-%m-%d\')} 之前的过期数据...\")\n return {\n \"status\": \"success\",\n \"cutoff_date\": cutoff.strftime(\"%Y-%m-%d %H:%M:%S\"),\n \"deleted_count\": 0\n }\n',NULL,'{\"days\": 30}',1,1,NULL,NULL,2,'ef8ed782-3abb-4bb4-a93b-68e4fca85e71',0,'清理过期操作日志和临时数据,建议每天凌晨3点执行','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('健康检查任务','health_check','default','default',NULL,NULL,'import logging\nimport psutil\n\ndef handler(*args, **kwargs):\n \"\"\"系统健康检查:采集 CPU、内存、磁盘使用率\"\"\"\n logger = logging.getLogger(__name__)\n cpu = psutil.cpu_percent(interval=1)\n mem = psutil.virtual_memory()\n disk = psutil.disk_usage(\"/\")\n status = \"healthy\" if cpu < 80 and mem.percent < 90 and disk.percent < 90 else \"warning\"\n logger.info(f\"健康检查: CPU={cpu}% MEM={mem.percent}% DISK={disk.percent}%\")\n return {\n \"status\": status,\n \"cpu_percent\": cpu,\n \"memory_percent\": mem.percent,\n \"disk_percent\": disk.percent,\n \"memory_total_gb\": round(mem.total / (1024**3), 1),\n \"disk_total_gb\": round(disk.total / (1024**3), 1)\n }\n',NULL,NULL,1,1,NULL,NULL,3,'f858efcf-74db-477a-9c52-ad9482200f1f',0,'系统资源健康检查,建议每5分钟执行一次','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('邮件批量发送','email_batch','sqlalchemy','default',NULL,NULL,'import logging\n\ndef handler(*args, **kwargs):\n \"\"\"批量发送待发送邮件\"\"\"\n logger = logging.getLogger(__name__)\n batch_size = kwargs.get(\"batch_size\", 50)\n logger.info(f\"开始批量发送邮件,每批 {batch_size} 封...\")\n return {\n \"status\": \"success\",\n \"sent_count\": 0,\n \"failed_count\": 0,\n \"batch_size\": batch_size\n }\n',NULL,'{\"batch_size\": 50}',0,2,NULL,NULL,4,'5fe0bcb0-c977-4b06-8104-79179853160c',0,'批量发送待发送邮件,建议每分钟执行一次','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL); +/*!40000 ALTER TABLE `task_node` ENABLE KEYS */; + +-- +-- Table structure for table `task_workflow` +-- + +DROP TABLE IF EXISTS `task_workflow`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `task_workflow` ( + `name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '流程名称', + `code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '流程编码', + `workflow_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '流程状态: draft/published/archived', + `nodes` json DEFAULT NULL COMMENT 'Vue Flow nodes JSON', + `edges` json DEFAULT NULL COMMENT 'Vue Flow edges JSON', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + `created_id` int DEFAULT NULL COMMENT '创建人ID', + `updated_id` int DEFAULT NULL COMMENT '更新人ID', + `deleted_id` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_task_workflow_code` (`tenant_id`,`code`), + UNIQUE KEY `ix_task_workflow_uuid` (`uuid`), + KEY `ix_task_workflow_is_deleted` (`is_deleted`), + KEY `ix_task_workflow_deleted_id` (`deleted_id`), + KEY `ix_task_workflow_tenant_id` (`tenant_id`), + KEY `ix_task_workflow_updated_time` (`updated_time`), + KEY `ix_task_workflow_created_id` (`created_id`), + KEY `ix_task_workflow_id` (`id`), + KEY `ix_task_workflow_updated_id` (`updated_id`), + KEY `ix_task_workflow_created_time` (`created_time`), + KEY `ix_task_workflow_deleted_time` (`deleted_time`), + KEY `ix_task_workflow_status` (`status`), + CONSTRAINT `task_workflow_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `task_workflow_ibfk_2` FOREIGN KEY (`created_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `task_workflow_ibfk_3` FOREIGN KEY (`updated_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `task_workflow_ibfk_4` FOREIGN KEY (`deleted_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作流定义表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `task_workflow` +-- + +/*!40000 ALTER TABLE `task_workflow` DISABLE KEYS */; +/*!40000 ALTER TABLE `task_workflow` ENABLE KEYS */; + +-- +-- Table structure for table `task_workflow_node_type` +-- + +DROP TABLE IF EXISTS `task_workflow_node_type`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `task_workflow_node_type` ( + `name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '显示名称', + `code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '节点编码,对应画布 node.type', + `category` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '分类: trigger/action/condition/control', + `func` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Python 代码块,须定义 handler(*args,**kwargs)', + `args` text COLLATE utf8mb4_unicode_ci COMMENT '默认位置参数,逗号分隔', + `kwargs` text COLLATE utf8mb4_unicode_ci COMMENT '默认关键字参数 JSON', + `sort_order` int NOT NULL COMMENT '排序', + `is_active` tinyint(1) NOT NULL COMMENT '是否启用', + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UUID全局唯一标识', + `status` int NOT NULL COMMENT '状态', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '备注/描述', + `created_time` datetime NOT NULL COMMENT '创建时间', + `updated_time` datetime NOT NULL COMMENT '更新时间', + `is_deleted` tinyint(1) NOT NULL COMMENT '是否已删除(0:未删除 1:已删除)', + `deleted_time` datetime DEFAULT NULL COMMENT '删除时间', + `tenant_id` int NOT NULL COMMENT '租户ID', + `created_id` int DEFAULT NULL COMMENT '创建人ID', + `updated_id` int DEFAULT NULL COMMENT '更新人ID', + `deleted_id` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `tenant_id` (`tenant_id`,`code`), + UNIQUE KEY `ix_task_workflow_node_type_uuid` (`uuid`), + KEY `ix_task_workflow_node_type_status` (`status`), + KEY `ix_task_workflow_node_type_is_deleted` (`is_deleted`), + KEY `ix_task_workflow_node_type_created_time` (`created_time`), + KEY `ix_task_workflow_node_type_created_id` (`created_id`), + KEY `ix_task_workflow_node_type_deleted_time` (`deleted_time`), + KEY `ix_task_workflow_node_type_deleted_id` (`deleted_id`), + KEY `ix_task_workflow_node_type_id` (`id`), + KEY `ix_task_workflow_node_type_updated_time` (`updated_time`), + KEY `ix_task_workflow_node_type_tenant_id` (`tenant_id`), + KEY `ix_task_workflow_node_type_updated_id` (`updated_id`), + CONSTRAINT `task_workflow_node_type_ibfk_1` FOREIGN KEY (`tenant_id`) REFERENCES `platform_tenant` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `task_workflow_node_type_ibfk_2` FOREIGN KEY (`created_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `task_workflow_node_type_ibfk_3` FOREIGN KEY (`updated_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `task_workflow_node_type_ibfk_4` FOREIGN KEY (`deleted_id`) REFERENCES `sys_user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作流编排节点类型(非定时任务节点)'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `task_workflow_node_type` +-- + +/*!40000 ALTER TABLE `task_workflow_node_type` DISABLE KEYS */; +INSERT INTO `task_workflow_node_type` VALUES ('HTTP请求','http_request','action','import json\nimport urllib.request\n\ndef handler(*args, **kwargs):\n \"\"\"发送 HTTP 请求并返回响应\"\"\"\n url = kwargs.get(\"url\", \"\")\n method = kwargs.get(\"method\", \"GET\")\n headers = kwargs.get(\"headers\", {})\n body = kwargs.get(\"body\")\n if not url:\n raise ValueError(\"缺少 url 参数\")\n req = urllib.request.Request(url, method=method, headers=headers)\n if body and isinstance(body, dict):\n req.data = json.dumps(body).encode()\n with urllib.request.urlopen(req) as resp:\n return {\"status_code\": resp.status, \"body\": resp.read().decode()}\n',NULL,'{\"url\": \"\", \"method\": \"GET\"}',1,1,1,'ab5bd4cd-1a22-41e6-81dd-f363f7dc1bb2',0,'发送 HTTP 请求,支持 GET/POST 等方法','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('发送通知','send_notification','action','import logging\n\ndef handler(*args, **kwargs):\n \"\"\"发送通知消息\"\"\"\n logger = logging.getLogger(__name__)\n channel = kwargs.get(\"channel\", \"system\")\n title = kwargs.get(\"title\", \"工作流通知\")\n content = kwargs.get(\"content\", \"\")\n recipients = kwargs.get(\"recipients\", [])\n logger.info(f\"[{channel}] 发送通知: {title} -> {len(recipients)}人\")\n return {\n \"channel\": channel,\n \"title\": title,\n \"recipient_count\": len(recipients),\n \"status\": \"sent\"\n }\n',NULL,'{\"channel\": \"system\", \"title\": \"工作流通知\", \"recipients\": []}',2,1,2,'e6d204b9-5216-4c5c-86e0-d1e414d73d9c',0,'发送系统通知、邮件或短信','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('条件判断','condition','condition','import json\n\ndef handler(*args, **kwargs):\n \"\"\"条件分支:根据 upstream 结果决定走向\"\"\"\n upstream = kwargs.get(\"upstream\", {})\n variables = kwargs.get(\"variables\", {})\n field = kwargs.get(\"field\", \"status\")\n expected = kwargs.get(\"expected\", \"success\")\n operator = kwargs.get(\"operator\", \"eq\")\n last = list(upstream.values())[-1] if upstream else {}\n actual = last.get(field) if isinstance(last, dict) else last\n operations = {\n \"eq\": lambda a, e: a == e,\n \"ne\": lambda a, e: a != e,\n \"gt\": lambda a, e: a > e,\n \"lt\": lambda a, e: a < e,\n \"contains\": lambda a, e: str(e) in str(a)\n }\n op = operations.get(operator, operations[\"eq\"])\n result = op(actual, expected)\n return {\"passed\": result, \"actual\": actual, \"expected\": expected}\n',NULL,'{\"field\": \"status\", \"expected\": \"success\", \"operator\": \"eq\"}',3,1,3,'d9bb707d-c8c9-4dc2-ba23-96320a502446',0,'根据上游节点输出判断分支走向','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('数据转换','data_transform','action','import json\nfrom datetime import datetime\n\ndef handler(*args, **kwargs):\n \"\"\"转换上游数据格式\"\"\"\n upstream = kwargs.get(\"upstream\", {})\n mapping = kwargs.get(\"mapping\", {})\n result = {}\n for upstream_key, target_key in mapping.items():\n for source, value in upstream.items():\n if isinstance(value, dict) and upstream_key in value:\n result[target_key] = value[upstream_key]\n result[\"transformed_at\"] = datetime.now().isoformat()\n return result\n',NULL,'{\"mapping\": {}}',4,1,4,'84e203ce-1fbd-44db-b573-e4f233d1cfaa',0,'转换上游节点的数据格式','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL),('聚合汇总','aggregate','action','import json\n\ndef handler(*args, **kwargs):\n \"\"\"聚合上游多个节点的输出\"\"\"\n upstream = kwargs.get(\"upstream\", {})\n variables = kwargs.get(\"variables\", {})\n results = {\n \"node_count\": len(upstream),\n \"nodes\": list(upstream.keys()),\n \"values\": list(upstream.values()),\n \"variables\": variables\n }\n return results\n',NULL,NULL,5,1,5,'5025078a-30a6-4178-a9de-7baf68140553',0,'将多个上游节点的输出聚合到一个结果中','2026-06-17 00:17:10','2026-06-17 00:17:10',0,NULL,1,NULL,NULL,NULL); +/*!40000 ALTER TABLE `task_workflow_node_type` ENABLE KEYS */; + +-- +-- Dumping routines for database 'fastapiadmin' +-- +-- +-- WARNING: can't read the INFORMATION_SCHEMA.libraries table. It's most probably an old server 8.4.3. +-- +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-06-17 0:17:30 diff --git a/backend/sql/postgres/fastapiadmin_2026-04-19_224727.sql b/backend/sql/postgres/fastapiadmin_2026-04-19_224727.sql deleted file mode 100644 index 91935879..00000000 --- a/backend/sql/postgres/fastapiadmin_2026-04-19_224727.sql +++ /dev/null @@ -1,6573 +0,0 @@ --- --- PostgreSQL database dump --- - -\restrict U84VGCVMrsljQl8C3zlCI31HzTnzJhIQqXq4FvOU82CzFlE476WyEoS3uEhMqE8 - --- Dumped from database version 17.5 (ServBay) --- Dumped by pg_dump version 18.2 - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET transaction_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - --- --- Name: ai; Type: SCHEMA; Schema: -; Owner: root --- - -CREATE SCHEMA ai; - - -ALTER SCHEMA ai OWNER TO root; - -SET default_tablespace = ''; - -SET default_table_access_method = heap; - --- --- Name: agno_schema_versions; Type: TABLE; Schema: ai; Owner: root --- - -CREATE TABLE ai.agno_schema_versions ( - table_name character varying NOT NULL, - version character varying NOT NULL, - created_at character varying NOT NULL, - updated_at character varying -); - - -ALTER TABLE ai.agno_schema_versions OWNER TO root; - --- --- Name: agno_sessions; Type: TABLE; Schema: ai; Owner: root --- - -CREATE TABLE ai.agno_sessions ( - session_id character varying NOT NULL, - session_type character varying NOT NULL, - agent_id character varying, - team_id character varying, - workflow_id character varying, - user_id character varying, - session_data jsonb, - agent_data jsonb, - team_data jsonb, - workflow_data jsonb, - metadata jsonb, - runs jsonb, - summary jsonb, - created_at bigint NOT NULL, - updated_at bigint -); - - -ALTER TABLE ai.agno_sessions OWNER TO root; - --- --- Name: app_portal; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.app_portal ( - name character varying(64) NOT NULL, - access_url character varying(500) NOT NULL, - icon_url character varying(300), - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - tenant_id integer NOT NULL, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.app_portal OWNER TO root; - --- --- Name: TABLE app_portal; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.app_portal IS '门户应用'; - - --- --- Name: COLUMN app_portal.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.name IS '应用名称'; - - --- --- Name: COLUMN app_portal.access_url; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.access_url IS '访问地址'; - - --- --- Name: COLUMN app_portal.icon_url; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.icon_url IS '应用图标URL'; - - --- --- Name: COLUMN app_portal.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.id IS '主键ID'; - - --- --- Name: COLUMN app_portal.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN app_portal.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN app_portal.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.description IS '备注/描述'; - - --- --- Name: COLUMN app_portal.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.created_time IS '创建时间'; - - --- --- Name: COLUMN app_portal.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.updated_time IS '更新时间'; - - --- --- Name: COLUMN app_portal.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN app_portal.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.deleted_time IS '删除时间'; - - --- --- Name: COLUMN app_portal.tenant_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.tenant_id IS '租户ID'; - - --- --- Name: COLUMN app_portal.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.created_id IS '创建人ID'; - - --- --- Name: COLUMN app_portal.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.updated_id IS '更新人ID'; - - --- --- Name: COLUMN app_portal.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.app_portal.deleted_id IS '删除人ID'; - - --- --- Name: app_portal_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.app_portal_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.app_portal_id_seq OWNER TO root; - --- --- Name: app_portal_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.app_portal_id_seq OWNED BY public.app_portal.id; - - --- --- Name: apscheduler_jobs; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.apscheduler_jobs ( - id character varying(191) NOT NULL, - next_run_time double precision, - job_state bytea NOT NULL -); - - -ALTER TABLE public.apscheduler_jobs OWNER TO root; - --- --- Name: gen_demo; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.gen_demo ( - name character varying(64) NOT NULL, - a integer, - b bigint, - c double precision, - d boolean NOT NULL, - e date, - f time without time zone, - g timestamp without time zone, - h text, - i json, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.gen_demo OWNER TO root; - --- --- Name: TABLE gen_demo; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.gen_demo IS '示例表'; - - --- --- Name: COLUMN gen_demo.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.name IS '名称'; - - --- --- Name: COLUMN gen_demo.a; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.a IS '整数'; - - --- --- Name: COLUMN gen_demo.b; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.b IS '大整数'; - - --- --- Name: COLUMN gen_demo.c; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.c IS '浮点数'; - - --- --- Name: COLUMN gen_demo.d; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.d IS '布尔型'; - - --- --- Name: COLUMN gen_demo.e; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.e IS '日期'; - - --- --- Name: COLUMN gen_demo.f; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.f IS '时间'; - - --- --- Name: COLUMN gen_demo.g; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.g IS '日期时间'; - - --- --- Name: COLUMN gen_demo.h; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.h IS '长文本'; - - --- --- Name: COLUMN gen_demo.i; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.i IS '元数据(JSON格式)'; - - --- --- Name: COLUMN gen_demo.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.id IS '主键ID'; - - --- --- Name: COLUMN gen_demo.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN gen_demo.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN gen_demo.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.description IS '备注/描述'; - - --- --- Name: COLUMN gen_demo.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.created_time IS '创建时间'; - - --- --- Name: COLUMN gen_demo.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.updated_time IS '更新时间'; - - --- --- Name: COLUMN gen_demo.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN gen_demo.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.deleted_time IS '删除时间'; - - --- --- Name: COLUMN gen_demo.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.created_id IS '创建人ID'; - - --- --- Name: COLUMN gen_demo.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.updated_id IS '更新人ID'; - - --- --- Name: COLUMN gen_demo.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo.deleted_id IS '删除人ID'; - - --- --- Name: gen_demo01; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.gen_demo01 ( - name character varying(64) NOT NULL, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.gen_demo01 OWNER TO root; - --- --- Name: TABLE gen_demo01; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.gen_demo01 IS '示例1表'; - - --- --- Name: COLUMN gen_demo01.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.name IS '名称'; - - --- --- Name: COLUMN gen_demo01.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.id IS '主键ID'; - - --- --- Name: COLUMN gen_demo01.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN gen_demo01.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN gen_demo01.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.description IS '备注/描述'; - - --- --- Name: COLUMN gen_demo01.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.created_time IS '创建时间'; - - --- --- Name: COLUMN gen_demo01.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.updated_time IS '更新时间'; - - --- --- Name: COLUMN gen_demo01.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN gen_demo01.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.deleted_time IS '删除时间'; - - --- --- Name: COLUMN gen_demo01.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.created_id IS '创建人ID'; - - --- --- Name: COLUMN gen_demo01.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.updated_id IS '更新人ID'; - - --- --- Name: COLUMN gen_demo01.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_demo01.deleted_id IS '删除人ID'; - - --- --- Name: gen_demo01_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.gen_demo01_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.gen_demo01_id_seq OWNER TO root; - --- --- Name: gen_demo01_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.gen_demo01_id_seq OWNED BY public.gen_demo01.id; - - --- --- Name: gen_demo_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.gen_demo_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.gen_demo_id_seq OWNER TO root; - --- --- Name: gen_demo_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.gen_demo_id_seq OWNED BY public.gen_demo.id; - - --- --- Name: gen_table; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.gen_table ( - table_name character varying(200) NOT NULL, - table_comment character varying(500), - class_name character varying(100) NOT NULL, - package_name character varying(100), - module_name character varying(30), - business_name character varying(30), - function_name character varying(100), - sub_table_name character varying(64) DEFAULT NULL::character varying, - sub_table_fk_name character varying(64) DEFAULT NULL::character varying, - parent_menu_id integer, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.gen_table OWNER TO root; - --- --- Name: TABLE gen_table; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.gen_table IS '代码生成表'; - - --- --- Name: COLUMN gen_table.table_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.table_name IS '表名称'; - - --- --- Name: COLUMN gen_table.table_comment; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.table_comment IS '表描述'; - - --- --- Name: COLUMN gen_table.class_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.class_name IS '实体类名称'; - - --- --- Name: COLUMN gen_table.package_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.package_name IS '生成包路径'; - - --- --- Name: COLUMN gen_table.module_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.module_name IS '生成模块名'; - - --- --- Name: COLUMN gen_table.business_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.business_name IS '生成业务名'; - - --- --- Name: COLUMN gen_table.function_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.function_name IS '生成功能名'; - - --- --- Name: COLUMN gen_table.sub_table_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.sub_table_name IS '关联子表的表名'; - - --- --- Name: COLUMN gen_table.sub_table_fk_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.sub_table_fk_name IS '子表关联的外键名'; - - --- --- Name: COLUMN gen_table.parent_menu_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.parent_menu_id IS '父菜单ID'; - - --- --- Name: COLUMN gen_table.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.id IS '主键ID'; - - --- --- Name: COLUMN gen_table.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN gen_table.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN gen_table.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.description IS '备注/描述'; - - --- --- Name: COLUMN gen_table.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.created_time IS '创建时间'; - - --- --- Name: COLUMN gen_table.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.updated_time IS '更新时间'; - - --- --- Name: COLUMN gen_table.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN gen_table.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.deleted_time IS '删除时间'; - - --- --- Name: COLUMN gen_table.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.created_id IS '创建人ID'; - - --- --- Name: COLUMN gen_table.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.updated_id IS '更新人ID'; - - --- --- Name: COLUMN gen_table.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table.deleted_id IS '删除人ID'; - - --- --- Name: gen_table_column; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.gen_table_column ( - column_name character varying(200) NOT NULL, - column_comment character varying(500), - column_type character varying(100) NOT NULL, - column_length character varying(50), - column_default character varying(200), - is_pk boolean DEFAULT false NOT NULL, - is_increment boolean DEFAULT false NOT NULL, - is_nullable boolean DEFAULT true NOT NULL, - is_unique boolean DEFAULT false NOT NULL, - python_type character varying(100), - python_field character varying(200), - is_insert boolean DEFAULT true NOT NULL, - is_edit boolean DEFAULT true NOT NULL, - is_list boolean DEFAULT true NOT NULL, - is_query boolean DEFAULT false NOT NULL, - query_type character varying(50), - html_type character varying(100), - dict_type character varying(200), - sort integer NOT NULL, - table_id integer NOT NULL, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.gen_table_column OWNER TO root; - --- --- Name: TABLE gen_table_column; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.gen_table_column IS '代码生成表字段'; - - --- --- Name: COLUMN gen_table_column.column_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.column_name IS '列名称'; - - --- --- Name: COLUMN gen_table_column.column_comment; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.column_comment IS '列描述'; - - --- --- Name: COLUMN gen_table_column.column_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.column_type IS '列类型'; - - --- --- Name: COLUMN gen_table_column.column_length; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.column_length IS '列长度'; - - --- --- Name: COLUMN gen_table_column.column_default; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.column_default IS '列默认值'; - - --- --- Name: COLUMN gen_table_column.is_pk; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_pk IS '是否主键'; - - --- --- Name: COLUMN gen_table_column.is_increment; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_increment IS '是否自增'; - - --- --- Name: COLUMN gen_table_column.is_nullable; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_nullable IS '是否允许为空'; - - --- --- Name: COLUMN gen_table_column.is_unique; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_unique IS '是否唯一'; - - --- --- Name: COLUMN gen_table_column.python_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.python_type IS 'Python类型'; - - --- --- Name: COLUMN gen_table_column.python_field; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.python_field IS 'Python字段名'; - - --- --- Name: COLUMN gen_table_column.is_insert; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_insert IS '是否为新增字段'; - - --- --- Name: COLUMN gen_table_column.is_edit; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_edit IS '是否编辑字段'; - - --- --- Name: COLUMN gen_table_column.is_list; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_list IS '是否列表字段'; - - --- --- Name: COLUMN gen_table_column.is_query; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_query IS '是否查询字段'; - - --- --- Name: COLUMN gen_table_column.query_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.query_type IS '查询方式'; - - --- --- Name: COLUMN gen_table_column.html_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.html_type IS '显示类型'; - - --- --- Name: COLUMN gen_table_column.dict_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.dict_type IS '字典类型'; - - --- --- Name: COLUMN gen_table_column.sort; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.sort IS '排序'; - - --- --- Name: COLUMN gen_table_column.table_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.table_id IS '归属表编号'; - - --- --- Name: COLUMN gen_table_column.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.id IS '主键ID'; - - --- --- Name: COLUMN gen_table_column.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN gen_table_column.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN gen_table_column.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.description IS '备注/描述'; - - --- --- Name: COLUMN gen_table_column.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.created_time IS '创建时间'; - - --- --- Name: COLUMN gen_table_column.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.updated_time IS '更新时间'; - - --- --- Name: COLUMN gen_table_column.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN gen_table_column.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.deleted_time IS '删除时间'; - - --- --- Name: COLUMN gen_table_column.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.created_id IS '创建人ID'; - - --- --- Name: COLUMN gen_table_column.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.updated_id IS '更新人ID'; - - --- --- Name: COLUMN gen_table_column.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.gen_table_column.deleted_id IS '删除人ID'; - - --- --- Name: gen_table_column_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.gen_table_column_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.gen_table_column_id_seq OWNER TO root; - --- --- Name: gen_table_column_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.gen_table_column_id_seq OWNED BY public.gen_table_column.id; - - --- --- Name: gen_table_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.gen_table_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.gen_table_id_seq OWNER TO root; - --- --- Name: gen_table_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.gen_table_id_seq OWNED BY public.gen_table.id; - - --- --- Name: sys_dept; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_dept ( - name character varying(64) NOT NULL, - "order" integer NOT NULL, - code character varying(16) NOT NULL, - leader character varying(32), - phone character varying(11), - email character varying(64), - parent_id integer, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.sys_dept OWNER TO root; - --- --- Name: TABLE sys_dept; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_dept IS '部门表'; - - --- --- Name: COLUMN sys_dept.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.name IS '部门名称'; - - --- --- Name: COLUMN sys_dept."order"; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept."order" IS '显示排序'; - - --- --- Name: COLUMN sys_dept.code; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.code IS '部门编码'; - - --- --- Name: COLUMN sys_dept.leader; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.leader IS '部门负责人'; - - --- --- Name: COLUMN sys_dept.phone; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.phone IS '手机'; - - --- --- Name: COLUMN sys_dept.email; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.email IS '邮箱'; - - --- --- Name: COLUMN sys_dept.parent_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.parent_id IS '父级部门ID'; - - --- --- Name: COLUMN sys_dept.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.id IS '主键ID'; - - --- --- Name: COLUMN sys_dept.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_dept.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_dept.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.description IS '备注/描述'; - - --- --- Name: COLUMN sys_dept.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_dept.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_dept.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_dept.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dept.deleted_time IS '删除时间'; - - --- --- Name: sys_dept_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_dept_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_dept_id_seq OWNER TO root; - --- --- Name: sys_dept_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_dept_id_seq OWNED BY public.sys_dept.id; - - --- --- Name: sys_dict_data; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_dict_data ( - dict_sort integer NOT NULL, - dict_label character varying(255) NOT NULL, - dict_value character varying(255) NOT NULL, - css_class character varying(255), - list_class character varying(255), - is_default boolean NOT NULL, - dict_type character varying(255) NOT NULL, - dict_type_id integer NOT NULL, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.sys_dict_data OWNER TO root; - --- --- Name: TABLE sys_dict_data; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_dict_data IS '字典数据表'; - - --- --- Name: COLUMN sys_dict_data.dict_sort; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.dict_sort IS '字典排序'; - - --- --- Name: COLUMN sys_dict_data.dict_label; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.dict_label IS '字典标签'; - - --- --- Name: COLUMN sys_dict_data.dict_value; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.dict_value IS '字典键值'; - - --- --- Name: COLUMN sys_dict_data.css_class; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.css_class IS '样式属性(其他样式扩展)'; - - --- --- Name: COLUMN sys_dict_data.list_class; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.list_class IS '表格回显样式'; - - --- --- Name: COLUMN sys_dict_data.is_default; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.is_default IS '是否默认(True是 False否)'; - - --- --- Name: COLUMN sys_dict_data.dict_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.dict_type IS '字典类型'; - - --- --- Name: COLUMN sys_dict_data.dict_type_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.dict_type_id IS '字典类型ID'; - - --- --- Name: COLUMN sys_dict_data.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.id IS '主键ID'; - - --- --- Name: COLUMN sys_dict_data.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_dict_data.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_dict_data.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.description IS '备注/描述'; - - --- --- Name: COLUMN sys_dict_data.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_dict_data.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_dict_data.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_dict_data.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_data.deleted_time IS '删除时间'; - - --- --- Name: sys_dict_data_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_dict_data_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_dict_data_id_seq OWNER TO root; - --- --- Name: sys_dict_data_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_dict_data_id_seq OWNED BY public.sys_dict_data.id; - - --- --- Name: sys_dict_type; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_dict_type ( - dict_name character varying(64) NOT NULL, - dict_type character varying(255) NOT NULL, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.sys_dict_type OWNER TO root; - --- --- Name: TABLE sys_dict_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_dict_type IS '字典类型表'; - - --- --- Name: COLUMN sys_dict_type.dict_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.dict_name IS '字典名称'; - - --- --- Name: COLUMN sys_dict_type.dict_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.dict_type IS '字典类型'; - - --- --- Name: COLUMN sys_dict_type.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.id IS '主键ID'; - - --- --- Name: COLUMN sys_dict_type.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_dict_type.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_dict_type.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.description IS '备注/描述'; - - --- --- Name: COLUMN sys_dict_type.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_dict_type.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_dict_type.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_dict_type.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_dict_type.deleted_time IS '删除时间'; - - --- --- Name: sys_dict_type_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_dict_type_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_dict_type_id_seq OWNER TO root; - --- --- Name: sys_dict_type_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_dict_type_id_seq OWNED BY public.sys_dict_type.id; - - --- --- Name: sys_log; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_log ( - type integer NOT NULL, - request_path character varying(255) NOT NULL, - request_method character varying(10) NOT NULL, - request_payload text, - request_ip character varying(50), - login_location character varying(255), - request_os character varying(64), - request_browser character varying(64), - response_code integer NOT NULL, - response_json text, - process_time character varying(20), - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.sys_log OWNER TO root; - --- --- Name: TABLE sys_log; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_log IS '系统日志表'; - - --- --- Name: COLUMN sys_log.type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.type IS '日志类型(1登录日志 2操作日志)'; - - --- --- Name: COLUMN sys_log.request_path; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.request_path IS '请求路径'; - - --- --- Name: COLUMN sys_log.request_method; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.request_method IS '请求方式'; - - --- --- Name: COLUMN sys_log.request_payload; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.request_payload IS '请求体'; - - --- --- Name: COLUMN sys_log.request_ip; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.request_ip IS '请求IP地址'; - - --- --- Name: COLUMN sys_log.login_location; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.login_location IS '登录位置'; - - --- --- Name: COLUMN sys_log.request_os; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.request_os IS '操作系统'; - - --- --- Name: COLUMN sys_log.request_browser; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.request_browser IS '浏览器'; - - --- --- Name: COLUMN sys_log.response_code; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.response_code IS '响应状态码'; - - --- --- Name: COLUMN sys_log.response_json; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.response_json IS '响应体'; - - --- --- Name: COLUMN sys_log.process_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.process_time IS '处理时间'; - - --- --- Name: COLUMN sys_log.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.id IS '主键ID'; - - --- --- Name: COLUMN sys_log.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_log.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_log.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.description IS '备注/描述'; - - --- --- Name: COLUMN sys_log.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_log.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_log.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_log.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.deleted_time IS '删除时间'; - - --- --- Name: COLUMN sys_log.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.created_id IS '创建人ID'; - - --- --- Name: COLUMN sys_log.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.updated_id IS '更新人ID'; - - --- --- Name: COLUMN sys_log.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_log.deleted_id IS '删除人ID'; - - --- --- Name: sys_log_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_log_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_log_id_seq OWNER TO root; - --- --- Name: sys_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_log_id_seq OWNED BY public.sys_log.id; - - --- --- Name: sys_menu; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_menu ( - name character varying(50) NOT NULL, - type integer NOT NULL, - "order" integer NOT NULL, - permission character varying(100), - icon character varying(50), - route_name character varying(100), - route_path character varying(200), - component_path character varying(200), - redirect character varying(200), - hidden boolean NOT NULL, - keep_alive boolean NOT NULL, - always_show boolean NOT NULL, - title character varying(50), - params json, - affix boolean NOT NULL, - parent_id integer, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.sys_menu OWNER TO root; - --- --- Name: TABLE sys_menu; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_menu IS '菜单表'; - - --- --- Name: COLUMN sys_menu.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.name IS '菜单名称'; - - --- --- Name: COLUMN sys_menu.type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.type IS '菜单类型(1:目录 2:菜单 3:按钮/权限 4:链接)'; - - --- --- Name: COLUMN sys_menu."order"; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu."order" IS '显示排序'; - - --- --- Name: COLUMN sys_menu.permission; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.permission IS '权限标识(如:module_system:user:query)'; - - --- --- Name: COLUMN sys_menu.icon; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.icon IS '菜单图标'; - - --- --- Name: COLUMN sys_menu.route_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.route_name IS '路由名称'; - - --- --- Name: COLUMN sys_menu.route_path; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.route_path IS '路由路径'; - - --- --- Name: COLUMN sys_menu.component_path; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.component_path IS '组件路径'; - - --- --- Name: COLUMN sys_menu.redirect; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.redirect IS '重定向地址'; - - --- --- Name: COLUMN sys_menu.hidden; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.hidden IS '是否隐藏(True:隐藏 False:显示)'; - - --- --- Name: COLUMN sys_menu.keep_alive; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.keep_alive IS '是否缓存(True:是 False:否)'; - - --- --- Name: COLUMN sys_menu.always_show; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.always_show IS '是否始终显示(True:是 False:否)'; - - --- --- Name: COLUMN sys_menu.title; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.title IS '菜单标题'; - - --- --- Name: COLUMN sys_menu.params; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.params IS '路由参数(JSON对象)'; - - --- --- Name: COLUMN sys_menu.affix; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.affix IS '是否固定标签页(True:是 False:否)'; - - --- --- Name: COLUMN sys_menu.parent_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.parent_id IS '父菜单ID'; - - --- --- Name: COLUMN sys_menu.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.id IS '主键ID'; - - --- --- Name: COLUMN sys_menu.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_menu.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_menu.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.description IS '备注/描述'; - - --- --- Name: COLUMN sys_menu.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_menu.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_menu.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_menu.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_menu.deleted_time IS '删除时间'; - - --- --- Name: sys_menu_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_menu_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_menu_id_seq OWNER TO root; - --- --- Name: sys_menu_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_menu_id_seq OWNED BY public.sys_menu.id; - - --- --- Name: sys_notice; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_notice ( - notice_title character varying(64) NOT NULL, - notice_type character varying(1) NOT NULL, - notice_content text, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.sys_notice OWNER TO root; - --- --- Name: TABLE sys_notice; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_notice IS '通知公告表'; - - --- --- Name: COLUMN sys_notice.notice_title; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.notice_title IS '公告标题'; - - --- --- Name: COLUMN sys_notice.notice_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.notice_type IS '公告类型(1通知 2公告)'; - - --- --- Name: COLUMN sys_notice.notice_content; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.notice_content IS '公告内容'; - - --- --- Name: COLUMN sys_notice.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.id IS '主键ID'; - - --- --- Name: COLUMN sys_notice.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_notice.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_notice.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.description IS '备注/描述'; - - --- --- Name: COLUMN sys_notice.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_notice.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_notice.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_notice.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.deleted_time IS '删除时间'; - - --- --- Name: COLUMN sys_notice.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.created_id IS '创建人ID'; - - --- --- Name: COLUMN sys_notice.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.updated_id IS '更新人ID'; - - --- --- Name: COLUMN sys_notice.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_notice.deleted_id IS '删除人ID'; - - --- --- Name: sys_notice_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_notice_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_notice_id_seq OWNER TO root; - --- --- Name: sys_notice_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_notice_id_seq OWNED BY public.sys_notice.id; - - --- --- Name: sys_param; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_param ( - config_name character varying(64) NOT NULL, - config_key character varying(500) NOT NULL, - config_value character varying(500), - config_type boolean, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.sys_param OWNER TO root; - --- --- Name: TABLE sys_param; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_param IS '系统参数表'; - - --- --- Name: COLUMN sys_param.config_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.config_name IS '参数名称'; - - --- --- Name: COLUMN sys_param.config_key; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.config_key IS '参数键名'; - - --- --- Name: COLUMN sys_param.config_value; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.config_value IS '参数键值'; - - --- --- Name: COLUMN sys_param.config_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.config_type IS '系统内置(True:是 False:否)'; - - --- --- Name: COLUMN sys_param.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.id IS '主键ID'; - - --- --- Name: COLUMN sys_param.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_param.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_param.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.description IS '备注/描述'; - - --- --- Name: COLUMN sys_param.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_param.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_param.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_param.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_param.deleted_time IS '删除时间'; - - --- --- Name: sys_param_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_param_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_param_id_seq OWNER TO root; - --- --- Name: sys_param_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_param_id_seq OWNED BY public.sys_param.id; - - --- --- Name: sys_position; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_position ( - name character varying(64) NOT NULL, - "order" integer NOT NULL, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.sys_position OWNER TO root; - --- --- Name: TABLE sys_position; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_position IS '岗位表'; - - --- --- Name: COLUMN sys_position.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.name IS '岗位名称'; - - --- --- Name: COLUMN sys_position."order"; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position."order" IS '显示排序'; - - --- --- Name: COLUMN sys_position.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.id IS '主键ID'; - - --- --- Name: COLUMN sys_position.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_position.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_position.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.description IS '备注/描述'; - - --- --- Name: COLUMN sys_position.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_position.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_position.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_position.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.deleted_time IS '删除时间'; - - --- --- Name: COLUMN sys_position.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.created_id IS '创建人ID'; - - --- --- Name: COLUMN sys_position.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.updated_id IS '更新人ID'; - - --- --- Name: COLUMN sys_position.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_position.deleted_id IS '删除人ID'; - - --- --- Name: sys_position_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_position_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_position_id_seq OWNER TO root; - --- --- Name: sys_position_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_position_id_seq OWNED BY public.sys_position.id; - - --- --- Name: sys_role; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_role ( - name character varying(64) NOT NULL, - code character varying(16) NOT NULL, - "order" integer NOT NULL, - data_scope integer NOT NULL, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.sys_role OWNER TO root; - --- --- Name: TABLE sys_role; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_role IS '角色表'; - - --- --- Name: COLUMN sys_role.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.name IS '角色名称'; - - --- --- Name: COLUMN sys_role.code; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.code IS '角色编码'; - - --- --- Name: COLUMN sys_role."order"; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role."order" IS '显示排序'; - - --- --- Name: COLUMN sys_role.data_scope; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.data_scope IS '数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)'; - - --- --- Name: COLUMN sys_role.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.id IS '主键ID'; - - --- --- Name: COLUMN sys_role.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_role.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_role.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.description IS '备注/描述'; - - --- --- Name: COLUMN sys_role.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_role.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_role.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_role.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role.deleted_time IS '删除时间'; - - --- --- Name: sys_role_depts; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_role_depts ( - role_id integer NOT NULL, - dept_id integer NOT NULL -); - - -ALTER TABLE public.sys_role_depts OWNER TO root; - --- --- Name: TABLE sys_role_depts; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_role_depts IS '角色部门关联表'; - - --- --- Name: COLUMN sys_role_depts.role_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role_depts.role_id IS '角色ID'; - - --- --- Name: COLUMN sys_role_depts.dept_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role_depts.dept_id IS '部门ID'; - - --- --- Name: sys_role_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_role_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_role_id_seq OWNER TO root; - --- --- Name: sys_role_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_role_id_seq OWNED BY public.sys_role.id; - - --- --- Name: sys_role_menus; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_role_menus ( - role_id integer NOT NULL, - menu_id integer NOT NULL -); - - -ALTER TABLE public.sys_role_menus OWNER TO root; - --- --- Name: TABLE sys_role_menus; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_role_menus IS '角色菜单关联表'; - - --- --- Name: COLUMN sys_role_menus.role_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role_menus.role_id IS '角色ID'; - - --- --- Name: COLUMN sys_role_menus.menu_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_role_menus.menu_id IS '菜单ID'; - - --- --- Name: sys_tenant; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_tenant ( - name character varying(100) NOT NULL, - code character varying(100) NOT NULL, - start_time timestamp without time zone, - end_time timestamp without time zone, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.sys_tenant OWNER TO root; - --- --- Name: TABLE sys_tenant; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_tenant IS '租户表'; - - --- --- Name: COLUMN sys_tenant.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.name IS '租户名称'; - - --- --- Name: COLUMN sys_tenant.code; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.code IS '租户编码'; - - --- --- Name: COLUMN sys_tenant.start_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.start_time IS '开始时间'; - - --- --- Name: COLUMN sys_tenant.end_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.end_time IS '结束时间'; - - --- --- Name: COLUMN sys_tenant.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.id IS '主键ID'; - - --- --- Name: COLUMN sys_tenant.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_tenant.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_tenant.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.description IS '备注/描述'; - - --- --- Name: COLUMN sys_tenant.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_tenant.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_tenant.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_tenant.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_tenant.deleted_time IS '删除时间'; - - --- --- Name: sys_tenant_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_tenant_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_tenant_id_seq OWNER TO root; - --- --- Name: sys_tenant_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_tenant_id_seq OWNED BY public.sys_tenant.id; - - --- --- Name: sys_user; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_user ( - username character varying(64) NOT NULL, - password character varying(255) NOT NULL, - name character varying(32) NOT NULL, - mobile character varying(11), - email character varying(64), - gender character varying(1), - avatar character varying(255), - is_superuser boolean NOT NULL, - last_login timestamp with time zone, - gitee_login character varying(32), - github_login character varying(32), - wx_login character varying(32), - qq_login character varying(32), - dept_id integer, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - tenant_id integer NOT NULL, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.sys_user OWNER TO root; - --- --- Name: TABLE sys_user; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_user IS '用户表'; - - --- --- Name: COLUMN sys_user.username; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.username IS '用户名/登录账号'; - - --- --- Name: COLUMN sys_user.password; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.password IS '密码哈希'; - - --- --- Name: COLUMN sys_user.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.name IS '昵称'; - - --- --- Name: COLUMN sys_user.mobile; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.mobile IS '手机号'; - - --- --- Name: COLUMN sys_user.email; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.email IS '邮箱'; - - --- --- Name: COLUMN sys_user.gender; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.gender IS '性别(0:男 1:女 2:未知)'; - - --- --- Name: COLUMN sys_user.avatar; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.avatar IS '头像URL地址'; - - --- --- Name: COLUMN sys_user.is_superuser; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.is_superuser IS '是否超管'; - - --- --- Name: COLUMN sys_user.last_login; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.last_login IS '最后登录时间'; - - --- --- Name: COLUMN sys_user.gitee_login; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.gitee_login IS 'Gitee登录'; - - --- --- Name: COLUMN sys_user.github_login; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.github_login IS 'Github登录'; - - --- --- Name: COLUMN sys_user.wx_login; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.wx_login IS '微信登录'; - - --- --- Name: COLUMN sys_user.qq_login; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.qq_login IS 'QQ登录'; - - --- --- Name: COLUMN sys_user.dept_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.dept_id IS '部门ID'; - - --- --- Name: COLUMN sys_user.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.id IS '主键ID'; - - --- --- Name: COLUMN sys_user.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN sys_user.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN sys_user.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.description IS '备注/描述'; - - --- --- Name: COLUMN sys_user.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.created_time IS '创建时间'; - - --- --- Name: COLUMN sys_user.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.updated_time IS '更新时间'; - - --- --- Name: COLUMN sys_user.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN sys_user.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.deleted_time IS '删除时间'; - - --- --- Name: COLUMN sys_user.tenant_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.tenant_id IS '租户ID'; - - --- --- Name: COLUMN sys_user.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.created_id IS '创建人ID'; - - --- --- Name: COLUMN sys_user.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.updated_id IS '更新人ID'; - - --- --- Name: COLUMN sys_user.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user.deleted_id IS '删除人ID'; - - --- --- Name: sys_user_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.sys_user_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.sys_user_id_seq OWNER TO root; - --- --- Name: sys_user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.sys_user_id_seq OWNED BY public.sys_user.id; - - --- --- Name: sys_user_positions; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_user_positions ( - user_id integer NOT NULL, - position_id integer NOT NULL -); - - -ALTER TABLE public.sys_user_positions OWNER TO root; - --- --- Name: TABLE sys_user_positions; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_user_positions IS '用户岗位关联表'; - - --- --- Name: COLUMN sys_user_positions.user_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user_positions.user_id IS '用户ID'; - - --- --- Name: COLUMN sys_user_positions.position_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user_positions.position_id IS '岗位ID'; - - --- --- Name: sys_user_roles; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.sys_user_roles ( - user_id integer NOT NULL, - role_id integer NOT NULL -); - - -ALTER TABLE public.sys_user_roles OWNER TO root; - --- --- Name: TABLE sys_user_roles; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.sys_user_roles IS '用户角色关联表'; - - --- --- Name: COLUMN sys_user_roles.user_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user_roles.user_id IS '用户ID'; - - --- --- Name: COLUMN sys_user_roles.role_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.sys_user_roles.role_id IS '角色ID'; - - --- --- Name: task_job; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.task_job ( - job_id character varying(64) NOT NULL, - job_name character varying(128), - trigger_type character varying(32), - status character varying(16) NOT NULL, - next_run_time character varying(64), - job_state text, - result text, - error text, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone -); - - -ALTER TABLE public.task_job OWNER TO root; - --- --- Name: TABLE task_job; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.task_job IS '任务执行日志表'; - - --- --- Name: COLUMN task_job.job_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.job_id IS '任务ID'; - - --- --- Name: COLUMN task_job.job_name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.job_name IS '任务名称'; - - --- --- Name: COLUMN task_job.trigger_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.trigger_type IS '触发方式: cron/interval/date/manual'; - - --- --- Name: COLUMN task_job.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.status IS '执行状态'; - - --- --- Name: COLUMN task_job.next_run_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.next_run_time IS '下次执行时间'; - - --- --- Name: COLUMN task_job.job_state; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.job_state IS '任务状态信息'; - - --- --- Name: COLUMN task_job.result; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.result IS '执行结果'; - - --- --- Name: COLUMN task_job.error; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.error IS '错误信息'; - - --- --- Name: COLUMN task_job.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.id IS '主键ID'; - - --- --- Name: COLUMN task_job.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN task_job.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.description IS '备注/描述'; - - --- --- Name: COLUMN task_job.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.created_time IS '创建时间'; - - --- --- Name: COLUMN task_job.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.updated_time IS '更新时间'; - - --- --- Name: COLUMN task_job.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN task_job.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_job.deleted_time IS '删除时间'; - - --- --- Name: task_job_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.task_job_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.task_job_id_seq OWNER TO root; - --- --- Name: task_job_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.task_job_id_seq OWNED BY public.task_job.id; - - --- --- Name: task_node; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.task_node ( - name character varying(64) NOT NULL, - code character varying(32) NOT NULL, - jobstore character varying(64), - executor character varying(64), - trigger character varying(64), - trigger_args text, - func text, - args text, - kwargs text, - "coalesce" boolean, - max_instances integer, - start_date character varying(64), - end_date character varying(64), - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.task_node OWNER TO root; - --- --- Name: TABLE task_node; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.task_node IS '节点类型表'; - - --- --- Name: COLUMN task_node.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.name IS '节点名称'; - - --- --- Name: COLUMN task_node.code; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.code IS '节点编码'; - - --- --- Name: COLUMN task_node.jobstore; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.jobstore IS '存储器'; - - --- --- Name: COLUMN task_node.executor; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.executor IS '执行器'; - - --- --- Name: COLUMN task_node.trigger; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.trigger IS '触发器'; - - --- --- Name: COLUMN task_node.trigger_args; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.trigger_args IS '触发器参数'; - - --- --- Name: COLUMN task_node.func; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.func IS '代码块'; - - --- --- Name: COLUMN task_node.args; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.args IS '位置参数'; - - --- --- Name: COLUMN task_node.kwargs; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.kwargs IS '关键字参数'; - - --- --- Name: COLUMN task_node."coalesce"; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node."coalesce" IS '是否合并运行'; - - --- --- Name: COLUMN task_node.max_instances; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.max_instances IS '最大实例数'; - - --- --- Name: COLUMN task_node.start_date; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.start_date IS '开始时间'; - - --- --- Name: COLUMN task_node.end_date; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.end_date IS '结束时间'; - - --- --- Name: COLUMN task_node.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.id IS '主键ID'; - - --- --- Name: COLUMN task_node.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN task_node.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN task_node.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.description IS '备注/描述'; - - --- --- Name: COLUMN task_node.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.created_time IS '创建时间'; - - --- --- Name: COLUMN task_node.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.updated_time IS '更新时间'; - - --- --- Name: COLUMN task_node.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN task_node.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.deleted_time IS '删除时间'; - - --- --- Name: COLUMN task_node.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.created_id IS '创建人ID'; - - --- --- Name: COLUMN task_node.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.updated_id IS '更新人ID'; - - --- --- Name: COLUMN task_node.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_node.deleted_id IS '删除人ID'; - - --- --- Name: task_node_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.task_node_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.task_node_id_seq OWNER TO root; - --- --- Name: task_node_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.task_node_id_seq OWNED BY public.task_node.id; - - --- --- Name: task_workflow; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.task_workflow ( - name character varying(128) NOT NULL, - code character varying(64) NOT NULL, - workflow_status character varying(32) NOT NULL, - nodes json, - edges json, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.task_workflow OWNER TO root; - --- --- Name: TABLE task_workflow; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.task_workflow IS '工作流定义表'; - - --- --- Name: COLUMN task_workflow.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.name IS '流程名称'; - - --- --- Name: COLUMN task_workflow.code; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.code IS '流程编码'; - - --- --- Name: COLUMN task_workflow.workflow_status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.workflow_status IS '流程状态: draft/published/archived'; - - --- --- Name: COLUMN task_workflow.nodes; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.nodes IS 'Vue Flow nodes JSON'; - - --- --- Name: COLUMN task_workflow.edges; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.edges IS 'Vue Flow edges JSON'; - - --- --- Name: COLUMN task_workflow.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.id IS '主键ID'; - - --- --- Name: COLUMN task_workflow.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN task_workflow.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN task_workflow.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.description IS '备注/描述'; - - --- --- Name: COLUMN task_workflow.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.created_time IS '创建时间'; - - --- --- Name: COLUMN task_workflow.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.updated_time IS '更新时间'; - - --- --- Name: COLUMN task_workflow.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN task_workflow.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.deleted_time IS '删除时间'; - - --- --- Name: COLUMN task_workflow.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.created_id IS '创建人ID'; - - --- --- Name: COLUMN task_workflow.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.updated_id IS '更新人ID'; - - --- --- Name: COLUMN task_workflow.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow.deleted_id IS '删除人ID'; - - --- --- Name: task_workflow_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.task_workflow_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.task_workflow_id_seq OWNER TO root; - --- --- Name: task_workflow_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.task_workflow_id_seq OWNED BY public.task_workflow.id; - - --- --- Name: task_workflow_node_type; Type: TABLE; Schema: public; Owner: root --- - -CREATE TABLE public.task_workflow_node_type ( - name character varying(128) NOT NULL, - code character varying(64) NOT NULL, - category character varying(32) NOT NULL, - func text NOT NULL, - args text, - kwargs text, - sort_order integer NOT NULL, - is_active boolean NOT NULL, - id integer NOT NULL, - uuid character varying(64) NOT NULL, - status character varying(10) NOT NULL, - description text, - created_time timestamp without time zone NOT NULL, - updated_time timestamp without time zone NOT NULL, - is_deleted boolean NOT NULL, - deleted_time timestamp without time zone, - created_id integer, - updated_id integer, - deleted_id integer -); - - -ALTER TABLE public.task_workflow_node_type OWNER TO root; - --- --- Name: TABLE task_workflow_node_type; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON TABLE public.task_workflow_node_type IS '工作流编排节点类型(非定时任务节点)'; - - --- --- Name: COLUMN task_workflow_node_type.name; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.name IS '显示名称'; - - --- --- Name: COLUMN task_workflow_node_type.code; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.code IS '节点编码,对应画布 node.type'; - - --- --- Name: COLUMN task_workflow_node_type.category; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.category IS '分类: trigger/action/condition/control'; - - --- --- Name: COLUMN task_workflow_node_type.func; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.func IS 'Python 代码块,须定义 handler(*args,**kwargs)'; - - --- --- Name: COLUMN task_workflow_node_type.args; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.args IS '默认位置参数,逗号分隔'; - - --- --- Name: COLUMN task_workflow_node_type.kwargs; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.kwargs IS '默认关键字参数 JSON'; - - --- --- Name: COLUMN task_workflow_node_type.sort_order; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.sort_order IS '排序'; - - --- --- Name: COLUMN task_workflow_node_type.is_active; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.is_active IS '是否启用'; - - --- --- Name: COLUMN task_workflow_node_type.id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.id IS '主键ID'; - - --- --- Name: COLUMN task_workflow_node_type.uuid; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.uuid IS 'UUID全局唯一标识'; - - --- --- Name: COLUMN task_workflow_node_type.status; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.status IS '状态(0:正常 1:禁用)'; - - --- --- Name: COLUMN task_workflow_node_type.description; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.description IS '备注/描述'; - - --- --- Name: COLUMN task_workflow_node_type.created_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.created_time IS '创建时间'; - - --- --- Name: COLUMN task_workflow_node_type.updated_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.updated_time IS '更新时间'; - - --- --- Name: COLUMN task_workflow_node_type.is_deleted; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.is_deleted IS '是否已删除(0:未删除 1:已删除)'; - - --- --- Name: COLUMN task_workflow_node_type.deleted_time; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.deleted_time IS '删除时间'; - - --- --- Name: COLUMN task_workflow_node_type.created_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.created_id IS '创建人ID'; - - --- --- Name: COLUMN task_workflow_node_type.updated_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.updated_id IS '更新人ID'; - - --- --- Name: COLUMN task_workflow_node_type.deleted_id; Type: COMMENT; Schema: public; Owner: root --- - -COMMENT ON COLUMN public.task_workflow_node_type.deleted_id IS '删除人ID'; - - --- --- Name: task_workflow_node_type_id_seq; Type: SEQUENCE; Schema: public; Owner: root --- - -CREATE SEQUENCE public.task_workflow_node_type_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER SEQUENCE public.task_workflow_node_type_id_seq OWNER TO root; - --- --- Name: task_workflow_node_type_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root --- - -ALTER SEQUENCE public.task_workflow_node_type_id_seq OWNED BY public.task_workflow_node_type.id; - - --- --- Name: app_portal id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.app_portal ALTER COLUMN id SET DEFAULT nextval('public.app_portal_id_seq'::regclass); - - --- --- Name: gen_demo id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.gen_demo ALTER COLUMN id SET DEFAULT nextval('public.gen_demo_id_seq'::regclass); - - --- --- Name: gen_demo01 id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.gen_demo01 ALTER COLUMN id SET DEFAULT nextval('public.gen_demo01_id_seq'::regclass); - - --- --- Name: gen_table id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.gen_table ALTER COLUMN id SET DEFAULT nextval('public.gen_table_id_seq'::regclass); - - --- --- Name: gen_table_column id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.gen_table_column ALTER COLUMN id SET DEFAULT nextval('public.gen_table_column_id_seq'::regclass); - - --- --- Name: sys_dept id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_dept ALTER COLUMN id SET DEFAULT nextval('public.sys_dept_id_seq'::regclass); - - --- --- Name: sys_dict_data id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_dict_data ALTER COLUMN id SET DEFAULT nextval('public.sys_dict_data_id_seq'::regclass); - - --- --- Name: sys_dict_type id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_dict_type ALTER COLUMN id SET DEFAULT nextval('public.sys_dict_type_id_seq'::regclass); - - --- --- Name: sys_log id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_log ALTER COLUMN id SET DEFAULT nextval('public.sys_log_id_seq'::regclass); - - --- --- Name: sys_menu id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_menu ALTER COLUMN id SET DEFAULT nextval('public.sys_menu_id_seq'::regclass); - - --- --- Name: sys_notice id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_notice ALTER COLUMN id SET DEFAULT nextval('public.sys_notice_id_seq'::regclass); - - --- --- Name: sys_param id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_param ALTER COLUMN id SET DEFAULT nextval('public.sys_param_id_seq'::regclass); - - --- --- Name: sys_position id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_position ALTER COLUMN id SET DEFAULT nextval('public.sys_position_id_seq'::regclass); - - --- --- Name: sys_role id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_role ALTER COLUMN id SET DEFAULT nextval('public.sys_role_id_seq'::regclass); - - --- --- Name: sys_tenant id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_tenant ALTER COLUMN id SET DEFAULT nextval('public.sys_tenant_id_seq'::regclass); - - --- --- Name: sys_user id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.sys_user ALTER COLUMN id SET DEFAULT nextval('public.sys_user_id_seq'::regclass); - - --- --- Name: task_job id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.task_job ALTER COLUMN id SET DEFAULT nextval('public.task_job_id_seq'::regclass); - - --- --- Name: task_node id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.task_node ALTER COLUMN id SET DEFAULT nextval('public.task_node_id_seq'::regclass); - - --- --- Name: task_workflow id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.task_workflow ALTER COLUMN id SET DEFAULT nextval('public.task_workflow_id_seq'::regclass); - - --- --- Name: task_workflow_node_type id; Type: DEFAULT; Schema: public; Owner: root --- - -ALTER TABLE ONLY public.task_workflow_node_type ALTER COLUMN id SET DEFAULT nextval('public.task_workflow_node_type_id_seq'::regclass); - - --- --- Data for Name: agno_schema_versions; Type: TABLE DATA; Schema: ai; Owner: root --- - -COPY ai.agno_schema_versions (table_name, version, created_at, updated_at) FROM stdin; -agno_sessions 2.5.0 2026-03-10T22:38:46.429268 2026-03-10T22:38:46.429268 -\. - - --- --- Data for Name: agno_sessions; Type: TABLE DATA; Schema: ai; Owner: root --- - -COPY ai.agno_sessions (session_id, session_type, agent_id, team_id, workflow_id, user_id, session_data, agent_data, team_data, workflow_data, metadata, runs, summary, created_at, updated_at) FROM stdin; -d0375348-cc9c-4103-a7ce-18d14560a785 team \N 1 \N admin {"session_name": "新对话", "session_state": {}, "session_metrics": {"details": {"model": [{"id": "deepseek-chat", "provider": "OpenAI", "input_tokens": 898, "total_tokens": 915, "output_tokens": 17, "cache_read_tokens": 320}]}, "input_tokens": 898, "total_tokens": 915, "output_tokens": 17, "cache_read_tokens": 320}} \N null \N null [{"input": {"input_content": "你好"}, "model": "deepseek-chat", "tools": [], "run_id": "44f0df2a-aaff-403f-bbb7-de1081d45580", "status": "COMPLETED", "content": "你好!我是你的AI助手,很高兴为你服务。有什么我可以帮助你的吗?", "metrics": {"details": {"model": [{"id": "deepseek-chat", "provider": "OpenAI", "input_tokens": 898, "total_tokens": 915, "output_tokens": 17, "cache_read_tokens": 320}]}, "duration": 2.4872381659988605, "input_tokens": 898, "total_tokens": 915, "output_tokens": 17, "cache_read_tokens": 320, "time_to_first_token": 2.4092763329990703}, "team_id": "1", "user_id": "admin", "messages": [{"id": "6f9ce5a8-ac26-4ca3-ada9-459641e0225c", "role": "system", "content": "You coordinate a team of specialized AI agents to fulfill the user's request. Delegate to members when their expertise or tools are needed. For straightforward requests you can handle directly — including using your own tools — respond without delegating.\\n\\n{username} 您好:
您的验证码是:
验证码 5 分钟内有效,请勿泄露给他人。
此邮件由系统自动发送,请勿回复。
您的工单 {{ ticket_title }} 收到新回复:
{{ reply_content }}
回复时间:{{ reply_time }}
尊敬的 {{ tenant_name }}:
您的 {{ package_name }} 套餐将于 {{ expire_date }} 到期,剩余 {{ remaining_days }} 天。
到期后部分功能将受限,请及时续费以保证服务正常使用。
尊敬的客户:
您的发票已开具完成:
| 发票号码 | {{ invoice_no }} |
| 发票抬头 | {{ invoice_title }} |
| 开票金额 | ¥{{ invoice_amount }} |
欢迎使用 FastApiAdmin 系统!
这是一个功能强大的权限管理系统,支持多租户、角色权限控制等功能。
1 c3ad6510-a8c6-4eee-b913-4bbd5afb2df6 0 系统上线公告 2026-06-17 00:21:30.43888 2026-06-17 00:21:30.438881 f \N 1 \N \N \N +系统维护通知 1系统将于本周六凌晨2:00-4:00进行例行维护,请提前保存工作。
2 78e8be9c-fa14-4433-a2ad-44479d86a800 0 系统维护通知 2026-06-17 00:21:30.438885 2026-06-17 00:21:30.438886 f \N 1 \N \N \N +新功能发布 2本次更新新增了工作流引擎、代码生成器等功能,欢迎体验!
3 0f131f2b-bcae-40be-8b24-5d9ce0b75712 0 新功能发布 2026-06-17 00:21:30.438889 2026-06-17 00:21:30.438889 f \N 1 \N \N \N +安全更新提醒 1请所有用户尽快更新密码,建议使用至少8位包含大小写字母、数字和特殊字符的强密码。
更新方法:登录后进入「个人中心」->「修改密码」。
4 ea06e6c4-02a0-40e0-b9b3-58b4097787c4 0 安全更新提醒 2026-06-17 00:21:30.438892 2026-06-17 00:21:30.438893 f \N 1 \N \N \N +节假日值班安排 1春节假期(2月10日-2月17日)期间系统值班安排如下:
联系电话:138-0000-0000
紧急问题请直接联系值班人员。
5 955ed722-8c9b-439a-82f1-41f658c89c11 0 节假日值班通知 2026-06-17 00:21:30.438896 2026-06-17 00:21:30.438896 f \N 1 \N \N \N +v2.0 版本升级公告 2v2.0 大版本即将发布,主要更新:
升级时间另行通知。
6 a637a497-b381-4ee6-af28-dae2d59c5bec 0 v2.0 版本升级公告 2026-06-17 00:21:30.438899 2026-06-17 00:21:30.438899 f \N 1 \N \N \N +\. + + +-- +-- Data for Name: sys_notice_read; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_notice_read (user_id, notice_id, read_time) FROM stdin; +1 1 2025-06-01 09:15:00 +1 2 2025-06-10 08:30:00 +1 3 2025-07-01 10:00:00 +2 1 2025-06-01 09:20:00 +2 2 2025-06-10 09:00:00 +3 1 2025-06-01 10:30:00 +4 1 2025-06-02 14:00:00 +5 1 2025-06-03 11:00:00 +6 6 2025-06-20 10:00:00 +8 2 2025-06-10 16:00:00 +\. + + +-- +-- Data for Name: sys_operation_log; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_operation_log (request_path, request_method, request_payload, response_code, response_json, process_time, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id, created_id, updated_id, deleted_id) FROM stdin; +/api/v1/system/auth/login POST {"username": "super", "password": "***"} 200 {"code": 200, "msg": "登录成功"} 45ms 1 71bfefbd-5ed2-4fa6-8343-00c8b2e1d9a6 0 用户登录 2026-06-17 00:21:30.455243 2026-06-17 00:21:30.455244 f \N 1 \N \N \N +/api/v1/system/user/current/info GET \N 200 {"code": 200, "data": {"username": "super"}} 12ms 2 57776805-805f-4576-a882-24b449af3e06 0 获取当前用户信息 2026-06-17 00:21:30.455249 2026-06-17 00:21:30.455249 f \N 1 \N \N \N +/api/v1/platform/menu/create POST {"name": "测试菜单", "type": 2, "parent_id": 1} 200 {"code": 200, "msg": "创建成功"} 23ms 3 2ab5d32d-3387-4f73-b379-04d4e1ff7431 0 创建菜单 2026-06-17 00:21:30.455252 2026-06-17 00:21:30.455253 f \N 1 \N \N \N +/api/v1/system/user/update/3 PUT {"name": "普通用户", "status": 0} 200 {"code": 200, "msg": "更新成功"} 18ms 4 52cc94a5-3af9-4efe-bfcd-440137bd8bfa 0 更新用户信息 2026-06-17 00:21:30.455256 2026-06-17 00:21:30.455256 f \N 1 \N \N \N +/api/v1/system/dept/create POST {"name": "测试部门", "parent_id": 1} 400 {"code": 400, "msg": "部门编码已存在"} 8ms 5 9560f252-5bf5-4f89-95d8-1289b3915e72 0 创建部门(失败) 2026-06-17 00:21:30.455259 2026-06-17 00:21:30.45526 f \N 1 \N \N \N +/api/v1/system/role/delete DELETE {"ids": [5]} 200 {"code": 200, "msg": "删除成功"} 15ms 6 97e26ac2-97c4-425b-afcf-54c61d9d93cd 0 删除角色 2026-06-17 00:21:30.455263 2026-06-17 00:21:30.455263 f \N 1 \N \N \N +/api/v1/platform/menu/list GET \N 200 {"code": 200, "data": {"items": [...]}} 35ms 7 ac5e9562-f5b2-485f-8b02-417972b465aa 0 查询菜单列表 2026-06-17 00:21:30.455266 2026-06-17 00:21:30.455267 f \N 3 \N \N \N +/api/v1/system/dict/data/list GET \N 200 {"code": 200, "data": {"items": [...]}} 22ms 8 027f5ec0-8d09-4c40-a63f-ef700d1832a5 0 查询字典数据 2026-06-17 00:21:30.45527 2026-06-17 00:21:30.45527 f \N 3 \N \N \N +/api/v1/workflow/definition/create POST {"name": "审批流程", "code": "approval_v1"} 200 {"code": 200, "msg": "创建成功"} 28ms 9 d7eaca6e-2168-406c-8530-e3135c5791d8 0 创建工作流 2026-06-17 00:21:30.455273 2026-06-17 00:21:30.455273 f \N 4 \N \N \N +/api/v1/system/notice/create POST {"notice_title": "测试通知", "notice_type": "1"} 200 {"code": 200, "msg": "创建成功"} 11ms 10 fdb1a5f8-6406-4953-be28-4bd2751d9488 0 创建通知 2026-06-17 00:21:30.455276 2026-06-17 00:21:30.455277 f \N 1 \N \N \N +/api/v1/system/user/export POST {"status": 0} 200 {"file": "用户列表_20250601.xlsx"} 156ms 11 03d5dc94-cf24-4300-95ea-4b15760526d0 0 导出用户数据 2026-06-17 00:21:30.45528 2026-06-17 00:21:30.45528 f \N 1 \N \N \N +/api/v1/system/user/import POST "file": "users.xlsx" (multipart/form-data) 200 {"code": 200, "msg": "成功导入 25 条数据"} 320ms 12 e4cfa72a-0016-4fb0-ad13-74824208299a 0 批量导入用户 2026-06-17 00:21:30.455283 2026-06-17 00:21:30.455283 f \N 1 \N \N \N +/api/v1/cronjob/node/execute/1 POST {"trigger": "now"} 200 {"code": 200, "msg": "调试节点成功"} 1024ms 13 46b590de-cc3d-4bc9-8f56-6fc5fbc30c8d 0 执行定时任务节点 2026-06-17 00:21:30.455286 2026-06-17 00:21:30.455287 f \N 1 \N \N \N +/api/v1/workflow/definition/execute POST {"workflow_id": 1, "variables": {}} 200 {"code": 200, "data": {"status": "completed"}} 3200ms 14 0f71902a-8669-485d-9d1e-2bc16c25b423 0 执行工作流 2026-06-17 00:21:30.45529 2026-06-17 00:21:30.45529 f \N 4 \N \N \N +/api/v1/cronjob/job/log/delete DELETE {"ids": [1, 2, 3]} 200 {"code": 200, "msg": "删除成功"} 19ms 15 f095a2c0-e5d4-490c-a641-4e634abb25c4 0 批量删除执行日志 2026-06-17 00:21:30.455293 2026-06-17 00:21:30.455294 f \N 1 \N \N \N +\. + + +-- +-- Data for Name: sys_param; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_param (config_name, config_key, config_value, config_type, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id) FROM stdin; +演示模式启用 demo_enable false t 1 76181d1a-7298-4079-8770-cdbd73a37c86 0 是否启用演示模式 2026-06-17 00:21:30.36406 2026-06-17 00:21:30.364061 f \N 1 +演示访问IP白名单 ip_white_list ["127.0.0.1", "::1"] t 2 8d74ef7c-efb2-4f2c-a388-f3080b893dda 0 演示模式下允许访问的IP列表 2026-06-17 00:21:30.364065 2026-06-17 00:21:30.364066 f \N 1 +接口白名单 white_api_list_path ["/api/v1/system/auth/login", "/api/v1/system/auth/token/refresh", "/api/v1/system/auth/captcha/get", "/api/v1/system/auth/logout", "/api/v1/system/config/info", "/api/v1/system/user/current/info", "/api/v1/system/notice/available", "/api/v1/system/auth/auto-login/users", "/api/v1/system/auth/auto-login/token", "/api/v1/system/auth/auto-login", "/common/health", "/common/health/ready", "/common/health/live", "/metrics"] t 3 1443bf5f-d4dd-46f8-aa46-94503c11e2f8 0 无需登录即可访问的接口列表 2026-06-17 00:21:30.364069 2026-06-17 00:21:30.364069 f \N 1 +访问IP黑名单 ip_black_list [] t 4 121a8eaa-0e62-4e51-a482-3553700f784c 0 禁止访问的IP列表 2026-06-17 00:21:30.364072 2026-06-17 00:21:30.364072 f \N 1 +登录失败次数限制 login_failed_limit 5 t 5 3b7fc693-afd2-46dc-921c-f78af42c5f06 0 登录失败最大次数 2026-06-17 00:21:30.364075 2026-06-17 00:21:30.364076 f \N 1 +登录锁定时间(分钟) login_lock_time 15 t 6 040109a2-4e87-4a47-9607-e645a5e39b46 0 登录失败后锁定时间 2026-06-17 00:21:30.364079 2026-06-17 00:21:30.364079 f \N 1 +Token过期时间(分钟) token_expire_minutes 120 t 7 c01d25ac-c5c6-4989-ba82-292b771e3ca1 0 Access Token过期时间 2026-06-17 00:21:30.364082 2026-06-17 00:21:30.364082 f \N 1 +Refresh Token过期时间(天) refresh_token_expire_days 7 t 8 7c17df22-ef10-489b-92a9-14a59819652a 0 Refresh Token过期时间 2026-06-17 00:21:30.364085 2026-06-17 00:21:30.364085 f \N 1 +密码有效期(天) password_expire_days 90 t 9 524bab19-827d-413d-b76e-0edd5a1f128c 0 密码有效期 2026-06-17 00:21:30.364088 2026-06-17 00:21:30.364088 f \N 1 +密码最小长度 password_min_length 6 t 10 fbd4aa1d-ba34-4c0f-b499-0e378900dd25 0 密码最小长度 2026-06-17 00:21:30.364091 2026-06-17 00:21:30.364092 f \N 1 +是否启用验证码 captcha_enable true t 11 ef68aaa2-abed-4fdc-a0dc-876b79192566 0 登录时是否启用验证码 2026-06-17 00:21:30.364094 2026-06-17 00:21:30.364095 f \N 1 +是否记录操作日志 operation_log_enable true t 12 85393bf1-d391-4fb7-bde7-8369b2a2ab9a 0 是否记录用户操作日志 2026-06-17 00:21:30.364098 2026-06-17 00:21:30.364098 f \N 1 +操作日志保留天数 operation_log_retention_days 90 t 13 fbac894f-d8fd-4b25-8b0e-03635326830e 0 操作日志保留天数 2026-06-17 00:21:30.364101 2026-06-17 00:21:30.364102 f \N 1 +登录日志保留天数 login_log_retention_days 30 t 14 1db2cdd3-4686-4cb8-aac9-0ba1ad2f9d69 0 登录日志保留天数 2026-06-17 00:21:30.364105 2026-06-17 00:21:30.364105 f \N 1 +文件上传大小限制(MB) file_upload_max_size 50 t 15 ff5db1b8-de66-4981-b16a-7e5b1f83e2df 0 单个文件上传最大大小 2026-06-17 00:21:30.364108 2026-06-17 00:21:30.364108 f \N 1 +是否启用IP归属地查询 ip_location_enable false t 16 7bca54e8-82eb-4bd2-a283-6465aa39900e 0 登录时是否查询IP归属地 2026-06-17 00:21:30.364111 2026-06-17 00:21:30.364111 f \N 1 +调度器状态 scheduler_status stopped t 17 f87b3017-43a4-428c-b488-dc908c4ac8e0 0 \N 2026-06-17 00:21:38.35444 2026-06-17 00:21:38.354443 f \N 1 +\. + + +-- +-- Data for Name: sys_position; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_position (name, code, "order", id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id, created_id, updated_id, deleted_id) FROM stdin; +技术总监 TECH_DIRECTOR 1 1 bd8a01b8-0da1-4c5d-923c-a6d21d2a02c3 0 技术部门负责人 2026-06-17 00:21:30.387719 2026-06-17 00:21:30.38772 f \N 1 \N \N \N +高级工程师 SR_ENGINEER 2 2 d942f31c-d958-49d5-b870-a9058978d336 0 高级技术岗位 2026-06-17 00:21:30.387724 2026-06-17 00:21:30.387725 f \N 1 \N \N \N +工程师 ENGINEER 3 3 b9626313-5a15-4e94-b1e7-973fc8ba9d33 0 技术岗位 2026-06-17 00:21:30.387728 2026-06-17 00:21:30.387729 f \N 1 \N \N \N +产品经理 PRODUCT_MANAGER 4 4 04ca058b-b440-4ae7-b85a-4538c72bca50 0 产品管理岗位 2026-06-17 00:21:30.387732 2026-06-17 00:21:30.387732 f \N 1 \N \N \N +运营专员 OPERATOR 5 5 f528a4f5-6a31-4e1e-900e-374886800108 0 运营岗位 2026-06-17 00:21:30.387735 2026-06-17 00:21:30.387736 f \N 1 \N \N \N +HR专员 HR_STAFF 6 6 c7d385b6-d132-477c-8a36-8091143fbd06 0 人事专员 2026-06-17 00:21:30.387739 2026-06-17 00:21:30.387739 f \N 1 \N \N \N +\. + + +-- +-- Data for Name: sys_role; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_role (name, code, "order", data_scope, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id) FROM stdin; +超级管理员 SUPER_ADMIN 1 4 1 6f4ed572-650c-47af-a354-7e24db218539 0 拥有系统最高权限 2026-06-17 00:21:30.37375 2026-06-17 00:21:30.373751 f \N 1 +管理员 ADMIN 2 3 2 854bcf57-c7d3-4306-b2bd-133298ffc74c 0 管理租户内所有资源 2026-06-17 00:21:30.373755 2026-06-17 00:21:30.373756 f \N 1 +普通用户 USER 3 1 3 a7f5f4b4-1317-4094-be4a-a455347a2cf8 0 仅能查看和操作自己的数据 2026-06-17 00:21:30.373759 2026-06-17 00:21:30.373759 f \N 1 +星辰管理员 STAR_ADMIN 1 4 4 78665473-6122-49c4-b99b-01e30e970259 0 星辰科技有限公司管理员 2026-06-17 00:21:30.373762 2026-06-17 00:21:30.373763 f \N 3 +星辰员工 STAR_STAFF 2 2 5 bc44e6fe-31e9-49ba-bd27-d3a2b226ee95 0 星辰科技有限公司普通员工 2026-06-17 00:21:30.373766 2026-06-17 00:21:30.373766 f \N 3 +创新管理员 INNO_ADMIN 1 4 6 8cca0cd1-088a-4579-ab0a-f215568f006f 0 创新工坊管理员 2026-06-17 00:21:30.373769 2026-06-17 00:21:30.373769 f \N 4 +创新员工 INNO_STAFF 2 2 7 c93319c9-5c46-41b0-91b0-8626221fc1d3 0 创新工坊普通员工 2026-06-17 00:21:30.373772 2026-06-17 00:21:30.373772 f \N 4 +\. + + +-- +-- Data for Name: sys_role_depts; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_role_depts (role_id, dept_id) FROM stdin; +\. + + +-- +-- Data for Name: sys_role_menus; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_role_menus (role_id, menu_id) FROM stdin; +\. + + +-- +-- Data for Name: sys_ticket; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_ticket (title, ticket_content, summary, ticket_type, images, reply, assigned_id, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id, created_id, updated_id, deleted_id) FROM stdin; +系统登录页面优化建议建议在登录页面增加记住密码功能和第三方登录入口,提升用户体验。
建议在登录页面增加记住密码功能和第三方登录入口 suggestion \N 感谢您的建议,我们将在下个版本中加入记住密码功能。 2 1 9af641c2-8c21-43b1-846f-e638c8e7b8c0 2 用户体验优化 2026-06-17 00:21:30.446285 2026-06-17 00:21:30.446287 f \N 1 \N \N \N +表格导出功能异常当数据量超过1000条时,导出Excel功能会超时失败。
数据量超过1000条导出Excel超时 bug \N \N 3 2 9c79b610-17b1-4afb-b083-468b682c34d0 1 导出功能问题 2026-06-17 00:21:30.446291 2026-06-17 00:21:30.446292 f \N 1 \N \N \N +希望增加批量删除功能用户管理页面希望支持批量选择删除,提高管理效率。
用户管理页面希望支持批量选择删除 optimize \N \N \N 3 c361873e-c48e-4e9c-b852-0cfd70ef7b20 0 功能优化建议 2026-06-17 00:21:30.446295 2026-06-17 00:21:30.446295 f \N 1 \N \N \N +手机端适配问题反馈在iPhone Safari浏览器上,菜单栏折叠后无法展开,需要刷新页面才能恢复。
iPhone Safari菜单折叠后无法展开 bug ["https://example.com/screenshot1.png"] \N 4 4 cec1f308-fc71-49c7-91ed-c626844bc71d 1 移动端兼容性问题 2026-06-17 00:21:30.446298 2026-06-17 00:21:30.446299 f \N 1 \N \N \N +增加数据权限粒度当前数据权限只能控制到部门级别,希望能支持自定义数据范围,如只查看本人创建的数据、指定项目范围等。
数据权限需要支持自定义范围 optimize \N 已纳入Q3规划,感谢反馈。 2 5 95c170aa-d05c-4422-976b-961f01e0b5d6 2 数据权限增强 2026-06-17 00:21:30.446302 2026-06-17 00:21:30.446302 f \N 1 \N \N \N +工作流审批节点无法修改已发布的工作流无法修改审批节点配置,需要先取消发布才能修改,操作繁琐。
已发布工作流无法直接修改节点 bug \N \N \N 6 27942d15-1029-4d83-a790-414afdcd84c6 0 星辰科技反馈工作流问题 2026-06-17 00:21:30.446305 2026-06-17 00:21:30.446306 f \N 3 \N \N \N +希望增加钉钉集成团队使用钉钉进行日常协作,希望能将通知和待办事项同步到钉钉工作台。
希望支持钉钉消息集成 suggestion \N 我们会评估第三方集成的优先级。 \N 7 5f91e159-18d3-4674-9a74-290689e5caf6 3 创新工坊第三方集成需求 2026-06-17 00:21:30.446309 2026-06-17 00:21:30.446309 f \N 4 \N \N \N +其他-文档链接失效帮助文档中的API接口说明链接跳转404,影响开发对接。
帮助文档API链接404 other \N \N 3 8 c00eaad7-6927-4035-866e-99cf89261afb 0 文档链接问题 2026-06-17 00:21:30.446312 2026-06-17 00:21:30.446312 f \N 1 \N \N \N +\. + + +-- +-- Data for Name: sys_user; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_user (username, password, name, mobile, email, gender, avatar, is_superuser, last_login, gitee_login, github_login, wx_login, qq_login, dept_id, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id, created_id, updated_id, deleted_id) FROM stdin; +super $2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa 超级管理员 13800138000 super@example.com 0 https://service.fastapiadmin.com/api/v1/static/image/avatar.png t \N \N \N \N \N 1 1 52d0624a-333c-43d8-9241-63a7af84293a 0 系统超级管理员 2026-06-17 00:21:30.397062 2026-06-17 00:21:30.397064 f \N 1 \N \N \N +admin $2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa 管理员 13800138001 admin@example.com 0 https://service.fastapiadmin.com/api/v1/static/image/avatar.png t \N \N \N \N \N 2 2 a092f4c7-6977-4fe2-97e4-66ff50d5e626 0 技术部门管理员 2026-06-17 00:21:30.397069 2026-06-17 00:21:30.39707 f \N 1 1 \N \N +user $2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa 普通用户 13800138002 user@example.com 0 https://service.fastapiadmin.com/api/v1/static/image/avatar.png f \N \N \N \N \N 3 3 b08ba61e-a5d8-4035-9bdf-f9568ce959c5 0 后端开发工程师 2026-06-17 00:21:30.397073 2026-06-17 00:21:30.397073 f \N 1 1 \N \N +product $2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa 产品经理 13800138003 product@example.com 1 https://service.fastapiadmin.com/api/v1/static/image/avatar.png f \N \N \N \N \N 5 4 e48b05fc-8cc6-4e51-a1eb-89ffe5e28309 0 产品经理 2026-06-17 00:21:30.397077 2026-06-17 00:21:30.397077 f \N 1 1 \N \N +hr $2b$12$e2IJgS/cvHgJ0H3G7Xa08OXoXnk6N/NX3IZRtubBDElA0VLZhkNOa HR专员 13800138004 hr@example.com 1 https://service.fastapiadmin.com/api/v1/static/image/avatar.png f \N \N \N \N \N 6 5 035a1d93-5438-49b7-b75e-8929ea20e40d 0 人力资源专员 2026-06-17 00:21:30.39708 2026-06-17 00:21:30.397081 f \N 1 1 \N \N +zhang_admin $2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe 张明 13800001001 zhang@star-tech.dev 2 \N f \N \N \N \N \N \N 6 bb5ff822-0c90-4375-b78f-2baff1bcab47 0 星辰科技管理员 2026-06-17 00:21:30.399531 2026-06-17 00:21:30.399533 f \N 3 \N \N \N +wang_dev $2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe 王华 13800001002 wang@star-tech.dev 2 \N f \N \N \N \N \N \N 7 06888386-60b3-4c83-b5e3-f60457161577 0 星辰科技研发工程师 2026-06-17 00:21:30.399538 2026-06-17 00:21:30.399538 f \N 3 \N \N \N +li_admin $2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe 李芳 13800002001 li@inno.work 2 \N f \N \N \N \N \N \N 8 dac7ebe1-5d89-4803-81f1-98da53b07261 0 创新工坊创始人 2026-06-17 00:21:30.399542 2026-06-17 00:21:30.399542 f \N 4 \N \N \N +zhao_eng $2b$12$rej/LQJMp5Zujt2YglsaCulQ4wNzYlPupSG0glJPYGzt.nSMV5QDe 赵强 13800002002 zhao@inno.work 2 \N f \N \N \N \N \N \N 9 4357a8e3-add4-4d24-b7b6-11f72e85c181 0 创新工坊技术合伙人 2026-06-17 00:21:30.399545 2026-06-17 00:21:30.399546 f \N 4 \N \N \N +\. + + +-- +-- Data for Name: sys_user_positions; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_user_positions (user_id, position_id) FROM stdin; +\. + + +-- +-- Data for Name: sys_user_roles; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.sys_user_roles (user_id, role_id) FROM stdin; +1 1 +2 2 +3 3 +4 3 +5 3 +6 4 +7 5 +8 6 +9 7 +\. + + +-- +-- Data for Name: task_job; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.task_job (job_id, job_name, trigger_type, status, next_run_time, job_state, result, error, id, uuid, description, created_time, updated_time, is_deleted, deleted_time, tenant_id) FROM stdin; +system_tenant_expiry_check 租户到期检查 interval pending 2026-06-17 01:21:30.550363+08:00 {\n "version": 1,\n "id": "system_tenant_expiry_check",\n "func": "app.api.v1.module_platform.tenant.service:TenantService.check_tenant_expiry",\n "trigger": "interval[1:00:00]",\n "executor": "default",\n "args": [],\n "kwargs": {},\n "name": "租户到期检查",\n "misfire_grace_time": 1,\n "coalesce": true,\n "max_instances": 5,\n "next_run_time": "2026-06-17 01:21:30.550363+08:00"\n} \N \N 1 27022f86-9e39-40a8-86cd-f1ffeb40cba6 \N 2026-06-17 00:21:30.555117 2026-06-17 00:21:30.555118 f \N 1 +system_grace_reminder 宽限期续费提醒 cron pending 2026-06-17 09:00:00+08:00 {\n "version": 1,\n "id": "system_grace_reminder",\n "func": "app.api.v1.module_platform.tenant.service:TenantService.send_grace_reminders",\n "trigger": "cron[hour='9', minute='0']",\n "executor": "default",\n "args": [],\n "kwargs": {},\n "name": "宽限期续费提醒",\n "misfire_grace_time": 1,\n "coalesce": true,\n "max_instances": 5,\n "next_run_time": "2026-06-17 09:00:00+08:00"\n} \N \N 2 788ba8f2-a3c4-46c9-bcba-8709871a8ff2 \N 2026-06-17 00:21:30.564823 2026-06-17 00:21:30.564824 f \N 1 +system_clean_expired 过期租户归档清理 cron pending 2026-07-01 02:00:00+08:00 {\n "version": 1,\n "id": "system_clean_expired",\n "func": "app.api.v1.module_platform.tenant.service:TenantService.clean_expired_tenants",\n "trigger": "cron[day='1', hour='2', minute='0']",\n "executor": "default",\n "args": [],\n "kwargs": {},\n "name": "过期租户归档清理",\n "misfire_grace_time": 1,\n "coalesce": true,\n "max_instances": 5,\n "next_run_time": "2026-07-01 02:00:00+08:00"\n} \N \N 3 736e4fb8-7f55-4176-af0a-d809c80833a0 \N 2026-06-17 00:21:30.569196 2026-06-17 00:21:30.569197 f \N 1 +system_cancel_expired_orders 超时订单取消 interval pending 2026-06-17 00:26:30.571867+08:00 {\n "version": 1,\n "id": "system_cancel_expired_orders",\n "func": "app.api.v1.module_platform.order.service:OrderService.cancel_expired_orders",\n "trigger": "interval[0:05:00]",\n "executor": "default",\n "args": [],\n "kwargs": {},\n "name": "超时订单取消",\n "misfire_grace_time": 1,\n "coalesce": true,\n "max_instances": 5,\n "next_run_time": "2026-06-17 00:26:30.571867+08:00"\n} \N \N 4 4cdd985a-f04a-49b2-bf86-dd272f522f72 \N 2026-06-17 00:21:30.572895 2026-06-17 00:21:30.572896 f \N 1 +system_cleanup_operation_log 操作日志清理 cron pending 2026-06-21 03:00:00+08:00 {\n "version": 1,\n "id": "system_cleanup_operation_log",\n "func": "app.api.v1.module_system.log.service:OperationLogService.cleanup_operation_log",\n "trigger": "cron[day_of_week='sun', hour='3', minute='0']",\n "executor": "default",\n "args": [],\n "kwargs": {},\n "name": "操作日志清理",\n "misfire_grace_time": 1,\n "coalesce": true,\n "max_instances": 5,\n "next_run_time": "2026-06-21 03:00:00+08:00"\n} \N \N 5 d29563d8-14cd-4e78-a1b2-993ce832d216 \N 2026-06-17 00:21:30.576771 2026-06-17 00:21:30.576772 f \N 1 +\. + + +-- +-- Data for Name: task_node; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.task_node (name, code, jobstore, executor, trigger, trigger_args, func, args, kwargs, "coalesce", max_instances, start_date, end_date, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id, created_id, updated_id, deleted_id) FROM stdin; +演示任务 demo_job default default \N \N import logging\n\ndef handler(*args, **kwargs):\n """演示任务:打印参数并返回执行摘要"""\n logger = logging.getLogger(__name__)\n logger.info(f"演示任务执行中,参数: args={args}, kwargs={kwargs}")\n return {\n "status": "success",\n "message": "演示任务执行成功",\n "args_received": len(args),\n "kwargs_keys": list(kwargs.keys())\n }\n \N \N f 1 \N \N 1 35bb1567-2194-42a9-82fc-f5360bc01293 0 最简演示任务,用于验证调度器基本功能 2026-06-17 00:21:30.45975 2026-06-17 00:21:30.459752 f \N 1 \N \N \N +数据库清理任务 db_cleanup sqlalchemy default \N \N import logging\nfrom datetime import datetime, timedelta\n\ndef handler(*args, **kwargs):\n """清理过期数据:删除N天前的日志和临时数据"""\n logger = logging.getLogger(__name__)\n days = kwargs.get("days", 90)\n cutoff = datetime.now() - timedelta(days=days)\n logger.info(f"清理 {cutoff.strftime('%Y-%m-%d')} 之前的过期数据...")\n return {\n "status": "success",\n "cutoff_date": cutoff.strftime("%Y-%m-%d %H:%M:%S"),\n "deleted_count": 0\n }\n \N {"days": 30} t 1 \N \N 2 0146ccfa-88e9-4a69-bfd0-c2df8b385168 0 清理过期操作日志和临时数据,建议每天凌晨3点执行 2026-06-17 00:21:30.459756 2026-06-17 00:21:30.459757 f \N 1 \N \N \N +健康检查任务 health_check default default \N \N import logging\nimport psutil\n\ndef handler(*args, **kwargs):\n """系统健康检查:采集 CPU、内存、磁盘使用率"""\n logger = logging.getLogger(__name__)\n cpu = psutil.cpu_percent(interval=1)\n mem = psutil.virtual_memory()\n disk = psutil.disk_usage("/")\n status = "healthy" if cpu < 80 and mem.percent < 90 and disk.percent < 90 else "warning"\n logger.info(f"健康检查: CPU={cpu}% MEM={mem.percent}% DISK={disk.percent}%")\n return {\n "status": status,\n "cpu_percent": cpu,\n "memory_percent": mem.percent,\n "disk_percent": disk.percent,\n "memory_total_gb": round(mem.total / (1024**3), 1),\n "disk_total_gb": round(disk.total / (1024**3), 1)\n }\n \N \N t 1 \N \N 3 03d7ab4f-604e-4cd8-a20d-e721d29c138b 0 系统资源健康检查,建议每5分钟执行一次 2026-06-17 00:21:30.45976 2026-06-17 00:21:30.459761 f \N 1 \N \N \N +邮件批量发送 email_batch sqlalchemy default \N \N import logging\n\ndef handler(*args, **kwargs):\n """批量发送待发送邮件"""\n logger = logging.getLogger(__name__)\n batch_size = kwargs.get("batch_size", 50)\n logger.info(f"开始批量发送邮件,每批 {batch_size} 封...")\n return {\n "status": "success",\n "sent_count": 0,\n "failed_count": 0,\n "batch_size": batch_size\n }\n \N {"batch_size": 50} f 2 \N \N 4 9087fffb-fba2-4410-8e6c-97ea0530104f 0 批量发送待发送邮件,建议每分钟执行一次 2026-06-17 00:21:30.459764 2026-06-17 00:21:30.459764 f \N 1 \N \N \N +\. + + +-- +-- Data for Name: task_workflow; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.task_workflow (name, code, workflow_status, nodes, edges, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id, created_id, updated_id, deleted_id) FROM stdin; +\. + + +-- +-- Data for Name: task_workflow_node_type; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.task_workflow_node_type (name, code, category, func, args, kwargs, sort_order, is_active, id, uuid, status, description, created_time, updated_time, is_deleted, deleted_time, tenant_id, created_id, updated_id, deleted_id) FROM stdin; +HTTP请求 http_request action import json\nimport urllib.request\n\ndef handler(*args, **kwargs):\n """发送 HTTP 请求并返回响应"""\n url = kwargs.get("url", "")\n method = kwargs.get("method", "GET")\n headers = kwargs.get("headers", {})\n body = kwargs.get("body")\n if not url:\n raise ValueError("缺少 url 参数")\n req = urllib.request.Request(url, method=method, headers=headers)\n if body and isinstance(body, dict):\n req.data = json.dumps(body).encode()\n with urllib.request.urlopen(req) as resp:\n return {"status_code": resp.status, "body": resp.read().decode()}\n \N {"url": "", "method": "GET"} 1 t 1 7b265628-1b81-4d81-90e9-65b8ae16b7a5 0 发送 HTTP 请求,支持 GET/POST 等方法 2026-06-17 00:21:30.464515 2026-06-17 00:21:30.464516 f \N 1 \N \N \N +发送通知 send_notification action import logging\n\ndef handler(*args, **kwargs):\n """发送通知消息"""\n logger = logging.getLogger(__name__)\n channel = kwargs.get("channel", "system")\n title = kwargs.get("title", "工作流通知")\n content = kwargs.get("content", "")\n recipients = kwargs.get("recipients", [])\n logger.info(f"[{channel}] 发送通知: {title} -> {len(recipients)}人")\n return {\n "channel": channel,\n "title": title,\n "recipient_count": len(recipients),\n "status": "sent"\n }\n \N {"channel": "system", "title": "工作流通知", "recipients": []} 2 t 2 cb12b484-3454-4f6d-89b3-17ee81953012 0 发送系统通知、邮件或短信 2026-06-17 00:21:30.46452 2026-06-17 00:21:30.464521 f \N 1 \N \N \N +条件判断 condition condition import json\n\ndef handler(*args, **kwargs):\n """条件分支:根据 upstream 结果决定走向"""\n upstream = kwargs.get("upstream", {})\n variables = kwargs.get("variables", {})\n field = kwargs.get("field", "status")\n expected = kwargs.get("expected", "success")\n operator = kwargs.get("operator", "eq")\n last = list(upstream.values())[-1] if upstream else {}\n actual = last.get(field) if isinstance(last, dict) else last\n operations = {\n "eq": lambda a, e: a == e,\n "ne": lambda a, e: a != e,\n "gt": lambda a, e: a > e,\n "lt": lambda a, e: a < e,\n "contains": lambda a, e: str(e) in str(a)\n }\n op = operations.get(operator, operations["eq"])\n result = op(actual, expected)\n return {"passed": result, "actual": actual, "expected": expected}\n \N {"field": "status", "expected": "success", "operator": "eq"} 3 t 3 7869013c-ef53-42dc-a04b-d5913c3cfc05 0 根据上游节点输出判断分支走向 2026-06-17 00:21:30.464524 2026-06-17 00:21:30.464525 f \N 1 \N \N \N +数据转换 data_transform action import json\nfrom datetime import datetime\n\ndef handler(*args, **kwargs):\n """转换上游数据格式"""\n upstream = kwargs.get("upstream", {})\n mapping = kwargs.get("mapping", {})\n result = {}\n for upstream_key, target_key in mapping.items():\n for source, value in upstream.items():\n if isinstance(value, dict) and upstream_key in value:\n result[target_key] = value[upstream_key]\n result["transformed_at"] = datetime.now().isoformat()\n return result\n \N {"mapping": {}} 4 t 4 b3de912b-bf29-49e3-a50e-ccde37275675 0 转换上游节点的数据格式 2026-06-17 00:21:30.464528 2026-06-17 00:21:30.464528 f \N 1 \N \N \N +聚合汇总 aggregate action import json\n\ndef handler(*args, **kwargs):\n """聚合上游多个节点的输出"""\n upstream = kwargs.get("upstream", {})\n variables = kwargs.get("variables", {})\n results = {\n "node_count": len(upstream),\n "nodes": list(upstream.keys()),\n "values": list(upstream.values()),\n "variables": variables\n }\n return results\n \N \N 5 t 5 138b569e-288b-43e7-b6b6-1fe00a770de3 0 将多个上游节点的输出聚合到一个结果中 2026-06-17 00:21:30.464531 2026-06-17 00:21:30.464532 f \N 1 \N \N \N +\. + + +-- +-- Name: example_demo_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.example_demo_id_seq', 6, true); + + +-- +-- Name: gen_table_column_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.gen_table_column_id_seq', 1, false); + + +-- +-- Name: gen_table_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.gen_table_id_seq', 1, false); + + +-- +-- Name: platform_email_config_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_email_config_id_seq', 1, true); + + +-- +-- Name: platform_email_log_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_email_log_id_seq', 1, false); + + +-- +-- Name: platform_email_template_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_email_template_id_seq', 6, true); + + +-- +-- Name: platform_invoice_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_invoice_id_seq', 4, true); + + +-- +-- Name: platform_menu_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_menu_id_seq', 220, true); + + +-- +-- Name: platform_order_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_order_id_seq', 9, true); + + +-- +-- Name: platform_package_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_package_id_seq', 4, true); + + +-- +-- Name: platform_package_menu_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_package_menu_id_seq', 30, true); + + +-- +-- Name: platform_package_plugin_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_package_plugin_id_seq', 1, false); + + +-- +-- Name: platform_payment_record_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_payment_record_id_seq', 7, true); + + +-- +-- Name: platform_plugin_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_plugin_id_seq', 5, true); + + +-- +-- Name: platform_refund_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_refund_id_seq', 1, true); + + +-- +-- Name: platform_tenant_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_tenant_id_seq', 4, true); + + +-- +-- Name: platform_tenant_plugin_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_tenant_plugin_id_seq', 8, true); + + +-- +-- Name: platform_user_tenant_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.platform_user_tenant_id_seq', 10, true); + + +-- +-- Name: sys_dept_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_dept_id_seq', 13, true); + + +-- +-- Name: sys_dict_data_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_dict_data_id_seq', 34, true); + + +-- +-- Name: sys_dict_type_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_dict_type_id_seq', 10, true); + + +-- +-- Name: sys_login_log_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_login_log_id_seq', 12, true); + + +-- +-- Name: sys_notice_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_notice_id_seq', 6, true); + + +-- +-- Name: sys_operation_log_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_operation_log_id_seq', 15, true); + + +-- +-- Name: sys_param_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_param_id_seq', 17, true); + + +-- +-- Name: sys_position_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_position_id_seq', 6, true); + + +-- +-- Name: sys_role_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_role_id_seq', 7, true); + + +-- +-- Name: sys_ticket_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_ticket_id_seq', 8, true); + + +-- +-- Name: sys_user_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.sys_user_id_seq', 9, true); + + +-- +-- Name: task_job_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.task_job_id_seq', 5, true); + + +-- +-- Name: task_node_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.task_node_id_seq', 4, true); + + +-- +-- Name: task_workflow_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.task_workflow_id_seq', 1, false); + + +-- +-- Name: task_workflow_node_type_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.task_workflow_node_type_id_seq', 5, true); + + +-- +-- Name: apscheduler_jobs apscheduler_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.apscheduler_jobs + ADD CONSTRAINT apscheduler_jobs_pkey PRIMARY KEY (id); + + +-- +-- Name: example_demo example_demo_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.example_demo + ADD CONSTRAINT example_demo_pkey PRIMARY KEY (id); + + +-- +-- Name: gen_table_column gen_table_column_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table_column + ADD CONSTRAINT gen_table_column_pkey PRIMARY KEY (id); + + +-- +-- Name: gen_table gen_table_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table + ADD CONSTRAINT gen_table_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_email_config platform_email_config_name_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_email_config + ADD CONSTRAINT platform_email_config_name_key UNIQUE (name); + + +-- +-- Name: platform_email_config platform_email_config_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_email_config + ADD CONSTRAINT platform_email_config_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_email_log platform_email_log_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_email_log + ADD CONSTRAINT platform_email_log_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_email_template platform_email_template_name_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_email_template + ADD CONSTRAINT platform_email_template_name_key UNIQUE (name); + + +-- +-- Name: platform_email_template platform_email_template_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_email_template + ADD CONSTRAINT platform_email_template_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_email_template platform_email_template_template_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_email_template + ADD CONSTRAINT platform_email_template_template_code_key UNIQUE (template_code); + + +-- +-- Name: platform_invoice platform_invoice_invoice_no_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_invoice + ADD CONSTRAINT platform_invoice_invoice_no_key UNIQUE (invoice_no); + + +-- +-- Name: platform_invoice platform_invoice_order_id_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_invoice + ADD CONSTRAINT platform_invoice_order_id_key UNIQUE (order_id); + + +-- +-- Name: platform_invoice platform_invoice_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_invoice + ADD CONSTRAINT platform_invoice_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_menu platform_menu_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_menu + ADD CONSTRAINT platform_menu_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_order platform_order_order_no_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_order + ADD CONSTRAINT platform_order_order_no_key UNIQUE (order_no); + + +-- +-- Name: platform_order platform_order_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_order + ADD CONSTRAINT platform_order_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_package platform_package_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package + ADD CONSTRAINT platform_package_code_key UNIQUE (code); + + +-- +-- Name: platform_package_menu platform_package_menu_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_menu + ADD CONSTRAINT platform_package_menu_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_package platform_package_name_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package + ADD CONSTRAINT platform_package_name_key UNIQUE (name); + + +-- +-- Name: platform_package platform_package_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package + ADD CONSTRAINT platform_package_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_package_plugin platform_package_plugin_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_plugin + ADD CONSTRAINT platform_package_plugin_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_payment_record platform_payment_record_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_payment_record + ADD CONSTRAINT platform_payment_record_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_payment_record platform_payment_record_transaction_id_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_payment_record + ADD CONSTRAINT platform_payment_record_transaction_id_key UNIQUE (transaction_id); + + +-- +-- Name: platform_plugin platform_plugin_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_plugin + ADD CONSTRAINT platform_plugin_code_key UNIQUE (code); + + +-- +-- Name: platform_plugin platform_plugin_name_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_plugin + ADD CONSTRAINT platform_plugin_name_key UNIQUE (name); + + +-- +-- Name: platform_plugin platform_plugin_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_plugin + ADD CONSTRAINT platform_plugin_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_refund platform_refund_order_id_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_refund + ADD CONSTRAINT platform_refund_order_id_key UNIQUE (order_id); + + +-- +-- Name: platform_refund platform_refund_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_refund + ADD CONSTRAINT platform_refund_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_refund platform_refund_refund_no_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_refund + ADD CONSTRAINT platform_refund_refund_no_key UNIQUE (refund_no); + + +-- +-- Name: platform_tenant platform_tenant_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant + ADD CONSTRAINT platform_tenant_code_key UNIQUE (code); + + +-- +-- Name: platform_tenant platform_tenant_name_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant + ADD CONSTRAINT platform_tenant_name_key UNIQUE (name); + + +-- +-- Name: platform_tenant platform_tenant_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant + ADD CONSTRAINT platform_tenant_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_tenant_plugin platform_tenant_plugin_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant_plugin + ADD CONSTRAINT platform_tenant_plugin_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_user_tenant platform_user_tenant_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_user_tenant + ADD CONSTRAINT platform_user_tenant_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_dept sys_dept_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dept + ADD CONSTRAINT sys_dept_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_dept sys_dept_tenant_id_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dept + ADD CONSTRAINT sys_dept_tenant_id_code_key UNIQUE (tenant_id, code); + + +-- +-- Name: sys_dict_data sys_dict_data_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dict_data + ADD CONSTRAINT sys_dict_data_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_dict_type sys_dict_type_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dict_type + ADD CONSTRAINT sys_dict_type_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_dict_type sys_dict_type_tenant_id_dict_type_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dict_type + ADD CONSTRAINT sys_dict_type_tenant_id_dict_type_key UNIQUE (tenant_id, dict_type); + + +-- +-- Name: sys_login_log sys_login_log_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_login_log + ADD CONSTRAINT sys_login_log_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_notice sys_notice_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice + ADD CONSTRAINT sys_notice_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_operation_log sys_operation_log_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_operation_log + ADD CONSTRAINT sys_operation_log_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_param sys_param_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_param + ADD CONSTRAINT sys_param_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_position sys_position_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_position + ADD CONSTRAINT sys_position_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_role_depts sys_role_depts_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role_depts + ADD CONSTRAINT sys_role_depts_pkey PRIMARY KEY (role_id, dept_id); + + +-- +-- Name: sys_role_menus sys_role_menus_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role_menus + ADD CONSTRAINT sys_role_menus_pkey PRIMARY KEY (role_id, menu_id); + + +-- +-- Name: sys_role sys_role_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role + ADD CONSTRAINT sys_role_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_role sys_role_tenant_id_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role + ADD CONSTRAINT sys_role_tenant_id_code_key UNIQUE (tenant_id, code); + + +-- +-- Name: sys_ticket sys_ticket_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_ticket + ADD CONSTRAINT sys_ticket_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_user sys_user_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user + ADD CONSTRAINT sys_user_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_user_positions sys_user_positions_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user_positions + ADD CONSTRAINT sys_user_positions_pkey PRIMARY KEY (user_id, position_id); + + +-- +-- Name: sys_user_roles sys_user_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user_roles + ADD CONSTRAINT sys_user_roles_pkey PRIMARY KEY (user_id, role_id); + + +-- +-- Name: sys_user sys_user_tenant_id_username_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user + ADD CONSTRAINT sys_user_tenant_id_username_key UNIQUE (tenant_id, username); + + +-- +-- Name: task_job task_job_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_job + ADD CONSTRAINT task_job_pkey PRIMARY KEY (id); + + +-- +-- Name: task_node task_node_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_node + ADD CONSTRAINT task_node_pkey PRIMARY KEY (id); + + +-- +-- Name: task_node task_node_tenant_id_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_node + ADD CONSTRAINT task_node_tenant_id_code_key UNIQUE (tenant_id, code); + + +-- +-- Name: task_workflow_node_type task_workflow_node_type_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow_node_type + ADD CONSTRAINT task_workflow_node_type_pkey PRIMARY KEY (id); + + +-- +-- Name: task_workflow_node_type task_workflow_node_type_tenant_id_code_key; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow_node_type + ADD CONSTRAINT task_workflow_node_type_tenant_id_code_key UNIQUE (tenant_id, code); + + +-- +-- Name: task_workflow task_workflow_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow + ADD CONSTRAINT task_workflow_pkey PRIMARY KEY (id); + + +-- +-- Name: sys_dict_data uq_dict_data_value; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dict_data + ADD CONSTRAINT uq_dict_data_value UNIQUE (tenant_id, dict_type_id, dict_value); + + +-- +-- Name: platform_package_menu uq_package_menu; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_menu + ADD CONSTRAINT uq_package_menu UNIQUE (package_id, menu_id); + + +-- +-- Name: platform_package_plugin uq_package_plugin; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_plugin + ADD CONSTRAINT uq_package_plugin UNIQUE (package_id, plugin_id); + + +-- +-- Name: task_workflow uq_task_workflow_code; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow + ADD CONSTRAINT uq_task_workflow_code UNIQUE (tenant_id, code); + + +-- +-- Name: platform_tenant_plugin uq_tenant_plugin; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant_plugin + ADD CONSTRAINT uq_tenant_plugin UNIQUE (tenant_id, plugin_id); + + +-- +-- Name: sys_notice_read uq_user_notice_read; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice_read + ADD CONSTRAINT uq_user_notice_read PRIMARY KEY (user_id, notice_id); + + +-- +-- Name: platform_user_tenant uq_user_tenant; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_user_tenant + ADD CONSTRAINT uq_user_tenant UNIQUE (user_id, tenant_id); + + +-- +-- Name: ix_apscheduler_jobs_next_run_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_apscheduler_jobs_next_run_time ON public.apscheduler_jobs USING btree (next_run_time); + + +-- +-- Name: ix_example_demo_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_created_id ON public.example_demo USING btree (created_id); + + +-- +-- Name: ix_example_demo_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_created_time ON public.example_demo USING btree (created_time); + + +-- +-- Name: ix_example_demo_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_deleted_id ON public.example_demo USING btree (deleted_id); + + +-- +-- Name: ix_example_demo_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_deleted_time ON public.example_demo USING btree (deleted_time); + + +-- +-- Name: ix_example_demo_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_id ON public.example_demo USING btree (id); + + +-- +-- Name: ix_example_demo_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_is_deleted ON public.example_demo USING btree (is_deleted); + + +-- +-- Name: ix_example_demo_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_status ON public.example_demo USING btree (status); + + +-- +-- Name: ix_example_demo_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_tenant_id ON public.example_demo USING btree (tenant_id); + + +-- +-- Name: ix_example_demo_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_updated_id ON public.example_demo USING btree (updated_id); + + +-- +-- Name: ix_example_demo_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_example_demo_updated_time ON public.example_demo USING btree (updated_time); + + +-- +-- Name: ix_example_demo_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_example_demo_uuid ON public.example_demo USING btree (uuid); + + +-- +-- Name: ix_gen_table_column_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_created_id ON public.gen_table_column USING btree (created_id); + + +-- +-- Name: ix_gen_table_column_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_created_time ON public.gen_table_column USING btree (created_time); + + +-- +-- Name: ix_gen_table_column_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_deleted_id ON public.gen_table_column USING btree (deleted_id); + + +-- +-- Name: ix_gen_table_column_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_deleted_time ON public.gen_table_column USING btree (deleted_time); + + +-- +-- Name: ix_gen_table_column_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_id ON public.gen_table_column USING btree (id); + + +-- +-- Name: ix_gen_table_column_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_is_deleted ON public.gen_table_column USING btree (is_deleted); + + +-- +-- Name: ix_gen_table_column_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_status ON public.gen_table_column USING btree (status); + + +-- +-- Name: ix_gen_table_column_table_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_table_id ON public.gen_table_column USING btree (table_id); + + +-- +-- Name: ix_gen_table_column_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_tenant_id ON public.gen_table_column USING btree (tenant_id); + + +-- +-- Name: ix_gen_table_column_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_updated_id ON public.gen_table_column USING btree (updated_id); + + +-- +-- Name: ix_gen_table_column_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_column_updated_time ON public.gen_table_column USING btree (updated_time); + + +-- +-- Name: ix_gen_table_column_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_gen_table_column_uuid ON public.gen_table_column USING btree (uuid); + + +-- +-- Name: ix_gen_table_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_created_id ON public.gen_table USING btree (created_id); + + +-- +-- Name: ix_gen_table_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_created_time ON public.gen_table USING btree (created_time); + + +-- +-- Name: ix_gen_table_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_deleted_id ON public.gen_table USING btree (deleted_id); + + +-- +-- Name: ix_gen_table_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_deleted_time ON public.gen_table USING btree (deleted_time); + + +-- +-- Name: ix_gen_table_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_id ON public.gen_table USING btree (id); + + +-- +-- Name: ix_gen_table_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_is_deleted ON public.gen_table USING btree (is_deleted); + + +-- +-- Name: ix_gen_table_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_status ON public.gen_table USING btree (status); + + +-- +-- Name: ix_gen_table_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_tenant_id ON public.gen_table USING btree (tenant_id); + + +-- +-- Name: ix_gen_table_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_updated_id ON public.gen_table USING btree (updated_id); + + +-- +-- Name: ix_gen_table_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_gen_table_updated_time ON public.gen_table USING btree (updated_time); + + +-- +-- Name: ix_gen_table_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_gen_table_uuid ON public.gen_table USING btree (uuid); + + +-- +-- Name: ix_platform_email_config_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_config_created_time ON public.platform_email_config USING btree (created_time); + + +-- +-- Name: ix_platform_email_config_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_config_deleted_time ON public.platform_email_config USING btree (deleted_time); + + +-- +-- Name: ix_platform_email_config_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_config_id ON public.platform_email_config USING btree (id); + + +-- +-- Name: ix_platform_email_config_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_config_is_deleted ON public.platform_email_config USING btree (is_deleted); + + +-- +-- Name: ix_platform_email_config_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_config_status ON public.platform_email_config USING btree (status); + + +-- +-- Name: ix_platform_email_config_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_config_updated_time ON public.platform_email_config USING btree (updated_time); + + +-- +-- Name: ix_platform_email_config_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_email_config_uuid ON public.platform_email_config USING btree (uuid); + + +-- +-- Name: ix_platform_email_log_config_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_config_id ON public.platform_email_log USING btree (config_id); + + +-- +-- Name: ix_platform_email_log_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_created_time ON public.platform_email_log USING btree (created_time); + + +-- +-- Name: ix_platform_email_log_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_deleted_time ON public.platform_email_log USING btree (deleted_time); + + +-- +-- Name: ix_platform_email_log_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_id ON public.platform_email_log USING btree (id); + + +-- +-- Name: ix_platform_email_log_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_is_deleted ON public.platform_email_log USING btree (is_deleted); + + +-- +-- Name: ix_platform_email_log_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_status ON public.platform_email_log USING btree (status); + + +-- +-- Name: ix_platform_email_log_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_tenant_id ON public.platform_email_log USING btree (tenant_id); + + +-- +-- Name: ix_platform_email_log_to_email; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_to_email ON public.platform_email_log USING btree (to_email); + + +-- +-- Name: ix_platform_email_log_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_log_updated_time ON public.platform_email_log USING btree (updated_time); + + +-- +-- Name: ix_platform_email_log_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_email_log_uuid ON public.platform_email_log USING btree (uuid); + + +-- +-- Name: ix_platform_email_template_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_template_created_time ON public.platform_email_template USING btree (created_time); + + +-- +-- Name: ix_platform_email_template_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_template_deleted_time ON public.platform_email_template USING btree (deleted_time); + + +-- +-- Name: ix_platform_email_template_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_template_id ON public.platform_email_template USING btree (id); + + +-- +-- Name: ix_platform_email_template_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_template_is_deleted ON public.platform_email_template USING btree (is_deleted); + + +-- +-- Name: ix_platform_email_template_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_template_status ON public.platform_email_template USING btree (status); + + +-- +-- Name: ix_platform_email_template_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_email_template_updated_time ON public.platform_email_template USING btree (updated_time); + + +-- +-- Name: ix_platform_email_template_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_email_template_uuid ON public.platform_email_template USING btree (uuid); + + +-- +-- Name: ix_platform_invoice_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_invoice_created_time ON public.platform_invoice USING btree (created_time); + + +-- +-- Name: ix_platform_invoice_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_invoice_deleted_time ON public.platform_invoice USING btree (deleted_time); + + +-- +-- Name: ix_platform_invoice_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_invoice_id ON public.platform_invoice USING btree (id); + + +-- +-- Name: ix_platform_invoice_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_invoice_is_deleted ON public.platform_invoice USING btree (is_deleted); + + +-- +-- Name: ix_platform_invoice_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_invoice_status ON public.platform_invoice USING btree (status); + + +-- +-- Name: ix_platform_invoice_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_invoice_tenant_id ON public.platform_invoice USING btree (tenant_id); + + +-- +-- Name: ix_platform_invoice_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_invoice_updated_time ON public.platform_invoice USING btree (updated_time); + + +-- +-- Name: ix_platform_invoice_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_invoice_uuid ON public.platform_invoice USING btree (uuid); + + +-- +-- Name: ix_platform_menu_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_menu_created_time ON public.platform_menu USING btree (created_time); + + +-- +-- Name: ix_platform_menu_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_menu_deleted_time ON public.platform_menu USING btree (deleted_time); + + +-- +-- Name: ix_platform_menu_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_menu_id ON public.platform_menu USING btree (id); + + +-- +-- Name: ix_platform_menu_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_menu_is_deleted ON public.platform_menu USING btree (is_deleted); + + +-- +-- Name: ix_platform_menu_parent_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_menu_parent_id ON public.platform_menu USING btree (parent_id); + + +-- +-- Name: ix_platform_menu_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_menu_status ON public.platform_menu USING btree (status); + + +-- +-- Name: ix_platform_menu_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_menu_updated_time ON public.platform_menu USING btree (updated_time); + + +-- +-- Name: ix_platform_menu_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_menu_uuid ON public.platform_menu USING btree (uuid); + + +-- +-- Name: ix_platform_order_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_order_created_time ON public.platform_order USING btree (created_time); + + +-- +-- Name: ix_platform_order_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_order_deleted_time ON public.platform_order USING btree (deleted_time); + + +-- +-- Name: ix_platform_order_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_order_id ON public.platform_order USING btree (id); + + +-- +-- Name: ix_platform_order_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_order_is_deleted ON public.platform_order USING btree (is_deleted); + + +-- +-- Name: ix_platform_order_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_order_status ON public.platform_order USING btree (status); + + +-- +-- Name: ix_platform_order_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_order_tenant_id ON public.platform_order USING btree (tenant_id); + + +-- +-- Name: ix_platform_order_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_order_updated_time ON public.platform_order USING btree (updated_time); + + +-- +-- Name: ix_platform_order_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_order_uuid ON public.platform_order USING btree (uuid); + + +-- +-- Name: ix_platform_package_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_created_time ON public.platform_package USING btree (created_time); + + +-- +-- Name: ix_platform_package_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_deleted_time ON public.platform_package USING btree (deleted_time); + + +-- +-- Name: ix_platform_package_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_id ON public.platform_package USING btree (id); + + +-- +-- Name: ix_platform_package_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_is_deleted ON public.platform_package USING btree (is_deleted); + + +-- +-- Name: ix_platform_package_menu_menu_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_menu_menu_id ON public.platform_package_menu USING btree (menu_id); + + +-- +-- Name: ix_platform_package_menu_package_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_menu_package_id ON public.platform_package_menu USING btree (package_id); + + +-- +-- Name: ix_platform_package_plugin_package_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_plugin_package_id ON public.platform_package_plugin USING btree (package_id); + + +-- +-- Name: ix_platform_package_plugin_plugin_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_plugin_plugin_id ON public.platform_package_plugin USING btree (plugin_id); + + +-- +-- Name: ix_platform_package_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_status ON public.platform_package USING btree (status); + + +-- +-- Name: ix_platform_package_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_package_updated_time ON public.platform_package USING btree (updated_time); + + +-- +-- Name: ix_platform_package_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_package_uuid ON public.platform_package USING btree (uuid); + + +-- +-- Name: ix_platform_payment_record_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_payment_record_created_time ON public.platform_payment_record USING btree (created_time); + + +-- +-- Name: ix_platform_payment_record_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_payment_record_deleted_time ON public.platform_payment_record USING btree (deleted_time); + + +-- +-- Name: ix_platform_payment_record_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_payment_record_id ON public.platform_payment_record USING btree (id); + + +-- +-- Name: ix_platform_payment_record_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_payment_record_is_deleted ON public.platform_payment_record USING btree (is_deleted); + + +-- +-- Name: ix_platform_payment_record_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_payment_record_status ON public.platform_payment_record USING btree (status); + + +-- +-- Name: ix_platform_payment_record_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_payment_record_tenant_id ON public.platform_payment_record USING btree (tenant_id); + + +-- +-- Name: ix_platform_payment_record_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_payment_record_updated_time ON public.platform_payment_record USING btree (updated_time); + + +-- +-- Name: ix_platform_payment_record_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_payment_record_uuid ON public.platform_payment_record USING btree (uuid); + + +-- +-- Name: ix_platform_plugin_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_plugin_created_time ON public.platform_plugin USING btree (created_time); + + +-- +-- Name: ix_platform_plugin_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_plugin_deleted_time ON public.platform_plugin USING btree (deleted_time); + + +-- +-- Name: ix_platform_plugin_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_plugin_id ON public.platform_plugin USING btree (id); + + +-- +-- Name: ix_platform_plugin_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_plugin_is_deleted ON public.platform_plugin USING btree (is_deleted); + + +-- +-- Name: ix_platform_plugin_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_plugin_status ON public.platform_plugin USING btree (status); + + +-- +-- Name: ix_platform_plugin_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_plugin_updated_time ON public.platform_plugin USING btree (updated_time); + + +-- +-- Name: ix_platform_plugin_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_plugin_uuid ON public.platform_plugin USING btree (uuid); + + +-- +-- Name: ix_platform_refund_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_refund_created_time ON public.platform_refund USING btree (created_time); + + +-- +-- Name: ix_platform_refund_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_refund_deleted_time ON public.platform_refund USING btree (deleted_time); + + +-- +-- Name: ix_platform_refund_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_refund_id ON public.platform_refund USING btree (id); + + +-- +-- Name: ix_platform_refund_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_refund_is_deleted ON public.platform_refund USING btree (is_deleted); + + +-- +-- Name: ix_platform_refund_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_refund_status ON public.platform_refund USING btree (status); + + +-- +-- Name: ix_platform_refund_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_refund_tenant_id ON public.platform_refund USING btree (tenant_id); + + +-- +-- Name: ix_platform_refund_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_refund_updated_time ON public.platform_refund USING btree (updated_time); + + +-- +-- Name: ix_platform_refund_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_refund_uuid ON public.platform_refund USING btree (uuid); + + +-- +-- Name: ix_platform_tenant_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_created_time ON public.platform_tenant USING btree (created_time); + + +-- +-- Name: ix_platform_tenant_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_deleted_time ON public.platform_tenant USING btree (deleted_time); + + +-- +-- Name: ix_platform_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_id ON public.platform_tenant USING btree (id); + + +-- +-- Name: ix_platform_tenant_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_is_deleted ON public.platform_tenant USING btree (is_deleted); + + +-- +-- Name: ix_platform_tenant_package_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_package_id ON public.platform_tenant USING btree (package_id); + + +-- +-- Name: ix_platform_tenant_plugin_plugin_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_plugin_plugin_id ON public.platform_tenant_plugin USING btree (plugin_id); + + +-- +-- Name: ix_platform_tenant_plugin_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_plugin_tenant_id ON public.platform_tenant_plugin USING btree (tenant_id); + + +-- +-- Name: ix_platform_tenant_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_status ON public.platform_tenant USING btree (status); + + +-- +-- Name: ix_platform_tenant_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_tenant_updated_time ON public.platform_tenant USING btree (updated_time); + + +-- +-- Name: ix_platform_tenant_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_platform_tenant_uuid ON public.platform_tenant USING btree (uuid); + + +-- +-- Name: ix_platform_user_tenant_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_user_tenant_tenant_id ON public.platform_user_tenant USING btree (tenant_id); + + +-- +-- Name: ix_platform_user_tenant_user_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_platform_user_tenant_user_id ON public.platform_user_tenant USING btree (user_id); + + +-- +-- Name: ix_sys_dept_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_created_time ON public.sys_dept USING btree (created_time); + + +-- +-- Name: ix_sys_dept_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_deleted_time ON public.sys_dept USING btree (deleted_time); + + +-- +-- Name: ix_sys_dept_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_id ON public.sys_dept USING btree (id); + + +-- +-- Name: ix_sys_dept_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_is_deleted ON public.sys_dept USING btree (is_deleted); + + +-- +-- Name: ix_sys_dept_parent_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_parent_id ON public.sys_dept USING btree (parent_id); + + +-- +-- Name: ix_sys_dept_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_status ON public.sys_dept USING btree (status); + + +-- +-- Name: ix_sys_dept_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_tenant_id ON public.sys_dept USING btree (tenant_id); + + +-- +-- Name: ix_sys_dept_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dept_updated_time ON public.sys_dept USING btree (updated_time); + + +-- +-- Name: ix_sys_dept_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_dept_uuid ON public.sys_dept USING btree (uuid); + + +-- +-- Name: ix_sys_dict_data_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_data_created_time ON public.sys_dict_data USING btree (created_time); + + +-- +-- Name: ix_sys_dict_data_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_data_deleted_time ON public.sys_dict_data USING btree (deleted_time); + + +-- +-- Name: ix_sys_dict_data_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_data_id ON public.sys_dict_data USING btree (id); + + +-- +-- Name: ix_sys_dict_data_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_data_is_deleted ON public.sys_dict_data USING btree (is_deleted); + + +-- +-- Name: ix_sys_dict_data_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_data_status ON public.sys_dict_data USING btree (status); + + +-- +-- Name: ix_sys_dict_data_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_data_tenant_id ON public.sys_dict_data USING btree (tenant_id); + + +-- +-- Name: ix_sys_dict_data_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_data_updated_time ON public.sys_dict_data USING btree (updated_time); + + +-- +-- Name: ix_sys_dict_data_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_dict_data_uuid ON public.sys_dict_data USING btree (uuid); + + +-- +-- Name: ix_sys_dict_type_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_type_created_time ON public.sys_dict_type USING btree (created_time); + + +-- +-- Name: ix_sys_dict_type_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_type_deleted_time ON public.sys_dict_type USING btree (deleted_time); + + +-- +-- Name: ix_sys_dict_type_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_type_id ON public.sys_dict_type USING btree (id); + + +-- +-- Name: ix_sys_dict_type_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_type_is_deleted ON public.sys_dict_type USING btree (is_deleted); + + +-- +-- Name: ix_sys_dict_type_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_type_status ON public.sys_dict_type USING btree (status); + + +-- +-- Name: ix_sys_dict_type_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_type_tenant_id ON public.sys_dict_type USING btree (tenant_id); + + +-- +-- Name: ix_sys_dict_type_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_dict_type_updated_time ON public.sys_dict_type USING btree (updated_time); + + +-- +-- Name: ix_sys_dict_type_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_dict_type_uuid ON public.sys_dict_type USING btree (uuid); + + +-- +-- Name: ix_sys_login_log_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_created_id ON public.sys_login_log USING btree (created_id); + + +-- +-- Name: ix_sys_login_log_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_created_time ON public.sys_login_log USING btree (created_time); + + +-- +-- Name: ix_sys_login_log_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_deleted_id ON public.sys_login_log USING btree (deleted_id); + + +-- +-- Name: ix_sys_login_log_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_deleted_time ON public.sys_login_log USING btree (deleted_time); + + +-- +-- Name: ix_sys_login_log_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_id ON public.sys_login_log USING btree (id); + + +-- +-- Name: ix_sys_login_log_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_is_deleted ON public.sys_login_log USING btree (is_deleted); + + +-- +-- Name: ix_sys_login_log_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_tenant_id ON public.sys_login_log USING btree (tenant_id); + + +-- +-- Name: ix_sys_login_log_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_updated_id ON public.sys_login_log USING btree (updated_id); + + +-- +-- Name: ix_sys_login_log_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_login_log_updated_time ON public.sys_login_log USING btree (updated_time); + + +-- +-- Name: ix_sys_login_log_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_login_log_uuid ON public.sys_login_log USING btree (uuid); + + +-- +-- Name: ix_sys_notice_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_created_id ON public.sys_notice USING btree (created_id); + + +-- +-- Name: ix_sys_notice_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_created_time ON public.sys_notice USING btree (created_time); + + +-- +-- Name: ix_sys_notice_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_deleted_id ON public.sys_notice USING btree (deleted_id); + + +-- +-- Name: ix_sys_notice_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_deleted_time ON public.sys_notice USING btree (deleted_time); + + +-- +-- Name: ix_sys_notice_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_id ON public.sys_notice USING btree (id); + + +-- +-- Name: ix_sys_notice_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_is_deleted ON public.sys_notice USING btree (is_deleted); + + +-- +-- Name: ix_sys_notice_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_status ON public.sys_notice USING btree (status); + + +-- +-- Name: ix_sys_notice_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_tenant_id ON public.sys_notice USING btree (tenant_id); + + +-- +-- Name: ix_sys_notice_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_updated_id ON public.sys_notice USING btree (updated_id); + + +-- +-- Name: ix_sys_notice_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_notice_updated_time ON public.sys_notice USING btree (updated_time); + + +-- +-- Name: ix_sys_notice_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_notice_uuid ON public.sys_notice USING btree (uuid); + + +-- +-- Name: ix_sys_operation_log_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_created_id ON public.sys_operation_log USING btree (created_id); + + +-- +-- Name: ix_sys_operation_log_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_created_time ON public.sys_operation_log USING btree (created_time); + + +-- +-- Name: ix_sys_operation_log_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_deleted_id ON public.sys_operation_log USING btree (deleted_id); + + +-- +-- Name: ix_sys_operation_log_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_deleted_time ON public.sys_operation_log USING btree (deleted_time); + + +-- +-- Name: ix_sys_operation_log_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_id ON public.sys_operation_log USING btree (id); + + +-- +-- Name: ix_sys_operation_log_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_is_deleted ON public.sys_operation_log USING btree (is_deleted); + + +-- +-- Name: ix_sys_operation_log_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_status ON public.sys_operation_log USING btree (status); + + +-- +-- Name: ix_sys_operation_log_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_tenant_id ON public.sys_operation_log USING btree (tenant_id); + + +-- +-- Name: ix_sys_operation_log_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_updated_id ON public.sys_operation_log USING btree (updated_id); + + +-- +-- Name: ix_sys_operation_log_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_operation_log_updated_time ON public.sys_operation_log USING btree (updated_time); + + +-- +-- Name: ix_sys_operation_log_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_operation_log_uuid ON public.sys_operation_log USING btree (uuid); + + +-- +-- Name: ix_sys_param_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_param_created_time ON public.sys_param USING btree (created_time); + + +-- +-- Name: ix_sys_param_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_param_deleted_time ON public.sys_param USING btree (deleted_time); + + +-- +-- Name: ix_sys_param_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_param_id ON public.sys_param USING btree (id); + + +-- +-- Name: ix_sys_param_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_param_is_deleted ON public.sys_param USING btree (is_deleted); + + +-- +-- Name: ix_sys_param_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_param_status ON public.sys_param USING btree (status); + + +-- +-- Name: ix_sys_param_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_param_tenant_id ON public.sys_param USING btree (tenant_id); + + +-- +-- Name: ix_sys_param_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_param_updated_time ON public.sys_param USING btree (updated_time); + + +-- +-- Name: ix_sys_param_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_param_uuid ON public.sys_param USING btree (uuid); + + +-- +-- Name: ix_sys_position_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_created_id ON public.sys_position USING btree (created_id); + + +-- +-- Name: ix_sys_position_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_created_time ON public.sys_position USING btree (created_time); + + +-- +-- Name: ix_sys_position_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_deleted_id ON public.sys_position USING btree (deleted_id); + + +-- +-- Name: ix_sys_position_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_deleted_time ON public.sys_position USING btree (deleted_time); + + +-- +-- Name: ix_sys_position_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_id ON public.sys_position USING btree (id); + + +-- +-- Name: ix_sys_position_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_is_deleted ON public.sys_position USING btree (is_deleted); + + +-- +-- Name: ix_sys_position_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_status ON public.sys_position USING btree (status); + + +-- +-- Name: ix_sys_position_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_tenant_id ON public.sys_position USING btree (tenant_id); + + +-- +-- Name: ix_sys_position_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_updated_id ON public.sys_position USING btree (updated_id); + + +-- +-- Name: ix_sys_position_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_position_updated_time ON public.sys_position USING btree (updated_time); + + +-- +-- Name: ix_sys_position_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_position_uuid ON public.sys_position USING btree (uuid); + + +-- +-- Name: ix_sys_role_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_role_created_time ON public.sys_role USING btree (created_time); + + +-- +-- Name: ix_sys_role_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_role_deleted_time ON public.sys_role USING btree (deleted_time); + + +-- +-- Name: ix_sys_role_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_role_id ON public.sys_role USING btree (id); + + +-- +-- Name: ix_sys_role_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_role_is_deleted ON public.sys_role USING btree (is_deleted); + + +-- +-- Name: ix_sys_role_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_role_status ON public.sys_role USING btree (status); + + +-- +-- Name: ix_sys_role_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_role_tenant_id ON public.sys_role USING btree (tenant_id); + + +-- +-- Name: ix_sys_role_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_role_updated_time ON public.sys_role USING btree (updated_time); + + +-- +-- Name: ix_sys_role_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_role_uuid ON public.sys_role USING btree (uuid); + + +-- +-- Name: ix_sys_ticket_assigned_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_assigned_id ON public.sys_ticket USING btree (assigned_id); + + +-- +-- Name: ix_sys_ticket_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_created_id ON public.sys_ticket USING btree (created_id); + + +-- +-- Name: ix_sys_ticket_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_created_time ON public.sys_ticket USING btree (created_time); + + +-- +-- Name: ix_sys_ticket_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_deleted_id ON public.sys_ticket USING btree (deleted_id); + + +-- +-- Name: ix_sys_ticket_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_deleted_time ON public.sys_ticket USING btree (deleted_time); + + +-- +-- Name: ix_sys_ticket_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_id ON public.sys_ticket USING btree (id); + + +-- +-- Name: ix_sys_ticket_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_is_deleted ON public.sys_ticket USING btree (is_deleted); + + +-- +-- Name: ix_sys_ticket_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_status ON public.sys_ticket USING btree (status); + + +-- +-- Name: ix_sys_ticket_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_tenant_id ON public.sys_ticket USING btree (tenant_id); + + +-- +-- Name: ix_sys_ticket_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_updated_id ON public.sys_ticket USING btree (updated_id); + + +-- +-- Name: ix_sys_ticket_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_ticket_updated_time ON public.sys_ticket USING btree (updated_time); + + +-- +-- Name: ix_sys_ticket_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_ticket_uuid ON public.sys_ticket USING btree (uuid); + + +-- +-- Name: ix_sys_user_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_created_id ON public.sys_user USING btree (created_id); + + +-- +-- Name: ix_sys_user_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_created_time ON public.sys_user USING btree (created_time); + + +-- +-- Name: ix_sys_user_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_deleted_id ON public.sys_user USING btree (deleted_id); + + +-- +-- Name: ix_sys_user_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_deleted_time ON public.sys_user USING btree (deleted_time); + + +-- +-- Name: ix_sys_user_dept_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_dept_id ON public.sys_user USING btree (dept_id); + + +-- +-- Name: ix_sys_user_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_id ON public.sys_user USING btree (id); + + +-- +-- Name: ix_sys_user_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_is_deleted ON public.sys_user USING btree (is_deleted); + + +-- +-- Name: ix_sys_user_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_status ON public.sys_user USING btree (status); + + +-- +-- Name: ix_sys_user_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_tenant_id ON public.sys_user USING btree (tenant_id); + + +-- +-- Name: ix_sys_user_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_updated_id ON public.sys_user USING btree (updated_id); + + +-- +-- Name: ix_sys_user_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_sys_user_updated_time ON public.sys_user USING btree (updated_time); + + +-- +-- Name: ix_sys_user_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_sys_user_uuid ON public.sys_user USING btree (uuid); + + +-- +-- Name: ix_task_job_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_job_created_time ON public.task_job USING btree (created_time); + + +-- +-- Name: ix_task_job_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_job_deleted_time ON public.task_job USING btree (deleted_time); + + +-- +-- Name: ix_task_job_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_job_id ON public.task_job USING btree (id); + + +-- +-- Name: ix_task_job_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_job_is_deleted ON public.task_job USING btree (is_deleted); + + +-- +-- Name: ix_task_job_job_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_job_job_id ON public.task_job USING btree (job_id); + + +-- +-- Name: ix_task_job_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_job_tenant_id ON public.task_job USING btree (tenant_id); + + +-- +-- Name: ix_task_job_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_job_updated_time ON public.task_job USING btree (updated_time); + + +-- +-- Name: ix_task_job_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_task_job_uuid ON public.task_job USING btree (uuid); + + +-- +-- Name: ix_task_node_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_created_id ON public.task_node USING btree (created_id); + + +-- +-- Name: ix_task_node_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_created_time ON public.task_node USING btree (created_time); + + +-- +-- Name: ix_task_node_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_deleted_id ON public.task_node USING btree (deleted_id); + + +-- +-- Name: ix_task_node_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_deleted_time ON public.task_node USING btree (deleted_time); + + +-- +-- Name: ix_task_node_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_id ON public.task_node USING btree (id); + + +-- +-- Name: ix_task_node_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_is_deleted ON public.task_node USING btree (is_deleted); + + +-- +-- Name: ix_task_node_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_status ON public.task_node USING btree (status); + + +-- +-- Name: ix_task_node_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_tenant_id ON public.task_node USING btree (tenant_id); + + +-- +-- Name: ix_task_node_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_updated_id ON public.task_node USING btree (updated_id); + + +-- +-- Name: ix_task_node_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_node_updated_time ON public.task_node USING btree (updated_time); + + +-- +-- Name: ix_task_node_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_task_node_uuid ON public.task_node USING btree (uuid); + + +-- +-- Name: ix_task_workflow_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_created_id ON public.task_workflow USING btree (created_id); + + +-- +-- Name: ix_task_workflow_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_created_time ON public.task_workflow USING btree (created_time); + + +-- +-- Name: ix_task_workflow_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_deleted_id ON public.task_workflow USING btree (deleted_id); + + +-- +-- Name: ix_task_workflow_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_deleted_time ON public.task_workflow USING btree (deleted_time); + + +-- +-- Name: ix_task_workflow_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_id ON public.task_workflow USING btree (id); + + +-- +-- Name: ix_task_workflow_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_is_deleted ON public.task_workflow USING btree (is_deleted); + + +-- +-- Name: ix_task_workflow_node_type_created_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_created_id ON public.task_workflow_node_type USING btree (created_id); + + +-- +-- Name: ix_task_workflow_node_type_created_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_created_time ON public.task_workflow_node_type USING btree (created_time); + + +-- +-- Name: ix_task_workflow_node_type_deleted_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_deleted_id ON public.task_workflow_node_type USING btree (deleted_id); + + +-- +-- Name: ix_task_workflow_node_type_deleted_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_deleted_time ON public.task_workflow_node_type USING btree (deleted_time); + + +-- +-- Name: ix_task_workflow_node_type_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_id ON public.task_workflow_node_type USING btree (id); + + +-- +-- Name: ix_task_workflow_node_type_is_deleted; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_is_deleted ON public.task_workflow_node_type USING btree (is_deleted); + + +-- +-- Name: ix_task_workflow_node_type_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_status ON public.task_workflow_node_type USING btree (status); + + +-- +-- Name: ix_task_workflow_node_type_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_tenant_id ON public.task_workflow_node_type USING btree (tenant_id); + + +-- +-- Name: ix_task_workflow_node_type_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_updated_id ON public.task_workflow_node_type USING btree (updated_id); + + +-- +-- Name: ix_task_workflow_node_type_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_node_type_updated_time ON public.task_workflow_node_type USING btree (updated_time); + + +-- +-- Name: ix_task_workflow_node_type_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_task_workflow_node_type_uuid ON public.task_workflow_node_type USING btree (uuid); + + +-- +-- Name: ix_task_workflow_status; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_status ON public.task_workflow USING btree (status); + + +-- +-- Name: ix_task_workflow_tenant_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_tenant_id ON public.task_workflow USING btree (tenant_id); + + +-- +-- Name: ix_task_workflow_updated_id; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_updated_id ON public.task_workflow USING btree (updated_id); + + +-- +-- Name: ix_task_workflow_updated_time; Type: INDEX; Schema: public; Owner: root +-- + +CREATE INDEX ix_task_workflow_updated_time ON public.task_workflow USING btree (updated_time); + + +-- +-- Name: ix_task_workflow_uuid; Type: INDEX; Schema: public; Owner: root +-- + +CREATE UNIQUE INDEX ix_task_workflow_uuid ON public.task_workflow USING btree (uuid); + + +-- +-- Name: example_demo example_demo_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.example_demo + ADD CONSTRAINT example_demo_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: example_demo example_demo_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.example_demo + ADD CONSTRAINT example_demo_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: example_demo example_demo_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.example_demo + ADD CONSTRAINT example_demo_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: example_demo example_demo_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.example_demo + ADD CONSTRAINT example_demo_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: gen_table_column gen_table_column_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table_column + ADD CONSTRAINT gen_table_column_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: gen_table_column gen_table_column_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table_column + ADD CONSTRAINT gen_table_column_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: gen_table_column gen_table_column_table_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table_column + ADD CONSTRAINT gen_table_column_table_id_fkey FOREIGN KEY (table_id) REFERENCES public.gen_table(id) ON DELETE CASCADE; + + +-- +-- Name: gen_table_column gen_table_column_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table_column + ADD CONSTRAINT gen_table_column_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: gen_table_column gen_table_column_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table_column + ADD CONSTRAINT gen_table_column_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: gen_table gen_table_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table + ADD CONSTRAINT gen_table_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: gen_table gen_table_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table + ADD CONSTRAINT gen_table_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: gen_table gen_table_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table + ADD CONSTRAINT gen_table_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: gen_table gen_table_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.gen_table + ADD CONSTRAINT gen_table_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: platform_email_log platform_email_log_config_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_email_log + ADD CONSTRAINT platform_email_log_config_id_fkey FOREIGN KEY (config_id) REFERENCES public.platform_email_config(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: platform_invoice platform_invoice_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_invoice + ADD CONSTRAINT platform_invoice_order_id_fkey FOREIGN KEY (order_id) REFERENCES public.platform_order(id); + + +-- +-- Name: platform_invoice platform_invoice_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_invoice + ADD CONSTRAINT platform_invoice_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: platform_menu platform_menu_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_menu + ADD CONSTRAINT platform_menu_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.platform_menu(id) ON DELETE SET NULL; + + +-- +-- Name: platform_order platform_order_package_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_order + ADD CONSTRAINT platform_order_package_id_fkey FOREIGN KEY (package_id) REFERENCES public.platform_package(id); + + +-- +-- Name: platform_order platform_order_plugin_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_order + ADD CONSTRAINT platform_order_plugin_id_fkey FOREIGN KEY (plugin_id) REFERENCES public.platform_plugin(id); + + +-- +-- Name: platform_order platform_order_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_order + ADD CONSTRAINT platform_order_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: platform_package_menu platform_package_menu_menu_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_menu + ADD CONSTRAINT platform_package_menu_menu_id_fkey FOREIGN KEY (menu_id) REFERENCES public.platform_menu(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: platform_package_menu platform_package_menu_package_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_menu + ADD CONSTRAINT platform_package_menu_package_id_fkey FOREIGN KEY (package_id) REFERENCES public.platform_package(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: platform_package_plugin platform_package_plugin_package_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_plugin + ADD CONSTRAINT platform_package_plugin_package_id_fkey FOREIGN KEY (package_id) REFERENCES public.platform_package(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: platform_package_plugin platform_package_plugin_plugin_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_package_plugin + ADD CONSTRAINT platform_package_plugin_plugin_id_fkey FOREIGN KEY (plugin_id) REFERENCES public.platform_plugin(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: platform_payment_record platform_payment_record_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_payment_record + ADD CONSTRAINT platform_payment_record_order_id_fkey FOREIGN KEY (order_id) REFERENCES public.platform_order(id); + + +-- +-- Name: platform_payment_record platform_payment_record_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_payment_record + ADD CONSTRAINT platform_payment_record_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: platform_refund platform_refund_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_refund + ADD CONSTRAINT platform_refund_order_id_fkey FOREIGN KEY (order_id) REFERENCES public.platform_order(id); + + +-- +-- Name: platform_refund platform_refund_reviewer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_refund + ADD CONSTRAINT platform_refund_reviewer_id_fkey FOREIGN KEY (reviewer_id) REFERENCES public.sys_user(id); + + +-- +-- Name: platform_refund platform_refund_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_refund + ADD CONSTRAINT platform_refund_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: platform_tenant platform_tenant_package_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant + ADD CONSTRAINT platform_tenant_package_id_fkey FOREIGN KEY (package_id) REFERENCES public.platform_package(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: platform_tenant_plugin platform_tenant_plugin_plugin_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant_plugin + ADD CONSTRAINT platform_tenant_plugin_plugin_id_fkey FOREIGN KEY (plugin_id) REFERENCES public.platform_plugin(id) ON DELETE CASCADE; + + +-- +-- Name: platform_tenant_plugin platform_tenant_plugin_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_tenant_plugin + ADD CONSTRAINT platform_tenant_plugin_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON DELETE CASCADE; + + +-- +-- Name: platform_user_tenant platform_user_tenant_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_user_tenant + ADD CONSTRAINT platform_user_tenant_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: platform_user_tenant platform_user_tenant_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.platform_user_tenant + ADD CONSTRAINT platform_user_tenant_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_dept sys_dept_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dept + ADD CONSTRAINT sys_dept_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.sys_dept(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_dept sys_dept_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dept + ADD CONSTRAINT sys_dept_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_dict_data sys_dict_data_dict_type_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dict_data + ADD CONSTRAINT sys_dict_data_dict_type_id_fkey FOREIGN KEY (dict_type_id) REFERENCES public.sys_dict_type(id) ON DELETE CASCADE; + + +-- +-- Name: sys_dict_data sys_dict_data_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dict_data + ADD CONSTRAINT sys_dict_data_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_dict_type sys_dict_type_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_dict_type + ADD CONSTRAINT sys_dict_type_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_login_log sys_login_log_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_login_log + ADD CONSTRAINT sys_login_log_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_login_log sys_login_log_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_login_log + ADD CONSTRAINT sys_login_log_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_login_log sys_login_log_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_login_log + ADD CONSTRAINT sys_login_log_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_login_log sys_login_log_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_login_log + ADD CONSTRAINT sys_login_log_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_notice sys_notice_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice + ADD CONSTRAINT sys_notice_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_notice sys_notice_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice + ADD CONSTRAINT sys_notice_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_notice_read sys_notice_read_notice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice_read + ADD CONSTRAINT sys_notice_read_notice_id_fkey FOREIGN KEY (notice_id) REFERENCES public.sys_notice(id) ON DELETE CASCADE; + + +-- +-- Name: sys_notice_read sys_notice_read_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice_read + ADD CONSTRAINT sys_notice_read_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.sys_user(id) ON DELETE CASCADE; + + +-- +-- Name: sys_notice sys_notice_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice + ADD CONSTRAINT sys_notice_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_notice sys_notice_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_notice + ADD CONSTRAINT sys_notice_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_operation_log sys_operation_log_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_operation_log + ADD CONSTRAINT sys_operation_log_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_operation_log sys_operation_log_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_operation_log + ADD CONSTRAINT sys_operation_log_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_operation_log sys_operation_log_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_operation_log + ADD CONSTRAINT sys_operation_log_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_operation_log sys_operation_log_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_operation_log + ADD CONSTRAINT sys_operation_log_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_param sys_param_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_param + ADD CONSTRAINT sys_param_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_position sys_position_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_position + ADD CONSTRAINT sys_position_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_position sys_position_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_position + ADD CONSTRAINT sys_position_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_position sys_position_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_position + ADD CONSTRAINT sys_position_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_position sys_position_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_position + ADD CONSTRAINT sys_position_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_role_depts sys_role_depts_dept_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role_depts + ADD CONSTRAINT sys_role_depts_dept_id_fkey FOREIGN KEY (dept_id) REFERENCES public.sys_dept(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_role_depts sys_role_depts_role_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role_depts + ADD CONSTRAINT sys_role_depts_role_id_fkey FOREIGN KEY (role_id) REFERENCES public.sys_role(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_role_menus sys_role_menus_menu_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role_menus + ADD CONSTRAINT sys_role_menus_menu_id_fkey FOREIGN KEY (menu_id) REFERENCES public.platform_menu(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_role_menus sys_role_menus_role_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role_menus + ADD CONSTRAINT sys_role_menus_role_id_fkey FOREIGN KEY (role_id) REFERENCES public.sys_role(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_role sys_role_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_role + ADD CONSTRAINT sys_role_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_ticket sys_ticket_assigned_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_ticket + ADD CONSTRAINT sys_ticket_assigned_id_fkey FOREIGN KEY (assigned_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_ticket sys_ticket_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_ticket + ADD CONSTRAINT sys_ticket_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_ticket sys_ticket_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_ticket + ADD CONSTRAINT sys_ticket_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_ticket sys_ticket_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_ticket + ADD CONSTRAINT sys_ticket_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_ticket sys_ticket_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_ticket + ADD CONSTRAINT sys_ticket_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_user sys_user_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user + ADD CONSTRAINT sys_user_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_user sys_user_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user + ADD CONSTRAINT sys_user_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_user sys_user_dept_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user + ADD CONSTRAINT sys_user_dept_id_fkey FOREIGN KEY (dept_id) REFERENCES public.sys_dept(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: sys_user_positions sys_user_positions_position_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user_positions + ADD CONSTRAINT sys_user_positions_position_id_fkey FOREIGN KEY (position_id) REFERENCES public.sys_position(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_user_positions sys_user_positions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user_positions + ADD CONSTRAINT sys_user_positions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_user_roles sys_user_roles_role_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user_roles + ADD CONSTRAINT sys_user_roles_role_id_fkey FOREIGN KEY (role_id) REFERENCES public.sys_role(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_user_roles sys_user_roles_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user_roles + ADD CONSTRAINT sys_user_roles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: sys_user sys_user_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user + ADD CONSTRAINT sys_user_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: sys_user sys_user_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.sys_user + ADD CONSTRAINT sys_user_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_job task_job_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_job + ADD CONSTRAINT task_job_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: task_node task_node_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_node + ADD CONSTRAINT task_node_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_node task_node_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_node + ADD CONSTRAINT task_node_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_node task_node_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_node + ADD CONSTRAINT task_node_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: task_node task_node_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_node + ADD CONSTRAINT task_node_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_workflow task_workflow_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow + ADD CONSTRAINT task_workflow_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_workflow task_workflow_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow + ADD CONSTRAINT task_workflow_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_workflow_node_type task_workflow_node_type_created_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow_node_type + ADD CONSTRAINT task_workflow_node_type_created_id_fkey FOREIGN KEY (created_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_workflow_node_type task_workflow_node_type_deleted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow_node_type + ADD CONSTRAINT task_workflow_node_type_deleted_id_fkey FOREIGN KEY (deleted_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_workflow_node_type task_workflow_node_type_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow_node_type + ADD CONSTRAINT task_workflow_node_type_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: task_workflow_node_type task_workflow_node_type_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow_node_type + ADD CONSTRAINT task_workflow_node_type_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: task_workflow task_workflow_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow + ADD CONSTRAINT task_workflow_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.platform_tenant(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: task_workflow task_workflow_updated_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.task_workflow + ADD CONSTRAINT task_workflow_updated_id_fkey FOREIGN KEY (updated_id) REFERENCES public.sys_user(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict 9qExZeqqbdUYt6emtfQgYjzUgI8DjpcaY12XCXlE27m3yq2zeOHxmNos5PWNVpi + diff --git a/backend/static/swagger/custom-ui/scripts.js b/backend/static/swagger/custom-ui/scripts.js deleted file mode 100644 index 4097a79e..00000000 --- a/backend/static/swagger/custom-ui/scripts.js +++ /dev/null @@ -1,1027 +0,0 @@ -class LangJinDocsUI { - constructor() { - this.defaults = { - url: '/openapi.json' - } - } - configure(options = {}){ - // 合并用户参数和默认参数 - this.config = { ...this.defaults, ...options }; - console.log("LangJinDocsUI configured with:", this.config); - } - async initialize(){ - await this.buildAPIDocsData(); - this.renderUI(); - } - async buildAPIDocsData(){ - const response = await fetch(this.config.url); - let data = await response.json(); - this.openapi = data.openapi; - this.info = data.info; - this.apiData = []; - const paths = data.paths; - const schemas = data.components.schemas || {}; - const securitySchemes = data.components.securitySchemes || {}; - - for (let key in paths) { - for (let method in paths[key]) { - this.apiData.push({ - "operationId": paths[key][method].operationId || '', - "tags": paths[key][method].tags || [], - "summary": paths[key][method].summary || key, - "description": paths[key][method].description || '', - "path": key, - "method": method.toUpperCase(), - "headers": this.__parse_headers(paths[key][method].requestBody || {}), - "parameters": this.__parse_parameters(paths[key][method].parameters || []), - "body": this.__parse_requestBody(schemas, paths[key][method].requestBody || {}), - "responses": this.__parse_responses(paths[key][method].responses || {}) - }); - } - } - // console.log("API Documentation Data:", JSON.stringify(this.apiData)); - } - __parse_parameters(parameters){ - let params = []; - for (let param of parameters) { - params.push({ - "name": param.name, - "in": param.in, - "required": param.required || false, - "description": param.description || '', - "type": param.schema.type || 'string', - "value": "" // 添加默认空值 - }); - } - return params; - } - __parse_headers(requestBody){ - if (!requestBody || Object.keys(requestBody).length === 0) { - return {}; - } - const content = requestBody.content || {}; - const contentTypes = Object.keys(content); - if (contentTypes.length === 0) { - return {}; - } - const contentType = contentTypes[0]; - return {"Content-Type": contentType}; - } - __parse_requestBody(schemas, requestBody){ - if (!requestBody || Object.keys(requestBody).length === 0) { - return {}; - } - const content = requestBody.content || {}; - const contentTypes = Object.keys(content); - if (contentTypes.length === 0) { - return {}; - } - const contentType = contentTypes[0]; - const schema = content[contentType].schema; - - // 检查是否有 $ref 引用 - if (!schema || !schema['$ref']) { - return {}; - } - - const schemaRef = schema['$ref']; - const key = schemaRef.replace("#/components/schemas/", ""); - - // 检查 schemas 中是否存在该 key - if (!schemas[key]) { - return {}; - } - - const properties = schemas[key].properties || {}; - const required = schemas[key].required || []; - let body = {}; - body[contentType] = []; - for(let key in properties){ - body[contentType].push({ - "name": key, - "type": properties[key].type || 'string', - "description": properties[key].description || '', - "required": required.includes(key), - "value": "" - }); - } - return body; - } - __parse_responses(responses){ - return {}; - } - - - renderUI() { - const container = document.getElementById('swagger-ui'); - container.innerHTML = ''; - - // 创建顶部导航栏 - const navBar = document.createElement('header'); - navBar.className = 'topbar'; - navBar.innerHTML = ` - - `; - container.appendChild(navBar); - - // 创建内容布局容器 - const contentContainer = document.createElement('div'); - contentContainer.className = 'content-container'; - - // 创建左侧菜单 - const menuContainer = document.createElement('aside'); - menuContainer.className = 'menu-container'; - menuContainer.id = 'menu-container'; - contentContainer.appendChild(menuContainer); - - // 创建主内容区 - const mainContainer = document.createElement('main'); - mainContainer.className = 'main-container'; - mainContainer.id = 'main-container'; - contentContainer.appendChild(mainContainer); - - container.appendChild(contentContainer); - - // 渲染菜单项 - this.renderMenuItems(); - - // 添加搜索事件监听 - const searchInput = document.querySelector('.search-input'); - searchInput.addEventListener('input', (e) => { - const keyword = e.target.value.trim(); - this.handleSearch(keyword); - }); - - // 添加配置按钮事件监听 - const configButton = document.getElementById('configButton'); - configButton.addEventListener('click', () => { - this.openConfigDialog(); - }); - } - - handleSearch(keyword) { - const menuItems = document.querySelectorAll('.menu-item'); - - if (keyword) { - const lowerKeyword = keyword.toLowerCase(); - - // 遍历所有标签 - menuItems.forEach(menuItem => { - const submenu = menuItem.nextElementSibling; - const apiItems = submenu.querySelectorAll('.api-item'); - let hasVisibleItems = false; - - // 遍历当前标签下的所有API - apiItems.forEach(apiItem => { - const apiPath = apiItem.dataset.path.toLowerCase(); - const apiSummary = apiItem.querySelector('.summary').textContent.toLowerCase(); - - // 检查是否匹配搜索关键词 - if (apiPath.includes(lowerKeyword) || apiSummary.includes(lowerKeyword)) { - apiItem.style.display = 'block'; - hasVisibleItems = true; - } else { - apiItem.style.display = 'none'; - } - }); - - // 如果当前标签下有可见的API,则显示该标签 - if (hasVisibleItems) { - menuItem.style.display = 'block'; - // 确保搜索时展开所有包含匹配项的标签 - submenu.classList.add('show'); - menuItem.classList.add('active'); - menuItem.querySelector('.arrow').textContent = '▲'; - } else { - menuItem.style.display = 'none'; - submenu.style.display = 'none'; - } - }); - } else { - // 清空搜索时恢复所有菜单项 - const menuItems = document.querySelectorAll('.menu-item'); - menuItems.forEach(menuItem => { - menuItem.style.display = 'block'; - const submenu = menuItem.nextElementSibling; - submenu.style.display = 'block'; - }); - } - } - - renderMenuItems() { - const menuContainer = document.getElementById('menu-container'); - - // 创建搜索框 - const searchContainer = document.createElement('div'); - searchContainer.className = 'search-container'; - searchContainer.innerHTML = ` - - `; - menuContainer.appendChild(searchContainer); - - const menu = document.createElement('ul'); - menu.className = 'menu-list'; - - // 按标签分组API - const tagsMap = {}; - this.apiData.forEach(api => { - api.tags.forEach(tag => { - if (!tagsMap[tag]) { - tagsMap[tag] = []; - } - tagsMap[tag].push(api); - }); - }); - - // 创建每个标签的菜单项 - const tags = Object.keys(tagsMap); - for (let i = 0; i < tags.length; i++) { - const tag = tags[i]; - const count = tagsMap[tag].length; - const menuItem = document.createElement('li'); - menuItem.className = 'menu-item'; - menuItem.innerHTML = ` - - `; - menuItem.onclick = () => this.toggleMenuGroup(menuItem); - menu.appendChild(menuItem); - - // 创建子菜单 - const subMenu = document.createElement('ul'); - subMenu.className = 'submenu'; - - tagsMap[tag].forEach(api => { - const apiItem = document.createElement('li'); - apiItem.className = 'api-item'; - apiItem.dataset.path = api.path; - apiItem.dataset.method = api.method; - apiItem.innerHTML = ` -${api.description || ''}
- -| 参数名 | -值 | -类型 | -是否必填 | -简述 | -操作 | -
|---|---|---|---|---|---|
| - | - | - - | -
-
-
-
- |
- - | - - | -
| - | - | - - | -
-
-
-
- |
- - | - - | -
| 参数名 | -值 | -是否必填 | -简述 | -操作 | -
|---|---|---|---|---|
| - | - |
-
-
-
- |
- - | - - | -
| - | - |
-
-
-
- |
- - | - - | -
| 参数名 | -值 | -类型 | -是否必填 | -简述 | -操作 | -
|---|---|---|---|---|---|
| - | - | - - | -
-
-
-
- |
- - | - - | -
{}
- | Key | -Value | -操作 | -
|---|---|---|
| - | - | - |
内容
", + status=0, + ) + assert s.notice_title == "测试公告" + + # 类型错误 + with pytest.raises(ValueError, match="公告类型"): + NoticeCreateSchema( + notice_title="x", notice_type="3", notice_content="c" + ) + + def test_unread_count_logic(self) -> None: + """未读数量 = 总数 - 已读数""" + # 纯逻辑测试,不依赖 DB + total = 10 + read = 3 + unread = total - read + assert unread == 7 + + def test_mark_read_service_success(self) -> None: + """测试 mark_read_service 成功标记已读逻辑""" + from unittest.mock import AsyncMock, MagicMock + + # 验证 NoticeReadModel 结构 + from app.api.v1.module_system.notice.model import NoticeReadModel + + assert NoticeReadModel.__tablename__ == "sys_notice_read" + + # 模拟数据库行为 - 直接使用 mock 而不实例化 ORM 模型 + mock_db = AsyncMock() + + # 模拟创建已读记录 + mock_record = MagicMock() + mock_record.user_id = 1 + mock_record.notice_id = 1 + mock_db.add(mock_record) + + assert mock_db.add.called + added = mock_db.add.call_args[0][0] + assert added.user_id == 1 + assert added.notice_id == 1 + + def test_mark_read_service_idempotent(self) -> None: + """测试 mark_read_service 幂等性逻辑""" + from unittest.mock import AsyncMock, MagicMock + + mock_db = AsyncMock() + + # 模拟已读状态(查询返回记录) + mock_result = MagicMock() + mock_existing = MagicMock() + mock_result.scalar_one_or_none.return_value = mock_existing + mock_db.execute.return_value = mock_result + + # 验证:已读时不添加新记录 + result = mock_result.scalar_one_or_none() + assert result is mock_existing + # 不调用 add(已读 = 幂等) + assert not mock_db.add.called + + def test_mark_read_service_notice_not_found(self) -> None: + """测试 mark_read_service 公告不存在逻辑""" + # 公告不存在时应返回错误信息 + error_msg = "该公告不存在" + assert "不存在" in error_msg + + def test_mark_all_read_service(self) -> None: + """测试 mark_all_read_service 批量标记已读逻辑""" + # 总数 - 已读数 = 未读数 + total_notices = 5 + read_notices = {1, 2} + unread = total_notices - len(read_notices) + assert unread == 3 + + +# ==================== User 模块测试 ==================== + +class TestUserModule: + """User 模块单元测试""" + + def test_user_model_tenant_id(self) -> None: + from app.api.v1.module_system.user.model import UserModel + + assert hasattr(UserModel, "tenant_id") + assert hasattr(UserModel, "username") + assert hasattr(UserModel, "password") + assert hasattr(UserModel, "is_superuser") + + def test_user_create_schema(self) -> None: + from app.api.v1.module_system.user.schema import UserCreateSchema + + s = UserCreateSchema( + username="testuser", + password="Test123!", + name="测试用户", + mobile="13800138000", + email="test@example.com", + ) + assert s.username == "testuser" + + def test_user_create_schema_weak_password(self) -> None: + """弱密码应在 service 层校验(schema 层只校验最小长度)""" + from app.api.v1.module_system.user.schema import UserCreateSchema + + # Schema 层接受最小长度 6 的密码 + s = UserCreateSchema( + username="test", + password="123456", + name="测试", + ) + assert s.password == "123456" + + +# ==================== Role 模块测试 ==================== + +class TestRoleModule: + """Role 模块单元测试""" + + def test_role_model(self) -> None: + from app.api.v1.module_system.role.model import RoleModel + + assert hasattr(RoleModel, "tenant_id") + assert hasattr(RoleModel, "name") + assert hasattr(RoleModel, "code") + assert hasattr(RoleModel, "data_scope") + + def test_role_schema(self) -> None: + from app.api.v1.module_system.role.schema import RoleCreateSchema + + s = RoleCreateSchema(name="超级管理员", code="super_admin", data_scope=4) + assert s.data_scope == 4 + + def test_data_scope_values(self) -> None: + """data_scope 枚举值""" + valid = {1, 2, 3, 4, 5} + assert 4 in valid # 全部数据 + + +# ==================== Dept 模块测试 ==================== + +class TestDeptModule: + """Dept 模块单元测试""" + + def test_dept_model(self) -> None: + from app.api.v1.module_system.dept.model import DeptModel + + assert hasattr(DeptModel, "tenant_id") + assert hasattr(DeptModel, "name") + assert hasattr(DeptModel, "parent_id") + + def test_dept_schema(self) -> None: + from app.api.v1.module_system.dept.schema import DeptCreateSchema + + s = DeptCreateSchema(name="技术部", code="tech_dept", order=1) + assert s.name == "技术部" + + +# ==================== Menu 模块测试 ==================== + +class TestMenuModule: + """Menu 模块单元测试""" + + def test_menu_model(self) -> None: + from app.api.v1.module_platform.menu.model import MenuModel + + assert MenuModel.__tablename__ == "platform_menu" + assert hasattr(MenuModel, "name") + assert hasattr(MenuModel, "type") + assert hasattr(MenuModel, "permission") + + def test_menu_type_values(self) -> None: + """菜单类型: 1=目录 2=菜单 3=按钮 4=外链""" + # type 字段类型为 Integer + + from app.api.v1.module_platform.menu.model import MenuModel + + col = next( + (c for c in MenuModel.__table__.columns if c.name == "type"), None + ) + assert col is not None + + +# ==================== Dict 模块测试 ==================== + +class TestDictModule: + """Dict 模块单元测试""" + + def test_dict_type_model(self) -> None: + from app.api.v1.module_system.dict.model import DictTypeModel + + assert DictTypeModel.__tablename__ == "sys_dict_type" + assert hasattr(DictTypeModel, "dict_name") + assert hasattr(DictTypeModel, "dict_type") + + def test_dict_data_model(self) -> None: + from app.api.v1.module_system.dict.model import DictDataModel + + assert DictDataModel.__tablename__ == "sys_dict_data" + assert hasattr(DictDataModel, "dict_label") + assert hasattr(DictDataModel, "dict_value") + + +# ==================== Params 模块测试 ==================== + +class TestParamsModule: + """Params 模块单元测试""" + + def test_params_model(self) -> None: + from app.api.v1.module_system.params.model import ParamsModel + + assert hasattr(ParamsModel, "config_name") + assert hasattr(ParamsModel, "config_key") + assert hasattr(ParamsModel, "config_value") + assert hasattr(ParamsModel, "config_type") + + +# ==================== Auth 模块测试 ==================== + +class TestAuthModule: + """Auth 模块单元测试""" + + def test_auth_schema(self) -> None: + from app.core.base_schema import AuthSchema + + assert "user" in AuthSchema.model_fields + assert "check_data_scope" in AuthSchema.model_fields + + def test_login_flow_mock(self) -> None: + """验证登录相关 schema 逻辑""" + from app.core.base_schema import JWTOutSchema + + out = JWTOutSchema( + access_token="fake_token", + refresh_token="fake_refresh", + token_type="bearer", + expires_in=3600, + ) + assert out.access_token == "fake_token" + assert out.token_type == "bearer" + + +# ==================== OperationLog 模块测试 ==================== + +class TestOperationLogModule: + """OperationLog 模块单元测试""" + + def test_operation_log_model(self) -> None: + from app.api.v1.module_system.log.model import OperationLogModel + + assert hasattr(OperationLogModel, "request_path") + assert hasattr(OperationLogModel, "request_method") + assert hasattr(OperationLogModel, "response_code") diff --git a/backend/tests/test_unit.py b/backend/tests/test_unit.py index f85641b5..7398a25f 100644 --- a/backend/tests/test_unit.py +++ b/backend/tests/test_unit.py @@ -39,7 +39,7 @@ class TestModels: def test_tenant_model(self) -> None: from app.api.v1.module_platform.tenant.model import TenantModel - assert TenantModel.__tablename__ == "sys_tenant" + assert TenantModel.__tablename__ == "platform_tenant" assert hasattr(TenantModel, "name") assert hasattr(TenantModel, "code") assert hasattr(TenantModel, "status") @@ -49,27 +49,30 @@ class TestModels: """测试租户模型中的配额字段(单一大表设计)""" from app.api.v1.module_platform.tenant.model import TenantModel - assert hasattr(TenantModel, "max_users") - assert hasattr(TenantModel, "max_roles") - assert hasattr(TenantModel, "max_storage_mb") - assert hasattr(TenantModel, "max_depts") + assert hasattr(TenantModel, "package_id") + assert hasattr(TenantModel, "start_time") def test_tenant_config_model(self) -> None: - from app.api.v1.module_platform.tenant.model import TenantConfigModel + """TenantModel 包含配置字段(单一大表设计)""" + from app.api.v1.module_platform.tenant.model import TenantModel - assert TenantConfigModel.__tablename__ == "sys_tenant_config" - assert hasattr(TenantConfigModel, "config_key") + # 配置字段已合并到 TenantModel + assert hasattr(TenantModel, "name") + assert hasattr(TenantModel, "code") + assert hasattr(TenantModel, "status") - def test_tenant_menu_model(self) -> None: - from app.api.v1.module_platform.tenant.model import TenantMenuModel + def test_package_model_quota(self) -> None: + from app.api.v1.module_platform.package.model import PackageModel - assert TenantMenuModel.__tablename__ == "sys_tenant_menu" - assert hasattr(TenantMenuModel, "menu_id") + assert hasattr(PackageModel, "max_users") + assert hasattr(PackageModel, "max_roles") + assert hasattr(PackageModel, "max_depts") + assert hasattr(PackageModel, "max_storage_mb") def test_tenant_user_model(self) -> None: from app.api.v1.module_platform.tenant.model import TenantUserModel - assert TenantUserModel.__tablename__ == "sys_user_tenant" + assert TenantUserModel.__tablename__ == "platform_user_tenant" assert hasattr(TenantUserModel, "role") assert hasattr(TenantUserModel, "is_default") @@ -86,7 +89,7 @@ class TestModels: assert hasattr(RoleModel, "menus") def test_menu_model_has_type(self) -> None: - from app.api.v1.module_system.menu.model import MenuModel + from app.api.v1.module_platform.menu.model import MenuModel assert hasattr(MenuModel, "type") assert hasattr(MenuModel, "name") @@ -102,16 +105,16 @@ class TestModels: def test_plugin_model(self) -> None: from app.api.v1.module_platform.plugin.model import PluginModel - assert PluginModel.__tablename__ == "sys_plugin" + assert PluginModel.__tablename__ == "platform_plugin" assert hasattr(PluginModel, "code") def test_tenant_plugin_model(self) -> None: from app.api.v1.module_platform.plugin.model import TenantPluginModel - assert TenantPluginModel.__tablename__ == "sys_tenant_plugin" + assert TenantPluginModel.__tablename__ == "platform_tenant_plugin" def test_ticket_model(self) -> None: - from app.api.v1.module_platform.ticket.model import TicketModel + from app.api.v1.module_system.ticket.model import TicketModel assert TicketModel.__tablename__ == "sys_ticket" assert hasattr(TicketModel, "ticket_type") @@ -127,7 +130,7 @@ class TestSchemas: def test_tenant_create_schema(self) -> None: from app.api.v1.module_platform.tenant.schema import TenantCreateSchema - s = TenantCreateSchema(name="test", code="test001", status="0") + s = TenantCreateSchema(name="test", code="test001", status=0) assert s.name == "test" assert s.code == "test001" @@ -138,18 +141,19 @@ class TestSchemas: assert s.name == "renamed" assert s.code is None # 未传则为 None - def test_tenant_quota_schema_validation(self) -> None: - from app.api.v1.module_platform.tenant.schema import TenantQuotaUpdateSchema + def test_package_schema_quota(self) -> None: + from app.api.v1.module_platform.package.schema import PackageCreateSchema - s = TenantQuotaUpdateSchema(max_users=100, max_roles=30) + s = PackageCreateSchema(name="test", code="test", max_users=100, max_roles=30) assert s.max_users == 100 + assert s.max_roles == 30 # 负数应被拒绝 - with pytest.raises(Exception): - TenantQuotaUpdateSchema(max_users=-1) + with pytest.raises(ValueError): + PackageCreateSchema(name="test", code="test", max_users=-1) def test_ticket_schema(self) -> None: - from app.api.v1.module_platform.ticket.schema import TicketCreateSchema + from app.api.v1.module_system.ticket.schema import TicketCreateSchema s = TicketCreateSchema(title="test", ticket_type="suggestion") assert s.title == "test" @@ -157,7 +161,7 @@ class TestSchemas: def test_menu_type_enum(self) -> None: """菜单类型: 1=目录 2=菜单 3=按钮 4=链接""" - from app.api.v1.module_system.menu.model import MenuModel + from app.api.v1.module_platform.menu.model import MenuModel assert hasattr(MenuModel, "type") # type 是 int 字段 @@ -203,9 +207,217 @@ class TestEnums: def test_menu_type_values(self) -> None: """type: 1=目录 2=菜单 3=按钮 4=链接""" - from app.api.v1.module_system.menu.model import MenuModel + from app.api.v1.module_platform.menu.model import MenuModel - assert MenuModel.__tablename__ == "sys_menu" + assert MenuModel.__tablename__ == "platform_menu" # type 字段是 int 类型 col = next((c for c in MenuModel.__table__.columns if c.name == "type"), None) assert col is not None + + +# ==================== 06. 租户自助注册(PRD §4.5)==================== + + +class TestTenantRegister: + """TenantRegisterSchema / TenantRegisterOutSchema 校验。""" + + def test_register_schema_valid(self) -> None: + from app.api.v1.module_system.auth.schema import TenantRegisterSchema + + s = TenantRegisterSchema( + username="testowner", + password="TestPass123", + email="test@example.com", + tenant_name="测试企业", + ) + assert s.username == "testowner" + assert s.email == "test@example.com" + assert s.tenant_name == "测试企业" + + def test_register_schema_minimal(self) -> None: + """仅必填字段。""" + from app.api.v1.module_system.auth.schema import TenantRegisterSchema + + s = TenantRegisterSchema( + username="owner2", + password="secret123", + email="owner2@example.com", + ) + assert s.tenant_name is None + + def test_register_schema_short_password(self) -> None: + """密码少于 6 位应拒绝。""" + from pydantic import ValidationError + + from app.api.v1.module_system.auth.schema import TenantRegisterSchema + + with pytest.raises(ValidationError): + TenantRegisterSchema( + username="x", + password="123", + email="a@b.com", + ) + + def test_register_schema_bad_email(self) -> None: + """非法邮箱应拒绝。""" + from pydantic import ValidationError + + from app.api.v1.module_system.auth.schema import TenantRegisterSchema + + with pytest.raises(ValidationError): + TenantRegisterSchema( + username="x", + password="123456", + email="not-an-email", + ) + + def test_register_out_schema_fields(self) -> None: + """注册响应 Schema 包含所有必要字段。""" + from app.api.v1.module_system.auth.schema import TenantRegisterOutSchema + + fields = set(TenantRegisterOutSchema.model_fields.keys()) + for f in ("user_id", "tenant_id", "tenant_name", "tenant_code", "package", "trial_end", "message"): + assert f in fields, f"{f} 未在 TenantRegisterOutSchema 中找到" + + +# ==================== 07. 支付回调套餐激活 ==================== + + +class TestActivateTenantPackage: + """PaymentService._activate_tenant_package 日期计算逻辑。""" + + def test_new_calculation(self) -> None: + """order_type=new:设置 start_time=now, end_time=now+period。""" + from datetime import datetime, timedelta + + now = datetime(2025, 1, 1, 12, 0, 0) + period_months = 1 + duration = timedelta(days=30 * period_months) + + # 模拟 _activate_tenant_package 中 new 分支的日期计算 + fake = {"package_id": None, "start_time": None, "end_time": None, "status": 1} + fake["package_id"] = 1 + fake["start_time"] = now + fake["end_time"] = now + duration + fake["status"] = 0 + + assert fake["package_id"] == 1 + assert fake["start_time"] == now + assert fake["end_time"] == now + timedelta(days=30) + assert fake["status"] == 0 + + def test_renew_extends_end_time(self) -> None: + """order_type=renew:在原有 end_time 基础上顺延。""" + from datetime import datetime, timedelta + + now = datetime(2025, 1, 1) + original_end = now + timedelta(days=30) + fake = {"end_time": original_end, "status": 1} + + # 模拟 renew 分支:base = max(end_time, now) + duration + base = max(fake["end_time"], now) + fake["end_time"] = base + timedelta(days=30) + fake["status"] = 0 + + assert fake["end_time"] == original_end + timedelta(days=30) + assert fake["status"] == 0 + + def test_upgrade_changes_package(self) -> None: + """order_type=upgrade:更换 package_id,状态置为 active。""" + fake = {"package_id": 1, "status": 1} + + fake["package_id"] = 2 + fake["status"] = 0 + + assert fake["package_id"] == 2 + assert fake["status"] == 0 + + +# ==================== 08. 插件热重载 ==================== + + +class TestPluginReload: + """discover.reload_dynamic_router 可正常导入且签名正确。""" + + def test_reload_function_exists(self) -> None: + from app.core.discover import reload_dynamic_router + + assert callable(reload_dynamic_router) + + def test_set_app_ref_exists(self) -> None: + from app.core.discover import set_app_ref + + assert callable(set_app_ref) + + def test_reload_service_exists(self) -> None: + """PluginService.reload_service 方法存在且为类方法。""" + import inspect + + from app.api.v1.module_platform.plugin.service import PluginService + + assert hasattr(PluginService, "reload_service") + assert isinstance( + inspect.getattr_static(PluginService, "reload_service"), + (classmethod, classmethod), + ) + + +# ==================== 18. status 字段类型一致性 ==================== + +class TestStatusFieldType: + """验证 status 字段从 String(10) 迁移为 SmallInteger 后的一致性""" + + def test_model_mixin_status_is_integer(self) -> None: + """验证 ModelMixin 子类的 status 字段已转为 SmallInteger""" + from app.api.v1.module_system.user.model import UserModel + + col = UserModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper(), ( + f"UserModel.status 应为 SmallInteger,当前为 {col.type}" + ) + + def test_tenant_status_is_integer(self) -> None: + from app.api.v1.module_platform.tenant.model import TenantModel + + col = TenantModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper(), ( + f"TenantModel.status 应为 SmallInteger,当前为 {col.type}" + ) + + def test_user_status_is_integer(self) -> None: + from app.api.v1.module_system.user.model import UserModel + + col = UserModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper() + + def test_role_status_is_integer(self) -> None: + from app.api.v1.module_system.role.model import RoleModel + + col = RoleModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper() + + def test_dept_status_is_integer(self) -> None: + from app.api.v1.module_system.dept.model import DeptModel + + col = DeptModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper() + + def test_menu_status_is_integer(self) -> None: + from app.api.v1.module_platform.menu.model import MenuModel + + col = MenuModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper() + + def test_package_status_is_integer(self) -> None: + from app.api.v1.module_platform.package.model import PackageModel + + col = PackageModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper(), ( + f"PackageModel.status 应为 SmallInteger,当前为 {col.type}" + ) + + def test_ticket_status_is_integer(self) -> None: + from app.api.v1.module_system.ticket.model import TicketModel + + col = TicketModel.__table__.c["status"] + assert "SMALLINT" in str(col.type).upper() diff --git a/backend/uv.lock b/backend/uv.lock index b403002c..b49cae9a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -41,6 +41,28 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, ] +[[package]] +name = "aioredis" +version = "2.0.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "async-timeout" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/cf/9eb144a0b05809ffc5d29045c4b51039000ea275bc1268d0351c9e7dfc06/aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e", size = 111047, upload-time = "2021-12-27T20:28:17.557Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/a9/0da089c3ae7a31cbcd2dcf0214f6f571e1295d292b6139e2bac68ec081d0/aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6", size = 71243, upload-time = "2021-12-27T20:28:16.36Z" }, +] + +[[package]] +name = "aiosmtplib" +version = "3.0.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/2a/812517f8350cd317aad2ba1ce25dfc213c6f1f2e62e1cbf662b4bdc51d34/aiosmtplib-3.0.2.tar.gz", hash = "sha256:08fd840f9dbc23258025dca229e8a8f04d2ccf3ecb1319585615bfc7933f7f47", size = 59941, upload-time = "2024-07-31T05:13:10.065Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/35/441faea7a11159795881a6ec869454f40269e4e3806dced935a35d83a412/aiosmtplib-3.0.2-py3-none-any.whl", hash = "sha256:8783059603a34834c7c90ca51103c3aa129d5922003b5ce98dbaa6d4440f10fc", size = 27111, upload-time = "2024-07-31T05:13:08.515Z" }, +] + [[package]] name = "aiosqlite" version = "0.17.0" @@ -249,6 +271,7 @@ source = { virtual = "." } dependencies = [ { name = "agno" }, { name = "aiofiles" }, + { name = "aiosmtplib" }, { name = "aiosqlite" }, { name = "alembic" }, { name = "apscheduler" }, @@ -259,9 +282,10 @@ dependencies = [ { name = "click" }, { name = "croniter" }, { name = "cryptography" }, - { name = "dependency-injector" }, { name = "fastapi" }, + { name = "fastapi-cache2", extra = ["redis"] }, { name = "fastapi-limiter" }, + { name = "fastapi-mail" }, { name = "greenlet" }, { name = "gunicorn" }, { name = "httpx" }, @@ -274,7 +298,6 @@ dependencies = [ { name = "passlib" }, { name = "pillow" }, { name = "prefect" }, - { name = "prometheus-fastapi-instrumentator" }, { name = "psutil" }, { name = "psycopg" }, { name = "psycopg-binary" }, @@ -288,6 +311,7 @@ dependencies = [ { name = "sqlalchemy" }, { name = "sqlglot", extra = ["rs"] }, { name = "structlog" }, + { name = "tinycss2" }, { name = "tomli", marker = "python_full_version < '3.15'" }, { name = "typer" }, { name = "user-agents" }, @@ -299,6 +323,7 @@ dependencies = [ dev = [ { name = "fakeredis" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -306,6 +331,7 @@ dev = [ requires-dist = [ { name = "agno", specifier = "==2.5.8" }, { name = "aiofiles", specifier = "==24.1.0" }, + { name = "aiosmtplib", specifier = ">=3.0.2" }, { name = "aiosqlite", specifier = "==0.17.0" }, { name = "alembic", specifier = "==1.15.1" }, { name = "apscheduler", specifier = "==3.11.0" }, @@ -316,9 +342,10 @@ requires-dist = [ { name = "click", specifier = "==8.1.7" }, { name = "croniter", specifier = ">=1.0.12,<5.0.0" }, { name = "cryptography", specifier = "==45.0.2" }, - { name = "dependency-injector", specifier = "==4.49.0" }, { name = "fastapi", specifier = "==0.115.2" }, + { name = "fastapi-cache2", extras = ["redis"], specifier = ">=0.1.8" }, { name = "fastapi-limiter", specifier = "==0.1.6" }, + { name = "fastapi-mail", specifier = "==1.5.1" }, { name = "greenlet", specifier = "==3.1.1" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "httpx", specifier = "==0.27.2" }, @@ -331,11 +358,10 @@ requires-dist = [ { name = "passlib", specifier = "==1.7.4" }, { name = "pillow", specifier = "==11.0.0" }, { name = "prefect", specifier = ">=3.5.0,<4" }, - { name = "prometheus-fastapi-instrumentator", specifier = "==7.1.0" }, { name = "psutil", specifier = "==6.1.0" }, { name = "psycopg", specifier = "==3.3.2" }, { name = "psycopg-binary", specifier = "==3.3.2" }, - { name = "pydantic-settings", specifier = "==2.5.2" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "pydantic-validation-decorator", specifier = "==0.1.4" }, { name = "pyjwt", specifier = "==2.9.0" }, { name = "pymysql", specifier = "==1.1.2" }, @@ -345,6 +371,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==2.0.45" }, { name = "sqlglot", extras = ["rs"], specifier = "==27.8.0" }, { name = "structlog", specifier = "==25.5.0" }, + { name = "tinycss2", specifier = "==1.5.1" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, { name = "tomli", marker = "python_full_version < '3.15'", specifier = ">=2.0.1" }, { name = "typer", specifier = "==0.19.0" }, @@ -357,9 +384,19 @@ requires-dist = [ dev = [ { name = "fakeredis", specifier = ">=2.21.0,<3" }, { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "ruff", specifier = "==0.14.13" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "bcrypt" version = "4.0.1" @@ -400,6 +437,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "7.0.5" @@ -728,28 +774,6 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, ] -[[package]] -name = "dependency-injector" -version = "4.49.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/be/26bb530d06618fb0bb34244d46b0d0ccc53d0974e680d8653f1b1b313a0e/dependency_injector-4.49.0.tar.gz", hash = "sha256:17a04dbfaa8159f1dc068fc26bc2fa0af9774cdd87f99e3b61bd74c9e7171589", size = 1168930, upload-time = "2026-03-22T21:20:05.524Z" } -wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/5d/cc49fb34e0c03aa56d7583de00e2f8f5aa1b8a878b695e970dcdb751a477/dependency_injector-4.49.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:9690192fd5aed07f21dfdfae07696fef12c68bf98e4c0e1af8f8128b255a74a7", size = 1769395, upload-time = "2026-03-22T21:19:14.163Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/97/b3b144c96e1f7fff0a7e2e83eb0767bd23b6bacffd0ac8cff397d350e94d/dependency_injector-4.49.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f91f2a191bdb17bd3068f32fe65f04128bc162c6237ea554c117b303c22aaabb", size = 1852089, upload-time = "2026-03-22T21:19:16.354Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/e7/33061f427bcb56c8936d5db464d757d926bf752a874683fb64b2ee225463/dependency_injector-4.49.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:733c0d88b26be17a48e5741cc3e3956080112e40c07a38ff38e99dfa772f9772", size = 1765608, upload-time = "2026-03-22T21:19:19.217Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/4d/2751a6c055de4a200d65af297ecd926d6b6107f66f3849e8122928abf461/dependency_injector-4.49.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:45720b30a2a3df6e5e2320e242f6dd94540ba27c3da57cafdc37fdeec59d5ce3", size = 1746555, upload-time = "2026-03-22T21:19:21.231Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/6f/f74fee9629528f0879295b9f89a5c751d3ad931eca0c78407f715e5472a6/dependency_injector-4.49.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b5d2f1be2dc971db47b1305a83b5a8c24d0eba7fb4cea7845679f9c9f24a0a9", size = 1843223, upload-time = "2026-03-22T21:19:23.356Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/f0/45948c7c933f063039a44afb4bd61747a7bafd50693e6ccdc972fac0839c/dependency_injector-4.49.0-cp310-abi3-win32.whl", hash = "sha256:0593c8aaade651a5a88ff8ba1271a8364773e76d3aa2efbeacc3be4969cafd1c", size = 1546172, upload-time = "2026-03-22T21:19:25.392Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/b5/1d8e5627137cb9a6812ecaa468eaf39154f6605c5088da4749e5a8579483/dependency_injector-4.49.0-cp310-abi3-win_amd64.whl", hash = "sha256:fa4b587158b0d65a1f9681ca648da3f9bf90f312f68c2f2e73cc58296ec2bf45", size = 1674743, upload-time = "2026-03-22T21:19:27.018Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/35/ca21ab897fc193dcdbad1f856361e7614b8e2b69f9f9351e9a87a3c58e51/dependency_injector-4.49.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6c4b49df30f13f5e4361719b21c79445db11869a7a00d80a0486c03fd764ba8f", size = 1744444, upload-time = "2026-03-22T21:19:57.332Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/44/d108aeee8f2edd3e725ac0e32d16e4339a034a07da9ddaf07f772f425140/dependency_injector-4.49.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b0c637ba230e390631da13bb80c955a9f85487f78c9772c0f6a3b50bfbff3a6", size = 1822320, upload-time = "2026-03-22T21:19:59.334Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/32/6243ef32c384dda156b053c3df5c8b6c3ac42250ec089a09915f015d38a1/dependency_injector-4.49.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5857b2672512654110dd0371fa965b98255e2f0507dd4732a066767b72e23c4", size = 1741215, upload-time = "2026-03-22T21:20:01.523Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/52/a1957d4ef87a52c13f2b790c1cc5fae17eb385fbe2e978c7fd8c1ebb4ea9/dependency_injector-4.49.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8ffa2ac9297446f73bd28ada81aadf4494a52d869159d58923435bcd88b5ef60", size = 1652017, upload-time = "2026-03-22T21:20:03.653Z" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -867,6 +891,25 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/14/bbe7776356ef01f830f8085ca3ac2aea59c73727b6ffaa757abeb7d2900b/fastapi-0.115.2-py3-none-any.whl", hash = "sha256:61704c71286579cc5a598763905928f24ee98bfcc07aabe84cfefb98812bbc86", size = 94650, upload-time = "2024-10-12T10:06:28.501Z" }, ] +[[package]] +name = "fastapi-cache2" +version = "0.1.8" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "pendulum" }, + { name = "uvicorn" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/63/097bf906454b5020ec866bc63d6b150cb56bf59fb50a7aaa00da97c2eac4/fastapi-cache2-0.1.8.tar.gz", hash = "sha256:e074a6dad600f9a19f0e7111f58350d6cc7c17d27a0bad1f67336d178b19387b", size = 11783, upload-time = "2021-11-26T00:57:18.005Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/04/a576e5535bc535164edb34e7b2c284fa7790654a80cd3de2598eeba47531/fastapi_cache2-0.1.8-py3-none-any.whl", hash = "sha256:7cdd5037cdc0b10a99c0effcef30c42e212f4fc20db49a472dbf0d42174e955a", size = 18430, upload-time = "2021-11-26T00:57:16.857Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "aioredis" }, +] + [[package]] name = "fastapi-limiter" version = "0.1.6" @@ -880,6 +923,24 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/b5/6f6b4d18bee1cafc857eae12738b3a03b7d1102b833668be868938c57b9d/fastapi_limiter-0.1.6-py3-none-any.whl", hash = "sha256:2e53179a4208b8f2c8795e38bb001324d3dc37d2800ff49fd28ec5caabf7a240", size = 15829, upload-time = "2024-01-05T09:14:47.613Z" }, ] +[[package]] +name = "fastapi-mail" +version = "1.5.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "aiosmtplib" }, + { name = "blinker" }, + { name = "email-validator" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "starlette" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/9e/531e8d0ed10bb4928151a3d29269bda45ec9478d5f5b8ea49fd9ed6d585e/fastapi_mail-1.5.1.tar.gz", hash = "sha256:fe7c373f6d69fd60d673752dc4471edbcd551a6c966c7b3d69a7035a8be9ccb7", size = 13276, upload-time = "2025-10-16T11:07:30.194Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/06/e09c14cb0b884f86ecbaeaf60782a4133cf33569bff56249c0aa71c16fc5/fastapi_mail-1.5.1-py3-none-any.whl", hash = "sha256:1d6e82c3eb823bec858eacc5e9e7b9994f3d9c95917e776a75313ec767f10270", size = 15119, upload-time = "2025-10-16T11:07:28.854Z" }, +] + [[package]] name = "fsspec" version = "2026.2.0" @@ -1890,8 +1951,8 @@ name = "pendulum" version = "3.2.0" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } dependencies = [ - { name = "python-dateutil", marker = "python_full_version < '3.13'" }, - { name = "tzdata", marker = "python_full_version < '3.13'" }, + { name = "python-dateutil" }, + { name = "tzdata" }, ] sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } wheels = [ @@ -2099,24 +2160,11 @@ wheels = [ [[package]] name = "prometheus-client" -version = "0.24.1" +version = "0.22.1" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/cf/40dde0a2be27cc1eb41e333d1a674a74ce8b8b0457269cc640fd42b07cf7/prometheus_client-0.22.1.tar.gz", hash = "sha256:190f1331e783cf21eb60bca559354e0a4d4378facecf78f5428c39b675d20d28", size = 69746, upload-time = "2025-06-02T14:29:01.152Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, -] - -[[package]] -name = "prometheus-fastapi-instrumentator" -version = "7.1.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -dependencies = [ - { name = "prometheus-client" }, - { name = "starlette" }, -] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/6d/24d53033cf93826aa7857699a4450c1c67e5b9c710e925b1ed2b320c04df/prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e", size = 20220, upload-time = "2025-03-19T19:35:05.351Z" } -wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/72/0824c18f3bc75810f55dacc2dd933f6ec829771180245ae3cc976195dec0/prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9", size = 19296, upload-time = "2025-03-19T19:35:04.323Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/ae/ec06af4fe3ee72d16973474f122541746196aaa16cea6f66d18b963c6177/prometheus_client-0.22.1-py3-none-any.whl", hash = "sha256:cca895342e308174341b2cbf99a56bef291fbc0ef7b9e5412a0f26d653ba7094", size = 58694, upload-time = "2025-06-02T14:29:00.068Z" }, ] [[package]] @@ -2387,15 +2435,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.5.2" +version = "2.14.1" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/27/0bed9dd26b93328b60a1402febc780e7be72b42847fa8b5c94b7d0aeb6d1/pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0", size = 70938, upload-time = "2024-09-11T09:08:08.489Z" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/8d/29e82e333f32d9e2051c10764b906c2a6cd140992910b5f49762790911ba/pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907", size = 26864, upload-time = "2024-09-11T09:08:07.242Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] @@ -2482,6 +2531,20 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3282,6 +3345,18 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + [[package]] name = "toml" version = "0.10.2" diff --git a/docker/README.md b/docker/README.md index 7e80e824..05a762f0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -10,19 +10,21 @@ docker/ │ └── Dockerfile # 后端 Dockerfile(多阶段构建) ├── nginx/ # Nginx 配置 │ ├── nginx.conf # Nginx 配置文件 -│ ├── ssl/ # SSL 证书目录(需自行放置证书文件) -│ │ └── .gitkeep # 保留目录结构 -│ ├── web/ # 前端静态文件(放置 dist/ 后部署) -│ │ └── .gitkeep -│ ├── app/ # 移动端 H5 静态文件(放置 dist/ 后部署,按需启用) -│ │ └── .gitkeep -│ └── docs/ # 文档网站静态文件(放置 dist/ 后部署,按需启用) -│ └── .gitkeep -├── mysql/ # MySQL 持久化目录 -│ └── .gitkeep # 容器运行后自动产生数据文件 -├── redis/ # Redis 持久化目录 -│ └── .gitkeep # 容器运行后自动产生数据文件 +│ ├── ssl/ # SSL 证书目录(放置 server.key / server.pem) +│ │ ├── server.key # SSL 私钥 +│ │ └── server.pem # SSL 证书 +│ ├── web/ # 前端静态文件(构建后自动放置 dist/) +│ └── app/ + docs/ # 移动端 H5 / 文档站点(按需启用) +├── mysql/ # MySQL 持久化 & 初始化 +│ ├── init/ # 首次启动时执行的 SQL 脚本 +│ │ └── init.sql +│ └── data/ # 数据库数据文件(自动生成) +├── redis/ # Redis 持久化 & 配置 +│ ├── conf/ +│ │ └── redis.conf # Redis 持久化配置文件 +│ └── data/ # Redis 数据文件(自动生成) ├── docker-compose.yaml # Docker Compose 编排文件 +├── docker-compose-example.yaml # 编排文件示例 ├── .env # 环境变量配置文件(.gitignore 已排除) ├── .env.example # 环境变量示例文件 ├── env.sh # 环境变量加载脚本 @@ -37,33 +39,36 @@ docker/ - Docker (>= 20.10) - Docker Compose (v2,Docker 内置的 `docker compose` 命令) +- 如需构建前端:Node.js (>= 18) 和 npm / pnpm ### 环境配置 1. **配置环境变量** ```bash - cp .env.example .env - chmod 600 .env # 限制权限,防止其他用户读取密码 + cp docker/.env.example docker/.env + chmod 600 docker/.env # 限制权限,防止其他用户读取密码 ``` 主要配置项: - | 变量 | 说明 | - |------|------| - | `MYSQL_ROOT_PASSWORD` | MySQL 根密码 | - | `MYSQL_DATABASE` | 数据库名 | - | `MYSQL_USER` | 数据库用户 | - | `MYSQL_PASSWORD` | 数据库密码 | - | `REDIS_PASSWORD` | Redis 密码 | - | `BACKEND_PORT` | 后端服务端口 | - | `HTTP_PORT` | HTTP 端口 | - | `HTTPS_PORT` | HTTPS 端口 | + | 变量 | 必填 | 默认值 | 说明 | + |------|------|--------|------| + | `MYSQL_ROOT_PASSWORD` | 是 | - | MySQL root 密码 | + | `MYSQL_DATABASE` | 否 | `fastapiadmin` | 数据库名 | + | `MYSQL_USER` | 否 | `fastapiadmin` | 数据库用户 | + | `MYSQL_PASSWORD` | 是 | - | 数据库密码 | + | `REDIS_PASSWORD` | 是 | - | Redis 密码 | + | `BACKEND_PORT` | 否 | `8001` | 后端服务宿主机端口 | + | `HTTP_PORT` | 否 | `80` | HTTP 宿主机端口 | + | `HTTPS_PORT` | 否 | `443` | HTTPS 宿主机端口 | + | `DEPLOY_ENV` | 否 | `prod` | 部署环境(dev/prod) | + | `BACKEND_IMAGE_TAG` | 否 | `3.0.0` | 后端镜像标签 | + | `BUILD_WEB` | 否 | `false` | 部署时构建前端 Web | + | `NGINX_SERVER_NAME` | 否 | `service.fastapiadmin.com` | Nginx 域名 | 2. **SSL 证书配置**(可选,但生产环境必须) - 将 SSL 证书文件放在 `nginx/ssl/` 目录下: - ```bash # 自签名证书(测试用): openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ @@ -90,16 +95,18 @@ docker/ | 命令 | 说明 | |------|------| -| `./deploy.sh` | 完整部署流程 | +| `./deploy.sh` | 完整部署(跳过前端构建) | | `./deploy.sh start` | 启动所有容器 | | `./deploy.sh stop` | 停止所有容器 | | `./deploy.sh restart` | 重启所有容器 | | `./deploy.sh logs` | 查看所有容器日志 | | `./deploy.sh verify` | 验证部署状态 | -| `./deploy.sh clean` | 清理旧镜像 | -| `./deploy.sh --build-frontend` | 完整部署并构建前端 | +| `./deploy.sh clean` | 清理旧镜像和构建缓存 | +| `./deploy.sh --build-frontend` | 完整部署并构建前端(web / app / docs) | | `./deploy.sh --skip-frontend` | 完整部署并跳过前端构建(默认) | +或在 `.env` 中设置 `BUILD_WEB=true` 使部署脚本自动构建前端。 + ### 方式二:手动操作 ```bash @@ -121,6 +128,20 @@ docker compose down docker compose up -d --no-deps --build [service_name] ``` +## 访问信息 + +部署完成后,可以通过以下地址访问: + +| 服务 | 地址 | +|------|------| +| 前端 | `https://your-domain/web` | +| Swagger API 文档 | `https://your-domain/docs` | +| ReDoc API 文档 | `https://your-domain/redoc` | +| API 健康检查 | `https://your-domain/health` | +| 后台登录 | 默认账号 `admin`,密码 `123456` | + +> **注意**: `docs`(官网)和 `app`(移动端 H5)默认不参与部署,如需启用请取消 docker-compose.yaml 中相关卷挂载注释。 + ## 容器资源限制 | 服务 | CPU 限制 | 内存限制 | 内存预留 | @@ -132,18 +153,6 @@ docker compose up -d --no-deps --build [service_name] 如需调整,修改 `docker-compose.yaml` 中对应服务的 `deploy.resources` 字段。 -## 访问信息 - -部署完成后,可以通过以下地址访问: - -| 服务 | 地址 | -|------|------| -| 前端 | `https://域名(或 ip)/web` | -| API 文档 | `https://域名(或 ip)/api/v1/docs` | -| 登录信息 | 账号 `admin`,密码 `123456` | - -> **注意**: `docs` 和 `app` 目录默认不参与部署,如需启用请取消 docker-compose.yaml 中相关配置注释。 - ## 日志管理 ```bash @@ -159,16 +168,48 @@ docker compose logs -f [服务名] 各容器的日志会被 Docker 自动轮转,限制为每个容器最多保留 3 个日志文件,每个最大 10MB。可在 `docker-compose.yaml` 中调整 `logging` 配置。 +## 前端构建 + +构建前端有三种方式: + +1. **本地构建后手动部署**(推荐) + ```bash + cd frontend/web + npm install && npm run build + cp -r dist ../docker/nginx/web/ + ``` + +2. **使用部署脚本** + ```bash + ./deploy.sh --build-frontend + ``` + +3. **自动构建**:在 `.env` 中设置 + ```env + BUILD_WEB=true + # BUILD_APP=true + # BUILD_DOCS=true + ``` + +## 生产部署建议 + +1. **移除开发卷挂载**:生产环境下,在 `docker-compose.yaml` 中注释掉 `backend` 服务的 `volumes` 挂载,使用镜像内自带的代码 +2. **使用正规 SSL 证书**:将 CA 签发的证书替换 `nginx/ssl/` 下的文件 +3. **修改域名**:在 `nginx/nginx.conf` 和 `.env` 中修改 `server_name` +4. **关闭调试**:确保 `.env` 中 `DEPLOY_ENV=prod` +5. **限制数据库端口**:生产环境建议注释 MySQL/Redis 的 `ports` 映射,仅通过容器内部网络访问 + ## 常见问题 | 问题 | 解决方案 | |------|----------| | 环境变量文件不存在 | 部署脚本会自动从 `.env.example` 创建,或手动 `cp docker/.env.example docker/.env` | -| 前端构建失败 | 建议在本地构建前端,将 `dist` 目录上传到 `docker/nginx/` 对应目录 | +| 前端构建失败 | 建议在本地构建前端,将 `dist` 目录复制到 `docker/nginx/web/` 目录 | | 容器启动失败 | `cd docker && docker compose logs [服务名]` 查看具体错误 | | 数据库连接失败 | 确保 `.env` 中数据库配置正确,MySQL 容器正常运行 | | 端口冲突 | 修改 `.env` 中的端口配置,重新部署 | | MySQL 数据目录权限问题 | `sudo chown -R 999:999 docker/mysql/data && sudo chmod -R 755 docker/mysql/data` | +| 后端健康检查失败 | 确保 MySQL 和 Redis 容器已就绪,查看后端日志:`docker compose logs backend` | ## 安全建议 diff --git a/frontend/web/package.json b/frontend/web/package.json index b77ebbea..2ae4b9b2 100755 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -205,5 +205,12 @@ }, "author": "fastapiadmin <948080782@qq.com>", "license": "MIT", - "homepage": "https://gitee.com/fastapiadmin/FastapiAdmin" + "homepage": "https://gitee.com/fastapiadmin/FastapiAdmin", + "browserslist": [ + "Chrome >= 84", + "Firefox >= 83", + "Safari >= 14.1", + "Edge >= 84", + "not dead" + ] } diff --git a/frontend/web/public/group.jpg b/frontend/web/public/group.jpg index 3b66072c..f61331d6 100644 Binary files a/frontend/web/public/group.jpg and b/frontend/web/public/group.jpg differ diff --git a/frontend/web/src/__tests__/smoke.spec.ts b/frontend/web/src/__tests__/smoke.spec.ts new file mode 100644 index 00000000..eae80ace --- /dev/null +++ b/frontend/web/src/__tests__/smoke.spec.ts @@ -0,0 +1,34 @@ +/** + * 前端关键模块烟雾测试 + * 验证运行时枚举、类型定义的完整性和正确性。 + * + * 注意:const enum(ThemeMode/DeviceEnum/LayoutMode/ResultEnum 等) + * 在 TypeScript isolatedModules 模式下会被内联为常量,无法在运行时访问。 + * MenuTypeEnum 为常规 enum,下列代码生成枚举也为常规 enum。 + */ + +import { describe, it, expect } from "vitest"; + +// ══════════════════ 菜单类型枚举 ══════════════════ +describe("MenuTypeEnum — 菜单类型", () => { + it("should define 4 menu types with correct values", async () => { + const { MenuTypeEnum } = await import("@/enums/system/menu.enum"); + expect(MenuTypeEnum.CATALOG).toBe(1); + expect(MenuTypeEnum.MENU).toBe(2); + expect(MenuTypeEnum.BUTTON).toBe(3); + expect(MenuTypeEnum.EXTLINK).toBe(4); + }); +}); + +// ══════════════════ 代码生成枚举 ══════════════════ +describe("代码生成枚举 — 完整性", () => { + it("FormRuleType should be importable", async () => { + const mod = await import("@/enums/codegen/form.enum"); + expect(mod).toBeDefined(); + }); + + it("QueryRuleType should be importable", async () => { + const mod = await import("@/enums/codegen/query.enum"); + expect(mod).toBeDefined(); + }); +}); diff --git a/frontend/web/src/api/module_ai/chat.ts b/frontend/web/src/api/module_ai/chat.ts index 44acb482..545f0982 100644 --- a/frontend/web/src/api/module_ai/chat.ts +++ b/frontend/web/src/api/module_ai/chat.ts @@ -113,6 +113,7 @@ export interface AiChatResponse { name: string; arguments: Record{{ title }}
{{ subtitle }}
@@ -66,10 +66,12 @@ const props = withDefaults(defineProps订单 {{ orderNo }} 已完成支付
+¥{{ (amount / 100).toFixed(2) }}
+{{ error }}
+请使用{{ methodLabel }}扫码支付
+即将跳转{{ methodLabel }}支付...
+{{ item.description || "暂无描述" }}
+ + + + + +