feat: implement third-party order sync

This commit is contained in:
2026-06-07 02:05:48 +08:00
commit aa593449bb
398 changed files with 51460 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules/
.next/
dist/
coverage/
.idea/
.env
.env.*
!.env.example
*.log
.eslintcache
.stylelintcache
.DS_Store
Thumbs.db
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run pre-commit
+2
View File
@@ -0,0 +1,2 @@
registry=https://registry.npmmirror.com/
+3
View File
@@ -0,0 +1,3 @@
node_modules
.umi
.umi-production
+11
View File
@@ -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"]
}
+445
View File
@@ -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. 订单列表与订单详情。
+34
View File
@@ -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.
+59
View File
@@ -0,0 +1,59 @@
<div align="center">
<h1>Arco Design Pro</h1>
</div>
<div align="center">
基于 [Arco Design](https://arco.design/) React 组件库的开箱即用的中后台前端解决方案。
Admin 中后台管理页面,创新的多架构方案。
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/arco-design/arco-design-pro/blob/main/LICENSE)
</div>
<div align="center">
[English](./README.md) | 简体中文
</div>
![f769c408-adf4-4a85-b4a5-cc0d7e7f29ef](https://user-images.githubusercontent.com/19399269/148364725-b7a36383-04a9-4d67-87a4-91e970d0d083.gif)
## ✨ 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 模版。
![2c7d7cf6-75a8-4014-bc9c-c9999e006c3b](https://user-images.githubusercontent.com/19399269/148364848-bd19d06c-6ca4-4faf-abbf-671ebb05e680.png)
## 🌈 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
View File
@@ -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 | 目标课程 ID182:慢刷,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,≤5MBmultipart/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"
}
}
}
}
```
+52
View File
@@ -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`;
+348
View File
@@ -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` 等接口。
- 订单服务负责协调支付成功后的履约分流。
这样后续新增支付渠道或新增自营商品时,不需要改动第三方接口调用核心逻辑。
+84
View File
@@ -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',
},
},
);
+34
View File
@@ -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"
}
}
+56
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+76
View File
@@ -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;
}
+42
View File
@@ -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);
}
}
+52
View File
@@ -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();
});
});
+31
View File
@@ -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 {}
+213
View File
@@ -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,
})),
);
}
}
+13
View File
@@ -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');
});
});
+22
View File
@@ -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);
}
+4
View File
@@ -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');
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,7 @@
UPDATE roles
SET permissions = JSON_SET(
COALESCE(permissions, JSON_OBJECT()),
'$."order-logs"',
JSON_ARRAY('read', 'write')
)
WHERE code = 'super_admin';
+12
View File
@@ -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