commit aa593449bb4db8138ffdb99d99935e1a36551ffe
Author: 寒寒 <2596194220@qq.com>
Date: Sun Jun 7 02:05:48 2026 +0800
feat: implement third-party order sync
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fe18877
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+node_modules/
+
+.next/
+dist/
+coverage/
+.idea/
+
+.env
+.env.*
+!.env.example
+
+*.log
+.eslintcache
+.stylelintcache
+.DS_Store
+Thumbs.db
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..d0612ad
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npm run pre-commit
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..8f46dd2
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+registry=https://registry.npmmirror.com/
+
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..5892e28
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+node_modules
+.umi
+.umi-production
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..2b63da8
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,11 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "jsxSingleQuote": false,
+ "useTabs": false,
+ "trailingComma": "all",
+ "tabWidth": 2,
+ "proseWrap": "never",
+ "overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
+ "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
+}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..6c3b61d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,445 @@
+# Work Admin 商城后台项目说明
+
+本文是给后续开发者和大模型快速接手项目用的总览文档。进入仓库后应先读本文,再按需查看 `docs/README.md` 的第三方接口文档、`docs/database.md` 的数据库草案和 `docs/payment-integration.md` 的支付接入说明。
+
+# 开发规范
+
+- 进行最小颗粒的开发,每一个步骤开发完成请给我反馈。
+- 当前项目是中文项目,请尽量在前端项目中使用中文。
+- 所有的需要表格和查询的页面请参考`/list/search-table`的样式实现
+
+## 一句话目标
+
+建设一个商城系统订单与工单管理后台。系统既要兼容第三方课程/商品接口,也要支持平台自营分类和自营商品的售卖流程,并为后续支付能力预留完整订单与支付状态。
+
+## 当前进度
+
+已完成:
+
+- 工程初始化已完成,当前是 pnpm monorepo。
+- 前端位于 `packages/frontend`,基于 Next.js 16、React 18、Shadcn Admin 模板。
+- 后端位于 `packages/backend`,基于 NestJS 11,默认开发端口 `3001`。
+- 共享包位于 `packages/share`,用于沉淀前后端共用类型、常量和工具函数。
+- `docs/README.md` 已整理第三方接口文档。
+- `docs/database.md` 已记录数据库基础方向。
+- `docs/migrations` 中已有分类、课程、接口日志索引、课程价格和内容字段相关 SQL 草案。
+
+尚未完成:
+
+- 后端业务模块、TypeORM 实体、数据库连接、鉴权、第三方调用封装仍需实现。
+- 前端仍主要是 Shadcn Admin 模板页面,业务菜单和业务页面仍需替换/新增。
+- 登录、权限、商品管理、下单、订单管理、工单、日志、支付均未形成完整业务闭环。
+- 当前迁移草案更偏第三方课程缓存,后续需要扩展为同时支持第三方商品和自营商品的统一商城模型。
+
+## 本地默认登录
+
+- 管理员账号:`root`
+- 管理员密码:`123456`
+- 该账号仅用于本地开发。生产环境上线前必须修改密码,并禁止在文档或日志中暴露真实密码。
+
+## 技术栈
+
+- 包管理器:`pnpm@10.33.0`
+- Monorepo:`pnpm-workspace.yaml`,workspace 范围为 `packages/*`
+- 前端:React、TypeScript、Shadcn Admin 、Tailwind CSS 可按实际使用保留
+- 后端:NestJS 11、TypeScript、MySQL、TypeORM
+- 参数校验:Zod 或 NestJS DTO 校验管道
+- 鉴权:NextAuth.js 或后端自建 Session/JWT,优先选择与 NestJS 后端一致的方案
+- 密码哈希:bcrypt 或 argon2
+- 密钥加密:Node crypto,用于第三方 `key` 等敏感信息
+- 后续异步任务:Redis + BullMQ,可用于订单同步、支付补偿、日志拉取等
+
+## 常用命令
+
+- `pnpm dev`:同时启动前端和后端开发服务。
+- `pnpm dev:frontend`:只启动前端,默认 `http://localhost:3000`。
+- `pnpm dev:backend`:只启动后端,默认 `http://localhost:3001`。
+- `pnpm lint`:运行前端、后端、共享模块的 ESLint 和 Stylelint。
+- `pnpm test`:运行 workspace 测试。
+- `pnpm build`:构建前端、后端和共享模块。
+
+## 核心业务原则
+
+### 统一商城模型
+
+本项目不是单纯的第三方课程管理后台,而是商城后台。商品来源需要统一建模:
+
+- `third_party`:第三方接口同步来的分类和商品。下单后需要由服务端调用第三方接口提交信息。
+- `self_owned`:平台自己新建的分类和商品。下单后只需要写入本地数据库,不调用第三方下单接口。
+
+建议分类、商品、订单都保留来源字段,例如:
+
+- `source_type`: `third_party` 或 `self_owned`
+- `external_id`: 第三方分类/商品/订单 ID,自营数据为空
+- `provider`: 第三方供应商标识,当前默认可用 `biedawo`
+- `fulfillment_type`: `third_party_api`、`local_only`、`manual`
+
+### 商品售卖流程
+
+第三方商品:
+
+1. 后台同步第三方分类:`act=getcate`。
+2. 后台同步第三方课程/商品:`act=getclass`。
+3. 用户/管理员输入学校、账号、密码、项目 ID 查课:`act=get`。
+4. 选择课程并创建订单。
+5. 如果订单已支付或后台允许先提交,则服务端调用第三方下单:`act=add`。
+6. 保存第三方请求、脱敏后的响应、第三方订单号和本地订单状态。
+
+自营商品:
+
+1. 后台手动创建分类和商品。
+2. 商品可配置价格、上下架状态、库存/限购、交付类型、售后规则等。
+3. 用户/管理员创建订单时只写入本地订单、订单明细和交付记录。
+4. 不调用第三方 `get`、`add`、`orders` 等接口。
+5. 后续可由后台人工处理、自动发货或扩展为其他自营交付流程。
+
+### 支付预留
+
+订单创建后应进入 `pending_payment`,支付成功后再进入后续履约流程:
+
+- 第三方商品:支付成功后调用第三方下单接口,成功后进入 `submitted` 或 `processing`。
+- 自营商品:支付成功后生成本地交付记录,进入 `paid`、`processing` 或 `completed`,具体取决于交付类型。
+
+支付接入详情见 `docs/payment-integration.md`。
+
+## 核心业务模块
+
+### 1. 用户与权限
+
+- 管理员登录。
+- 用户管理、用户注册。
+- 角色权限:超级管理员、普通用户。
+- 操作审计日志。
+- 登录日志。
+
+### 2. 商品分类与商品管理
+
+第三方分类和商品:
+
+- 同步第三方分类:`act=getcate`。
+- 同步第三方课程/商品:`act=getclass`。
+- 本地缓存第三方分类和商品。
+- 支持手动同步和刷新。
+- 第三方商品必须标记 `source_type=third_party`,并保存第三方项目 ID、课程 ID、原始关键信息或结构化字段。
+
+自营分类和商品:
+
+- 后台新建、编辑、删除/停用自营分类。
+- 后台新建、编辑、上下架自营商品。
+- 支持价格、商品描述、库存、排序、封面、售后说明、交付类型等字段。
+- 自营商品必须标记 `source_type=self_owned`。
+
+### 3. 第三方 API 账号与调用日志
+
+- 基础地址默认 `https://biedawo.org/api.php`。
+- 当前推荐通过 `.env` 配置 `WK_BASE_URL`、`WK_APP_UID`、`WK_APP_KEY`。
+- `WK_APP_KEY` 原样作为第三方 `key` 使用,前端和日志展示时必须脱敏。
+- 排查第三方请求参数时,可临时设置 `WK_DEBUG_LOG=true`,后端控制台会输出脱敏后的请求 URL、form body、响应状态和响应内容。
+- 支持接口连接测试。
+- 所有第三方调用都必须写入接口调用日志,包括 act、耗时、状态、失败原因、脱敏请求、脱敏响应。
+- 前端不得直接请求第三方 API。
+
+### 4. 查课与下单
+
+第三方商品查课:
+
+- 查课接口:`act=get`。
+- 输入学校、账号、密码、项目 ID。
+- 展示可下单课程。
+- 支持通用 `expand` 参数。
+- 支持恐龙项目 `expand.konglong` 参数。
+
+统一下单:
+
+- 创建本地订单时先识别商品来源。
+- 第三方商品按第三方流程提交。
+- 自营商品只写入本地数据库。
+- 下单请求与响应必须落库,敏感字段必须脱敏展示。
+- 下单失败时展示第三方返回信息或本地错误信息。
+
+### 5. 订单管理
+
+第三方订单相关接口:
+
+- 获取订单:`act=orders`
+- 查单:`act=chadan`
+- 补单:`act=budan`
+- 改密:`act=gaimi`
+- 暂停:`act=stop`
+- 优先学习:`act=priority`
+- 课程转换:`act=convert`
+- 修改时长:`act=update_time`
+- 修改周期:`act=update_cycle`
+
+功能:
+
+- 订单列表。
+- 订单详情。
+- 按订单 ID、账号、学校、课程、状态、来源、项目筛选。
+- 第三方订单支持查单刷新、补单、改密、暂停、优先学习、课程转换、修改时长、修改周期。
+- 自营订单支持本地发货、备注、关闭、退款标记、售后处理等本地操作。
+- 所有操作写入 `order_actions` 或统一审计日志。
+
+### 6. 日志管理
+
+第三方日志接口:
+
+- 恐龙 Stream 日志:`/api/streamLogs`
+- 普通日志:`act=cha_logwk`
+- zhs 明细:`act=cha_log`
+- 易教育学习记录:`act=get_yjy_study_log`
+
+功能:
+
+- 订单日志查询。
+- Stream 日志实时展示。
+- 日志内容格式化展示。
+- 日志查询历史记录。
+- 异常日志标记。
+
+### 7. 工单管理
+
+第三方工单接口:
+
+- 上传工单图片:`act=uploadTicketImage`
+- 提交工单:`act=submitWorkOrder`
+- 查询工单:`act=queryWorkOrder`
+
+功能:
+
+- 工单图片上传。
+- 附件预览。
+- 提交工单。
+- 查询工单状态。
+- 本地保存工单记录。
+- 工单状态映射展示。
+- 限制同一订单重复创建工单。
+- 自营订单也应支持本地工单,必要时不调用第三方工单接口。
+
+工单状态映射:
+
+| 状态值 | 状态文本 | 说明 |
+| --- | --- | --- |
+| 1 | 待处理 | 已创建,等待处理 |
+| 2 | 处理中 | 管理员处理中 |
+| 3 | 已回复 | 等待用户确认 |
+| 4 | 已解决 | 工单已解决 |
+| 5 | 已关闭 | 工单关闭 |
+| 6 | 已取消 | 工单取消 |
+
+### 8. 支付管理
+
+支付模块需要预留并逐步实现:
+
+- 支付渠道配置。
+- 创建支付单。
+- 支付回调验签。
+- 支付状态同步。
+- 退款申请和退款回调。
+- 支付日志和回调日志。
+- 支付成功后触发对应订单履约。
+
+详细接入文档见 `docs/payment-integration.md`。
+
+## 第三方接口摘要
+
+通用规则:
+
+- 除 `/api/streamLogs` 外,所有接口使用 `POST`。
+- 请求头使用 `Content-Type: application/x-www-form-urlencoded`。
+- 基础 URL 为 `https://biedawo.org/api.php`。
+- 常规响应格式为 JSON。
+- `/api/streamLogs` 为 GET 请求,响应为 Stream 格式。
+
+接口清单详见 `docs/README.md`。实现时不要把 `uid`、`key`、学生账号、学生密码暴露给前端。
+
+## 下单 expand 参数
+
+通用字段:
+
+```json
+{
+ "score": 95,
+ "duration": 35,
+ "period": 7,
+ "staticIds": [1, 2]
+}
+```
+
+恐龙项目字段:
+
+```json
+{
+ "konglong": {
+ "remark": ["urgent", "code_ready"],
+ "city": "beijing",
+ "tag": "vip",
+ "config": {
+ "useTime": 60,
+ "code": "888888"
+ }
+ }
+}
+```
+
+## 建议数据模型方向
+
+后续不要把第三方课程和自营商品拆成两套完全独立的业务主干。建议使用统一主表并通过来源字段分流:
+
+- `categories`:分类表,支持 `source_type`、`provider`、`external_id`。
+- `products` :商品表,支持第三方课程和自营商品。
+- `product_skus`:如后续商品存在规格、套餐、周期,可新增 SKU。
+- `orders`:订单主表,记录用户、金额、支付状态、履约状态、来源摘要。
+- `order_items`:订单明细,记录商品快照。
+- `order_fulfillments`:履约记录,区分第三方 API 提交和本地交付。
+- `payments`:支付单,记录支付渠道、支付金额、支付状态、渠道流水号。
+- `payment_events`:支付回调和主动查询日志。
+- `refunds`:退款单。
+- `api_call_logs`:第三方接口调用日志。
+- `audit_logs`:后台操作审计日志。
+
+## 开发阶段计划
+
+### 阶段 1:项目初始化
+
+状态:基本完成。
+
+- 建立 monorepo。
+- 建立前端、后端、共享包。
+- 保留 Shadcn Admin 后台模板。
+- 准备基础 lint、test、build 命令。
+
+验收标准:
+
+- 可以启动本地开发服务。
+- 可以访问登录页和后台首页。
+- 后端服务可以启动。
+
+### 阶段 2:数据库、鉴权与基础后台
+
+状态:待开发。
+
+- 接入 MySQL 和 TypeORM。
+- 建立用户、角色、登录日志、审计日志基础表。
+- 实现管理员登录。
+- 实现 Session/JWT 鉴权。
+- 未登录不能访问后台页面。
+
+建议:
+- 强制改密时自动跳转账号安全页
+- 权限配置更细的资源枚举
+- 日志导出
+
+### 阶段 3:第三方 API 封装与接口日志
+
+状态:待开发。
+
+- 封装第三方通用调用客户端。
+- 从环境变量读取 `WK_BASE_URL`、`WK_APP_UID`、`WK_APP_KEY`。
+- 实现连接测试。
+
+### 阶段 4:分类与商品
+
+- 同步第三方分类和商品。
+- 新建自营分类和商品。
+- 商品列表同时展示第三方商品和自营商品。
+- 商品来源、状态、价格、描述、排序可管理。
+
+验收标准:
+
+- 可以同步第三方分类数据。
+- 可以同步第三方商品数据。
+- 可以创建自营分类。
+- 可以创建自营商品。
+
+### 阶段 5:查课、购物车/下单与支付预留
+
+状态:待开发。
+
+- 第三方商品支持查课。
+- 统一创建本地订单。
+- 订单创建后默认待支付。
+- 支付能力先预留表结构、状态和接口边界。
+- 支付成功后按商品来源触发履约。
+
+验收标准:
+
+- 第三方商品可以查课后创建订单。
+- 自营商品可以直接创建本地订单。
+- 订单能区分 `pending_payment`、`paid`、`submitted` 等状态。
+
+### 阶段 6:订单管理与第三方订单操作
+
+状态:待开发。
+
+- 订单列表和详情页。
+- 第三方订单同步和查单刷新。
+- 第三方订单补单、改密、暂停、优先学习、课程转换、修改时长、修改周期。
+- 自营订单本地发货、关闭、备注、售后处理。
+- 所有操作写入操作记录。
+
+### 阶段 7:日志管理
+
+状态:待开发。
+
+- 普通日志查询。
+- zhs 明细查询。
+- 易教育学习记录查询。
+- `/api/streamLogs` 服务端代理。
+- 日志查看页面。
+
+### 阶段 8:工单管理
+
+状态:待开发。
+
+- 第三方工单图片上传。
+- 第三方工单提交和查询。
+- 自营本地工单。
+- 附件上传与预览。
+- 工单与本地订单关联。
+
+### 阶段 9:支付接入
+
+状态:预留,待开发。
+
+- 按 `docs/payment-integration.md` 实现支付渠道、支付单、回调、退款和补偿。
+- 支付成功后触发第三方下单或自营履约。
+- 支付失败、超时、重复回调、金额不一致都要有明确处理。
+
+### 阶段 10:安全、审计与体验优化
+
+状态:待开发。
+
+- 敏感字段脱敏显示。
+- 操作审计。
+- 接口错误统一处理。
+- 请求超时与重试策略。
+- 表格筛选、分页、批量操作。
+- 数据备份说明。
+- 生产部署说明。
+
+## 安全要求
+
+- 第三方 `key` 必须加密保存或只通过安全环境变量读取。
+- 学生密码、第三方密钥、支付密钥、请求原文中的敏感字段展示时必须脱敏。
+- 前端不得直接请求第三方 API。
+- 前端不得直接处理支付渠道私钥、商户密钥、第三方 API key。
+- 第三方 API 调用必须经过服务端封装。
+- 支付回调必须验签,并校验金额、订单号、支付状态。
+- 所有订单操作、工单操作、配置修改、支付状态变更必须写入审计或事件日志。
+- 上传文件需要限制类型和大小。
+
+## 当前最高优先级
+
+最小可行版本优先完成:
+
+1. 数据库连接、TypeORM 基础实体和迁移机制。
+2. 登录与权限。
+3. 第三方 API 封装与调用日志。
+4. 统一分类和商品模型,兼容第三方与自营。
+5. 第三方分类/商品同步。
+6. 自营分类/商品管理。
+7. 查课。
+8. 统一创建订单,并预留支付状态。
+9. 第三方商品支付后提交第三方订单,自营商品支付后写入本地履约。
+10. 订单列表与订单详情。
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c87e042
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
+
+[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
+
+The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000..7504072
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,59 @@
+
+
Arco Design Pro
+
+
+
+
+基于 [Arco Design](https://arco.design/) React 组件库的开箱即用的中后台前端解决方案。
+
+Admin 中后台管理页面,创新的多架构方案。
+
+[](https://github.com/arco-design/arco-design-pro/blob/main/LICENSE)
+
+
+
+
+
+[English](./README.md) | 简体中文
+
+
+
+
+
+## ✨ Features
+
+- **TypeScript** - 代码完全使用 TypeScript 书写
+- **Arco Design** - 由 [ArcoDesign React](https://github.com/arco-design/arco-design) 组件库强力驱动
+- **Templates** - 16+ 页面模版,覆盖表格、列表、表单、工作台、可视化等场景。
+- **Themes** - 基于「[风格配置平台](https://arco.design/themes)」丰富的主题市场,让你的项目千变万化。
+- **Dark Theme** - 一键丝滑切换暗黑风格
+- **Mock** - 内置 API 模拟方案
+- **Flexible** - 灵活的多架构方案,支持 [next.js](https://github.com/vercel/next.js) / [vite](https://github.com/vitejs/vite) / [cra](https://github.com/facebook/create-react-app) 等开发框架
+- **I18n** - 内置国际化多语言解决方案
+- **Config** - 灵活配置页面配色、布局等
+
+## 🔥 多架构方案
+
+本质上 Pro 项目是一套项目模版,市面上常见的中后台项目模版一般都有固定的选型和架构,这样用户如果想自己修改架构,成本会比较大。
+
+所以 Arco Pro v2 版本设计了一套多架构方案,能够在最大化的代码重用的基础上,输出多种架构的 pro 模版。
+
+
+
+## 🌈 Usage
+
+```bash
+$ npm i @arco-design/arco-cli@latest yarn -g
+
+$ arco init my-project
+```
+
+## 💎 Changelog
+
+- [中文版](https://github.com/arco-design/arco-design-pro/blob/main/docs/changelog.zh-CN.md)
+
+- [英文版](https://github.com/arco-design/arco-design-pro/blob/main/docs/changelog.md)
+
+## LICENSE
+
+[MIT](frontend/LICENSE) © [ArcoDesign](https://arco.design)
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..03584c2
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,415 @@
+## 通用说明
+
+- **请求方式**:所有接口(除 `/api/streamLogs` 外)均为 **POST**
+- **请求头**:必须添加
+ `Content-Type: application/x-www-form-urlencoded`
+- **基础 URL**:`https://biedawo.org/api.php`
+- **响应格式**:JSON(`/api/streamLogs` 除外,为 Stream 格式)
+
+## 1. 查课接口
+
+**URL**:`https://biedawo.org/api.php?act=get`
+
+| 参数 | 解释 | 必传 |
+| -------- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| platform | 项目 ID | ✅ |
+| user | 下单账号 | ✅ |
+| pass | 下单密码 | ✅ |
+| school | 用户学校 | ✅ |
+
+---
+
+## 2. 下单接口
+
+**URL**:`https://biedawo.org/api.php?act=add`
+
+| 参数 | 解释 | 必传 |
+| -------- | ------------------------------------------------------------------------------ | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| platform | 项目 ID | ✅ |
+| user | 下单账号 | ✅ |
+| pass | 下单密码 | ✅ |
+| school | 用户学校 | ✅ |
+| kcname | 课程名称 | ✅ |
+| kcid | 课程 ID | ❌ |
+| expand | 扩展参数(恐龙项目需使用 `expand.konglong` 传递 remark、city、tag、config 等) | ❌ |
+
+---
+
+## 3. 查单接口
+
+**URL**:`https://biedawo.org/api.php?act=chadan`
+
+| 参数 | 解释 | 必传 |
+| -------- | ----------------------------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID(与 username 二选一) | ✅ |
+| username | 下单账号(与 id 二选一) | ✅ |
+
+---
+
+## 4. 补单接口
+
+**URL**:`https://biedawo.org/api.php?act=budan`
+
+| 参数 | 解释 | 必传 |
+| ---- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+
+---
+
+## 5. 改密接口
+
+**URL**:`https://biedawo.org/api.php?act=gaimi`
+
+| 参数 | 解释 | 必传 |
+| --------- | ------------------------------------------ | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+| newPwd | 新密码 | ❌ |
+| remark | 恐龙项目备注(支持数组或英文逗号分隔) | ❌ |
+| city | 恐龙项目代理 IP 归属地 | ❌ |
+| tag | 恐龙项目归属标记 | ❌ |
+| config | 恐龙项目动态配置(支持数组或 JSON 字符串) | ❌ |
+| autoReset | 是否自动补单(传 `1` 开启) | ❌ |
+
+> 支持的恐龙项目包括:稳(奶昔)系、坤坤、龙猫、10u、10u 单视频/考试、图图 qg、66 冷门、少系、pup、叶族、spacex、黑白、至强、继续教育 1/2 号、3Y 继续教育、欲梦、优优、皇族、红杉、神奇等。
+
+**示例(恐龙项目)**:
+
+```json
+{
+ "uid": 1023,
+ "key": "JISADHG783J",
+ "id": "202604200001",
+ "password": "new_password",
+ "remark": ["urgent", "code_ready"],
+ "city": "beijing",
+ "tag": "vip",
+ "config": {
+ "useTime": 60,
+ "code": "888888"
+ },
+ "autoReset": 1
+}
+```
+
+---
+
+## 6. 暂停接口
+
+**URL**:`https://biedawo.org/api.php?act=stop`
+
+| 参数 | 解释 | 必传 |
+| ---- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+
+> 支持:坤坤、奶昔、3y、继续教育 2 号、八九、课代表、恐龙、神奇项目
+
+---
+
+## 7. 优先学习接口
+
+**URL**:`https://biedawo.org/api.php?act=priority`
+
+| 参数 | 解释 | 必传 |
+| ---- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+
+> 支持:坤坤项目
+
+---
+
+## 8. 课程转换接口
+
+**URL**:`https://biedawo.org/api.php?act=convert`
+
+| 参数 | 解释 | 必传 |
+| ---------------- | ----------------------------------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+| convertToClassId | 目标课程 ID(182:慢刷,183:秒刷) | ✅ |
+
+> 支持:奶昔项目
+
+---
+
+## 9. 修改时长接口
+
+**URL**:`https://biedawo.org/api.php?act=update_time`
+
+| 参数 | 解释 | 必传 |
+| ---- | ------------ | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+| time | 时长(小时) | ✅ |
+
+> 支持:pup 项目
+
+---
+
+## 10. 修改周期接口
+
+**URL**:`https://biedawo.org/api.php?act=update_cycle`
+
+| 参数 | 解释 | 必传 |
+| ----- | ---------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+| cycle | 周期(天) | ✅ |
+
+> 支持:pup 项目
+
+---
+
+## 11. 恐龙日志接口(Stream)
+
+**URL**:`https://biedawo.org/api/streamLogs`
+**请求方式**:GET
+
+| 参数 | 解释 | 必传 |
+| ---- | ------- | ---- |
+| id | 订单 ID | ✅ |
+
+> 响应为 Stream 格式,参考 [CSDN 文档](https://blog.csdn.net/qq_42978535/article/details/142670351)
+
+---
+
+## 12. 日志接口
+
+**URL**:`https://biedawo.org/api.php?act=cha_logwk`
+
+| 参数 | 解释 | 必传 |
+| ---- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+
+> 支持:坤系列、PUP 系列、至强系列、课代表系列、优优项目
+
+---
+
+## 13. zhs 明细接口
+
+**URL**:`https://biedawo.org/api.php?act=cha_log`
+
+| 参数 | 解释 | 必传 |
+| ---- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+
+> 支持:坤系列
+
+---
+
+## 14. 获取易教育学习记录接口
+
+**URL**:`https://biedawo.org/api.php?act=get_yjy_study_log`
+
+| 参数 | 解释 | 必传 |
+| ---- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+
+---
+
+## 15. 获取订单接口
+
+**URL**:`https://biedawo.org/api.php?act=orders`
+
+| 参数 | 解释 | 必传 |
+| ------ | -------------------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| page | 页数(默认 1) | ❌ |
+| limit | 每页条数(默认 100) | ❌ |
+| recent | 近几天订单(默认 5) | ❌ |
+
+> 节流说明:每天 7 点后仅返回近 5 天订单数据
+
+---
+
+## 16. 获取分类接口
+
+**URL**:`https://biedawo.org/api.php?act=getcate`
+
+| 参数 | 解释 | 必传 |
+| ---- | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+
+---
+
+## 17. 获取课程接口
+
+**URL**:`https://biedawo.org/api.php?act=getclass`
+
+| 参数 | 解释 | 必传 |
+| ------ | ------------------------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| fenlei | 分类 ID(为空则返回所有) | ❌ |
+
+---
+
+## 18. 上传工单图片接口
+
+**URL**:`https://biedawo.org/api.php?act=uploadTicketImage`
+
+| 参数 | 解释 | 必传 |
+| ---- | ------------------------------------------------------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| file | 图片文件(jpg/png/gif/webp,≤5MB,multipart/form-data) | ✅ |
+
+**成功返回示例**:
+
+```json
+{
+ "code": 1,
+ "msg": "上传成功",
+ "data": {
+ "file_name": "abc123.jpg",
+ "file_path": "https://pan.pptvt.com/xxx/abc123.jpg",
+ "file_size": 12345,
+ "file_type": "jpg",
+ "mime_type": "image/jpeg",
+ "hash": "xxx",
+ "downurl": "https://...",
+ "viewurl": "https://..."
+ }
+}
+```
+
+---
+
+## 19. 提交工单接口
+
+**URL**:`https://biedawo.org/api.php?act=submitWorkOrder`
+
+| 参数 | 解释 | 必传 |
+| ----------- | -------------------------------------------------------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| id | 订单 ID | ✅ |
+| type | 订单类型(100:网课,200:闪电闪动,300:运动世界,...) | ❌ |
+| title | 工单标题(默认“订单问题反馈”) | ❌ |
+| content | 工单内容 | ✅ |
+| attachments | 附件 JSON 数组(含 file_name、file_path 等) | ❌ |
+
+**成功返回示例**:
+
+```json
+{
+ "code": 1,
+ "msg": "添加成功",
+ "data": {
+ "workId": 123,
+ "ticket_no": "TK24010112001234"
+ }
+}
+```
+
+> 限制:用户总充值需 ≥100 元,同一订单只能创建一个工单。
+
+---
+
+## 20. 查询工单接口
+
+**URL**:`https://biedawo.org/api.php?act=queryWorkOrder`
+
+| 参数 | 解释 | 必传 |
+| ------ | -------- | ---- |
+| uid | 您的 UID | ✅ |
+| key | 您的 KEY | ✅ |
+| workId | 工单 ID | ✅ |
+
+**状态说明**:
+
+| 状态值 | 状态文本 | 说明 |
+| ------ | -------- | ---------------- |
+| 1 | 待处理 | 已创建,等待处理 |
+| 2 | 处理中 | 管理员处理中 |
+| 3 | 已回复 | 等待用户确认 |
+| 4 | 已解决 | 工单已解决 |
+| 5 | 已关闭 | 工单关闭 |
+| 6 | 已取消 | 工单取消 |
+
+---
+
+## 附录:expand 参数说明(下单接口)
+
+### 通用字段(扁平结构)
+
+| 参数 | 解释 |
+| --------- | -------------------------------------------------------------------------------------------------------- |
+| score | 分数(如 95) |
+| duration | 时长 |
+| period | 周期 |
+| staticIds | 订单标识数组:1-班测不提交 2-秒刷 3-覆盖已作答 4-跳过已作答 5-不提交 6-强制提交 7-慢刷 8-不满分 9-仅必修 |
+
+### 恐龙项目专用(expand.konglong)
+
+| 参数 | 解释 |
+| --------------- | ----------------------------------- |
+| konglong.remark | 备注(数组,提交时转逗号拼接) |
+| konglong.city | 代理 IP 归属地 |
+| konglong.tag | 自定义标签 |
+| konglong.config | 动态配置对象(如 `{"useTime":60}`) |
+
+**通用示例**:
+
+```json
+{
+ "uid": 1023,
+ "key": "JISADHG783J",
+ "expand": {
+ "score": 95,
+ "duration": 35,
+ "period": 7,
+ "staticIds": [1, 2]
+ }
+}
+```
+
+**恐龙项目示例**:
+
+```json
+{
+ "uid": 1023,
+ "key": "JISADHG783J",
+ "platform": 12345,
+ "school": "demo_university",
+ "user": "student001",
+ "pass": "pwd123456",
+ "kcname": "course_name",
+ "kcid": "hash_value",
+ "expand": {
+ "konglong": {
+ "remark": ["urgent", "code_ready"],
+ "city": "beijing",
+ "tag": "vip",
+ "config": {
+ "useTime": 60,
+ "code": "888888"
+ }
+ }
+ }
+}
+```
diff --git a/docs/database.md b/docs/database.md
new file mode 100644
index 0000000..dd307b4
--- /dev/null
+++ b/docs/database.md
@@ -0,0 +1,52 @@
+# Database Draft
+
+第一阶段使用 TypeORM 描述 MySQL 数据模型。当前只保留管理后台的最小底座,并把第三方接口账号改为环境变量配置。
+
+后续数据库设计必须按商城模型扩展:第三方分类/商品和自营分类/商品使用统一业务主干,通过 `source_type`、`provider`、`external_id` 等字段区分来源。支付功能先按订单、支付单、支付事件和退款单预留结构,详细说明见 `docs/payment-integration.md`。
+
+## Initial Entities
+
+- `users`: 管理后台用户。
+- `roles`: 角色与权限集合。
+- `user_roles`: 用户角色关联表。
+- `api_call_logs`: 所有第三方接口调用日志。
+- `categories`: 商品分类。当前迁移草案偏第三方项目分类缓存,后续需要支持 `third_party` 和 `self_owned`。
+- `courses`: 第三方商品/课程缓存。后续可改造为统一 `products`,或保留表名但补齐商品来源、价格、库存、上下架和履约类型等字段。
+
+## Unified Commerce Model
+
+建议后续补齐或新建以下核心表:
+
+- `products`: 商品主表,统一承载第三方课程商品和自营商品。
+- `product_skus`: 商品规格/套餐表,后续存在周期、套餐、规格时使用。
+- `orders`: 订单主表,保存订单号、用户、金额、支付状态、履约状态。
+- `order_items`: 订单明细表,保存商品快照和下单扩展信息。
+- `order_fulfillments`: 履约记录表,区分第三方 API 提交、本地自动交付和人工处理。
+- `payments`: 支付单表,记录支付渠道、金额、渠道流水号和支付状态。
+- `payment_events`: 支付回调、主动查询、退款回调等事件日志。
+- `refunds`: 退款单表。
+- `audit_logs`: 后台操作审计日志。
+
+关键来源字段:
+
+- `source_type`: `third_party` 或 `self_owned`。
+- `provider`: 第三方供应商标识,当前第三方默认可用 `biedawo`,自营可为空。
+- `external_id`: 第三方分类、商品或订单 ID,自营数据为空。
+- `fulfillment_type`: `third_party_api`、`local_only`、`manual`。
+
+## Third-Party API Config
+
+当前不再维护渠道管理或多套接口账号配置。服务端统一读取:
+
+- `WK_BASE_URL`
+- `WK_APP_UID`
+- `WK_APP_KEY`
+
+`WK_APP_KEY` 原样作为第三方 `key` 使用,前端和日志展示时只允许脱敏显示。
+
+## Migration Policy
+
+- 不在运行时开启 `synchronize`。
+- 本地开发可以通过 TypeORM migration 生成 SQL。
+- 生产环境必须使用 migration 执行结构变更。
+- `.env` 不提交到仓库,参考 `.env.example` 创建本地配置。
diff --git a/docs/migrations/202606040001_phase3_courses.sql b/docs/migrations/202606040001_phase3_courses.sql
new file mode 100644
index 0000000..a945b2e
--- /dev/null
+++ b/docs/migrations/202606040001_phase3_courses.sql
@@ -0,0 +1,33 @@
+CREATE TABLE IF NOT EXISTS `categories` (
+ `id` varchar(36) NOT NULL,
+ `api_account_id` varchar(36) NOT NULL,
+ `remote_category_id` varchar(120) NOT NULL,
+ `name` varchar(160) NOT NULL,
+ `sort_order` int NOT NULL DEFAULT 0,
+ `raw_payload` json NULL,
+ `last_synced_at` datetime NOT NULL,
+ `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `IDX_categories_account_remote` (`api_account_id`, `remote_category_id`),
+ KEY `IDX_categories_account` (`api_account_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `courses` (
+ `id` varchar(36) NOT NULL,
+ `api_account_id` varchar(36) NOT NULL,
+ `category_id` varchar(36) NULL,
+ `remote_category_id` varchar(120) NOT NULL DEFAULT '',
+ `remote_course_id` varchar(160) NOT NULL,
+ `name` varchar(255) NOT NULL,
+ `is_favorite` tinyint NOT NULL DEFAULT 0,
+ `enabled` tinyint NOT NULL DEFAULT 1,
+ `raw_payload` json NULL,
+ `last_synced_at` datetime NOT NULL,
+ `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `IDX_courses_account_remote_category` (`api_account_id`, `remote_course_id`, `remote_category_id`),
+ KEY `IDX_courses_account` (`api_account_id`),
+ KEY `IDX_courses_category` (`category_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/docs/migrations/202606040002_add_course_content.sql b/docs/migrations/202606040002_add_course_content.sql
new file mode 100644
index 0000000..2bce82d
--- /dev/null
+++ b/docs/migrations/202606040002_add_course_content.sql
@@ -0,0 +1,7 @@
+ALTER TABLE `courses`
+ ADD COLUMN `content` text NULL AFTER `name`;
+
+UPDATE `courses`
+SET `content` = JSON_UNQUOTE(JSON_EXTRACT(`raw_payload`, '$.content'))
+WHERE `content` IS NULL
+ AND JSON_EXTRACT(`raw_payload`, '$.content') IS NOT NULL;
diff --git a/docs/migrations/202606040003_course_list_indexes.sql b/docs/migrations/202606040003_course_list_indexes.sql
new file mode 100644
index 0000000..26ed358
--- /dev/null
+++ b/docs/migrations/202606040003_course_list_indexes.sql
@@ -0,0 +1,5 @@
+CREATE INDEX `IDX_courses_account_favorite_updated`
+ ON `courses` (`api_account_id`, `is_favorite`, `updated_at`);
+
+CREATE INDEX `IDX_courses_account_category_favorite_updated`
+ ON `courses` (`api_account_id`, `category_id`, `is_favorite`, `updated_at`);
diff --git a/docs/migrations/202606040004_api_call_log_list_indexes.sql b/docs/migrations/202606040004_api_call_log_list_indexes.sql
new file mode 100644
index 0000000..61c0c7f
--- /dev/null
+++ b/docs/migrations/202606040004_api_call_log_list_indexes.sql
@@ -0,0 +1,5 @@
+CREATE INDEX `IDX_api_call_logs_created_at`
+ ON `api_call_logs` (`created_at`);
+
+CREATE INDEX `IDX_api_call_logs_act_created_at`
+ ON `api_call_logs` (`act`, `created_at`);
diff --git a/docs/migrations/202606040005_structured_course_products.sql b/docs/migrations/202606040005_structured_course_products.sql
new file mode 100644
index 0000000..22beb48
--- /dev/null
+++ b/docs/migrations/202606040005_structured_course_products.sql
@@ -0,0 +1,10 @@
+ALTER TABLE `courses`
+ ADD COLUMN `price` decimal(10,2) NOT NULL DEFAULT 0.00 AFTER `name`;
+
+UPDATE `courses`
+SET `price` = CAST(JSON_UNQUOTE(JSON_EXTRACT(`raw_payload`, '$.price')) AS DECIMAL(10,2))
+WHERE JSON_EXTRACT(`raw_payload`, '$.price') IS NOT NULL
+ AND JSON_UNQUOTE(JSON_EXTRACT(`raw_payload`, '$.price')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$';
+
+ALTER TABLE `courses`
+ DROP COLUMN `raw_payload`;
diff --git a/docs/payment-integration.md b/docs/payment-integration.md
new file mode 100644
index 0000000..a1e477b
--- /dev/null
+++ b/docs/payment-integration.md
@@ -0,0 +1,348 @@
+# 支付功能接入文档
+
+本文用于指导后续接入微信、支付宝或其他聚合支付渠道。当前项目尚未实现支付功能,开发时应先把订单、支付单、回调日志和履约流程的边界预留好,避免后续重构订单主链路。
+
+## 接入目标
+
+- 订单创建后先进入待支付状态。
+- 用户或管理员发起支付,后端创建支付单。
+- 支付渠道异步回调后,后端验签并更新支付状态。
+- 支付成功后触发订单履约:
+ - 第三方商品:服务端调用第三方下单接口。
+ - 自营商品:只写入本地数据库,生成本地交付记录。
+- 支持后续退款、支付状态主动查询、回调补偿和支付审计。
+
+## 推荐订单状态
+
+订单主状态建议拆成支付状态和履约状态,不要只用一个 `status` 混合表达全部含义。
+
+支付状态 `payment_status`:
+
+| 状态 | 说明 |
+| --- | --- |
+| `unpaid` | 未支付,订单刚创建 |
+| `paying` | 已创建支付单,等待渠道结果 |
+| `paid` | 已支付成功 |
+| `pay_failed` | 支付失败 |
+| `closed` | 支付超时或订单关闭 |
+| `refunding` | 退款中 |
+| `refunded` | 已退款 |
+| `partial_refunded` | 部分退款 |
+
+履约状态 `fulfillment_status`:
+
+| 状态 | 说明 |
+| --- | --- |
+| `pending` | 等待支付或等待履约 |
+| `submitting` | 正在提交第三方或生成本地交付 |
+| `submitted` | 已提交第三方或已生成交付 |
+| `processing` | 第三方或本地处理中 |
+| `completed` | 已完成 |
+| `failed` | 履约失败 |
+| `canceled` | 已取消 |
+
+## 推荐数据表
+
+### `orders`
+
+订单主表,保存业务订单。
+
+建议字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 本地订单 ID |
+| `order_no` | 本地订单号,展示和支付使用 |
+| `user_id` | 下单用户 |
+| `source_type` | `third_party` 或 `self_owned` |
+| `provider` | 第三方供应商标识,自营可为空 |
+| `total_amount` | 订单金额 |
+| `payable_amount` | 应付金额 |
+| `paid_amount` | 实付金额 |
+| `payment_status` | 支付状态 |
+| `fulfillment_status` | 履约状态 |
+| `remark` | 备注 |
+| `created_at` | 创建时间 |
+| `updated_at` | 更新时间 |
+
+### `order_items`
+
+订单明细表,保存商品快照,避免商品改价影响历史订单。
+
+建议字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 明细 ID |
+| `order_id` | 订单 ID |
+| `product_id` | 商品 ID |
+| `product_name` | 商品名称快照 |
+| `source_type` | 商品来源 |
+| `external_product_id` | 第三方商品 ID,自营为空 |
+| `unit_price` | 单价 |
+| `quantity` | 数量 |
+| `total_amount` | 明细金额 |
+| `payload` | 下单扩展信息,敏感字段入库前加密或脱敏 |
+
+### `payments`
+
+支付单表。一个订单可以有多次支付尝试,但同一时间只允许一个有效待支付支付单。
+
+建议字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 支付单 ID |
+| `payment_no` | 本地支付单号 |
+| `order_id` | 订单 ID |
+| `order_no` | 本地订单号冗余 |
+| `channel` | 支付渠道,如 `wechat`、`alipay`、`manual` |
+| `amount` | 支付金额 |
+| `currency` | 默认 `CNY` |
+| `status` | `created`、`paying`、`paid`、`failed`、`closed`、`refunded` |
+| `channel_trade_no` | 渠道交易号 |
+| `channel_payload` | 渠道创建支付返回,敏感字段脱敏 |
+| `paid_at` | 支付成功时间 |
+| `expired_at` | 支付过期时间 |
+| `created_at` | 创建时间 |
+| `updated_at` | 更新时间 |
+
+### `payment_events`
+
+支付事件表,保存支付回调、主动查询、退款回调等原始事件。
+
+建议字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 事件 ID |
+| `payment_id` | 支付单 ID |
+| `order_id` | 订单 ID |
+| `channel` | 支付渠道 |
+| `event_type` | `notify`、`query`、`refund_notify` |
+| `event_no` | 渠道事件号或渠道交易号,用于幂等 |
+| `status` | 处理状态 |
+| `raw_payload` | 原始回调内容,注意脱敏或加密 |
+| `verify_result` | 验签结果 |
+| `error_message` | 处理失败原因 |
+| `created_at` | 创建时间 |
+
+### `refunds`
+
+退款单表,后续退款功能使用。
+
+建议字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 退款单 ID |
+| `refund_no` | 本地退款单号 |
+| `payment_id` | 支付单 ID |
+| `order_id` | 订单 ID |
+| `amount` | 退款金额 |
+| `reason` | 退款原因 |
+| `status` | `created`、`processing`、`succeeded`、`failed` |
+| `channel_refund_no` | 渠道退款单号 |
+| `refunded_at` | 退款完成时间 |
+
+## 后端接口设计
+
+### 创建订单
+
+`POST /api/orders`
+
+职责:
+
+- 校验商品、价格、库存、上下架状态。
+- 创建 `orders` 和 `order_items`。
+- 设置 `payment_status=unpaid`。
+- 设置 `fulfillment_status=pending`。
+- 返回订单号和应付金额。
+
+注意:
+
+- 第三方商品在订单创建阶段不要把 `uid`、`key` 返回给前端。
+- 学生账号和密码等敏感信息必须由服务端保存,并在展示时脱敏。
+
+### 创建支付单
+
+`POST /api/payments`
+
+请求示例:
+
+```json
+{
+ "orderNo": "ORD202606060001",
+ "channel": "wechat",
+ "returnUrl": "https://example.com/orders/ORD202606060001"
+}
+```
+
+职责:
+
+- 校验订单存在且未支付。
+- 校验订单金额和当前商品金额是否允许支付。
+- 创建 `payments`。
+- 调用支付渠道统一下单接口。
+- 返回前端支付参数,例如二维码、跳转链接或小程序支付参数。
+
+### 支付回调
+
+`POST /api/payments/notify/:channel`
+
+职责:
+
+- 获取原始请求体。
+- 按渠道验签。
+- 写入 `payment_events`。
+- 校验本地支付单号、金额、币种、渠道交易号。
+- 幂等更新 `payments.status=paid`。
+- 幂等更新 `orders.payment_status=paid`。
+- 触发履约流程。
+- 返回渠道要求的成功响应。
+
+必须保证:
+
+- 重复回调不会重复提交第三方订单。
+- 金额不一致时不能标记为已支付。
+- 验签失败只记录事件,不更新支付成功。
+
+### 主动查询支付状态
+
+`GET /api/payments/:paymentNo/query`
+
+职责:
+
+- 调用渠道查询接口。
+- 写入 `payment_events`。
+- 如果渠道显示已支付,走同一套支付成功处理逻辑。
+
+### 申请退款
+
+`POST /api/refunds`
+
+职责:
+
+- 校验订单已支付且允许退款。
+- 创建 `refunds`。
+- 调用渠道退款接口。
+- 更新订单为 `refunding`。
+
+### 退款回调
+
+`POST /api/payments/refund-notify/:channel`
+
+职责:
+
+- 验签。
+- 写入 `payment_events`。
+- 幂等更新退款单和订单支付状态。
+
+## 支付成功后的履约流程
+
+支付成功处理函数建议抽成后端服务方法,例如:
+
+```ts
+async function handlePaymentSucceeded(paymentNo: string) {
+ // 1. 开启事务
+ // 2. 锁定 payment 和 order
+ // 3. 如果 payment/order 已处理过,直接返回
+ // 4. 标记 payment=paid、order.payment_status=paid
+ // 5. 根据 order.source_type 触发履约
+ // 6. 提交事务
+}
+```
+
+第三方商品:
+
+- 将订单履约状态改为 `submitting`。
+- 调用第三方 `act=add`。
+- 成功后保存第三方订单 ID 和响应摘要。
+- 将履约状态改为 `submitted` 或 `processing`。
+- 失败时改为 `failed`,保留失败原因,后台可手动重试。
+
+自营商品:
+
+- 将订单履约状态改为 `submitting`。
+- 生成本地交付记录或待处理任务。
+- 自动交付商品可直接改为 `completed`。
+- 人工处理商品改为 `processing`。
+
+## 幂等与事务
+
+必须使用幂等键:
+
+- 本地订单号 `order_no` 唯一。
+- 本地支付单号 `payment_no` 唯一。
+- 渠道交易号 `channel_trade_no` 唯一。
+- 支付事件 `event_no` 或原始回调哈希唯一。
+
+建议处理顺序:
+
+1. 回调进入后先验签。
+2. 写入 `payment_events`,重复事件直接返回成功。
+3. 开启数据库事务。
+4. 锁定支付单和订单。
+5. 校验金额和状态。
+6. 更新支付单和订单。
+7. 创建履约任务或直接履约。
+8. 提交事务。
+
+第三方下单如果耗时较长,建议支付事务内只创建 `order_fulfillments` 任务,事务外由 BullMQ 异步提交第三方,避免支付回调超时。
+
+## 环境变量建议
+
+```env
+PAYMENT_DEFAULT_CHANNEL=wechat
+
+WECHAT_PAY_MCH_ID=
+WECHAT_PAY_APP_ID=
+WECHAT_PAY_API_V3_KEY=
+WECHAT_PAY_PRIVATE_KEY=
+WECHAT_PAY_CERT_SERIAL_NO=
+WECHAT_PAY_NOTIFY_URL=
+
+ALIPAY_APP_ID=
+ALIPAY_PRIVATE_KEY=
+ALIPAY_PUBLIC_KEY=
+ALIPAY_NOTIFY_URL=
+ALIPAY_RETURN_URL=
+```
+
+生产环境要求:
+
+- 支付私钥和商户密钥不得提交仓库。
+- `.env` 不提交。
+- 私钥建议使用部署平台 Secret 或 KMS。
+- 回调地址必须是 HTTPS。
+
+## 前端页面建议
+
+- 订单确认页:展示商品、来源、价格、账号信息摘要、应付金额。
+- 支付页:展示支付方式、二维码/跳转按钮、倒计时、支付状态轮询。
+- 订单详情页:展示支付状态、履约状态、第三方提交状态或自营交付状态。
+- 后台支付管理:支付单列表、回调日志、主动查询、异常标记。
+- 后台退款管理:退款申请、退款结果、退款回调日志。
+
+## 测试清单
+
+- 创建订单后状态为 `unpaid` 和 `pending`。
+- 创建支付单后状态为 `paying`。
+- 正常回调可把支付单和订单标记为已支付。
+- 重复回调不会重复履约。
+- 金额不一致不会标记成功。
+- 验签失败不会更新订单。
+- 第三方商品支付成功后只提交一次第三方订单。
+- 自营商品支付成功后不调用第三方接口。
+- 支付超时后订单可关闭。
+- 退款成功后订单状态正确更新。
+
+## 与第三方商品的关系
+
+支付渠道和第三方课程接口是两套外部系统,不要混在一个服务里:
+
+- 支付服务只负责收钱、验签、退款和支付事件。
+- 第三方课程服务只负责 `getcate`、`getclass`、`get`、`add`、`orders` 等接口。
+- 订单服务负责协调支付成功后的履约分流。
+
+这样后续新增支付渠道或新增自营商品时,不需要改动第三方接口调用核心逻辑。
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..f9ed882
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,84 @@
+import js from '@eslint/js';
+import { createRequire } from 'node:module';
+import globals from 'globals';
+import tseslint from 'typescript-eslint';
+
+const frontendRequire = createRequire(
+ new URL('./packages/frontend/package.json', import.meta.url),
+);
+const reactHooks = frontendRequire('eslint-plugin-react-hooks');
+const reactRefreshModule = frontendRequire('eslint-plugin-react-refresh');
+const reactRefresh = reactRefreshModule.default ?? reactRefreshModule;
+
+export default tseslint.config(
+ {
+ ignores: [
+ '**/node_modules/**',
+ '**/.next/**',
+ '**/dist/**',
+ '**/coverage/**',
+ '**/*.d.ts',
+ ],
+ },
+ {
+ linterOptions: {
+ reportUnusedDisableDirectives: 'off',
+ },
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: [
+ 'packages/frontend/src/**/*.{ts,tsx,js,jsx}',
+ 'src/**/*.{ts,tsx,js,jsx}',
+ ],
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.es2021,
+ },
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ },
+ {
+ files: [
+ 'packages/backend/src/**/*.{ts,js}',
+ 'packages/share/src/**/*.{ts,js}',
+ ],
+ languageOptions: {
+ globals: {
+ ...globals.node,
+ ...globals.jest,
+ ...globals.es2023,
+ },
+ },
+ },
+ {
+ rules: {
+ '@typescript-eslint/ban-ts-comment': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/no-inferrable-types': 'off',
+ '@typescript-eslint/no-require-imports': 'off',
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ },
+ ],
+ 'no-console': 'off',
+ 'no-empty': ['error', { allowEmptyCatch: true }],
+ '@typescript-eslint/no-unused-expressions': 'off',
+ 'no-undef': 'off',
+ 'no-unused-vars': 'off',
+ },
+ },
+);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1eb774e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "study-work",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Study Work pnpm monorepo",
+ "scripts": {
+ "dev": "pnpm --filter @study-work/backend db:migrate && pnpm -r --parallel --stream --filter @study-work/frontend --filter @study-work/backend dev",
+ "dev:frontend": "pnpm --filter @study-work/frontend dev",
+ "dev:backend": "pnpm --filter @study-work/backend dev",
+ "build": "pnpm -r --if-present build",
+ "test": "pnpm -r --if-present test",
+ "test:basic": "pnpm --filter @study-work/backend exec jest --runInBand",
+ "lint": "pnpm lint:eslint && pnpm lint:stylelint",
+ "lint:eslint": "pnpm -r --if-present lint:eslint",
+ "lint:stylelint": "pnpm -r --if-present lint:stylelint",
+ "lint:frontend": "pnpm --filter @study-work/frontend lint",
+ "lint:backend": "pnpm --filter @study-work/backend lint",
+ "pre-commit": "pretty-quick --staged && pnpm lint",
+ "prepare": "husky install"
+ },
+ "packageManager": "pnpm@10.33.0",
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "eslint": "^10.4.1",
+ "globals": "^17.6.0",
+ "husky": "^7.0.2",
+ "postcss-less": "^5.0.0",
+ "prettier": "^2.4.1",
+ "pretty-quick": "^3.1.2",
+ "stylelint": "^17.12.0",
+ "stylelint-config-standard": "^40.0.0",
+ "typescript-eslint": "^8.60.1"
+ }
+}
diff --git a/packages/backend/.gitignore b/packages/backend/.gitignore
new file mode 100644
index 0000000..4b56acf
--- /dev/null
+++ b/packages/backend/.gitignore
@@ -0,0 +1,56 @@
+# compiled output
+/dist
+/node_modules
+/build
+
+# Logs
+logs
+*.log
+npm-debug.log*
+pnpm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# OS
+.DS_Store
+
+# Tests
+/coverage
+/.nyc_output
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# temp directory
+.temp
+.tmp
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
diff --git a/packages/backend/nest-cli.json b/packages/backend/nest-cli.json
new file mode 100644
index 0000000..f9aa683
--- /dev/null
+++ b/packages/backend/nest-cli.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "deleteOutDir": true
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
new file mode 100644
index 0000000..a169d3a
--- /dev/null
+++ b/packages/backend/package.json
@@ -0,0 +1,76 @@
+{
+ "name": "@study-work/backend",
+ "version": "0.0.1",
+ "description": "",
+ "author": "",
+ "private": true,
+ "license": "UNLICENSED",
+ "scripts": {
+ "build": "nest build",
+ "db:migrate": "node scripts/run-sql-migrations.js",
+ "dev": "nest start --watch",
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
+ "start": "nest start",
+ "start:dev": "nest start --watch",
+ "start:debug": "nest start --debug --watch",
+ "start:prod": "node dist/main",
+ "lint": "pnpm lint:eslint && pnpm lint:stylelint",
+ "lint:eslint": "eslint \"src/**/*.{ts,js}\" --cache",
+ "lint:stylelint": "stylelint \"src/**/*.{css,less,scss}\" --cache --allow-empty-input",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:cov": "jest --coverage",
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
+ "test:e2e": "jest --config ./test/jest-e2e.json"
+ },
+ "dependencies": {
+ "@nestjs/common": "^11.0.1",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.0.1",
+ "@nestjs/jwt": "^11.0.2",
+ "@nestjs/platform-express": "^11.0.1",
+ "@nestjs/schedule": "^6.1.3",
+ "@nestjs/typeorm": "^11.0.1",
+ "bcryptjs": "^3.0.3",
+ "mysql2": "^3.22.4",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.1",
+ "typeorm": "^0.3.30",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@nestjs/cli": "^11.0.0",
+ "@nestjs/schematics": "^11.0.0",
+ "@nestjs/testing": "^11.0.1",
+ "@types/express": "^5.0.0",
+ "@types/jest": "^30.0.0",
+ "@types/node": "^24.0.0",
+ "@types/supertest": "^7.0.0",
+ "jest": "^30.0.0",
+ "prettier": "^3.4.2",
+ "source-map-support": "^0.5.21",
+ "supertest": "^7.0.0",
+ "ts-jest": "^29.2.5",
+ "ts-loader": "^9.5.2",
+ "ts-node": "^10.9.2",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^5.7.3"
+ },
+ "jest": {
+ "moduleFileExtensions": [
+ "js",
+ "json",
+ "ts"
+ ],
+ "rootDir": "src",
+ "testRegex": ".*\\.spec\\.ts$|.*\\.module\\.spec\\.ts$",
+ "transform": {
+ "^.+\\.(t|j)s$": "ts-jest"
+ },
+ "collectCoverageFrom": [
+ "**/*.(t|j)s"
+ ],
+ "coverageDirectory": "../coverage",
+ "testEnvironment": "node"
+ }
+}
diff --git a/packages/backend/scripts/run-sql-migrations.js b/packages/backend/scripts/run-sql-migrations.js
new file mode 100644
index 0000000..10bf05f
--- /dev/null
+++ b/packages/backend/scripts/run-sql-migrations.js
@@ -0,0 +1,89 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+const fs = require('node:fs');
+const path = require('node:path');
+const mysql = require('mysql2/promise');
+
+function loadRootEnv() {
+ const envPath = path.resolve(__dirname, '../../../.env');
+ if (!fs.existsSync(envPath)) {
+ return;
+ }
+
+ const content = fs.readFileSync(envPath, 'utf8');
+ for (const line of content.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) {
+ continue;
+ }
+ const separatorIndex = trimmed.indexOf('=');
+ if (separatorIndex === -1) {
+ continue;
+ }
+ const key = trimmed.slice(0, separatorIndex).trim();
+ const value = trimmed
+ .slice(separatorIndex + 1)
+ .trim()
+ .replace(/^"|"$/g, '');
+ process.env[key] ??= value;
+ }
+}
+
+async function main() {
+ loadRootEnv();
+
+ if (!process.env.DATABASE_URL) {
+ throw new Error('DATABASE_URL is required');
+ }
+
+ const connection = await mysql.createConnection(process.env.DATABASE_URL);
+ const migrationsDir = path.resolve(__dirname, '../src/database/migrations');
+ const files = fs
+ .readdirSync(migrationsDir)
+ .filter((file) => file.endsWith('.sql'))
+ .sort();
+
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version VARCHAR(255) NOT NULL PRIMARY KEY,
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+ `);
+
+ for (const file of files) {
+ const [rows] = await connection.query(
+ 'SELECT version FROM schema_migrations WHERE version = ?',
+ [file],
+ );
+ if (rows.length > 0) {
+ console.log(`skip ${file}`);
+ continue;
+ }
+
+ const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
+ await connection.beginTransaction();
+ try {
+ for (const statement of sql.split(/;\s*(?:\r?\n|$)/)) {
+ const trimmed = statement.trim();
+ if (trimmed) {
+ await connection.query(trimmed);
+ }
+ }
+ await connection.query(
+ 'INSERT INTO schema_migrations (version) VALUES (?)',
+ [file],
+ );
+ await connection.commit();
+ console.log(`applied ${file}`);
+ } catch (error) {
+ await connection.rollback();
+ throw error;
+ }
+ }
+
+ await connection.end();
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/packages/backend/src/admin/admin-access.ts b/packages/backend/src/admin/admin-access.ts
new file mode 100644
index 0000000..6b3f32c
--- /dev/null
+++ b/packages/backend/src/admin/admin-access.ts
@@ -0,0 +1,45 @@
+import { ForbiddenException } from '@nestjs/common';
+import { User } from '../users/entities/user.entity';
+
+export const ADMIN_ROLE = 'super_admin';
+export const AGENT_ROLE = 'agent';
+export const NORMAL_USER_ROLE = 'user';
+
+export function getRoleCodes(user: User) {
+ return (user.roles || []).map((role) => role.code);
+}
+
+export function isAdmin(user: User) {
+ return getRoleCodes(user).includes(ADMIN_ROLE);
+}
+
+export function isAgent(user: User) {
+ return getRoleCodes(user).includes(AGENT_ROLE);
+}
+
+export function assertCanManageUsers(user: User) {
+ if (!isAdmin(user) && !isAgent(user)) {
+ throw new ForbiddenException('No permission to manage users');
+ }
+}
+
+export function assertAdmin(user: User) {
+ if (!isAdmin(user)) {
+ throw new ForbiddenException('Only administrators can perform this action');
+ }
+}
+
+export function assertCanManageTarget(actor: User, target: User) {
+ if (isAdmin(actor)) {
+ return;
+ }
+
+ if (
+ isAgent(actor) &&
+ (target.parentId === actor.id || target.parentPath?.includes(`/${actor.id}/`))
+ ) {
+ return;
+ }
+
+ throw new ForbiddenException('No permission to manage this user');
+}
diff --git a/packages/backend/src/admin/admin-logs.controller.ts b/packages/backend/src/admin/admin-logs.controller.ts
new file mode 100644
index 0000000..d4ed29e
--- /dev/null
+++ b/packages/backend/src/admin/admin-logs.controller.ts
@@ -0,0 +1,18 @@
+import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import { AdminLogsService } from './admin-logs.service';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin')
+export class AdminLogsController {
+ constructor(private readonly adminLogsService: AdminLogsService) {}
+
+ @Get('audit-logs')
+ listAuditLogs(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: Record,
+ ) {
+ return this.adminLogsService.listAuditLogs(request.user, query);
+ }
+}
diff --git a/packages/backend/src/admin/admin-logs.service.ts b/packages/backend/src/admin/admin-logs.service.ts
new file mode 100644
index 0000000..b7bef3f
--- /dev/null
+++ b/packages/backend/src/admin/admin-logs.service.ts
@@ -0,0 +1,49 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { AuditLog } from '../audit/entities/audit-log.entity';
+import { User } from '../users/entities/user.entity';
+import { assertAdmin } from './admin-access';
+
+@Injectable()
+export class AdminLogsService {
+ constructor(
+ @InjectRepository(AuditLog)
+ private readonly auditLogsRepository: Repository,
+ ) {}
+
+ async listAuditLogs(actor: User, query: Record) {
+ assertAdmin(actor);
+ const page = Math.max(Number(query.page || 1), 1);
+ const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
+ const actorUsername = String(query.actorUsername || '').trim();
+ const action = String(query.action || '').trim();
+ const loginResult = String(query.loginResult || '').trim();
+
+ const builder = this.auditLogsRepository
+ .createQueryBuilder('log')
+ .orderBy('log.createdAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (actorUsername) {
+ builder.andWhere('log.actorUsername LIKE :actorUsername', {
+ actorUsername: `%${actorUsername}%`,
+ });
+ }
+
+ if (action) {
+ builder.andWhere('log.action LIKE :action', { action: `%${action}%` });
+ }
+
+ if (loginResult) {
+ builder.andWhere(
+ 'log.action = :loginAction AND JSON_UNQUOTE(JSON_EXTRACT(log.metadata, "$.loginResult")) = :loginResult',
+ { loginAction: 'auth.login', loginResult },
+ );
+ }
+
+ const [list, total] = await builder.getManyAndCount();
+ return { list, total, page, pageSize };
+ }
+}
diff --git a/packages/backend/src/admin/admin-permissions.controller.ts b/packages/backend/src/admin/admin-permissions.controller.ts
new file mode 100644
index 0000000..631e87b
--- /dev/null
+++ b/packages/backend/src/admin/admin-permissions.controller.ts
@@ -0,0 +1,36 @@
+import { Body, Controller, Get, Param, Patch, Req, UseGuards } from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import { AdminPermissionsService } from './admin-permissions.service';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin/permissions')
+export class AdminPermissionsController {
+ constructor(private readonly permissionsService: AdminPermissionsService) {}
+
+ @Get()
+ list(@Req() request: AuthenticatedRequest) {
+ return this.permissionsService.listPermissions(request.user);
+ }
+
+ @Patch('roles/:code')
+ @OperationLog({
+ action: 'admin.permissions.update_role',
+ resourceType: 'role',
+ resourceIdParam: 'code',
+ metadataFromBody: ['permissions'],
+ description: '更新角色权限',
+ })
+ updateRole(
+ @Req() request: AuthenticatedRequest,
+ @Param('code') code: string,
+ @Body() body: { permissions?: Record },
+ ) {
+ return this.permissionsService.updateRolePermissions(
+ request.user,
+ code,
+ body.permissions || {},
+ );
+ }
+}
diff --git a/packages/backend/src/admin/admin-permissions.service.ts b/packages/backend/src/admin/admin-permissions.service.ts
new file mode 100644
index 0000000..bdc15cd
--- /dev/null
+++ b/packages/backend/src/admin/admin-permissions.service.ts
@@ -0,0 +1,53 @@
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Role } from '../users/entities/role.entity';
+import { User } from '../users/entities/user.entity';
+import { assertAdmin } from './admin-access';
+import { normalizePermissions, permissionResources } from './permission-resources';
+
+@Injectable()
+export class AdminPermissionsService {
+ constructor(
+ @InjectRepository(Role)
+ private readonly rolesRepository: Repository,
+ ) {}
+
+ async listPermissions(actor: User) {
+ assertAdmin(actor);
+ const roles = await this.rolesRepository.find({ order: { code: 'ASC' } });
+ return {
+ resources: permissionResources,
+ roles: roles.map((role) => ({
+ code: role.code,
+ name: role.name,
+ permissions: role.permissions || {},
+ })),
+ };
+ }
+
+ async updateRolePermissions(
+ actor: User,
+ roleCode: string,
+ permissions: Record,
+ ) {
+ assertAdmin(actor);
+ const role = await this.rolesRepository.findOne({ where: { code: roleCode } });
+ if (!role) {
+ throw new BadRequestException('角色不存在');
+ }
+ if (role.code === 'super_admin') {
+ throw new BadRequestException('超级管理员权限不可修改');
+ }
+
+ const normalized = normalizePermissions(permissions);
+
+ role.permissions = normalized;
+ await this.rolesRepository.save(role);
+ return {
+ code: role.code,
+ name: role.name,
+ permissions: role.permissions,
+ };
+ }
+}
diff --git a/packages/backend/src/admin/admin-users.controller.ts b/packages/backend/src/admin/admin-users.controller.ts
new file mode 100644
index 0000000..996b80a
--- /dev/null
+++ b/packages/backend/src/admin/admin-users.controller.ts
@@ -0,0 +1,143 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ Patch,
+ Post,
+ Query,
+ Req,
+ Delete,
+ UseGuards,
+} from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import { AdminUsersService } from './admin-users.service';
+import type {
+ AssignUserRoleDto,
+ CreateAdminUserDto,
+ UpdateAdminUserDto,
+ UpdateUserEnabledDto,
+ UpdateUserPermissionsDto,
+} from './dto/admin-user.dto';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin')
+export class AdminUsersController {
+ constructor(private readonly adminUsersService: AdminUsersService) {}
+
+ @Get('roles')
+ listRoles(@Req() request: AuthenticatedRequest) {
+ return this.adminUsersService.listRoles(request.user);
+ }
+
+ @Get('users')
+ listUsers(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: Record,
+ ) {
+ return this.adminUsersService.listUsers(request.user, query);
+ }
+
+ @Post('users')
+ @OperationLog({
+ action: 'admin.users.create',
+ resourceType: 'user',
+ resourceIdFromResult: 'id',
+ metadataFromBody: ['name', 'email', 'roleCode', 'parentId', 'mustChangePassword'],
+ description: '新建用户',
+ })
+ createUser(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: CreateAdminUserDto,
+ ) {
+ return this.adminUsersService.createUser(request.user, dto);
+ }
+
+ @Patch('users/:id/enabled')
+ @OperationLog({
+ action: 'admin.users.update_enabled',
+ resourceType: 'user',
+ resourceIdParam: 'id',
+ metadataFromBody: ['enabled'],
+ description: '更新用户启停状态',
+ })
+ updateEnabled(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: UpdateUserEnabledDto,
+ ) {
+ return this.adminUsersService.updateEnabled(request.user, id, dto);
+ }
+
+ @Patch('users/:id')
+ @OperationLog({
+ action: 'admin.users.update',
+ resourceType: 'user',
+ resourceIdParam: 'id',
+ metadataFromBody: ['name', 'email', 'roleCode', 'enabled', 'mustChangePassword'],
+ description: '编辑用户信息',
+ })
+ updateUser(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: UpdateAdminUserDto,
+ ) {
+ return this.adminUsersService.updateUser(request.user, id, dto);
+ }
+
+ @Patch('users/:id/role')
+ @OperationLog({
+ action: 'admin.users.assign_role',
+ resourceType: 'user',
+ resourceIdParam: 'id',
+ metadataFromBody: ['roleCode'],
+ description: '调整用户角色',
+ })
+ assignRole(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: AssignUserRoleDto,
+ ) {
+ return this.adminUsersService.assignRole(request.user, id, dto);
+ }
+
+ @Get('users/:id/permissions')
+ getUserPermissions(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ ) {
+ return this.adminUsersService.getUserPermissions(request.user, id);
+ }
+
+ @Patch('users/:id/permissions')
+ @OperationLog({
+ action: 'admin.users.update_permissions',
+ resourceType: 'user',
+ resourceIdParam: 'id',
+ metadataFromBody: ['permissions'],
+ description: '设置用户权限',
+ })
+ updateUserPermissions(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: UpdateUserPermissionsDto,
+ ) {
+ return this.adminUsersService.updateUserPermissions(request.user, id, dto);
+ }
+
+ @Delete('users/:id/permissions')
+ @OperationLog({
+ action: 'admin.users.reset_permissions',
+ resourceType: 'user',
+ resourceIdParam: 'id',
+ description: '恢复用户默认权限',
+ })
+ resetUserPermissions(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ ) {
+ return this.adminUsersService.resetUserPermissions(request.user, id);
+ }
+}
diff --git a/packages/backend/src/admin/admin-users.service.ts b/packages/backend/src/admin/admin-users.service.ts
new file mode 100644
index 0000000..bec4e17
--- /dev/null
+++ b/packages/backend/src/admin/admin-users.service.ts
@@ -0,0 +1,357 @@
+import {
+ BadRequestException,
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import * as bcrypt from 'bcryptjs';
+import { randomUUID } from 'node:crypto';
+import { Brackets, Repository } from 'typeorm';
+import { Role } from '../users/entities/role.entity';
+import { User } from '../users/entities/user.entity';
+import {
+ AGENT_ROLE,
+ ADMIN_ROLE,
+ NORMAL_USER_ROLE,
+ assertCanManageTarget,
+ assertCanManageUsers,
+ isAdmin,
+ isAgent,
+} from './admin-access';
+import type {
+ AssignUserRoleDto,
+ CreateAdminUserDto,
+ UpdateAdminUserDto,
+ UpdateUserEnabledDto,
+ UpdateUserPermissionsDto,
+} from './dto/admin-user.dto';
+import { normalizePermissions, permissionResources } from './permission-resources';
+
+const MANAGEABLE_ROLE_CODES = [AGENT_ROLE, NORMAL_USER_ROLE];
+
+@Injectable()
+export class AdminUsersService {
+ constructor(
+ @InjectRepository(Role)
+ private readonly rolesRepository: Repository,
+ @InjectRepository(User)
+ private readonly usersRepository: Repository,
+ ) {}
+
+ async listUsers(actor: User, query: Record) {
+ assertCanManageUsers(actor);
+
+ const page = Math.max(Number(query.page || 1), 1);
+ const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
+ const keyword = String(query.keyword || '').trim();
+
+ const builder = this.usersRepository
+ .createQueryBuilder('user')
+ .leftJoinAndSelect('user.roles', 'role')
+ .orderBy('user.createdAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (isAgent(actor)) {
+ builder.andWhere(
+ '(user.parentId = :actorId OR user.parentPath LIKE :actorPath)',
+ {
+ actorId: actor.id,
+ actorPath: `%/${actor.id}/%`,
+ },
+ );
+ }
+
+ if (keyword) {
+ builder.andWhere(
+ new Brackets((qb) => {
+ qb.where('user.name LIKE :keyword', { keyword: `%${keyword}%` }).orWhere(
+ 'user.email LIKE :keyword',
+ { keyword: `%${keyword}%` },
+ );
+ }),
+ );
+ }
+
+ const [users, total] = await builder.getManyAndCount();
+
+ return {
+ list: users.map((user) => this.toUserItem(user)),
+ total,
+ page,
+ pageSize,
+ };
+ }
+
+ async listRoles(actor: User) {
+ assertCanManageUsers(actor);
+ const roles = await this.rolesRepository.find({
+ order: { code: 'ASC' },
+ });
+
+ const allowedCodes = isAdmin(actor)
+ ? [ADMIN_ROLE, AGENT_ROLE, NORMAL_USER_ROLE]
+ : MANAGEABLE_ROLE_CODES;
+
+ return roles
+ .filter((role) => allowedCodes.includes(role.code))
+ .map((role) => ({
+ code: role.code,
+ name: role.name,
+ }));
+ }
+
+ async createUser(actor: User, dto: CreateAdminUserDto) {
+ assertCanManageUsers(actor);
+
+ const name = String(dto.name || '').trim();
+ const email = String(dto.email || '').trim();
+ const password = String(dto.password || '');
+ const roleCode = String(dto.roleCode || NORMAL_USER_ROLE);
+
+ if (!name || !email || !password) {
+ throw new BadRequestException('name, email and password are required');
+ }
+
+ this.assertAssignableRole(actor, roleCode);
+
+ const existed = await this.usersRepository.findOne({ where: { email } });
+ if (existed) {
+ throw new BadRequestException('email already exists');
+ }
+
+ const parentId = isAdmin(actor) ? dto.parentId || null : actor.id;
+ let parentPath: string | null = null;
+ if (parentId) {
+ const parent = await this.usersRepository.findOne({ where: { id: parentId } });
+ if (!parent) {
+ throw new BadRequestException('parent user does not exist');
+ }
+ if (!isAdmin(actor) && !this.isDescendantOf(parent, actor)) {
+ throw new ForbiddenException('cannot create user under this parent');
+ }
+ parentPath = `${parent.parentPath || '/'}${parent.id}/`;
+ }
+
+ const role = await this.getRoleByCode(roleCode);
+ const user = this.usersRepository.create({
+ id: randomUUID(),
+ name,
+ email,
+ passwordHash: await bcrypt.hash(password, 10),
+ enabled: true,
+ parentId,
+ parentPath,
+ tokenVersion: 0,
+ mustChangePassword: dto.mustChangePassword ?? true,
+ roles: [role],
+ });
+
+ await this.usersRepository.save(user);
+
+ return this.toUserItem(user);
+ }
+
+ async updateEnabled(
+ actor: User,
+ userId: string,
+ dto: UpdateUserEnabledDto,
+ ) {
+ const user = await this.getManageableUser(actor, userId);
+ user.enabled = Boolean(dto.enabled);
+ await this.usersRepository.save(user);
+
+ return this.toUserItem(user);
+ }
+
+ async updateUser(
+ actor: User,
+ userId: string,
+ dto: UpdateAdminUserDto,
+ ) {
+ const user = await this.getManageableUser(actor, userId);
+ const name = String(dto.name ?? user.name).trim();
+ const email = String(dto.email ?? user.email).trim();
+ const roleCode = String(dto.roleCode || user.roles?.[0]?.code || '');
+
+ if (!name || !email) {
+ throw new BadRequestException('用户名和邮箱不能为空');
+ }
+
+ this.assertAssignableRole(actor, roleCode);
+
+ const existed = await this.usersRepository.findOne({ where: { email } });
+ if (existed && existed.id !== user.id) {
+ throw new BadRequestException('邮箱已被其他用户使用');
+ }
+
+ const role = await this.getRoleByCode(roleCode);
+ user.name = name;
+ user.email = email;
+ user.roles = [role];
+ if (typeof dto.enabled === 'boolean') {
+ user.enabled = dto.enabled;
+ }
+ if (typeof dto.mustChangePassword === 'boolean') {
+ user.mustChangePassword = dto.mustChangePassword;
+ }
+
+ await this.usersRepository.save(user);
+
+ return this.toUserItem(user);
+ }
+
+ async assignRole(
+ actor: User,
+ userId: string,
+ dto: AssignUserRoleDto,
+ ) {
+ const roleCode = String(dto.roleCode || '');
+ this.assertAssignableRole(actor, roleCode);
+
+ const user = await this.getManageableUser(actor, userId);
+ const role = await this.getRoleByCode(roleCode);
+ user.roles = [role];
+
+ await this.usersRepository.save(user);
+
+ return this.toUserItem(user);
+ }
+
+ async getUserPermissions(actor: User, userId: string) {
+ const user = await this.getManageableUser(actor, userId, { allowSelf: true });
+ const rolePermissions = this.getRolePermissions(user.roles || []);
+ const customPermissions = user.permissions ?? null;
+
+ return {
+ resources: permissionResources,
+ rolePermissions,
+ customPermissions,
+ effectivePermissions: customPermissions ?? rolePermissions,
+ isCustom: Boolean(customPermissions),
+ canEdit: isAdmin(actor),
+ };
+ }
+
+ async updateUserPermissions(
+ actor: User,
+ userId: string,
+ dto: UpdateUserPermissionsDto,
+ ) {
+ if (!isAdmin(actor)) {
+ throw new ForbiddenException('Only system administrators can set user permissions');
+ }
+
+ const user = await this.getManageableUser(actor, userId, { allowSelf: true });
+ user.permissions = normalizePermissions(dto.permissions || {});
+ await this.usersRepository.save(user);
+
+ return this.getUserPermissions(actor, user.id);
+ }
+
+ async resetUserPermissions(actor: User, userId: string) {
+ if (!isAdmin(actor)) {
+ throw new ForbiddenException('Only system administrators can set user permissions');
+ }
+
+ const user = await this.getManageableUser(actor, userId, { allowSelf: true });
+ user.permissions = null;
+ await this.usersRepository.save(user);
+
+ return this.getUserPermissions(actor, user.id);
+ }
+
+ private async getManageableUser(
+ actor: User,
+ userId: string,
+ options: { allowSelf?: boolean } = {},
+ ) {
+ assertCanManageUsers(actor);
+
+ const user = await this.usersRepository.findOne({ where: { id: userId } });
+ if (!user) {
+ throw new NotFoundException('user not found');
+ }
+
+ if (!options.allowSelf && user.id === actor.id) {
+ throw new ForbiddenException('cannot manage yourself here');
+ }
+
+ assertCanManageTarget(actor, user);
+ return user;
+ }
+
+ private assertAssignableRole(actor: User, roleCode: string) {
+ if (isAdmin(actor)) {
+ if (![ADMIN_ROLE, AGENT_ROLE, NORMAL_USER_ROLE].includes(roleCode)) {
+ throw new BadRequestException('invalid role');
+ }
+ return;
+ }
+
+ if (isAgent(actor) && MANAGEABLE_ROLE_CODES.includes(roleCode)) {
+ return;
+ }
+
+ throw new ForbiddenException('cannot assign this role');
+ }
+
+ private async getRoleByCode(code: string) {
+ const role = await this.rolesRepository.findOne({ where: { code } });
+ if (!role) {
+ throw new BadRequestException('role does not exist');
+ }
+
+ return role;
+ }
+
+ private toUserItem(user: User) {
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ enabled: user.enabled,
+ parentId: user.parentId,
+ parentPath: user.parentPath,
+ mustChangePassword: user.mustChangePassword,
+ roles: (user.roles || []).map((role) => ({
+ code: role.code,
+ name: role.name,
+ })),
+ roleCode: user.roles?.[0]?.code,
+ roleName: user.roles?.[0]?.name,
+ lastLoginAt: user.lastLoginAt,
+ createdAt: user.createdAt,
+ updatedAt: user.updatedAt,
+ };
+ }
+
+ private getRolePermissions(roles: Role[]) {
+ const permissions: Record = {};
+
+ for (const role of roles) {
+ const rolePermissions = role.permissions;
+ if (Array.isArray(rolePermissions)) {
+ if (rolePermissions.includes('*')) {
+ permissions['*'] = ['*'];
+ }
+ continue;
+ }
+
+ if (rolePermissions) {
+ for (const [resource, actions] of Object.entries(rolePermissions)) {
+ permissions[resource] = Array.from(
+ new Set([...(permissions[resource] || []), ...actions]),
+ );
+ }
+ }
+ }
+
+ return permissions;
+ }
+
+ private isDescendantOf(target: User, actor: User) {
+ return target.id === actor.id || Boolean(target.parentPath?.includes(`/${actor.id}/`));
+ }
+}
diff --git a/packages/backend/src/admin/admin.module.ts b/packages/backend/src/admin/admin.module.ts
new file mode 100644
index 0000000..acede2e
--- /dev/null
+++ b/packages/backend/src/admin/admin.module.ts
@@ -0,0 +1,28 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AuditModule } from '../audit/audit.module';
+import { AuditLog } from '../audit/entities/audit-log.entity';
+import { AuthModule } from '../auth/auth.module';
+import { Role } from '../users/entities/role.entity';
+import { User } from '../users/entities/user.entity';
+import { AdminLogsController } from './admin-logs.controller';
+import { AdminLogsService } from './admin-logs.service';
+import { AdminPermissionsController } from './admin-permissions.controller';
+import { AdminPermissionsService } from './admin-permissions.service';
+import { AdminUsersController } from './admin-users.controller';
+import { AdminUsersService } from './admin-users.service';
+
+@Module({
+ imports: [
+ AuditModule,
+ AuthModule,
+ TypeOrmModule.forFeature([AuditLog, Role, User]),
+ ],
+ controllers: [
+ AdminLogsController,
+ AdminPermissionsController,
+ AdminUsersController,
+ ],
+ providers: [AdminLogsService, AdminPermissionsService, AdminUsersService],
+})
+export class AdminModule {}
diff --git a/packages/backend/src/admin/dto/admin-user.dto.ts b/packages/backend/src/admin/dto/admin-user.dto.ts
new file mode 100644
index 0000000..982f85e
--- /dev/null
+++ b/packages/backend/src/admin/dto/admin-user.dto.ts
@@ -0,0 +1,28 @@
+export type CreateAdminUserDto = {
+ name?: string;
+ email?: string;
+ password?: string;
+ roleCode?: string;
+ parentId?: string | null;
+ mustChangePassword?: boolean;
+};
+
+export type UpdateUserEnabledDto = {
+ enabled?: boolean;
+};
+
+export type UpdateAdminUserDto = {
+ name?: string;
+ email?: string;
+ roleCode?: string;
+ enabled?: boolean;
+ mustChangePassword?: boolean;
+};
+
+export type AssignUserRoleDto = {
+ roleCode?: string;
+};
+
+export type UpdateUserPermissionsDto = {
+ permissions?: Record;
+};
diff --git a/packages/backend/src/admin/permission-resources.ts b/packages/backend/src/admin/permission-resources.ts
new file mode 100644
index 0000000..e64c4d0
--- /dev/null
+++ b/packages/backend/src/admin/permission-resources.ts
@@ -0,0 +1,37 @@
+export const permissionResources = [
+ { resource: 'catalog/categories', name: '分类管理', actions: ['read', 'write'] },
+ { resource: 'catalog/products', name: '商品管理', actions: ['read', 'write'] },
+ { resource: 'orders', name: '订单管理', actions: ['read', 'write'] },
+ { resource: 'order-logs', name: '日志查询', actions: ['read', 'write'] },
+ { resource: 'admin/users', name: '用户管理', actions: ['read', 'write'] },
+ { resource: 'admin/audit-logs', name: '操作日志', actions: ['read'] },
+ { resource: 'admin/api-call-logs', name: '接口日志', actions: ['read'] },
+ { resource: 'admin/third-party', name: '第三方接口', actions: ['read', 'write'] },
+ { resource: 'admin/permissions', name: '权限配置', actions: ['read', 'write'] },
+];
+
+export function normalizePermissions(permissions: Record) {
+ const allowedResources = new Set(
+ permissionResources.map((item) => item.resource),
+ );
+ const allowedActionsByResource = new Map(
+ permissionResources.map((item) => [item.resource, new Set(item.actions)]),
+ );
+ const normalized: Record = {};
+
+ for (const [resource, actions] of Object.entries(permissions || {})) {
+ if (!allowedResources.has(resource) || !Array.isArray(actions)) {
+ continue;
+ }
+
+ const allowedActions = allowedActionsByResource.get(resource);
+ const nextActions = Array.from(
+ new Set(actions.filter((action) => allowedActions?.has(action))),
+ );
+ if (nextActions.length) {
+ normalized[resource] = nextActions;
+ }
+ }
+
+ return normalized;
+}
diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts
new file mode 100644
index 0000000..44cf4df
--- /dev/null
+++ b/packages/backend/src/app.module.ts
@@ -0,0 +1,42 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { APP_INTERCEPTOR } from '@nestjs/core';
+import { ScheduleModule } from '@nestjs/schedule';
+import { AuditModule } from './audit/audit.module';
+import { OperationLogInterceptor } from './audit/operation-log.interceptor';
+import { validateEnv } from './config/env';
+import { packageEnvPath, rootEnvPath } from './config/paths';
+import { DatabaseModule } from './database/database.module';
+import { AuthModule } from './auth/auth.module';
+import { AdminModule } from './admin/admin.module';
+import { CatalogModule } from './catalog/catalog.module';
+import { OrderLogsModule } from './order-logs/order-logs.module';
+import { OrdersModule } from './orders/orders.module';
+import { ThirdPartyModule } from './third-party/third-party.module';
+
+@Module({
+ imports: [
+ ConfigModule.forRoot({
+ envFilePath: [rootEnvPath, packageEnvPath],
+ isGlobal: true,
+ validate: validateEnv,
+ }),
+ ScheduleModule.forRoot(),
+ DatabaseModule,
+ AuditModule,
+ AuthModule,
+ AdminModule,
+ ThirdPartyModule,
+ CatalogModule,
+ OrdersModule,
+ OrderLogsModule,
+ ],
+ controllers: [],
+ providers: [
+ {
+ provide: APP_INTERCEPTOR,
+ useClass: OperationLogInterceptor,
+ },
+ ],
+})
+export class AppModule {}
diff --git a/packages/backend/src/audit/audit.module.ts b/packages/backend/src/audit/audit.module.ts
new file mode 100644
index 0000000..1bffb53
--- /dev/null
+++ b/packages/backend/src/audit/audit.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AuditLog } from './entities/audit-log.entity';
+import { AuditService } from './audit.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([AuditLog])],
+ providers: [AuditService],
+ exports: [AuditService],
+})
+export class AuditModule {}
diff --git a/packages/backend/src/audit/audit.service.ts b/packages/backend/src/audit/audit.service.ts
new file mode 100644
index 0000000..effba50
--- /dev/null
+++ b/packages/backend/src/audit/audit.service.ts
@@ -0,0 +1,36 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { AuditLog } from './entities/audit-log.entity';
+
+type WriteAuditLogInput = {
+ actorId?: string | null;
+ actorUsername?: string | null;
+ action: string;
+ resourceType?: string | null;
+ resourceId?: string | null;
+ metadata?: Record | null;
+ ipAddress?: string | null;
+};
+
+@Injectable()
+export class AuditService {
+ constructor(
+ @InjectRepository(AuditLog)
+ private readonly auditLogsRepository: Repository,
+ ) {}
+
+ async write(input: WriteAuditLogInput) {
+ const entity = this.auditLogsRepository.create({
+ actorId: input.actorId ?? null,
+ actorUsername: input.actorUsername ?? null,
+ action: input.action,
+ resourceType: input.resourceType ?? null,
+ resourceId: input.resourceId ?? null,
+ metadata: input.metadata ?? null,
+ ipAddress: input.ipAddress ?? null,
+ });
+
+ await this.auditLogsRepository.save(entity);
+ }
+}
diff --git a/packages/backend/src/audit/entities/audit-log.entity.ts b/packages/backend/src/audit/entities/audit-log.entity.ts
new file mode 100644
index 0000000..9e3b949
--- /dev/null
+++ b/packages/backend/src/audit/entities/audit-log.entity.ts
@@ -0,0 +1,38 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryGeneratedColumn,
+} from 'typeorm';
+
+@Entity('audit_logs')
+export class AuditLog {
+ @PrimaryGeneratedColumn('increment', { type: 'bigint', unsigned: true })
+ id: string;
+
+ @Index()
+ @Column({ name: 'actor_id', type: 'varchar', length: 36, nullable: true })
+ actorId: string | null;
+
+ @Column({ name: 'actor_username', type: 'varchar', length: 64, nullable: true })
+ actorUsername: string | null;
+
+ @Column({ type: 'varchar', length: 64 })
+ action: string;
+
+ @Column({ name: 'resource_type', type: 'varchar', length: 64, nullable: true })
+ resourceType: string | null;
+
+ @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
+ resourceId: string | null;
+
+ @Column({ type: 'json', nullable: true })
+ metadata: Record | null;
+
+ @Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
+ ipAddress: string | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+ createdAt: Date;
+}
diff --git a/packages/backend/src/audit/operation-log.decorator.ts b/packages/backend/src/audit/operation-log.decorator.ts
new file mode 100644
index 0000000..31c16c6
--- /dev/null
+++ b/packages/backend/src/audit/operation-log.decorator.ts
@@ -0,0 +1,17 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const OPERATION_LOG_METADATA = Symbol('OPERATION_LOG_METADATA');
+
+export type OperationLogOptions = {
+ action: string;
+ resourceType?: string;
+ resourceIdParam?: string;
+ resourceIdFromResult?: string;
+ metadataFromBody?: string[];
+ metadataFromQuery?: string[];
+ description?: string;
+};
+
+export function OperationLog(options: OperationLogOptions) {
+ return SetMetadata(OPERATION_LOG_METADATA, options);
+}
diff --git a/packages/backend/src/audit/operation-log.interceptor.spec.ts b/packages/backend/src/audit/operation-log.interceptor.spec.ts
new file mode 100644
index 0000000..a9b101a
--- /dev/null
+++ b/packages/backend/src/audit/operation-log.interceptor.spec.ts
@@ -0,0 +1,111 @@
+import { CallHandler, ExecutionContext } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { lastValueFrom, of, throwError } from 'rxjs';
+import { AuditService } from './audit.service';
+import { OperationLogInterceptor } from './operation-log.interceptor';
+
+function createContext(request: Record) {
+ return {
+ getHandler: () => function handler() {},
+ getClass: () => class Controller {},
+ switchToHttp: () => ({
+ getRequest: () => request,
+ }),
+ } as unknown as ExecutionContext;
+}
+
+describe('OperationLogInterceptor', () => {
+ it('writes a success operation log with masked request body', async () => {
+ const write = jest.fn();
+ const reflector = {
+ getAllAndOverride: jest.fn().mockReturnValue({
+ action: 'admin.users.create',
+ resourceType: 'user',
+ resourceIdFromResult: 'id',
+ metadataFromBody: ['name', 'password'],
+ }),
+ } as unknown as Reflector;
+ const interceptor = new OperationLogInterceptor(
+ { write } as unknown as AuditService,
+ reflector,
+ );
+
+ const request = {
+ user: { id: 'actor-1', name: 'root' },
+ params: {},
+ query: {},
+ body: { name: 'demo', password: '123456' },
+ headers: { 'user-agent': 'vitest' },
+ ip: '127.0.0.1',
+ };
+ const next = {
+ handle: () => of({ id: 'user-1' }),
+ } as CallHandler;
+
+ await expect(lastValueFrom(interceptor.intercept(createContext(request), next))).resolves.toEqual({
+ id: 'user-1',
+ });
+
+ expect(write).toHaveBeenCalledWith(
+ expect.objectContaining({
+ actorId: 'actor-1',
+ actorUsername: 'root',
+ action: 'admin.users.create',
+ resourceType: 'user',
+ resourceId: 'user-1',
+ ipAddress: '127.0.0.1',
+ metadata: expect.objectContaining({
+ status: 'success',
+ request: expect.objectContaining({
+ body: { name: 'demo', password: '******' },
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('writes a failed operation log and rethrows the error', async () => {
+ const write = jest.fn();
+ const reflector = {
+ getAllAndOverride: jest.fn().mockReturnValue({
+ action: 'admin.users.update',
+ resourceType: 'user',
+ resourceIdParam: 'id',
+ }),
+ } as unknown as Reflector;
+ const interceptor = new OperationLogInterceptor(
+ { write } as unknown as AuditService,
+ reflector,
+ );
+ const error = new Error('保存失败');
+ const request = {
+ user: { id: 'actor-1', name: 'root' },
+ params: { id: 'user-1' },
+ query: {},
+ body: {},
+ headers: {},
+ ip: '127.0.0.1',
+ };
+ const next = {
+ handle: () => throwError(() => error),
+ } as CallHandler;
+
+ await expect(
+ lastValueFrom(interceptor.intercept(createContext(request), next)),
+ ).rejects.toThrow('保存失败');
+
+ expect(write).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'admin.users.update',
+ resourceId: 'user-1',
+ metadata: expect.objectContaining({
+ status: 'failed',
+ error: {
+ name: 'Error',
+ message: '保存失败',
+ },
+ }),
+ }),
+ );
+ });
+});
diff --git a/packages/backend/src/audit/operation-log.interceptor.ts b/packages/backend/src/audit/operation-log.interceptor.ts
new file mode 100644
index 0000000..93b58a0
--- /dev/null
+++ b/packages/backend/src/audit/operation-log.interceptor.ts
@@ -0,0 +1,213 @@
+import {
+ CallHandler,
+ ExecutionContext,
+ Injectable,
+ NestInterceptor,
+} from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import type { Request } from 'express';
+import { catchError, from, mergeMap, Observable, throwError } from 'rxjs';
+import { AuditService } from './audit.service';
+import {
+ OPERATION_LOG_METADATA,
+ type OperationLogOptions,
+} from './operation-log.decorator';
+
+const sensitiveKeys = [
+ 'password',
+ 'oldPassword',
+ 'newPassword',
+ 'passwordHash',
+ 'token',
+ 'key',
+ 'secret',
+ 'studentPassword',
+];
+
+type RequestWithUser = Request & {
+ user?: {
+ id?: string;
+ name?: string;
+ username?: string;
+ };
+};
+
+@Injectable()
+export class OperationLogInterceptor implements NestInterceptor {
+ constructor(
+ private readonly auditService: AuditService,
+ private readonly reflector: Reflector,
+ ) {}
+
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const options = this.reflector.getAllAndOverride(
+ OPERATION_LOG_METADATA,
+ [context.getHandler(), context.getClass()],
+ );
+
+ if (!options) {
+ return next.handle();
+ }
+
+ const startedAt = Date.now();
+ const request = context.switchToHttp().getRequest();
+
+ return next.handle().pipe(
+ mergeMap((result) =>
+ from(
+ this.writeLog({
+ options,
+ request,
+ result,
+ status: 'success',
+ durationMs: Date.now() - startedAt,
+ }),
+ ).pipe(mergeMap(() => [result])),
+ ),
+ catchError((error: unknown) =>
+ from(
+ this.writeLog({
+ options,
+ request,
+ status: 'failed',
+ durationMs: Date.now() - startedAt,
+ error,
+ }),
+ ).pipe(mergeMap(() => throwError(() => error))),
+ ),
+ );
+ }
+
+ private async writeLog(input: {
+ options: OperationLogOptions;
+ request: RequestWithUser;
+ result?: unknown;
+ status: 'success' | 'failed';
+ durationMs: number;
+ error?: unknown;
+ }) {
+ const { options, request, result, status, durationMs, error } = input;
+ const actor = request.user;
+
+ await this.auditService.write({
+ actorId: actor?.id ?? null,
+ actorUsername: actor?.name || actor?.username || null,
+ action: options.action,
+ resourceType: options.resourceType ?? null,
+ resourceId: this.getResourceId(options, request, result),
+ metadata: {
+ status,
+ description: options.description,
+ durationMs,
+ request: this.pickRequestMetadata(request, options),
+ error: error ? this.toErrorMetadata(error) : undefined,
+ },
+ ipAddress: this.getIp(request),
+ });
+ }
+
+ private getResourceId(
+ options: OperationLogOptions,
+ request: RequestWithUser,
+ result?: unknown,
+ ) {
+ if (options.resourceIdParam) {
+ return String(request.params?.[options.resourceIdParam] ?? '') || null;
+ }
+
+ if (options.resourceIdFromResult) {
+ const value = this.readPath(result, options.resourceIdFromResult);
+ return value == null ? null : String(value);
+ }
+
+ return null;
+ }
+
+ private pickRequestMetadata(
+ request: RequestWithUser,
+ options: OperationLogOptions,
+ ) {
+ return {
+ params: this.maskValue(request.params || {}),
+ query: this.pickAndMask(request.query, options.metadataFromQuery),
+ body: this.pickAndMask(request.body, options.metadataFromBody),
+ userAgent: request.headers['user-agent'] ?? null,
+ };
+ }
+
+ private pickAndMask(value: unknown, keys?: string[]) {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ const source = value as Record;
+ if (!keys?.length) {
+ return this.maskValue(source);
+ }
+
+ const picked: Record = {};
+ for (const key of keys) {
+ if (key in source) {
+ picked[key] = source[key];
+ }
+ }
+
+ return this.maskValue(picked);
+ }
+
+ private maskValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map((item) => this.maskValue(item));
+ }
+
+ if (!value || typeof value !== 'object') {
+ return value;
+ }
+
+ const masked: Record = {};
+ for (const [key, item] of Object.entries(value as Record)) {
+ if (this.isSensitiveKey(key)) {
+ masked[key] = '******';
+ } else {
+ masked[key] = this.maskValue(item);
+ }
+ }
+
+ return masked;
+ }
+
+ private isSensitiveKey(key: string) {
+ const normalized = key.toLowerCase();
+ return sensitiveKeys.some((item) => normalized.includes(item.toLowerCase()));
+ }
+
+ private toErrorMetadata(error: unknown) {
+ if (error instanceof Error) {
+ return {
+ name: error.name,
+ message: error.message,
+ };
+ }
+
+ return { message: String(error) };
+ }
+
+ private readPath(value: unknown, path: string) {
+ return path.split('.').reduce((current, key) => {
+ if (!current || typeof current !== 'object') {
+ return undefined;
+ }
+
+ return (current as Record)[key];
+ }, value);
+ }
+
+ private getIp(request: Request) {
+ const forwardedFor = request.headers['x-forwarded-for'];
+ if (typeof forwardedFor === 'string') {
+ return forwardedFor.split(',')[0]?.trim() || null;
+ }
+
+ return request.ip || request.socket.remoteAddress || null;
+ }
+}
diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts
new file mode 100644
index 0000000..dc13625
--- /dev/null
+++ b/packages/backend/src/auth/auth.controller.ts
@@ -0,0 +1,70 @@
+import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthService } from './auth.service';
+import { AuthGuard } from './auth.guard';
+import type { ChangePasswordDto, LoginDto, RegisterDto } from './dto/login.dto';
+import type { AuthenticatedRequest } from './types/authenticated-request';
+import type { Request } from 'express';
+
+@Controller('api/user')
+export class AuthController {
+ constructor(private readonly authService: AuthService) {}
+
+ @Post('login')
+ login(@Body() dto: LoginDto, @Req() request: Request) {
+ return this.authService.login(dto, request);
+ }
+
+ @Post('register')
+ @OperationLog({
+ action: 'auth.register',
+ resourceType: 'user',
+ resourceIdFromResult: 'user.id',
+ metadataFromBody: ['name', 'email'],
+ description: '注册用户',
+ })
+ register(@Body() dto: RegisterDto) {
+ return this.authService.register(dto);
+ }
+
+ @UseGuards(AuthGuard)
+ @Post('change-password')
+ @OperationLog({
+ action: 'auth.change_password',
+ resourceType: 'user',
+ resourceIdFromResult: 'user.id',
+ metadataFromBody: [],
+ description: '修改密码',
+ })
+ changePassword(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: ChangePasswordDto,
+ ) {
+ return this.authService.changePassword(request.user, dto);
+ }
+
+ @UseGuards(AuthGuard)
+ @Post('refresh-token')
+ refreshToken(@Req() request: AuthenticatedRequest) {
+ return this.authService.refreshToken(request.user);
+ }
+
+ @UseGuards(AuthGuard)
+ @Post('logout')
+ @OperationLog({
+ action: 'auth.logout',
+ resourceType: 'user',
+ resourceIdFromResult: 'id',
+ metadataFromBody: [],
+ description: '退出登录',
+ })
+ logout(@Req() request: AuthenticatedRequest) {
+ return this.authService.logout(request.user);
+ }
+
+ @UseGuards(AuthGuard)
+ @Get('userInfo')
+ userInfo(@Req() request: AuthenticatedRequest) {
+ return this.authService.toUserInfo(request.user);
+ }
+}
diff --git a/packages/backend/src/auth/auth.guard.ts b/packages/backend/src/auth/auth.guard.ts
new file mode 100644
index 0000000..e43990b
--- /dev/null
+++ b/packages/backend/src/auth/auth.guard.ts
@@ -0,0 +1,52 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import type { AuthenticatedRequest } from './types/authenticated-request';
+import { UsersService } from '../users/users.service';
+
+type JwtPayload = {
+ sub: string;
+ username: string;
+ tokenVersion?: number;
+};
+
+@Injectable()
+export class AuthGuard implements CanActivate {
+ constructor(
+ private readonly jwtService: JwtService,
+ private readonly usersService: UsersService,
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const request = context.switchToHttp().getRequest();
+ const token = this.extractToken(request);
+
+ if (!token) {
+ throw new UnauthorizedException('Missing authorization token');
+ }
+
+ try {
+ const payload = await this.jwtService.verifyAsync(token);
+ const user = await this.usersService.findById(payload.sub);
+ if (!user || !user.enabled) {
+ throw new UnauthorizedException('Invalid user');
+ }
+ if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
+ throw new UnauthorizedException('Expired authorization token');
+ }
+ request.user = user;
+ return true;
+ } catch {
+ throw new UnauthorizedException('Invalid authorization token');
+ }
+ }
+
+ private extractToken(request: AuthenticatedRequest) {
+ const [type, token] = request.headers.authorization?.split(' ') ?? [];
+ return type === 'Bearer' ? token : undefined;
+ }
+}
diff --git a/packages/backend/src/auth/auth.module.spec.ts b/packages/backend/src/auth/auth.module.spec.ts
new file mode 100644
index 0000000..021873b
--- /dev/null
+++ b/packages/backend/src/auth/auth.module.spec.ts
@@ -0,0 +1,39 @@
+import { Test } from '@nestjs/testing';
+import { JwtService } from '@nestjs/jwt';
+import { getRepositoryToken } from '@nestjs/typeorm';
+import { AuditService } from '../audit/audit.service';
+import { Role } from '../users/entities/role.entity';
+import { User } from '../users/entities/user.entity';
+import { UsersService } from '../users/users.service';
+import { AuthGuard } from './auth.guard';
+
+describe('AuthGuard provider wiring', () => {
+ it('resolves with JwtService and UsersService dependencies', async () => {
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ AuthGuard,
+ UsersService,
+ {
+ provide: JwtService,
+ useValue: {
+ verifyAsync: jest.fn(),
+ },
+ },
+ {
+ provide: getRepositoryToken(User),
+ useValue: {},
+ },
+ {
+ provide: getRepositoryToken(Role),
+ useValue: {},
+ },
+ {
+ provide: AuditService,
+ useValue: {},
+ },
+ ],
+ }).compile();
+
+ expect(moduleRef.get(AuthGuard)).toBeDefined();
+ });
+});
diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts
new file mode 100644
index 0000000..4a11c5b
--- /dev/null
+++ b/packages/backend/src/auth/auth.module.ts
@@ -0,0 +1,31 @@
+import { Module } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { JwtModule } from '@nestjs/jwt';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AuditModule } from '../audit/audit.module';
+import { UsersModule } from '../users/users.module';
+import { Role } from '../users/entities/role.entity';
+import { AuthController } from './auth.controller';
+import { AuthGuard } from './auth.guard';
+import { AuthService } from './auth.service';
+
+@Module({
+ imports: [
+ AuditModule,
+ UsersModule,
+ TypeOrmModule.forFeature([Role]),
+ JwtModule.registerAsync({
+ inject: [ConfigService],
+ useFactory: (configService: ConfigService) => ({
+ secret: configService.getOrThrow('AUTH_SECRET'),
+ signOptions: {
+ expiresIn: '8h',
+ },
+ }),
+ }),
+ ],
+ controllers: [AuthController],
+ providers: [AuthGuard, AuthService],
+ exports: [AuthGuard, JwtModule, UsersModule],
+})
+export class AuthModule {}
diff --git a/packages/backend/src/auth/auth.service.ts b/packages/backend/src/auth/auth.service.ts
new file mode 100644
index 0000000..08693ce
--- /dev/null
+++ b/packages/backend/src/auth/auth.service.ts
@@ -0,0 +1,213 @@
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { InjectRepository } from '@nestjs/typeorm';
+import * as bcrypt from 'bcryptjs';
+import type { Request } from 'express';
+import { randomUUID } from 'node:crypto';
+import { Repository } from 'typeorm';
+import { AuditService } from '../audit/audit.service';
+import { Role } from '../users/entities/role.entity';
+import { User } from '../users/entities/user.entity';
+import { UsersService } from '../users/users.service';
+import type { ChangePasswordDto, LoginDto, RegisterDto } from './dto/login.dto';
+
+type LoginResult = 'success' | 'failed';
+
+@Injectable()
+export class AuthService {
+ constructor(
+ private readonly auditService: AuditService,
+ private readonly jwtService: JwtService,
+ private readonly usersService: UsersService,
+ @InjectRepository(Role)
+ private readonly rolesRepository: Repository,
+ ) {}
+
+ async login(dto: LoginDto, request: Request) {
+ const loginName = (dto.userName || dto.username || '').trim();
+ const password = dto.password || '';
+
+ if (!loginName || !password) {
+ await this.writeLoginAudit(loginName, 'failed', request, 'empty credentials');
+ throw new BadRequestException('用户名或密码不能为空');
+ }
+
+ const user = await this.usersService.findByLoginName(loginName);
+ if (!user || !user.enabled) {
+ await this.writeLoginAudit(loginName, 'failed', request, 'user not found or disabled');
+ throw new BadRequestException('账号或密码错误');
+ }
+
+ const passwordMatched = await bcrypt.compare(password, user.passwordHash);
+ if (!passwordMatched) {
+ await this.writeLoginAudit(loginName, 'failed', request, 'bad password', user);
+ throw new BadRequestException('账号或密码错误');
+ }
+
+ await this.usersService.markLoginSuccess(user.id);
+ await this.writeLoginAudit(loginName, 'success', request, null, user);
+
+ return {
+ status: 'ok',
+ token: await this.signUserToken(user),
+ user: this.toUserInfo(user),
+ };
+ }
+
+ async register(dto: RegisterDto) {
+ const name = String(dto.name || '').trim();
+ const email = String(dto.email || '').trim();
+ const password = String(dto.password || '');
+
+ if (!name || !email || !password) {
+ throw new BadRequestException('用户名、邮箱和密码不能为空');
+ }
+ if (password.length < 6) {
+ throw new BadRequestException('密码长度不能少于 6 位');
+ }
+
+ const existed = await this.usersService.findByEmail(email);
+ if (existed) {
+ throw new BadRequestException('邮箱已被注册');
+ }
+
+ const normalRole = await this.rolesRepository.findOne({ where: { code: 'user' } });
+ const user = new User();
+ user.id = randomUUID();
+ user.name = name;
+ user.email = email;
+ user.passwordHash = await bcrypt.hash(password, 10);
+ user.enabled = true;
+ user.parentId = null;
+ user.parentPath = null;
+ user.tokenVersion = 0;
+ user.mustChangePassword = false;
+ user.roles = normalRole ? [normalRole] : [];
+
+ await this.usersService.save(user);
+
+ return {
+ status: 'ok',
+ token: await this.signUserToken(user),
+ user: this.toUserInfo(user),
+ };
+ }
+
+ async changePassword(user: User, dto: ChangePasswordDto) {
+ const oldPassword = String(dto.oldPassword || '');
+ const newPassword = String(dto.newPassword || '');
+ if (!oldPassword || !newPassword) {
+ throw new BadRequestException('原密码和新密码不能为空');
+ }
+ if (newPassword.length < 6) {
+ throw new BadRequestException('新密码长度不能少于 6 位');
+ }
+
+ const matched = await bcrypt.compare(oldPassword, user.passwordHash);
+ if (!matched) {
+ throw new BadRequestException('原密码不正确');
+ }
+
+ user.passwordHash = await bcrypt.hash(newPassword, 10);
+ user.mustChangePassword = false;
+ user.tokenVersion = (user.tokenVersion || 0) + 1;
+ await this.usersService.save(user);
+
+ return { status: 'ok', token: await this.signUserToken(user) };
+ }
+
+ async refreshToken(user: User) {
+ return {
+ status: 'ok',
+ token: await this.signUserToken(user),
+ user: this.toUserInfo(user),
+ };
+ }
+
+ async logout(user: User) {
+ await this.usersService.incrementTokenVersion(user.id);
+ return { status: 'ok' };
+ }
+
+ toUserInfo(user: User) {
+ const primaryRole = user.roles?.[0];
+
+ return {
+ id: user.id,
+ name: user.name,
+ username: user.name,
+ email: user.email,
+ role: primaryRole?.code,
+ roleName: primaryRole?.name,
+ mustChangePassword: user.mustChangePassword,
+ avatar:
+ 'https://lf1-xgcdn-tos.pstatp.com/obj/vcloud/vadmin/start.8e0e4855ee346a46ccff8ff3e24db27b.png',
+ permissions: user.permissions ?? this.getPermissions(user.roles || []),
+ };
+ }
+
+ private signUserToken(user: User) {
+ return this.jwtService.signAsync({
+ sub: user.id,
+ username: user.name,
+ tokenVersion: user.tokenVersion || 0,
+ });
+ }
+
+ private getPermissions(roles: Role[]) {
+ const permissions: Record = {};
+
+ for (const role of roles) {
+ const rolePermissions = role.permissions;
+ if (Array.isArray(rolePermissions)) {
+ if (rolePermissions.includes('*')) {
+ permissions['*'] = ['*'];
+ }
+ continue;
+ }
+
+ if (rolePermissions) {
+ for (const [resource, actions] of Object.entries(rolePermissions)) {
+ permissions[resource] = Array.from(
+ new Set([...(permissions[resource] || []), ...actions]),
+ );
+ }
+ }
+ }
+
+ return permissions;
+ }
+
+ private async writeLoginAudit(
+ username: string,
+ result: LoginResult,
+ request: Request,
+ failureReason: string | null,
+ user?: User,
+ ) {
+ const loginName = username || '(empty)';
+ await this.auditService.write({
+ actorId: user?.id ?? null,
+ actorUsername: user?.name ?? loginName,
+ action: 'auth.login',
+ resourceType: 'user',
+ resourceId: user?.id ?? null,
+ metadata: {
+ loginResult: result,
+ loginName,
+ failureReason,
+ userAgent: request.headers['user-agent'] ?? null,
+ },
+ ipAddress: this.getIp(request),
+ });
+ }
+
+ private getIp(request: Request) {
+ const forwardedFor = request.headers['x-forwarded-for'];
+ if (typeof forwardedFor === 'string') {
+ return forwardedFor.split(',')[0]?.trim() || null;
+ }
+
+ return request.ip || request.socket.remoteAddress || null;
+ }
+}
diff --git a/packages/backend/src/auth/dto/login.dto.ts b/packages/backend/src/auth/dto/login.dto.ts
new file mode 100644
index 0000000..080fc2c
--- /dev/null
+++ b/packages/backend/src/auth/dto/login.dto.ts
@@ -0,0 +1,16 @@
+export type LoginDto = {
+ userName?: string;
+ username?: string;
+ password?: string;
+};
+
+export type RegisterDto = {
+ name?: string;
+ email?: string;
+ password?: string;
+};
+
+export type ChangePasswordDto = {
+ oldPassword?: string;
+ newPassword?: string;
+};
diff --git a/packages/backend/src/auth/types/authenticated-request.ts b/packages/backend/src/auth/types/authenticated-request.ts
new file mode 100644
index 0000000..45d0268
--- /dev/null
+++ b/packages/backend/src/auth/types/authenticated-request.ts
@@ -0,0 +1,6 @@
+import { Request } from 'express';
+import { User } from '../../users/entities/user.entity';
+
+export type AuthenticatedRequest = Request & {
+ user: User;
+};
diff --git a/packages/backend/src/catalog/catalog.controller.ts b/packages/backend/src/catalog/catalog.controller.ts
new file mode 100644
index 0000000..84a3fc9
--- /dev/null
+++ b/packages/backend/src/catalog/catalog.controller.ts
@@ -0,0 +1,200 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Patch,
+ Post,
+ Query,
+ Req,
+ UseGuards,
+} from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import { CatalogService } from './catalog.service';
+import type {
+ CatalogListQuery,
+ CreateCatalogCategoryDto,
+ CreateCatalogProductDto,
+ UpdateCatalogCategoryDto,
+ UpdateCatalogProductDto,
+ UpdateEnabledDto,
+} from './dto/catalog.dto';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin/catalog')
+export class CatalogController {
+ constructor(private readonly catalogService: CatalogService) {}
+
+ @Get('categories')
+ listCategories(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: CatalogListQuery,
+ ) {
+ return this.catalogService.listCategories(request.user, query);
+ }
+
+ @Post('categories')
+ @OperationLog({
+ action: 'catalog.categories.create',
+ resourceType: 'category',
+ resourceIdFromResult: 'id',
+ metadataFromBody: ['name', 'sortOrder', 'enabled'],
+ description: '新建自营分类',
+ })
+ createCategory(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: CreateCatalogCategoryDto,
+ ) {
+ return this.catalogService.createCategory(request.user, dto);
+ }
+
+ @Patch('categories/:id')
+ @OperationLog({
+ action: 'catalog.categories.update',
+ resourceType: 'category',
+ resourceIdParam: 'id',
+ metadataFromBody: ['name', 'sortOrder', 'enabled'],
+ description: '编辑自营分类',
+ })
+ updateCategory(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: UpdateCatalogCategoryDto,
+ ) {
+ return this.catalogService.updateCategory(request.user, id, dto);
+ }
+
+ @Patch('categories/:id/enabled')
+ @OperationLog({
+ action: 'catalog.categories.update_enabled',
+ resourceType: 'category',
+ resourceIdParam: 'id',
+ metadataFromBody: ['enabled'],
+ description: '更新分类启停状态',
+ })
+ updateCategoryEnabled(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: UpdateEnabledDto,
+ ) {
+ return this.catalogService.updateCategoryEnabled(request.user, id, dto);
+ }
+
+ @Delete('categories/:id')
+ @OperationLog({
+ action: 'catalog.categories.delete',
+ resourceType: 'category',
+ resourceIdParam: 'id',
+ description: '删除自营分类',
+ })
+ deleteCategory(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ ) {
+ return this.catalogService.deleteCategory(request.user, id);
+ }
+
+ @Post('categories/sync-third-party')
+ @OperationLog({
+ action: 'catalog.categories.sync_third_party',
+ resourceType: 'category',
+ description: '同步第三方分类',
+ })
+ syncThirdPartyCategories(@Req() request: AuthenticatedRequest) {
+ return this.catalogService.syncThirdPartyCategories(request.user);
+ }
+
+ @Get('sync-jobs')
+ listSyncJobs(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: CatalogListQuery,
+ ) {
+ return this.catalogService.listSyncJobs(request.user, query);
+ }
+
+ @Get('products')
+ listProducts(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: CatalogListQuery,
+ ) {
+ return this.catalogService.listProducts(request.user, query);
+ }
+
+ @Post('products')
+ @OperationLog({
+ action: 'catalog.products.create',
+ resourceType: 'product',
+ resourceIdFromResult: 'id',
+ metadataFromBody: ['name', 'categoryId', 'price', 'fulfillmentType', 'enabled'],
+ description: '新建自营商品',
+ })
+ createProduct(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: CreateCatalogProductDto,
+ ) {
+ return this.catalogService.createProduct(request.user, dto);
+ }
+
+ @Patch('products/:id')
+ @OperationLog({
+ action: 'catalog.products.update',
+ resourceType: 'product',
+ resourceIdParam: 'id',
+ metadataFromBody: ['name', 'categoryId', 'price', 'fulfillmentType', 'enabled'],
+ description: '编辑自营商品',
+ })
+ updateProduct(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: UpdateCatalogProductDto,
+ ) {
+ return this.catalogService.updateProduct(request.user, id, dto);
+ }
+
+ @Patch('products/:id/enabled')
+ @OperationLog({
+ action: 'catalog.products.update_enabled',
+ resourceType: 'product',
+ resourceIdParam: 'id',
+ metadataFromBody: ['enabled'],
+ description: '更新商品启停状态',
+ })
+ updateProductEnabled(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ @Body() dto: UpdateEnabledDto,
+ ) {
+ return this.catalogService.updateProductEnabled(request.user, id, dto);
+ }
+
+ @Delete('products/:id')
+ @OperationLog({
+ action: 'catalog.products.delete',
+ resourceType: 'product',
+ resourceIdParam: 'id',
+ description: '删除商品',
+ })
+ deleteProduct(
+ @Req() request: AuthenticatedRequest,
+ @Param('id') id: string,
+ ) {
+ return this.catalogService.deleteProduct(request.user, id);
+ }
+
+ @Post('products/sync-third-party')
+ @OperationLog({
+ action: 'catalog.products.sync_third_party',
+ resourceType: 'product',
+ metadataFromQuery: ['remoteCategoryId'],
+ description: '同步第三方商品',
+ })
+ syncThirdPartyProducts(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: CatalogListQuery,
+ ) {
+ return this.catalogService.syncThirdPartyProducts(request.user, query);
+ }
+}
diff --git a/packages/backend/src/catalog/catalog.module.ts b/packages/backend/src/catalog/catalog.module.ts
new file mode 100644
index 0000000..747d51a
--- /dev/null
+++ b/packages/backend/src/catalog/catalog.module.ts
@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AuthModule } from '../auth/auth.module';
+import { Category } from '../third-party/entities/category.entity';
+import { Course } from '../third-party/entities/course.entity';
+import { ThirdPartyModule } from '../third-party/third-party.module';
+import { CatalogController } from './catalog.controller';
+import { CatalogService } from './catalog.service';
+
+@Module({
+ imports: [
+ AuthModule,
+ ThirdPartyModule,
+ TypeOrmModule.forFeature([Category, Course]),
+ ],
+ controllers: [CatalogController],
+ providers: [CatalogService],
+})
+export class CatalogModule {}
diff --git a/packages/backend/src/catalog/catalog.service.ts b/packages/backend/src/catalog/catalog.service.ts
new file mode 100644
index 0000000..f2b7dcf
--- /dev/null
+++ b/packages/backend/src/catalog/catalog.service.ts
@@ -0,0 +1,581 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { randomUUID } from 'node:crypto';
+import { Brackets, Repository } from 'typeorm';
+import { assertAdmin } from '../admin/admin-access';
+import { User } from '../users/entities/user.entity';
+import { Category, SourceType } from '../third-party/entities/category.entity';
+import {
+ Course,
+ OrderFormField,
+ OrderFormFieldType,
+} from '../third-party/entities/course.entity';
+import { ThirdPartyCatalogService } from '../third-party/third-party-catalog.service';
+import type {
+ CatalogListQuery,
+ CreateCatalogCategoryDto,
+ CreateCatalogProductDto,
+ FulfillmentType,
+ OrderFormFieldDto,
+ UpdateCatalogCategoryDto,
+ UpdateCatalogProductDto,
+ UpdateEnabledDto,
+} from './dto/catalog.dto';
+
+const SOURCE_ALL = 'all';
+const SELF_OWNED_SOURCE: SourceType = 'self_owned';
+const DEFAULT_SELF_FULFILLMENT: FulfillmentType = 'local_only';
+const FULFILLMENT_TYPES = new Set([
+ 'third_party_api',
+ 'local_only',
+ 'manual',
+]);
+const ORDER_FORM_FIELD_TYPES = new Set([
+ 'text',
+ 'password',
+ 'phone',
+ 'number',
+ 'textarea',
+ 'select',
+ 'checkbox',
+]);
+
+@Injectable()
+export class CatalogService {
+ constructor(
+ @InjectRepository(Category)
+ private readonly categoriesRepository: Repository,
+ @InjectRepository(Course)
+ private readonly productsRepository: Repository,
+ private readonly thirdPartyCatalogService: ThirdPartyCatalogService,
+ ) {}
+
+ async listCategories(actor: User, query: CatalogListQuery) {
+ assertAdmin(actor);
+ const { page, pageSize } = this.getPagination(query);
+ const keyword = String(query.keyword || '').trim();
+ const sourceType = this.normalizeSourceType(query.sourceType);
+ const enabled = this.normalizeEnabled(query.enabled);
+
+ const builder = this.categoriesRepository
+ .createQueryBuilder('category')
+ .orderBy('category.sortOrder', 'ASC')
+ .addOrderBy('category.updatedAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (sourceType) {
+ builder.andWhere('category.sourceType = :sourceType', { sourceType });
+ }
+ if (typeof enabled === 'boolean') {
+ builder.andWhere('category.enabled = :enabled', { enabled });
+ }
+ if (keyword) {
+ builder.andWhere(
+ new Brackets((qb) => {
+ qb.where('category.name LIKE :keyword', {
+ keyword: `%${keyword}%`,
+ })
+ .orWhere('category.externalId LIKE :keyword', {
+ keyword: `%${keyword}%`,
+ })
+ .orWhere('category.remoteCategoryId LIKE :keyword', {
+ keyword: `%${keyword}%`,
+ });
+ }),
+ );
+ }
+
+ const [list, total] = await builder.getManyAndCount();
+ return { list, total, page, pageSize };
+ }
+
+ async createCategory(actor: User, dto: CreateCatalogCategoryDto) {
+ assertAdmin(actor);
+ const name = this.normalizeRequiredString(dto.name, '分类名称不能为空');
+ const category = this.categoriesRepository.create({
+ id: randomUUID(),
+ sourceType: SELF_OWNED_SOURCE,
+ apiAccountId: null,
+ provider: null,
+ externalId: null,
+ remoteCategoryId: null,
+ name,
+ sortOrder: this.normalizeInteger(dto.sortOrder, 0),
+ enabled: dto.enabled ?? true,
+ rawPayload: null,
+ lastSyncedAt: null,
+ });
+
+ return this.categoriesRepository.save(category);
+ }
+
+ async updateCategory(
+ actor: User,
+ id: string,
+ dto: UpdateCatalogCategoryDto,
+ ) {
+ assertAdmin(actor);
+ const category = await this.getCategory(id);
+ this.assertSelfOwned(category, '第三方分类只能同步或停用,不能手工编辑');
+
+ if (dto.name !== undefined) {
+ category.name = this.normalizeRequiredString(dto.name, '分类名称不能为空');
+ }
+ if (dto.sortOrder !== undefined) {
+ category.sortOrder = this.normalizeInteger(dto.sortOrder, 0);
+ }
+ if (typeof dto.enabled === 'boolean') {
+ category.enabled = dto.enabled;
+ }
+
+ return this.categoriesRepository.save(category);
+ }
+
+ async updateCategoryEnabled(actor: User, id: string, dto: UpdateEnabledDto) {
+ assertAdmin(actor);
+ const category = await this.getCategory(id);
+ category.enabled = Boolean(dto.enabled);
+ return this.categoriesRepository.save(category);
+ }
+
+ async deleteCategory(actor: User, id: string) {
+ assertAdmin(actor);
+ const category = await this.getCategory(id);
+ this.assertSelfOwned(category, '第三方分类不能删除,请使用停用');
+
+ const productCount = await this.productsRepository.count({
+ where: { categoryId: category.id },
+ });
+ if (productCount > 0) {
+ throw new BadRequestException('分类下已有商品,不能删除');
+ }
+
+ await this.categoriesRepository.remove(category);
+ return { id, deleted: true };
+ }
+
+ async listProducts(actor: User, query: CatalogListQuery) {
+ assertAdmin(actor);
+ const { page, pageSize } = this.getPagination(query);
+ const keyword = String(query.keyword || '').trim();
+ const sourceType = this.normalizeSourceType(query.sourceType);
+ const enabled = this.normalizeEnabled(query.enabled);
+ const categoryId = String(query.categoryId || '').trim();
+ const remoteCategoryId = String(query.remoteCategoryId || '').trim();
+
+ const builder = this.productsRepository
+ .createQueryBuilder('product')
+ .orderBy('product.sortOrder', 'ASC')
+ .addOrderBy('product.updatedAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (sourceType) {
+ builder.andWhere('product.sourceType = :sourceType', { sourceType });
+ }
+ if (typeof enabled === 'boolean') {
+ builder.andWhere('product.enabled = :enabled', { enabled });
+ }
+ if (categoryId) {
+ builder.andWhere('product.categoryId = :categoryId', { categoryId });
+ }
+ if (remoteCategoryId) {
+ builder.andWhere('product.remoteCategoryId = :remoteCategoryId', {
+ remoteCategoryId,
+ });
+ }
+ if (keyword) {
+ builder.andWhere(
+ new Brackets((qb) => {
+ qb.where('product.name LIKE :keyword', {
+ keyword: `%${keyword}%`,
+ })
+ .orWhere('product.externalId LIKE :keyword', {
+ keyword: `%${keyword}%`,
+ })
+ .orWhere('product.remoteCourseId LIKE :keyword', {
+ keyword: `%${keyword}%`,
+ });
+ }),
+ );
+ }
+
+ const [products, total] = await builder.getManyAndCount();
+ const categoryMap = await this.getCategoryMap(products);
+ return {
+ list: products.map((product) => this.toProductItem(product, categoryMap)),
+ total,
+ page,
+ pageSize,
+ };
+ }
+
+ async createProduct(actor: User, dto: CreateCatalogProductDto) {
+ assertAdmin(actor);
+ const category = await this.getOptionalCategory(dto.categoryId);
+ if (category) {
+ this.assertSelfOwned(category, '自营商品只能选择自营分类');
+ }
+
+ const product = this.productsRepository.create({
+ id: randomUUID(),
+ sourceType: SELF_OWNED_SOURCE,
+ apiAccountId: null,
+ provider: null,
+ externalId: null,
+ categoryId: category?.id ?? null,
+ remoteCategoryId: null,
+ remoteCourseId: null,
+ name: this.normalizeRequiredString(dto.name, '商品名称不能为空'),
+ price: this.normalizePrice(dto.price),
+ coverUrl: this.normalizeOptionalString(dto.coverUrl),
+ content: this.normalizeOptionalString(dto.content),
+ stock: this.normalizeNullableInteger(dto.stock),
+ salesLimit: this.normalizeNullableInteger(dto.salesLimit),
+ fulfillmentType: this.normalizeFulfillmentType(
+ dto.fulfillmentType,
+ DEFAULT_SELF_FULFILLMENT,
+ ),
+ afterSalesNote: this.normalizeOptionalString(dto.afterSalesNote),
+ orderFormSchema: this.normalizeOrderFormSchema(dto.orderFormSchema),
+ sortOrder: this.normalizeInteger(dto.sortOrder, 0),
+ isFavorite: false,
+ enabled: dto.enabled ?? true,
+ rawPayload: null,
+ lastSyncedAt: null,
+ });
+
+ const saved = await this.productsRepository.save(product);
+ return this.toProductItem(saved, category ? new Map([[category.id, category]]) : new Map());
+ }
+
+ async updateProduct(
+ actor: User,
+ id: string,
+ dto: UpdateCatalogProductDto,
+ ) {
+ assertAdmin(actor);
+ const product = await this.getProduct(id);
+ this.assertSelfOwned(product, '第三方商品只能同步或停用,不能手工编辑');
+
+ if (dto.categoryId !== undefined) {
+ const category = await this.getOptionalCategory(dto.categoryId);
+ if (category) {
+ this.assertSelfOwned(category, '自营商品只能选择自营分类');
+ }
+ product.categoryId = category?.id ?? null;
+ }
+ if (dto.name !== undefined) {
+ product.name = this.normalizeRequiredString(dto.name, '商品名称不能为空');
+ }
+ if (dto.price !== undefined) {
+ product.price = this.normalizePrice(dto.price);
+ }
+ if (dto.coverUrl !== undefined) {
+ product.coverUrl = this.normalizeOptionalString(dto.coverUrl);
+ }
+ if (dto.content !== undefined) {
+ product.content = this.normalizeOptionalString(dto.content);
+ }
+ if (dto.stock !== undefined) {
+ product.stock = this.normalizeNullableInteger(dto.stock);
+ }
+ if (dto.salesLimit !== undefined) {
+ product.salesLimit = this.normalizeNullableInteger(dto.salesLimit);
+ }
+ if (dto.fulfillmentType !== undefined) {
+ product.fulfillmentType = this.normalizeFulfillmentType(
+ dto.fulfillmentType,
+ DEFAULT_SELF_FULFILLMENT,
+ );
+ }
+ if (dto.afterSalesNote !== undefined) {
+ product.afterSalesNote = this.normalizeOptionalString(dto.afterSalesNote);
+ }
+ if (dto.orderFormSchema !== undefined) {
+ product.orderFormSchema = this.normalizeOrderFormSchema(dto.orderFormSchema);
+ }
+ if (dto.sortOrder !== undefined) {
+ product.sortOrder = this.normalizeInteger(dto.sortOrder, 0);
+ }
+ if (typeof dto.enabled === 'boolean') {
+ product.enabled = dto.enabled;
+ }
+
+ const saved = await this.productsRepository.save(product);
+ const categoryMap = await this.getCategoryMap([saved]);
+ return this.toProductItem(saved, categoryMap);
+ }
+
+ async updateProductEnabled(actor: User, id: string, dto: UpdateEnabledDto) {
+ assertAdmin(actor);
+ const product = await this.getProduct(id);
+ product.enabled = Boolean(dto.enabled);
+ const saved = await this.productsRepository.save(product);
+ const categoryMap = await this.getCategoryMap([saved]);
+ return this.toProductItem(saved, categoryMap);
+ }
+
+ async deleteProduct(actor: User, id: string) {
+ assertAdmin(actor);
+ const product = await this.getProduct(id);
+ await this.productsRepository.remove(product);
+ return { id, deleted: true };
+ }
+
+ async syncThirdPartyCategories(actor: User) {
+ assertAdmin(actor);
+ return this.thirdPartyCatalogService.syncThirdPartyCatalog({
+ triggerType: 'manual',
+ });
+ }
+
+ async syncThirdPartyProducts(actor: User, query: CatalogListQuery) {
+ assertAdmin(actor);
+ const remoteCategoryId = this.normalizeOptionalString(
+ query.remoteCategoryId,
+ );
+ return this.thirdPartyCatalogService.syncThirdPartyCatalog({
+ triggerType: 'manual',
+ remoteCategoryId,
+ });
+ }
+
+ async listSyncJobs(actor: User, query: CatalogListQuery) {
+ assertAdmin(actor);
+ return this.thirdPartyCatalogService.listSyncJobs(query);
+ }
+
+ private getPagination(query: CatalogListQuery) {
+ return {
+ page: Math.max(Number(query.page || 1), 1),
+ pageSize: Math.min(Math.max(Number(query.pageSize || 10), 1), 100),
+ };
+ }
+
+ private normalizeSourceType(sourceType?: SourceType | 'all') {
+ if (!sourceType || sourceType === SOURCE_ALL) {
+ return null;
+ }
+ if (sourceType === 'third_party' || sourceType === SELF_OWNED_SOURCE) {
+ return sourceType;
+ }
+ throw new BadRequestException('商品来源不正确');
+ }
+
+ private normalizeEnabled(value?: string) {
+ if (value === undefined || value === '' || value === SOURCE_ALL) {
+ return null;
+ }
+ if (value === 'true' || value === '1') {
+ return true;
+ }
+ if (value === 'false' || value === '0') {
+ return false;
+ }
+ throw new BadRequestException('启停状态不正确');
+ }
+
+ private normalizeRequiredString(value: unknown, message: string) {
+ const normalized = String(value ?? '').trim();
+ if (!normalized) {
+ throw new BadRequestException(message);
+ }
+ return normalized;
+ }
+
+ private normalizeOptionalString(value: unknown) {
+ const normalized = String(value ?? '').trim();
+ return normalized || null;
+ }
+
+ private normalizeInteger(value: unknown, fallback: number) {
+ if (value === undefined || value === null || value === '') {
+ return fallback;
+ }
+ const numberValue = Number(value);
+ if (!Number.isInteger(numberValue)) {
+ throw new BadRequestException('排序必须是整数');
+ }
+ return numberValue;
+ }
+
+ private normalizeNullableInteger(value: unknown) {
+ if (value === undefined || value === null || value === '') {
+ return null;
+ }
+ const numberValue = Number(value);
+ if (!Number.isInteger(numberValue) || numberValue < 0) {
+ throw new BadRequestException('库存和限购必须是非负整数');
+ }
+ return numberValue;
+ }
+
+ private normalizePrice(value: unknown) {
+ const numberValue = Number(value ?? 0);
+ if (!Number.isFinite(numberValue) || numberValue < 0) {
+ throw new BadRequestException('商品价格必须是非负数字');
+ }
+ return numberValue.toFixed(2);
+ }
+
+ private normalizeFulfillmentType(
+ value: unknown,
+ fallback: FulfillmentType,
+ ) {
+ const normalized = String(value || fallback) as FulfillmentType;
+ if (!FULFILLMENT_TYPES.has(normalized)) {
+ throw new BadRequestException('交付类型不正确');
+ }
+ return normalized;
+ }
+
+ private normalizeOrderFormSchema(value: unknown): OrderFormField[] | null {
+ if (value === undefined || value === null) {
+ return null;
+ }
+ if (!Array.isArray(value)) {
+ throw new BadRequestException('下单表单配置必须是数组');
+ }
+ if (value.length > 30) {
+ throw new BadRequestException('下单表单字段不能超过 30 个');
+ }
+
+ const keys = new Set();
+ return value.map((field, index) => {
+ if (!field || typeof field !== 'object' || Array.isArray(field)) {
+ throw new BadRequestException('下单表单字段格式不正确');
+ }
+ const item = field as OrderFormFieldDto;
+ const key = this.normalizeFieldKey(item.key, index);
+ if (keys.has(key)) {
+ throw new BadRequestException(`下单字段 ${key} 重复`);
+ }
+ keys.add(key);
+
+ const label = this.normalizeRequiredString(
+ item.label,
+ '下单字段名称不能为空',
+ );
+ const type = String(item.type || 'text') as OrderFormFieldType;
+ if (!ORDER_FORM_FIELD_TYPES.has(type)) {
+ throw new BadRequestException(`下单字段 ${label} 类型不正确`);
+ }
+ const options =
+ type === 'select'
+ ? this.normalizeFieldOptions(item.options, label)
+ : undefined;
+
+ const normalizedField: OrderFormField = {
+ key,
+ label,
+ type,
+ required: Boolean(item.required),
+ placeholder: this.normalizeOptionalString(item.placeholder),
+ ...(options ? { options } : {}),
+ };
+ return normalizedField;
+ });
+ }
+
+ private normalizeFieldKey(value: unknown, index: number) {
+ const normalized = String(value || `field_${index + 1}`)
+ .trim()
+ .replace(/\s+/g, '_');
+ if (!/^[a-zA-Z][a-zA-Z0-9_]{0,49}$/.test(normalized)) {
+ throw new BadRequestException(
+ '下单字段标识只能使用字母、数字、下划线,并且必须以字母开头',
+ );
+ }
+ return normalized;
+ }
+
+ private normalizeFieldOptions(
+ value: OrderFormFieldDto['options'],
+ fieldLabel: string,
+ ) {
+ if (!Array.isArray(value) || !value.length) {
+ throw new BadRequestException(`下拉字段 ${fieldLabel} 至少需要一个选项`);
+ }
+ return value.map((option) => {
+ const label = this.normalizeRequiredString(
+ option?.label,
+ `下拉字段 ${fieldLabel} 的选项名称不能为空`,
+ );
+ const optionValue = this.normalizeRequiredString(
+ option?.value,
+ `下拉字段 ${fieldLabel} 的选项值不能为空`,
+ );
+ return { label, value: optionValue };
+ });
+ }
+
+ private async getCategory(id: string) {
+ const category = await this.categoriesRepository.findOne({ where: { id } });
+ if (!category) {
+ throw new NotFoundException('分类不存在');
+ }
+ return category;
+ }
+
+ private async getOptionalCategory(id?: string | null) {
+ const categoryId = String(id || '').trim();
+ if (!categoryId) {
+ return null;
+ }
+ return this.getCategory(categoryId);
+ }
+
+ private async getProduct(id: string) {
+ const product = await this.productsRepository.findOne({ where: { id } });
+ if (!product) {
+ throw new NotFoundException('商品不存在');
+ }
+ return product;
+ }
+
+ private assertSelfOwned(
+ entity: Category | Course,
+ message: string,
+ ) {
+ if (entity.sourceType !== SELF_OWNED_SOURCE) {
+ throw new BadRequestException(message);
+ }
+ }
+
+ private async getCategoryMap(products: Course[]) {
+ const categoryIds = Array.from(
+ new Set(products.map((product) => product.categoryId).filter(Boolean)),
+ ) as string[];
+ if (!categoryIds.length) {
+ return new Map();
+ }
+
+ const categories = await this.categoriesRepository
+ .createQueryBuilder('category')
+ .where('category.id IN (:...categoryIds)', { categoryIds })
+ .getMany();
+
+ return new Map(categories.map((category) => [category.id, category]));
+ }
+
+ private toProductItem(
+ product: Course,
+ categoryMap: Map,
+ ) {
+ const category = product.categoryId
+ ? categoryMap.get(product.categoryId)
+ : null;
+
+ return {
+ ...product,
+ categoryName: category?.name ?? null,
+ categorySourceType: category?.sourceType ?? null,
+ };
+ }
+}
diff --git a/packages/backend/src/catalog/dto/catalog.dto.ts b/packages/backend/src/catalog/dto/catalog.dto.ts
new file mode 100644
index 0000000..afed436
--- /dev/null
+++ b/packages/backend/src/catalog/dto/catalog.dto.ts
@@ -0,0 +1,76 @@
+import type { SourceType } from '../../third-party/entities/category.entity';
+
+export type FulfillmentType = 'third_party_api' | 'local_only' | 'manual';
+export type OrderFormFieldType =
+ | 'text'
+ | 'password'
+ | 'phone'
+ | 'number'
+ | 'textarea'
+ | 'select'
+ | 'checkbox';
+
+export type OrderFormFieldDto = {
+ key?: string;
+ label?: string;
+ type?: OrderFormFieldType;
+ required?: boolean;
+ placeholder?: string | null;
+ options?: Array<{ label?: string; value?: string }>;
+};
+
+export type CreateCatalogCategoryDto = {
+ name?: string;
+ sortOrder?: number;
+ enabled?: boolean;
+};
+
+export type UpdateCatalogCategoryDto = {
+ name?: string;
+ sortOrder?: number;
+ enabled?: boolean;
+};
+
+export type CreateCatalogProductDto = {
+ categoryId?: string | null;
+ name?: string;
+ price?: number | string;
+ coverUrl?: string | null;
+ content?: string | null;
+ stock?: number | null;
+ salesLimit?: number | null;
+ fulfillmentType?: FulfillmentType;
+ afterSalesNote?: string | null;
+ orderFormSchema?: OrderFormFieldDto[] | null;
+ sortOrder?: number;
+ enabled?: boolean;
+};
+
+export type UpdateCatalogProductDto = {
+ categoryId?: string | null;
+ name?: string;
+ price?: number | string;
+ coverUrl?: string | null;
+ content?: string | null;
+ stock?: number | null;
+ salesLimit?: number | null;
+ fulfillmentType?: FulfillmentType;
+ afterSalesNote?: string | null;
+ orderFormSchema?: OrderFormFieldDto[] | null;
+ sortOrder?: number;
+ enabled?: boolean;
+};
+
+export type UpdateEnabledDto = {
+ enabled?: boolean;
+};
+
+export type CatalogListQuery = {
+ page?: number;
+ pageSize?: number;
+ keyword?: string;
+ sourceType?: SourceType | 'all';
+ enabled?: string;
+ categoryId?: string;
+ remoteCategoryId?: string;
+};
diff --git a/packages/backend/src/common/response/api-response.type.ts b/packages/backend/src/common/response/api-response.type.ts
new file mode 100644
index 0000000..c65b1d8
--- /dev/null
+++ b/packages/backend/src/common/response/api-response.type.ts
@@ -0,0 +1,8 @@
+export type ApiResponse = {
+ code: number;
+ data: T | null;
+ msg: string;
+};
+
+export const API_SUCCESS_CODE = 0;
+export const API_SUCCESS_MESSAGE = 'success';
diff --git a/packages/backend/src/common/response/http-exception.filter.ts b/packages/backend/src/common/response/http-exception.filter.ts
new file mode 100644
index 0000000..b544c3c
--- /dev/null
+++ b/packages/backend/src/common/response/http-exception.filter.ts
@@ -0,0 +1,69 @@
+import {
+ ArgumentsHost,
+ Catch,
+ ExceptionFilter,
+ HttpException,
+ HttpStatus,
+} from '@nestjs/common';
+import type { Response } from 'express';
+import type { ApiResponse } from './api-response.type';
+
+type ExceptionResponseBody = {
+ message?: string | string[];
+ msg?: string;
+ error?: string;
+};
+
+@Catch()
+export class HttpExceptionFilter implements ExceptionFilter {
+ catch(exception: unknown, host: ArgumentsHost) {
+ const response = host.switchToHttp().getResponse();
+ const status =
+ exception instanceof HttpException
+ ? exception.getStatus()
+ : HttpStatus.INTERNAL_SERVER_ERROR;
+
+ response.status(status).json({
+ code: status,
+ data: null,
+ msg: this.getMessage(exception),
+ } satisfies ApiResponse);
+ }
+
+ private getMessage(exception: unknown) {
+ if (exception instanceof HttpException) {
+ const exceptionResponse = exception.getResponse();
+
+ if (typeof exceptionResponse === 'string') {
+ return exceptionResponse;
+ }
+
+ if (this.isExceptionResponseBody(exceptionResponse)) {
+ const message =
+ exceptionResponse.msg ||
+ exceptionResponse.message ||
+ exceptionResponse.error;
+
+ if (Array.isArray(message)) {
+ return message.join('; ');
+ }
+
+ if (message) {
+ return message;
+ }
+ }
+
+ return exception.message;
+ }
+
+ if (exception instanceof Error && exception.message) {
+ return exception.message;
+ }
+
+ return 'Internal server error';
+ }
+
+ private isExceptionResponseBody(value: unknown): value is ExceptionResponseBody {
+ return Boolean(value && typeof value === 'object');
+ }
+}
diff --git a/packages/backend/src/common/response/response.interceptor.ts b/packages/backend/src/common/response/response.interceptor.ts
new file mode 100644
index 0000000..56f73a2
--- /dev/null
+++ b/packages/backend/src/common/response/response.interceptor.ts
@@ -0,0 +1,30 @@
+import {
+ CallHandler,
+ ExecutionContext,
+ Injectable,
+ NestInterceptor,
+} from '@nestjs/common';
+import { Observable, map } from 'rxjs';
+import {
+ API_SUCCESS_CODE,
+ API_SUCCESS_MESSAGE,
+ type ApiResponse,
+} from './api-response.type';
+
+@Injectable()
+export class ResponseInterceptor
+ implements NestInterceptor>
+{
+ intercept(
+ _context: ExecutionContext,
+ next: CallHandler,
+ ): Observable> {
+ return next.handle().pipe(
+ map((data) => ({
+ code: API_SUCCESS_CODE,
+ data: data ?? null,
+ msg: API_SUCCESS_MESSAGE,
+ })),
+ );
+ }
+}
diff --git a/packages/backend/src/config/env.spec.ts b/packages/backend/src/config/env.spec.ts
new file mode 100644
index 0000000..688758d
--- /dev/null
+++ b/packages/backend/src/config/env.spec.ts
@@ -0,0 +1,13 @@
+import { validateEnv } from './env';
+
+describe('validateEnv', () => {
+ it('accepts the minimum backend environment', () => {
+ const env = validateEnv({
+ DATABASE_URL: 'mysql://root:123456@127.0.0.1:3306/work_admin',
+ AUTH_SECRET: 'replace-with-a-long-random-secret',
+ });
+
+ expect(env.PORT).toBe(3001);
+ expect(env.DATABASE_URL).toContain('work_admin');
+ });
+});
diff --git a/packages/backend/src/config/env.ts b/packages/backend/src/config/env.ts
new file mode 100644
index 0000000..6ff0490
--- /dev/null
+++ b/packages/backend/src/config/env.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+
+const envSchema = z.object({
+ NODE_ENV: z.string().default('development'),
+ PORT: z.coerce.number().int().positive().default(3001),
+ DATABASE_URL: z.string().url(),
+ AUTH_SECRET: z.string().min(16, 'AUTH_SECRET must be at least 16 characters'),
+ WK_BASE_URL: z.string().url().default('https://biedawo.org/api.php'),
+ WK_APP_UID: z.string().optional(),
+ WK_APP_KEY: z.string().optional(),
+ WK_DEBUG_LOG: z.string().optional(),
+ CATALOG_SYNC_CRON_ENABLED: z.string().optional(),
+ CATALOG_SYNC_CRON: z.string().default('0 */2 * * *'),
+ THIRD_PARTY_ORDER_SYNC_CRON_ENABLED: z.string().optional(),
+ THIRD_PARTY_ORDER_SYNC_CRON: z.string().default('*/30 * * * *'),
+});
+
+export type AppEnv = z.infer;
+
+export function validateEnv(config: Record): AppEnv {
+ return envSchema.parse(config);
+}
diff --git a/packages/backend/src/config/paths.ts b/packages/backend/src/config/paths.ts
new file mode 100644
index 0000000..9f7ce0d
--- /dev/null
+++ b/packages/backend/src/config/paths.ts
@@ -0,0 +1,4 @@
+import { join } from 'node:path';
+
+export const rootEnvPath = join(__dirname, '../../../../.env');
+export const packageEnvPath = join(__dirname, '../../.env');
diff --git a/packages/backend/src/database/database.module.ts b/packages/backend/src/database/database.module.ts
new file mode 100644
index 0000000..6f917ad
--- /dev/null
+++ b/packages/backend/src/database/database.module.ts
@@ -0,0 +1,24 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+const isTest = process.env.NODE_ENV === 'test';
+
+@Module({
+ imports: isTest
+ ? []
+ : [
+ TypeOrmModule.forRootAsync({
+ imports: [ConfigModule],
+ useFactory: () => ({
+ type: 'mysql',
+ url: process.env.DATABASE_URL,
+ autoLoadEntities: true,
+ synchronize: false,
+ migrationsRun: false,
+ timezone: 'Z',
+ }),
+ }),
+ ],
+})
+export class DatabaseModule {}
diff --git a/packages/backend/src/database/migrations/202606060001_phase2_auth_base.sql b/packages/backend/src/database/migrations/202606060001_phase2_auth_base.sql
new file mode 100644
index 0000000..412dbc4
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060001_phase2_auth_base.sql
@@ -0,0 +1,114 @@
+CREATE TABLE IF NOT EXISTS roles (
+ id VARCHAR(36) NOT NULL,
+ code VARCHAR(80) NOT NULL,
+ name VARCHAR(80) NOT NULL,
+ permissions JSON NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_roles_code (code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS users (
+ id VARCHAR(36) NOT NULL,
+ name VARCHAR(80) NOT NULL,
+ email VARCHAR(160) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ enabled TINYINT NOT NULL DEFAULT 1,
+ last_login_at TIMESTAMP NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_users_email (email)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS user_roles (
+ user_id VARCHAR(36) NOT NULL,
+ role_id VARCHAR(36) NOT NULL,
+ PRIMARY KEY (user_id, role_id),
+ KEY idx_user_roles_user_id (user_id),
+ KEY idx_user_roles_role_id (role_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+SET @users_last_login_at_exists := (
+ SELECT COUNT(*)
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'last_login_at'
+);
+SET @users_last_login_at_sql := IF(
+ @users_last_login_at_exists = 0,
+ 'ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP NULL',
+ 'SELECT 1'
+);
+PREPARE users_last_login_at_stmt FROM @users_last_login_at_sql;
+EXECUTE users_last_login_at_stmt;
+DEALLOCATE PREPARE users_last_login_at_stmt;
+
+CREATE TABLE IF NOT EXISTS login_logs (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ user_id VARCHAR(36) NULL,
+ username VARCHAR(64) NOT NULL,
+ result VARCHAR(32) NOT NULL,
+ failure_reason VARCHAR(255) NULL,
+ ip_address VARCHAR(64) NULL,
+ user_agent VARCHAR(512) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_login_logs_user_id (user_id),
+ KEY idx_login_logs_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+ALTER TABLE login_logs MODIFY COLUMN user_id VARCHAR(36) NULL;
+
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ actor_id VARCHAR(36) NULL,
+ actor_username VARCHAR(64) NULL,
+ action VARCHAR(64) NOT NULL,
+ resource_type VARCHAR(64) NULL,
+ resource_id VARCHAR(64) NULL,
+ metadata JSON NULL,
+ ip_address VARCHAR(64) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_audit_logs_actor_id (actor_id),
+ KEY idx_audit_logs_created_at (created_at),
+ KEY idx_audit_logs_action (action)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+ALTER TABLE audit_logs MODIFY COLUMN actor_id VARCHAR(36) NULL;
+
+INSERT INTO roles (id, code, name, permissions)
+SELECT
+ '738cbba4-f6f9-444c-ada3-65fa28285aa1',
+ 'super_admin',
+ '超级管理员',
+ JSON_ARRAY('*')
+WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'super_admin');
+
+INSERT INTO users (id, name, email, password_hash, enabled)
+SELECT
+ '0f4e6efb-bfb4-4f12-b76c-20e6d0a5aa31',
+ 'root',
+ 'root@local.dev',
+ '$2b$10$VV/DpPjPNgSDM5fj1B2JxOxFanDrpcwt4qgm27ez8sN4Y8LpfYGna',
+ 1
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM users
+ WHERE name = 'root' OR email = 'root@local.dev'
+);
+
+INSERT INTO user_roles (user_id, role_id)
+SELECT users.id, roles.id
+FROM users
+JOIN roles ON roles.code = 'super_admin'
+WHERE (users.name = 'root' OR users.email = 'root@local.dev')
+ AND NOT EXISTS (
+ SELECT 1
+ FROM user_roles
+ WHERE user_roles.user_id = users.id
+ AND user_roles.role_id = roles.id
+ );
diff --git a/packages/backend/src/database/migrations/202606060002_user_admin.sql b/packages/backend/src/database/migrations/202606060002_user_admin.sql
new file mode 100644
index 0000000..af035f3
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060002_user_admin.sql
@@ -0,0 +1,47 @@
+SET @users_parent_id_exists := (
+ SELECT COUNT(*)
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'parent_id'
+);
+SET @users_parent_id_sql := IF(
+ @users_parent_id_exists = 0,
+ 'ALTER TABLE users ADD COLUMN parent_id VARCHAR(36) NULL AFTER enabled',
+ 'SELECT 1'
+);
+PREPARE users_parent_id_stmt FROM @users_parent_id_sql;
+EXECUTE users_parent_id_stmt;
+DEALLOCATE PREPARE users_parent_id_stmt;
+
+SET @users_parent_id_index_exists := (
+ SELECT COUNT(*)
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND index_name = 'idx_users_parent_id'
+);
+SET @users_parent_id_index_sql := IF(
+ @users_parent_id_index_exists = 0,
+ 'CREATE INDEX idx_users_parent_id ON users (parent_id)',
+ 'SELECT 1'
+);
+PREPARE users_parent_id_index_stmt FROM @users_parent_id_index_sql;
+EXECUTE users_parent_id_index_stmt;
+DEALLOCATE PREPARE users_parent_id_index_stmt;
+
+INSERT INTO roles (id, code, name, permissions)
+SELECT
+ '60e6fb3f-7b03-4e37-a4c1-1690c7aabf01',
+ 'agent',
+ '代理',
+ JSON_OBJECT('admin/users', JSON_ARRAY('read', 'write'))
+WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'agent');
+
+INSERT INTO roles (id, code, name, permissions)
+SELECT
+ 'c6805ef4-bc20-4ec8-93a7-2e8ea1b33677',
+ 'user',
+ '普通用户',
+ JSON_OBJECT()
+WHERE NOT EXISTS (SELECT 1 FROM roles WHERE code = 'user');
diff --git a/packages/backend/src/database/migrations/202606060003_phase2_security_logs_permissions.sql b/packages/backend/src/database/migrations/202606060003_phase2_security_logs_permissions.sql
new file mode 100644
index 0000000..928105e
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060003_phase2_security_logs_permissions.sql
@@ -0,0 +1,71 @@
+SET @users_parent_path_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'parent_path'
+);
+SET @users_parent_path_sql := IF(
+ @users_parent_path_exists = 0,
+ 'ALTER TABLE users ADD COLUMN parent_path VARCHAR(1024) NULL AFTER parent_id',
+ 'SELECT 1'
+);
+PREPARE users_parent_path_stmt FROM @users_parent_path_sql;
+EXECUTE users_parent_path_stmt;
+DEALLOCATE PREPARE users_parent_path_stmt;
+
+SET @users_token_version_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'token_version'
+);
+SET @users_token_version_sql := IF(
+ @users_token_version_exists = 0,
+ 'ALTER TABLE users ADD COLUMN token_version INT NOT NULL DEFAULT 0 AFTER parent_path',
+ 'SELECT 1'
+);
+PREPARE users_token_version_stmt FROM @users_token_version_sql;
+EXECUTE users_token_version_stmt;
+DEALLOCATE PREPARE users_token_version_stmt;
+
+SET @users_must_change_password_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'must_change_password'
+);
+SET @users_must_change_password_sql := IF(
+ @users_must_change_password_exists = 0,
+ 'ALTER TABLE users ADD COLUMN must_change_password TINYINT NOT NULL DEFAULT 0 AFTER token_version',
+ 'SELECT 1'
+);
+PREPARE users_must_change_password_stmt FROM @users_must_change_password_sql;
+EXECUTE users_must_change_password_stmt;
+DEALLOCATE PREPARE users_must_change_password_stmt;
+
+SET @users_parent_path_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = 'users' AND index_name = 'idx_users_parent_path'
+);
+SET @users_parent_path_index_sql := IF(
+ @users_parent_path_index_exists = 0,
+ 'CREATE INDEX idx_users_parent_path ON users (parent_path(255))',
+ 'SELECT 1'
+);
+PREPARE users_parent_path_index_stmt FROM @users_parent_path_index_sql;
+EXECUTE users_parent_path_index_stmt;
+DEALLOCATE PREPARE users_parent_path_index_stmt;
+
+UPDATE roles
+SET permissions = JSON_OBJECT(
+ '*', JSON_ARRAY('*'),
+ 'admin/users', JSON_ARRAY('read', 'write'),
+ 'admin/login-logs', JSON_ARRAY('read'),
+ 'admin/audit-logs', JSON_ARRAY('read'),
+ 'admin/permissions', JSON_ARRAY('read', 'write')
+)
+WHERE code = 'super_admin';
+
+UPDATE roles
+SET permissions = JSON_OBJECT(
+ 'admin/users', JSON_ARRAY('read', 'write')
+)
+WHERE code = 'agent';
+
+UPDATE roles
+SET permissions = JSON_OBJECT()
+WHERE code = 'user';
diff --git a/packages/backend/src/database/migrations/202606060004_phase3_api_call_logs.sql b/packages/backend/src/database/migrations/202606060004_phase3_api_call_logs.sql
new file mode 100644
index 0000000..30493a7
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060004_phase3_api_call_logs.sql
@@ -0,0 +1,136 @@
+CREATE TABLE IF NOT EXISTS api_call_logs (
+ id VARCHAR(36) NOT NULL,
+ provider VARCHAR(64) NOT NULL DEFAULT 'biedawo',
+ act VARCHAR(80) NOT NULL,
+ endpoint VARCHAR(255) NOT NULL,
+ method VARCHAR(12) NOT NULL,
+ request_payload JSON NULL,
+ response_payload JSON NULL,
+ success TINYINT NOT NULL DEFAULT 0,
+ status VARCHAR(16) NOT NULL DEFAULT 'failure',
+ status_code INT NULL,
+ duration_ms INT NULL,
+ error_message TEXT NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+SET @api_call_logs_provider_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND column_name = 'provider'
+);
+SET @api_call_logs_provider_sql := IF(
+ @api_call_logs_provider_exists = 0,
+ 'ALTER TABLE api_call_logs ADD COLUMN provider VARCHAR(64) NOT NULL DEFAULT ''biedawo'' AFTER id',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_provider_stmt FROM @api_call_logs_provider_sql;
+EXECUTE api_call_logs_provider_stmt;
+DEALLOCATE PREPARE api_call_logs_provider_stmt;
+
+SET @api_call_logs_status_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND column_name = 'status'
+);
+SET @api_call_logs_status_sql := IF(
+ @api_call_logs_status_exists = 0,
+ 'ALTER TABLE api_call_logs ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT ''failure'' AFTER success',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_status_stmt FROM @api_call_logs_status_sql;
+EXECUTE api_call_logs_status_stmt;
+DEALLOCATE PREPARE api_call_logs_status_stmt;
+
+SET @api_call_logs_endpoint_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND column_name = 'endpoint'
+);
+SET @api_call_logs_endpoint_sql := IF(
+ @api_call_logs_endpoint_exists = 0,
+ 'ALTER TABLE api_call_logs ADD COLUMN endpoint VARCHAR(255) NOT NULL DEFAULT '''' AFTER act',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_endpoint_stmt FROM @api_call_logs_endpoint_sql;
+EXECUTE api_call_logs_endpoint_stmt;
+DEALLOCATE PREPARE api_call_logs_endpoint_stmt;
+
+SET @api_call_logs_success_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND column_name = 'success'
+);
+SET @api_call_logs_success_sql := IF(
+ @api_call_logs_success_exists = 0,
+ 'ALTER TABLE api_call_logs ADD COLUMN success TINYINT NOT NULL DEFAULT 0 AFTER response_payload',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_success_stmt FROM @api_call_logs_success_sql;
+EXECUTE api_call_logs_success_stmt;
+DEALLOCATE PREPARE api_call_logs_success_stmt;
+
+SET @api_call_logs_status_code_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND column_name = 'status_code'
+);
+SET @api_call_logs_status_code_sql := IF(
+ @api_call_logs_status_code_exists = 0,
+ 'ALTER TABLE api_call_logs ADD COLUMN status_code INT NULL AFTER status',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_status_code_stmt FROM @api_call_logs_status_code_sql;
+EXECUTE api_call_logs_status_code_stmt;
+DEALLOCATE PREPARE api_call_logs_status_code_stmt;
+
+SET @api_call_logs_created_at_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND index_name = 'idx_api_call_logs_created_at'
+);
+SET @api_call_logs_created_at_index_sql := IF(
+ @api_call_logs_created_at_index_exists = 0,
+ 'CREATE INDEX idx_api_call_logs_created_at ON api_call_logs (created_at)',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_created_at_index_stmt FROM @api_call_logs_created_at_index_sql;
+EXECUTE api_call_logs_created_at_index_stmt;
+DEALLOCATE PREPARE api_call_logs_created_at_index_stmt;
+
+SET @api_call_logs_provider_created_at_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND index_name = 'idx_api_call_logs_provider_created_at'
+);
+SET @api_call_logs_provider_created_at_index_sql := IF(
+ @api_call_logs_provider_created_at_index_exists = 0,
+ 'CREATE INDEX idx_api_call_logs_provider_created_at ON api_call_logs (provider, created_at)',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_provider_created_at_index_stmt FROM @api_call_logs_provider_created_at_index_sql;
+EXECUTE api_call_logs_provider_created_at_index_stmt;
+DEALLOCATE PREPARE api_call_logs_provider_created_at_index_stmt;
+
+SET @api_call_logs_act_created_at_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'api_call_logs'
+ AND index_name = 'idx_api_call_logs_act_created_at'
+);
+SET @api_call_logs_act_created_at_index_sql := IF(
+ @api_call_logs_act_created_at_index_exists = 0,
+ 'CREATE INDEX idx_api_call_logs_act_created_at ON api_call_logs (act, created_at)',
+ 'SELECT 1'
+);
+PREPARE api_call_logs_act_created_at_index_stmt FROM @api_call_logs_act_created_at_index_sql;
+EXECUTE api_call_logs_act_created_at_index_stmt;
+DEALLOCATE PREPARE api_call_logs_act_created_at_index_stmt;
diff --git a/packages/backend/src/database/migrations/202606060005_phase3_permissions.sql b/packages/backend/src/database/migrations/202606060005_phase3_permissions.sql
new file mode 100644
index 0000000..fed5e3f
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060005_phase3_permissions.sql
@@ -0,0 +1,11 @@
+UPDATE roles
+SET permissions = JSON_OBJECT(
+ '*', JSON_ARRAY('*'),
+ 'admin/users', JSON_ARRAY('read', 'write'),
+ 'admin/login-logs', JSON_ARRAY('read'),
+ 'admin/audit-logs', JSON_ARRAY('read'),
+ 'admin/api-call-logs', JSON_ARRAY('read'),
+ 'admin/third-party', JSON_ARRAY('read', 'write'),
+ 'admin/permissions', JSON_ARRAY('read', 'write')
+)
+WHERE code = 'super_admin';
diff --git a/packages/backend/src/database/migrations/202606060006_phase3_categories_courses.sql b/packages/backend/src/database/migrations/202606060006_phase3_categories_courses.sql
new file mode 100644
index 0000000..ded4102
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060006_phase3_categories_courses.sql
@@ -0,0 +1,307 @@
+CREATE TABLE IF NOT EXISTS categories (
+ id VARCHAR(36) NOT NULL,
+ source_type VARCHAR(32) NOT NULL DEFAULT 'third_party',
+ api_account_id VARCHAR(36) NULL DEFAULT 'env-biedawo',
+ provider VARCHAR(64) NULL DEFAULT 'biedawo',
+ external_id VARCHAR(160) NULL,
+ remote_category_id VARCHAR(120) NULL,
+ name VARCHAR(160) NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ enabled TINYINT NOT NULL DEFAULT 1,
+ raw_payload JSON NULL,
+ last_synced_at DATETIME NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS courses (
+ id VARCHAR(36) NOT NULL,
+ source_type VARCHAR(32) NOT NULL DEFAULT 'third_party',
+ api_account_id VARCHAR(36) NULL DEFAULT 'env-biedawo',
+ provider VARCHAR(64) NULL DEFAULT 'biedawo',
+ external_id VARCHAR(160) NULL,
+ category_id VARCHAR(36) NULL,
+ remote_category_id VARCHAR(120) NULL,
+ remote_course_id VARCHAR(160) NULL,
+ name VARCHAR(255) NOT NULL,
+ price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ content TEXT NULL,
+ is_favorite TINYINT NOT NULL DEFAULT 0,
+ enabled TINYINT NOT NULL DEFAULT 1,
+ raw_payload JSON NULL,
+ last_synced_at DATETIME NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+SET @categories_source_type_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'source_type'
+);
+SET @categories_source_type_sql := IF(
+ @categories_source_type_exists = 0,
+ 'ALTER TABLE categories ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''third_party'' AFTER id',
+ 'SELECT 1'
+);
+PREPARE categories_source_type_stmt FROM @categories_source_type_sql;
+EXECUTE categories_source_type_stmt;
+DEALLOCATE PREPARE categories_source_type_stmt;
+
+SET @categories_api_account_id_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'api_account_id'
+);
+SET @categories_api_account_id_sql := IF(
+ @categories_api_account_id_exists = 0,
+ 'ALTER TABLE categories ADD COLUMN api_account_id VARCHAR(36) NULL DEFAULT ''env-biedawo'' AFTER source_type',
+ 'SELECT 1'
+);
+PREPARE categories_api_account_id_stmt FROM @categories_api_account_id_sql;
+EXECUTE categories_api_account_id_stmt;
+DEALLOCATE PREPARE categories_api_account_id_stmt;
+
+SET @categories_provider_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'provider'
+);
+SET @categories_provider_sql := IF(
+ @categories_provider_exists = 0,
+ 'ALTER TABLE categories ADD COLUMN provider VARCHAR(64) NULL DEFAULT ''biedawo'' AFTER source_type',
+ 'SELECT 1'
+);
+PREPARE categories_provider_stmt FROM @categories_provider_sql;
+EXECUTE categories_provider_stmt;
+DEALLOCATE PREPARE categories_provider_stmt;
+
+SET @categories_external_id_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'external_id'
+);
+SET @categories_external_id_sql := IF(
+ @categories_external_id_exists = 0,
+ 'ALTER TABLE categories ADD COLUMN external_id VARCHAR(160) NULL AFTER provider',
+ 'SELECT 1'
+);
+PREPARE categories_external_id_stmt FROM @categories_external_id_sql;
+EXECUTE categories_external_id_stmt;
+DEALLOCATE PREPARE categories_external_id_stmt;
+
+SET @categories_enabled_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'categories' AND column_name = 'enabled'
+);
+SET @categories_enabled_sql := IF(
+ @categories_enabled_exists = 0,
+ 'ALTER TABLE categories ADD COLUMN enabled TINYINT NOT NULL DEFAULT 1 AFTER sort_order',
+ 'SELECT 1'
+);
+PREPARE categories_enabled_stmt FROM @categories_enabled_sql;
+EXECUTE categories_enabled_stmt;
+DEALLOCATE PREPARE categories_enabled_stmt;
+
+SET @categories_last_synced_nullable := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'categories'
+ AND column_name = 'last_synced_at'
+ AND is_nullable = 'NO'
+);
+SET @categories_last_synced_sql := IF(
+ @categories_last_synced_nullable > 0,
+ 'ALTER TABLE categories MODIFY COLUMN last_synced_at DATETIME NULL',
+ 'SELECT 1'
+);
+PREPARE categories_last_synced_stmt FROM @categories_last_synced_sql;
+EXECUTE categories_last_synced_stmt;
+DEALLOCATE PREPARE categories_last_synced_stmt;
+
+UPDATE categories
+SET source_type = COALESCE(NULLIF(source_type, ''), 'third_party'),
+ provider = COALESCE(NULLIF(provider, ''), 'biedawo'),
+ external_id = COALESCE(NULLIF(external_id, ''), remote_category_id)
+WHERE source_type IS NULL
+ OR provider IS NULL
+ OR external_id IS NULL
+ OR source_type = ''
+ OR provider = ''
+ OR external_id = '';
+
+SET @courses_source_type_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'source_type'
+);
+SET @courses_source_type_sql := IF(
+ @courses_source_type_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''third_party'' AFTER id',
+ 'SELECT 1'
+);
+PREPARE courses_source_type_stmt FROM @courses_source_type_sql;
+EXECUTE courses_source_type_stmt;
+DEALLOCATE PREPARE courses_source_type_stmt;
+
+SET @courses_api_account_id_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'api_account_id'
+);
+SET @courses_api_account_id_sql := IF(
+ @courses_api_account_id_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN api_account_id VARCHAR(36) NULL DEFAULT ''env-biedawo'' AFTER source_type',
+ 'SELECT 1'
+);
+PREPARE courses_api_account_id_stmt FROM @courses_api_account_id_sql;
+EXECUTE courses_api_account_id_stmt;
+DEALLOCATE PREPARE courses_api_account_id_stmt;
+
+SET @courses_provider_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'provider'
+);
+SET @courses_provider_sql := IF(
+ @courses_provider_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN provider VARCHAR(64) NULL DEFAULT ''biedawo'' AFTER source_type',
+ 'SELECT 1'
+);
+PREPARE courses_provider_stmt FROM @courses_provider_sql;
+EXECUTE courses_provider_stmt;
+DEALLOCATE PREPARE courses_provider_stmt;
+
+SET @courses_external_id_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'external_id'
+);
+SET @courses_external_id_sql := IF(
+ @courses_external_id_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN external_id VARCHAR(160) NULL AFTER provider',
+ 'SELECT 1'
+);
+PREPARE courses_external_id_stmt FROM @courses_external_id_sql;
+EXECUTE courses_external_id_stmt;
+DEALLOCATE PREPARE courses_external_id_stmt;
+
+SET @courses_price_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'price'
+);
+SET @courses_price_sql := IF(
+ @courses_price_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN price DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER name',
+ 'SELECT 1'
+);
+PREPARE courses_price_stmt FROM @courses_price_sql;
+EXECUTE courses_price_stmt;
+DEALLOCATE PREPARE courses_price_stmt;
+
+SET @courses_content_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'content'
+);
+SET @courses_content_sql := IF(
+ @courses_content_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN content TEXT NULL AFTER price',
+ 'SELECT 1'
+);
+PREPARE courses_content_stmt FROM @courses_content_sql;
+EXECUTE courses_content_stmt;
+DEALLOCATE PREPARE courses_content_stmt;
+
+SET @courses_raw_payload_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'raw_payload'
+);
+SET @courses_raw_payload_sql := IF(
+ @courses_raw_payload_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN raw_payload JSON NULL AFTER enabled',
+ 'SELECT 1'
+);
+PREPARE courses_raw_payload_stmt FROM @courses_raw_payload_sql;
+EXECUTE courses_raw_payload_stmt;
+DEALLOCATE PREPARE courses_raw_payload_stmt;
+
+SET @courses_last_synced_nullable := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'courses'
+ AND column_name = 'last_synced_at'
+ AND is_nullable = 'NO'
+);
+SET @courses_last_synced_sql := IF(
+ @courses_last_synced_nullable > 0,
+ 'ALTER TABLE courses MODIFY COLUMN last_synced_at DATETIME NULL',
+ 'SELECT 1'
+);
+PREPARE courses_last_synced_stmt FROM @courses_last_synced_sql;
+EXECUTE courses_last_synced_stmt;
+DEALLOCATE PREPARE courses_last_synced_stmt;
+
+UPDATE courses
+SET source_type = COALESCE(NULLIF(source_type, ''), 'third_party'),
+ provider = COALESCE(NULLIF(provider, ''), 'biedawo'),
+ external_id = COALESCE(NULLIF(external_id, ''), remote_course_id)
+WHERE source_type IS NULL
+ OR provider IS NULL
+ OR external_id IS NULL
+ OR source_type = ''
+ OR provider = ''
+ OR external_id = '';
+
+SET @categories_source_provider_external_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'categories'
+ AND index_name = 'idx_categories_source_provider_external'
+);
+SET @categories_source_provider_external_index_sql := IF(
+ @categories_source_provider_external_index_exists = 0,
+ 'CREATE INDEX idx_categories_source_provider_external ON categories (source_type, provider, external_id)',
+ 'SELECT 1'
+);
+PREPARE categories_source_provider_external_index_stmt FROM @categories_source_provider_external_index_sql;
+EXECUTE categories_source_provider_external_index_stmt;
+DEALLOCATE PREPARE categories_source_provider_external_index_stmt;
+
+SET @categories_provider_remote_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'categories'
+ AND index_name = 'idx_categories_provider_remote'
+);
+SET @categories_provider_remote_index_sql := IF(
+ @categories_provider_remote_index_exists = 0,
+ 'CREATE INDEX idx_categories_provider_remote ON categories (provider, remote_category_id)',
+ 'SELECT 1'
+);
+PREPARE categories_provider_remote_index_stmt FROM @categories_provider_remote_index_sql;
+EXECUTE categories_provider_remote_index_stmt;
+DEALLOCATE PREPARE categories_provider_remote_index_stmt;
+
+SET @courses_source_provider_external_category_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'courses'
+ AND index_name = 'idx_courses_source_provider_external_category'
+);
+SET @courses_source_provider_external_category_index_sql := IF(
+ @courses_source_provider_external_category_index_exists = 0,
+ 'CREATE INDEX idx_courses_source_provider_external_category ON courses (source_type, provider, external_id, remote_category_id)',
+ 'SELECT 1'
+);
+PREPARE courses_source_provider_external_category_index_stmt FROM @courses_source_provider_external_category_index_sql;
+EXECUTE courses_source_provider_external_category_index_stmt;
+DEALLOCATE PREPARE courses_source_provider_external_category_index_stmt;
+
+SET @courses_provider_category_updated_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'courses'
+ AND index_name = 'idx_courses_provider_category_updated'
+);
+SET @courses_provider_category_updated_index_sql := IF(
+ @courses_provider_category_updated_index_exists = 0,
+ 'CREATE INDEX idx_courses_provider_category_updated ON courses (provider, remote_category_id, updated_at)',
+ 'SELECT 1'
+);
+PREPARE courses_provider_category_updated_index_stmt FROM @courses_provider_category_updated_index_sql;
+EXECUTE courses_provider_category_updated_index_stmt;
+DEALLOCATE PREPARE courses_provider_category_updated_index_stmt;
diff --git a/packages/backend/src/database/migrations/202606060007_merge_login_logs_into_audit_logs.sql b/packages/backend/src/database/migrations/202606060007_merge_login_logs_into_audit_logs.sql
new file mode 100644
index 0000000..b9b2074
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060007_merge_login_logs_into_audit_logs.sql
@@ -0,0 +1,69 @@
+SET @login_logs_exists := (
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'login_logs'
+);
+
+SET @merge_login_logs_sql := IF(
+ @login_logs_exists > 0,
+ 'INSERT INTO audit_logs (
+ actor_id,
+ actor_username,
+ action,
+ resource_type,
+ resource_id,
+ metadata,
+ ip_address,
+ created_at
+ )
+ SELECT
+ login_logs.user_id,
+ login_logs.username,
+ ''auth.login'',
+ ''user'',
+ login_logs.user_id,
+ JSON_OBJECT(
+ ''loginResult'', login_logs.result,
+ ''loginName'', login_logs.username,
+ ''failureReason'', login_logs.failure_reason,
+ ''userAgent'', login_logs.user_agent,
+ ''migratedFrom'', ''login_logs''
+ ),
+ login_logs.ip_address,
+ login_logs.created_at
+ FROM login_logs
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM audit_logs
+ WHERE audit_logs.action = ''auth.login''
+ AND audit_logs.created_at = login_logs.created_at
+ AND JSON_UNQUOTE(JSON_EXTRACT(audit_logs.metadata, ''$.loginName'')) = login_logs.username
+ AND JSON_UNQUOTE(JSON_EXTRACT(audit_logs.metadata, ''$.loginResult'')) = login_logs.result
+ )',
+ 'SELECT 1'
+);
+PREPARE merge_login_logs_stmt FROM @merge_login_logs_sql;
+EXECUTE merge_login_logs_stmt;
+DEALLOCATE PREPARE merge_login_logs_stmt;
+
+UPDATE roles
+SET permissions = JSON_REMOVE(permissions, '$."admin/login-logs"')
+WHERE JSON_CONTAINS_PATH(permissions, 'one', '$."admin/login-logs"');
+
+UPDATE roles
+SET permissions = JSON_SET(
+ COALESCE(permissions, JSON_OBJECT()),
+ '$."admin/audit-logs"',
+ JSON_ARRAY('read')
+)
+WHERE code = 'super_admin';
+
+SET @drop_login_logs_sql := IF(
+ @login_logs_exists > 0,
+ 'DROP TABLE login_logs',
+ 'SELECT 1'
+);
+PREPARE drop_login_logs_stmt FROM @drop_login_logs_sql;
+EXECUTE drop_login_logs_stmt;
+DEALLOCATE PREPARE drop_login_logs_stmt;
diff --git a/packages/backend/src/database/migrations/202606060008_user_permission_overrides.sql b/packages/backend/src/database/migrations/202606060008_user_permission_overrides.sql
new file mode 100644
index 0000000..1a5d85f
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060008_user_permission_overrides.sql
@@ -0,0 +1,15 @@
+SET @users_permissions_exists := (
+ SELECT COUNT(*)
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'users'
+ AND column_name = 'permissions'
+);
+SET @users_permissions_sql := IF(
+ @users_permissions_exists = 0,
+ 'ALTER TABLE users ADD COLUMN permissions JSON NULL AFTER must_change_password',
+ 'SELECT 1'
+);
+PREPARE users_permissions_stmt FROM @users_permissions_sql;
+EXECUTE users_permissions_stmt;
+DEALLOCATE PREPARE users_permissions_stmt;
diff --git a/packages/backend/src/database/migrations/202606060009_phase4_catalog_products.sql b/packages/backend/src/database/migrations/202606060009_phase4_catalog_products.sql
new file mode 100644
index 0000000..9bd65e8
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060009_phase4_catalog_products.sql
@@ -0,0 +1,114 @@
+SET @courses_cover_url_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'cover_url'
+);
+SET @courses_cover_url_sql := IF(
+ @courses_cover_url_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN cover_url VARCHAR(500) NULL AFTER price',
+ 'SELECT 1'
+);
+PREPARE courses_cover_url_stmt FROM @courses_cover_url_sql;
+EXECUTE courses_cover_url_stmt;
+DEALLOCATE PREPARE courses_cover_url_stmt;
+
+SET @courses_stock_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'stock'
+);
+SET @courses_stock_sql := IF(
+ @courses_stock_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN stock INT NULL AFTER content',
+ 'SELECT 1'
+);
+PREPARE courses_stock_stmt FROM @courses_stock_sql;
+EXECUTE courses_stock_stmt;
+DEALLOCATE PREPARE courses_stock_stmt;
+
+SET @courses_sales_limit_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'sales_limit'
+);
+SET @courses_sales_limit_sql := IF(
+ @courses_sales_limit_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN sales_limit INT NULL AFTER stock',
+ 'SELECT 1'
+);
+PREPARE courses_sales_limit_stmt FROM @courses_sales_limit_sql;
+EXECUTE courses_sales_limit_stmt;
+DEALLOCATE PREPARE courses_sales_limit_stmt;
+
+SET @courses_fulfillment_type_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'fulfillment_type'
+);
+SET @courses_fulfillment_type_sql := IF(
+ @courses_fulfillment_type_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN fulfillment_type VARCHAR(40) NOT NULL DEFAULT ''third_party_api'' AFTER sales_limit',
+ 'SELECT 1'
+);
+PREPARE courses_fulfillment_type_stmt FROM @courses_fulfillment_type_sql;
+EXECUTE courses_fulfillment_type_stmt;
+DEALLOCATE PREPARE courses_fulfillment_type_stmt;
+
+SET @courses_after_sales_note_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'after_sales_note'
+);
+SET @courses_after_sales_note_sql := IF(
+ @courses_after_sales_note_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN after_sales_note TEXT NULL AFTER fulfillment_type',
+ 'SELECT 1'
+);
+PREPARE courses_after_sales_note_stmt FROM @courses_after_sales_note_sql;
+EXECUTE courses_after_sales_note_stmt;
+DEALLOCATE PREPARE courses_after_sales_note_stmt;
+
+SET @courses_sort_order_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'sort_order'
+);
+SET @courses_sort_order_sql := IF(
+ @courses_sort_order_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN sort_order INT NOT NULL DEFAULT 0 AFTER after_sales_note',
+ 'SELECT 1'
+);
+PREPARE courses_sort_order_stmt FROM @courses_sort_order_sql;
+EXECUTE courses_sort_order_stmt;
+DEALLOCATE PREPARE courses_sort_order_stmt;
+
+UPDATE courses
+SET fulfillment_type = CASE
+ WHEN source_type = 'self_owned' THEN 'local_only'
+ ELSE 'third_party_api'
+END
+WHERE fulfillment_type IS NULL OR fulfillment_type = '';
+
+SET @categories_source_enabled_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'categories'
+ AND index_name = 'idx_categories_source_enabled_sort'
+);
+SET @categories_source_enabled_index_sql := IF(
+ @categories_source_enabled_index_exists = 0,
+ 'CREATE INDEX idx_categories_source_enabled_sort ON categories (source_type, enabled, sort_order)',
+ 'SELECT 1'
+);
+PREPARE categories_source_enabled_index_stmt FROM @categories_source_enabled_index_sql;
+EXECUTE categories_source_enabled_index_stmt;
+DEALLOCATE PREPARE categories_source_enabled_index_stmt;
+
+SET @courses_source_enabled_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'courses'
+ AND index_name = 'idx_courses_source_enabled_sort'
+);
+SET @courses_source_enabled_index_sql := IF(
+ @courses_source_enabled_index_exists = 0,
+ 'CREATE INDEX idx_courses_source_enabled_sort ON courses (source_type, enabled, sort_order, updated_at)',
+ 'SELECT 1'
+);
+PREPARE courses_source_enabled_index_stmt FROM @courses_source_enabled_index_sql;
+EXECUTE courses_source_enabled_index_stmt;
+DEALLOCATE PREPARE courses_source_enabled_index_stmt;
diff --git a/packages/backend/src/database/migrations/202606060010_phase4_self_owned_nullable_fields.sql b/packages/backend/src/database/migrations/202606060010_phase4_self_owned_nullable_fields.sql
new file mode 100644
index 0000000..7e26243
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606060010_phase4_self_owned_nullable_fields.sql
@@ -0,0 +1,12 @@
+ALTER TABLE categories
+ MODIFY COLUMN api_account_id VARCHAR(36) NULL DEFAULT NULL,
+ MODIFY COLUMN provider VARCHAR(64) NULL DEFAULT NULL,
+ MODIFY COLUMN external_id VARCHAR(160) NULL,
+ MODIFY COLUMN remote_category_id VARCHAR(120) NULL;
+
+ALTER TABLE courses
+ MODIFY COLUMN api_account_id VARCHAR(36) NULL DEFAULT NULL,
+ MODIFY COLUMN provider VARCHAR(64) NULL DEFAULT NULL,
+ MODIFY COLUMN external_id VARCHAR(160) NULL,
+ MODIFY COLUMN remote_category_id VARCHAR(120) NULL,
+ MODIFY COLUMN remote_course_id VARCHAR(160) NULL;
diff --git a/packages/backend/src/database/migrations/202606070011_catalog_sync_jobs.sql b/packages/backend/src/database/migrations/202606070011_catalog_sync_jobs.sql
new file mode 100644
index 0000000..f553b9f
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606070011_catalog_sync_jobs.sql
@@ -0,0 +1,94 @@
+CREATE TABLE IF NOT EXISTS catalog_sync_jobs (
+ id VARCHAR(36) NOT NULL,
+ provider VARCHAR(64) NOT NULL,
+ trigger_type VARCHAR(32) NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ started_at DATETIME(6) NOT NULL,
+ finished_at DATETIME(6) NULL,
+ category_total INT NOT NULL DEFAULT 0,
+ product_total INT NOT NULL DEFAULT 0,
+ category_created INT NOT NULL DEFAULT 0,
+ category_updated INT NOT NULL DEFAULT 0,
+ product_created INT NOT NULL DEFAULT 0,
+ product_updated INT NOT NULL DEFAULT 0,
+ product_removed INT NOT NULL DEFAULT 0,
+ error_message TEXT NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ INDEX idx_catalog_sync_jobs_provider_started (provider, started_at),
+ INDEX idx_catalog_sync_jobs_status_started (status, started_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+SET @courses_sync_status_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'sync_status'
+);
+SET @courses_sync_status_sql := IF(
+ @courses_sync_status_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN sync_status VARCHAR(32) NOT NULL DEFAULT ''active'' AFTER enabled',
+ 'SELECT 1'
+);
+PREPARE courses_sync_status_stmt FROM @courses_sync_status_sql;
+EXECUTE courses_sync_status_stmt;
+DEALLOCATE PREPARE courses_sync_status_stmt;
+
+SET @courses_last_seen_at_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'last_seen_at'
+);
+SET @courses_last_seen_at_sql := IF(
+ @courses_last_seen_at_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN last_seen_at DATETIME NULL AFTER last_synced_at',
+ 'SELECT 1'
+);
+PREPARE courses_last_seen_at_stmt FROM @courses_last_seen_at_sql;
+EXECUTE courses_last_seen_at_stmt;
+DEALLOCATE PREPARE courses_last_seen_at_stmt;
+
+SET @courses_last_seen_sync_job_id_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'last_seen_sync_job_id'
+);
+SET @courses_last_seen_sync_job_id_sql := IF(
+ @courses_last_seen_sync_job_id_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN last_seen_sync_job_id VARCHAR(36) NULL AFTER last_seen_at',
+ 'SELECT 1'
+);
+PREPARE courses_last_seen_sync_job_id_stmt FROM @courses_last_seen_sync_job_id_sql;
+EXECUTE courses_last_seen_sync_job_id_stmt;
+DEALLOCATE PREPARE courses_last_seen_sync_job_id_stmt;
+
+SET @courses_removed_at_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'removed_at'
+);
+SET @courses_removed_at_sql := IF(
+ @courses_removed_at_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN removed_at DATETIME NULL AFTER last_seen_sync_job_id',
+ 'SELECT 1'
+);
+PREPARE courses_removed_at_stmt FROM @courses_removed_at_sql;
+EXECUTE courses_removed_at_stmt;
+DEALLOCATE PREPARE courses_removed_at_stmt;
+
+UPDATE courses
+SET sync_status = 'active',
+ last_seen_at = COALESCE(last_seen_at, last_synced_at)
+WHERE source_type = 'third_party'
+ AND (sync_status IS NULL OR sync_status = '');
+
+SET @courses_sync_status_index_exists := (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'courses'
+ AND index_name = 'idx_courses_sync_status_seen'
+);
+SET @courses_sync_status_index_sql := IF(
+ @courses_sync_status_index_exists = 0,
+ 'CREATE INDEX idx_courses_sync_status_seen ON courses (source_type, provider, sync_status, last_seen_at)',
+ 'SELECT 1'
+);
+PREPARE courses_sync_status_index_stmt FROM @courses_sync_status_index_sql;
+EXECUTE courses_sync_status_index_stmt;
+DEALLOCATE PREPARE courses_sync_status_index_stmt;
diff --git a/packages/backend/src/database/migrations/202606070012_product_order_form_schema.sql b/packages/backend/src/database/migrations/202606070012_product_order_form_schema.sql
new file mode 100644
index 0000000..9af7c13
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606070012_product_order_form_schema.sql
@@ -0,0 +1,12 @@
+SET @courses_order_form_schema_exists := (
+ SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'courses' AND column_name = 'order_form_schema'
+);
+SET @courses_order_form_schema_sql := IF(
+ @courses_order_form_schema_exists = 0,
+ 'ALTER TABLE courses ADD COLUMN order_form_schema JSON NULL AFTER after_sales_note',
+ 'SELECT 1'
+);
+PREPARE courses_order_form_schema_stmt FROM @courses_order_form_schema_sql;
+EXECUTE courses_order_form_schema_stmt;
+DEALLOCATE PREPARE courses_order_form_schema_stmt;
diff --git a/packages/backend/src/database/migrations/202606070013_phase5_orders.sql b/packages/backend/src/database/migrations/202606070013_phase5_orders.sql
new file mode 100644
index 0000000..0e83434
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606070013_phase5_orders.sql
@@ -0,0 +1,60 @@
+CREATE TABLE IF NOT EXISTS orders (
+ id VARCHAR(36) NOT NULL,
+ order_no VARCHAR(40) NOT NULL,
+ user_id VARCHAR(36) NOT NULL,
+ source_type VARCHAR(32) NOT NULL,
+ provider VARCHAR(64) NULL,
+ total_amount DECIMAL(10, 2) NOT NULL,
+ payable_amount DECIMAL(10, 2) NOT NULL,
+ paid_amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
+ payment_status VARCHAR(32) NOT NULL,
+ fulfillment_status VARCHAR(32) NOT NULL,
+ remark VARCHAR(500) NULL,
+ paid_at DATETIME NULL,
+ closed_at DATETIME NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_orders_order_no (order_no),
+ KEY idx_orders_user_created (user_id, created_at),
+ KEY idx_orders_status_created (payment_status, fulfillment_status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS order_items (
+ id VARCHAR(36) NOT NULL,
+ order_id VARCHAR(36) NOT NULL,
+ product_id VARCHAR(36) NOT NULL,
+ product_name VARCHAR(255) NOT NULL,
+ source_type VARCHAR(32) NOT NULL,
+ external_product_id VARCHAR(160) NULL,
+ provider VARCHAR(64) NULL,
+ unit_price DECIMAL(10, 2) NOT NULL,
+ quantity INT NOT NULL DEFAULT 1,
+ total_amount DECIMAL(10, 2) NOT NULL,
+ payload JSON NULL,
+ encrypted_payload JSON NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ KEY idx_order_items_order_id (order_id),
+ KEY idx_order_items_product_id (product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS order_fulfillments (
+ id VARCHAR(36) NOT NULL,
+ order_id VARCHAR(36) NOT NULL,
+ order_item_id VARCHAR(36) NOT NULL,
+ fulfillment_type VARCHAR(40) NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ provider VARCHAR(64) NULL,
+ external_order_no VARCHAR(160) NULL,
+ request_payload JSON NULL,
+ response_payload JSON NULL,
+ error_message VARCHAR(500) NULL,
+ submitted_at DATETIME NULL,
+ completed_at DATETIME NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ KEY idx_order_fulfillments_order_id (order_id),
+ KEY idx_order_fulfillments_status (status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/packages/backend/src/database/migrations/202606070014_phase6_order_actions.sql b/packages/backend/src/database/migrations/202606070014_phase6_order_actions.sql
new file mode 100644
index 0000000..485a386
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606070014_phase6_order_actions.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS order_actions (
+ id VARCHAR(36) NOT NULL,
+ order_id VARCHAR(36) NOT NULL,
+ action VARCHAR(64) NOT NULL,
+ source_type VARCHAR(32) NOT NULL,
+ provider VARCHAR(64) NULL,
+ status VARCHAR(32) NOT NULL,
+ operator_id VARCHAR(36) NULL,
+ request_payload JSON NULL,
+ response_payload JSON NULL,
+ error_message VARCHAR(500) NULL,
+ remark VARCHAR(500) NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ KEY idx_order_actions_order_id (order_id, created_at),
+ KEY idx_order_actions_action_created (action, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/packages/backend/src/database/migrations/202606070015_third_party_orders.sql b/packages/backend/src/database/migrations/202606070015_third_party_orders.sql
new file mode 100644
index 0000000..74739bc
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606070015_third_party_orders.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS third_party_orders (
+ id VARCHAR(36) NOT NULL,
+ provider VARCHAR(64) NOT NULL,
+ external_order_no VARCHAR(160) NOT NULL,
+ username VARCHAR(160) NULL,
+ school VARCHAR(255) NULL,
+ course_name VARCHAR(255) NULL,
+ remote_status VARCHAR(120) NULL,
+ local_order_id VARCHAR(36) NULL,
+ raw_payload JSON NULL,
+ first_seen_at DATETIME NOT NULL,
+ last_seen_at DATETIME NOT NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_third_party_orders_provider_external (provider, external_order_no),
+ KEY idx_third_party_orders_last_seen (provider, last_seen_at),
+ KEY idx_third_party_orders_local_order (local_order_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/packages/backend/src/database/migrations/202606070016_phase7_order_logs_permissions.sql b/packages/backend/src/database/migrations/202606070016_phase7_order_logs_permissions.sql
new file mode 100644
index 0000000..0385af3
--- /dev/null
+++ b/packages/backend/src/database/migrations/202606070016_phase7_order_logs_permissions.sql
@@ -0,0 +1,7 @@
+UPDATE roles
+SET permissions = JSON_SET(
+ COALESCE(permissions, JSON_OBJECT()),
+ '$."order-logs"',
+ JSON_ARRAY('read', 'write')
+)
+WHERE code = 'super_admin';
diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts
new file mode 100644
index 0000000..cad3aaf
--- /dev/null
+++ b/packages/backend/src/main.ts
@@ -0,0 +1,12 @@
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+import { HttpExceptionFilter } from './common/response/http-exception.filter';
+import { ResponseInterceptor } from './common/response/response.interceptor';
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule);
+ app.useGlobalFilters(new HttpExceptionFilter());
+ app.useGlobalInterceptors(new ResponseInterceptor());
+ await app.listen(Number(process.env.PORT ?? 3001));
+}
+bootstrap();
diff --git a/packages/backend/src/order-logs/dto/order-logs.dto.ts b/packages/backend/src/order-logs/dto/order-logs.dto.ts
new file mode 100644
index 0000000..a56488e
--- /dev/null
+++ b/packages/backend/src/order-logs/dto/order-logs.dto.ts
@@ -0,0 +1,18 @@
+export type ThirdPartyOrderLogType = 'normal' | 'zhs' | 'yjy';
+
+export type QueryThirdPartyOrderLogDto = {
+ orderId?: string;
+ type?: ThirdPartyOrderLogType;
+};
+
+export type QueryStreamOrderLogDto = {
+ orderId?: string;
+};
+
+export type OrderLogHistoryQuery = {
+ page?: number | string;
+ pageSize?: number | string;
+ type?: string;
+ status?: string;
+ orderId?: string;
+};
diff --git a/packages/backend/src/order-logs/order-logs.controller.ts b/packages/backend/src/order-logs/order-logs.controller.ts
new file mode 100644
index 0000000..c1db3e6
--- /dev/null
+++ b/packages/backend/src/order-logs/order-logs.controller.ts
@@ -0,0 +1,52 @@
+import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import type {
+ OrderLogHistoryQuery,
+ QueryStreamOrderLogDto,
+ QueryThirdPartyOrderLogDto,
+} from './dto/order-logs.dto';
+import { OrderLogsService } from './order-logs.service';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin/order-logs')
+export class OrderLogsController {
+ constructor(private readonly orderLogsService: OrderLogsService) {}
+
+ @Post('query')
+ @OperationLog({
+ action: 'order_logs.query',
+ resourceType: 'order_log',
+ metadataFromBody: ['orderId', 'type'],
+ description: '查询第三方订单日志',
+ })
+ queryThirdPartyLog(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: QueryThirdPartyOrderLogDto,
+ ) {
+ return this.orderLogsService.queryThirdPartyLog(request.user, dto);
+ }
+
+ @Post('stream')
+ @OperationLog({
+ action: 'order_logs.stream',
+ resourceType: 'order_log',
+ metadataFromBody: ['orderId'],
+ description: '查询第三方 Stream 日志',
+ })
+ queryStreamLog(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: QueryStreamOrderLogDto,
+ ) {
+ return this.orderLogsService.queryStreamLog(request.user, dto);
+ }
+
+ @Get('history')
+ listHistory(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: OrderLogHistoryQuery,
+ ) {
+ return this.orderLogsService.listHistory(request.user, query);
+ }
+}
diff --git a/packages/backend/src/order-logs/order-logs.module.ts b/packages/backend/src/order-logs/order-logs.module.ts
new file mode 100644
index 0000000..3bf0147
--- /dev/null
+++ b/packages/backend/src/order-logs/order-logs.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AuthModule } from '../auth/auth.module';
+import { ApiCallLog } from '../third-party/entities/api-call-log.entity';
+import { ThirdPartyModule } from '../third-party/third-party.module';
+import { OrderLogsController } from './order-logs.controller';
+import { OrderLogsService } from './order-logs.service';
+
+@Module({
+ imports: [AuthModule, ThirdPartyModule, TypeOrmModule.forFeature([ApiCallLog])],
+ controllers: [OrderLogsController],
+ providers: [OrderLogsService],
+})
+export class OrderLogsModule {}
diff --git a/packages/backend/src/order-logs/order-logs.service.ts b/packages/backend/src/order-logs/order-logs.service.ts
new file mode 100644
index 0000000..e81fbab
--- /dev/null
+++ b/packages/backend/src/order-logs/order-logs.service.ts
@@ -0,0 +1,279 @@
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { assertAdmin } from '../admin/admin-access';
+import { ApiCallLog } from '../third-party/entities/api-call-log.entity';
+import { maskSensitivePayload } from '../third-party/third-party-security';
+import { ThirdPartyClientService } from '../third-party/third-party-client.service';
+import { User } from '../users/entities/user.entity';
+import type {
+ OrderLogHistoryQuery,
+ QueryStreamOrderLogDto,
+ QueryThirdPartyOrderLogDto,
+ ThirdPartyOrderLogType,
+} from './dto/order-logs.dto';
+
+const logTypeActs: Record = {
+ normal: 'cha_logwk',
+ zhs: 'cha_log',
+ yjy: 'get_yjy_study_log',
+};
+
+@Injectable()
+export class OrderLogsService {
+ constructor(
+ private readonly configService: ConfigService,
+ private readonly thirdPartyClientService: ThirdPartyClientService,
+ @InjectRepository(ApiCallLog)
+ private readonly apiCallLogsRepository: Repository,
+ ) {}
+
+ async queryThirdPartyLog(actor: User, dto: QueryThirdPartyOrderLogDto) {
+ assertAdmin(actor);
+ const orderId = this.getRequiredOrderId(dto.orderId);
+ const type = this.getLogType(dto.type);
+ const act = logTypeActs[type];
+ const response = await this.thirdPartyClientService.call(act, {
+ id: orderId,
+ });
+
+ return {
+ provider: 'biedawo',
+ type,
+ act,
+ orderId,
+ queriedAt: new Date().toISOString(),
+ response,
+ entries: this.normalizeEntries(response),
+ };
+ }
+
+ async queryStreamLog(actor: User, dto: QueryStreamOrderLogDto) {
+ assertAdmin(actor);
+ const orderId = this.getRequiredOrderId(dto.orderId);
+ const startedAt = Date.now();
+ const requestUrl = this.buildStreamLogUrl(orderId);
+ let httpStatus: number | null = null;
+ let responsePayload: unknown | null = null;
+ let errorMessage: string | null = null;
+ let status: 'success' | 'failure' = 'failure';
+ let logged = false;
+
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 15_000);
+ try {
+ const response = await fetch(requestUrl, {
+ method: 'GET',
+ signal: controller.signal,
+ });
+ httpStatus = response.status;
+ const text = await response.text();
+ responsePayload = this.parseStreamText(text);
+ status = response.ok ? 'success' : 'failure';
+ if (!response.ok) {
+ errorMessage = `HTTP ${response.status}`;
+ }
+
+ await this.writeStreamCallLog({
+ orderId,
+ requestUrl,
+ status,
+ httpStatus,
+ durationMs: Date.now() - startedAt,
+ responsePayload,
+ errorMessage,
+ });
+ logged = true;
+
+ if (!response.ok) {
+ throw new BadRequestException(`第三方 Stream 日志请求失败:HTTP ${response.status}`);
+ }
+
+ return {
+ provider: 'biedawo',
+ type: 'stream',
+ act: 'streamLogs',
+ orderId,
+ queriedAt: new Date().toISOString(),
+ response: responsePayload,
+ entries: this.normalizeStreamEntries(responsePayload),
+ };
+ } finally {
+ clearTimeout(timeout);
+ }
+ } catch (error) {
+ const message = this.getErrorMessage(error);
+ if (!errorMessage) {
+ errorMessage = message;
+ }
+ if (!httpStatus) {
+ responsePayload = null;
+ }
+ if (!logged) {
+ await this.writeStreamCallLog({
+ orderId,
+ requestUrl,
+ status: 'failure',
+ httpStatus,
+ durationMs: Date.now() - startedAt,
+ responsePayload,
+ errorMessage,
+ });
+ }
+ if (error instanceof BadRequestException) {
+ throw error;
+ }
+ throw new BadRequestException(message);
+ }
+ }
+
+ async listHistory(actor: User, query: OrderLogHistoryQuery) {
+ assertAdmin(actor);
+ const page = Math.max(Number(query.page || 1), 1);
+ const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
+ const type = String(query.type || '').trim();
+ const status = String(query.status || '').trim();
+ const orderId = String(query.orderId || '').trim();
+ const acts = type ? [this.typeToAct(type)] : Object.values(logTypeActs).concat('streamLogs');
+
+ const builder = this.apiCallLogsRepository
+ .createQueryBuilder('log')
+ .where('log.act IN (:...acts)', { acts })
+ .orderBy('log.createdAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (status) {
+ builder.andWhere('log.status = :status', { status });
+ }
+ if (orderId) {
+ builder.andWhere(
+ 'JSON_UNQUOTE(JSON_EXTRACT(log.requestPayload, "$.id")) LIKE :orderId',
+ { orderId: `%${orderId}%` },
+ );
+ }
+
+ const [list, total] = await builder.getManyAndCount();
+ return { list, total, page, pageSize };
+ }
+
+ private getRequiredOrderId(value: unknown) {
+ const orderId = String(value || '').trim();
+ if (!orderId) {
+ throw new BadRequestException('请填写第三方订单 ID');
+ }
+ return orderId;
+ }
+
+ private getLogType(value: unknown): ThirdPartyOrderLogType {
+ const type = String(value || 'normal').trim() as ThirdPartyOrderLogType;
+ if (!Object.hasOwn(logTypeActs, type)) {
+ throw new BadRequestException('不支持的日志类型');
+ }
+ return type;
+ }
+
+ private typeToAct(type: string) {
+ if (type === 'stream') {
+ return 'streamLogs';
+ }
+ return logTypeActs[this.getLogType(type)];
+ }
+
+ private buildStreamLogUrl(orderId: string) {
+ const apiUrl = new URL(this.configService.getOrThrow('WK_BASE_URL'));
+ const streamUrl = new URL('/api/streamLogs', apiUrl.origin);
+ streamUrl.searchParams.set('id', orderId);
+ return streamUrl.toString();
+ }
+
+ private parseStreamText(text: string) {
+ const lines = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ const events = lines.map((line) => {
+ if (!line.startsWith('data:')) {
+ return { raw: line };
+ }
+ const data = line.replace(/^data:\s*/, '');
+ try {
+ return JSON.parse(data) as unknown;
+ } catch {
+ return { raw: data };
+ }
+ });
+
+ return {
+ raw: text,
+ events,
+ };
+ }
+
+ private normalizeEntries(response: unknown) {
+ const data = this.extractData(response);
+ if (Array.isArray(data)) {
+ return data;
+ }
+ if (data && typeof data === 'object') {
+ const body = data as Record;
+ for (const key of ['list', 'logs', 'records', 'rows']) {
+ if (Array.isArray(body[key])) {
+ return body[key];
+ }
+ }
+ }
+ return [];
+ }
+
+ private normalizeStreamEntries(response: unknown) {
+ if (!response || typeof response !== 'object') {
+ return [];
+ }
+ const events = (response as { events?: unknown }).events;
+ return Array.isArray(events) ? events : [];
+ }
+
+ private extractData(response: unknown) {
+ if (!response || typeof response !== 'object') {
+ return response;
+ }
+ const body = response as Record;
+ return body.data ?? body.result ?? body.rows ?? body;
+ }
+
+ private async writeStreamCallLog(input: {
+ orderId: string;
+ requestUrl: string;
+ status: 'success' | 'failure';
+ httpStatus: number | null;
+ durationMs: number;
+ responsePayload: unknown | null;
+ errorMessage: string | null;
+ }) {
+ await this.thirdPartyClientService.writeLog({
+ act: 'streamLogs',
+ method: 'GET',
+ url: input.requestUrl,
+ status: input.status,
+ httpStatus: input.httpStatus,
+ durationMs: input.durationMs,
+ requestPayload: maskSensitivePayload({
+ act: 'streamLogs',
+ id: input.orderId,
+ }),
+ responsePayload: maskSensitivePayload(input.responsePayload),
+ errorMessage: input.errorMessage,
+ });
+ }
+
+ private getErrorMessage(error: unknown) {
+ if (error instanceof Error) {
+ return error.name === 'AbortError' ? '第三方 Stream 日志请求超时' : error.message;
+ }
+ return '第三方 Stream 日志请求失败';
+ }
+}
diff --git a/packages/backend/src/orders/dto/orders.dto.ts b/packages/backend/src/orders/dto/orders.dto.ts
new file mode 100644
index 0000000..e8363cc
--- /dev/null
+++ b/packages/backend/src/orders/dto/orders.dto.ts
@@ -0,0 +1,73 @@
+import type { SourceType } from '../../third-party/entities/category.entity';
+
+export type ThirdPartyCourseQueryDto = {
+ productId?: string;
+ school?: string;
+ account?: string;
+ password?: string;
+ expand?: Record | null;
+};
+
+export type SelectedCourseDto = {
+ courseName?: string;
+ courseId?: string | null;
+ raw?: Record | null;
+};
+
+export type CreateOrderDto = {
+ productId?: string;
+ quantity?: number;
+ orderPayload?: Record | null;
+ selectedCourse?: SelectedCourseDto | null;
+ expand?: Record | null;
+ remark?: string | null;
+};
+
+export type OrderListQuery = {
+ page?: number;
+ pageSize?: number;
+ keyword?: string;
+ sourceType?: SourceType | 'all';
+ paymentStatus?: string;
+ fulfillmentStatus?: string;
+};
+
+export type ThirdPartyOrderAction =
+ | 'budan'
+ | 'gaimi'
+ | 'stop'
+ | 'priority'
+ | 'convert'
+ | 'update-time'
+ | 'update-cycle';
+
+export type ThirdPartyOrderActionDto = {
+ externalOrderNo?: string | null;
+ username?: string | null;
+ newPwd?: string | null;
+ remark?: string | string[] | null;
+ city?: string | null;
+ tag?: string | null;
+ config?: Record | string | null;
+ autoReset?: boolean | number | string | null;
+ convertToClassId?: string | number | null;
+ time?: string | number | null;
+ cycle?: string | number | null;
+};
+
+export type ThirdPartyOrderListSyncDto = {
+ page?: string | number | null;
+ limit?: string | number | null;
+ recent?: string | number | null;
+};
+
+export type LocalOrderAction =
+ | 'process'
+ | 'complete'
+ | 'cancel'
+ | 'close'
+ | 'remark';
+
+export type LocalOrderActionDto = {
+ remark?: string | null;
+};
diff --git a/packages/backend/src/orders/entities/order-action.entity.ts b/packages/backend/src/orders/entities/order-action.entity.ts
new file mode 100644
index 0000000..7b3b38a
--- /dev/null
+++ b/packages/backend/src/orders/entities/order-action.entity.ts
@@ -0,0 +1,51 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+} from 'typeorm';
+import type { SourceType } from '../../third-party/entities/category.entity';
+
+export type OrderActionStatus = 'success' | 'failed';
+
+@Entity('order_actions')
+@Index('idx_order_actions_order_id', ['orderId', 'createdAt'])
+@Index('idx_order_actions_action_created', ['action', 'createdAt'])
+export class OrderAction {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ name: 'order_id', type: 'varchar', length: 36 })
+ orderId: string;
+
+ @Column({ type: 'varchar', length: 64 })
+ action: string;
+
+ @Column({ name: 'source_type', type: 'varchar', length: 32 })
+ sourceType: SourceType;
+
+ @Column({ type: 'varchar', length: 64, nullable: true })
+ provider: string | null;
+
+ @Column({ type: 'varchar', length: 32 })
+ status: OrderActionStatus;
+
+ @Column({ name: 'operator_id', type: 'varchar', length: 36, nullable: true })
+ operatorId: string | null;
+
+ @Column({ name: 'request_payload', type: 'json', nullable: true })
+ requestPayload: Record | null;
+
+ @Column({ name: 'response_payload', type: 'json', nullable: true })
+ responsePayload: unknown | null;
+
+ @Column({ name: 'error_message', type: 'varchar', length: 500, nullable: true })
+ errorMessage: string | null;
+
+ @Column({ type: 'varchar', length: 500, nullable: true })
+ remark: string | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+}
diff --git a/packages/backend/src/orders/entities/order-fulfillment.entity.ts b/packages/backend/src/orders/entities/order-fulfillment.entity.ts
new file mode 100644
index 0000000..51750bc
--- /dev/null
+++ b/packages/backend/src/orders/entities/order-fulfillment.entity.ts
@@ -0,0 +1,58 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import type { FulfillmentStatus } from './order.entity';
+
+export type FulfillmentType = 'third_party_api' | 'local_only' | 'manual';
+
+@Entity('order_fulfillments')
+@Index('idx_order_fulfillments_order_id', ['orderId'])
+@Index('idx_order_fulfillments_status', ['status', 'createdAt'])
+export class OrderFulfillment {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ name: 'order_id', type: 'varchar', length: 36 })
+ orderId: string;
+
+ @Column({ name: 'order_item_id', type: 'varchar', length: 36 })
+ orderItemId: string;
+
+ @Column({ name: 'fulfillment_type', type: 'varchar', length: 40 })
+ fulfillmentType: FulfillmentType;
+
+ @Column({ type: 'varchar', length: 32 })
+ status: FulfillmentStatus;
+
+ @Column({ type: 'varchar', length: 64, nullable: true })
+ provider: string | null;
+
+ @Column({ name: 'external_order_no', type: 'varchar', length: 160, nullable: true })
+ externalOrderNo: string | null;
+
+ @Column({ name: 'request_payload', type: 'json', nullable: true })
+ requestPayload: Record | null;
+
+ @Column({ name: 'response_payload', type: 'json', nullable: true })
+ responsePayload: unknown | null;
+
+ @Column({ name: 'error_message', type: 'varchar', length: 500, nullable: true })
+ errorMessage: string | null;
+
+ @Column({ name: 'submitted_at', type: 'datetime', nullable: true })
+ submittedAt: Date | null;
+
+ @Column({ name: 'completed_at', type: 'datetime', nullable: true })
+ completedAt: Date | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+}
diff --git a/packages/backend/src/orders/entities/order-item.entity.ts b/packages/backend/src/orders/entities/order-item.entity.ts
new file mode 100644
index 0000000..1dee744
--- /dev/null
+++ b/packages/backend/src/orders/entities/order-item.entity.ts
@@ -0,0 +1,52 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+} from 'typeorm';
+import type { SourceType } from '../../third-party/entities/category.entity';
+
+@Entity('order_items')
+@Index('idx_order_items_order_id', ['orderId'])
+@Index('idx_order_items_product_id', ['productId'])
+export class OrderItem {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ name: 'order_id', type: 'varchar', length: 36 })
+ orderId: string;
+
+ @Column({ name: 'product_id', type: 'varchar', length: 36 })
+ productId: string;
+
+ @Column({ name: 'product_name', type: 'varchar', length: 255 })
+ productName: string;
+
+ @Column({ name: 'source_type', type: 'varchar', length: 32 })
+ sourceType: SourceType;
+
+ @Column({ name: 'external_product_id', type: 'varchar', length: 160, nullable: true })
+ externalProductId: string | null;
+
+ @Column({ type: 'varchar', length: 64, nullable: true })
+ provider: string | null;
+
+ @Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 })
+ unitPrice: string;
+
+ @Column({ type: 'int', default: 1 })
+ quantity: number;
+
+ @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2 })
+ totalAmount: string;
+
+ @Column({ type: 'json', nullable: true })
+ payload: Record | null;
+
+ @Column({ name: 'encrypted_payload', type: 'json', nullable: true })
+ encryptedPayload: Record | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+}
diff --git a/packages/backend/src/orders/entities/order.entity.ts b/packages/backend/src/orders/entities/order.entity.ts
new file mode 100644
index 0000000..5d4df43
--- /dev/null
+++ b/packages/backend/src/orders/entities/order.entity.ts
@@ -0,0 +1,89 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import type { SourceType } from '../../third-party/entities/category.entity';
+
+export type PaymentStatus =
+ | 'unpaid'
+ | 'paying'
+ | 'paid'
+ | 'pay_failed'
+ | 'closed'
+ | 'refunding'
+ | 'refunded'
+ | 'partial_refunded';
+
+export type FulfillmentStatus =
+ | 'pending'
+ | 'submitting'
+ | 'submitted'
+ | 'processing'
+ | 'completed'
+ | 'failed'
+ | 'canceled';
+
+@Entity('orders')
+@Index('uk_orders_order_no', ['orderNo'], { unique: true })
+@Index('idx_orders_user_created', ['userId', 'createdAt'])
+@Index('idx_orders_status_created', [
+ 'paymentStatus',
+ 'fulfillmentStatus',
+ 'createdAt',
+])
+export class Order {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ name: 'order_no', type: 'varchar', length: 40 })
+ orderNo: string;
+
+ @Column({ name: 'user_id', type: 'varchar', length: 36 })
+ userId: string;
+
+ @Column({ name: 'source_type', type: 'varchar', length: 32 })
+ sourceType: SourceType;
+
+ @Column({ type: 'varchar', length: 64, nullable: true })
+ provider: string | null;
+
+ @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2 })
+ totalAmount: string;
+
+ @Column({ name: 'payable_amount', type: 'decimal', precision: 10, scale: 2 })
+ payableAmount: string;
+
+ @Column({
+ name: 'paid_amount',
+ type: 'decimal',
+ precision: 10,
+ scale: 2,
+ default: 0,
+ })
+ paidAmount: string;
+
+ @Column({ name: 'payment_status', type: 'varchar', length: 32 })
+ paymentStatus: PaymentStatus;
+
+ @Column({ name: 'fulfillment_status', type: 'varchar', length: 32 })
+ fulfillmentStatus: FulfillmentStatus;
+
+ @Column({ type: 'varchar', length: 500, nullable: true })
+ remark: string | null;
+
+ @Column({ name: 'paid_at', type: 'datetime', nullable: true })
+ paidAt: Date | null;
+
+ @Column({ name: 'closed_at', type: 'datetime', nullable: true })
+ closedAt: Date | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+}
diff --git a/packages/backend/src/orders/entities/third-party-order.entity.ts b/packages/backend/src/orders/entities/third-party-order.entity.ts
new file mode 100644
index 0000000..06f7fc2
--- /dev/null
+++ b/packages/backend/src/orders/entities/third-party-order.entity.ts
@@ -0,0 +1,57 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+
+export const THIRD_PARTY_ORDER_PROVIDER = 'biedawo';
+
+@Entity('third_party_orders')
+@Index('uk_third_party_orders_provider_external', ['provider', 'externalOrderNo'], {
+ unique: true,
+})
+@Index('idx_third_party_orders_last_seen', ['provider', 'lastSeenAt'])
+@Index('idx_third_party_orders_local_order', ['localOrderId'])
+export class ThirdPartyOrder {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ type: 'varchar', length: 64 })
+ provider: string;
+
+ @Column({ name: 'external_order_no', type: 'varchar', length: 160 })
+ externalOrderNo: string;
+
+ @Column({ type: 'varchar', length: 160, nullable: true })
+ username: string | null;
+
+ @Column({ type: 'varchar', length: 255, nullable: true })
+ school: string | null;
+
+ @Column({ name: 'course_name', type: 'varchar', length: 255, nullable: true })
+ courseName: string | null;
+
+ @Column({ name: 'remote_status', type: 'varchar', length: 120, nullable: true })
+ remoteStatus: string | null;
+
+ @Column({ name: 'local_order_id', type: 'varchar', length: 36, nullable: true })
+ localOrderId: string | null;
+
+ @Column({ name: 'raw_payload', type: 'json', nullable: true })
+ rawPayload: Record | null;
+
+ @Column({ name: 'first_seen_at', type: 'datetime' })
+ firstSeenAt: Date;
+
+ @Column({ name: 'last_seen_at', type: 'datetime' })
+ lastSeenAt: Date;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+}
diff --git a/packages/backend/src/orders/orders-third-party-admin.controller.ts b/packages/backend/src/orders/orders-third-party-admin.controller.ts
new file mode 100644
index 0000000..d05a551
--- /dev/null
+++ b/packages/backend/src/orders/orders-third-party-admin.controller.ts
@@ -0,0 +1,26 @@
+import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import type { ThirdPartyOrderListSyncDto } from './dto/orders.dto';
+import { OrdersService } from './orders.service';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin/third-party/orders')
+export class OrdersThirdPartyAdminController {
+ constructor(private readonly ordersService: OrdersService) {}
+
+ @Post('sync')
+ @OperationLog({
+ action: 'third_party.orders.sync',
+ resourceType: 'order',
+ metadataFromBody: ['page', 'limit', 'recent'],
+ description: '拉取第三方订单列表并同步本地订单',
+ })
+ syncThirdPartyOrderList(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: ThirdPartyOrderListSyncDto,
+ ) {
+ return this.ordersService.syncThirdPartyOrderList(request.user, dto);
+ }
+}
diff --git a/packages/backend/src/orders/orders-third-party-sync.task.ts b/packages/backend/src/orders/orders-third-party-sync.task.ts
new file mode 100644
index 0000000..379e880
--- /dev/null
+++ b/packages/backend/src/orders/orders-third-party-sync.task.ts
@@ -0,0 +1,30 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+import { OrdersService } from './orders.service';
+
+@Injectable()
+export class OrdersThirdPartySyncTask {
+ private readonly logger = new Logger(OrdersThirdPartySyncTask.name);
+
+ constructor(private readonly ordersService: OrdersService) {}
+
+ @Cron(process.env.THIRD_PARTY_ORDER_SYNC_CRON || '*/30 * * * *')
+ async handleOrderSync() {
+ if (process.env.THIRD_PARTY_ORDER_SYNC_CRON_ENABLED !== 'true') {
+ return;
+ }
+
+ try {
+ await this.ordersService.syncThirdPartyOrderListFromTask({
+ page: process.env.THIRD_PARTY_ORDER_SYNC_PAGE,
+ limit: process.env.THIRD_PARTY_ORDER_SYNC_LIMIT,
+ recent: process.env.THIRD_PARTY_ORDER_SYNC_RECENT,
+ });
+ } catch (error) {
+ this.logger.error(
+ error instanceof Error ? error.message : String(error),
+ error instanceof Error ? error.stack : undefined,
+ );
+ }
+ }
+}
diff --git a/packages/backend/src/orders/orders.controller.ts b/packages/backend/src/orders/orders.controller.ts
new file mode 100644
index 0000000..5177683
--- /dev/null
+++ b/packages/backend/src/orders/orders.controller.ts
@@ -0,0 +1,175 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ Patch,
+ Post,
+ Query,
+ Req,
+ UseGuards,
+} from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import type {
+ CreateOrderDto,
+ LocalOrderAction,
+ LocalOrderActionDto,
+ OrderListQuery,
+ ThirdPartyOrderAction,
+ ThirdPartyOrderActionDto,
+ ThirdPartyOrderListSyncDto,
+ ThirdPartyCourseQueryDto,
+} from './dto/orders.dto';
+import { OrdersService } from './orders.service';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin/orders')
+export class OrdersController {
+ constructor(private readonly ordersService: OrdersService) {}
+
+ @Post('course-query')
+ @OperationLog({
+ action: 'orders.course_query',
+ resourceType: 'order',
+ metadataFromBody: ['productId', 'school', 'account'],
+ description: '第三方查课',
+ })
+ queryThirdPartyCourses(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: ThirdPartyCourseQueryDto,
+ ) {
+ return this.ordersService.queryThirdPartyCourses(request.user, dto);
+ }
+
+ @Post()
+ @OperationLog({
+ action: 'orders.create',
+ resourceType: 'order',
+ resourceIdFromResult: 'id',
+ metadataFromBody: ['productId', 'quantity', 'remark'],
+ description: '创建订单',
+ })
+ createOrder(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: CreateOrderDto,
+ ) {
+ return this.ordersService.createOrder(request.user, dto);
+ }
+
+ @Get()
+ listOrders(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: OrderListQuery,
+ ) {
+ return this.ordersService.listOrders(request.user, query);
+ }
+
+ @Get(':idOrNo')
+ getOrderDetail(
+ @Req() request: AuthenticatedRequest,
+ @Param('idOrNo') idOrNo: string,
+ ) {
+ return this.ordersService.getOrderDetail(request.user, idOrNo);
+ }
+
+ @Patch(':idOrNo/mark-paid')
+ @OperationLog({
+ action: 'orders.mark_paid',
+ resourceType: 'order',
+ resourceIdParam: 'idOrNo',
+ description: '标记订单已支付并触发履约',
+ })
+ markOrderPaid(
+ @Req() request: AuthenticatedRequest,
+ @Param('idOrNo') idOrNo: string,
+ ) {
+ return this.ordersService.markOrderPaid(request.user, idOrNo);
+ }
+
+ @Post('third-party/sync-list')
+ @OperationLog({
+ action: 'orders.third_party.sync_list',
+ resourceType: 'order',
+ metadataFromBody: ['page', 'limit', 'recent'],
+ description: '拉取第三方订单列表并同步本地订单',
+ })
+ syncThirdPartyOrderList(
+ @Req() request: AuthenticatedRequest,
+ @Body() dto: ThirdPartyOrderListSyncDto,
+ ) {
+ return this.ordersService.syncThirdPartyOrderList(request.user, dto);
+ }
+
+ @Post(':idOrNo/third-party/refresh')
+ @OperationLog({
+ action: 'orders.third_party.refresh',
+ resourceType: 'order',
+ resourceIdParam: 'idOrNo',
+ metadataFromBody: ['externalOrderNo', 'username'],
+ description: '第三方订单查单刷新',
+ })
+ refreshThirdPartyOrder(
+ @Req() request: AuthenticatedRequest,
+ @Param('idOrNo') idOrNo: string,
+ @Body() dto: ThirdPartyOrderActionDto,
+ ) {
+ return this.ordersService.refreshThirdPartyOrder(
+ request.user,
+ idOrNo,
+ dto,
+ );
+ }
+
+ @Post(':idOrNo/third-party/:action')
+ @OperationLog({
+ action: 'orders.third_party.action',
+ resourceType: 'order',
+ resourceIdParam: 'idOrNo',
+ metadataFromBody: [
+ 'externalOrderNo',
+ 'username',
+ 'convertToClassId',
+ 'time',
+ 'cycle',
+ 'autoReset',
+ ],
+ description: '第三方订单操作',
+ })
+ runThirdPartyOrderAction(
+ @Req() request: AuthenticatedRequest,
+ @Param('idOrNo') idOrNo: string,
+ @Param('action') action: ThirdPartyOrderAction,
+ @Body() dto: ThirdPartyOrderActionDto,
+ ) {
+ return this.ordersService.runThirdPartyOrderAction(
+ request.user,
+ idOrNo,
+ action,
+ dto,
+ );
+ }
+
+ @Patch(':idOrNo/local/:action')
+ @OperationLog({
+ action: 'orders.local.action',
+ resourceType: 'order',
+ resourceIdParam: 'idOrNo',
+ metadataFromBody: ['remark'],
+ description: '自营订单本地操作',
+ })
+ runLocalOrderAction(
+ @Req() request: AuthenticatedRequest,
+ @Param('idOrNo') idOrNo: string,
+ @Param('action') action: LocalOrderAction,
+ @Body() dto: LocalOrderActionDto,
+ ) {
+ return this.ordersService.runLocalOrderAction(
+ request.user,
+ idOrNo,
+ action,
+ dto,
+ );
+ }
+}
diff --git a/packages/backend/src/orders/orders.module.ts b/packages/backend/src/orders/orders.module.ts
new file mode 100644
index 0000000..85f9bb6
--- /dev/null
+++ b/packages/backend/src/orders/orders.module.ts
@@ -0,0 +1,33 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AuthModule } from '../auth/auth.module';
+import { ThirdPartyModule } from '../third-party/third-party.module';
+import { Course } from '../third-party/entities/course.entity';
+import { OrderAction } from './entities/order-action.entity';
+import { OrderFulfillment } from './entities/order-fulfillment.entity';
+import { OrderItem } from './entities/order-item.entity';
+import { Order } from './entities/order.entity';
+import { ThirdPartyOrder } from './entities/third-party-order.entity';
+import { OrdersController } from './orders.controller';
+import { OrdersThirdPartyAdminController } from './orders-third-party-admin.controller';
+import { OrdersThirdPartySyncTask } from './orders-third-party-sync.task';
+import { OrdersService } from './orders.service';
+
+@Module({
+ imports: [
+ AuthModule,
+ ThirdPartyModule,
+ TypeOrmModule.forFeature([
+ Course,
+ Order,
+ OrderItem,
+ OrderFulfillment,
+ OrderAction,
+ ThirdPartyOrder,
+ ]),
+ ],
+ controllers: [OrdersController, OrdersThirdPartyAdminController],
+ providers: [OrdersService, OrdersThirdPartySyncTask],
+ exports: [OrdersService],
+})
+export class OrdersModule {}
diff --git a/packages/backend/src/orders/orders.service.ts b/packages/backend/src/orders/orders.service.ts
new file mode 100644
index 0000000..ed4d3a7
--- /dev/null
+++ b/packages/backend/src/orders/orders.service.ts
@@ -0,0 +1,1630 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { randomBytes, randomUUID, createCipheriv, createDecipheriv } from 'node:crypto';
+import { Brackets, Repository } from 'typeorm';
+import { ConfigService } from '@nestjs/config';
+import { assertAdmin } from '../admin/admin-access';
+import { maskSensitivePayload } from '../third-party/third-party-security';
+import { ThirdPartyClientService } from '../third-party/third-party-client.service';
+import { Course, OrderFormField } from '../third-party/entities/course.entity';
+import { User } from '../users/entities/user.entity';
+import type {
+ CreateOrderDto,
+ LocalOrderAction,
+ LocalOrderActionDto,
+ OrderListQuery,
+ SelectedCourseDto,
+ ThirdPartyOrderAction,
+ ThirdPartyOrderActionDto,
+ ThirdPartyOrderListSyncDto,
+ ThirdPartyCourseQueryDto,
+} from './dto/orders.dto';
+import { OrderAction } from './entities/order-action.entity';
+import { OrderFulfillment } from './entities/order-fulfillment.entity';
+import { OrderItem } from './entities/order-item.entity';
+import {
+ FulfillmentStatus,
+ Order,
+ PaymentStatus,
+} from './entities/order.entity';
+import {
+ ThirdPartyOrder,
+ THIRD_PARTY_ORDER_PROVIDER,
+} from './entities/third-party-order.entity';
+
+type UnknownRecord = Record;
+
+const SOURCE_ALL = 'all';
+const THIRD_PARTY_SOURCE = 'third_party';
+const SELF_OWNED_SOURCE = 'self_owned';
+const PAYMENT_STATUSES = new Set([
+ 'unpaid',
+ 'paying',
+ 'paid',
+ 'pay_failed',
+ 'closed',
+ 'refunding',
+ 'refunded',
+ 'partial_refunded',
+]);
+const FULFILLMENT_STATUSES = new Set([
+ 'pending',
+ 'submitting',
+ 'submitted',
+ 'processing',
+ 'completed',
+ 'failed',
+ 'canceled',
+]);
+const THIRD_PARTY_ACTIONS: Record = {
+ budan: 'budan',
+ gaimi: 'gaimi',
+ stop: 'stop',
+ priority: 'priority',
+ convert: 'convert',
+ 'update-time': 'update_time',
+ 'update-cycle': 'update_cycle',
+};
+const LOCAL_ORDER_ACTIONS = new Set([
+ 'process',
+ 'complete',
+ 'cancel',
+ 'close',
+ 'remark',
+]);
+const SENSITIVE_ORDER_KEYS = new Set([
+ 'password',
+ 'pass',
+ 'studentPassword',
+ 'accountPassword',
+]);
+
+@Injectable()
+export class OrdersService {
+ constructor(
+ private readonly configService: ConfigService,
+ private readonly thirdPartyClientService: ThirdPartyClientService,
+ @InjectRepository(Course)
+ private readonly productsRepository: Repository,
+ @InjectRepository(Order)
+ private readonly ordersRepository: Repository,
+ @InjectRepository(OrderItem)
+ private readonly orderItemsRepository: Repository,
+ @InjectRepository(OrderFulfillment)
+ private readonly fulfillmentsRepository: Repository,
+ @InjectRepository(OrderAction)
+ private readonly orderActionsRepository: Repository,
+ @InjectRepository(ThirdPartyOrder)
+ private readonly thirdPartyOrdersRepository: Repository,
+ ) {}
+
+ async queryThirdPartyCourses(actor: User, dto: ThirdPartyCourseQueryDto) {
+ assertAdmin(actor);
+ const product = await this.getProduct(dto.productId);
+ this.assertPurchasable(product);
+ if (product.sourceType !== THIRD_PARTY_SOURCE) {
+ throw new BadRequestException('只有第三方商品需要查课');
+ }
+
+ const school = this.normalizeRequiredString(dto.school, '学校不能为空');
+ const account = this.normalizeRequiredString(dto.account, '账号不能为空');
+ const password = this.normalizeRequiredString(dto.password, '密码不能为空');
+ const platform = this.getThirdPartyPlatform(product);
+ const payload: UnknownRecord = {
+ platform,
+ school,
+ user: account,
+ pass: password,
+ };
+ const expand = this.normalizeRecord(dto.expand);
+ if (expand) {
+ payload.expand = expand;
+ }
+
+ const response = await this.thirdPartyClientService.call('get', payload);
+ const courses = this.extractCourseRecords(response);
+ return {
+ product: this.toProductSummary(product),
+ query: {
+ platform,
+ school,
+ account: this.maskText(account),
+ },
+ courses,
+ raw: maskSensitivePayload(response),
+ };
+ }
+
+ async createOrder(actor: User, dto: CreateOrderDto) {
+ assertAdmin(actor);
+ const product = await this.getProduct(dto.productId);
+ this.assertPurchasable(product);
+ const quantity = this.normalizeQuantity(dto.quantity);
+ const orderPayload = this.normalizeRecord(dto.orderPayload) ?? {};
+ this.validateOrderPayload(product.orderFormSchema ?? [], orderPayload);
+ const selectedCourse = this.normalizeSelectedCourse(dto.selectedCourse);
+ if (product.sourceType === THIRD_PARTY_SOURCE && !selectedCourse?.courseName) {
+ throw new BadRequestException('第三方商品需要先选择查课结果');
+ }
+
+ const unitPrice = Number(product.price || 0);
+ const totalAmount = (unitPrice * quantity).toFixed(2);
+ const orderNo = await this.generateOrderNo();
+ const { publicPayload, encryptedPayload } =
+ this.splitSensitivePayload(orderPayload);
+ const now = new Date();
+ const order = this.ordersRepository.create({
+ id: randomUUID(),
+ orderNo,
+ userId: actor.id,
+ sourceType: product.sourceType,
+ provider: product.provider,
+ totalAmount,
+ payableAmount: totalAmount,
+ paidAmount: '0.00',
+ paymentStatus: 'unpaid',
+ fulfillmentStatus: 'pending',
+ remark: this.normalizeOptionalString(dto.remark),
+ paidAt: null,
+ closedAt: null,
+ });
+
+ await this.ordersRepository.save(order);
+
+ const itemPayload = {
+ ...publicPayload,
+ selectedCourse: selectedCourse
+ ? maskSensitivePayload(selectedCourse)
+ : null,
+ expand: this.normalizeRecord(dto.expand),
+ };
+ const orderItem = this.orderItemsRepository.create({
+ id: randomUUID(),
+ orderId: order.id,
+ productId: product.id,
+ productName: product.name,
+ sourceType: product.sourceType,
+ externalProductId: product.externalId ?? product.remoteCourseId,
+ provider: product.provider,
+ unitPrice: Number(product.price || 0).toFixed(2),
+ quantity,
+ totalAmount,
+ payload: itemPayload,
+ encryptedPayload: Object.keys(encryptedPayload).length
+ ? encryptedPayload
+ : null,
+ createdAt: now,
+ });
+ await this.orderItemsRepository.save(orderItem);
+
+ return this.getOrderDetail(actor, order.id);
+ }
+
+ async listOrders(actor: User, query: OrderListQuery) {
+ assertAdmin(actor);
+ const { page, pageSize } = this.getPagination(query);
+ const keyword = String(query.keyword || '').trim();
+ const sourceType = this.normalizeSourceType(query.sourceType);
+ const paymentStatus = this.normalizePaymentStatus(query.paymentStatus);
+ const fulfillmentStatus = this.normalizeFulfillmentStatus(
+ query.fulfillmentStatus,
+ );
+
+ const builder = this.ordersRepository
+ .createQueryBuilder('order')
+ .orderBy('order.createdAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (sourceType) {
+ builder.andWhere('order.sourceType = :sourceType', { sourceType });
+ }
+ if (paymentStatus) {
+ builder.andWhere('order.paymentStatus = :paymentStatus', {
+ paymentStatus,
+ });
+ }
+ if (fulfillmentStatus) {
+ builder.andWhere('order.fulfillmentStatus = :fulfillmentStatus', {
+ fulfillmentStatus,
+ });
+ }
+ if (keyword) {
+ builder.andWhere(
+ new Brackets((qb) => {
+ qb.where('order.orderNo LIKE :keyword', { keyword: `%${keyword}%` })
+ .orWhere('order.remark LIKE :keyword', {
+ keyword: `%${keyword}%`,
+ });
+ }),
+ );
+ }
+
+ const [orders, total] = await builder.getManyAndCount();
+ const items = await this.getItemsByOrderIds(orders.map((order) => order.id));
+ return {
+ list: orders.map((order) => ({
+ ...order,
+ items: items.get(order.id) ?? [],
+ })),
+ total,
+ page,
+ pageSize,
+ };
+ }
+
+ async getOrderDetail(actor: User, idOrNo: string) {
+ assertAdmin(actor);
+ const order = await this.ordersRepository.findOne({
+ where: [{ id: idOrNo }, { orderNo: idOrNo }],
+ });
+ if (!order) {
+ throw new NotFoundException('订单不存在');
+ }
+
+ const [items, fulfillments, actions] = await Promise.all([
+ this.orderItemsRepository.find({
+ where: { orderId: order.id },
+ order: { createdAt: 'ASC' },
+ }),
+ this.fulfillmentsRepository.find({
+ where: { orderId: order.id },
+ order: { createdAt: 'ASC' },
+ }),
+ this.orderActionsRepository.find({
+ where: { orderId: order.id },
+ order: { createdAt: 'DESC' },
+ }),
+ ]);
+
+ return { ...order, items, fulfillments, actions };
+ }
+
+ async markOrderPaid(actor: User, idOrNo: string) {
+ assertAdmin(actor);
+ const detail = await this.getOrderDetail(actor, idOrNo);
+ const order = await this.ordersRepository.findOneByOrFail({
+ id: detail.id,
+ });
+
+ if (order.paymentStatus === 'paid') {
+ return this.getOrderDetail(actor, order.id);
+ }
+ if (order.paymentStatus !== 'unpaid' && order.paymentStatus !== 'paying') {
+ throw new BadRequestException('当前订单状态不能标记已支付');
+ }
+
+ order.paymentStatus = 'paid';
+ order.paidAmount = order.payableAmount;
+ order.paidAt = new Date();
+ await this.ordersRepository.save(order);
+ await this.fulfillPaidOrder(order.id);
+ return this.getOrderDetail(actor, order.id);
+ }
+
+ async refreshThirdPartyOrder(
+ actor: User,
+ idOrNo: string,
+ dto: ThirdPartyOrderActionDto,
+ ) {
+ assertAdmin(actor);
+ const detail = await this.getOrderDetail(actor, idOrNo);
+ this.assertThirdPartyOrder(detail);
+ const context = await this.getOrderOperationContext(detail.id);
+ const requestPayload = this.buildThirdPartyOrderIdentityPayload(
+ context,
+ dto,
+ true,
+ );
+ return this.callThirdPartyOrderAction({
+ actor,
+ order: context.order,
+ fulfillment: context.fulfillment,
+ action: 'chadan',
+ requestPayload,
+ remark: this.normalizeOptionalString(dto.remark),
+ updateOrder: async (response) => {
+ context.fulfillment.responsePayload = maskSensitivePayload(response);
+ const externalOrderNo = this.extractExternalOrderNo(response);
+ if (externalOrderNo) {
+ context.fulfillment.externalOrderNo = externalOrderNo;
+ }
+ await this.fulfillmentsRepository.save(context.fulfillment);
+ },
+ });
+ }
+
+ async syncThirdPartyOrderList(
+ actor: User,
+ dto: ThirdPartyOrderListSyncDto,
+ ) {
+ assertAdmin(actor);
+ return this.syncThirdPartyOrderListInternal(actor, dto);
+ }
+
+ async syncThirdPartyOrderListFromTask(dto: ThirdPartyOrderListSyncDto = {}) {
+ return this.syncThirdPartyOrderListInternal(null, dto);
+ }
+
+ private async syncThirdPartyOrderListInternal(
+ actor: User | null,
+ dto: ThirdPartyOrderListSyncDto,
+ ) {
+ const requestPayload = this.buildThirdPartyOrderListPayload(dto);
+ const response = await this.thirdPartyClientService.call(
+ 'orders',
+ requestPayload,
+ );
+ const records = this.extractRecords(response);
+ const provider = THIRD_PARTY_ORDER_PROVIDER;
+ const remoteOrderNos = Array.from(
+ new Set(
+ records
+ .map((record) => this.readRemoteOrderNo(record))
+ .filter(Boolean),
+ ),
+ ) as string[];
+ const now = new Date();
+
+ if (!remoteOrderNos.length) {
+ return {
+ provider,
+ total: records.length,
+ matched: 0,
+ created: 0,
+ updated: 0,
+ imported: 0,
+ localUpdated: 0,
+ unmatched: records.length,
+ page: requestPayload.page,
+ limit: requestPayload.limit,
+ recent: requestPayload.recent,
+ };
+ }
+
+ const thirdPartyOrders = await this.thirdPartyOrdersRepository
+ .createQueryBuilder('thirdPartyOrder')
+ .where('thirdPartyOrder.provider = :provider', { provider })
+ .andWhere('thirdPartyOrder.externalOrderNo IN (:...remoteOrderNos)', {
+ remoteOrderNos,
+ })
+ .getMany();
+ const thirdPartyOrderMap = new Map(
+ thirdPartyOrders.map((item) => [item.externalOrderNo, item]),
+ );
+ const fulfillments = await this.fulfillmentsRepository
+ .createQueryBuilder('fulfillment')
+ .where('fulfillment.externalOrderNo IN (:...remoteOrderNos)', {
+ remoteOrderNos,
+ })
+ .getMany();
+ const fulfillmentMap = new Map(
+ fulfillments
+ .filter((item) => item.externalOrderNo)
+ .map((item) => [item.externalOrderNo as string, item]),
+ );
+ const orderIds = Array.from(
+ new Set([
+ ...fulfillments.map((item) => item.orderId),
+ ...thirdPartyOrders
+ .map((item) => item.localOrderId)
+ .filter(Boolean),
+ ]),
+ );
+ const orders = orderIds.length
+ ? await this.ordersRepository
+ .createQueryBuilder('order')
+ .where('order.id IN (:...orderIds)', { orderIds })
+ .getMany()
+ : [];
+ const orderMap = new Map(orders.map((order) => [order.id, order]));
+ let matched = 0;
+ let created = 0;
+ let updated = 0;
+ let imported = 0;
+ let localUpdated = 0;
+ const thirdPartyOrdersToSave: ThirdPartyOrder[] = [];
+
+ for (const record of records) {
+ const remoteOrderNo = this.readRemoteOrderNo(record);
+ if (!remoteOrderNo) {
+ continue;
+ }
+ let fulfillment = fulfillmentMap.get(remoteOrderNo);
+ const maskedRecord = maskSensitivePayload(record) as UnknownRecord;
+ const existingThirdPartyOrder = thirdPartyOrderMap.get(remoteOrderNo);
+ let order =
+ (fulfillment ? orderMap.get(fulfillment.orderId) : null) ??
+ (existingThirdPartyOrder?.localOrderId
+ ? orderMap.get(existingThirdPartyOrder.localOrderId)
+ : null) ??
+ null;
+ const thirdPartyOrder =
+ existingThirdPartyOrder ??
+ this.thirdPartyOrdersRepository.create({
+ id: randomUUID(),
+ provider,
+ externalOrderNo: remoteOrderNo,
+ firstSeenAt: now,
+ });
+
+ thirdPartyOrder.username = this.readThirdPartyOrderUsername(record);
+ thirdPartyOrder.school = this.readOptionalString(record, [
+ 'school',
+ 'schoolName',
+ 'xuexiao',
+ 'xx',
+ ]);
+ thirdPartyOrder.courseName = this.readOptionalString(record, [
+ 'course',
+ 'courseName',
+ 'course_name',
+ 'kcname',
+ 'name',
+ 'title',
+ ]);
+ thirdPartyOrder.remoteStatus = this.readOptionalString(record, [
+ 'status',
+ 'state',
+ 'process',
+ 'progress',
+ 'order_status',
+ 'orderStatus',
+ ]);
+ thirdPartyOrder.localOrderId = order?.id ?? null;
+ thirdPartyOrder.rawPayload = maskedRecord;
+ thirdPartyOrder.lastSeenAt = now;
+ thirdPartyOrdersToSave.push(thirdPartyOrder);
+
+ if (existingThirdPartyOrder) {
+ updated += 1;
+ } else {
+ created += 1;
+ }
+
+ if (!order) {
+ const importedOrder = await this.importThirdPartyOrderFromRecord({
+ actor,
+ provider,
+ remoteOrderNo,
+ requestPayload,
+ record,
+ maskedRecord,
+ now,
+ });
+ order = importedOrder.order;
+ fulfillment = importedOrder.fulfillment;
+ thirdPartyOrder.localOrderId = order.id;
+ orderMap.set(order.id, order);
+ fulfillmentMap.set(remoteOrderNo, fulfillment);
+ imported += 1;
+ } else if (!fulfillment) {
+ fulfillment = await this.ensureThirdPartyFulfillmentForImportedOrder({
+ order,
+ provider,
+ remoteOrderNo,
+ requestPayload,
+ maskedRecord,
+ now,
+ });
+ fulfillmentMap.set(remoteOrderNo, fulfillment);
+ }
+
+ matched += 1;
+ fulfillment.responsePayload = maskedRecord;
+ const nextStatus = this.mapThirdPartyOrderStatus(record);
+ if (nextStatus) {
+ fulfillment.status = nextStatus;
+ order.fulfillmentStatus = nextStatus;
+ if (!fulfillment.submittedAt) {
+ fulfillment.submittedAt = new Date();
+ }
+ if (nextStatus === 'completed' && !fulfillment.completedAt) {
+ fulfillment.completedAt = new Date();
+ }
+ await this.ordersRepository.save(order);
+ }
+ await this.fulfillmentsRepository.save(fulfillment);
+ await this.writeOrderAction({
+ actor,
+ order,
+ action: 'third_party.orders.sync',
+ status: 'success',
+ requestPayload,
+ responsePayload: maskedRecord,
+ remark: '同步第三方订单列表',
+ });
+ localUpdated += 1;
+ }
+
+ if (thirdPartyOrdersToSave.length) {
+ await this.thirdPartyOrdersRepository.save(thirdPartyOrdersToSave);
+ }
+
+ return {
+ provider,
+ total: records.length,
+ matched,
+ created,
+ updated,
+ imported,
+ localUpdated,
+ unmatched: records.length - matched,
+ page: requestPayload.page,
+ limit: requestPayload.limit,
+ recent: requestPayload.recent,
+ };
+ }
+
+ async runThirdPartyOrderAction(
+ actor: User,
+ idOrNo: string,
+ action: ThirdPartyOrderAction,
+ dto: ThirdPartyOrderActionDto,
+ ) {
+ assertAdmin(actor);
+ const act = THIRD_PARTY_ACTIONS[action];
+ if (!act) {
+ throw new BadRequestException('第三方订单操作不正确');
+ }
+
+ const detail = await this.getOrderDetail(actor, idOrNo);
+ this.assertThirdPartyOrder(detail);
+ const context = await this.getOrderOperationContext(detail.id);
+ const requestPayload = this.buildThirdPartyActionPayload(action, context, dto);
+ return this.callThirdPartyOrderAction({
+ actor,
+ order: context.order,
+ fulfillment: context.fulfillment,
+ action: act,
+ requestPayload,
+ remark: this.normalizeOptionalString(dto.remark),
+ updateOrder: async (response) => {
+ context.fulfillment.responsePayload = maskSensitivePayload(response);
+ if (action === 'budan') {
+ context.fulfillment.status = 'submitted';
+ context.order.fulfillmentStatus = 'submitted';
+ await this.ordersRepository.save(context.order);
+ }
+ if (action === 'stop') {
+ context.fulfillment.status = 'canceled';
+ context.order.fulfillmentStatus = 'canceled';
+ await this.ordersRepository.save(context.order);
+ }
+ await this.fulfillmentsRepository.save(context.fulfillment);
+ },
+ });
+ }
+
+ async runLocalOrderAction(
+ actor: User,
+ idOrNo: string,
+ action: LocalOrderAction,
+ dto: LocalOrderActionDto,
+ ) {
+ assertAdmin(actor);
+ if (!LOCAL_ORDER_ACTIONS.has(action)) {
+ throw new BadRequestException('本地订单操作不正确');
+ }
+
+ const detail = await this.getOrderDetail(actor, idOrNo);
+ if (detail.sourceType !== SELF_OWNED_SOURCE) {
+ throw new BadRequestException('只有自营订单支持本地操作');
+ }
+
+ const context = await this.getOrderOperationContext(detail.id);
+ const remark = this.normalizeOptionalString(dto.remark);
+ try {
+ await this.applyLocalOrderAction(context, action, remark);
+ await this.writeOrderAction({
+ actor,
+ order: context.order,
+ action: `local.${action}`,
+ status: 'success',
+ requestPayload: { remark },
+ responsePayload: {
+ paymentStatus: context.order.paymentStatus,
+ fulfillmentStatus: context.order.fulfillmentStatus,
+ },
+ remark,
+ });
+ return this.getOrderDetail(actor, context.order.id);
+ } catch (error) {
+ await this.writeOrderAction({
+ actor,
+ order: context.order,
+ action: `local.${action}`,
+ status: 'failed',
+ requestPayload: { remark },
+ responsePayload: null,
+ errorMessage: this.getErrorMessage(error),
+ remark,
+ });
+ throw error;
+ }
+ }
+
+ private async fulfillPaidOrder(orderId: string) {
+ const order = await this.ordersRepository.findOneByOrFail({ id: orderId });
+ if (order.fulfillmentStatus !== 'pending') {
+ return;
+ }
+
+ const [item] = await this.orderItemsRepository.find({
+ where: { orderId },
+ order: { createdAt: 'ASC' },
+ take: 1,
+ });
+ if (!item) {
+ throw new BadRequestException('订单明细不存在');
+ }
+
+ const product = await this.productsRepository.findOne({
+ where: { id: item.productId },
+ });
+ if (!product) {
+ throw new NotFoundException('商品不存在');
+ }
+
+ order.fulfillmentStatus = 'submitting';
+ await this.ordersRepository.save(order);
+
+ const fulfillment = this.fulfillmentsRepository.create({
+ id: randomUUID(),
+ orderId: order.id,
+ orderItemId: item.id,
+ fulfillmentType:
+ product.sourceType === THIRD_PARTY_SOURCE ? 'third_party_api' : 'local_only',
+ status: 'submitting',
+ provider: product.provider,
+ externalOrderNo: null,
+ requestPayload: null,
+ responsePayload: null,
+ errorMessage: null,
+ submittedAt: null,
+ completedAt: null,
+ });
+ await this.fulfillmentsRepository.save(fulfillment);
+
+ if (product.sourceType === THIRD_PARTY_SOURCE) {
+ await this.submitThirdPartyOrder(order, item, product, fulfillment);
+ return;
+ }
+
+ fulfillment.status =
+ product.fulfillmentType === 'manual' ? 'processing' : 'completed';
+ fulfillment.submittedAt = new Date();
+ fulfillment.completedAt =
+ fulfillment.status === 'completed' ? fulfillment.submittedAt : null;
+ await this.fulfillmentsRepository.save(fulfillment);
+
+ order.fulfillmentStatus = fulfillment.status;
+ await this.ordersRepository.save(order);
+ }
+
+ private async submitThirdPartyOrder(
+ order: Order,
+ item: OrderItem,
+ product: Course,
+ fulfillment: OrderFulfillment,
+ ) {
+ const payload = item.payload ?? {};
+ const sensitivePayload = this.decryptPayload(item.encryptedPayload);
+ const selectedCourse = this.normalizeSelectedCourse(
+ payload.selectedCourse as SelectedCourseDto | null,
+ );
+ const account = this.normalizeRequiredString(
+ payload.account ?? payload.user,
+ '账号不能为空',
+ );
+ const school = this.normalizeRequiredString(payload.school, '学校不能为空');
+ const password = this.normalizeRequiredString(
+ sensitivePayload.password ?? sensitivePayload.pass,
+ '密码不能为空',
+ );
+ const requestPayload: UnknownRecord = {
+ platform: this.getThirdPartyPlatform(product),
+ school,
+ user: account,
+ pass: password,
+ kcname: selectedCourse?.courseName || item.productName,
+ };
+ const courseId = selectedCourse?.courseId || product.remoteCourseId;
+ if (courseId) {
+ requestPayload.kcid = courseId;
+ }
+ const expand = this.normalizeRecord(payload.expand);
+ if (expand) {
+ requestPayload.expand = expand;
+ }
+
+ fulfillment.requestPayload = maskSensitivePayload(requestPayload);
+ await this.fulfillmentsRepository.save(fulfillment);
+
+ try {
+ const response = await this.thirdPartyClientService.call('add', requestPayload);
+ fulfillment.status = 'submitted';
+ fulfillment.submittedAt = new Date();
+ fulfillment.responsePayload = maskSensitivePayload(response);
+ fulfillment.externalOrderNo = this.extractExternalOrderNo(response);
+ await this.fulfillmentsRepository.save(fulfillment);
+
+ order.fulfillmentStatus = 'submitted';
+ await this.ordersRepository.save(order);
+ } catch (error) {
+ fulfillment.status = 'failed';
+ fulfillment.errorMessage = this.getErrorMessage(error);
+ await this.fulfillmentsRepository.save(fulfillment);
+
+ order.fulfillmentStatus = 'failed';
+ await this.ordersRepository.save(order);
+ throw error;
+ }
+ }
+
+ private async callThirdPartyOrderAction(input: {
+ actor: User;
+ order: Order;
+ fulfillment: OrderFulfillment;
+ action: string;
+ requestPayload: UnknownRecord;
+ remark: string | null;
+ updateOrder: (response: unknown) => Promise;
+ }) {
+ const maskedRequestPayload = maskSensitivePayload(input.requestPayload);
+ try {
+ const response = await this.thirdPartyClientService.call(
+ input.action,
+ input.requestPayload,
+ );
+ const maskedResponsePayload = maskSensitivePayload(response);
+ await input.updateOrder(maskedResponsePayload);
+ await this.writeOrderAction({
+ actor: input.actor,
+ order: input.order,
+ action: `third_party.${input.action}`,
+ status: 'success',
+ requestPayload: maskedRequestPayload,
+ responsePayload: maskedResponsePayload,
+ remark: input.remark,
+ });
+ return this.getOrderDetail(input.actor, input.order.id);
+ } catch (error) {
+ input.fulfillment.errorMessage = this.getErrorMessage(error);
+ await this.fulfillmentsRepository.save(input.fulfillment);
+ await this.writeOrderAction({
+ actor: input.actor,
+ order: input.order,
+ action: `third_party.${input.action}`,
+ status: 'failed',
+ requestPayload: maskedRequestPayload,
+ responsePayload: null,
+ errorMessage: this.getErrorMessage(error),
+ remark: input.remark,
+ });
+ throw error;
+ }
+ }
+
+ private async applyLocalOrderAction(
+ context: Awaited>,
+ action: LocalOrderAction,
+ remark: string | null,
+ ) {
+ const { order, fulfillment } = context;
+ if (action === 'remark') {
+ order.remark = remark;
+ await this.ordersRepository.save(order);
+ return;
+ }
+
+ if (action === 'close') {
+ if (order.paymentStatus === 'paid') {
+ throw new BadRequestException('已支付订单不能直接关闭,请走售后处理');
+ }
+ order.paymentStatus = 'closed';
+ order.fulfillmentStatus = 'canceled';
+ order.closedAt = new Date();
+ fulfillment.status = 'canceled';
+ fulfillment.errorMessage = remark;
+ await Promise.all([
+ this.ordersRepository.save(order),
+ this.fulfillmentsRepository.save(fulfillment),
+ ]);
+ return;
+ }
+
+ if (order.paymentStatus !== 'paid') {
+ throw new BadRequestException('订单未支付,不能处理履约');
+ }
+
+ if (action === 'process') {
+ order.fulfillmentStatus = 'processing';
+ fulfillment.status = 'processing';
+ fulfillment.submittedAt = fulfillment.submittedAt ?? new Date();
+ }
+ if (action === 'complete') {
+ order.fulfillmentStatus = 'completed';
+ fulfillment.status = 'completed';
+ fulfillment.submittedAt = fulfillment.submittedAt ?? new Date();
+ fulfillment.completedAt = new Date();
+ }
+ if (action === 'cancel') {
+ order.fulfillmentStatus = 'canceled';
+ fulfillment.status = 'canceled';
+ fulfillment.errorMessage = remark;
+ }
+
+ await Promise.all([
+ this.ordersRepository.save(order),
+ this.fulfillmentsRepository.save(fulfillment),
+ ]);
+ }
+
+ private buildThirdPartyActionPayload(
+ action: ThirdPartyOrderAction,
+ context: Awaited>,
+ dto: ThirdPartyOrderActionDto,
+ ) {
+ const payload = this.buildThirdPartyOrderIdentityPayload(context, dto, false);
+ if (action === 'gaimi') {
+ const newPwd =
+ this.normalizeOptionalString(dto.newPwd) ??
+ this.normalizeOptionalString((dto as UnknownRecord).password);
+ if (newPwd) {
+ payload.newPwd = newPwd;
+ }
+ this.assignIfPresent(payload, 'remark', dto.remark);
+ this.assignIfPresent(payload, 'city', dto.city);
+ this.assignIfPresent(payload, 'tag', dto.tag);
+ this.assignIfPresent(payload, 'config', dto.config);
+ const autoReset = this.normalizeAutoReset(dto.autoReset);
+ if (autoReset !== null) {
+ payload.autoReset = autoReset;
+ }
+ }
+ if (action === 'convert') {
+ payload.convertToClassId = this.normalizeRequiredString(
+ dto.convertToClassId,
+ '目标课程 ID 不能为空',
+ );
+ }
+ if (action === 'update-time') {
+ payload.time = this.normalizePositiveNumber(dto.time, '时长不能为空');
+ }
+ if (action === 'update-cycle') {
+ payload.cycle = this.normalizePositiveNumber(dto.cycle, '周期不能为空');
+ }
+ return payload;
+ }
+
+ private buildThirdPartyOrderListPayload(dto: ThirdPartyOrderListSyncDto) {
+ return {
+ page: this.normalizePositiveInteger(dto.page, 1, 1_000, '页码不正确'),
+ limit: this.normalizePositiveInteger(
+ dto.limit,
+ 100,
+ 500,
+ '每页数量不正确',
+ ),
+ recent: this.normalizePositiveInteger(
+ dto.recent,
+ 5,
+ 365,
+ '近几天参数不正确',
+ ),
+ };
+ }
+
+ private readRemoteOrderNo(item: UnknownRecord) {
+ return this.readOptionalString(item, [
+ 'id',
+ 'oid',
+ 'order_id',
+ 'orderNo',
+ 'order_no',
+ 'third_order_no',
+ 'thirdOrderNo',
+ ]);
+ }
+
+ private mapThirdPartyOrderStatus(item: UnknownRecord): FulfillmentStatus | null {
+ const status = this.readOptionalString(item, [
+ 'status',
+ 'state',
+ 'process',
+ 'progress',
+ 'order_status',
+ 'orderStatus',
+ ]);
+ if (!status) {
+ return null;
+ }
+
+ const normalized = status.toLowerCase();
+ if (/完成|已完成|完结|success|completed|done/.test(normalized)) {
+ return 'completed';
+ }
+ if (/失败|异常|错误|fail|failed|error/.test(normalized)) {
+ return 'failed';
+ }
+ if (/取消|暂停|停止|关闭|cancel|canceled|stop|stopped|closed/.test(normalized)) {
+ return 'canceled';
+ }
+ if (/提交|已提交|submitted/.test(normalized)) {
+ return 'submitted';
+ }
+ if (/处理|进行|学习|运行|processing|running|working/.test(normalized)) {
+ return 'processing';
+ }
+ return null;
+ }
+
+ private readThirdPartyOrderUsername(item: UnknownRecord) {
+ return this.readOptionalString(item, [
+ 'username',
+ 'user',
+ 'account',
+ 'studentAccount',
+ 'student_account',
+ ]);
+ }
+
+ private async importThirdPartyOrderFromRecord(input: {
+ actor: User | null;
+ provider: string;
+ remoteOrderNo: string;
+ requestPayload: UnknownRecord;
+ record: UnknownRecord;
+ maskedRecord: UnknownRecord;
+ now: Date;
+ }) {
+ const order = this.ordersRepository.create({
+ id: randomUUID(),
+ orderNo: await this.generateOrderNo(),
+ userId: input.actor?.id ?? 'system',
+ sourceType: THIRD_PARTY_SOURCE,
+ provider: input.provider,
+ totalAmount: this.readThirdPartyOrderAmount(input.record),
+ payableAmount: this.readThirdPartyOrderAmount(input.record),
+ paidAmount: this.readThirdPartyOrderAmount(input.record),
+ paymentStatus: 'paid',
+ fulfillmentStatus:
+ this.mapThirdPartyOrderStatus(input.record) ?? 'processing',
+ remark: `第三方订单同步导入:${input.remoteOrderNo}`,
+ paidAt: input.now,
+ closedAt: null,
+ });
+ await this.ordersRepository.save(order);
+
+ const orderItem = this.orderItemsRepository.create({
+ id: randomUUID(),
+ orderId: order.id,
+ productId: randomUUID(),
+ productName:
+ this.readOptionalString(input.record, [
+ 'course',
+ 'courseName',
+ 'course_name',
+ 'kcname',
+ 'name',
+ 'title',
+ ]) ?? `第三方订单 ${input.remoteOrderNo}`,
+ sourceType: THIRD_PARTY_SOURCE,
+ externalProductId: this.readOptionalString(input.record, [
+ 'cid',
+ 'classId',
+ 'class_id',
+ 'courseId',
+ 'course_id',
+ 'goodsId',
+ 'goods_id',
+ ]),
+ provider: input.provider,
+ unitPrice: this.readThirdPartyOrderAmount(input.record),
+ quantity: 1,
+ totalAmount: this.readThirdPartyOrderAmount(input.record),
+ payload: {
+ externalOrderNo: input.remoteOrderNo,
+ username: this.readThirdPartyOrderUsername(input.record),
+ school: this.readOptionalString(input.record, [
+ 'school',
+ 'schoolName',
+ 'xuexiao',
+ 'xx',
+ ]),
+ raw: input.maskedRecord,
+ },
+ encryptedPayload: null,
+ createdAt: input.now,
+ });
+ await this.orderItemsRepository.save(orderItem);
+
+ const fulfillment = this.fulfillmentsRepository.create({
+ id: randomUUID(),
+ orderId: order.id,
+ orderItemId: orderItem.id,
+ fulfillmentType: 'third_party_api',
+ status: order.fulfillmentStatus,
+ provider: input.provider,
+ externalOrderNo: input.remoteOrderNo,
+ requestPayload: input.requestPayload,
+ responsePayload: input.maskedRecord,
+ errorMessage: null,
+ submittedAt: input.now,
+ completedAt: order.fulfillmentStatus === 'completed' ? input.now : null,
+ });
+ await this.fulfillmentsRepository.save(fulfillment);
+
+ await this.writeOrderAction({
+ actor: input.actor,
+ order,
+ action: 'third_party.orders.import',
+ status: 'success',
+ requestPayload: input.requestPayload,
+ responsePayload: input.maskedRecord,
+ remark: '同步第三方订单列表时导入本地订单',
+ });
+
+ return { order, fulfillment };
+ }
+
+ private async ensureThirdPartyFulfillmentForImportedOrder(input: {
+ order: Order;
+ provider: string;
+ remoteOrderNo: string;
+ requestPayload: UnknownRecord;
+ maskedRecord: UnknownRecord;
+ now: Date;
+ }) {
+ const [existingItem] = await this.orderItemsRepository.find({
+ where: { orderId: input.order.id },
+ order: { createdAt: 'ASC' },
+ take: 1,
+ });
+ const item =
+ existingItem ??
+ this.orderItemsRepository.create({
+ id: randomUUID(),
+ orderId: input.order.id,
+ productId: randomUUID(),
+ productName: `第三方订单 ${input.remoteOrderNo}`,
+ sourceType: THIRD_PARTY_SOURCE,
+ externalProductId: null,
+ provider: input.provider,
+ unitPrice: input.order.totalAmount,
+ quantity: 1,
+ totalAmount: input.order.totalAmount,
+ payload: {
+ externalOrderNo: input.remoteOrderNo,
+ raw: input.maskedRecord,
+ },
+ encryptedPayload: null,
+ createdAt: input.now,
+ });
+ if (!existingItem) {
+ await this.orderItemsRepository.save(item);
+ }
+
+ const fulfillment = this.fulfillmentsRepository.create({
+ id: randomUUID(),
+ orderId: input.order.id,
+ orderItemId: item.id,
+ fulfillmentType: 'third_party_api',
+ status: input.order.fulfillmentStatus,
+ provider: input.provider,
+ externalOrderNo: input.remoteOrderNo,
+ requestPayload: input.requestPayload,
+ responsePayload: input.maskedRecord,
+ errorMessage: null,
+ submittedAt: input.now,
+ completedAt:
+ input.order.fulfillmentStatus === 'completed' ? input.now : null,
+ });
+ await this.fulfillmentsRepository.save(fulfillment);
+ return fulfillment;
+ }
+
+ private readThirdPartyOrderAmount(item: UnknownRecord) {
+ const value = this.readOptionalString(item, [
+ 'amount',
+ 'price',
+ 'money',
+ 'totalAmount',
+ 'total_amount',
+ 'payableAmount',
+ 'payable_amount',
+ ]);
+ const amount = Number(value ?? 0);
+ return Number.isFinite(amount) && amount >= 0 ? amount.toFixed(2) : '0.00';
+ }
+
+ private buildThirdPartyOrderIdentityPayload(
+ context: Awaited>,
+ dto: ThirdPartyOrderActionDto,
+ allowUsername: boolean,
+ ) {
+ const payload: UnknownRecord = {};
+ const externalOrderNo =
+ this.normalizeOptionalString(dto.externalOrderNo) ??
+ this.normalizeOptionalString(context.fulfillment.externalOrderNo);
+ if (externalOrderNo) {
+ payload.id = externalOrderNo;
+ return payload;
+ }
+
+ if (allowUsername) {
+ const username =
+ this.normalizeOptionalString(dto.username) ??
+ this.readOrderAccount(context.item.payload);
+ if (username) {
+ payload.username = username;
+ return payload;
+ }
+ }
+
+ throw new BadRequestException('缺少第三方订单 ID');
+ }
+
+ private async getOrderOperationContext(orderId: string) {
+ const order = await this.ordersRepository.findOneByOrFail({ id: orderId });
+ const [item] = await this.orderItemsRepository.find({
+ where: { orderId },
+ order: { createdAt: 'ASC' },
+ take: 1,
+ });
+ if (!item) {
+ throw new BadRequestException('订单明细不存在');
+ }
+
+ let [fulfillment] = await this.fulfillmentsRepository.find({
+ where: { orderId },
+ order: { createdAt: 'ASC' },
+ take: 1,
+ });
+ if (!fulfillment) {
+ fulfillment = this.fulfillmentsRepository.create({
+ id: randomUUID(),
+ orderId: order.id,
+ orderItemId: item.id,
+ fulfillmentType:
+ order.sourceType === THIRD_PARTY_SOURCE
+ ? 'third_party_api'
+ : 'manual',
+ status: order.fulfillmentStatus,
+ provider: order.provider,
+ externalOrderNo: null,
+ requestPayload: null,
+ responsePayload: null,
+ errorMessage: null,
+ submittedAt: null,
+ completedAt: null,
+ });
+ await this.fulfillmentsRepository.save(fulfillment);
+ }
+
+ return { order, item, fulfillment };
+ }
+
+ private async writeOrderAction(input: {
+ actor: User | null;
+ order: Order;
+ action: string;
+ status: 'success' | 'failed';
+ requestPayload: UnknownRecord | null;
+ responsePayload: unknown | null;
+ errorMessage?: string | null;
+ remark?: string | null;
+ }) {
+ const action = this.orderActionsRepository.create({
+ id: randomUUID(),
+ orderId: input.order.id,
+ action: input.action,
+ sourceType: input.order.sourceType,
+ provider: input.order.provider,
+ status: input.status,
+ operatorId: input.actor?.id ?? null,
+ requestPayload: input.requestPayload,
+ responsePayload: input.responsePayload,
+ errorMessage: input.errorMessage ?? null,
+ remark: input.remark ?? null,
+ });
+ await this.orderActionsRepository.save(action);
+ }
+
+ private assertThirdPartyOrder(order: { sourceType: string }) {
+ if (order.sourceType !== THIRD_PARTY_SOURCE) {
+ throw new BadRequestException('只有第三方订单支持该操作');
+ }
+ }
+
+ private readOrderAccount(payload: OrderItem['payload']) {
+ if (!payload) {
+ return null;
+ }
+ return this.normalizeOptionalString(payload.account ?? payload.user);
+ }
+
+ private assignIfPresent(
+ payload: UnknownRecord,
+ key: string,
+ value: unknown,
+ ) {
+ if (value !== undefined && value !== null && value !== '') {
+ payload[key] = value;
+ }
+ }
+
+ private normalizeAutoReset(value: unknown) {
+ if (value === undefined || value === null || value === '') {
+ return null;
+ }
+ return ['1', 'true', 'yes', 'on'].includes(
+ String(value).trim().toLowerCase(),
+ )
+ ? 1
+ : 0;
+ }
+
+ private normalizePositiveNumber(value: unknown, message: string) {
+ const normalized = Number(value);
+ if (!Number.isFinite(normalized) || normalized <= 0) {
+ throw new BadRequestException(message);
+ }
+ return normalized;
+ }
+
+ private normalizePositiveInteger(
+ value: unknown,
+ fallback: number,
+ max: number,
+ message: string,
+ ) {
+ if (value === undefined || value === null || value === '') {
+ return fallback;
+ }
+ const normalized = Number(value);
+ if (
+ !Number.isInteger(normalized) ||
+ normalized <= 0 ||
+ normalized > max
+ ) {
+ throw new BadRequestException(message);
+ }
+ return normalized;
+ }
+
+ private async getProduct(id?: string) {
+ const productId = this.normalizeRequiredString(id, '商品不能为空');
+ const product = await this.productsRepository.findOne({
+ where: { id: productId },
+ });
+ if (!product) {
+ throw new NotFoundException('商品不存在');
+ }
+ return product;
+ }
+
+ private assertPurchasable(product: Course) {
+ if (!product.enabled || product.syncStatus === 'removed') {
+ throw new BadRequestException('商品未上架,不能下单');
+ }
+ if (product.stock !== null && product.stock <= 0) {
+ throw new BadRequestException('商品库存不足');
+ }
+ }
+
+ private validateOrderPayload(
+ schema: OrderFormField[],
+ payload: UnknownRecord,
+ ) {
+ for (const field of schema) {
+ if (!field.required) {
+ continue;
+ }
+ const value = payload[field.key];
+ if (value === undefined || value === null || String(value).trim() === '') {
+ throw new BadRequestException(`${field.label}不能为空`);
+ }
+ }
+ }
+
+ private splitSensitivePayload(payload: UnknownRecord) {
+ const publicPayload: UnknownRecord = {};
+ const sensitivePayload: UnknownRecord = {};
+
+ for (const [key, value] of Object.entries(payload)) {
+ if (SENSITIVE_ORDER_KEYS.has(key)) {
+ sensitivePayload[key] = value;
+ publicPayload[key] = this.maskText(value);
+ } else {
+ publicPayload[key] = value;
+ }
+ }
+
+ return {
+ publicPayload: maskSensitivePayload(publicPayload),
+ encryptedPayload: Object.fromEntries(
+ Object.entries(sensitivePayload).map(([key, value]) => [
+ key,
+ this.encryptValue(value),
+ ]),
+ ),
+ };
+ }
+
+ private decryptPayload(payload: OrderItem['encryptedPayload']) {
+ if (!payload) {
+ return {};
+ }
+
+ return Object.fromEntries(
+ Object.entries(payload).map(([key, value]) => [
+ key,
+ this.decryptValue(String(value)),
+ ]),
+ );
+ }
+
+ private encryptValue(value: unknown) {
+ const key = this.getEncryptionKey();
+ const iv = randomBytes(12);
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
+ const encrypted = Buffer.concat([
+ cipher.update(String(value ?? ''), 'utf8'),
+ cipher.final(),
+ ]);
+ const tag = cipher.getAuthTag();
+ return [
+ iv.toString('base64url'),
+ tag.toString('base64url'),
+ encrypted.toString('base64url'),
+ ].join('.');
+ }
+
+ private decryptValue(value: string) {
+ const [ivText, tagText, encryptedText] = value.split('.');
+ if (!ivText || !tagText || !encryptedText) {
+ throw new BadRequestException('订单敏感信息格式不正确');
+ }
+ const decipher = createDecipheriv(
+ 'aes-256-gcm',
+ this.getEncryptionKey(),
+ Buffer.from(ivText, 'base64url'),
+ );
+ decipher.setAuthTag(Buffer.from(tagText, 'base64url'));
+ return Buffer.concat([
+ decipher.update(Buffer.from(encryptedText, 'base64url')),
+ decipher.final(),
+ ]).toString('utf8');
+ }
+
+ private getEncryptionKey() {
+ const secret = this.configService.getOrThrow('AUTH_SECRET');
+ return Buffer.from(secret.padEnd(32, secret).slice(0, 32));
+ }
+
+ private extractCourseRecords(response: unknown) {
+ const records = this.extractRecords(response);
+ return records.map((item, index) => ({
+ courseId: this.readOptionalString(item, [
+ 'id',
+ 'kcid',
+ 'cid',
+ 'course_id',
+ 'class_id',
+ 'hash',
+ ]),
+ courseName:
+ this.readOptionalString(item, ['name', 'kcname', 'title', 'label']) ??
+ `课程 ${index + 1}`,
+ raw: maskSensitivePayload(item),
+ }));
+ }
+
+ private extractRecords(response: unknown): UnknownRecord[] {
+ const value = this.unwrapResponse(response);
+ if (Array.isArray(value)) {
+ return value.filter(this.isRecord);
+ }
+ if (this.isRecord(value)) {
+ return Object.entries(value).map(([key, item]) =>
+ this.isRecord(item)
+ ? { id: key, ...item }
+ : { id: key, name: item },
+ );
+ }
+ return [];
+ }
+
+ private unwrapResponse(response: unknown): unknown {
+ if (!this.isRecord(response)) {
+ return response;
+ }
+ return response.data ?? response.rows ?? response.list ?? response.result ?? response;
+ }
+
+ private normalizeSelectedCourse(value?: SelectedCourseDto | null) {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+ const courseName = this.normalizeOptionalString(value.courseName);
+ return {
+ courseName,
+ courseId: this.normalizeOptionalString(value.courseId),
+ raw: this.normalizeRecord(value.raw),
+ };
+ }
+
+ private normalizeRecord(value: unknown): UnknownRecord | null {
+ if (value === undefined || value === null || value === '') {
+ return null;
+ }
+ if (!this.isRecord(value)) {
+ throw new BadRequestException('扩展信息格式不正确');
+ }
+ return value;
+ }
+
+ private normalizeRequiredString(value: unknown, message: string) {
+ const normalized = String(value ?? '').trim();
+ if (!normalized) {
+ throw new BadRequestException(message);
+ }
+ return normalized;
+ }
+
+ private normalizeOptionalString(value: unknown) {
+ const normalized = String(value ?? '').trim();
+ return normalized || null;
+ }
+
+ private normalizeQuantity(value: unknown) {
+ const quantity = Number(value ?? 1);
+ if (!Number.isInteger(quantity) || quantity <= 0 || quantity > 999) {
+ throw new BadRequestException('购买数量不正确');
+ }
+ return quantity;
+ }
+
+ private normalizeSourceType(sourceType?: OrderListQuery['sourceType']) {
+ if (!sourceType || sourceType === SOURCE_ALL) {
+ return null;
+ }
+ if (sourceType === THIRD_PARTY_SOURCE || sourceType === SELF_OWNED_SOURCE) {
+ return sourceType;
+ }
+ throw new BadRequestException('订单来源不正确');
+ }
+
+ private normalizePaymentStatus(value?: string) {
+ if (!value || value === SOURCE_ALL) {
+ return null;
+ }
+ if (PAYMENT_STATUSES.has(value as PaymentStatus)) {
+ return value as PaymentStatus;
+ }
+ throw new BadRequestException('支付状态不正确');
+ }
+
+ private normalizeFulfillmentStatus(value?: string) {
+ if (!value || value === SOURCE_ALL) {
+ return null;
+ }
+ if (FULFILLMENT_STATUSES.has(value as FulfillmentStatus)) {
+ return value as FulfillmentStatus;
+ }
+ throw new BadRequestException('履约状态不正确');
+ }
+
+ private getThirdPartyPlatform(product: Course) {
+ return this.normalizeRequiredString(
+ product.remoteCategoryId ?? product.externalId,
+ '第三方项目 ID 缺失',
+ );
+ }
+
+ private toProductSummary(product: Course) {
+ return {
+ id: product.id,
+ name: product.name,
+ sourceType: product.sourceType,
+ provider: product.provider,
+ remoteCategoryId: product.remoteCategoryId,
+ remoteCourseId: product.remoteCourseId,
+ price: product.price,
+ };
+ }
+
+ private getPagination(query: OrderListQuery) {
+ return {
+ page: Math.max(Number(query.page || 1), 1),
+ pageSize: Math.min(Math.max(Number(query.pageSize || 10), 1), 100),
+ };
+ }
+
+ private async getItemsByOrderIds(orderIds: string[]) {
+ if (!orderIds.length) {
+ return new Map();
+ }
+
+ const items = await this.orderItemsRepository
+ .createQueryBuilder('item')
+ .where('item.orderId IN (:...orderIds)', { orderIds })
+ .orderBy('item.createdAt', 'ASC')
+ .getMany();
+ const map = new Map();
+ for (const item of items) {
+ map.set(item.orderId, [...(map.get(item.orderId) ?? []), item]);
+ }
+ return map;
+ }
+
+ private async generateOrderNo() {
+ const date = new Date();
+ const dateText = [
+ date.getFullYear(),
+ String(date.getMonth() + 1).padStart(2, '0'),
+ String(date.getDate()).padStart(2, '0'),
+ ].join('');
+ for (let index = 0; index < 5; index += 1) {
+ const orderNo = `ORD${dateText}${String(Date.now()).slice(-6)}${randomBytes(2).toString('hex').toUpperCase()}`;
+ const exists = await this.ordersRepository.exist({ where: { orderNo } });
+ if (!exists) {
+ return orderNo;
+ }
+ }
+ throw new BadRequestException('生成订单号失败,请重试');
+ }
+
+ private extractExternalOrderNo(response: unknown) {
+ const value = this.unwrapResponse(response);
+ if (!this.isRecord(value)) {
+ return null;
+ }
+ return this.readOptionalString(value, [
+ 'id',
+ 'oid',
+ 'order_id',
+ 'orderNo',
+ 'order_no',
+ 'third_order_no',
+ ]);
+ }
+
+ private readOptionalString(item: UnknownRecord, keys: string[]) {
+ for (const key of keys) {
+ const value = item[key];
+ if (value !== undefined && value !== null && String(value).trim()) {
+ return String(value).trim();
+ }
+ }
+ return null;
+ }
+
+ private maskText(value: unknown) {
+ const text = String(value ?? '');
+ if (!text) {
+ return '';
+ }
+ if (text.length <= 4) {
+ return '****';
+ }
+ return `${text.slice(0, 2)}****${text.slice(-2)}`;
+ }
+
+ private isRecord(value: unknown): value is UnknownRecord {
+ return !!value && typeof value === 'object' && !Array.isArray(value);
+ }
+
+ private getErrorMessage(error: unknown) {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
+ }
+}
diff --git a/packages/backend/src/third-party/entities/api-call-log.entity.ts b/packages/backend/src/third-party/entities/api-call-log.entity.ts
new file mode 100644
index 0000000..bd50d39
--- /dev/null
+++ b/packages/backend/src/third-party/entities/api-call-log.entity.ts
@@ -0,0 +1,53 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+} from 'typeorm';
+
+export type ApiCallLogStatus = 'success' | 'failure';
+
+@Entity('api_call_logs')
+@Index('idx_api_call_logs_provider_created_at', ['provider', 'createdAt'])
+@Index('idx_api_call_logs_act_created_at', ['act', 'createdAt'])
+export class ApiCallLog {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ type: 'varchar', length: 64, default: 'biedawo' })
+ provider: string;
+
+ @Column({ type: 'varchar', length: 80 })
+ act: string;
+
+ @Column({ type: 'varchar', length: 12 })
+ method: string;
+
+ @Column({ name: 'endpoint', type: 'varchar', length: 255 })
+ url: string;
+
+ @Column({ type: 'varchar', length: 16 })
+ status: ApiCallLogStatus;
+
+ @Column({ type: 'tinyint', default: 0 })
+ success: boolean;
+
+ @Column({ name: 'status_code', type: 'int', nullable: true })
+ httpStatus: number | null;
+
+ @Column({ name: 'duration_ms', type: 'int', nullable: true })
+ durationMs: number | null;
+
+ @Column({ name: 'request_payload', type: 'json', nullable: true })
+ requestPayload: Record | null;
+
+ @Column({ name: 'response_payload', type: 'json', nullable: true })
+ responsePayload: unknown | null;
+
+ @Column({ name: 'error_message', type: 'text', nullable: true })
+ errorMessage: string | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+}
diff --git a/packages/backend/src/third-party/entities/catalog-sync-job.entity.ts b/packages/backend/src/third-party/entities/catalog-sync-job.entity.ts
new file mode 100644
index 0000000..426fa13
--- /dev/null
+++ b/packages/backend/src/third-party/entities/catalog-sync-job.entity.ts
@@ -0,0 +1,64 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+
+export type CatalogSyncTriggerType = 'manual' | 'scheduled';
+export type CatalogSyncJobStatus = 'running' | 'success' | 'failed';
+
+@Entity('catalog_sync_jobs')
+@Index('idx_catalog_sync_jobs_provider_started', ['provider', 'startedAt'])
+@Index('idx_catalog_sync_jobs_status_started', ['status', 'startedAt'])
+export class CatalogSyncJob {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ type: 'varchar', length: 64 })
+ provider: string;
+
+ @Column({ name: 'trigger_type', type: 'varchar', length: 32 })
+ triggerType: CatalogSyncTriggerType;
+
+ @Column({ type: 'varchar', length: 32 })
+ status: CatalogSyncJobStatus;
+
+ @Column({ name: 'started_at', type: 'datetime', precision: 6 })
+ startedAt: Date;
+
+ @Column({ name: 'finished_at', type: 'datetime', precision: 6, nullable: true })
+ finishedAt: Date | null;
+
+ @Column({ name: 'category_total', type: 'int', default: 0 })
+ categoryTotal: number;
+
+ @Column({ name: 'product_total', type: 'int', default: 0 })
+ productTotal: number;
+
+ @Column({ name: 'category_created', type: 'int', default: 0 })
+ categoryCreated: number;
+
+ @Column({ name: 'category_updated', type: 'int', default: 0 })
+ categoryUpdated: number;
+
+ @Column({ name: 'product_created', type: 'int', default: 0 })
+ productCreated: number;
+
+ @Column({ name: 'product_updated', type: 'int', default: 0 })
+ productUpdated: number;
+
+ @Column({ name: 'product_removed', type: 'int', default: 0 })
+ productRemoved: number;
+
+ @Column({ name: 'error_message', type: 'text', nullable: true })
+ errorMessage: string | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+}
diff --git a/packages/backend/src/third-party/entities/category.entity.ts b/packages/backend/src/third-party/entities/category.entity.ts
new file mode 100644
index 0000000..ac427bc
--- /dev/null
+++ b/packages/backend/src/third-party/entities/category.entity.ts
@@ -0,0 +1,58 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+
+export type SourceType = 'third_party' | 'self_owned';
+
+@Entity('categories')
+@Index('idx_categories_source_provider_external', [
+ 'sourceType',
+ 'provider',
+ 'externalId',
+])
+@Index('idx_categories_provider_remote', ['provider', 'remoteCategoryId'])
+export class Category {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ name: 'source_type', type: 'varchar', length: 32 })
+ sourceType: SourceType;
+
+ @Column({ name: 'api_account_id', type: 'varchar', length: 36, nullable: true })
+ apiAccountId: string | null;
+
+ @Column({ type: 'varchar', length: 64, nullable: true })
+ provider: string | null;
+
+ @Column({ name: 'external_id', type: 'varchar', length: 160, nullable: true })
+ externalId: string | null;
+
+ @Column({ name: 'remote_category_id', type: 'varchar', length: 120, nullable: true })
+ remoteCategoryId: string | null;
+
+ @Column({ type: 'varchar', length: 160 })
+ name: string;
+
+ @Column({ name: 'sort_order', type: 'int', default: 0 })
+ sortOrder: number;
+
+ @Column({ type: 'tinyint', default: 1 })
+ enabled: boolean;
+
+ @Column({ name: 'raw_payload', type: 'json', nullable: true })
+ rawPayload: Record | null;
+
+ @Column({ name: 'last_synced_at', type: 'datetime', nullable: true })
+ lastSyncedAt: Date | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+}
diff --git a/packages/backend/src/third-party/entities/course.entity.ts b/packages/backend/src/third-party/entities/course.entity.ts
new file mode 100644
index 0000000..36a2388
--- /dev/null
+++ b/packages/backend/src/third-party/entities/course.entity.ts
@@ -0,0 +1,127 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import type { SourceType } from './category.entity';
+
+export type ProductSyncStatus = 'active' | 'removed';
+
+@Entity('courses')
+@Index('idx_courses_source_provider_external_category', [
+ 'sourceType',
+ 'provider',
+ 'externalId',
+ 'remoteCategoryId',
+])
+@Index('idx_courses_provider_category_updated', [
+ 'provider',
+ 'remoteCategoryId',
+ 'updatedAt',
+])
+export class Course {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ name: 'source_type', type: 'varchar', length: 32 })
+ sourceType: SourceType;
+
+ @Column({ name: 'api_account_id', type: 'varchar', length: 36, nullable: true })
+ apiAccountId: string | null;
+
+ @Column({ type: 'varchar', length: 64, nullable: true })
+ provider: string | null;
+
+ @Column({ name: 'external_id', type: 'varchar', length: 160, nullable: true })
+ externalId: string | null;
+
+ @Column({ name: 'category_id', type: 'varchar', length: 36, nullable: true })
+ categoryId: string | null;
+
+ @Column({ name: 'remote_category_id', type: 'varchar', length: 120, nullable: true })
+ remoteCategoryId: string | null;
+
+ @Column({ name: 'remote_course_id', type: 'varchar', length: 160, nullable: true })
+ remoteCourseId: string | null;
+
+ @Column({ type: 'varchar', length: 255 })
+ name: string;
+
+ @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
+ price: string;
+
+ @Column({ name: 'cover_url', type: 'varchar', length: 500, nullable: true })
+ coverUrl: string | null;
+
+ @Column({ type: 'text', nullable: true })
+ content: string | null;
+
+ @Column({ type: 'int', nullable: true })
+ stock: number | null;
+
+ @Column({ name: 'sales_limit', type: 'int', nullable: true })
+ salesLimit: number | null;
+
+ @Column({ name: 'fulfillment_type', type: 'varchar', length: 40, default: 'third_party_api' })
+ fulfillmentType: string;
+
+ @Column({ name: 'after_sales_note', type: 'text', nullable: true })
+ afterSalesNote: string | null;
+
+ @Column({ name: 'order_form_schema', type: 'json', nullable: true })
+ orderFormSchema: OrderFormField[] | null;
+
+ @Column({ name: 'sort_order', type: 'int', default: 0 })
+ sortOrder: number;
+
+ @Column({ name: 'is_favorite', type: 'tinyint', default: 0 })
+ isFavorite: boolean;
+
+ @Column({ type: 'tinyint', default: 1 })
+ enabled: boolean;
+
+ @Column({ name: 'sync_status', type: 'varchar', length: 32, default: 'active' })
+ syncStatus: ProductSyncStatus;
+
+ @Column({ name: 'raw_payload', type: 'json', nullable: true })
+ rawPayload: Record | null;
+
+ @Column({ name: 'last_synced_at', type: 'datetime', nullable: true })
+ lastSyncedAt: Date | null;
+
+ @Column({ name: 'last_seen_at', type: 'datetime', nullable: true })
+ lastSeenAt: Date | null;
+
+ @Column({ name: 'last_seen_sync_job_id', type: 'varchar', length: 36, nullable: true })
+ lastSeenSyncJobId: string | null;
+
+ @Column({ name: 'removed_at', type: 'datetime', nullable: true })
+ removedAt: Date | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+}
+
+export type OrderFormFieldType =
+ | 'text'
+ | 'password'
+ | 'phone'
+ | 'number'
+ | 'textarea'
+ | 'select'
+ | 'checkbox';
+
+export type OrderFormField = {
+ key: string;
+ label: string;
+ type: OrderFormFieldType;
+ required: boolean;
+ placeholder?: string | null;
+ options?: Array<{ label: string; value: string }>;
+};
diff --git a/packages/backend/src/third-party/third-party-admin.controller.ts b/packages/backend/src/third-party/third-party-admin.controller.ts
new file mode 100644
index 0000000..505d8d4
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-admin.controller.ts
@@ -0,0 +1,74 @@
+import { Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
+import { OperationLog } from '../audit/operation-log.decorator';
+import { AuthGuard } from '../auth/auth.guard';
+import type { AuthenticatedRequest } from '../auth/types/authenticated-request';
+import { ThirdPartyAdminService } from './third-party-admin.service';
+
+@UseGuards(AuthGuard)
+@Controller('api/admin/third-party')
+export class ThirdPartyAdminController {
+ constructor(private readonly thirdPartyAdminService: ThirdPartyAdminService) {}
+
+ @Get('config-status')
+ getConfigStatus(@Req() request: AuthenticatedRequest) {
+ return this.thirdPartyAdminService.getConfigStatus(request.user);
+ }
+
+ @Post('test-connection')
+ @OperationLog({
+ action: 'third_party.test_connection',
+ resourceType: 'third_party',
+ description: '测试第三方接口连接',
+ })
+ testConnection(@Req() request: AuthenticatedRequest) {
+ return this.thirdPartyAdminService.testConnection(request.user);
+ }
+
+ @Get('api-call-logs')
+ listApiCallLogs(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: Record,
+ ) {
+ return this.thirdPartyAdminService.listApiCallLogs(request.user, query);
+ }
+
+ @Post('categories/sync')
+ @OperationLog({
+ action: 'third_party.categories.sync',
+ resourceType: 'category',
+ description: '同步第三方分类',
+ })
+ syncCategories(@Req() request: AuthenticatedRequest) {
+ return this.thirdPartyAdminService.syncCategories(request.user);
+ }
+
+ @Get('categories')
+ listCategories(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: Record,
+ ) {
+ return this.thirdPartyAdminService.listCategories(request.user, query);
+ }
+
+ @Post('courses/sync')
+ @OperationLog({
+ action: 'third_party.courses.sync',
+ resourceType: 'course',
+ metadataFromQuery: ['remoteCategoryId'],
+ description: '同步第三方课程',
+ })
+ syncCourses(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: Record,
+ ) {
+ return this.thirdPartyAdminService.syncCourses(request.user, query);
+ }
+
+ @Get('courses')
+ listCourses(
+ @Req() request: AuthenticatedRequest,
+ @Query() query: Record,
+ ) {
+ return this.thirdPartyAdminService.listCourses(request.user, query);
+ }
+}
diff --git a/packages/backend/src/third-party/third-party-admin.service.ts b/packages/backend/src/third-party/third-party-admin.service.ts
new file mode 100644
index 0000000..6dc281a
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-admin.service.ts
@@ -0,0 +1,90 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { User } from '../users/entities/user.entity';
+import { assertAdmin } from '../admin/admin-access';
+import { ApiCallLog } from './entities/api-call-log.entity';
+import { ThirdPartyCatalogService } from './third-party-catalog.service';
+import { ThirdPartyClientService } from './third-party-client.service';
+
+@Injectable()
+export class ThirdPartyAdminService {
+ constructor(
+ private readonly thirdPartyClientService: ThirdPartyClientService,
+ private readonly thirdPartyCatalogService: ThirdPartyCatalogService,
+ @InjectRepository(ApiCallLog)
+ private readonly apiCallLogsRepository: Repository,
+ ) {}
+
+ async testConnection(actor: User) {
+ assertAdmin(actor);
+ const response = await this.thirdPartyClientService.testConnection();
+ return {
+ provider: 'biedawo',
+ act: 'getcate',
+ connected: true,
+ response,
+ };
+ }
+
+ getConfigStatus(actor: User) {
+ assertAdmin(actor);
+ return this.thirdPartyClientService.getConfigStatus();
+ }
+
+ async listApiCallLogs(actor: User, query: Record) {
+ assertAdmin(actor);
+ const page = Math.max(Number(query.page || 1), 1);
+ const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
+ const act = String(query.act || '').trim();
+ const status = String(query.status || '').trim();
+ const provider = String(query.provider || '').trim();
+ const keyword = String(query.keyword || '').trim();
+
+ const builder = this.apiCallLogsRepository
+ .createQueryBuilder('log')
+ .orderBy('log.createdAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (act) {
+ builder.andWhere('log.act LIKE :act', { act: `%${act}%` });
+ }
+ if (status) {
+ builder.andWhere('log.status = :status', { status });
+ }
+ if (provider) {
+ builder.andWhere('log.provider = :provider', { provider });
+ }
+ if (keyword) {
+ builder.andWhere(
+ '(log.act LIKE :keyword OR log.errorMessage LIKE :keyword OR log.url LIKE :keyword)',
+ { keyword: `%${keyword}%` },
+ );
+ }
+
+ const [list, total] = await builder.getManyAndCount();
+ return { list, total, page, pageSize };
+ }
+
+ async syncCategories(actor: User) {
+ assertAdmin(actor);
+ return this.thirdPartyCatalogService.syncCategories();
+ }
+
+ async syncCourses(actor: User, query: Record) {
+ assertAdmin(actor);
+ const remoteCategoryId = String(query.remoteCategoryId || query.fenlei || '').trim();
+ return this.thirdPartyCatalogService.syncCourses(remoteCategoryId || undefined);
+ }
+
+ async listCategories(actor: User, query: Record) {
+ assertAdmin(actor);
+ return this.thirdPartyCatalogService.listCategories(query);
+ }
+
+ async listCourses(actor: User, query: Record) {
+ assertAdmin(actor);
+ return this.thirdPartyCatalogService.listCourses(query);
+ }
+}
diff --git a/packages/backend/src/third-party/third-party-catalog-sync.task.ts b/packages/backend/src/third-party/third-party-catalog-sync.task.ts
new file mode 100644
index 0000000..58bc437
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-catalog-sync.task.ts
@@ -0,0 +1,28 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+import { ThirdPartyCatalogService } from './third-party-catalog.service';
+
+@Injectable()
+export class ThirdPartyCatalogSyncTask {
+ private readonly logger = new Logger(ThirdPartyCatalogSyncTask.name);
+
+ constructor(private readonly thirdPartyCatalogService: ThirdPartyCatalogService) {}
+
+ @Cron(process.env.CATALOG_SYNC_CRON || '0 */2 * * *')
+ async handleCatalogSync() {
+ if (process.env.CATALOG_SYNC_CRON_ENABLED !== 'true') {
+ return;
+ }
+
+ try {
+ await this.thirdPartyCatalogService.syncThirdPartyCatalog({
+ triggerType: 'scheduled',
+ });
+ } catch (error) {
+ this.logger.error(
+ error instanceof Error ? error.message : String(error),
+ error instanceof Error ? error.stack : undefined,
+ );
+ }
+ }
+}
diff --git a/packages/backend/src/third-party/third-party-catalog.service.spec.ts b/packages/backend/src/third-party/third-party-catalog.service.spec.ts
new file mode 100644
index 0000000..fcb2ab8
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-catalog.service.spec.ts
@@ -0,0 +1,165 @@
+import { Category } from './entities/category.entity';
+import { CatalogSyncJob } from './entities/catalog-sync-job.entity';
+import { Course } from './entities/course.entity';
+import { OrderItem } from '../orders/entities/order-item.entity';
+import { ThirdPartyCatalogService } from './third-party-catalog.service';
+
+function createRepositoryMock() {
+ return {
+ create: jest.fn((input: Partial) => input),
+ findOne: jest.fn(),
+ find: jest.fn(),
+ exist: jest.fn(),
+ remove: jest.fn(async (input: Partial) => input),
+ save: jest.fn(async (input: Partial) => input),
+ createQueryBuilder: jest.fn(),
+ };
+}
+
+describe('ThirdPartyCatalogService', () => {
+ const client = {
+ call: jest.fn(),
+ };
+ const catalogSyncJobsRepository = createRepositoryMock();
+ const categoriesRepository = createRepositoryMock();
+ const coursesRepository = createRepositoryMock();
+ const orderItemsRepository = createRepositoryMock();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('syncs remote categories into local cache', async () => {
+ client.call.mockResolvedValue({
+ code: 1,
+ data: [
+ { id: '100', name: '大学课程', sort: 2 },
+ { cid: '200', title: '职业培训' },
+ ],
+ });
+ categoriesRepository.findOne.mockResolvedValue(null);
+ const service = new ThirdPartyCatalogService(
+ client as never,
+ catalogSyncJobsRepository as never,
+ categoriesRepository as never,
+ coursesRepository as never,
+ orderItemsRepository as never,
+ );
+
+ const result = await service.syncCategories();
+
+ expect(client.call).toHaveBeenCalledWith('getcate');
+ expect(categoriesRepository.save).toHaveBeenCalledTimes(2);
+ expect(categoriesRepository.save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sourceType: 'third_party',
+ provider: 'biedawo',
+ externalId: '100',
+ remoteCategoryId: '100',
+ name: '大学课程',
+ }),
+ );
+ expect(result).toEqual(
+ expect.objectContaining({
+ total: 2,
+ created: 2,
+ updated: 0,
+ }),
+ );
+ });
+
+ it('syncs remote courses and links cached categories', async () => {
+ client.call.mockResolvedValue({
+ code: 1,
+ data: [{ kcid: 'kc-1', kcname: '形势与政策', fenlei: '100', price: '12.5' }],
+ });
+ categoriesRepository.findOne.mockResolvedValue({ id: 'category-id' });
+ coursesRepository.findOne.mockResolvedValue(null);
+ const service = new ThirdPartyCatalogService(
+ client as never,
+ catalogSyncJobsRepository as never,
+ categoriesRepository as never,
+ coursesRepository as never,
+ orderItemsRepository as never,
+ );
+
+ const result = await service.syncCourses('100');
+
+ expect(client.call).toHaveBeenCalledWith('getclass', { fenlei: '100' });
+ expect(coursesRepository.save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sourceType: 'third_party',
+ provider: 'biedawo',
+ externalId: 'kc-1',
+ remoteCourseId: 'kc-1',
+ remoteCategoryId: '100',
+ categoryId: 'category-id',
+ name: '形势与政策',
+ price: '12.50',
+ }),
+ );
+ expect(result).toEqual(
+ expect.objectContaining({
+ remoteCategoryId: '100',
+ total: 1,
+ created: 1,
+ updated: 0,
+ }),
+ );
+ });
+
+ it('marks missing third-party products as removed when orders already exist', async () => {
+ client.call
+ .mockResolvedValueOnce({
+ code: 1,
+ data: [{ id: '100', name: '大学课程' }],
+ })
+ .mockResolvedValueOnce({
+ code: 1,
+ data: [],
+ });
+ categoriesRepository.findOne.mockResolvedValue(null);
+ coursesRepository.find.mockResolvedValue([
+ {
+ id: 'product-id',
+ sourceType: 'third_party',
+ provider: 'biedawo',
+ externalId: 'kc-removed',
+ remoteCategoryId: '100',
+ enabled: true,
+ syncStatus: 'active',
+ },
+ ]);
+ orderItemsRepository.exist.mockResolvedValue(true);
+ const service = new ThirdPartyCatalogService(
+ client as never,
+ catalogSyncJobsRepository as never,
+ categoriesRepository as never,
+ coursesRepository as never,
+ orderItemsRepository as never,
+ );
+
+ const result = await service.syncThirdPartyCatalog({
+ triggerType: 'manual',
+ remoteCategoryId: '100',
+ });
+
+ expect(orderItemsRepository.exist).toHaveBeenCalledWith({
+ where: { productId: 'product-id' },
+ });
+ expect(coursesRepository.remove).not.toHaveBeenCalled();
+ expect(coursesRepository.save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'product-id',
+ enabled: false,
+ syncStatus: 'removed',
+ }),
+ );
+ expect(result).toEqual(
+ expect.objectContaining({
+ categoryTotal: 1,
+ productRemoved: 1,
+ }),
+ );
+ });
+});
diff --git a/packages/backend/src/third-party/third-party-catalog.service.ts b/packages/backend/src/third-party/third-party-catalog.service.ts
new file mode 100644
index 0000000..43a5259
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-catalog.service.ts
@@ -0,0 +1,619 @@
+import { randomUUID } from 'node:crypto';
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Not, Repository } from 'typeorm';
+import { OrderItem } from '../orders/entities/order-item.entity';
+import {
+ CatalogSyncJob,
+ CatalogSyncTriggerType,
+} from './entities/catalog-sync-job.entity';
+import { Category } from './entities/category.entity';
+import { Course, OrderFormField } from './entities/course.entity';
+import { ThirdPartyClientService } from './third-party-client.service';
+
+const PROVIDER = 'biedawo';
+const API_ACCOUNT_ID = 'env-biedawo';
+
+type UnknownRecord = Record;
+
+type NormalizedCategory = {
+ externalId: string;
+ name: string;
+ sortOrder: number;
+ rawPayload: UnknownRecord;
+};
+
+type NormalizedCourse = {
+ externalId: string;
+ remoteCategoryId: string | null;
+ name: string;
+ price: string;
+ content: string | null;
+ rawPayload: UnknownRecord;
+};
+
+type CatalogSyncOptions = {
+ triggerType: CatalogSyncTriggerType;
+ remoteCategoryId?: string | null;
+};
+
+type SyncStats = {
+ categoryTotal: number;
+ productTotal: number;
+ categoryCreated: number;
+ categoryUpdated: number;
+ productCreated: number;
+ productUpdated: number;
+ productRemoved: number;
+};
+
+@Injectable()
+export class ThirdPartyCatalogService {
+ private readonly logger = new Logger(ThirdPartyCatalogService.name);
+ private catalogSyncRunning = false;
+
+ constructor(
+ private readonly thirdPartyClientService: ThirdPartyClientService,
+ @InjectRepository(CatalogSyncJob)
+ private readonly catalogSyncJobsRepository: Repository,
+ @InjectRepository(Category)
+ private readonly categoriesRepository: Repository,
+ @InjectRepository(Course)
+ private readonly coursesRepository: Repository,
+ @InjectRepository(OrderItem)
+ private readonly orderItemsRepository: Repository,
+ ) {}
+
+ async syncThirdPartyCatalog(options: CatalogSyncOptions) {
+ if (this.catalogSyncRunning) {
+ return {
+ provider: PROVIDER,
+ skipped: true,
+ reason: 'catalog sync is already running',
+ };
+ }
+
+ this.catalogSyncRunning = true;
+ const now = new Date();
+ const job = this.catalogSyncJobsRepository.create({
+ id: randomUUID(),
+ provider: PROVIDER,
+ triggerType: options.triggerType,
+ status: 'running',
+ startedAt: now,
+ finishedAt: null,
+ categoryTotal: 0,
+ productTotal: 0,
+ categoryCreated: 0,
+ categoryUpdated: 0,
+ productCreated: 0,
+ productUpdated: 0,
+ productRemoved: 0,
+ errorMessage: null,
+ });
+ await this.catalogSyncJobsRepository.save(job);
+
+ try {
+ const stats = await this.syncCatalogSnapshot(
+ job.id,
+ now,
+ options.remoteCategoryId,
+ );
+ Object.assign(job, {
+ ...stats,
+ status: 'success',
+ finishedAt: new Date(),
+ });
+ await this.catalogSyncJobsRepository.save(job);
+ return {
+ provider: PROVIDER,
+ jobId: job.id,
+ triggerType: job.triggerType,
+ status: job.status,
+ ...stats,
+ startedAt: job.startedAt,
+ finishedAt: job.finishedAt,
+ };
+ } catch (error) {
+ job.status = 'failed';
+ job.finishedAt = new Date();
+ job.errorMessage =
+ error instanceof Error ? error.message : String(error);
+ await this.catalogSyncJobsRepository.save(job);
+ throw error;
+ } finally {
+ this.catalogSyncRunning = false;
+ }
+ }
+
+ async syncCategories() {
+ const items = await this.fetchCategories();
+ const syncedAt = new Date();
+ let created = 0;
+ let updated = 0;
+
+ for (const item of items) {
+ const existing = await this.categoriesRepository.findOne({
+ where: {
+ sourceType: 'third_party',
+ provider: PROVIDER,
+ externalId: item.externalId,
+ },
+ });
+ const entity = this.categoriesRepository.create({
+ id: existing?.id ?? randomUUID(),
+ sourceType: 'third_party',
+ apiAccountId: API_ACCOUNT_ID,
+ provider: PROVIDER,
+ externalId: item.externalId,
+ remoteCategoryId: item.externalId,
+ name: item.name,
+ sortOrder: item.sortOrder,
+ enabled: true,
+ rawPayload: item.rawPayload,
+ lastSyncedAt: syncedAt,
+ });
+ await this.categoriesRepository.save(entity);
+ if (existing) {
+ updated += 1;
+ } else {
+ created += 1;
+ }
+ }
+
+ return {
+ provider: PROVIDER,
+ total: items.length,
+ created,
+ updated,
+ syncedAt,
+ };
+ }
+
+ async syncCourses(remoteCategoryId?: string) {
+ const items = await this.fetchCourses(remoteCategoryId);
+ const syncedAt = new Date();
+ let created = 0;
+ let updated = 0;
+
+ for (const item of items) {
+ const category = item.remoteCategoryId
+ ? await this.categoriesRepository.findOne({
+ where: {
+ sourceType: 'third_party',
+ provider: PROVIDER,
+ externalId: item.remoteCategoryId,
+ },
+ })
+ : null;
+ const existing = await this.coursesRepository.findOne({
+ where: {
+ sourceType: 'third_party',
+ provider: PROVIDER,
+ externalId: item.externalId,
+ remoteCategoryId: item.remoteCategoryId ?? '',
+ },
+ });
+ const entity = this.coursesRepository.create({
+ id: existing?.id ?? randomUUID(),
+ sourceType: 'third_party',
+ apiAccountId: API_ACCOUNT_ID,
+ provider: PROVIDER,
+ externalId: item.externalId,
+ categoryId: category?.id ?? existing?.categoryId ?? null,
+ remoteCategoryId: item.remoteCategoryId ?? '',
+ remoteCourseId: item.externalId,
+ name: item.name,
+ price: item.price,
+ content: item.content,
+ isFavorite: existing?.isFavorite ?? false,
+ enabled: true,
+ syncStatus: 'active',
+ rawPayload: item.rawPayload,
+ lastSyncedAt: syncedAt,
+ lastSeenAt: syncedAt,
+ removedAt: null,
+ });
+ await this.coursesRepository.save(entity);
+ if (existing) {
+ updated += 1;
+ } else {
+ created += 1;
+ }
+ }
+
+ return {
+ provider: PROVIDER,
+ remoteCategoryId: remoteCategoryId || null,
+ total: items.length,
+ created,
+ updated,
+ syncedAt,
+ };
+ }
+
+ async listCategories(query: Record) {
+ const page = Math.max(Number(query.page || 1), 1);
+ const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
+ const keyword = String(query.keyword || '').trim();
+
+ const builder = this.categoriesRepository
+ .createQueryBuilder('category')
+ .where('category.sourceType = :sourceType', { sourceType: 'third_party' })
+ .andWhere('category.provider = :provider', { provider: PROVIDER })
+ .orderBy('category.sortOrder', 'ASC')
+ .addOrderBy('category.updatedAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (keyword) {
+ builder.andWhere(
+ '(category.name LIKE :keyword OR category.externalId LIKE :keyword)',
+ { keyword: `%${keyword}%` },
+ );
+ }
+
+ const [list, total] = await builder.getManyAndCount();
+ return { list, total, page, pageSize };
+ }
+
+ async listSyncJobs(query: Record) {
+ const page = Math.max(Number(query.page || 1), 1);
+ const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
+
+ const [list, total] = await this.catalogSyncJobsRepository.findAndCount({
+ order: { startedAt: 'DESC' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ });
+
+ return { list, total, page, pageSize };
+ }
+
+ private async syncCatalogSnapshot(
+ jobId: string,
+ syncedAt: Date,
+ remoteCategoryId?: string | null,
+ ): Promise {
+ const categoryItems = await this.fetchCategories();
+ const scopedRemoteCategoryId = String(remoteCategoryId || '').trim();
+ const syncCategoryItems = scopedRemoteCategoryId
+ ? categoryItems.filter((item) => item.externalId === scopedRemoteCategoryId)
+ : categoryItems;
+ const stats: SyncStats = {
+ categoryTotal: syncCategoryItems.length,
+ productTotal: 0,
+ categoryCreated: 0,
+ categoryUpdated: 0,
+ productCreated: 0,
+ productUpdated: 0,
+ productRemoved: 0,
+ };
+
+ const categoryMap = new Map();
+ for (const [index, item] of categoryItems.entries()) {
+ const existing = await this.categoriesRepository.findOne({
+ where: {
+ sourceType: 'third_party',
+ provider: PROVIDER,
+ externalId: item.externalId,
+ },
+ });
+ const changed =
+ !existing ||
+ existing.name !== item.name ||
+ existing.sortOrder !== item.sortOrder ||
+ JSON.stringify(existing.rawPayload ?? null) !==
+ JSON.stringify(item.rawPayload ?? null);
+ const category = this.categoriesRepository.create({
+ id: existing?.id ?? randomUUID(),
+ sourceType: 'third_party',
+ apiAccountId: API_ACCOUNT_ID,
+ provider: PROVIDER,
+ externalId: item.externalId,
+ remoteCategoryId: item.externalId,
+ name: item.name,
+ sortOrder: item.sortOrder ?? index,
+ enabled: true,
+ rawPayload: item.rawPayload,
+ lastSyncedAt: syncedAt,
+ });
+ const saved = await this.categoriesRepository.save(category);
+ categoryMap.set(item.externalId, saved);
+ if (existing) {
+ if (changed) stats.categoryUpdated += 1;
+ } else {
+ stats.categoryCreated += 1;
+ }
+ }
+
+ const seenKeys = new Set();
+ for (const category of syncCategoryItems) {
+ const courses = await this.fetchCourses(category.externalId);
+ stats.productTotal += courses.length;
+ for (const item of courses) {
+ const remoteCategoryId = item.remoteCategoryId || category.externalId;
+ const key = this.getProductKey(remoteCategoryId, item.externalId);
+ seenKeys.add(key);
+ const localCategory = categoryMap.get(remoteCategoryId) ?? null;
+ const existing = await this.coursesRepository.findOne({
+ where: {
+ sourceType: 'third_party',
+ provider: PROVIDER,
+ externalId: item.externalId,
+ remoteCategoryId,
+ },
+ });
+ const changed =
+ !existing ||
+ existing.name !== item.name ||
+ String(existing.price) !== item.price ||
+ existing.content !== item.content ||
+ existing.categoryId !== (localCategory?.id ?? null) ||
+ existing.syncStatus !== 'active' ||
+ JSON.stringify(existing.rawPayload ?? null) !==
+ JSON.stringify(item.rawPayload ?? null);
+ const product = this.coursesRepository.create({
+ id: existing?.id ?? randomUUID(),
+ sourceType: 'third_party',
+ apiAccountId: API_ACCOUNT_ID,
+ provider: PROVIDER,
+ externalId: item.externalId,
+ categoryId: localCategory?.id ?? existing?.categoryId ?? null,
+ remoteCategoryId,
+ remoteCourseId: item.externalId,
+ name: item.name,
+ price: item.price,
+ coverUrl: existing?.coverUrl ?? null,
+ content: item.content,
+ stock: existing?.stock ?? null,
+ salesLimit: existing?.salesLimit ?? null,
+ fulfillmentType: 'third_party_api',
+ afterSalesNote: existing?.afterSalesNote ?? null,
+ orderFormSchema: existing?.orderFormSchema ?? this.getDefaultThirdPartyOrderFormSchema(),
+ sortOrder: existing?.sortOrder ?? 0,
+ isFavorite: existing?.isFavorite ?? false,
+ enabled: true,
+ syncStatus: 'active',
+ rawPayload: item.rawPayload,
+ lastSyncedAt: syncedAt,
+ lastSeenAt: syncedAt,
+ lastSeenSyncJobId: jobId,
+ removedAt: null,
+ });
+ await this.coursesRepository.save(product);
+ if (existing) {
+ if (changed) stats.productUpdated += 1;
+ } else {
+ stats.productCreated += 1;
+ }
+ }
+ }
+
+ const existingProducts = await this.coursesRepository.find({
+ where: {
+ sourceType: 'third_party',
+ provider: PROVIDER,
+ ...(scopedRemoteCategoryId
+ ? { remoteCategoryId: scopedRemoteCategoryId }
+ : {}),
+ syncStatus: Not('removed'),
+ },
+ });
+
+ for (const product of existingProducts) {
+ const key = this.getProductKey(
+ product.remoteCategoryId ?? '',
+ product.externalId ?? product.remoteCourseId ?? '',
+ );
+ if (!seenKeys.has(key)) {
+ await this.retireMissingProduct(product, syncedAt);
+ stats.productRemoved += 1;
+ }
+ }
+
+ return stats;
+ }
+
+ private async retireMissingProduct(product: Course, syncedAt: Date) {
+ const hasOrders = await this.orderItemsRepository.exist({
+ where: { productId: product.id },
+ });
+ if (!hasOrders) {
+ await this.coursesRepository.remove(product);
+ return;
+ }
+
+ product.enabled = false;
+ product.syncStatus = 'removed';
+ product.removedAt = syncedAt;
+ await this.coursesRepository.save(product);
+ }
+
+ private async fetchCategories() {
+ const response = await this.thirdPartyClientService.call('getcate');
+ return this.extractRecords(response).map((item, index) =>
+ this.normalizeCategory(item, index),
+ );
+ }
+
+ private async fetchCourses(remoteCategoryId?: string) {
+ const payload = remoteCategoryId ? { fenlei: remoteCategoryId } : {};
+ const response = await this.thirdPartyClientService.call('getclass', payload);
+ return this.extractRecords(response).map((item) =>
+ this.normalizeCourse(item, remoteCategoryId),
+ );
+ }
+
+ private getProductKey(remoteCategoryId: string, externalId: string) {
+ return `${PROVIDER}:${remoteCategoryId}:${externalId}`;
+ }
+
+ private getDefaultThirdPartyOrderFormSchema(): OrderFormField[] {
+ return [
+ {
+ key: 'school',
+ label: '学校',
+ type: 'text',
+ required: true,
+ placeholder: '请输入学校名称',
+ },
+ {
+ key: 'account',
+ label: '账号',
+ type: 'text',
+ required: true,
+ placeholder: '请输入学习账号',
+ },
+ {
+ key: 'password',
+ label: '密码',
+ type: 'password',
+ required: true,
+ placeholder: '请输入学习密码',
+ },
+ ];
+ }
+
+ async listCourses(query: Record) {
+ const page = Math.max(Number(query.page || 1), 1);
+ const pageSize = Math.min(Math.max(Number(query.pageSize || 10), 1), 100);
+ const keyword = String(query.keyword || '').trim();
+ const remoteCategoryId = String(query.remoteCategoryId || '').trim();
+
+ const builder = this.coursesRepository
+ .createQueryBuilder('course')
+ .where('course.sourceType = :sourceType', { sourceType: 'third_party' })
+ .andWhere('course.provider = :provider', { provider: PROVIDER })
+ .orderBy('course.updatedAt', 'DESC')
+ .skip((page - 1) * pageSize)
+ .take(pageSize);
+
+ if (keyword) {
+ builder.andWhere(
+ '(course.name LIKE :keyword OR course.externalId LIKE :keyword)',
+ { keyword: `%${keyword}%` },
+ );
+ }
+ if (remoteCategoryId) {
+ builder.andWhere('course.remoteCategoryId = :remoteCategoryId', {
+ remoteCategoryId,
+ });
+ }
+
+ const [list, total] = await builder.getManyAndCount();
+ return { list, total, page, pageSize };
+ }
+
+ private extractRecords(response: unknown): UnknownRecord[] {
+ const value = this.unwrapResponse(response);
+ if (Array.isArray(value)) {
+ return value.filter(this.isRecord);
+ }
+ if (this.isRecord(value)) {
+ return Object.entries(value).map(([key, item]) =>
+ this.isRecord(item) ? { id: key, ...item } : { id: key, name: item },
+ );
+ }
+ return [];
+ }
+
+ private unwrapResponse(response: unknown): unknown {
+ if (!this.isRecord(response)) {
+ return response;
+ }
+ return response.data ?? response.rows ?? response.list ?? response.result ?? response;
+ }
+
+ private normalizeCategory(
+ item: UnknownRecord,
+ index: number,
+ ): NormalizedCategory {
+ const externalId = this.readString(
+ item,
+ ['id', 'cid', 'cateid', 'category_id', 'fenlei', 'value'],
+ `category-${index + 1}`,
+ );
+ return {
+ externalId,
+ name: this.readString(item, ['name', 'title', 'label', 'fenlei_name'], externalId),
+ sortOrder: this.readNumber(item, ['sort', 'sort_order', 'order'], index),
+ rawPayload: item,
+ };
+ }
+
+ private normalizeCourse(
+ item: UnknownRecord,
+ fallbackRemoteCategoryId?: string,
+ ): NormalizedCourse {
+ const externalId = this.readString(item, [
+ 'id',
+ 'kcid',
+ 'cid',
+ 'course_id',
+ 'class_id',
+ 'value',
+ ]);
+ return {
+ externalId,
+ remoteCategoryId:
+ this.readOptionalString(item, [
+ 'fenlei',
+ 'category_id',
+ 'cateid',
+ 'cid',
+ 'sortid',
+ ]) ??
+ fallbackRemoteCategoryId ??
+ '',
+ name: this.readString(item, ['name', 'kcname', 'title', 'label'], externalId),
+ price: this.readPrice(item),
+ content: this.readOptionalString(item, ['content', 'desc', 'description', 'remark']),
+ rawPayload: item,
+ };
+ }
+
+ private readString(
+ item: UnknownRecord,
+ keys: string[],
+ fallback?: string,
+ ): string {
+ return this.readOptionalString(item, keys) ?? fallback ?? randomUUID();
+ }
+
+ private readOptionalString(item: UnknownRecord, keys: string[]) {
+ for (const key of keys) {
+ const value = item[key];
+ if (value !== undefined && value !== null && String(value).trim()) {
+ return String(value).trim();
+ }
+ }
+ return null;
+ }
+
+ private readNumber(item: UnknownRecord, keys: string[], fallback: number) {
+ for (const key of keys) {
+ const value = Number(item[key]);
+ if (Number.isFinite(value)) {
+ return value;
+ }
+ }
+ return fallback;
+ }
+
+ private readPrice(item: UnknownRecord) {
+ const value = this.readOptionalString(item, [
+ 'price',
+ 'money',
+ 'amount',
+ 'cost',
+ 'sell_price',
+ ]);
+ const numberValue = Number(value);
+ return Number.isFinite(numberValue) ? numberValue.toFixed(2) : '0.00';
+ }
+
+ private isRecord(value: unknown): value is UnknownRecord {
+ return !!value && typeof value === 'object' && !Array.isArray(value);
+ }
+}
diff --git a/packages/backend/src/third-party/third-party-client.service.spec.ts b/packages/backend/src/third-party/third-party-client.service.spec.ts
new file mode 100644
index 0000000..6dd2367
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-client.service.spec.ts
@@ -0,0 +1,162 @@
+import { ConfigService } from '@nestjs/config';
+import { ThirdPartyClientService } from './third-party-client.service';
+
+describe('ThirdPartyClientService', () => {
+ const save = jest.fn();
+ const create = jest.fn((input) => input);
+ const repository = { create, save };
+ const config = {
+ getOrThrow: jest.fn((key: string) => {
+ if (key === 'WK_BASE_URL') {
+ return 'https://biedawo.org/api.php';
+ }
+ throw new Error(`Missing config ${key}`);
+ }),
+ get: jest.fn((key: string) => {
+ const values: Record = {
+ WK_BASE_URL: 'https://biedawo.org/api.php',
+ WK_APP_UID: '10001',
+ WK_APP_KEY: 'plain-key',
+ };
+ return values[key];
+ }),
+ };
+ const debugConfig = {
+ ...config,
+ get: jest.fn((key: string) => {
+ const values: Record = {
+ WK_BASE_URL: 'https://biedawo.org/api.php',
+ WK_APP_UID: '10001',
+ WK_APP_KEY: 'plain-key',
+ WK_DEBUG_LOG: 'true',
+ };
+ return values[key];
+ }),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('writes a masked failure log when third-party business response fails', async () => {
+ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify({ code: 0, msg: '无效账号' }),
+ } as Response);
+ const service = new ThirdPartyClientService(
+ config as unknown as ConfigService,
+ repository as never,
+ );
+
+ await expect(
+ service.call('get', {
+ platform: 100,
+ user: 'student001',
+ pass: 'student-password',
+ }),
+ ).rejects.toThrow('第三方接口返回失败:无效账号');
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ href: 'https://biedawo.org/api.php?act=get',
+ }),
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: expect.any(URLSearchParams),
+ }),
+ );
+ const [, requestInit] = fetchMock.mock.calls[0];
+ expect((requestInit?.body as URLSearchParams).get('uid')).toBe('10001');
+ expect((requestInit?.body as URLSearchParams).get('key')).toBe('plain-key');
+ expect((requestInit?.body as URLSearchParams).get('act')).toBeNull();
+ expect(save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ act: 'get',
+ status: 'failure',
+ httpStatus: 200,
+ requestPayload: expect.objectContaining({
+ key: 'pl****ey',
+ pass: 'st****rd',
+ }),
+ responsePayload: { code: 0, msg: '无效账号' },
+ errorMessage: '无效账号',
+ }),
+ );
+ });
+
+ it('exposes safe third-party config status', () => {
+ const service = new ThirdPartyClientService(
+ config as unknown as ConfigService,
+ repository as never,
+ );
+
+ expect(service.getConfigStatus()).toEqual({
+ provider: 'biedawo',
+ baseUrl: 'https://biedawo.org/api.php',
+ uidConfigured: true,
+ keyConfigured: true,
+ debugLogEnabled: false,
+ });
+ });
+
+ it('writes a failure log and returns a client error when network request fails', async () => {
+ jest
+ .spyOn(global, 'fetch')
+ .mockRejectedValue(new TypeError('fetch failed'));
+ const service = new ThirdPartyClientService(
+ config as unknown as ConfigService,
+ repository as never,
+ );
+
+ await expect(service.call('getcate')).rejects.toThrow('fetch failed');
+
+ expect(save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ act: 'getcate',
+ status: 'failure',
+ httpStatus: null,
+ responsePayload: null,
+ errorMessage: 'fetch failed',
+ }),
+ );
+ });
+
+ it('prints masked request diagnostics when debug logging is enabled', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify({ code: 1, data: [] }),
+ } as Response);
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
+ const service = new ThirdPartyClientService(
+ debugConfig as unknown as ConfigService,
+ repository as never,
+ );
+
+ await service.call('getcate');
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ '[third-party] request',
+ expect.objectContaining({
+ act: 'getcate',
+ method: 'POST',
+ url: 'https://biedawo.org/api.php?act=getcate',
+ body: expect.objectContaining({
+ uid: '10001',
+ key: 'pl****ey',
+ }),
+ keyLength: 9,
+ keyPreview: 'plai****-key',
+ keyHasOuterWhitespace: false,
+ }),
+ );
+ });
+});
diff --git a/packages/backend/src/third-party/third-party-client.service.ts b/packages/backend/src/third-party/third-party-client.service.ts
new file mode 100644
index 0000000..02ea3dd
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-client.service.ts
@@ -0,0 +1,327 @@
+import { randomUUID } from 'node:crypto';
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { ApiCallLog } from './entities/api-call-log.entity';
+import { maskSensitivePayload } from './third-party-security';
+
+export type ThirdPartyPayload = Record;
+
+type CallOptions = {
+ timeoutMs?: number;
+};
+
+@Injectable()
+export class ThirdPartyClientService {
+ constructor(
+ private readonly configService: ConfigService,
+ @InjectRepository(ApiCallLog)
+ private readonly apiCallLogsRepository: Repository,
+ ) {}
+
+ async call(
+ act: string,
+ payload: ThirdPartyPayload = {},
+ options: CallOptions = {},
+ ): Promise {
+ const startedAt = Date.now();
+ const baseUrl = this.configService.getOrThrow('WK_BASE_URL');
+ const requestUrl = new URL(baseUrl);
+ requestUrl.searchParams.set('act', act);
+ let httpStatus: number | null = null;
+ let timeout: NodeJS.Timeout | undefined;
+ let logged = false;
+ let maskedRequestPayload: Record = maskSensitivePayload({
+ act,
+ ...payload,
+ });
+
+ try {
+ const bodyPayload = {
+ ...payload,
+ uid: this.getUid(),
+ key: this.getApiKey(),
+ };
+ maskedRequestPayload = maskSensitivePayload({ act, ...bodyPayload });
+ const requestBody = this.toUrlEncodedBody(bodyPayload);
+ this.debugLogRequest(act, requestUrl.toString(), requestBody);
+ const controller = new AbortController();
+ timeout = setTimeout(
+ () => controller.abort(),
+ options.timeoutMs ?? 15_000,
+ );
+
+ const response = await fetch(requestUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: requestBody,
+ signal: controller.signal,
+ });
+
+ httpStatus = response.status;
+ const { parsedResponse, responseText } = await this.parseResponse(response);
+ const maskedResponsePayload = maskSensitivePayload(parsedResponse);
+ const businessError = this.getBusinessError(parsedResponse);
+ const errorMessage = response.ok ? businessError : `HTTP ${response.status}`;
+ const status = response.ok && !businessError ? 'success' : 'failure';
+ this.debugLogResponse({
+ act,
+ status,
+ httpStatus,
+ durationMs: Date.now() - startedAt,
+ responseText,
+ parsedResponse: maskedResponsePayload,
+ errorMessage,
+ });
+
+ await this.writeLog({
+ act,
+ url: requestUrl.toString(),
+ status,
+ httpStatus,
+ durationMs: Date.now() - startedAt,
+ requestPayload: maskedRequestPayload,
+ responsePayload: maskedResponsePayload,
+ errorMessage,
+ });
+ logged = true;
+
+ if (!response.ok) {
+ throw new BadRequestException(`第三方接口请求失败:HTTP ${response.status}`);
+ }
+ if (businessError) {
+ throw new BadRequestException(`第三方接口返回失败:${businessError}`);
+ }
+
+ return parsedResponse as T;
+ } catch (error) {
+ if (logged) {
+ throw error;
+ }
+ const message = this.getErrorMessage(error);
+ await this.writeLog({
+ act,
+ url: requestUrl.toString(),
+ status: 'failure',
+ httpStatus,
+ durationMs: Date.now() - startedAt,
+ requestPayload: maskedRequestPayload,
+ responsePayload: null,
+ errorMessage: message,
+ });
+ throw this.toClientException(error);
+ } finally {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ }
+ }
+
+ async testConnection() {
+ return this.call('getcate');
+ }
+
+ getConfigStatus() {
+ const apiKey = this.configService.get('WK_APP_KEY');
+
+ return {
+ provider: 'biedawo',
+ baseUrl: this.configService.get('WK_BASE_URL') ?? null,
+ uidConfigured: Boolean(this.configService.get('WK_APP_UID')),
+ keyConfigured: Boolean(apiKey),
+ debugLogEnabled: this.isDebugEnabled(),
+ };
+ }
+
+ private getUid() {
+ const uid = this.configService.get('WK_APP_UID');
+ if (!uid) {
+ throw new BadRequestException('缺少 WK_APP_UID,无法调用第三方接口');
+ }
+ return uid;
+ }
+
+ private getApiKey() {
+ const apiKey = this.configService.get('WK_APP_KEY');
+ if (!apiKey) {
+ throw new BadRequestException('缺少 WK_APP_KEY,无法调用第三方接口');
+ }
+ return apiKey;
+ }
+
+ private toUrlEncodedBody(payload: ThirdPartyPayload) {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(payload)) {
+ if (value === undefined || value === null) {
+ continue;
+ }
+ params.set(
+ key,
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
+ );
+ }
+ return params;
+ }
+
+ private async parseResponse(response: Response) {
+ const text = await response.text();
+ if (!text) {
+ return { parsedResponse: null, responseText: text };
+ }
+
+ try {
+ return { parsedResponse: JSON.parse(text) as unknown, responseText: text };
+ } catch {
+ return { parsedResponse: { raw: text }, responseText: text };
+ }
+ }
+
+ private debugLogRequest(
+ act: string,
+ url: string,
+ requestBody: URLSearchParams,
+ ) {
+ if (!this.isDebugEnabled()) {
+ return;
+ }
+
+ const requestPayload = Object.fromEntries(requestBody.entries());
+ const key = requestBody.get('key') ?? '';
+ const uid = requestBody.get('uid') ?? '';
+ console.log('[third-party] request', {
+ act,
+ method: 'POST',
+ url,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: maskSensitivePayload(requestPayload),
+ bodyString: this.maskBodyString(requestBody.toString()),
+ uidLength: uid.length,
+ uidHasOuterWhitespace: uid !== uid.trim(),
+ keyLength: key.length,
+ keyPreview: this.previewSecret(key),
+ keyHasOuterWhitespace: key !== key.trim(),
+ });
+ }
+
+ private debugLogResponse(input: {
+ act: string;
+ status: 'success' | 'failure';
+ httpStatus: number | null;
+ durationMs: number;
+ responseText: string;
+ parsedResponse: unknown;
+ errorMessage: string | null;
+ }) {
+ if (!this.isDebugEnabled()) {
+ return;
+ }
+
+ console.log('[third-party] response', {
+ act: input.act,
+ status: input.status,
+ httpStatus: input.httpStatus,
+ durationMs: input.durationMs,
+ responseText: this.maskResponseText(input.responseText),
+ parsedResponse: input.parsedResponse,
+ errorMessage: input.errorMessage,
+ });
+ }
+
+ private isDebugEnabled() {
+ return ['1', 'true', 'yes', 'on'].includes(
+ String(this.configService.get('WK_DEBUG_LOG') || '').toLowerCase(),
+ );
+ }
+
+ private maskBodyString(value: string) {
+ const params = new URLSearchParams(value);
+ for (const key of Array.from(params.keys())) {
+ if (/key|pass|password|secret|token|authorization/i.test(key)) {
+ params.set(key, String(maskSensitivePayload({ [key]: params.get(key) })[key]));
+ }
+ }
+ return params.toString();
+ }
+
+ private maskResponseText(value: string) {
+ if (!value) {
+ return value;
+ }
+
+ try {
+ return JSON.stringify(maskSensitivePayload(JSON.parse(value)));
+ } catch {
+ return value;
+ }
+ }
+
+ private previewSecret(value: string) {
+ if (!value) {
+ return '';
+ }
+ if (value.length <= 8) {
+ return '*'.repeat(value.length);
+ }
+ return `${value.slice(0, 4)}****${value.slice(-4)}`;
+ }
+
+ async writeLog(input: {
+ act: string;
+ url: string;
+ method?: string;
+ status: 'success' | 'failure';
+ httpStatus: number | null;
+ durationMs: number;
+ requestPayload: Record;
+ responsePayload: unknown | null;
+ errorMessage: string | null;
+ }) {
+ const entity = this.apiCallLogsRepository.create({
+ id: randomUUID(),
+ provider: 'biedawo',
+ act: input.act,
+ method: input.method ?? 'POST',
+ url: input.url,
+ status: input.status,
+ success: input.status === 'success',
+ httpStatus: input.httpStatus,
+ durationMs: input.durationMs,
+ requestPayload: input.requestPayload,
+ responsePayload: input.responsePayload,
+ errorMessage: input.errorMessage,
+ });
+ await this.apiCallLogsRepository.save(entity);
+ }
+
+ private getErrorMessage(error: unknown) {
+ if (error instanceof Error) {
+ return error.name === 'AbortError' ? '第三方接口请求超时' : error.message;
+ }
+ return '第三方接口请求失败';
+ }
+
+ private toClientException(error: unknown) {
+ if (error instanceof BadRequestException) {
+ return error;
+ }
+ return new BadRequestException(this.getErrorMessage(error));
+ }
+
+ private getBusinessError(response: unknown) {
+ if (!response || typeof response !== 'object' || !('code' in response)) {
+ return null;
+ }
+
+ const body = response as { code?: unknown; msg?: unknown; message?: unknown };
+ if (body.code === 1 || body.code === '1' || body.code === 200 || body.code === '200') {
+ return null;
+ }
+
+ return String(body.msg || body.message || `业务状态码 ${String(body.code)}`);
+ }
+}
diff --git a/packages/backend/src/third-party/third-party-security.spec.ts b/packages/backend/src/third-party/third-party-security.spec.ts
new file mode 100644
index 0000000..367a0c4
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-security.spec.ts
@@ -0,0 +1,23 @@
+import { maskSensitivePayload } from './third-party-security';
+
+describe('third-party security helpers', () => {
+ it('masks sensitive fields recursively', () => {
+ const masked = maskSensitivePayload({
+ uid: '10001',
+ key: 'abcdef123456',
+ expand: {
+ pass: 'student-password',
+ score: 95,
+ },
+ });
+
+ expect(masked).toEqual({
+ uid: '10001',
+ key: 'ab****56',
+ expand: {
+ pass: 'st****rd',
+ score: 95,
+ },
+ });
+ });
+});
diff --git a/packages/backend/src/third-party/third-party-security.ts b/packages/backend/src/third-party/third-party-security.ts
new file mode 100644
index 0000000..4a28c22
--- /dev/null
+++ b/packages/backend/src/third-party/third-party-security.ts
@@ -0,0 +1,31 @@
+const sensitiveKeyPattern = /(key|pass|password|secret|token|authorization)/i;
+
+export function maskValue(value: unknown) {
+ if (value === null || value === undefined || value === '') {
+ return value;
+ }
+ const text = String(value);
+ if (text.length <= 4) {
+ return '****';
+ }
+ return `${text.slice(0, 2)}****${text.slice(-2)}`;
+}
+
+export function maskSensitivePayload(payload: T): T {
+ if (Array.isArray(payload)) {
+ return payload.map((item) => maskSensitivePayload(item)) as T;
+ }
+
+ if (!payload || typeof payload !== 'object') {
+ return payload;
+ }
+
+ return Object.fromEntries(
+ Object.entries(payload).map(([key, value]) => [
+ key,
+ sensitiveKeyPattern.test(key)
+ ? maskValue(value)
+ : maskSensitivePayload(value),
+ ]),
+ ) as T;
+}
diff --git a/packages/backend/src/third-party/third-party.module.ts b/packages/backend/src/third-party/third-party.module.ts
new file mode 100644
index 0000000..8958720
--- /dev/null
+++ b/packages/backend/src/third-party/third-party.module.ts
@@ -0,0 +1,35 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AuthModule } from '../auth/auth.module';
+import { OrderItem } from '../orders/entities/order-item.entity';
+import { ApiCallLog } from './entities/api-call-log.entity';
+import { CatalogSyncJob } from './entities/catalog-sync-job.entity';
+import { Category } from './entities/category.entity';
+import { Course } from './entities/course.entity';
+import { ThirdPartyAdminController } from './third-party-admin.controller';
+import { ThirdPartyAdminService } from './third-party-admin.service';
+import { ThirdPartyCatalogService } from './third-party-catalog.service';
+import { ThirdPartyCatalogSyncTask } from './third-party-catalog-sync.task';
+import { ThirdPartyClientService } from './third-party-client.service';
+
+@Module({
+ imports: [
+ AuthModule,
+ TypeOrmModule.forFeature([
+ ApiCallLog,
+ CatalogSyncJob,
+ Category,
+ Course,
+ OrderItem,
+ ]),
+ ],
+ controllers: [ThirdPartyAdminController],
+ providers: [
+ ThirdPartyAdminService,
+ ThirdPartyCatalogService,
+ ThirdPartyCatalogSyncTask,
+ ThirdPartyClientService,
+ ],
+ exports: [ThirdPartyCatalogService, ThirdPartyClientService],
+})
+export class ThirdPartyModule {}
diff --git a/packages/backend/src/users/entities/role.entity.ts b/packages/backend/src/users/entities/role.entity.ts
new file mode 100644
index 0000000..9e4ea79
--- /dev/null
+++ b/packages/backend/src/users/entities/role.entity.ts
@@ -0,0 +1,33 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ ManyToMany,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import { User } from './user.entity';
+
+@Entity('roles')
+export class Role {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ type: 'varchar', length: 80, unique: true })
+ code: string;
+
+ @Column({ type: 'varchar', length: 80 })
+ name: string;
+
+ @Column({ type: 'json', nullable: true })
+ permissions: Record | string[] | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+
+ @ManyToMany(() => User, (user) => user.roles)
+ users: User[];
+}
diff --git a/packages/backend/src/users/entities/user.entity.ts b/packages/backend/src/users/entities/user.entity.ts
new file mode 100644
index 0000000..10c7beb
--- /dev/null
+++ b/packages/backend/src/users/entities/user.entity.ts
@@ -0,0 +1,64 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ JoinTable,
+ ManyToMany,
+ PrimaryColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import { Role } from './role.entity';
+
+@Entity('users')
+export class User {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ id: string;
+
+ @Column({ type: 'varchar', length: 80 })
+ name: string;
+
+ @Index({ unique: true })
+ @Column({ type: 'varchar', length: 160 })
+ email: string;
+
+ @Column({ name: 'password_hash', type: 'varchar', length: 255 })
+ passwordHash: string;
+
+ @Column({ type: 'tinyint', default: 1 })
+ enabled: boolean;
+
+ @Index()
+ @Column({ name: 'parent_id', type: 'varchar', length: 36, nullable: true })
+ parentId: string | null;
+
+ @Index()
+ @Column({ name: 'parent_path', type: 'varchar', length: 1024, nullable: true })
+ parentPath: string | null;
+
+ @Column({ name: 'token_version', type: 'int', default: 0 })
+ tokenVersion: number;
+
+ @Column({ name: 'must_change_password', type: 'tinyint', default: 0 })
+ mustChangePassword: boolean;
+
+ @Column({ type: 'json', nullable: true })
+ permissions: Record | null;
+
+ @Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
+ lastLoginAt: Date | null;
+
+ @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 6 })
+ updatedAt: Date;
+
+ @ManyToMany(() => Role, (role) => role.users, { eager: true })
+ @JoinTable({
+ name: 'user_roles',
+ joinColumn: { name: 'user_id', referencedColumnName: 'id' },
+ inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
+ })
+ roles: Role[];
+}
diff --git a/packages/backend/src/users/users.module.ts b/packages/backend/src/users/users.module.ts
new file mode 100644
index 0000000..d6aa2db
--- /dev/null
+++ b/packages/backend/src/users/users.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Role } from './entities/role.entity';
+import { User } from './entities/user.entity';
+import { UsersService } from './users.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Role, User])],
+ providers: [UsersService],
+ exports: [UsersService],
+})
+export class UsersModule {}
diff --git a/packages/backend/src/users/users.service.ts b/packages/backend/src/users/users.service.ts
new file mode 100644
index 0000000..12456ae
--- /dev/null
+++ b/packages/backend/src/users/users.service.ts
@@ -0,0 +1,47 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { User } from './entities/user.entity';
+
+@Injectable()
+export class UsersService {
+ constructor(
+ @InjectRepository(User)
+ private readonly usersRepository: Repository,
+ ) {}
+
+ findByLoginName(loginName: string) {
+ return this.usersRepository
+ .createQueryBuilder('user')
+ .leftJoinAndSelect('user.roles', 'role')
+ .where('user.name = :loginName', { loginName })
+ .orWhere('user.email = :loginName', { loginName })
+ .getOne();
+ }
+
+ findById(id: string) {
+ return this.usersRepository.findOne({
+ where: { id },
+ });
+ }
+
+ findByEmail(email: string) {
+ return this.usersRepository.findOne({
+ where: { email },
+ });
+ }
+
+ async markLoginSuccess(userId: string) {
+ await this.usersRepository.update(userId, {
+ lastLoginAt: new Date(),
+ });
+ }
+
+ async save(user: User) {
+ return this.usersRepository.save(user);
+ }
+
+ async incrementTokenVersion(userId: string) {
+ await this.usersRepository.increment({ id: userId }, 'tokenVersion', 1);
+ }
+}
diff --git a/packages/backend/tsconfig.build.json b/packages/backend/tsconfig.build.json
new file mode 100644
index 0000000..64f86c6
--- /dev/null
+++ b/packages/backend/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
+}
diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json
new file mode 100644
index 0000000..aba29b0
--- /dev/null
+++ b/packages/backend/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "resolvePackageJsonExports": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "declaration": true,
+ "removeComments": true,
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "allowSyntheticDefaultImports": true,
+ "target": "ES2023",
+ "sourceMap": true,
+ "outDir": "./dist",
+ "baseUrl": "./",
+ "incremental": true,
+ "skipLibCheck": true,
+ "strictNullChecks": true,
+ "forceConsistentCasingInFileNames": true,
+ "noImplicitAny": false,
+ "strictBindCallApply": false,
+ "noFallthroughCasesInSwitch": false
+ }
+}
diff --git a/packages/frontend/.env.example b/packages/frontend/.env.example
new file mode 100644
index 0000000..a32bad2
--- /dev/null
+++ b/packages/frontend/.env.example
@@ -0,0 +1 @@
+VITE_CLERK_PUBLISHABLE_KEY=
\ No newline at end of file
diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore
new file mode 100644
index 0000000..ccc9a73
--- /dev/null
+++ b/packages/frontend/.gitignore
@@ -0,0 +1,34 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+.env
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+!.vscode/settings.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Test coverage
+/coverage
+
+# Vitest artifacts (browser screenshots/attachments)
+**/__screenshots__/
+.vitest-attachments/
diff --git a/packages/frontend/.prettierignore b/packages/frontend/.prettierignore
new file mode 100644
index 0000000..890dfbf
--- /dev/null
+++ b/packages/frontend/.prettierignore
@@ -0,0 +1,19 @@
+# Ignore everything
+/*
+
+# Except these files & folders
+!/src
+!index.html
+!package.json
+!tailwind.config.js
+!tsconfig.json
+!tsconfig.node.json
+!vite.config.ts
+!.prettierrc
+!README.md
+!eslint.config.js
+!postcss.config.js
+!.vscode/
+
+# Ignore auto generated routeTree.gen.ts
+/src/routeTree.gen.ts
\ No newline at end of file
diff --git a/packages/frontend/.prettierrc b/packages/frontend/.prettierrc
new file mode 100644
index 0000000..29295a8
--- /dev/null
+++ b/packages/frontend/.prettierrc
@@ -0,0 +1,54 @@
+{
+ "arrowParens": "always",
+ "semi": false,
+ "tabWidth": 2,
+ "printWidth": 80,
+ "singleQuote": true,
+ "jsxSingleQuote": true,
+ "trailingComma": "es5",
+ "bracketSpacing": true,
+ "endOfLine": "lf",
+ "plugins": [
+ "@trivago/prettier-plugin-sort-imports",
+ "prettier-plugin-tailwindcss"
+ ],
+ "tailwindFunctions": [
+ "cn",
+ "clsx"
+ ],
+ "tailwindStylesheet": "./src/styles/index.css",
+ "importOrder": [
+ "^path$",
+ "^vite$",
+ "^@vitejs/(.*)$",
+ "^react$",
+ "^react-dom/client$",
+ "^react/(.*)$",
+ "^globals$",
+ "^zod$",
+ "^axios$",
+ "^date-fns$",
+ "^react-hook-form$",
+ "^use-intl$",
+ "^@radix-ui/(.*)$",
+ "^@hookform/resolvers/zod$",
+ "^@tanstack/react-query$",
+ "^@tanstack/react-router$",
+ "^@tanstack/react-table$",
+ "",
+ "^@/assets/(.*)",
+ "^@/api/(.*)$",
+ "^@/stores/(.*)$",
+ "^@/lib/(.*)$",
+ "^@/utils/(.*)$",
+ "^@/constants/(.*)$",
+ "^@/context/(.*)$",
+ "^@/hooks/(.*)$",
+ "^@/components/layouts/(.*)$",
+ "^@/components/ui/(.*)$",
+ "^@/components/errors/(.*)$",
+ "^@/components/(.*)$",
+ "^@/features/(.*)$",
+ "^[./]"
+ ]
+}
diff --git a/packages/frontend/.tanstack/tmp/06d7c4d0-1d3469a84ece2105deae882ff2676ccf b/packages/frontend/.tanstack/tmp/06d7c4d0-1d3469a84ece2105deae882ff2676ccf
new file mode 100644
index 0000000..a789490
--- /dev/null
+++ b/packages/frontend/.tanstack/tmp/06d7c4d0-1d3469a84ece2105deae882ff2676ccf
@@ -0,0 +1,936 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as ClerkRouteRouteImport } from './routes/clerk/route'
+import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
+import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
+import { Route as AuthenticatedTicketsRouteImport } from './routes/_authenticated/tickets'
+import { Route as AuthenticatedSelfOwnedOrderRouteImport } from './routes/_authenticated/self-owned-order'
+import { Route as AuthenticatedOrdersRouteImport } from './routes/_authenticated/orders'
+import { Route as AuthenticatedOrderLogsRouteImport } from './routes/_authenticated/order-logs'
+import { Route as AuthenticatedCourseQueryRouteImport } from './routes/_authenticated/course-query'
+import { Route as errors503RouteImport } from './routes/(errors)/503'
+import { Route as errors500RouteImport } from './routes/(errors)/500'
+import { Route as errors404RouteImport } from './routes/(errors)/404'
+import { Route as errors403RouteImport } from './routes/(errors)/403'
+import { Route as errors401RouteImport } from './routes/(errors)/401'
+import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
+import { Route as authSignIn2RouteImport } from './routes/(auth)/sign-in-2'
+import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
+import { Route as authOtpRouteImport } from './routes/(auth)/otp'
+import { Route as authForgotPasswordRouteImport } from './routes/(auth)/forgot-password'
+import { Route as ClerkAuthenticatedRouteRouteImport } from './routes/clerk/_authenticated/route'
+import { Route as ClerkauthRouteRouteImport } from './routes/clerk/(auth)/route'
+import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authenticated/settings/route'
+import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
+import { Route as AuthenticatedTasksIndexRouteImport } from './routes/_authenticated/tasks/index'
+import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
+import { Route as AuthenticatedHelpCenterIndexRouteImport } from './routes/_authenticated/help-center/index'
+import { Route as AuthenticatedChatsIndexRouteImport } from './routes/_authenticated/chats/index'
+import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
+import { Route as ClerkAuthenticatedUserManagementRouteImport } from './routes/clerk/_authenticated/user-management'
+import { Route as ClerkauthSignUpRouteImport } from './routes/clerk/(auth)/sign-up'
+import { Route as ClerkauthSignInRouteImport } from './routes/clerk/(auth)/sign-in'
+import { Route as AuthenticatedSystemUsersRouteImport } from './routes/_authenticated/system/users'
+import { Route as AuthenticatedSystemThirdPartyRouteImport } from './routes/_authenticated/system/third-party'
+import { Route as AuthenticatedSystemAuditLogsRouteImport } from './routes/_authenticated/system/audit-logs'
+import { Route as AuthenticatedSettingsNotificationsRouteImport } from './routes/_authenticated/settings/notifications'
+import { Route as AuthenticatedSettingsDisplayRouteImport } from './routes/_authenticated/settings/display'
+import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance'
+import { Route as AuthenticatedSettingsAccountRouteImport } from './routes/_authenticated/settings/account'
+import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error'
+import { Route as AuthenticatedCatalogProductsRouteImport } from './routes/_authenticated/catalog/products'
+import { Route as AuthenticatedCatalogCategoriesRouteImport } from './routes/_authenticated/catalog/categories'
+
+const ClerkRouteRoute = ClerkRouteRouteImport.update({
+ id: '/clerk',
+ path: '/clerk',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
+ id: '/_authenticated',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const AuthenticatedTicketsRoute = AuthenticatedTicketsRouteImport.update({
+ id: '/tickets',
+ path: '/tickets',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const AuthenticatedSelfOwnedOrderRoute =
+ AuthenticatedSelfOwnedOrderRouteImport.update({
+ id: '/self-owned-order',
+ path: '/self-owned-order',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedOrdersRoute = AuthenticatedOrdersRouteImport.update({
+ id: '/orders',
+ path: '/orders',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const AuthenticatedOrderLogsRoute = AuthenticatedOrderLogsRouteImport.update({
+ id: '/order-logs',
+ path: '/order-logs',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const AuthenticatedCourseQueryRoute =
+ AuthenticatedCourseQueryRouteImport.update({
+ id: '/course-query',
+ path: '/course-query',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const errors503Route = errors503RouteImport.update({
+ id: '/(errors)/503',
+ path: '/503',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const errors500Route = errors500RouteImport.update({
+ id: '/(errors)/500',
+ path: '/500',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const errors404Route = errors404RouteImport.update({
+ id: '/(errors)/404',
+ path: '/404',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const errors403Route = errors403RouteImport.update({
+ id: '/(errors)/403',
+ path: '/403',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const errors401Route = errors401RouteImport.update({
+ id: '/(errors)/401',
+ path: '/401',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const authSignUpRoute = authSignUpRouteImport.update({
+ id: '/(auth)/sign-up',
+ path: '/sign-up',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const authSignIn2Route = authSignIn2RouteImport.update({
+ id: '/(auth)/sign-in-2',
+ path: '/sign-in-2',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const authSignInRoute = authSignInRouteImport.update({
+ id: '/(auth)/sign-in',
+ path: '/sign-in',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const authOtpRoute = authOtpRouteImport.update({
+ id: '/(auth)/otp',
+ path: '/otp',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const authForgotPasswordRoute = authForgotPasswordRouteImport.update({
+ id: '/(auth)/forgot-password',
+ path: '/forgot-password',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ClerkAuthenticatedRouteRoute = ClerkAuthenticatedRouteRouteImport.update({
+ id: '/_authenticated',
+ getParentRoute: () => ClerkRouteRoute,
+} as any)
+const ClerkauthRouteRoute = ClerkauthRouteRouteImport.update({
+ id: '/(auth)',
+ getParentRoute: () => ClerkRouteRoute,
+} as any)
+const AuthenticatedSettingsRouteRoute =
+ AuthenticatedSettingsRouteRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedUsersIndexRoute = AuthenticatedUsersIndexRouteImport.update({
+ id: '/users/',
+ path: '/users/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const AuthenticatedTasksIndexRoute = AuthenticatedTasksIndexRouteImport.update({
+ id: '/tasks/',
+ path: '/tasks/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const AuthenticatedSettingsIndexRoute =
+ AuthenticatedSettingsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => AuthenticatedSettingsRouteRoute,
+ } as any)
+const AuthenticatedHelpCenterIndexRoute =
+ AuthenticatedHelpCenterIndexRouteImport.update({
+ id: '/help-center/',
+ path: '/help-center/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedChatsIndexRoute = AuthenticatedChatsIndexRouteImport.update({
+ id: '/chats/',
+ path: '/chats/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
+ id: '/apps/',
+ path: '/apps/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
+const ClerkAuthenticatedUserManagementRoute =
+ ClerkAuthenticatedUserManagementRouteImport.update({
+ id: '/user-management',
+ path: '/user-management',
+ getParentRoute: () => ClerkAuthenticatedRouteRoute,
+ } as any)
+const ClerkauthSignUpRoute = ClerkauthSignUpRouteImport.update({
+ id: '/sign-up',
+ path: '/sign-up',
+ getParentRoute: () => ClerkauthRouteRoute,
+} as any)
+const ClerkauthSignInRoute = ClerkauthSignInRouteImport.update({
+ id: '/sign-in',
+ path: '/sign-in',
+ getParentRoute: () => ClerkauthRouteRoute,
+} as any)
+const AuthenticatedSystemUsersRoute =
+ AuthenticatedSystemUsersRouteImport.update({
+ id: '/system/users',
+ path: '/system/users',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedSystemThirdPartyRoute =
+ AuthenticatedSystemThirdPartyRouteImport.update({
+ id: '/system/third-party',
+ path: '/system/third-party',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedSystemAuditLogsRoute =
+ AuthenticatedSystemAuditLogsRouteImport.update({
+ id: '/system/audit-logs',
+ path: '/system/audit-logs',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedSettingsNotificationsRoute =
+ AuthenticatedSettingsNotificationsRouteImport.update({
+ id: '/notifications',
+ path: '/notifications',
+ getParentRoute: () => AuthenticatedSettingsRouteRoute,
+ } as any)
+const AuthenticatedSettingsDisplayRoute =
+ AuthenticatedSettingsDisplayRouteImport.update({
+ id: '/display',
+ path: '/display',
+ getParentRoute: () => AuthenticatedSettingsRouteRoute,
+ } as any)
+const AuthenticatedSettingsAppearanceRoute =
+ AuthenticatedSettingsAppearanceRouteImport.update({
+ id: '/appearance',
+ path: '/appearance',
+ getParentRoute: () => AuthenticatedSettingsRouteRoute,
+ } as any)
+const AuthenticatedSettingsAccountRoute =
+ AuthenticatedSettingsAccountRouteImport.update({
+ id: '/account',
+ path: '/account',
+ getParentRoute: () => AuthenticatedSettingsRouteRoute,
+ } as any)
+const AuthenticatedErrorsErrorRoute =
+ AuthenticatedErrorsErrorRouteImport.update({
+ id: '/errors/$error',
+ path: '/errors/$error',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedCatalogProductsRoute =
+ AuthenticatedCatalogProductsRouteImport.update({
+ id: '/catalog/products',
+ path: '/catalog/products',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+const AuthenticatedCatalogCategoriesRoute =
+ AuthenticatedCatalogCategoriesRouteImport.update({
+ id: '/catalog/categories',
+ path: '/catalog/categories',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof AuthenticatedIndexRoute
+ '/clerk': typeof ClerkauthRouteRouteWithChildren
+ '/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
+ '/forgot-password': typeof authForgotPasswordRoute
+ '/otp': typeof authOtpRoute
+ '/sign-in': typeof authSignInRoute
+ '/sign-in-2': typeof authSignIn2Route
+ '/sign-up': typeof authSignUpRoute
+ '/401': typeof errors401Route
+ '/403': typeof errors403Route
+ '/404': typeof errors404Route
+ '/500': typeof errors500Route
+ '/503': typeof errors503Route
+ '/course-query': typeof AuthenticatedCourseQueryRoute
+ '/order-logs': typeof AuthenticatedOrderLogsRoute
+ '/orders': typeof AuthenticatedOrdersRoute
+ '/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
+ '/tickets': typeof AuthenticatedTicketsRoute
+ '/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
+ '/catalog/products': typeof AuthenticatedCatalogProductsRoute
+ '/errors/$error': typeof AuthenticatedErrorsErrorRoute
+ '/settings/account': typeof AuthenticatedSettingsAccountRoute
+ '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
+ '/settings/display': typeof AuthenticatedSettingsDisplayRoute
+ '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+ '/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
+ '/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
+ '/system/users': typeof AuthenticatedSystemUsersRoute
+ '/clerk/sign-in': typeof ClerkauthSignInRoute
+ '/clerk/sign-up': typeof ClerkauthSignUpRoute
+ '/clerk/user-management': typeof ClerkAuthenticatedUserManagementRoute
+ '/apps/': typeof AuthenticatedAppsIndexRoute
+ '/chats/': typeof AuthenticatedChatsIndexRoute
+ '/help-center/': typeof AuthenticatedHelpCenterIndexRoute
+ '/settings/': typeof AuthenticatedSettingsIndexRoute
+ '/tasks/': typeof AuthenticatedTasksIndexRoute
+ '/users/': typeof AuthenticatedUsersIndexRoute
+}
+export interface FileRoutesByTo {
+ '/clerk': typeof ClerkauthRouteRouteWithChildren
+ '/forgot-password': typeof authForgotPasswordRoute
+ '/otp': typeof authOtpRoute
+ '/sign-in': typeof authSignInRoute
+ '/sign-in-2': typeof authSignIn2Route
+ '/sign-up': typeof authSignUpRoute
+ '/401': typeof errors401Route
+ '/403': typeof errors403Route
+ '/404': typeof errors404Route
+ '/500': typeof errors500Route
+ '/503': typeof errors503Route
+ '/course-query': typeof AuthenticatedCourseQueryRoute
+ '/order-logs': typeof AuthenticatedOrderLogsRoute
+ '/orders': typeof AuthenticatedOrdersRoute
+ '/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
+ '/tickets': typeof AuthenticatedTicketsRoute
+ '/': typeof AuthenticatedIndexRoute
+ '/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
+ '/catalog/products': typeof AuthenticatedCatalogProductsRoute
+ '/errors/$error': typeof AuthenticatedErrorsErrorRoute
+ '/settings/account': typeof AuthenticatedSettingsAccountRoute
+ '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
+ '/settings/display': typeof AuthenticatedSettingsDisplayRoute
+ '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+ '/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
+ '/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
+ '/system/users': typeof AuthenticatedSystemUsersRoute
+ '/clerk/sign-in': typeof ClerkauthSignInRoute
+ '/clerk/sign-up': typeof ClerkauthSignUpRoute
+ '/clerk/user-management': typeof ClerkAuthenticatedUserManagementRoute
+ '/apps': typeof AuthenticatedAppsIndexRoute
+ '/chats': typeof AuthenticatedChatsIndexRoute
+ '/help-center': typeof AuthenticatedHelpCenterIndexRoute
+ '/settings': typeof AuthenticatedSettingsIndexRoute
+ '/tasks': typeof AuthenticatedTasksIndexRoute
+ '/users': typeof AuthenticatedUsersIndexRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/_authenticated': typeof AuthenticatedRouteRouteWithChildren
+ '/clerk': typeof ClerkRouteRouteWithChildren
+ '/_authenticated/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
+ '/clerk/(auth)': typeof ClerkauthRouteRouteWithChildren
+ '/clerk/_authenticated': typeof ClerkAuthenticatedRouteRouteWithChildren
+ '/(auth)/forgot-password': typeof authForgotPasswordRoute
+ '/(auth)/otp': typeof authOtpRoute
+ '/(auth)/sign-in': typeof authSignInRoute
+ '/(auth)/sign-in-2': typeof authSignIn2Route
+ '/(auth)/sign-up': typeof authSignUpRoute
+ '/(errors)/401': typeof errors401Route
+ '/(errors)/403': typeof errors403Route
+ '/(errors)/404': typeof errors404Route
+ '/(errors)/500': typeof errors500Route
+ '/(errors)/503': typeof errors503Route
+ '/_authenticated/course-query': typeof AuthenticatedCourseQueryRoute
+ '/_authenticated/order-logs': typeof AuthenticatedOrderLogsRoute
+ '/_authenticated/orders': typeof AuthenticatedOrdersRoute
+ '/_authenticated/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
+ '/_authenticated/tickets': typeof AuthenticatedTicketsRoute
+ '/_authenticated/': typeof AuthenticatedIndexRoute
+ '/_authenticated/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
+ '/_authenticated/catalog/products': typeof AuthenticatedCatalogProductsRoute
+ '/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute
+ '/_authenticated/settings/account': typeof AuthenticatedSettingsAccountRoute
+ '/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
+ '/_authenticated/settings/display': typeof AuthenticatedSettingsDisplayRoute
+ '/_authenticated/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+ '/_authenticated/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
+ '/_authenticated/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
+ '/_authenticated/system/users': typeof AuthenticatedSystemUsersRoute
+ '/clerk/(auth)/sign-in': typeof ClerkauthSignInRoute
+ '/clerk/(auth)/sign-up': typeof ClerkauthSignUpRoute
+ '/clerk/_authenticated/user-management': typeof ClerkAuthenticatedUserManagementRoute
+ '/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
+ '/_authenticated/chats/': typeof AuthenticatedChatsIndexRoute
+ '/_authenticated/help-center/': typeof AuthenticatedHelpCenterIndexRoute
+ '/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
+ '/_authenticated/tasks/': typeof AuthenticatedTasksIndexRoute
+ '/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/clerk'
+ | '/settings'
+ | '/forgot-password'
+ | '/otp'
+ | '/sign-in'
+ | '/sign-in-2'
+ | '/sign-up'
+ | '/401'
+ | '/403'
+ | '/404'
+ | '/500'
+ | '/503'
+ | '/course-query'
+ | '/order-logs'
+ | '/orders'
+ | '/self-owned-order'
+ | '/tickets'
+ | '/catalog/categories'
+ | '/catalog/products'
+ | '/errors/$error'
+ | '/settings/account'
+ | '/settings/appearance'
+ | '/settings/display'
+ | '/settings/notifications'
+ | '/system/audit-logs'
+ | '/system/third-party'
+ | '/system/users'
+ | '/clerk/sign-in'
+ | '/clerk/sign-up'
+ | '/clerk/user-management'
+ | '/apps/'
+ | '/chats/'
+ | '/help-center/'
+ | '/settings/'
+ | '/tasks/'
+ | '/users/'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/clerk'
+ | '/forgot-password'
+ | '/otp'
+ | '/sign-in'
+ | '/sign-in-2'
+ | '/sign-up'
+ | '/401'
+ | '/403'
+ | '/404'
+ | '/500'
+ | '/503'
+ | '/course-query'
+ | '/order-logs'
+ | '/orders'
+ | '/self-owned-order'
+ | '/tickets'
+ | '/'
+ | '/catalog/categories'
+ | '/catalog/products'
+ | '/errors/$error'
+ | '/settings/account'
+ | '/settings/appearance'
+ | '/settings/display'
+ | '/settings/notifications'
+ | '/system/audit-logs'
+ | '/system/third-party'
+ | '/system/users'
+ | '/clerk/sign-in'
+ | '/clerk/sign-up'
+ | '/clerk/user-management'
+ | '/apps'
+ | '/chats'
+ | '/help-center'
+ | '/settings'
+ | '/tasks'
+ | '/users'
+ id:
+ | '__root__'
+ | '/_authenticated'
+ | '/clerk'
+ | '/_authenticated/settings'
+ | '/clerk/(auth)'
+ | '/clerk/_authenticated'
+ | '/(auth)/forgot-password'
+ | '/(auth)/otp'
+ | '/(auth)/sign-in'
+ | '/(auth)/sign-in-2'
+ | '/(auth)/sign-up'
+ | '/(errors)/401'
+ | '/(errors)/403'
+ | '/(errors)/404'
+ | '/(errors)/500'
+ | '/(errors)/503'
+ | '/_authenticated/course-query'
+ | '/_authenticated/order-logs'
+ | '/_authenticated/orders'
+ | '/_authenticated/self-owned-order'
+ | '/_authenticated/tickets'
+ | '/_authenticated/'
+ | '/_authenticated/catalog/categories'
+ | '/_authenticated/catalog/products'
+ | '/_authenticated/errors/$error'
+ | '/_authenticated/settings/account'
+ | '/_authenticated/settings/appearance'
+ | '/_authenticated/settings/display'
+ | '/_authenticated/settings/notifications'
+ | '/_authenticated/system/audit-logs'
+ | '/_authenticated/system/third-party'
+ | '/_authenticated/system/users'
+ | '/clerk/(auth)/sign-in'
+ | '/clerk/(auth)/sign-up'
+ | '/clerk/_authenticated/user-management'
+ | '/_authenticated/apps/'
+ | '/_authenticated/chats/'
+ | '/_authenticated/help-center/'
+ | '/_authenticated/settings/'
+ | '/_authenticated/tasks/'
+ | '/_authenticated/users/'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
+ ClerkRouteRoute: typeof ClerkRouteRouteWithChildren
+ authForgotPasswordRoute: typeof authForgotPasswordRoute
+ authOtpRoute: typeof authOtpRoute
+ authSignInRoute: typeof authSignInRoute
+ authSignIn2Route: typeof authSignIn2Route
+ authSignUpRoute: typeof authSignUpRoute
+ errors401Route: typeof errors401Route
+ errors403Route: typeof errors403Route
+ errors404Route: typeof errors404Route
+ errors500Route: typeof errors500Route
+ errors503Route: typeof errors503Route
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/clerk': {
+ id: '/clerk'
+ path: '/clerk'
+ fullPath: '/clerk'
+ preLoaderRoute: typeof ClerkRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/_authenticated': {
+ id: '/_authenticated'
+ path: ''
+ fullPath: '/'
+ preLoaderRoute: typeof AuthenticatedRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/_authenticated/': {
+ id: '/_authenticated/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof AuthenticatedIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/tickets': {
+ id: '/_authenticated/tickets'
+ path: '/tickets'
+ fullPath: '/tickets'
+ preLoaderRoute: typeof AuthenticatedTicketsRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/self-owned-order': {
+ id: '/_authenticated/self-owned-order'
+ path: '/self-owned-order'
+ fullPath: '/self-owned-order'
+ preLoaderRoute: typeof AuthenticatedSelfOwnedOrderRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/orders': {
+ id: '/_authenticated/orders'
+ path: '/orders'
+ fullPath: '/orders'
+ preLoaderRoute: typeof AuthenticatedOrdersRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/order-logs': {
+ id: '/_authenticated/order-logs'
+ path: '/order-logs'
+ fullPath: '/order-logs'
+ preLoaderRoute: typeof AuthenticatedOrderLogsRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/course-query': {
+ id: '/_authenticated/course-query'
+ path: '/course-query'
+ fullPath: '/course-query'
+ preLoaderRoute: typeof AuthenticatedCourseQueryRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/(errors)/503': {
+ id: '/(errors)/503'
+ path: '/503'
+ fullPath: '/503'
+ preLoaderRoute: typeof errors503RouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(errors)/500': {
+ id: '/(errors)/500'
+ path: '/500'
+ fullPath: '/500'
+ preLoaderRoute: typeof errors500RouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(errors)/404': {
+ id: '/(errors)/404'
+ path: '/404'
+ fullPath: '/404'
+ preLoaderRoute: typeof errors404RouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(errors)/403': {
+ id: '/(errors)/403'
+ path: '/403'
+ fullPath: '/403'
+ preLoaderRoute: typeof errors403RouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(errors)/401': {
+ id: '/(errors)/401'
+ path: '/401'
+ fullPath: '/401'
+ preLoaderRoute: typeof errors401RouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(auth)/sign-up': {
+ id: '/(auth)/sign-up'
+ path: '/sign-up'
+ fullPath: '/sign-up'
+ preLoaderRoute: typeof authSignUpRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(auth)/sign-in-2': {
+ id: '/(auth)/sign-in-2'
+ path: '/sign-in-2'
+ fullPath: '/sign-in-2'
+ preLoaderRoute: typeof authSignIn2RouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(auth)/sign-in': {
+ id: '/(auth)/sign-in'
+ path: '/sign-in'
+ fullPath: '/sign-in'
+ preLoaderRoute: typeof authSignInRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(auth)/otp': {
+ id: '/(auth)/otp'
+ path: '/otp'
+ fullPath: '/otp'
+ preLoaderRoute: typeof authOtpRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/(auth)/forgot-password': {
+ id: '/(auth)/forgot-password'
+ path: '/forgot-password'
+ fullPath: '/forgot-password'
+ preLoaderRoute: typeof authForgotPasswordRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/clerk/_authenticated': {
+ id: '/clerk/_authenticated'
+ path: ''
+ fullPath: '/clerk'
+ preLoaderRoute: typeof ClerkAuthenticatedRouteRouteImport
+ parentRoute: typeof ClerkRouteRoute
+ }
+ '/clerk/(auth)': {
+ id: '/clerk/(auth)'
+ path: ''
+ fullPath: '/clerk'
+ preLoaderRoute: typeof ClerkauthRouteRouteImport
+ parentRoute: typeof ClerkRouteRoute
+ }
+ '/_authenticated/settings': {
+ id: '/_authenticated/settings'
+ path: '/settings'
+ fullPath: '/settings'
+ preLoaderRoute: typeof AuthenticatedSettingsRouteRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/users/': {
+ id: '/_authenticated/users/'
+ path: '/users'
+ fullPath: '/users/'
+ preLoaderRoute: typeof AuthenticatedUsersIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/tasks/': {
+ id: '/_authenticated/tasks/'
+ path: '/tasks'
+ fullPath: '/tasks/'
+ preLoaderRoute: typeof AuthenticatedTasksIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/settings/': {
+ id: '/_authenticated/settings/'
+ path: '/'
+ fullPath: '/settings/'
+ preLoaderRoute: typeof AuthenticatedSettingsIndexRouteImport
+ parentRoute: typeof AuthenticatedSettingsRouteRoute
+ }
+ '/_authenticated/help-center/': {
+ id: '/_authenticated/help-center/'
+ path: '/help-center'
+ fullPath: '/help-center/'
+ preLoaderRoute: typeof AuthenticatedHelpCenterIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/chats/': {
+ id: '/_authenticated/chats/'
+ path: '/chats'
+ fullPath: '/chats/'
+ preLoaderRoute: typeof AuthenticatedChatsIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/apps/': {
+ id: '/_authenticated/apps/'
+ path: '/apps'
+ fullPath: '/apps/'
+ preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/clerk/_authenticated/user-management': {
+ id: '/clerk/_authenticated/user-management'
+ path: '/user-management'
+ fullPath: '/clerk/user-management'
+ preLoaderRoute: typeof ClerkAuthenticatedUserManagementRouteImport
+ parentRoute: typeof ClerkAuthenticatedRouteRoute
+ }
+ '/clerk/(auth)/sign-up': {
+ id: '/clerk/(auth)/sign-up'
+ path: '/sign-up'
+ fullPath: '/clerk/sign-up'
+ preLoaderRoute: typeof ClerkauthSignUpRouteImport
+ parentRoute: typeof ClerkauthRouteRoute
+ }
+ '/clerk/(auth)/sign-in': {
+ id: '/clerk/(auth)/sign-in'
+ path: '/sign-in'
+ fullPath: '/clerk/sign-in'
+ preLoaderRoute: typeof ClerkauthSignInRouteImport
+ parentRoute: typeof ClerkauthRouteRoute
+ }
+ '/_authenticated/system/users': {
+ id: '/_authenticated/system/users'
+ path: '/system/users'
+ fullPath: '/system/users'
+ preLoaderRoute: typeof AuthenticatedSystemUsersRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/system/third-party': {
+ id: '/_authenticated/system/third-party'
+ path: '/system/third-party'
+ fullPath: '/system/third-party'
+ preLoaderRoute: typeof AuthenticatedSystemThirdPartyRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/system/audit-logs': {
+ id: '/_authenticated/system/audit-logs'
+ path: '/system/audit-logs'
+ fullPath: '/system/audit-logs'
+ preLoaderRoute: typeof AuthenticatedSystemAuditLogsRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/settings/notifications': {
+ id: '/_authenticated/settings/notifications'
+ path: '/notifications'
+ fullPath: '/settings/notifications'
+ preLoaderRoute: typeof AuthenticatedSettingsNotificationsRouteImport
+ parentRoute: typeof AuthenticatedSettingsRouteRoute
+ }
+ '/_authenticated/settings/display': {
+ id: '/_authenticated/settings/display'
+ path: '/display'
+ fullPath: '/settings/display'
+ preLoaderRoute: typeof AuthenticatedSettingsDisplayRouteImport
+ parentRoute: typeof AuthenticatedSettingsRouteRoute
+ }
+ '/_authenticated/settings/appearance': {
+ id: '/_authenticated/settings/appearance'
+ path: '/appearance'
+ fullPath: '/settings/appearance'
+ preLoaderRoute: typeof AuthenticatedSettingsAppearanceRouteImport
+ parentRoute: typeof AuthenticatedSettingsRouteRoute
+ }
+ '/_authenticated/settings/account': {
+ id: '/_authenticated/settings/account'
+ path: '/account'
+ fullPath: '/settings/account'
+ preLoaderRoute: typeof AuthenticatedSettingsAccountRouteImport
+ parentRoute: typeof AuthenticatedSettingsRouteRoute
+ }
+ '/_authenticated/errors/$error': {
+ id: '/_authenticated/errors/$error'
+ path: '/errors/$error'
+ fullPath: '/errors/$error'
+ preLoaderRoute: typeof AuthenticatedErrorsErrorRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/catalog/products': {
+ id: '/_authenticated/catalog/products'
+ path: '/catalog/products'
+ fullPath: '/catalog/products'
+ preLoaderRoute: typeof AuthenticatedCatalogProductsRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ '/_authenticated/catalog/categories': {
+ id: '/_authenticated/catalog/categories'
+ path: '/catalog/categories'
+ fullPath: '/catalog/categories'
+ preLoaderRoute: typeof AuthenticatedCatalogCategoriesRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
+ }
+}
+
+interface AuthenticatedSettingsRouteRouteChildren {
+ AuthenticatedSettingsAccountRoute: typeof AuthenticatedSettingsAccountRoute
+ AuthenticatedSettingsAppearanceRoute: typeof AuthenticatedSettingsAppearanceRoute
+ AuthenticatedSettingsDisplayRoute: typeof AuthenticatedSettingsDisplayRoute
+ AuthenticatedSettingsNotificationsRoute: typeof AuthenticatedSettingsNotificationsRoute
+ AuthenticatedSettingsIndexRoute: typeof AuthenticatedSettingsIndexRoute
+}
+
+const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteChildren =
+ {
+ AuthenticatedSettingsAccountRoute: AuthenticatedSettingsAccountRoute,
+ AuthenticatedSettingsAppearanceRoute: AuthenticatedSettingsAppearanceRoute,
+ AuthenticatedSettingsDisplayRoute: AuthenticatedSettingsDisplayRoute,
+ AuthenticatedSettingsNotificationsRoute:
+ AuthenticatedSettingsNotificationsRoute,
+ AuthenticatedSettingsIndexRoute: AuthenticatedSettingsIndexRoute,
+ }
+
+const AuthenticatedSettingsRouteRouteWithChildren =
+ AuthenticatedSettingsRouteRoute._addFileChildren(
+ AuthenticatedSettingsRouteRouteChildren,
+ )
+
+interface AuthenticatedRouteRouteChildren {
+ AuthenticatedSettingsRouteRoute: typeof AuthenticatedSettingsRouteRouteWithChildren
+ AuthenticatedCourseQueryRoute: typeof AuthenticatedCourseQueryRoute
+ AuthenticatedOrderLogsRoute: typeof AuthenticatedOrderLogsRoute
+ AuthenticatedOrdersRoute: typeof AuthenticatedOrdersRoute
+ AuthenticatedSelfOwnedOrderRoute: typeof AuthenticatedSelfOwnedOrderRoute
+ AuthenticatedTicketsRoute: typeof AuthenticatedTicketsRoute
+ AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
+ AuthenticatedCatalogCategoriesRoute: typeof AuthenticatedCatalogCategoriesRoute
+ AuthenticatedCatalogProductsRoute: typeof AuthenticatedCatalogProductsRoute
+ AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute
+ AuthenticatedSystemAuditLogsRoute: typeof AuthenticatedSystemAuditLogsRoute
+ AuthenticatedSystemThirdPartyRoute: typeof AuthenticatedSystemThirdPartyRoute
+ AuthenticatedSystemUsersRoute: typeof AuthenticatedSystemUsersRoute
+ AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
+ AuthenticatedChatsIndexRoute: typeof AuthenticatedChatsIndexRoute
+ AuthenticatedHelpCenterIndexRoute: typeof AuthenticatedHelpCenterIndexRoute
+ AuthenticatedTasksIndexRoute: typeof AuthenticatedTasksIndexRoute
+ AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
+}
+
+const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
+ AuthenticatedSettingsRouteRoute: AuthenticatedSettingsRouteRouteWithChildren,
+ AuthenticatedCourseQueryRoute: AuthenticatedCourseQueryRoute,
+ AuthenticatedOrderLogsRoute: AuthenticatedOrderLogsRoute,
+ AuthenticatedOrdersRoute: AuthenticatedOrdersRoute,
+ AuthenticatedSelfOwnedOrderRoute: AuthenticatedSelfOwnedOrderRoute,
+ AuthenticatedTicketsRoute: AuthenticatedTicketsRoute,
+ AuthenticatedIndexRoute: AuthenticatedIndexRoute,
+ AuthenticatedCatalogCategoriesRoute: AuthenticatedCatalogCategoriesRoute,
+ AuthenticatedCatalogProductsRoute: AuthenticatedCatalogProductsRoute,
+ AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute,
+ AuthenticatedSystemAuditLogsRoute: AuthenticatedSystemAuditLogsRoute,
+ AuthenticatedSystemThirdPartyRoute: AuthenticatedSystemThirdPartyRoute,
+ AuthenticatedSystemUsersRoute: AuthenticatedSystemUsersRoute,
+ AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
+ AuthenticatedChatsIndexRoute: AuthenticatedChatsIndexRoute,
+ AuthenticatedHelpCenterIndexRoute: AuthenticatedHelpCenterIndexRoute,
+ AuthenticatedTasksIndexRoute: AuthenticatedTasksIndexRoute,
+ AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
+}
+
+const AuthenticatedRouteRouteWithChildren =
+ AuthenticatedRouteRoute._addFileChildren(AuthenticatedRouteRouteChildren)
+
+interface ClerkauthRouteRouteChildren {
+ ClerkauthSignInRoute: typeof ClerkauthSignInRoute
+ ClerkauthSignUpRoute: typeof ClerkauthSignUpRoute
+}
+
+const ClerkauthRouteRouteChildren: ClerkauthRouteRouteChildren = {
+ ClerkauthSignInRoute: ClerkauthSignInRoute,
+ ClerkauthSignUpRoute: ClerkauthSignUpRoute,
+}
+
+const ClerkauthRouteRouteWithChildren = ClerkauthRouteRoute._addFileChildren(
+ ClerkauthRouteRouteChildren,
+)
+
+interface ClerkAuthenticatedRouteRouteChildren {
+ ClerkAuthenticatedUserManagementRoute: typeof ClerkAuthenticatedUserManagementRoute
+}
+
+const ClerkAuthenticatedRouteRouteChildren: ClerkAuthenticatedRouteRouteChildren =
+ {
+ ClerkAuthenticatedUserManagementRoute:
+ ClerkAuthenticatedUserManagementRoute,
+ }
+
+const ClerkAuthenticatedRouteRouteWithChildren =
+ ClerkAuthenticatedRouteRoute._addFileChildren(
+ ClerkAuthenticatedRouteRouteChildren,
+ )
+
+interface ClerkRouteRouteChildren {
+ ClerkauthRouteRoute: typeof ClerkauthRouteRouteWithChildren
+ ClerkAuthenticatedRouteRoute: typeof ClerkAuthenticatedRouteRouteWithChildren
+}
+
+const ClerkRouteRouteChildren: ClerkRouteRouteChildren = {
+ ClerkauthRouteRoute: ClerkauthRouteRouteWithChildren,
+ ClerkAuthenticatedRouteRoute: ClerkAuthenticatedRouteRouteWithChildren,
+}
+
+const ClerkRouteRouteWithChildren = ClerkRouteRoute._addFileChildren(
+ ClerkRouteRouteChildren,
+)
+
+const rootRouteChildren: RootRouteChildren = {
+ AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
+ ClerkRouteRoute: ClerkRouteRouteWithChildren,
+ authForgotPasswordRoute: authForgotPasswordRoute,
+ authOtpRoute: authOtpRoute,
+ authSignInRoute: authSignInRoute,
+ authSignIn2Route: authSignIn2Route,
+ authSignUpRoute: authSignUpRoute,
+ errors401Route: errors401Route,
+ errors403Route: errors403Route,
+ errors404Route: errors404Route,
+ errors500Route: errors500Route,
+ errors503Route: errors503Route,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
diff --git a/packages/frontend/README.md b/packages/frontend/README.md
new file mode 100644
index 0000000..82b6da5
--- /dev/null
+++ b/packages/frontend/README.md
@@ -0,0 +1,119 @@
+# Shadcn Admin Dashboard
+
+Admin Dashboard UI crafted with Shadcn and Vite. Built with responsiveness and accessibility in mind.
+
+
+
+[](https://go.clerk.com/GttUAaK)
+
+I've been creating dashboard UIs at work and for my personal projects. I always wanted to make a reusable collection of dashboard UI for future projects; and here it is now. While I've created a few custom components, some of the code is directly adapted from ShadcnUI examples.
+
+> This is not a starter project (template) though. I'll probably make one in the future.
+
+## Features
+
+- Light/dark mode
+- Responsive
+- Accessible
+- With built-in Sidebar component
+- Global search command
+- 10+ pages
+- Extra custom components
+- RTL support
+
+
+Customized Components (click to expand)
+
+This project uses Shadcn UI components, but some have been slightly modified for better RTL (Right-to-Left) support and other improvements. These customized components differ from the original Shadcn UI versions.
+
+If you want to update components using the Shadcn CLI (e.g., `npx shadcn@latest add `), it's generally safe for non-customized components. For the listed customized ones, you may need to manually merge changes to preserve the project's modifications and avoid overwriting RTL support or other updates.
+
+> If you don't require RTL support, you can safely update the 'RTL Updated Components' via the Shadcn CLI, as these changes are primarily for RTL compatibility. The 'Modified Components' may have other customizations to consider.
+
+### Modified Components
+
+- scroll-area
+- sonner
+- separator
+
+### RTL Updated Components
+
+- alert-dialog
+- calendar
+- command
+- dialog
+- dropdown-menu
+- select
+- table
+- sheet
+- sidebar
+- switch
+
+**Notes:**
+
+- **Modified Components**: These have general updates, potentially including RTL adjustments.
+- **RTL Updated Components**: These have specific changes for RTL language support (e.g., layout, positioning).
+- For implementation details, check the source files in `src/components/ui/`.
+- All other Shadcn UI components in the project are standard and can be safely updated via the CLI.
+
+
+
+## Tech Stack
+
+**UI:** [ShadcnUI](https://ui.shadcn.com) (TailwindCSS + RadixUI)
+
+**Build Tool:** [Vite](https://vitejs.dev/)
+
+**Routing:** [TanStack Router](https://tanstack.com/router/latest)
+
+**Type Checking:** [TypeScript](https://www.typescriptlang.org/)
+
+**Linting/Formatting:** [ESLint](https://eslint.org/) & [Prettier](https://prettier.io/)
+
+**Icons:** [Lucide Icons](https://lucide.dev/icons/), [Tabler Icons](https://tabler.io/icons) (Brand icons only)
+
+**Auth (partial):** [Clerk](https://go.clerk.com/GttUAaK)
+
+## Run Locally
+
+Clone the project
+
+```bash
+ git clone https://github.com/satnaing/shadcn-admin.git
+```
+
+Go to the project directory
+
+```bash
+ cd shadcn-admin
+```
+
+Install dependencies
+
+```bash
+ pnpm install
+```
+
+Start the server
+
+```bash
+ pnpm run dev
+```
+
+## Sponsoring this project ❤️
+
+If you find this project helpful or use this in your own work, consider [sponsoring me](https://github.com/sponsors/satnaing) to support development and maintenance. You can [buy me a coffee](https://buymeacoffee.com/satnaing) as well. Don’t worry, every penny helps. Thank you! 🙏
+
+For questions or sponsorship inquiries, feel free to reach out at [satnaingdev@gmail.com](mailto:satnaingdev@gmail.com).
+
+### Current Sponsor
+
+- [Clerk](https://go.clerk.com/GttUAaK) - authentication and user management for the modern web
+
+## Author
+
+Crafted with 🤍 by [@satnaing](https://github.com/satnaing)
+
+## License
+
+Licensed under the [MIT License](https://choosealicense.com/licenses/mit/)
diff --git a/packages/frontend/components.json b/packages/frontend/components.json
new file mode 100644
index 0000000..0793c55
--- /dev/null
+++ b/packages/frontend/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/styles/index.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/packages/frontend/index.html b/packages/frontend/index.html
new file mode 100644
index 0000000..f569fc3
--- /dev/null
+++ b/packages/frontend/index.html
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+ Shadcn Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/knip.config.ts b/packages/frontend/knip.config.ts
new file mode 100644
index 0000000..04ee1ec
--- /dev/null
+++ b/packages/frontend/knip.config.ts
@@ -0,0 +1,11 @@
+import type { KnipConfig } from 'knip'
+
+const config: KnipConfig = {
+ ignore: [
+ 'src/components/ui/**',
+ 'src/components/layout/app-title.tsx',
+ 'src/tanstack-table.d.ts',
+ ],
+}
+
+export default config
\ No newline at end of file
diff --git a/packages/frontend/netlify.toml b/packages/frontend/netlify.toml
new file mode 100644
index 0000000..ff1c050
--- /dev/null
+++ b/packages/frontend/netlify.toml
@@ -0,0 +1,4 @@
+[[redirects]]
+ from = "/*"
+ to = "/index.html"
+ status = 200
\ No newline at end of file
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
new file mode 100644
index 0000000..6d56553
--- /dev/null
+++ b/packages/frontend/package.json
@@ -0,0 +1,95 @@
+{
+ "name": "@study-work/frontend",
+ "private": false,
+ "version": "2.2.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview",
+ "format:check": "prettier --check .",
+ "format": "prettier --write .",
+ "knip": "knip",
+ "test": "vitest run --browser.headless",
+ "test:watch": "vitest --browser.headless",
+ "test:ui": "vitest --ui --browser.headless",
+ "test:browser": "vitest",
+ "test:coverage": "vitest run --coverage --browser.headless",
+ "test:browser:install": "playwright install chromium --with-deps"
+ },
+ "dependencies": {
+ "@clerk/react": "^6.4.2",
+ "@hookform/resolvers": "^5.2.2",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
+ "@radix-ui/react-avatar": "^1.1.11",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-direction": "^1.1.1",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-radio-group": "^1.3.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tailwindcss/vite": "^4.2.2",
+ "@tanstack/react-query": "^5.99.0",
+ "@tanstack/react-router": "^1.168.22",
+ "@tanstack/react-table": "^8.21.3",
+ "axios": "^1.15.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "1.1.1",
+ "date-fns": "^4.1.0",
+ "input-otp": "^1.4.2",
+ "lucide-react": "^1.8.0",
+ "react": "^19.2.5",
+ "react-day-picker": "9.14.0",
+ "react-dom": "^19.2.5",
+ "react-hook-form": "^7.72.1",
+ "react-top-loading-bar": "^3.0.2",
+ "recharts": "^3.8.1",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.5.0",
+ "tailwindcss": "^4.2.2",
+ "tw-animate-css": "^1.4.0",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.12"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@faker-js/faker": "^10.4.0",
+ "@tanstack/eslint-plugin-query": "^5.99.0",
+ "@tanstack/react-query-devtools": "^5.99.0",
+ "@tanstack/react-router-devtools": "^1.166.13",
+ "@tanstack/router-plugin": "^1.167.22",
+ "@trivago/prettier-plugin-sort-imports": "^6.0.2",
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "@vitest/browser-playwright": "^4.1.4",
+ "@vitest/coverage-v8": "^4.1.4",
+ "@vitest/ui": "^4.1.4",
+ "eslint": "^10.2.1",
+ "eslint-plugin-react-hooks": "7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.5.0",
+ "knip": "^6.4.1",
+ "playwright": "1.59.1",
+ "prettier": "^3.8.3",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "typescript": "~6.0.3",
+ "typescript-eslint": "^8.58.2",
+ "vite": "^8.0.8",
+ "vitest": "^4.1.4",
+ "vitest-browser-react": "^2.2.0"
+ }
+}
diff --git a/packages/frontend/public/images/favicon.png b/packages/frontend/public/images/favicon.png
new file mode 100644
index 0000000..b34bb5c
Binary files /dev/null and b/packages/frontend/public/images/favicon.png differ
diff --git a/packages/frontend/public/images/favicon.svg b/packages/frontend/public/images/favicon.svg
new file mode 100644
index 0000000..37d40ff
--- /dev/null
+++ b/packages/frontend/public/images/favicon.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/packages/frontend/public/images/favicon_light.png b/packages/frontend/public/images/favicon_light.png
new file mode 100644
index 0000000..6d11aaa
Binary files /dev/null and b/packages/frontend/public/images/favicon_light.png differ
diff --git a/packages/frontend/public/images/favicon_light.svg b/packages/frontend/public/images/favicon_light.svg
new file mode 100644
index 0000000..85241c6
--- /dev/null
+++ b/packages/frontend/public/images/favicon_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/frontend/public/images/shadcn-admin.png b/packages/frontend/public/images/shadcn-admin.png
new file mode 100644
index 0000000..ab9cef2
Binary files /dev/null and b/packages/frontend/public/images/shadcn-admin.png differ
diff --git a/packages/frontend/src/assets/brand-icons/icon-discord.tsx b/packages/frontend/src/assets/brand-icons/icon-discord.tsx
new file mode 100644
index 0000000..2e67460
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-discord.tsx
@@ -0,0 +1,28 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconDiscord({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Discord
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-docker.tsx b/packages/frontend/src/assets/brand-icons/icon-docker.tsx
new file mode 100644
index 0000000..176ae3f
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-docker.tsx
@@ -0,0 +1,33 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconDocker({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Docker
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-facebook.tsx b/packages/frontend/src/assets/brand-icons/icon-facebook.tsx
new file mode 100644
index 0000000..edb1c47
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-facebook.tsx
@@ -0,0 +1,25 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconFacebook({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Facebook
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-figma.tsx b/packages/frontend/src/assets/brand-icons/icon-figma.tsx
new file mode 100644
index 0000000..9e73cd3
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-figma.tsx
@@ -0,0 +1,27 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconFigma({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Figma
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-github.tsx b/packages/frontend/src/assets/brand-icons/icon-github.tsx
new file mode 100644
index 0000000..b478aa8
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-github.tsx
@@ -0,0 +1,25 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconGithub({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ GitHub
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-gitlab.tsx b/packages/frontend/src/assets/brand-icons/icon-gitlab.tsx
new file mode 100644
index 0000000..6d5aa2f
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-gitlab.tsx
@@ -0,0 +1,25 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconGitlab({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ GitLab
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-gmail.tsx b/packages/frontend/src/assets/brand-icons/icon-gmail.tsx
new file mode 100644
index 0000000..e9e2f3a
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-gmail.tsx
@@ -0,0 +1,28 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconGmail({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Gmail
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-medium.tsx b/packages/frontend/src/assets/brand-icons/icon-medium.tsx
new file mode 100644
index 0000000..815223e
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-medium.tsx
@@ -0,0 +1,30 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconMedium({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Medium
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-notion.tsx b/packages/frontend/src/assets/brand-icons/icon-notion.tsx
new file mode 100644
index 0000000..a6867bb
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-notion.tsx
@@ -0,0 +1,28 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconNotion({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Notion
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-skype.tsx b/packages/frontend/src/assets/brand-icons/icon-skype.tsx
new file mode 100644
index 0000000..272180e
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-skype.tsx
@@ -0,0 +1,26 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconSkype({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Skype
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-slack.tsx b/packages/frontend/src/assets/brand-icons/icon-slack.tsx
new file mode 100644
index 0000000..022b5fe
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-slack.tsx
@@ -0,0 +1,28 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconSlack({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Slack
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-stripe.tsx b/packages/frontend/src/assets/brand-icons/icon-stripe.tsx
new file mode 100644
index 0000000..8e009ef
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-stripe.tsx
@@ -0,0 +1,25 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconStripe({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Stripe
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-telegram.tsx b/packages/frontend/src/assets/brand-icons/icon-telegram.tsx
new file mode 100644
index 0000000..1143fc7
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-telegram.tsx
@@ -0,0 +1,25 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconTelegram({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Telegram
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-trello.tsx b/packages/frontend/src/assets/brand-icons/icon-trello.tsx
new file mode 100644
index 0000000..8bcefa6
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-trello.tsx
@@ -0,0 +1,27 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconTrello({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Trello
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-whatsapp.tsx b/packages/frontend/src/assets/brand-icons/icon-whatsapp.tsx
new file mode 100644
index 0000000..32ac7d2
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-whatsapp.tsx
@@ -0,0 +1,26 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconWhatsapp({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ WhatsApp
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/icon-zoom.tsx b/packages/frontend/src/assets/brand-icons/icon-zoom.tsx
new file mode 100644
index 0000000..0116466
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/icon-zoom.tsx
@@ -0,0 +1,26 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconZoom({ className, ...props }: SVGProps) {
+ return (
+ path]:stroke-current', className)}
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ {...props}
+ >
+ Zoom
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/brand-icons/index.ts b/packages/frontend/src/assets/brand-icons/index.ts
new file mode 100644
index 0000000..530491c
--- /dev/null
+++ b/packages/frontend/src/assets/brand-icons/index.ts
@@ -0,0 +1,16 @@
+export { IconDiscord } from './icon-discord'
+export { IconDocker } from './icon-docker'
+export { IconFacebook } from './icon-facebook'
+export { IconFigma } from './icon-figma'
+export { IconGithub } from './icon-github'
+export { IconGitlab } from './icon-gitlab'
+export { IconGmail } from './icon-gmail'
+export { IconMedium } from './icon-medium'
+export { IconNotion } from './icon-notion'
+export { IconSkype } from './icon-skype'
+export { IconSlack } from './icon-slack'
+export { IconStripe } from './icon-stripe'
+export { IconTelegram } from './icon-telegram'
+export { IconTrello } from './icon-trello'
+export { IconWhatsapp } from './icon-whatsapp'
+export { IconZoom } from './icon-zoom'
diff --git a/packages/frontend/src/assets/clerk-full-logo.tsx b/packages/frontend/src/assets/clerk-full-logo.tsx
new file mode 100644
index 0000000..9635f9e
--- /dev/null
+++ b/packages/frontend/src/assets/clerk-full-logo.tsx
@@ -0,0 +1,41 @@
+import { type SVGProps } from 'react'
+
+export function ClerkFullLogo(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/clerk-logo.tsx b/packages/frontend/src/assets/clerk-logo.tsx
new file mode 100644
index 0000000..efae313
--- /dev/null
+++ b/packages/frontend/src/assets/clerk-logo.tsx
@@ -0,0 +1,23 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function ClerkLogo({ className, ...props }: SVGProps) {
+ return (
+ path]:fill-foreground', className)}
+ {...props}
+ >
+ Clerk
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-dir.tsx b/packages/frontend/src/assets/custom/icon-dir.tsx
new file mode 100644
index 0000000..af4ada1
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-dir.tsx
@@ -0,0 +1,110 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+import { type Direction } from '@/context/direction-provider'
+
+type IconDirProps = SVGProps & {
+ dir: Direction
+}
+
+export function IconDir({ dir, className, ...props }: IconDirProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-layout-compact.tsx b/packages/frontend/src/assets/custom/icon-layout-compact.tsx
new file mode 100644
index 0000000..5bcaa7b
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-layout-compact.tsx
@@ -0,0 +1,131 @@
+import { type SVGProps } from 'react'
+
+export function IconLayoutCompact(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-layout-default.tsx b/packages/frontend/src/assets/custom/icon-layout-default.tsx
new file mode 100644
index 0000000..57722c6
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-layout-default.tsx
@@ -0,0 +1,124 @@
+import { type SVGProps } from 'react'
+
+export function IconLayoutDefault(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-layout-full.tsx b/packages/frontend/src/assets/custom/icon-layout-full.tsx
new file mode 100644
index 0000000..cdb5313
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-layout-full.tsx
@@ -0,0 +1,100 @@
+import { type SVGProps } from 'react'
+
+export function IconLayoutFull(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-sidebar-floating.tsx b/packages/frontend/src/assets/custom/icon-sidebar-floating.tsx
new file mode 100644
index 0000000..2bfe4a6
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-sidebar-floating.tsx
@@ -0,0 +1,82 @@
+import { type SVGProps } from 'react'
+
+export function IconSidebarFloating(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-sidebar-inset.tsx b/packages/frontend/src/assets/custom/icon-sidebar-inset.tsx
new file mode 100644
index 0000000..695b7b7
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-sidebar-inset.tsx
@@ -0,0 +1,58 @@
+import { type SVGProps } from 'react'
+
+export function IconSidebarInset(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-sidebar-sidebar.tsx b/packages/frontend/src/assets/custom/icon-sidebar-sidebar.tsx
new file mode 100644
index 0000000..b049d7c
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-sidebar-sidebar.tsx
@@ -0,0 +1,53 @@
+import { type SVGProps } from 'react'
+
+export function IconSidebarSidebar(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-theme-dark.tsx b/packages/frontend/src/assets/custom/icon-theme-dark.tsx
new file mode 100644
index 0000000..b9ea2eb
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-theme-dark.tsx
@@ -0,0 +1,79 @@
+import { type SVGProps } from 'react'
+
+export function IconThemeDark(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-theme-light.tsx b/packages/frontend/src/assets/custom/icon-theme-light.tsx
new file mode 100644
index 0000000..7e9c50d
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-theme-light.tsx
@@ -0,0 +1,78 @@
+import { type SVGProps } from 'react'
+
+export function IconThemeLight(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/custom/icon-theme-system.tsx b/packages/frontend/src/assets/custom/icon-theme-system.tsx
new file mode 100644
index 0000000..6c678ba
--- /dev/null
+++ b/packages/frontend/src/assets/custom/icon-theme-system.tsx
@@ -0,0 +1,116 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function IconThemeSystem({
+ className,
+ ...props
+}: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/assets/logo.tsx b/packages/frontend/src/assets/logo.tsx
new file mode 100644
index 0000000..7571f44
--- /dev/null
+++ b/packages/frontend/src/assets/logo.tsx
@@ -0,0 +1,24 @@
+import { type SVGProps } from 'react'
+import { cn } from '@/lib/utils'
+
+export function Logo({ className, ...props }: SVGProps) {
+ return (
+
+ Shadcn-Admin
+
+
+ )
+}
diff --git a/packages/frontend/src/components/coming-soon.tsx b/packages/frontend/src/components/coming-soon.tsx
new file mode 100644
index 0000000..1fcbb0f
--- /dev/null
+++ b/packages/frontend/src/components/coming-soon.tsx
@@ -0,0 +1,16 @@
+import { Telescope } from 'lucide-react'
+
+export function ComingSoon() {
+ return (
+
+
+
+
功能开发中
+
+ 当前模块尚未接入业务接口。
+ 后续会按商品、订单、工单和支付阶段逐步开放。
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/command-menu.tsx b/packages/frontend/src/components/command-menu.tsx
new file mode 100644
index 0000000..223b04d
--- /dev/null
+++ b/packages/frontend/src/components/command-menu.tsx
@@ -0,0 +1,91 @@
+import React from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
+import { useSearch } from '@/context/search-provider'
+import { useTheme } from '@/context/theme-provider'
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@/components/ui/command'
+import { sidebarData } from './layout/data/sidebar-data'
+import { ScrollArea } from './ui/scroll-area'
+
+export function CommandMenu() {
+ const navigate = useNavigate()
+ const { setTheme } = useTheme()
+ const { open, setOpen } = useSearch()
+
+ const runCommand = React.useCallback(
+ (command: () => unknown) => {
+ setOpen(false)
+ command()
+ },
+ [setOpen]
+ )
+
+ return (
+
+
+
+
+ No results found.
+ {sidebarData.navGroups.map((group) => (
+
+ {group.items.map((navItem, i) => {
+ if (navItem.url)
+ return (
+ {
+ runCommand(() => navigate({ to: navItem.url }))
+ }}
+ >
+
+ {navItem.title}
+
+ )
+
+ return navItem.items?.map((subItem, i) => (
+ {
+ runCommand(() => navigate({ to: subItem.url }))
+ }}
+ >
+
+ {navItem.title} {subItem.title}
+
+ ))
+ })}
+
+ ))}
+
+
+ runCommand(() => setTheme('light'))}>
+ Light
+
+ runCommand(() => setTheme('dark'))}>
+
+ Dark
+
+ runCommand(() => setTheme('system'))}>
+
+ System
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/config-drawer.test.tsx b/packages/frontend/src/components/config-drawer.test.tsx
new file mode 100644
index 0000000..9ce7262
--- /dev/null
+++ b/packages/frontend/src/components/config-drawer.test.tsx
@@ -0,0 +1,319 @@
+import { clearCookies } from '@/test-utils/cookies'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, type RenderResult } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+import { getCookie, setCookie } from '@/lib/cookies'
+import { DirectionProvider } from '@/context/direction-provider'
+import { LayoutProvider } from '@/context/layout-provider'
+import { ThemeProvider } from '@/context/theme-provider'
+import { SidebarProvider } from '@/components/ui/sidebar'
+import { ConfigDrawer } from './config-drawer'
+
+async function renderConfigDrawer({
+ sidebarDefaultOpen = true,
+}: {
+ sidebarDefaultOpen?: boolean
+} = {}) {
+ return await render(
+
+
+
+
+
+
+
+
+
+ )
+}
+
+async function openDrawer(screen: RenderResult) {
+ await userEvent.click(
+ screen.getByRole('button', { name: '打开界面设置' })
+ )
+ await expect
+ .element(screen.getByText('界面设置'))
+ .toBeInTheDocument()
+}
+
+describe('ConfigDrawer (integration)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ clearCookies()
+
+ document.documentElement.classList.remove('light', 'dark')
+ document.documentElement.removeAttribute('dir')
+ })
+
+ it('opens the drawer and renders the sections', async () => {
+ const screen = await renderConfigDrawer()
+
+ await openDrawer(screen)
+
+ const drawer = screen.getByRole('dialog', { name: '界面设置' })
+
+ await expect.element(drawer).toBeInTheDocument()
+
+ await expect.element(drawer.getByText('主题')).toBeInTheDocument()
+ await expect.element(drawer.getByText('布局')).toBeInTheDocument()
+ await expect
+ .element(drawer.getByText('侧边栏').first())
+ .toBeInTheDocument()
+ await expect.element(drawer.getByText('文字方向')).toBeInTheDocument()
+ await expect
+ .element(
+ screen.getByRole('button', {
+ name: '恢复默认界面设置',
+ })
+ )
+ .toBeInTheDocument()
+ })
+
+ describe('theme preference', () => {
+ it('applies light theme to and cookie', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择浅色' })
+ )
+ await vi.waitFor(() =>
+ expect(document.documentElement.classList.contains('light')).toBe(true)
+ )
+ expect(getCookie('vite-ui-theme')).toBe('light')
+ })
+
+ it('applies dark theme to and cookie', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+ await userEvent.click(screen.getByRole('radio', { name: '选择深色' }))
+ await vi.waitFor(() =>
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+ )
+ expect(getCookie('vite-ui-theme')).toBe('dark')
+ })
+
+ it('applies system theme: stores cookie and applies a resolved light or dark class', async () => {
+ // Pre-seed light so mounted theme is not system; re-selecting System alone would not fire setTheme.
+ setCookie('vite-ui-theme', 'light')
+
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择跟随系统' })
+ )
+ await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('system'))
+ await vi.waitFor(() => {
+ const root = document.documentElement
+ const hasLight = root.classList.contains('light')
+ const hasDark = root.classList.contains('dark')
+ expect(hasLight !== hasDark).toBe(true)
+ })
+ })
+ })
+
+ describe('sidebar variant', () => {
+ it('selecting floating updates layout_variant cookie', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择浮动' })
+ )
+ await vi.waitFor(() =>
+ expect(getCookie('layout_variant')).toBe('floating')
+ )
+ })
+
+ it('selecting sidebar updates layout_variant cookie', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择标准' })
+ )
+ await vi.waitFor(() =>
+ expect(getCookie('layout_variant')).toBe('sidebar')
+ )
+ })
+
+ it('selecting inset updates layout_variant cookie after another variant', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择浮动' })
+ )
+ await vi.waitFor(() =>
+ expect(getCookie('layout_variant')).toBe('floating')
+ )
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择内嵌' })
+ )
+ await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('inset'))
+ })
+ })
+
+ it('selecting full layout sets collapsible to offcanvas and closes sidebar', async () => {
+ const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择全宽' })
+ )
+ await vi.waitFor(() =>
+ expect(getCookie('layout_collapsible')).toBe('offcanvas')
+ )
+ await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('false'))
+ })
+
+ describe('section reset buttons', () => {
+ it('resets theme via section control after choosing dark', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+
+ await userEvent.click(screen.getByRole('radio', { name: '选择深色' }))
+ await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('dark'))
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: '恢复默认主题',
+ })
+ )
+ await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('system'))
+ })
+
+ it('resets direction via section control after choosing RTL', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择从右到左' })
+ )
+ await vi.waitFor(() =>
+ expect(document.documentElement.getAttribute('dir')).toBe('rtl')
+ )
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: '恢复默认文字方向',
+ })
+ )
+ await vi.waitFor(() =>
+ expect(document.documentElement.getAttribute('dir')).toBe('ltr')
+ )
+ expect(getCookie('dir')).toBe('ltr')
+ })
+
+ it('resets sidebar style via section control after choosing floating', async () => {
+ const screen = await renderConfigDrawer()
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择浮动' })
+ )
+ await vi.waitFor(() =>
+ expect(getCookie('layout_variant')).toBe('floating')
+ )
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: '恢复默认侧边栏样式',
+ })
+ )
+ await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('inset'))
+ })
+
+ it('resets layout via section control after choosing compact', async () => {
+ const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择紧凑' })
+ )
+ await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('false'))
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: '恢复默认布局',
+ })
+ )
+ await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('true'))
+ await vi.waitFor(() =>
+ expect(getCookie('layout_collapsible')).toBe('icon')
+ )
+ })
+ })
+
+ it('changes direction and applies it to ', async () => {
+ const screen = await renderConfigDrawer()
+
+ await openDrawer(screen)
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择从右到左' })
+ )
+ await vi.waitFor(() =>
+ expect(document.documentElement.getAttribute('dir')).toBe('rtl')
+ )
+ expect(getCookie('dir')).toBe('rtl')
+ })
+
+ it('updates layout: selecting non-default closes sidebar and changes layout cookie', async () => {
+ const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
+
+ await openDrawer(screen)
+
+ await expect
+ .element(screen.getByRole('radio', { name: '选择默认' }))
+ .toHaveAttribute('data-state', 'checked')
+
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择紧凑' })
+ )
+
+ await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('false'))
+ await vi.waitFor(() => expect(getCookie('layout_collapsible')).toBe('icon'))
+ })
+
+ it('reset restores defaults across sidebar/theme/layout/direction', async () => {
+ const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
+
+ await openDrawer(screen)
+
+ await userEvent.click(screen.getByRole('radio', { name: '选择深色' }))
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择从右到左' })
+ )
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择浮动' })
+ )
+ await userEvent.click(
+ screen.getByRole('radio', { name: '选择全宽' })
+ )
+
+ await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('dark'))
+ await vi.waitFor(() => expect(getCookie('dir')).toBe('rtl'))
+ await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('floating'))
+ await vi.waitFor(() =>
+ expect(getCookie('layout_collapsible')).toBe('offcanvas')
+ )
+
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: '恢复默认界面设置',
+ })
+ )
+
+ await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('true'))
+ await vi.waitFor(() => expect(getCookie('dir')).toBeUndefined())
+ await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBeUndefined())
+ await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('inset'))
+ await vi.waitFor(() => expect(getCookie('layout_collapsible')).toBe('icon'))
+ await vi.waitFor(() =>
+ expect(document.documentElement.getAttribute('dir')).toBe('ltr')
+ )
+ })
+})
diff --git a/packages/frontend/src/components/config-drawer.tsx b/packages/frontend/src/components/config-drawer.tsx
new file mode 100644
index 0000000..b2b0605
--- /dev/null
+++ b/packages/frontend/src/components/config-drawer.tsx
@@ -0,0 +1,362 @@
+import { type SVGProps } from 'react'
+import { Root as Radio, Item } from '@radix-ui/react-radio-group'
+import { CircleCheck, RotateCcw, Settings } from 'lucide-react'
+import { IconDir } from '@/assets/custom/icon-dir'
+import { IconLayoutCompact } from '@/assets/custom/icon-layout-compact'
+import { IconLayoutDefault } from '@/assets/custom/icon-layout-default'
+import { IconLayoutFull } from '@/assets/custom/icon-layout-full'
+import { IconSidebarFloating } from '@/assets/custom/icon-sidebar-floating'
+import { IconSidebarInset } from '@/assets/custom/icon-sidebar-inset'
+import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
+import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
+import { IconThemeLight } from '@/assets/custom/icon-theme-light'
+import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
+import { cn } from '@/lib/utils'
+import { useDirection } from '@/context/direction-provider'
+import { type Collapsible, useLayout } from '@/context/layout-provider'
+import { useTheme } from '@/context/theme-provider'
+import { Button } from '@/components/ui/button'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from '@/components/ui/sheet'
+import { useSidebar } from './ui/sidebar'
+
+export function ConfigDrawer() {
+ const { setOpen } = useSidebar()
+ const { resetDir } = useDirection()
+ const { resetTheme } = useTheme()
+ const { resetLayout } = useLayout()
+
+ const handleReset = () => {
+ setOpen(true)
+ resetDir()
+ resetTheme()
+ resetLayout()
+ }
+
+ return (
+
+
+
+
+
+
+ 界面设置
+
+ 调整后台界面的主题、侧边栏和布局偏好。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function SectionTitle({
+ title,
+ showReset = false,
+ onReset,
+ resetAriaLabel,
+ className,
+}: {
+ title: string
+ showReset?: boolean
+ onReset?: () => void
+ /** Shown on the small per-section reset (RotateCcw) for accessibility and tests. */
+ resetAriaLabel?: string
+ className?: string
+}) {
+ return (
+
+ {title}
+ {showReset && onReset && (
+
+ )}
+
+ )
+}
+
+function RadioGroupItem({
+ item,
+ isTheme = false,
+}: {
+ item: {
+ value: string
+ label: string
+ icon: (props: SVGProps) => React.ReactElement
+ }
+ isTheme?: boolean
+}) {
+ return (
+ -
+
+
+
+
+
+ {item.label}
+
+
+ )
+}
+
+function ThemeConfig() {
+ const { defaultTheme, theme, setTheme } = useTheme()
+ return (
+
+
setTheme(defaultTheme)}
+ resetAriaLabel='恢复默认主题'
+ />
+
+ {[
+ {
+ value: 'system',
+ label: '跟随系统',
+ icon: IconThemeSystem,
+ },
+ {
+ value: 'light',
+ label: '浅色',
+ icon: IconThemeLight,
+ },
+ {
+ value: 'dark',
+ label: '深色',
+ icon: IconThemeDark,
+ },
+ ].map((item) => (
+
+ ))}
+
+
+ 在跟随系统、浅色和深色模式之间选择
+
+
+ )
+}
+
+function SidebarConfig() {
+ const { defaultVariant, variant, setVariant } = useLayout()
+ return (
+
+ setVariant(defaultVariant)}
+ resetAriaLabel='恢复默认侧边栏样式'
+ />
+
+ {[
+ {
+ value: 'inset',
+ label: '内嵌',
+ icon: IconSidebarInset,
+ },
+ {
+ value: 'floating',
+ label: '浮动',
+ icon: IconSidebarFloating,
+ },
+ {
+ value: 'sidebar',
+ label: '标准',
+ icon: IconSidebarSidebar,
+ },
+ ].map((item) => (
+
+ ))}
+
+
+
+ )
+}
+
+function LayoutConfig() {
+ const { open, setOpen } = useSidebar()
+ const { defaultCollapsible, collapsible, setCollapsible } = useLayout()
+
+ const radioState = open ? 'default' : collapsible
+
+ return (
+
+
{
+ setOpen(true)
+ setCollapsible(defaultCollapsible)
+ }}
+ resetAriaLabel='恢复默认布局'
+ />
+ {
+ if (v === 'default') {
+ setOpen(true)
+ return
+ }
+ setOpen(false)
+ setCollapsible(v as Collapsible)
+ }}
+ className='grid w-full max-w-md grid-cols-3 gap-4'
+ aria-label='选择布局'
+ aria-describedby='layout-description'
+ >
+ {[
+ {
+ value: 'default',
+ label: '默认',
+ icon: IconLayoutDefault,
+ },
+ {
+ value: 'icon',
+ label: '紧凑',
+ icon: IconLayoutCompact,
+ },
+ {
+ value: 'offcanvas',
+ label: '全宽',
+ icon: IconLayoutFull,
+ },
+ ].map((item) => (
+
+ ))}
+
+
+ 在默认展开、紧凑图标和全宽布局之间选择
+
+
+ )
+}
+
+function DirConfig() {
+ const { defaultDir, dir, setDir } = useDirection()
+ return (
+
+
setDir(defaultDir)}
+ resetAriaLabel='恢复默认文字方向'
+ />
+
+ {[
+ {
+ value: 'ltr',
+ label: '从左到右',
+ icon: (props: SVGProps) => (
+
+ ),
+ },
+ {
+ value: 'rtl',
+ label: '从右到左',
+ icon: (props: SVGProps) => (
+
+ ),
+ },
+ ].map((item) => (
+
+ ))}
+
+
+ 在从左到右和从右到左之间选择
+
+
+ )
+}
diff --git a/packages/frontend/src/components/confirm-dialog.test.tsx b/packages/frontend/src/components/confirm-dialog.test.tsx
new file mode 100644
index 0000000..8a1c90c
--- /dev/null
+++ b/packages/frontend/src/components/confirm-dialog.test.tsx
@@ -0,0 +1,218 @@
+import type { SubmitEvent } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+import { ConfirmDialog } from './confirm-dialog'
+
+describe('ConfirmDialog', () => {
+ it('renders title, description, and default buttons', async () => {
+ const { getByRole, getByText } = await render(
+
+ )
+
+ await expect
+ .element(getByRole('heading', { name: 'Delete item' }))
+ .toBeInTheDocument()
+ await expect
+ .element(getByText('This action cannot be undone.'))
+ .toBeInTheDocument()
+ await expect
+ .element(getByRole('button', { name: 'Cancel' }))
+ .toBeInTheDocument()
+ await expect
+ .element(getByRole('button', { name: 'Continue' }))
+ .toBeInTheDocument()
+ })
+
+ it('calls handleConfirm when the confirm button is clicked', async () => {
+ const handleConfirm = vi.fn()
+ const { getByRole } = await render(
+
+ )
+
+ await userEvent.click(getByRole('button', { name: 'Sign out' }))
+ expect(handleConfirm).toHaveBeenCalledOnce()
+ })
+
+ it('disables confirm when disabled is true', async () => {
+ const handleConfirm = vi.fn()
+ const { getByRole } = await render(
+
+ )
+
+ const confirm = getByRole('button', { name: 'Continue' })
+ await expect.element(confirm).toBeDisabled()
+ expect(handleConfirm).not.toHaveBeenCalled()
+ })
+
+ it('when isLoading is true, disables cancel and confirm', async () => {
+ const handleConfirm = vi.fn()
+ const { getByRole } = await render(
+
+ )
+
+ await expect.element(getByRole('button', { name: 'Cancel' })).toBeDisabled()
+ await expect
+ .element(getByRole('button', { name: 'Continue' }))
+ .toBeDisabled()
+ })
+
+ it('supports custom button texts', async () => {
+ const { getByRole } = await render(
+
+ )
+
+ await expect
+ .element(getByRole('button', { name: 'No' }))
+ .toBeInTheDocument()
+ await expect
+ .element(getByRole('button', { name: 'Yes' }))
+ .toBeInTheDocument()
+ })
+
+ it('renders confirm as submit button linked to desc form when `form` is set', async () => {
+ const { getByRole } = await render(
+
+ Type DELETE to confirm.
+
+ }
+ confirmText='Delete'
+ destructive
+ />
+ )
+
+ const deleteBtn = getByRole('button', { name: 'Delete' })
+ await expect.element(deleteBtn).toHaveAttribute('type', 'submit')
+ await expect
+ .element(deleteBtn)
+ .toHaveAttribute('form', 'tasks-multi-delete-form')
+ })
+
+ it('submits the desc form when confirm is clicked (form prop, no handleConfirm)', async () => {
+ const handleFormSubmit = vi.fn((e: SubmitEvent) => {
+ e.preventDefault()
+ })
+
+ const { getByRole } = await render(
+
+ Confirm deletion.
+
+ }
+ confirmText='Delete'
+ destructive
+ />
+ )
+
+ await userEvent.click(getByRole('button', { name: 'Delete' }))
+
+ expect(handleFormSubmit).toHaveBeenCalledOnce()
+ })
+
+ it('submits the form when Enter key is pressed', async () => {
+ const handleFormSubmit = vi.fn((e: SubmitEvent) => {
+ e.preventDefault()
+ })
+
+ const { getByPlaceholder } = await render(
+
+
+
+ }
+ confirmText='Delete'
+ destructive
+ />
+ )
+
+ await userEvent.fill(getByPlaceholder('username'), 'test')
+ await userEvent.keyboard('{Enter}')
+ expect(handleFormSubmit).toHaveBeenCalledOnce()
+ })
+
+ it('does not submit the form when confirm is disabled (typed confirmation mismatch)', async () => {
+ const handleFormSubmit = vi.fn((e: SubmitEvent) => {
+ e.preventDefault()
+ })
+
+ const { getByRole } = await render(
+
+ Enter username to enable Delete.
+
+ }
+ confirmText='Delete'
+ destructive
+ />
+ )
+
+ const deleteBtn = getByRole('button', { name: 'Delete' })
+ await expect.element(deleteBtn).toBeDisabled()
+ expect(handleFormSubmit).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/frontend/src/components/confirm-dialog.tsx b/packages/frontend/src/components/confirm-dialog.tsx
new file mode 100644
index 0000000..aed089c
--- /dev/null
+++ b/packages/frontend/src/components/confirm-dialog.tsx
@@ -0,0 +1,72 @@
+import { cn } from '@/lib/utils'
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+
+type ConfirmDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ title: React.ReactNode
+ disabled?: boolean
+ desc: React.JSX.Element | string
+ cancelBtnText?: string
+ confirmText?: React.ReactNode
+ destructive?: boolean
+ isLoading?: boolean
+ className?: string
+ children?: React.ReactNode
+} & (
+ | { form: string; handleConfirm?: undefined }
+ | { form?: undefined; handleConfirm: () => void }
+)
+
+export function ConfirmDialog(props: ConfirmDialogProps) {
+ const {
+ title,
+ desc,
+ children,
+ className,
+ confirmText,
+ cancelBtnText,
+ destructive,
+ isLoading,
+ disabled = false,
+ form,
+ handleConfirm,
+ ...actions
+ } = props
+ return (
+
+
+
+ {title}
+
+ {desc}
+
+
+ {children}
+
+
+ {cancelBtnText ?? 'Cancel'}
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/data-table/bulk-actions.tsx b/packages/frontend/src/components/data-table/bulk-actions.tsx
new file mode 100644
index 0000000..ebdeb05
--- /dev/null
+++ b/packages/frontend/src/components/data-table/bulk-actions.tsx
@@ -0,0 +1,213 @@
+import { useState, useEffect, useRef } from 'react'
+import { type Table } from '@tanstack/react-table'
+import { X } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Separator } from '@/components/ui/separator'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+
+type DataTableBulkActionsProps = {
+ table: Table
+ entityName: string
+ children: React.ReactNode
+}
+
+/**
+ * A modular toolbar for displaying bulk actions when table rows are selected.
+ *
+ * @template TData The type of data in the table.
+ * @param {object} props The component props.
+ * @param {Table} props.table The react-table instance.
+ * @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
+ * @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
+ * @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
+ */
+export function DataTableBulkActions({
+ table,
+ entityName,
+ children,
+}: DataTableBulkActionsProps): React.ReactNode | null {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedCount = selectedRows.length
+ const toolbarRef = useRef(null)
+ const [announcement, setAnnouncement] = useState('')
+
+ // Announce selection changes to screen readers
+ useEffect(() => {
+ if (selectedCount > 0) {
+ const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
+
+ // Use queueMicrotask to defer state update and avoid cascading renders
+ queueMicrotask(() => {
+ setAnnouncement(message)
+ })
+
+ // Clear announcement after a delay
+ const timer = setTimeout(() => setAnnouncement(''), 3000)
+ return () => clearTimeout(timer)
+ }
+ }, [selectedCount, entityName])
+
+ const handleClearSelection = () => {
+ table.resetRowSelection()
+ }
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ const buttons = toolbarRef.current?.querySelectorAll('button')
+ if (!buttons) return
+
+ const currentIndex = Array.from(buttons).findIndex(
+ (button) => button === document.activeElement
+ )
+
+ switch (event.key) {
+ case 'ArrowRight': {
+ event.preventDefault()
+ const nextIndex = (currentIndex + 1) % buttons.length
+ buttons[nextIndex]?.focus()
+ break
+ }
+ case 'ArrowLeft': {
+ event.preventDefault()
+ const prevIndex =
+ currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
+ buttons[prevIndex]?.focus()
+ break
+ }
+ case 'Home':
+ event.preventDefault()
+ buttons[0]?.focus()
+ break
+ case 'End':
+ event.preventDefault()
+ buttons[buttons.length - 1]?.focus()
+ break
+ case 'Escape': {
+ // Check if the Escape key came from a dropdown trigger or content
+ // We can't check dropdown state because Radix UI closes it before our handler runs
+ const target = event.target as HTMLElement
+ const activeElement = document.activeElement as HTMLElement
+
+ // Check if the event target or currently focused element is a dropdown trigger
+ const isFromDropdownTrigger =
+ target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
+ activeElement?.getAttribute('data-slot') ===
+ 'dropdown-menu-trigger' ||
+ target?.closest('[data-slot="dropdown-menu-trigger"]') ||
+ activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
+
+ // Check if the focused element is inside dropdown content (which is portaled)
+ const isFromDropdownContent =
+ activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
+ target?.closest('[data-slot="dropdown-menu-content"]')
+
+ if (isFromDropdownTrigger || isFromDropdownContent) {
+ // Escape was meant for the dropdown - don't clear selection
+ return
+ }
+
+ // Escape was meant for the toolbar - clear selection
+ event.preventDefault()
+ handleClearSelection()
+ break
+ }
+ }
+ }
+
+ if (selectedCount === 0) {
+ return null
+ }
+
+ return (
+ <>
+ {/* Live region for screen reader announcements */}
+
+ {announcement}
+
+
+ 1 ? 's' : ''}`}
+ aria-describedby='bulk-actions-description'
+ tabIndex={-1}
+ onKeyDown={handleKeyDown}
+ className={cn(
+ 'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
+ 'transition-all delay-100 duration-300 ease-out hover:scale-105',
+ 'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none'
+ )}
+ >
+
+
+
+
+
+
+ Clear selection (Escape)
+
+
+
+
+
+
+
+ {selectedCount}
+ {' '}
+
+ {entityName}
+ {selectedCount > 1 ? 's' : ''}
+ {' '}
+ selected
+
+
+
+
+ {children}
+
+
+ >
+ )
+}
diff --git a/packages/frontend/src/components/data-table/column-header.tsx b/packages/frontend/src/components/data-table/column-header.tsx
new file mode 100644
index 0000000..180037a
--- /dev/null
+++ b/packages/frontend/src/components/data-table/column-header.tsx
@@ -0,0 +1,74 @@
+import {
+ ArrowDownIcon,
+ ArrowUpIcon,
+ CaretSortIcon,
+ EyeNoneIcon,
+} from '@radix-ui/react-icons'
+import { type Column } from '@tanstack/react-table'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+
+type DataTableColumnHeaderProps =
+ React.HTMLAttributes & {
+ column: Column
+ title: string
+ }
+
+export function DataTableColumnHeader({
+ column,
+ title,
+ className,
+}: DataTableColumnHeaderProps) {
+ if (!column.getCanSort()) {
+ return {title}
+ }
+
+ return (
+
+
+
+
+
+
+ column.toggleSorting(false)}>
+
+ Asc
+
+ column.toggleSorting(true)}>
+
+ Desc
+
+ {column.getCanHide() && (
+ <>
+
+ column.toggleVisibility(false)}>
+
+ Hide
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/data-table/faceted-filter.tsx b/packages/frontend/src/components/data-table/faceted-filter.tsx
new file mode 100644
index 0000000..6923a11
--- /dev/null
+++ b/packages/frontend/src/components/data-table/faceted-filter.tsx
@@ -0,0 +1,146 @@
+import * as React from 'react'
+import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
+import { type Column } from '@tanstack/react-table'
+import { cn } from '@/lib/utils'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Separator } from '@/components/ui/separator'
+
+type DataTableFacetedFilterProps = {
+ column?: Column
+ title?: string
+ options: {
+ label: string
+ value: string
+ icon?: React.ComponentType<{ className?: string }>
+ }[]
+}
+
+export function DataTableFacetedFilter({
+ column,
+ title,
+ options,
+}: DataTableFacetedFilterProps) {
+ const facets = column?.getFacetedUniqueValues()
+ const selectedValues = new Set(column?.getFilterValue() as string[])
+
+ return (
+
+
+
+
+
+
+
+
+ No results found.
+
+ {options.map((option) => {
+ const isSelected = selectedValues.has(option.value)
+ return (
+ {
+ if (isSelected) {
+ selectedValues.delete(option.value)
+ } else {
+ selectedValues.add(option.value)
+ }
+ const filterValues = Array.from(selectedValues)
+ column?.setFilterValue(
+ filterValues.length ? filterValues : undefined
+ )
+ }}
+ >
+
+
+
+ {option.icon && (
+
+ )}
+ {option.label}
+ {facets?.get(option.value) && (
+
+ {facets.get(option.value)}
+
+ )}
+
+ )
+ })}
+
+ {selectedValues.size > 0 && (
+ <>
+
+
+ column?.setFilterValue(undefined)}
+ className='justify-center text-center'
+ >
+ Clear filters
+
+
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/data-table/index.ts b/packages/frontend/src/components/data-table/index.ts
new file mode 100644
index 0000000..bb1245b
--- /dev/null
+++ b/packages/frontend/src/components/data-table/index.ts
@@ -0,0 +1,4 @@
+export { DataTablePagination } from './pagination'
+export { DataTableColumnHeader } from './column-header'
+export { DataTableToolbar } from './toolbar'
+export { DataTableBulkActions } from './bulk-actions'
diff --git a/packages/frontend/src/components/data-table/pagination.tsx b/packages/frontend/src/components/data-table/pagination.tsx
new file mode 100644
index 0000000..5e5991b
--- /dev/null
+++ b/packages/frontend/src/components/data-table/pagination.tsx
@@ -0,0 +1,130 @@
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ DoubleArrowLeftIcon,
+ DoubleArrowRightIcon,
+} from '@radix-ui/react-icons'
+import { type Table } from '@tanstack/react-table'
+import { cn, getPageNumbers } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+
+type DataTablePaginationProps = {
+ table: Table
+ className?: string
+}
+
+export function DataTablePagination({
+ table,
+ className,
+}: DataTablePaginationProps) {
+ const currentPage = table.getState().pagination.pageIndex + 1
+ const totalPages = table.getPageCount()
+ const pageNumbers = getPageNumbers(currentPage, totalPages)
+
+ return (
+
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+
Rows per page
+
+
+
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+
+
+ {/* Page number buttons */}
+ {pageNumbers.map((pageNumber, index) => (
+
+ {pageNumber === '...' ? (
+ ...
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/data-table/toolbar.tsx b/packages/frontend/src/components/data-table/toolbar.tsx
new file mode 100644
index 0000000..047ad32
--- /dev/null
+++ b/packages/frontend/src/components/data-table/toolbar.tsx
@@ -0,0 +1,85 @@
+import { Cross2Icon } from '@radix-ui/react-icons'
+import { type Table } from '@tanstack/react-table'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { DataTableFacetedFilter } from './faceted-filter'
+import { DataTableViewOptions } from './view-options'
+
+type DataTableToolbarProps = {
+ table: Table
+ searchPlaceholder?: string
+ searchKey?: string
+ filters?: {
+ columnId: string
+ title: string
+ options: {
+ label: string
+ value: string
+ icon?: React.ComponentType<{ className?: string }>
+ }[]
+ }[]
+}
+
+export function DataTableToolbar({
+ table,
+ searchPlaceholder = 'Filter...',
+ searchKey,
+ filters = [],
+}: DataTableToolbarProps) {
+ const isFiltered =
+ table.getState().columnFilters.length > 0 || table.getState().globalFilter
+
+ return (
+
+
+ {searchKey ? (
+
+ table.getColumn(searchKey)?.setFilterValue(event.target.value)
+ }
+ className='h-8 w-37.5 lg:w-62.5'
+ />
+ ) : (
+
table.setGlobalFilter(event.target.value)}
+ className='h-8 w-37.5 lg:w-62.5'
+ />
+ )}
+
+ {filters.map((filter) => {
+ const column = table.getColumn(filter.columnId)
+ if (!column) return null
+ return (
+
+ )
+ })}
+
+ {isFiltered && (
+
+ )}
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/data-table/view-options.tsx b/packages/frontend/src/components/data-table/view-options.tsx
new file mode 100644
index 0000000..269f11e
--- /dev/null
+++ b/packages/frontend/src/components/data-table/view-options.tsx
@@ -0,0 +1,56 @@
+import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
+import { MixerHorizontalIcon } from '@radix-ui/react-icons'
+import { type Table } from '@tanstack/react-table'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from '@/components/ui/dropdown-menu'
+
+type DataTableViewOptionsProps = {
+ table: Table
+}
+
+export function DataTableViewOptions({
+ table,
+}: DataTableViewOptionsProps) {
+ return (
+
+
+
+
+
+ Toggle columns
+
+ {table
+ .getAllColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== 'undefined' && column.getCanHide()
+ )
+ .map((column) => {
+ return (
+ column.toggleVisibility(!!value)}
+ >
+ {column.id}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/packages/frontend/src/components/date-picker.tsx b/packages/frontend/src/components/date-picker.tsx
new file mode 100644
index 0000000..18b7b5b
--- /dev/null
+++ b/packages/frontend/src/components/date-picker.tsx
@@ -0,0 +1,51 @@
+import { format } from 'date-fns'
+import { Calendar as CalendarIcon } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+
+type DatePickerProps = {
+ selected: Date | undefined
+ onSelect: (date: Date | undefined) => void
+ placeholder?: string
+}
+
+export function DatePicker({
+ selected,
+ onSelect,
+ placeholder = 'Pick a date',
+}: DatePickerProps) {
+ return (
+
+
+
+
+
+
+ date > new Date() || date < new Date('1900-01-01')
+ }
+ />
+
+
+ )
+}
diff --git a/packages/frontend/src/components/layout/app-sidebar.tsx b/packages/frontend/src/components/layout/app-sidebar.tsx
new file mode 100644
index 0000000..9f3f7b9
--- /dev/null
+++ b/packages/frontend/src/components/layout/app-sidebar.tsx
@@ -0,0 +1,80 @@
+import { useLayout } from '@/context/layout-provider'
+import { canAccess } from '@/lib/permissions'
+import { useAuthStore } from '@/stores/auth-store'
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarRail,
+} from '@/components/ui/sidebar'
+// import { AppTitle } from './app-title'
+import { sidebarData } from './data/sidebar-data'
+import { NavGroup } from './nav-group'
+import { NavUser } from './nav-user'
+import { TeamSwitcher } from './team-switcher'
+import { type NavGroup as NavGroupType, type NavItem } from './types'
+
+function filterNavItem(
+ item: NavItem,
+ permissions: Record | undefined
+): NavItem | null {
+ if (!canAccess(permissions, item.permission)) {
+ return null
+ }
+
+ if (!item.items) {
+ return item
+ }
+
+ const items = item.items.filter((subItem) =>
+ canAccess(permissions, subItem.permission)
+ )
+
+ if (!items.length) {
+ return null
+ }
+
+ return { ...item, items }
+}
+
+function filterNavGroups(
+ groups: NavGroupType[],
+ permissions: Record | undefined
+) {
+ return groups
+ .map((group) => ({
+ ...group,
+ items: group.items
+ .map((item) => filterNavItem(item, permissions))
+ .filter((item): item is NavItem => Boolean(item)),
+ }))
+ .filter((group) => group.items.length)
+}
+
+export function AppSidebar() {
+ const { collapsible, variant } = useLayout()
+ const permissions = useAuthStore((state) => state.auth.user?.permissions)
+ const navGroups = filterNavGroups(sidebarData.navGroups, permissions)
+
+ return (
+
+
+
+
+ {/* Replace with the following
+ /* if you want to use the normal app title instead of TeamSwitch dropdown */}
+ {/* */}
+
+
+ {navGroups.map((props) => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/layout/app-title.tsx b/packages/frontend/src/components/layout/app-title.tsx
new file mode 100644
index 0000000..112e463
--- /dev/null
+++ b/packages/frontend/src/components/layout/app-title.tsx
@@ -0,0 +1,64 @@
+import { Link } from '@tanstack/react-router'
+import { Menu, X } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from '@/components/ui/sidebar'
+import { Button } from '../ui/button'
+
+export function AppTitle() {
+ const { setOpenMobile } = useSidebar()
+ return (
+
+
+
+
+ setOpenMobile(false)}
+ className='grid flex-1 text-start text-sm leading-tight'
+ >
+ Shadcn-Admin
+ Vite + ShadcnUI
+
+
+
+
+
+
+ )
+}
+
+function ToggleSidebar({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
diff --git a/packages/frontend/src/components/layout/authenticated-layout.tsx b/packages/frontend/src/components/layout/authenticated-layout.tsx
new file mode 100644
index 0000000..003e6e2
--- /dev/null
+++ b/packages/frontend/src/components/layout/authenticated-layout.tsx
@@ -0,0 +1,42 @@
+import { Outlet } from '@tanstack/react-router'
+import { getCookie } from '@/lib/cookies'
+import { cn } from '@/lib/utils'
+import { LayoutProvider } from '@/context/layout-provider'
+import { SearchProvider } from '@/context/search-provider'
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
+import { AppSidebar } from '@/components/layout/app-sidebar'
+import { SkipToMain } from '@/components/skip-to-main'
+
+type AuthenticatedLayoutProps = {
+ children?: React.ReactNode
+}
+
+export function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
+ const defaultOpen = getCookie('sidebar_state') !== 'false'
+ return (
+
+
+
+
+
+
+ {children ?? }
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/layout/data/sidebar-data.ts b/packages/frontend/src/components/layout/data/sidebar-data.ts
new file mode 100644
index 0000000..bb9682a
--- /dev/null
+++ b/packages/frontend/src/components/layout/data/sidebar-data.ts
@@ -0,0 +1,161 @@
+import {
+ ClipboardList,
+ Cable,
+ Home,
+ LayoutGrid,
+ PackageSearch,
+ ReceiptText,
+ ScrollText,
+ Settings,
+ ShieldCheck,
+ TicketCheck,
+ Users,
+} from 'lucide-react'
+import { type SidebarData } from '../types'
+
+export const sidebarData: SidebarData = {
+ user: {
+ name: '管理员',
+ email: 'root@local.dev',
+ avatar: '',
+ },
+ teams: [
+ {
+ name: 'Work Admin',
+ logo: LayoutGrid,
+ plan: '商城后台',
+ },
+ ],
+ navGroups: [
+ {
+ title: '工作台',
+ items: [
+ {
+ title: '后台首页',
+ url: '/',
+ icon: Home,
+ },
+ ],
+ },
+ {
+ title: '商城业务',
+ items: [
+ {
+ title: '商品管理',
+ icon: PackageSearch,
+ items: [
+ {
+ title: '分类管理',
+ url: '/catalog/categories',
+ permission: {
+ resource: 'catalog/categories',
+ action: 'read',
+ },
+ },
+ {
+ title: '商品列表',
+ url: '/catalog/products',
+ permission: {
+ resource: 'catalog/products',
+ action: 'read',
+ },
+ },
+ ],
+ },
+ {
+ title: '订单管理',
+ icon: ReceiptText,
+ items: [
+ {
+ title: '订单列表',
+ url: '/orders',
+ permission: {
+ resource: 'orders',
+ action: 'read',
+ },
+ },
+ {
+ title: '查课下单',
+ url: '/course-query',
+ permission: {
+ resource: 'orders',
+ action: 'write',
+ },
+ },
+ {
+ title: '自营下单',
+ url: '/self-owned-order',
+ permission: {
+ resource: 'orders',
+ action: 'write',
+ },
+ },
+ ],
+ },
+ {
+ title: '工单管理',
+ url: '/tickets',
+ icon: TicketCheck,
+ },
+ {
+ title: '日志查询',
+ url: '/order-logs',
+ icon: ClipboardList,
+ permission: {
+ resource: 'order-logs',
+ action: 'read',
+ },
+ },
+ ],
+ },
+ {
+ title: '系统管理',
+ items: [
+ {
+ title: '用户与权限',
+ icon: ShieldCheck,
+ items: [
+ {
+ title: '用户管理',
+ url: '/system/users',
+ icon: Users,
+ permission: {
+ resource: 'admin/users',
+ action: 'read',
+ },
+ },
+ ],
+ },
+ {
+ title: '安全审计',
+ icon: ScrollText,
+ items: [
+ {
+ title: '第三方接口',
+ url: '/system/third-party',
+ icon: Cable,
+ permission: {
+ resource: 'admin/third-party',
+ action: 'read',
+ },
+ },
+ {
+ title: '操作日志',
+ url: '/system/audit-logs',
+ icon: ScrollText,
+ permission: {
+ resource: 'admin/audit-logs',
+ action: 'read',
+ },
+ },
+ ],
+ },
+ {
+ title: '账号安全',
+ url: '/settings/account',
+ icon: Settings,
+ },
+ ],
+ },
+ ],
+}
diff --git a/packages/frontend/src/components/layout/header.tsx b/packages/frontend/src/components/layout/header.tsx
new file mode 100644
index 0000000..4c07798
--- /dev/null
+++ b/packages/frontend/src/components/layout/header.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react'
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+import { SidebarTrigger } from '@/components/ui/sidebar'
+
+type HeaderProps = React.HTMLAttributes & {
+ fixed?: boolean
+ ref?: React.Ref
+}
+
+export function Header({ className, fixed, children, ...props }: HeaderProps) {
+ const [offset, setOffset] = useState(0)
+
+ useEffect(() => {
+ const onScroll = () => {
+ setOffset(document.body.scrollTop || document.documentElement.scrollTop)
+ }
+
+ // Add scroll listener to the body
+ document.addEventListener('scroll', onScroll, { passive: true })
+
+ // Clean up the event listener on unmount
+ return () => document.removeEventListener('scroll', onScroll)
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/packages/frontend/src/components/layout/main.tsx b/packages/frontend/src/components/layout/main.tsx
new file mode 100644
index 0000000..b3d6d0a
--- /dev/null
+++ b/packages/frontend/src/components/layout/main.tsx
@@ -0,0 +1,27 @@
+import { cn } from '@/lib/utils'
+
+type MainProps = React.HTMLAttributes & {
+ fixed?: boolean
+ fluid?: boolean
+ ref?: React.Ref
+}
+
+export function Main({ fixed, className, fluid, ...props }: MainProps) {
+ return (
+
+ )
+}
diff --git a/packages/frontend/src/components/layout/nav-group.tsx b/packages/frontend/src/components/layout/nav-group.tsx
new file mode 100644
index 0000000..3a0b73c
--- /dev/null
+++ b/packages/frontend/src/components/layout/nav-group.tsx
@@ -0,0 +1,185 @@
+import { type ReactNode } from 'react'
+import { Link, useLocation } from '@tanstack/react-router'
+import { ChevronRight } from 'lucide-react'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible'
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ useSidebar,
+} from '@/components/ui/sidebar'
+import { Badge } from '../ui/badge'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '../ui/dropdown-menu'
+import {
+ type NavCollapsible,
+ type NavItem,
+ type NavLink,
+ type NavGroup as NavGroupProps,
+} from './types'
+
+export function NavGroup({ title, items }: NavGroupProps) {
+ const { state, isMobile } = useSidebar()
+ const href = useLocation({ select: (location) => location.href })
+ return (
+
+ {title}
+
+ {items.map((item) => {
+ const key = `${item.title}-${item.url}`
+
+ if (!item.items)
+ return
+
+ if (state === 'collapsed' && !isMobile)
+ return (
+
+ )
+
+ return
+ })}
+
+
+ )
+}
+
+function NavBadge({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
+ const { setOpenMobile } = useSidebar()
+ return (
+
+
+ setOpenMobile(false)}>
+ {item.icon && }
+ {item.title}
+ {item.badge && {item.badge}}
+
+
+
+ )
+}
+
+function SidebarMenuCollapsible({
+ item,
+ href,
+}: {
+ item: NavCollapsible
+ href: string
+}) {
+ const { setOpenMobile } = useSidebar()
+ return (
+
+
+
+
+ {item.icon && }
+ {item.title}
+ {item.badge && {item.badge}}
+
+
+
+
+
+ {item.items.map((subItem) => (
+
+
+ setOpenMobile(false)}>
+ {subItem.icon && }
+ {subItem.title}
+ {subItem.badge && {subItem.badge}}
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+function SidebarMenuCollapsedDropdown({
+ item,
+ href,
+}: {
+ item: NavCollapsible
+ href: string
+}) {
+ return (
+
+
+
+
+ {item.icon && }
+ {item.title}
+ {item.badge && {item.badge}}
+
+
+
+
+
+ {item.title} {item.badge ? `(${item.badge})` : ''}
+
+
+ {item.items.map((sub) => (
+
+
+ {sub.icon && }
+ {sub.title}
+ {sub.badge && (
+ {sub.badge}
+ )}
+
+
+ ))}
+
+
+
+ )
+}
+
+function checkIsActive(href: string, item: NavItem, mainNav = false) {
+ return (
+ href === item.url || // /endpint?search=param
+ href.split('?')[0] === item.url || // endpoint
+ !!item?.items?.filter((i) => i.url === href).length || // if child nav is active
+ (mainNav &&
+ href.split('/')[1] !== '' &&
+ href.split('/')[1] === item?.url?.split('/')[1])
+ )
+}
diff --git a/packages/frontend/src/components/layout/nav-user.tsx b/packages/frontend/src/components/layout/nav-user.tsx
new file mode 100644
index 0000000..46877ca
--- /dev/null
+++ b/packages/frontend/src/components/layout/nav-user.tsx
@@ -0,0 +1,119 @@
+import { Link } from '@tanstack/react-router'
+import { BadgeCheck, ChevronsUpDown, LogOut, Users } from 'lucide-react'
+import useDialogState from '@/hooks/use-dialog-state'
+import { hasPermission } from '@/lib/permissions'
+import { useAuthStore } from '@/stores/auth-store'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from '@/components/ui/sidebar'
+import { SignOutDialog } from '@/components/sign-out-dialog'
+
+type NavUserProps = {
+ user: {
+ name: string
+ email: string
+ avatar: string
+ }
+}
+
+export function NavUser({ user }: NavUserProps) {
+ const { isMobile } = useSidebar()
+ const [open, setOpen] = useDialogState()
+ const authUser = useAuthStore((state) => state.auth.user)
+ const displayUser = {
+ name: authUser?.name || user.name,
+ email: authUser?.email || user.email,
+ avatar: authUser?.avatar || user.avatar,
+ roleName: authUser?.roleName || '管理员',
+ }
+ const fallback = displayUser.name.slice(0, 1).toUpperCase() || '管'
+ const canReadUsers = hasPermission(authUser?.permissions, 'admin/users')
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {fallback}
+
+
+ {displayUser.name}
+ {displayUser.email}
+
+
+
+
+
+
+
+
+
+ {fallback}
+
+
+
+ {displayUser.name}
+
+ {displayUser.email}
+
+
+
+
+
+
+
+
+ 账号安全
+
+
+ {canReadUsers && (
+
+
+
+ 用户管理
+
+
+ )}
+
+
+ setOpen(true)}
+ >
+
+ 退出登录
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/frontend/src/components/layout/team-switcher.tsx b/packages/frontend/src/components/layout/team-switcher.tsx
new file mode 100644
index 0000000..96b9969
--- /dev/null
+++ b/packages/frontend/src/components/layout/team-switcher.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react'
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from '@/components/ui/sidebar'
+
+type TeamSwitcherProps = {
+ teams: {
+ name: string
+ logo: React.ElementType
+ plan: string
+ }[]
+}
+
+export function TeamSwitcher({ teams }: TeamSwitcherProps) {
+ const activeTeam = teams[0]
+
+ return (
+
+
+
+
+
+ {activeTeam.name}
+ {activeTeam.plan}
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/layout/top-nav.tsx b/packages/frontend/src/components/layout/top-nav.tsx
new file mode 100644
index 0000000..9059fec
--- /dev/null
+++ b/packages/frontend/src/components/layout/top-nav.tsx
@@ -0,0 +1,70 @@
+import { Link } from '@tanstack/react-router'
+import { Menu } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+
+type TopNavProps = React.HTMLAttributes & {
+ links: {
+ title: string
+ href: string
+ isActive: boolean
+ disabled?: boolean
+ }[]
+}
+
+export function TopNav({ className, links, ...props }: TopNavProps) {
+ return (
+ <>
+
+
+
+
+
+ {links.map(({ title, href, isActive, disabled }) => (
+
+
+ {title}
+
+
+ ))}
+
+
+
+
+ >
+ )
+}
diff --git a/packages/frontend/src/components/layout/types.ts b/packages/frontend/src/components/layout/types.ts
new file mode 100644
index 0000000..412bcc3
--- /dev/null
+++ b/packages/frontend/src/components/layout/types.ts
@@ -0,0 +1,48 @@
+import { type LinkProps } from '@tanstack/react-router'
+
+type User = {
+ name: string
+ email: string
+ avatar: string
+}
+
+type Team = {
+ name: string
+ logo: React.ElementType
+ plan: string
+}
+
+type BaseNavItem = {
+ title: string
+ badge?: string
+ icon?: React.ElementType
+ permission?: {
+ resource: string
+ action?: string
+ }
+}
+
+type NavLink = BaseNavItem & {
+ url: LinkProps['to'] | (string & {})
+ items?: never
+}
+
+type NavCollapsible = BaseNavItem & {
+ items: (BaseNavItem & { url: LinkProps['to'] | (string & {}) })[]
+ url?: never
+}
+
+type NavItem = NavCollapsible | NavLink
+
+type NavGroup = {
+ title: string
+ items: NavItem[]
+}
+
+type SidebarData = {
+ user: User
+ teams: Team[]
+ navGroups: NavGroup[]
+}
+
+export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink }
diff --git a/packages/frontend/src/components/learn-more.tsx b/packages/frontend/src/components/learn-more.tsx
new file mode 100644
index 0000000..54e2785
--- /dev/null
+++ b/packages/frontend/src/components/learn-more.tsx
@@ -0,0 +1,44 @@
+import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
+import { CircleQuestionMark } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+
+type LearnMoreProps = React.ComponentProps & {
+ contentProps?: React.ComponentProps
+ triggerProps?: React.ComponentProps
+}
+
+export function LearnMore({
+ children,
+ contentProps,
+ triggerProps,
+ ...props
+}: LearnMoreProps) {
+ return (
+
+
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/packages/frontend/src/components/long-text.tsx b/packages/frontend/src/components/long-text.tsx
new file mode 100644
index 0000000..74bf47d
--- /dev/null
+++ b/packages/frontend/src/components/long-text.tsx
@@ -0,0 +1,84 @@
+import { useRef, useState } from 'react'
+import { cn } from '@/lib/utils'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+
+type LongTextProps = {
+ children: React.ReactNode
+ className?: string
+ contentClassName?: string
+}
+
+export function LongText({
+ children,
+ className = '',
+ contentClassName = '',
+}: LongTextProps) {
+ const ref = useRef(null)
+ const [isOverflown, setIsOverflown] = useState(false)
+
+ // Use ref callback to check overflow when element is mounted
+ const refCallback = (node: HTMLDivElement | null) => {
+ ref.current = node
+ if (node && checkOverflow(node)) {
+ queueMicrotask(() => setIsOverflown(true))
+ }
+ }
+
+ if (!isOverflown)
+ return (
+
+ {children}
+
+ )
+
+ return (
+ <>
+
+
+
+
+
+ {children}
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ {children}
+
+
+
+ >
+ )
+}
+
+const checkOverflow = (textContainer: HTMLDivElement | null) => {
+ if (textContainer) {
+ return (
+ textContainer.offsetHeight < textContainer.scrollHeight ||
+ textContainer.offsetWidth < textContainer.scrollWidth
+ )
+ }
+ return false
+}
diff --git a/packages/frontend/src/components/navigation-progress.tsx b/packages/frontend/src/components/navigation-progress.tsx
new file mode 100644
index 0000000..e233820
--- /dev/null
+++ b/packages/frontend/src/components/navigation-progress.tsx
@@ -0,0 +1,25 @@
+import { useEffect, useRef } from 'react'
+import { useRouterState } from '@tanstack/react-router'
+import LoadingBar, { type LoadingBarRef } from 'react-top-loading-bar'
+
+export function NavigationProgress() {
+ const ref = useRef(null)
+ const state = useRouterState()
+
+ useEffect(() => {
+ if (state.status === 'pending') {
+ ref.current?.continuousStart()
+ } else {
+ ref.current?.complete()
+ }
+ }, [state.status])
+
+ return (
+
+ )
+}
diff --git a/packages/frontend/src/components/password-input.test.tsx b/packages/frontend/src/components/password-input.test.tsx
new file mode 100644
index 0000000..b58b4de
--- /dev/null
+++ b/packages/frontend/src/components/password-input.test.tsx
@@ -0,0 +1,99 @@
+import { useForm } from 'react-hook-form'
+import { describe, expect, it } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+} from '@/components/ui/form'
+import { PasswordInput } from './password-input'
+
+describe('PasswordInput', () => {
+ it('renders the password input correctly', async () => {
+ const { getByPlaceholder, getByRole } = await render(
+
+ )
+
+ const passwordInput = getByPlaceholder('password')
+ const showPasswordButton = getByRole('button', { name: /show password/i })
+
+ await expect.element(passwordInput).toBeInTheDocument()
+ await expect.element(passwordInput).toHaveAttribute('type', 'password')
+ await expect.element(showPasswordButton).toBeVisible()
+ })
+
+ it('toggles the password visibility when the show password button is clicked', async () => {
+ const { getByPlaceholder, getByRole } = await render(
+
+ )
+
+ const passwordInput = getByPlaceholder('password')
+ const showPasswordButton = getByRole('button', { name: /show password/i })
+
+ await expect.element(passwordInput).toHaveAttribute('type', 'password')
+ await expect.element(showPasswordButton).toBeInTheDocument()
+
+ await userEvent.click(showPasswordButton)
+
+ await expect.element(passwordInput).toHaveAttribute('type', 'text')
+ const hidePasswordButton = getByRole('button', { name: /hide password/i })
+ await expect.element(hidePasswordButton).toBeInTheDocument()
+
+ await userEvent.click(hidePasswordButton)
+
+ await expect.element(passwordInput).toHaveAttribute('type', 'password')
+ await expect
+ .element(getByRole('button', { name: /show password/i }))
+ .toBeInTheDocument()
+ })
+
+ it('disables the show password button when the password input is disabled', async () => {
+ const { getByPlaceholder, getByRole } = await render(
+
+ )
+
+ const passwordInput = getByPlaceholder('password')
+ const showPasswordButton = getByRole('button', { name: /show password/i })
+ await expect.element(showPasswordButton).toBeDisabled()
+ await expect.element(passwordInput).toBeDisabled()
+ })
+
+ it('works with FormLabel and react-hook-form field spread', async () => {
+ function PasswordInLabeledForm() {
+ const form = useForm<{ password: string }>({
+ defaultValues: { password: '' },
+ })
+
+ return (
+
+
+ )
+ }
+
+ const { getByLabelText } = await render()
+
+ const password = getByLabelText(/^Password$/i)
+ await expect.element(password).toHaveAttribute('type', 'password')
+
+ await userEvent.type(password, 'secret-value')
+
+ await expect.element(password).toHaveValue('secret-value')
+ })
+})
diff --git a/packages/frontend/src/components/password-input.tsx b/packages/frontend/src/components/password-input.tsx
new file mode 100644
index 0000000..aa4dc58
--- /dev/null
+++ b/packages/frontend/src/components/password-input.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react'
+import { Eye, EyeOff } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Button } from './ui/button'
+
+type PasswordInputProps = Omit<
+ React.InputHTMLAttributes,
+ 'type'
+> & {
+ ref?: React.Ref
+}
+
+export function PasswordInput({
+ className,
+ disabled,
+ ref,
+ ...props
+}: PasswordInputProps) {
+ const [showPassword, setShowPassword] = React.useState(false)
+
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/profile-dropdown.tsx b/packages/frontend/src/components/profile-dropdown.tsx
new file mode 100644
index 0000000..a894680
--- /dev/null
+++ b/packages/frontend/src/components/profile-dropdown.tsx
@@ -0,0 +1,75 @@
+import { Link } from '@tanstack/react-router'
+import { BadgeCheck, LogOut, Users } from 'lucide-react'
+import useDialogState from '@/hooks/use-dialog-state'
+import { hasPermission } from '@/lib/permissions'
+import { useAuthStore } from '@/stores/auth-store'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { SignOutDialog } from '@/components/sign-out-dialog'
+
+export function ProfileDropdown() {
+ const [open, setOpen] = useDialogState()
+ const user = useAuthStore((state) => state.auth.user)
+ const name = user?.name || '管理员'
+ const email = user?.email || 'root@local.dev'
+ const fallback = name.slice(0, 1).toUpperCase() || '管'
+ const canReadUsers = hasPermission(user?.permissions, 'admin/users')
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 账号安全
+
+
+ {canReadUsers && (
+
+
+
+ 用户管理
+
+
+ )}
+
+
+ setOpen(true)}>
+
+ 退出登录
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/frontend/src/components/search.tsx b/packages/frontend/src/components/search.tsx
new file mode 100644
index 0000000..a152edb
--- /dev/null
+++ b/packages/frontend/src/components/search.tsx
@@ -0,0 +1,34 @@
+import { SearchIcon } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { useSearch } from '@/context/search-provider'
+import { Button } from './ui/button'
+
+export function Search({
+ className = '',
+ placeholder = 'Search',
+ ...props
+}: React.ComponentProps<'button'> & { placeholder?: string }) {
+ const { setOpen } = useSearch()
+ return (
+
+ )
+}
diff --git a/packages/frontend/src/components/select-dropdown.tsx b/packages/frontend/src/components/select-dropdown.tsx
new file mode 100644
index 0000000..2bba569
--- /dev/null
+++ b/packages/frontend/src/components/select-dropdown.tsx
@@ -0,0 +1,62 @@
+import { Loader } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { FormControl } from '@/components/ui/form'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+
+type SelectDropdownProps = {
+ onValueChange?: (value: string) => void
+ defaultValue: string | undefined
+ placeholder?: string
+ isPending?: boolean
+ items: { label: string; value: string }[] | undefined
+ disabled?: boolean
+ className?: string
+ isControlled?: boolean
+}
+
+export function SelectDropdown({
+ defaultValue,
+ onValueChange,
+ isPending,
+ items,
+ placeholder,
+ disabled,
+ className = '',
+ isControlled = false,
+}: SelectDropdownProps) {
+ const defaultState = isControlled
+ ? { value: defaultValue, onValueChange }
+ : { defaultValue, onValueChange }
+ return (
+
+ )
+}
diff --git a/packages/frontend/src/components/sign-out-dialog.test.tsx b/packages/frontend/src/components/sign-out-dialog.test.tsx
new file mode 100644
index 0000000..c541568
--- /dev/null
+++ b/packages/frontend/src/components/sign-out-dialog.test.tsx
@@ -0,0 +1,56 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+import { SignOutDialog } from './sign-out-dialog'
+
+const navigate = vi.fn()
+const reset = vi.fn()
+
+const MOCK_HREF = 'https://app.test/dashboard?tab=1'
+
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: () => ({
+ auth: { reset },
+ }),
+}))
+
+vi.mock('@tanstack/react-router', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useNavigate: () => navigate,
+ useLocation: () => ({ href: MOCK_HREF }),
+ }
+})
+
+describe('SignOutDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('calls auth.reset and navigates to sign-in with current location as redirect', async () => {
+ const { getByRole } = await render(
+
+ )
+
+ await userEvent.click(getByRole('button', { name: /^Sign out$/i }))
+
+ expect(reset).toHaveBeenCalledOnce()
+ expect(navigate).toHaveBeenCalledWith({
+ to: '/sign-in',
+ search: { redirect: MOCK_HREF },
+ replace: true,
+ })
+ })
+
+ it('does not call reset or navigate when Cancel is clicked', async () => {
+ const { getByRole } = await render(
+
+ )
+
+ await userEvent.click(getByRole('button', { name: /^Cancel$/i }))
+
+ expect(reset).not.toHaveBeenCalled()
+ expect(navigate).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/frontend/src/components/sign-out-dialog.tsx b/packages/frontend/src/components/sign-out-dialog.tsx
new file mode 100644
index 0000000..46370d0
--- /dev/null
+++ b/packages/frontend/src/components/sign-out-dialog.tsx
@@ -0,0 +1,39 @@
+import { useNavigate, useLocation } from '@tanstack/react-router'
+import { useAuthStore } from '@/stores/auth-store'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+interface SignOutDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const { auth } = useAuthStore()
+
+ const handleSignOut = () => {
+ auth.reset()
+ // Preserve current location for redirect after sign-in
+ const currentPath = location.href
+ navigate({
+ to: '/sign-in',
+ search: { redirect: currentPath },
+ replace: true,
+ })
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/frontend/src/components/skip-to-main.tsx b/packages/frontend/src/components/skip-to-main.tsx
new file mode 100644
index 0000000..7ab6440
--- /dev/null
+++ b/packages/frontend/src/components/skip-to-main.tsx
@@ -0,0 +1,10 @@
+export function SkipToMain() {
+ return (
+
+ Skip to Main
+
+ )
+}
diff --git a/packages/frontend/src/components/theme-switch.tsx b/packages/frontend/src/components/theme-switch.tsx
new file mode 100644
index 0000000..16137bc
--- /dev/null
+++ b/packages/frontend/src/components/theme-switch.tsx
@@ -0,0 +1,58 @@
+import { useEffect } from 'react'
+import { Check, Moon, Sun } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { useTheme } from '@/context/theme-provider'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+
+export function ThemeSwitch() {
+ const { theme, setTheme } = useTheme()
+
+ /* Update theme-color meta tag
+ * when theme is updated */
+ useEffect(() => {
+ const themeColor = theme === 'dark' ? '#020817' : '#fff'
+ const metaThemeColor = document.querySelector("meta[name='theme-color']")
+ if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
+ }, [theme])
+
+ return (
+
+
+
+
+
+ setTheme('light')}>
+ Light{' '}
+
+
+ setTheme('dark')}>
+ Dark
+
+
+ setTheme('system')}>
+ System
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/components/ui/alert-dialog.tsx b/packages/frontend/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..184fa90
--- /dev/null
+++ b/packages/frontend/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,154 @@
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/packages/frontend/src/components/ui/alert.tsx b/packages/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..debcdf6
--- /dev/null
+++ b/packages/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,65 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/packages/frontend/src/components/ui/avatar.tsx b/packages/frontend/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..b2a343e
--- /dev/null
+++ b/packages/frontend/src/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/packages/frontend/src/components/ui/badge.tsx b/packages/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..26769c3
--- /dev/null
+++ b/packages/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/packages/frontend/src/components/ui/button.tsx b/packages/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..27b86ed
--- /dev/null
+++ b/packages/frontend/src/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/packages/frontend/src/components/ui/calendar.tsx b/packages/frontend/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..7b1c90a
--- /dev/null
+++ b/packages/frontend/src/components/ui/calendar.tsx
@@ -0,0 +1,210 @@
+import * as React from 'react'
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from 'lucide-react'
+import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant']
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString('default', { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'relative flex flex-col gap-4 md:flex-row',
+ defaultClassNames.months
+ ),
+ month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
+ nav: cn(
+ 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ 'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ 'absolute inset-0 bg-popover opacity-0',
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ 'font-medium select-none',
+ captionLayout === 'label'
+ ? 'text-sm'
+ : 'flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
+ defaultClassNames.caption_label
+ ),
+ table: 'w-full border-collapse',
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
+ defaultClassNames.weekday
+ ),
+ week: cn('mt-2 flex w-full', defaultClassNames.week),
+ week_number_header: cn(
+ 'w-(--cell-size) select-none',
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ 'text-[0.8rem] text-muted-foreground select-none',
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ 'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ 'rounded-l-md bg-accent',
+ defaultClassNames.range_start
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
+ today: cn(
+ 'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
+ defaultClassNames.today
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ )
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+