feat: implement third-party order sync
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
*.log
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run pre-commit
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.umi
|
||||||
|
.umi-production
|
||||||
+11
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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. 订单列表与订单详情。
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<div align="center">
|
||||||
|
<h1>Arco Design Pro</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
基于 [Arco Design](https://arco.design/) React 组件库的开箱即用的中后台前端解决方案。
|
||||||
|
|
||||||
|
Admin 中后台管理页面,创新的多架构方案。
|
||||||
|
|
||||||
|
[](https://github.com/arco-design/arco-design-pro/blob/main/LICENSE)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[English](./README.md) | 简体中文
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ✨ 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)
|
||||||
+415
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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` 创建本地配置。
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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`);
|
||||||
@@ -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`);
|
||||||
@@ -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`;
|
||||||
@@ -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` 等接口。
|
||||||
|
- 订单服务负责协调支付成功后的履约分流。
|
||||||
|
|
||||||
|
这样后续新增支付渠道或新增自营商品时,不需要改动第三方接口调用核心逻辑。
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.adminLogsService.listAuditLogs(request.user, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AuditLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listAuditLogs(actor: User, query: Record<string, unknown>) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string[]> },
|
||||||
|
) {
|
||||||
|
return this.permissionsService.updateRolePermissions(
|
||||||
|
request.user,
|
||||||
|
code,
|
||||||
|
body.permissions || {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Role>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<string, string[]>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Role>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly usersRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listUsers(actor: User, query: Record<string, unknown>) {
|
||||||
|
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<string, string[]> = {};
|
||||||
|
|
||||||
|
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}/`));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<string, string[]>;
|
||||||
|
};
|
||||||
@@ -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<string, string[]>) {
|
||||||
|
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<string, string[]> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLog)
|
||||||
|
private readonly auditLogsRepository: Repository<AuditLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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: '保存失败',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<unknown> {
|
||||||
|
const options = this.reflector.getAllAndOverride<OperationLogOptions>(
|
||||||
|
OPERATION_LOG_METADATA,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
if (!keys?.length) {
|
||||||
|
return this.maskValue(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked: Record<string, unknown> = {};
|
||||||
|
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<string, unknown> = {};
|
||||||
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
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<unknown>((current, key) => {
|
||||||
|
if (!current || typeof current !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (current as Record<string, unknown>)[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Missing authorization token');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>('AUTH_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: '8h',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthGuard, AuthService],
|
||||||
|
exports: [AuthGuard, JwtModule, UsersModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -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<Role>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<string, string[]> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
export type AuthenticatedRequest = Request & {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<FulfillmentType>([
|
||||||
|
'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<Category>,
|
||||||
|
@InjectRepository(Course)
|
||||||
|
private readonly productsRepository: Repository<Course>,
|
||||||
|
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<string>();
|
||||||
|
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<string, Category>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, Category>,
|
||||||
|
) {
|
||||||
|
const category = product.categoryId
|
||||||
|
? categoryMap.get(product.categoryId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...product,
|
||||||
|
categoryName: category?.name ?? null,
|
||||||
|
categorySourceType: category?.sourceType ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type ApiResponse<T = unknown> = {
|
||||||
|
code: number;
|
||||||
|
data: T | null;
|
||||||
|
msg: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const API_SUCCESS_CODE = 0;
|
||||||
|
export const API_SUCCESS_MESSAGE = 'success';
|
||||||
@@ -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<Response>();
|
||||||
|
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<null>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>
|
||||||
|
implements NestInterceptor<T, ApiResponse<T>>
|
||||||
|
{
|
||||||
|
intercept(
|
||||||
|
_context: ExecutionContext,
|
||||||
|
next: CallHandler<T>,
|
||||||
|
): Observable<ApiResponse<T>> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => ({
|
||||||
|
code: API_SUCCESS_CODE,
|
||||||
|
data: data ?? null,
|
||||||
|
msg: API_SUCCESS_MESSAGE,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof envSchema>;
|
||||||
|
|
||||||
|
export function validateEnv(config: Record<string, unknown>): AppEnv {
|
||||||
|
return envSchema.parse(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export const rootEnvPath = join(__dirname, '../../../../.env');
|
||||||
|
export const packageEnvPath = join(__dirname, '../../.env');
|
||||||
@@ -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 {}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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');
|
||||||
+71
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
+69
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+12
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
UPDATE roles
|
||||||
|
SET permissions = JSON_SET(
|
||||||
|
COALESCE(permissions, JSON_OBJECT()),
|
||||||
|
'$."order-logs"',
|
||||||
|
JSON_ARRAY('read', 'write')
|
||||||
|
)
|
||||||
|
WHERE code = 'super_admin';
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<ThirdPartyOrderLogType, string> = {
|
||||||
|
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<ApiCallLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<string>('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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 日志请求失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectedCourseDto = {
|
||||||
|
courseName?: string;
|
||||||
|
courseId?: string | null;
|
||||||
|
raw?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateOrderDto = {
|
||||||
|
productId?: string;
|
||||||
|
quantity?: number;
|
||||||
|
orderPayload?: Record<string, unknown> | null;
|
||||||
|
selectedCourse?: SelectedCourseDto | null;
|
||||||
|
expand?: Record<string, unknown> | 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, unknown> | 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;
|
||||||
|
};
|
||||||
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ name: 'encrypted_payload', type: 'json', nullable: true })
|
||||||
|
encryptedPayload: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 6 })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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 }>;
|
||||||
|
};
|
||||||
@@ -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<string, unknown>,
|
||||||
|
) {
|
||||||
|
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<string, unknown>,
|
||||||
|
) {
|
||||||
|
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<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.thirdPartyAdminService.syncCourses(request.user, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('courses')
|
||||||
|
listCourses(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Query() query: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.thirdPartyAdminService.listCourses(request.user, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiCallLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
assertAdmin(actor);
|
||||||
|
const remoteCategoryId = String(query.remoteCategoryId || query.fenlei || '').trim();
|
||||||
|
return this.thirdPartyCatalogService.syncCourses(remoteCategoryId || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCategories(actor: User, query: Record<string, unknown>) {
|
||||||
|
assertAdmin(actor);
|
||||||
|
return this.thirdPartyCatalogService.listCategories(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCourses(actor: User, query: Record<string, unknown>) {
|
||||||
|
assertAdmin(actor);
|
||||||
|
return this.thirdPartyCatalogService.listCourses(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T extends object>() {
|
||||||
|
return {
|
||||||
|
create: jest.fn((input: Partial<T>) => input),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
exist: jest.fn(),
|
||||||
|
remove: jest.fn(async (input: Partial<T>) => input),
|
||||||
|
save: jest.fn(async (input: Partial<T>) => input),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThirdPartyCatalogService', () => {
|
||||||
|
const client = {
|
||||||
|
call: jest.fn(),
|
||||||
|
};
|
||||||
|
const catalogSyncJobsRepository = createRepositoryMock<CatalogSyncJob>();
|
||||||
|
const categoriesRepository = createRepositoryMock<Category>();
|
||||||
|
const coursesRepository = createRepositoryMock<Course>();
|
||||||
|
const orderItemsRepository = createRepositoryMock<OrderItem>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<CatalogSyncJob>,
|
||||||
|
@InjectRepository(Category)
|
||||||
|
private readonly categoriesRepository: Repository<Category>,
|
||||||
|
@InjectRepository(Course)
|
||||||
|
private readonly coursesRepository: Repository<Course>,
|
||||||
|
@InjectRepository(OrderItem)
|
||||||
|
private readonly orderItemsRepository: Repository<OrderItem>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<SyncStats> {
|
||||||
|
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<string, Category>();
|
||||||
|
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<string>();
|
||||||
|
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<string, unknown>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
type CallOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ThirdPartyClientService {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@InjectRepository(ApiCallLog)
|
||||||
|
private readonly apiCallLogsRepository: Repository<ApiCallLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async call<T = unknown>(
|
||||||
|
act: string,
|
||||||
|
payload: ThirdPartyPayload = {},
|
||||||
|
options: CallOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const baseUrl = this.configService.getOrThrow<string>('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<string, unknown> = 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<string>('WK_APP_KEY');
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'biedawo',
|
||||||
|
baseUrl: this.configService.get<string>('WK_BASE_URL') ?? null,
|
||||||
|
uidConfigured: Boolean(this.configService.get<string>('WK_APP_UID')),
|
||||||
|
keyConfigured: Boolean(apiKey),
|
||||||
|
debugLogEnabled: this.isDebugEnabled(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUid() {
|
||||||
|
const uid = this.configService.get<string>('WK_APP_UID');
|
||||||
|
if (!uid) {
|
||||||
|
throw new BadRequestException('缺少 WK_APP_UID,无法调用第三方接口');
|
||||||
|
}
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getApiKey() {
|
||||||
|
const apiKey = this.configService.get<string>('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<string>('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<string, unknown>;
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user