Initial commit
This commit is contained in:
8
后端架构设计/.claude/settings.local.json
Normal file
8
后端架构设计/.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cd:*)",
|
||||
"Bash(cat:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
85
后端架构设计/00-文档索引.md
Normal file
85
后端架构设计/00-文档索引.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 后端架构设计文档索引
|
||||
|
||||
## 架构总览
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [01-单体架构总体设计.md](./01-单体架构总体设计.md) | 整体架构图、技术栈、项目结构、模块划分、API格式、错误码 |
|
||||
| [01-单体架构设计.md](./01-单体架构设计.md) | 补充架构说明 |
|
||||
|
||||
---
|
||||
|
||||
## 数据库设计
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [02-数据库设计-用户Skill积分.md](./02-数据库设计-用户Skill积分.md) | users / skill_categories / skills / skill_reviews / skill_downloads / user_points / points_records / points_rules 表结构 |
|
||||
| [03-数据库设计-订单支付邀请.md](./03-数据库设计-订单支付邀请.md) | orders / order_items / order_refunds / recharge_orders / payment_records / invite_codes / invite_records 表结构 |
|
||||
|
||||
---
|
||||
|
||||
## 服务开发文档
|
||||
|
||||
### 用户服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [04-用户服务开发文档-part1.md](./04-用户服务开发文档-part1.md) | Entity / DTO / VO / Repository |
|
||||
| [04-用户服务开发文档-part2.md](./04-用户服务开发文档-part2.md) | UserService 接口 + Impl + Controller |
|
||||
|
||||
### Skill 服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [05-Skill服务开发文档.md](./05-Skill服务开发文档.md) | Entity / DTO / VO / Repository / Service / Controller |
|
||||
|
||||
### 积分服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [06-积分服务开发文档.md](./06-积分服务开发文档.md) | Entity / DTO / VO / Repository / Service / Controller |
|
||||
|
||||
### 订单服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [07-订单服务开发文档-part1.md](./07-订单服务开发文档-part1.md) | Entity / DTO / VO / Repository / Service接口 |
|
||||
| [07-订单服务开发文档-part2.md](./07-订单服务开发文档-part2.md) | OrderServiceImpl + OrderController |
|
||||
|
||||
### 支付服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [08-支付服务开发文档.md](./08-支付服务开发文档.md) | RechargeOrder / PaymentRecord / RechargeConfig / PaymentService + Impl + Controller |
|
||||
|
||||
### 邀请服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [09-邀请服务开发文档.md](./09-邀请服务开发文档.md) | InviteCode / InviteRecord / Repository / InviteService + Impl + Controller + 流程图 |
|
||||
|
||||
### 管理后台
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [10-管理后台-part1-权限与DTO.md](./10-管理后台-part1-权限与DTO.md) | 角色常量 / SecurityConfig片段 / 管理端 DTO & VO |
|
||||
| [10-管理后台-part2-Service.md](./10-管理后台-part2-Service.md) | AdminService 接口 + AdminServiceImpl(看板/用户/Skill/订单/积分规则) |
|
||||
| [10-管理后台-part3-Controller.md](./10-管理后台-part3-Controller.md) | AdminController + API 汇总表 |
|
||||
|
||||
---
|
||||
|
||||
## 通用基础设施
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [11-通用基础设施-part1-响应与异常.md](./11-通用基础设施-part1-响应与异常.md) | Result / ErrorCode / BusinessException / GlobalExceptionHandler |
|
||||
| [11-通用基础设施-part2-JWT与拦截器.md](./11-通用基础设施-part2-JWT与拦截器.md) | JwtUtil / UserContext / AuthInterceptor / WebMvcConfig |
|
||||
| [11-通用基础设施-part3-配置与工具类.md](./11-通用基础设施-part3-配置与工具类.md) | RedisConfig / MybatisPlusConfig / IdGenerator / pom.xml依赖 / application.yml完整示例 |
|
||||
|
||||
---
|
||||
|
||||
## 快速上手顺序
|
||||
|
||||
```
|
||||
1. 阅读 01-单体架构总体设计 → 理解整体结构
|
||||
2. 执行 02/03 数据库脚本 → 建表
|
||||
3. 配置 11-part3 的 application.yml
|
||||
4. 按模块顺序开发:用户 → Skill → 积分 → 订单 → 支付 → 邀请
|
||||
5. 最后接入 10-管理后台
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
309
后端架构设计/01-单体架构总体设计.md
Normal file
309
后端架构设计/01-单体架构总体设计.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# OpenClaw Skills 后端Java架构设计 - 单体架构
|
||||
|
||||
## 一、架构概览
|
||||
|
||||
### 1.1 整体架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 前端应用层 │
|
||||
│ Web / 小程序 / App │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 单体应用 (Spring Boot) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Controller 层 (API接口) │ │
|
||||
│ │ UserController SkillController OrderController ... │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Service 层 (业务逻辑) │ │
|
||||
│ │ UserService SkillService PointsService ... │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Repository 层 (数据访问) │ │
|
||||
│ │ UserRepository SkillRepository OrderRepository ... │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 基础设施层 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MySQL 8.0 │ Redis 7.x │ 腾讯云COS(文件存储) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 技术栈
|
||||
|
||||
| 层级 | 技术选型 | 说明 |
|
||||
|------|--------|------|
|
||||
| **框架** | Spring Boot 3.x | Web框架 |
|
||||
| **ORM** | MyBatis Plus | 数据访问 |
|
||||
| **数据库** | MySQL 8.0 | 主数据存储 |
|
||||
| **缓存** | Redis 7.x | 会话、缓存、分布式锁 |
|
||||
| **认证** | JWT + Spring Security | 用户认证 |
|
||||
| **文件存储** | 腾讯云COS | 图片/文件上传存储 |
|
||||
| **支付** | 微信支付SDK / 支付宝SDK | 支付集成 |
|
||||
| **部署** | Docker | 容器化 |
|
||||
|
||||
## 二、项目结构
|
||||
|
||||
```
|
||||
openclaw-backend/
|
||||
├── src/main/java/com/openclaw/
|
||||
│ ├── controller/ # 控制层
|
||||
│ │ ├── UserController.java
|
||||
│ │ ├── SkillController.java
|
||||
│ │ ├── OrderController.java
|
||||
│ │ ├── PointsController.java
|
||||
│ │ ├── PaymentController.java
|
||||
│ │ └── InviteController.java
|
||||
│ │
|
||||
│ ├── service/ # 业务层
|
||||
│ │ ├── UserService.java
|
||||
│ │ ├── SkillService.java
|
||||
│ │ ├── OrderService.java
|
||||
│ │ ├── PointsService.java
|
||||
│ │ ├── PaymentService.java
|
||||
│ │ ├── InviteService.java
|
||||
│ │ └── impl/
|
||||
│ │ ├── UserServiceImpl.java
|
||||
│ │ ├── SkillServiceImpl.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── repository/ # 数据访问层
|
||||
│ │ ├── UserRepository.java
|
||||
│ │ ├── SkillRepository.java
|
||||
│ │ ├── OrderRepository.java
|
||||
│ │ ├── PointsRepository.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── entity/ # 实体类
|
||||
│ │ ├── User.java
|
||||
│ │ ├── Skill.java
|
||||
│ │ ├── Order.java
|
||||
│ │ ├── UserPoints.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── dto/ # 数据传输对象
|
||||
│ │ ├── UserRegisterDTO.java
|
||||
│ │ ├── SkillListDTO.java
|
||||
│ │ ├── OrderCreateDTO.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── config/ # 配置类
|
||||
│ │ ├── SecurityConfig.java
|
||||
│ │ ├── RedisConfig.java
|
||||
│ │ ├── MybatisPlusConfig.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── exception/ # 异常处理
|
||||
│ │ ├── BusinessException.java
|
||||
│ │ ├── GlobalExceptionHandler.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── util/ # 工具类
|
||||
│ │ ├── JwtUtil.java
|
||||
│ │ ├── EncryptUtil.java
|
||||
│ │ ├── IdGenerator.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── constant/ # 常量
|
||||
│ │ ├── ErrorCode.java
|
||||
│ │ ├── PointsConstant.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── interceptor/ # 拦截器
|
||||
│ │ └── AuthInterceptor.java
|
||||
│ │
|
||||
│ ├── listener/ # 消息监听
|
||||
│ │ ├── OrderEventListener.java
|
||||
│ │ ├── PaymentEventListener.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ └── OpenclawApplication.java # 启动类
|
||||
│
|
||||
├── src/main/resources/
|
||||
│ ├── application.yml # 主配置
|
||||
│ ├── application-dev.yml # 开发环境
|
||||
│ ├── application-prod.yml # 生产环境
|
||||
│ ├── db/
|
||||
│ │ └── migration/
|
||||
│ │ ├── V1__init_users.sql
|
||||
│ │ ├── V2__init_skills.sql
|
||||
│ │ ├── V3__init_orders.sql
|
||||
│ │ └── ...
|
||||
│ └── logback-spring.xml # 日志配置
|
||||
│
|
||||
├── pom.xml # Maven配置
|
||||
├── Dockerfile # Docker配置
|
||||
├── docker-compose.yml # 容器编排
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 三、核心模块设计
|
||||
|
||||
### 3.1 用户模块 (User Module)
|
||||
|
||||
**职责**:用户注册、登录、个人信息管理
|
||||
|
||||
**核心表**:
|
||||
- `users` - 用户基本信息
|
||||
- `user_profiles` - 用户详细资料
|
||||
- `user_auth` - 第三方授权
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/users/register - 注册
|
||||
- POST /api/v1/users/login - 登录
|
||||
- GET /api/v1/users/profile - 获取个人信息
|
||||
- PUT /api/v1/users/profile - 更新个人信息
|
||||
- POST /api/v1/users/logout - 登出
|
||||
|
||||
### 3.2 Skill模块 (Skill Module)
|
||||
|
||||
**职责**:Skill管理、浏览、搜索、下载
|
||||
|
||||
**核心表**:
|
||||
- `skills` - Skill基本信息
|
||||
- `skill_categories` - 分类
|
||||
- `skill_reviews` - 评价评论
|
||||
- `skill_downloads` - 下载记录
|
||||
|
||||
**关键API**:
|
||||
- GET /api/v1/skills - 列表
|
||||
- GET /api/v1/skills/{id} - 详情
|
||||
- POST /api/v1/skills - 上传Skill
|
||||
- GET /api/v1/skills/search - 搜索
|
||||
- POST /api/v1/skills/{id}/reviews - 发表评价
|
||||
|
||||
### 3.3 积分模块 (Points Module)
|
||||
|
||||
**职责**:积分获取、消耗、明细
|
||||
|
||||
**核心表**:
|
||||
- `user_points` - 用户积分账户
|
||||
- `points_records` - 积分流水
|
||||
- `points_rules` - 积分规则
|
||||
|
||||
**关键API**:
|
||||
- GET /api/v1/points/balance - 获取余额
|
||||
- GET /api/v1/points/records - 积分明细
|
||||
- POST /api/v1/points/sign-in - 签到
|
||||
- POST /api/v1/points/consume - 消耗积分
|
||||
|
||||
### 3.4 订单模块 (Order Module)
|
||||
|
||||
**职责**:订单创建、支付、退款
|
||||
|
||||
**核心表**:
|
||||
- `orders` - 订单主表
|
||||
- `order_items` - 订单项
|
||||
- `order_refunds` - 退款记录
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/orders - 创建订单
|
||||
- GET /api/v1/orders/{id} - 订单详情
|
||||
- POST /api/v1/orders/{id}/pay - 支付
|
||||
- POST /api/v1/orders/{id}/refund - 申请退款
|
||||
|
||||
### 3.5 支付模块 (Payment Module)
|
||||
|
||||
**职责**:支付处理、充值
|
||||
|
||||
**核心表**:
|
||||
- `recharge_orders` - 充值订单
|
||||
- `payment_records` - 支付记录
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/payments/recharge - 发起充值
|
||||
- POST /api/v1/payments/callback - 支付回调
|
||||
- GET /api/v1/payments/records - 支付记录
|
||||
|
||||
### 3.6 邀请模块 (Invite Module)
|
||||
|
||||
**职责**:邀请码、邀请奖励
|
||||
|
||||
**核心表**:
|
||||
- `invite_codes` - 邀请码
|
||||
- `invite_records` - 邀请记录
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/invites/generate - 生成邀请码
|
||||
- GET /api/v1/invites/records - 邀请记录
|
||||
|
||||
## 四、API响应格式
|
||||
|
||||
### 4.1 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "张三"
|
||||
},
|
||||
"timestamp": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 分页响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"records": [
|
||||
{ "id": 1, "name": "Skill1" },
|
||||
{ "id": 2, "name": "Skill2" }
|
||||
],
|
||||
"total": 100,
|
||||
"size": 10,
|
||||
"current": 1,
|
||||
"pages": 10
|
||||
},
|
||||
"timestamp": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "请求参数错误",
|
||||
"data": null,
|
||||
"timestamp": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
## 五、错误码定义
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权(需要登录) |
|
||||
| 403 | 禁止访问(无权限) |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器错误 |
|
||||
| 1001 | 用户不存在 |
|
||||
| 1002 | 密码错误 |
|
||||
| 1003 | 手机号已注册 |
|
||||
| 2001 | Skill不存在 |
|
||||
| 2002 | Skill已下架 |
|
||||
| 3001 | 积分不足 |
|
||||
| 3002 | 积分规则不存在 |
|
||||
| 4001 | 订单不存在 |
|
||||
| 4002 | 订单状态错误 |
|
||||
| 5001 | 支付失败 |
|
||||
| 5002 | 充值订单不存在 |
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
491
后端架构设计/01-单体架构设计.md
Normal file
491
后端架构设计/01-单体架构设计.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# OpenClaw Skills 后端Java架构设计 - 单体架构
|
||||
|
||||
## 一、架构概览
|
||||
|
||||
### 1.1 整体架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 前端应用层 │
|
||||
│ Web / 小程序 / App │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 单体应用 (Spring Boot) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Controller 层 (API接口) │ │
|
||||
│ │ UserController SkillController OrderController ... │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Service 层 (业务逻辑) │ │
|
||||
│ │ UserService SkillService PointsService ... │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Repository 层 (数据访问) │ │
|
||||
│ │ UserRepository SkillRepository OrderRepository ... │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 基础设施层 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MySQL 8.0 │ Redis 7.x │ RabbitMQ │ Elasticsearch │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 技术栈
|
||||
|
||||
| 层级 | 技术选型 | 说明 |
|
||||
|------|--------|------|
|
||||
| **框架** | Spring Boot 3.x | Web框架 |
|
||||
| **ORM** | MyBatis Plus | 数据访问 |
|
||||
| **数据库** | MySQL 8.0 | 主数据存储 |
|
||||
| **缓存** | Redis 7.x | 会话、缓存 |
|
||||
| **搜索** | Elasticsearch 8.x | Skill搜索 |
|
||||
| **消息队列** | RabbitMQ 3.x | 异步处理 |
|
||||
| **认证** | JWT + Spring Security | 用户认证 |
|
||||
| **文件存储** | 七牛云 / 阿里云OSS | 文件上传 |
|
||||
| **支付** | 微信支付SDK / 支付宝SDK | 支付集成 |
|
||||
| **部署** | Docker | 容器化 |
|
||||
|
||||
## 二、项目结构
|
||||
|
||||
```
|
||||
openclaw-backend/
|
||||
├── src/main/java/com/openclaw/
|
||||
│ ├── controller/ # 控制层
|
||||
│ │ ├── UserController.java
|
||||
│ │ ├── SkillController.java
|
||||
│ │ ├── OrderController.java
|
||||
│ │ ├── PointsController.java
|
||||
│ │ ├── PaymentController.java
|
||||
│ │ └── InviteController.java
|
||||
│ │
|
||||
│ ├── service/ # 业务层
|
||||
│ │ ├── UserService.java
|
||||
│ │ ├── SkillService.java
|
||||
│ │ ├── OrderService.java
|
||||
│ │ ├── PointsService.java
|
||||
│ │ ├── PaymentService.java
|
||||
│ │ ├── InviteService.java
|
||||
│ │ └── impl/
|
||||
│ │ ├── UserServiceImpl.java
|
||||
│ │ ├── SkillServiceImpl.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── repository/ # 数据访问层
|
||||
│ │ ├── UserRepository.java
|
||||
│ │ ├── SkillRepository.java
|
||||
│ │ ├── OrderRepository.java
|
||||
│ │ ├── PointsRepository.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── entity/ # 实体类
|
||||
│ │ ├── User.java
|
||||
│ │ ├── Skill.java
|
||||
│ │ ├── Order.java
|
||||
│ │ ├── UserPoints.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── dto/ # 数据传输对象
|
||||
│ │ ├── UserRegisterDTO.java
|
||||
│ │ ├── SkillListDTO.java
|
||||
│ │ ├── OrderCreateDTO.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── config/ # 配置类
|
||||
│ │ ├── SecurityConfig.java
|
||||
│ │ ├── RedisConfig.java
|
||||
│ │ ├── MybatisPlusConfig.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── exception/ # 异常处理
|
||||
│ │ ├── BusinessException.java
|
||||
│ │ ├── GlobalExceptionHandler.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── util/ # 工具类
|
||||
│ │ ├── JwtUtil.java
|
||||
│ │ ├── EncryptUtil.java
|
||||
│ │ ├── IdGenerator.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── constant/ # 常量
|
||||
│ │ ├── ErrorCode.java
|
||||
│ │ ├── PointsConstant.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── interceptor/ # 拦截器
|
||||
│ │ └── AuthInterceptor.java
|
||||
│ │
|
||||
│ ├── listener/ # 消息监听
|
||||
│ │ ├── OrderEventListener.java
|
||||
│ │ ├── PaymentEventListener.java
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ └── OpenclawApplication.java # 启动类
|
||||
│
|
||||
├── src/main/resources/
|
||||
│ ├── application.yml # 主配置
|
||||
│ ├── application-dev.yml # 开发环境
|
||||
│ ├── application-prod.yml # 生产环境
|
||||
│ ├── db/
|
||||
│ │ └── migration/
|
||||
│ │ ├── V1__init_users.sql
|
||||
│ │ ├── V2__init_skills.sql
|
||||
│ │ ├── V3__init_orders.sql
|
||||
│ │ └── ...
|
||||
│ └── logback-spring.xml # 日志配置
|
||||
│
|
||||
├── pom.xml # Maven配置
|
||||
├── Dockerfile # Docker配置
|
||||
├── docker-compose.yml # 容器编排
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 三、核心模块设计
|
||||
|
||||
### 3.1 用户模块 (User Module)
|
||||
|
||||
**职责**:用户注册、登录、个人信息管理
|
||||
|
||||
**核心表**:
|
||||
- `users` - 用户基本信息
|
||||
- `user_profiles` - 用户详细资料
|
||||
- `user_auth` - 第三方授权
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/users/register - 注册
|
||||
- POST /api/v1/users/login - 登录
|
||||
- GET /api/v1/users/profile - 获取个人信息
|
||||
- PUT /api/v1/users/profile - 更新个人信息
|
||||
- POST /api/v1/users/logout - 登出
|
||||
|
||||
### 3.2 Skill模块 (Skill Module)
|
||||
|
||||
**职责**:Skill管理、浏览、搜索、下载
|
||||
|
||||
**核心表**:
|
||||
- `skills` - Skill基本信息
|
||||
- `skill_categories` - 分类
|
||||
- `skill_reviews` - 评价评论
|
||||
- `skill_downloads` - 下载记录
|
||||
|
||||
**关键API**:
|
||||
- GET /api/v1/skills - 列表
|
||||
- GET /api/v1/skills/{id} - 详情
|
||||
- POST /api/v1/skills - 上传Skill
|
||||
- GET /api/v1/skills/search - 搜索
|
||||
- POST /api/v1/skills/{id}/reviews - 发表评价
|
||||
|
||||
### 3.3 积分模块 (Points Module)
|
||||
|
||||
**职责**:积分获取、消耗、明细
|
||||
|
||||
**核心表**:
|
||||
- `user_points` - 用户积分账户
|
||||
- `points_records` - 积分流水
|
||||
- `points_rules` - 积分规则
|
||||
|
||||
**关键API**:
|
||||
- GET /api/v1/points/balance - 获取余额
|
||||
- GET /api/v1/points/records - 积分明细
|
||||
- POST /api/v1/points/sign-in - 签到
|
||||
- POST /api/v1/points/consume - 消耗积分
|
||||
|
||||
### 3.4 订单模块 (Order Module)
|
||||
|
||||
**职责**:订单创建、支付、退款
|
||||
|
||||
**核心表**:
|
||||
- `orders` - 订单主表
|
||||
- `order_items` - 订单项
|
||||
- `order_refunds` - 退款记录
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/orders - 创建订单
|
||||
- GET /api/v1/orders/{id} - 订单详情
|
||||
- POST /api/v1/orders/{id}/pay - 支付
|
||||
- POST /api/v1/orders/{id}/refund - 申请退款
|
||||
|
||||
### 3.5 支付模块 (Payment Module)
|
||||
|
||||
**职责**:支付处理、充值
|
||||
|
||||
**核心表**:
|
||||
- `recharge_orders` - 充值订单
|
||||
- `payment_records` - 支付记录
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/payments/recharge - 发起充值
|
||||
- POST /api/v1/payments/callback - 支付回调
|
||||
- GET /api/v1/payments/records - 支付记录
|
||||
|
||||
### 3.6 邀请模块 (Invite Module)
|
||||
|
||||
**职责**:邀请码、邀请奖励
|
||||
|
||||
**核心表**:
|
||||
- `invite_codes` - 邀请码
|
||||
- `invite_records` - 邀请记录
|
||||
|
||||
**关键API**:
|
||||
- POST /api/v1/invites/generate - 生成邀请码
|
||||
- GET /api/v1/invites/records - 邀请记录
|
||||
|
||||
## 四、API响应格式
|
||||
|
||||
### 4.1 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "张三"
|
||||
},
|
||||
"timestamp": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 分页响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"records": [
|
||||
{ "id": 1, "name": "Skill1" },
|
||||
{ "id": 2, "name": "Skill2" }
|
||||
],
|
||||
"total": 100,
|
||||
"size": 10,
|
||||
"current": 1,
|
||||
"pages": 10
|
||||
},
|
||||
"timestamp": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "请求参数错误",
|
||||
"data": null,
|
||||
"timestamp": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 错误码定义
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权(需要登录) |
|
||||
| 403 | 禁止访问(无权限) |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器错误 |
|
||||
| 1001 | 用户不存在 |
|
||||
| 1002 | 密码错误 |
|
||||
| 1003 | 手机号已注册 |
|
||||
| 2001 | Skill不存在 |
|
||||
| 2002 | Skill已下架 |
|
||||
| 3001 | 积分不足 |
|
||||
| 3002 | 积分规则不存在 |
|
||||
| 4001 | 订单不存在 |
|
||||
| 4002 | 订单状态错误 |
|
||||
| 5001 | 支付失败 |
|
||||
| 5002 | 充值订单不存在 |
|
||||
|
||||
## 五、数据库设计
|
||||
|
||||
### 5.1 用户表
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
|
||||
phone VARCHAR(20) UNIQUE NOT NULL COMMENT '手机号',
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希',
|
||||
nickname VARCHAR(100) COMMENT '昵称',
|
||||
avatar_url VARCHAR(500) COMMENT '头像URL',
|
||||
status ENUM('active', 'inactive', 'banned') DEFAULT 'active' COMMENT '状态',
|
||||
member_level ENUM('normal', 'silver', 'gold', 'diamond') DEFAULT 'normal' COMMENT '会员等级',
|
||||
growth_value INT DEFAULT 0 COMMENT '成长值',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted_at TIMESTAMP NULL COMMENT '删除时间',
|
||||
INDEX idx_phone (phone),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
```
|
||||
|
||||
### 5.2 积分表
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_points (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '积分ID',
|
||||
user_id BIGINT NOT NULL UNIQUE COMMENT '用户ID',
|
||||
available_points INT DEFAULT 0 COMMENT '可用积分',
|
||||
frozen_points INT DEFAULT 0 COMMENT '冻结积分',
|
||||
total_earned INT DEFAULT 0 COMMENT '累计获取',
|
||||
total_consumed INT DEFAULT 0 COMMENT '累计消耗',
|
||||
last_sign_in_date DATE COMMENT '最后签到日期',
|
||||
sign_in_streak INT DEFAULT 0 COMMENT '连续签到天数',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户积分表';
|
||||
```
|
||||
|
||||
### 5.3 积分流水表
|
||||
|
||||
```sql
|
||||
CREATE TABLE points_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '流水ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
points_type ENUM('earn', 'consume') NOT NULL COMMENT '类型',
|
||||
source ENUM('register', 'sign_in', 'invite', 'join_community', 'recharge', 'skill_purchase', 'review', 'activity') NOT NULL COMMENT '来源',
|
||||
amount INT NOT NULL COMMENT '积分数量',
|
||||
description VARCHAR(255) COMMENT '描述',
|
||||
related_id BIGINT COMMENT '关联ID',
|
||||
related_type VARCHAR(50) COMMENT '关联类型',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_source (source),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分流水表';
|
||||
```
|
||||
|
||||
### 5.4 订单表
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID',
|
||||
order_no VARCHAR(50) NOT NULL UNIQUE COMMENT '订单号',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
total_amount DECIMAL(10, 2) NOT NULL COMMENT '总金额',
|
||||
paid_amount DECIMAL(10, 2) DEFAULT 0 COMMENT '已支付金额',
|
||||
points_used INT DEFAULT 0 COMMENT '使用积分',
|
||||
status ENUM('pending', 'paid', 'completed', 'cancelled', 'refunding', 'refunded') DEFAULT 'pending' COMMENT '订单状态',
|
||||
payment_method ENUM('wechat', 'alipay', 'points', 'mixed') DEFAULT 'wechat' COMMENT '支付方式',
|
||||
remark VARCHAR(500) COMMENT '备注',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_order_no (order_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表';
|
||||
```
|
||||
|
||||
## 六、配置文件
|
||||
|
||||
### 6.1 application.yml
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
application:
|
||||
name: openclaw-backend
|
||||
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/openclaw?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
|
||||
username: root
|
||||
password: wang200314
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password:
|
||||
timeout: 10000ms
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: false
|
||||
|
||||
jackson:
|
||||
default-timezone: GMT+8
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath*:/mapper/**/*.xml
|
||||
type-aliases-package: com.openclaw.entity
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
context-path: /
|
||||
compression:
|
||||
enabled: true
|
||||
min-response-size: 1024
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.openclaw: DEBUG
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: logs/openclaw.log
|
||||
max-size: 10MB
|
||||
max-history: 30
|
||||
```
|
||||
|
||||
## 七、开发流程
|
||||
|
||||
### 7.1 本地开发环境启动
|
||||
|
||||
```bash
|
||||
# 1. 启动MySQL
|
||||
docker run -d --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:8.0
|
||||
|
||||
# 2. 启动Redis
|
||||
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||
|
||||
# 3. 启动RabbitMQ
|
||||
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
|
||||
|
||||
# 4. 创建数据库
|
||||
mysql -u root -p < init.sql
|
||||
|
||||
# 5. 启动应用
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
### 7.2 代码提交规范
|
||||
|
||||
```
|
||||
feat: 新增用户注册功能
|
||||
fix: 修复积分计算错误
|
||||
docs: 更新API文档
|
||||
refactor: 重构订单服务
|
||||
test: 添加支付测试用例
|
||||
chore: 更新依赖版本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
234
后端架构设计/02-数据库设计-用户Skill积分.md
Normal file
234
后端架构设计/02-数据库设计-用户Skill积分.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# 数据库设计文档(用户 / Skill / 积分模块)
|
||||
|
||||
## 一、用户模块
|
||||
|
||||
### 1.1 users 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
|
||||
phone VARCHAR(20) UNIQUE NOT NULL COMMENT '手机号',
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希(BCrypt)',
|
||||
nickname VARCHAR(100) COMMENT '昵称',
|
||||
avatar_url VARCHAR(500) COMMENT '头像URL(腾讯云COS)',
|
||||
status ENUM('active', 'inactive', 'banned') DEFAULT 'active' COMMENT '状态',
|
||||
member_level ENUM('normal', 'silver', 'gold', 'diamond') DEFAULT 'normal' COMMENT '会员等级',
|
||||
growth_value INT DEFAULT 0 COMMENT '成长值',
|
||||
ban_reason VARCHAR(255) COMMENT '封禁原因',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL COMMENT '软删除时间',
|
||||
INDEX idx_phone (phone),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
```
|
||||
|
||||
> **变更说明**:新增 `ban_reason` 字段,用于管理员封禁用户时记录原因。
|
||||
|
||||
### 1.2 user_profiles 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_profiles (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资料ID',
|
||||
user_id BIGINT NOT NULL UNIQUE COMMENT '用户ID',
|
||||
real_name VARCHAR(100) COMMENT '真实姓名',
|
||||
id_card VARCHAR(50) COMMENT '身份证号(加密)',
|
||||
gender ENUM('male', 'female', 'unknown') DEFAULT 'unknown',
|
||||
birthday DATE,
|
||||
city VARCHAR(100),
|
||||
bio TEXT COMMENT '个人简介',
|
||||
auth_status ENUM('none', 'pending', 'approved', 'rejected') DEFAULT 'none' COMMENT '实名认证',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户详细资料表';
|
||||
```
|
||||
|
||||
### 1.3 user_auth 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_auth (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
auth_type ENUM('wechat', 'alipay', 'email') NOT NULL COMMENT '授权类型',
|
||||
auth_id VARCHAR(255) NOT NULL COMMENT '第三方唯一ID',
|
||||
auth_name VARCHAR(100) COMMENT '第三方昵称',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_auth (auth_type, auth_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='第三方授权表';
|
||||
```
|
||||
|
||||
## 二、Skill模块
|
||||
|
||||
### 2.1 skill_categories 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE skill_categories (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL UNIQUE COMMENT '分类名称',
|
||||
parent_id INT DEFAULT NULL COMMENT '父分类ID(NULL=一级)',
|
||||
icon_url VARCHAR(500) COMMENT '图标(腾讯云COS)',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_parent_id (parent_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Skill分类表';
|
||||
|
||||
INSERT INTO skill_categories (name, parent_id, sort_order) VALUES
|
||||
('办公自动化', NULL, 1), ('数据处理', NULL, 2),
|
||||
('客服助手', NULL, 3), ('内容创作', NULL, 4),
|
||||
('营销推广', NULL, 5), ('其他', NULL, 99);
|
||||
```
|
||||
|
||||
### 2.2 skills 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE skills (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
creator_id BIGINT NOT NULL COMMENT '创建者ID',
|
||||
name VARCHAR(200) NOT NULL COMMENT 'Skill名称',
|
||||
description TEXT COMMENT '详细描述',
|
||||
cover_image_url VARCHAR(500) COMMENT '封面图(腾讯云COS)',
|
||||
category_id INT NOT NULL COMMENT '分类ID',
|
||||
price DECIMAL(10, 2) DEFAULT 0.00 COMMENT '价格(元)',
|
||||
is_free BOOLEAN DEFAULT FALSE COMMENT '是否免费',
|
||||
status ENUM('draft','pending','approved','rejected','offline') DEFAULT 'draft' COMMENT '状态',
|
||||
reject_reason VARCHAR(500) COMMENT '审核拒绝原因',
|
||||
auditor_id BIGINT COMMENT '审核人ID',
|
||||
audited_at TIMESTAMP NULL COMMENT '审核时间',
|
||||
download_count INT DEFAULT 0 COMMENT '下载次数',
|
||||
rating DECIMAL(3, 2) DEFAULT 0.00 COMMENT '平均评分',
|
||||
rating_count INT DEFAULT 0 COMMENT '评分人数',
|
||||
version VARCHAR(50) COMMENT '版本号',
|
||||
file_size BIGINT COMMENT '文件大小(字节)',
|
||||
file_url VARCHAR(500) COMMENT '文件(腾讯云COS)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL COMMENT '软删除',
|
||||
FOREIGN KEY (creator_id) REFERENCES users(id),
|
||||
FOREIGN KEY (category_id) REFERENCES skill_categories(id),
|
||||
INDEX idx_creator_id (creator_id),
|
||||
INDEX idx_category_id (category_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_is_free (is_free),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_download_count (download_count),
|
||||
FULLTEXT INDEX ft_search (name, description)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Skill表';
|
||||
```
|
||||
|
||||
> **变更说明**:新增 `auditor_id`、`audited_at` 字段,用于管理后台记录审核操作人和审核时间。
|
||||
|
||||
### 2.3 skill_reviews 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE skill_reviews (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
skill_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
order_id BIGINT COMMENT '关联订单ID',
|
||||
rating INT NOT NULL COMMENT '评分(1-5)',
|
||||
content TEXT COMMENT '评价内容',
|
||||
images JSON COMMENT '图片URL数组(腾讯云COS)',
|
||||
helpful_count INT DEFAULT 0 COMMENT '有帮助人数',
|
||||
status ENUM('pending','approved','rejected') DEFAULT 'approved',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_skill_id (skill_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
CONSTRAINT chk_rating CHECK (rating >= 1 AND rating <= 5)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Skill评价表';
|
||||
```
|
||||
|
||||
### 2.4 skill_downloads 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE skill_downloads (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
skill_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
order_id BIGINT COMMENT '关联订单(免费为NULL)',
|
||||
download_type ENUM('free','paid','points') NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE KEY uk_user_skill (user_id, skill_id),
|
||||
INDEX idx_skill_id (skill_id),
|
||||
INDEX idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Skill获取记录表';
|
||||
```
|
||||
|
||||
## 三、积分模块
|
||||
|
||||
### 3.1 user_points 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_points (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL UNIQUE,
|
||||
available_points INT DEFAULT 0 COMMENT '可用积分',
|
||||
frozen_points INT DEFAULT 0 COMMENT '冻结积分',
|
||||
total_earned INT DEFAULT 0 COMMENT '累计获取',
|
||||
total_consumed INT DEFAULT 0 COMMENT '累计消耗',
|
||||
last_sign_in_date DATE COMMENT '最后签到日期',
|
||||
sign_in_streak INT DEFAULT 0 COMMENT '连续签到天数',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户积分账户表';
|
||||
```
|
||||
|
||||
### 3.2 points_records 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE points_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
points_type ENUM('earn','consume','freeze','unfreeze','admin_correct') NOT NULL COMMENT '变动类型',
|
||||
source ENUM(
|
||||
'register','sign_in','invite','invited','join_community',
|
||||
'recharge','skill_purchase','review','activity',
|
||||
'admin_add','admin_deduct','admin_correct','refund'
|
||||
) NOT NULL COMMENT '来源',
|
||||
amount INT NOT NULL COMMENT '变动量(正:获得 负:消耗)',
|
||||
balance INT NOT NULL COMMENT '变动后余额',
|
||||
description VARCHAR(255) COMMENT '描述',
|
||||
related_id BIGINT COMMENT '关联业务ID',
|
||||
related_type VARCHAR(50) COMMENT '关联业务类型',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_source (source),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分流水表';
|
||||
```
|
||||
|
||||
> **变更说明**:`points_type` 新增 `admin_correct`;`source` 新增 `invited`(被邀请奖励)、`admin_add`、`admin_deduct`、`admin_correct`、`refund`,与代码中的实际使用对齐。
|
||||
|
||||
### 3.3 points_rules 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE points_rules (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
rule_name VARCHAR(100) NOT NULL COMMENT '规则名称',
|
||||
source ENUM('register','sign_in','invite','join_community','recharge','review','activity') NOT NULL UNIQUE,
|
||||
points_amount INT NOT NULL COMMENT '积分数量',
|
||||
frequency_limit INT COMMENT '周期内上限(NULL不限)',
|
||||
frequency_period ENUM('daily','weekly','monthly','unlimited') DEFAULT 'unlimited',
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分规则表';
|
||||
|
||||
INSERT INTO points_rules (rule_name, source, points_amount, frequency_limit, frequency_period) VALUES
|
||||
('新用户注册', 'register', 300, 1, 'unlimited'),
|
||||
('每日签到', 'sign_in', 10, 1, 'daily'),
|
||||
('邀请好友', 'invite', 100, NULL, 'unlimited'),
|
||||
('加入社群', 'join_community', 50, 1, 'unlimited'),
|
||||
('发表评价', 'review', 5, 3, 'daily');
|
||||
```
|
||||
226
后端架构设计/03-数据库设计-订单支付邀请.md
Normal file
226
后端架构设计/03-数据库设计-订单支付邀请.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# 数据库设计文档(订单 / 支付 / 邀请模块)
|
||||
|
||||
## 一、订单模块
|
||||
|
||||
### 1.1 orders 表 - 订单主表
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_no VARCHAR(50) NOT NULL UNIQUE COMMENT '订单号(全局唯一)',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
total_amount DECIMAL(10, 2) NOT NULL COMMENT '应付总金额(元)',
|
||||
cash_amount DECIMAL(10, 2) DEFAULT 0.00 COMMENT '现金支付部分',
|
||||
points_used INT DEFAULT 0 COMMENT '使用积分数',
|
||||
points_deduct_amount DECIMAL(10, 2) DEFAULT 0.00 COMMENT '积分抵扣金额',
|
||||
status ENUM('pending','paid','completed','cancelled','refunding','refunded') DEFAULT 'pending',
|
||||
payment_method ENUM('wechat','alipay','points','mixed') COMMENT '支付方式',
|
||||
remark VARCHAR(500) COMMENT '备注',
|
||||
cancel_reason VARCHAR(255) COMMENT '取消原因',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||
expired_at TIMESTAMP NULL COMMENT '超时取消时间',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_order_no (order_no),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表';
|
||||
```
|
||||
|
||||
**订单号生成规则**:`ORD + 年月日时分秒 + 6位序列号`,例如 `ORD20260316143022000001`
|
||||
|
||||
**状态流转**:
|
||||
```
|
||||
pending → paid → completed
|
||||
pending → cancelled(超时/主动取消)
|
||||
paid → refunding → refunded
|
||||
```
|
||||
|
||||
### 1.2 order_items 表 - 订单项
|
||||
|
||||
```sql
|
||||
CREATE TABLE order_items (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
skill_id BIGINT NOT NULL COMMENT 'SkillID',
|
||||
skill_name VARCHAR(200) NOT NULL COMMENT 'Skill名称快照',
|
||||
skill_cover VARCHAR(500) COMMENT 'Skill封面快照(腾讯云COS)',
|
||||
unit_price DECIMAL(10, 2) NOT NULL COMMENT '下单时单价快照',
|
||||
quantity INT DEFAULT 1,
|
||||
total_price DECIMAL(10, 2) NOT NULL COMMENT '小计',
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id),
|
||||
INDEX idx_order_id (order_id),
|
||||
INDEX idx_skill_id (skill_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单项表';
|
||||
```
|
||||
|
||||
### 1.3 order_refunds 表 - 退款记录
|
||||
|
||||
```sql
|
||||
CREATE TABLE order_refunds (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
refund_no VARCHAR(50) NOT NULL UNIQUE COMMENT '退款单号',
|
||||
refund_amount DECIMAL(10, 2) NOT NULL COMMENT '退款金额',
|
||||
refund_points INT DEFAULT 0 COMMENT '退回积分',
|
||||
reason VARCHAR(255) COMMENT '退款原因',
|
||||
images JSON COMMENT '凭证图片(腾讯云COS URL数组)',
|
||||
status ENUM('pending','approved','rejected','completed') DEFAULT 'pending',
|
||||
reject_reason VARCHAR(255) COMMENT '拒绝原因',
|
||||
operator_id BIGINT COMMENT '处理人(管理员ID)',
|
||||
processed_at TIMESTAMP NULL COMMENT '处理时间',
|
||||
remark VARCHAR(255) COMMENT '处理备注',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL COMMENT '退款完成时间',
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id),
|
||||
INDEX idx_order_id (order_id),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退款表';
|
||||
```
|
||||
|
||||
> **变更说明**:原 `handled_by` 重命名为 `operator_id`;新增 `processed_at`(处理时间)和 `remark`(处理备注),与 `AdminServiceImpl.processRefund()` 对齐。
|
||||
|
||||
## 二、支付模块
|
||||
|
||||
### 2.1 recharge_orders 表 - 充值订单
|
||||
|
||||
```sql
|
||||
CREATE TABLE recharge_orders (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
recharge_no VARCHAR(50) NOT NULL UNIQUE COMMENT '充值单号',
|
||||
user_id BIGINT NOT NULL,
|
||||
amount DECIMAL(10, 2) NOT NULL COMMENT '充值金额(元)',
|
||||
bonus_points INT DEFAULT 0 COMMENT '赠送积分',
|
||||
total_points INT NOT NULL COMMENT '到账总积分(按金额换算+赠送)',
|
||||
payment_method ENUM('wechat','alipay') NOT NULL,
|
||||
status ENUM('pending','paid','failed','cancelled') DEFAULT 'pending',
|
||||
transaction_id VARCHAR(100) COMMENT '微信/支付宝交易流水号',
|
||||
notify_data TEXT COMMENT '支付回调原始数据',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
paid_at TIMESTAMP NULL COMMENT '支付完成时间',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值订单表';
|
||||
```
|
||||
|
||||
**充值赠送规则**(在 `application.yml` 中配置):
|
||||
|
||||
| 充值金额 | 赠送积分 | 到账总积分 |
|
||||
|---------|---------|----------|
|
||||
| ¥10 | 10 | 1010 |
|
||||
| ¥50 | 60 | 5060 |
|
||||
| ¥100 | 150 | 10150 |
|
||||
| ¥500 | 800 | 50800 |
|
||||
| ¥1000 | 2000 | 102000 |
|
||||
|
||||
> 到账总积分 = 充值金额 × 100(1元=100积分)+ 赠送积分
|
||||
|
||||
### 2.2 payment_records 表 - 支付流水
|
||||
|
||||
```sql
|
||||
CREATE TABLE payment_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
biz_type ENUM('order','recharge') NOT NULL COMMENT '业务类型',
|
||||
biz_id BIGINT NOT NULL COMMENT '业务ID(order_id 或 recharge_id)',
|
||||
biz_no VARCHAR(50) NOT NULL COMMENT '业务单号',
|
||||
amount DECIMAL(10, 2) NOT NULL COMMENT '支付金额',
|
||||
payment_method ENUM('wechat','alipay','points') NOT NULL,
|
||||
transaction_id VARCHAR(100) COMMENT '三方交易号',
|
||||
status ENUM('pending','success','failed') DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_biz_no (biz_no),
|
||||
INDEX idx_transaction_id (transaction_id),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付流水表';
|
||||
```
|
||||
|
||||
## 三、邀请模块
|
||||
|
||||
### 3.1 invite_codes 表 - 邀请码
|
||||
|
||||
```sql
|
||||
CREATE TABLE invite_codes (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL COMMENT '邀请人ID',
|
||||
code VARCHAR(50) NOT NULL UNIQUE COMMENT '邀请码(大写字母+数字)',
|
||||
invite_url VARCHAR(500) COMMENT '邀请链接',
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
use_count INT DEFAULT 0 COMMENT '已使用次数',
|
||||
max_use_count INT DEFAULT -1 COMMENT '最大使用次数(-1为不限)',
|
||||
expired_at TIMESTAMP NULL COMMENT '过期时间(NULL为永不过期)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE KEY uk_user_code (user_id),
|
||||
INDEX idx_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='邀请码表';
|
||||
```
|
||||
|
||||
> **变更说明**:原 `status ENUM('active','inactive')` 改为 `is_active BOOLEAN`,`used_count` 重命名为 `use_count`;新增 `max_use_count`(最大使用次数)、`expired_at`(过期时间)、`updated_at`,与 `InviteCode.java` 实体对齐。
|
||||
|
||||
### 3.2 invite_records 表 - 邀请记录
|
||||
|
||||
```sql
|
||||
CREATE TABLE invite_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
inviter_id BIGINT NOT NULL COMMENT '邀请人ID',
|
||||
invitee_id BIGINT NOT NULL COMMENT '被邀请人ID',
|
||||
invite_code VARCHAR(50) COMMENT '使用的邀请码',
|
||||
status ENUM('registered','first_paid') DEFAULT 'registered' COMMENT '状态',
|
||||
inviter_reward_points INT DEFAULT 0 COMMENT '邀请人获得积分',
|
||||
invitee_reward_points INT DEFAULT 0 COMMENT '被邀请人获得积分',
|
||||
reward_given BOOLEAN DEFAULT FALSE COMMENT '奖励是否已发放',
|
||||
rewarded_at TIMESTAMP NULL COMMENT '奖励发放时间',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (inviter_id) REFERENCES users(id),
|
||||
FOREIGN KEY (invitee_id) REFERENCES users(id),
|
||||
UNIQUE KEY uk_invitee (invitee_id),
|
||||
INDEX idx_inviter_id (inviter_id),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='邀请记录表';
|
||||
```
|
||||
|
||||
> **变更说明**:字段 `inviter_reward_points` / `invitee_reward_points` 与 `InviteRecord.java` 实体对齐;新增 `rewarded_at` 字段。
|
||||
|
||||
## 四、Redis 缓存 Key 设计
|
||||
|
||||
| Key | 说明 | TTL |
|
||||
|-----|------|-----|
|
||||
| `user:info:{userId}` | 用户基本信息缓存 | 30分钟 |
|
||||
| `user:token:{token}` | JWT Token黑名单(登出时写入) | 与token同期 |
|
||||
| `user:points:{userId}` | 用户积分余额缓存 | 5分钟 |
|
||||
| `skill:detail:{skillId}` | Skill详情缓存 | 10分钟 |
|
||||
| `skill:hot:list` | 热门Skill列表 | 1小时 |
|
||||
| `skill:category:list` | 分类列表 | 24小时 |
|
||||
| `sign_in:lock:{userId}:{date}` | 签到幂等锁 | 24小时 |
|
||||
| `invite:code:{code}` | 邀请码验证缓存 | 1小时 |
|
||||
| `order:lock:{orderNo}` | 订单支付分布式锁 | 30秒 |
|
||||
| `captcha:sms:{phone}` | 短信验证码 | 5分钟 |
|
||||
|
||||
## 五、数据库初始化 SQL 执行顺序
|
||||
|
||||
```
|
||||
1. V1__init_users.sql -- users, user_profiles, user_auth
|
||||
2. V2__init_skills.sql -- skill_categories, skills, skill_reviews, skill_downloads
|
||||
3. V3__init_points.sql -- user_points, points_records, points_rules
|
||||
4. V4__init_orders.sql -- orders, order_items, order_refunds
|
||||
5. V5__init_payments.sql -- recharge_orders, payment_records
|
||||
6. V6__init_invites.sql -- invite_codes, invite_records
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.1
|
||||
**创建日期**:2026-03-16
|
||||
**最后更新**:2026-03-16(修复字段缺失问题)
|
||||
354
后端架构设计/04-用户服务开发文档-part1.md
Normal file
354
后端架构设计/04-用户服务开发文档-part1.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# 用户服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### User.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("users")
|
||||
public class User {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String phone;
|
||||
private String passwordHash;
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
private String status; // active / inactive / banned
|
||||
private String memberLevel; // normal / silver / gold / diamond
|
||||
private Integer growthValue;
|
||||
private String banReason; // 封禁原因
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
@TableLogic
|
||||
private LocalDateTime deletedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### UserProfile.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("user_profiles")
|
||||
public class UserProfile {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String realName;
|
||||
private String idCard;
|
||||
private String gender; // male / female / unknown
|
||||
private LocalDate birthday;
|
||||
private String city;
|
||||
private String bio;
|
||||
private String authStatus; // none / pending / approved / rejected
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### UserRegisterDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserRegisterDTO {
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||
private String phone;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 20, message = "密码长度6-20位")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
private String smsCode;
|
||||
|
||||
private String inviteCode; // 邀请码(可选)
|
||||
}
|
||||
```
|
||||
|
||||
### UserLoginDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserLoginDTO {
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
private String phone;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
}
|
||||
```
|
||||
|
||||
### UserUpdateDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class UserUpdateDTO {
|
||||
private String nickname;
|
||||
private String avatarUrl; // 腾讯云COS上传后的URL
|
||||
private String gender;
|
||||
private LocalDate birthday;
|
||||
private String city;
|
||||
private String bio;
|
||||
}
|
||||
```
|
||||
|
||||
### UserVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class UserVO {
|
||||
private Long id;
|
||||
private String phone;
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
private String memberLevel;
|
||||
private Integer growthValue;
|
||||
private Integer availablePoints;
|
||||
private String inviteCode;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
### LoginVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginVO {
|
||||
private String token;
|
||||
private UserVO user;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、Service 接口
|
||||
|
||||
### UserService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface UserService {
|
||||
void sendSmsCode(String phone);
|
||||
LoginVO register(UserRegisterDTO dto);
|
||||
LoginVO login(UserLoginDTO dto);
|
||||
void logout(String token);
|
||||
UserVO getCurrentUser(Long userId);
|
||||
UserVO updateProfile(Long userId, UserUpdateDTO dto);
|
||||
void changePassword(Long userId, String oldPassword, String newPassword);
|
||||
void resetPassword(String phone, String smsCode, String newPassword);
|
||||
}
|
||||
```
|
||||
|
||||
## 四、Service 实现(核心逻辑)
|
||||
|
||||
### UserServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.*;
|
||||
import com.openclaw.util.JwtUtil;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final UserProfileRepository userProfileRepository;
|
||||
private final UserPointsRepository userPointsRepository;
|
||||
private final InviteCodeRepository inviteCodeRepository;
|
||||
private final PointsService pointsService;
|
||||
private final InviteService inviteService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
public void sendSmsCode(String phone) {
|
||||
String code = String.valueOf((int)((Math.random() * 9 + 1) * 100000));
|
||||
redisTemplate.opsForValue().set("captcha:sms:" + phone, code, 5, TimeUnit.MINUTES);
|
||||
// TODO: 调用腾讯云短信SDK发送
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LoginVO register(UserRegisterDTO dto) {
|
||||
// 1. 校验短信验证码
|
||||
String cached = redisTemplate.opsForValue().get("captcha:sms:" + dto.getPhone());
|
||||
if (!dto.getSmsCode().equals(cached)) throw new BusinessException(ErrorCode.SMS_CODE_ERROR);
|
||||
|
||||
// 2. 手机号唯一性检查
|
||||
if (userRepository.existsByPhone(dto.getPhone())) throw new BusinessException(ErrorCode.PHONE_ALREADY_EXISTS);
|
||||
|
||||
// 3. 创建用户
|
||||
User user = new User();
|
||||
user.setPhone(dto.getPhone());
|
||||
user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
|
||||
user.setNickname("用户" + dto.getPhone().substring(7));
|
||||
user.setStatus("active");
|
||||
user.setMemberLevel("normal");
|
||||
user.setGrowthValue(0);
|
||||
userRepository.save(user);
|
||||
|
||||
// 4. 初始化资料
|
||||
UserProfile profile = new UserProfile();
|
||||
profile.setUserId(user.getId());
|
||||
profile.setAuthStatus("none");
|
||||
userProfileRepository.save(profile);
|
||||
|
||||
// 5. 初始化积分 + 注册奖励
|
||||
pointsService.initUserPoints(user.getId());
|
||||
pointsService.earnPoints(user.getId(), "register", null, null);
|
||||
|
||||
// 6. 邀请码处理
|
||||
if (dto.getInviteCode() != null) {
|
||||
inviteService.handleInviteRegister(dto.getInviteCode(), user.getId());
|
||||
}
|
||||
|
||||
// 7. 生成自己的邀请码
|
||||
inviteService.generateInviteCode(user.getId());
|
||||
|
||||
// 8. 清除验证码
|
||||
redisTemplate.delete("captcha:sms:" + dto.getPhone());
|
||||
|
||||
return buildLoginVO(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginVO login(UserLoginDTO dto) {
|
||||
User user = userRepository.findByPhone(dto.getPhone())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
if ("banned".equals(user.getStatus())) throw new BusinessException(ErrorCode.USER_BANNED);
|
||||
if (!passwordEncoder.matches(dto.getPassword(), user.getPasswordHash()))
|
||||
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
|
||||
return buildLoginVO(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout(String token) {
|
||||
long remaining = jwtUtil.getExpiration(token);
|
||||
redisTemplate.opsForValue().set("user:token:" + token, "1", remaining, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserVO getCurrentUser(Long userId) {
|
||||
User user = userRepository.getById(userId);
|
||||
if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
return buildUserVO(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserVO updateProfile(Long userId, UserUpdateDTO dto) {
|
||||
User user = userRepository.getById(userId);
|
||||
if (dto.getNickname() != null) user.setNickname(dto.getNickname());
|
||||
if (dto.getAvatarUrl() != null) user.setAvatarUrl(dto.getAvatarUrl());
|
||||
userRepository.updateById(user);
|
||||
|
||||
UserProfile p = userProfileRepository.findByUserId(userId);
|
||||
if (dto.getGender() != null) p.setGender(dto.getGender());
|
||||
if (dto.getBirthday() != null) p.setBirthday(dto.getBirthday());
|
||||
if (dto.getCity() != null) p.setCity(dto.getCity());
|
||||
if (dto.getBio() != null) p.setBio(dto.getBio());
|
||||
userProfileRepository.updateById(p);
|
||||
return buildUserVO(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changePassword(Long userId, String oldPwd, String newPwd) {
|
||||
User user = userRepository.getById(userId);
|
||||
if (!passwordEncoder.matches(oldPwd, user.getPasswordHash()))
|
||||
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
|
||||
user.setPasswordHash(passwordEncoder.encode(newPwd));
|
||||
userRepository.updateById(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetPassword(String phone, String smsCode, String newPassword) {
|
||||
String cached = redisTemplate.opsForValue().get("captcha:sms:" + phone);
|
||||
if (!smsCode.equals(cached)) throw new BusinessException(ErrorCode.SMS_CODE_ERROR);
|
||||
User user = userRepository.findByPhone(phone)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
userRepository.updateById(user);
|
||||
redisTemplate.delete("captcha:sms:" + phone);
|
||||
}
|
||||
|
||||
private LoginVO buildLoginVO(User user) {
|
||||
LoginVO vo = new LoginVO();
|
||||
vo.setToken(jwtUtil.generateToken(user.getId()));
|
||||
vo.setUser(buildUserVO(user));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private UserVO buildUserVO(User user) {
|
||||
UserVO vo = new UserVO();
|
||||
vo.setId(user.getId());
|
||||
vo.setPhone(user.getPhone());
|
||||
vo.setNickname(user.getNickname());
|
||||
vo.setAvatarUrl(user.getAvatarUrl());
|
||||
vo.setMemberLevel(user.getMemberLevel());
|
||||
vo.setGrowthValue(user.getGrowthValue());
|
||||
vo.setCreatedAt(user.getCreatedAt());
|
||||
UserPoints pts = userPointsRepository.findByUserId(user.getId());
|
||||
if (pts != null) vo.setAvailablePoints(pts.getAvailablePoints());
|
||||
InviteCode ic = inviteCodeRepository.findByUserId(user.getId());
|
||||
if (ic != null) vo.setInviteCode(ic.getCode());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
374
后端架构设计/04-用户服务开发文档-part2.md
Normal file
374
后端架构设计/04-用户服务开发文档-part2.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# 用户服务开发文档 - Part 2(Controller + 通用工具)
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### UserController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.service.UserService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
/** 发送短信验证码(注册/找回密码用) */
|
||||
@PostMapping("/sms-code")
|
||||
public Result<Void> sendSmsCode(@RequestParam String phone) {
|
||||
userService.sendSmsCode(phone);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 用户注册 */
|
||||
@PostMapping("/register")
|
||||
public Result<LoginVO> register(@Valid @RequestBody UserRegisterDTO dto) {
|
||||
return Result.ok(userService.register(dto));
|
||||
}
|
||||
|
||||
/** 用户登录 */
|
||||
@PostMapping("/login")
|
||||
public Result<LoginVO> login(@Valid @RequestBody UserLoginDTO dto) {
|
||||
return Result.ok(userService.login(dto));
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
@PostMapping("/logout")
|
||||
public Result<Void> logout(@RequestHeader("Authorization") String authorization) {
|
||||
String token = authorization.replace("Bearer ", "");
|
||||
userService.logout(token);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
@GetMapping("/profile")
|
||||
public Result<UserVO> getProfile() {
|
||||
return Result.ok(userService.getCurrentUser(UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 更新个人信息 */
|
||||
@PutMapping("/profile")
|
||||
public Result<UserVO> updateProfile(@RequestBody UserUpdateDTO dto) {
|
||||
return Result.ok(userService.updateProfile(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 修改密码 */
|
||||
@PutMapping("/password")
|
||||
public Result<Void> changePassword(
|
||||
@RequestParam String oldPassword,
|
||||
@RequestParam String newPassword) {
|
||||
userService.changePassword(UserContext.getUserId(), oldPassword, newPassword);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 忘记密码 - 重置 */
|
||||
@PostMapping("/password/reset")
|
||||
public Result<Void> resetPassword(
|
||||
@RequestParam String phone,
|
||||
@RequestParam String smsCode,
|
||||
@RequestParam String newPassword) {
|
||||
userService.resetPassword(phone, smsCode, newPassword);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 六、通用工具类
|
||||
|
||||
### Result.java
|
||||
|
||||
```java
|
||||
package com.openclaw.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class Result<T> {
|
||||
private Integer code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Long timestamp = System.currentTimeMillis();
|
||||
|
||||
public static <T> Result<T> ok(T data) {
|
||||
Result<T> r = new Result<>();
|
||||
r.setCode(200);
|
||||
r.setMessage("success");
|
||||
r.setData(data);
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> Result<T> ok() { return ok(null); }
|
||||
|
||||
public static <T> Result<T> error(int code, String message) {
|
||||
Result<T> r = new Result<>();
|
||||
r.setCode(code);
|
||||
r.setMessage(message);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ErrorCode.java
|
||||
|
||||
```java
|
||||
package com.openclaw.constant;
|
||||
|
||||
public interface ErrorCode {
|
||||
// 用户模块 1xxx
|
||||
BusinessError USER_NOT_FOUND = new BusinessError(1001, "用户不存在");
|
||||
BusinessError PASSWORD_ERROR = new BusinessError(1002, "密码错误");
|
||||
BusinessError PHONE_ALREADY_EXISTS = new BusinessError(1003, "手机号已注册");
|
||||
BusinessError USER_BANNED = new BusinessError(1004, "账号已封禁");
|
||||
BusinessError SMS_CODE_ERROR = new BusinessError(1005, "验证码错误或已过期");
|
||||
|
||||
// Skill模块 2xxx
|
||||
BusinessError SKILL_NOT_FOUND = new BusinessError(2001, "Skill不存在");
|
||||
BusinessError SKILL_OFFLINE = new BusinessError(2002, "Skill已下架");
|
||||
BusinessError SKILL_ALREADY_OWNED = new BusinessError(2003, "已拥有该Skill");
|
||||
|
||||
// 积分模块 3xxx
|
||||
BusinessError POINTS_NOT_ENOUGH = new BusinessError(3001, "积分不足");
|
||||
BusinessError ALREADY_SIGNED_IN = new BusinessError(3002, "今日已签到");
|
||||
|
||||
// 订单模块 4xxx
|
||||
BusinessError ORDER_NOT_FOUND = new BusinessError(4001, "订单不存在");
|
||||
BusinessError ORDER_STATUS_ERROR = new BusinessError(4002, "订单状态异常");
|
||||
|
||||
// 支付模块 5xxx
|
||||
BusinessError PAYMENT_FAILED = new BusinessError(5001, "支付失败");
|
||||
BusinessError RECHARGE_NOT_FOUND = new BusinessError(5002, "充值订单不存在");
|
||||
|
||||
record BusinessError(int code, String message) {}
|
||||
}
|
||||
```
|
||||
|
||||
### BusinessException.java
|
||||
|
||||
```java
|
||||
package com.openclaw.exception;
|
||||
|
||||
import com.openclaw.constant.ErrorCode.BusinessError;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
private final int code;
|
||||
|
||||
public BusinessException(BusinessError error) {
|
||||
super(error.message());
|
||||
this.code = error.code();
|
||||
}
|
||||
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GlobalExceptionHandler.java
|
||||
|
||||
```java
|
||||
package com.openclaw.exception;
|
||||
|
||||
import com.openclaw.common.Result;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public Result<Void> handleBusiness(BusinessException e) {
|
||||
log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
|
||||
return Result.error(e.getCode(), e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(BindException.class)
|
||||
public Result<Void> handleValidation(BindException e) {
|
||||
String msg = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
|
||||
return Result.error(400, msg);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public Result<Void> handleException(Exception e) {
|
||||
log.error("系统异常", e);
|
||||
return Result.error(500, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JwtUtil.java
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.expiration:604800}")
|
||||
private long expiration; // 默认7天(秒)
|
||||
|
||||
private Key getKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||
}
|
||||
|
||||
public String generateToken(Long userId) {
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userId))
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
|
||||
.signWith(getKey(), SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Long getUserId(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getKey()).build()
|
||||
.parseClaimsJws(token).getBody();
|
||||
return Long.parseLong(claims.getSubject());
|
||||
}
|
||||
|
||||
public long getExpiration(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getKey()).build()
|
||||
.parseClaimsJws(token).getBody();
|
||||
long now = System.currentTimeMillis();
|
||||
return (claims.getExpiration().getTime() - now) / 1000;
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
Jwts.parserBuilder().setSigningKey(getKey()).build().parseClaimsJws(token);
|
||||
return true;
|
||||
} catch (JwtException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthInterceptor.java(JWT认证拦截器)
|
||||
|
||||
```java
|
||||
package com.openclaw.interceptor;
|
||||
|
||||
import com.openclaw.util.JwtUtil;
|
||||
import com.openclaw.util.UserContext;
|
||||
import jakarta.servlet.http.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
|
||||
String auth = req.getHeader("Authorization");
|
||||
if (!StringUtils.hasText(auth) || !auth.startsWith("Bearer ")) {
|
||||
res.setStatus(401);
|
||||
return false;
|
||||
}
|
||||
String token = auth.substring(7);
|
||||
// 检查 Token 是否在黑名单(已登出)
|
||||
if (Boolean.TRUE.equals(redisTemplate.hasKey("user:token:" + token))) {
|
||||
res.setStatus(401);
|
||||
return false;
|
||||
}
|
||||
if (!jwtUtil.isValid(token)) {
|
||||
res.setStatus(401);
|
||||
return false;
|
||||
}
|
||||
UserContext.setUserId(jwtUtil.getUserId(token));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object h, Exception ex) {
|
||||
UserContext.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UserContext.java
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
public class UserContext {
|
||||
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
|
||||
|
||||
public static void setUserId(Long userId) { USER_ID.set(userId); }
|
||||
public static Long getUserId() { return USER_ID.get(); }
|
||||
public static void clear() { USER_ID.remove(); }
|
||||
}
|
||||
```
|
||||
|
||||
### WebMvcConfig.java(注册拦截器)
|
||||
|
||||
```java
|
||||
package com.openclaw.config;
|
||||
|
||||
import com.openclaw.interceptor.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.*;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/v1/**")
|
||||
// 不需要登录的接口
|
||||
.excludePathPatterns(
|
||||
"/api/v1/users/sms-code",
|
||||
"/api/v1/users/register",
|
||||
"/api/v1/users/login",
|
||||
"/api/v1/users/password/reset",
|
||||
"/api/v1/skills", // Skill列表公开
|
||||
"/api/v1/skills/{id}", // Skill详情公开
|
||||
"/api/v1/skills/search", // 搜索公开
|
||||
"/api/v1/payments/callback" // 支付回调无token
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
432
后端架构设计/05-Skill服务开发文档.md
Normal file
432
后端架构设计/05-Skill服务开发文档.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Skill服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### Skill.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("skills")
|
||||
public class Skill {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long creatorId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String coverImageUrl;
|
||||
private Integer categoryId;
|
||||
private BigDecimal price;
|
||||
private Boolean isFree;
|
||||
private String status; // draft/pending/approved/rejected/offline
|
||||
private String rejectReason;
|
||||
private Long auditorId; // 审核人ID
|
||||
private LocalDateTime auditedAt; // 审核时间
|
||||
private Integer downloadCount;
|
||||
private BigDecimal rating;
|
||||
private Integer ratingCount;
|
||||
private String version;
|
||||
private Long fileSize;
|
||||
private String fileUrl;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
@TableLogic
|
||||
private LocalDateTime deletedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillCategory.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("skill_categories")
|
||||
public class SkillCategory {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private String name;
|
||||
private Integer parentId;
|
||||
private String iconUrl;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillReview.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("skill_reviews")
|
||||
public class SkillReview {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long skillId;
|
||||
private Long userId;
|
||||
private Long orderId;
|
||||
private Integer rating;
|
||||
private String content;
|
||||
private String images; // JSON字符串
|
||||
private Integer helpfulCount;
|
||||
private String status; // pending/approved/rejected
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### SkillQueryDTO.java(列表查询参数)
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SkillQueryDTO {
|
||||
private Integer categoryId; // 分类筛选
|
||||
private String keyword; // 关键词搜索
|
||||
private Boolean isFree; // 是否免费
|
||||
private String sort; // newest/hottest/rating/price_asc/price_desc
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 10;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillCreateDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class SkillCreateDTO {
|
||||
@NotBlank(message = "Skill名称不能为空")
|
||||
private String name;
|
||||
|
||||
private String description;
|
||||
private String coverImageUrl; // 腾讯云COS URL
|
||||
|
||||
@NotNull(message = "分类不能为空")
|
||||
private Integer categoryId;
|
||||
|
||||
private BigDecimal price = BigDecimal.ZERO;
|
||||
private Boolean isFree = false;
|
||||
private String version;
|
||||
private String fileUrl; // 腾讯云COS URL
|
||||
private Long fileSize;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillReviewDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class SkillReviewDTO {
|
||||
@NotNull @Min(1) @Max(5)
|
||||
private Integer rating;
|
||||
|
||||
private String content;
|
||||
private List<String> images; // 腾讯云COS URL列表
|
||||
}
|
||||
```
|
||||
|
||||
### SkillVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class SkillVO {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String coverImageUrl;
|
||||
private Integer categoryId;
|
||||
private String categoryName;
|
||||
private BigDecimal price;
|
||||
private Boolean isFree;
|
||||
private Integer downloadCount;
|
||||
private BigDecimal rating;
|
||||
private Integer ratingCount;
|
||||
private String version;
|
||||
private Long fileSize;
|
||||
private String creatorNickname;
|
||||
private Boolean owned; // 当前用户是否已拥有
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、Service 接口
|
||||
|
||||
### SkillService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface SkillService {
|
||||
IPage<SkillVO> listSkills(SkillQueryDTO query, Long currentUserId);
|
||||
SkillVO getSkillDetail(Long skillId, Long currentUserId);
|
||||
SkillVO createSkill(Long userId, SkillCreateDTO dto);
|
||||
void submitReview(Long skillId, Long userId, SkillReviewDTO dto);
|
||||
boolean hasOwned(Long userId, Long skillId);
|
||||
void grantAccess(Long userId, Long skillId, Long orderId, String type);
|
||||
}
|
||||
```
|
||||
|
||||
## 四、Service 实现
|
||||
|
||||
### SkillServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.SkillService;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SkillServiceImpl implements SkillService {
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillCategoryRepository categoryRepository;
|
||||
private final SkillReviewRepository reviewRepository;
|
||||
private final SkillDownloadRepository downloadRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public IPage<SkillVO> listSkills(SkillQueryDTO query, Long currentUserId) {
|
||||
Page<Skill> page = new Page<>(query.getPageNum(), query.getPageSize());
|
||||
LambdaQueryWrapper<Skill> wrapper = new LambdaQueryWrapper<>()
|
||||
.eq(Skill::getStatus, "approved")
|
||||
.eq(query.getCategoryId() != null, Skill::getCategoryId, query.getCategoryId())
|
||||
.eq(query.getIsFree() != null, Skill::getIsFree, query.getIsFree())
|
||||
.and(query.getKeyword() != null, w ->
|
||||
w.like(Skill::getName, query.getKeyword())
|
||||
.or().like(Skill::getDescription, query.getKeyword()));
|
||||
|
||||
// 排序
|
||||
switch (query.getSort() == null ? "newest" : query.getSort()) {
|
||||
case "hottest" -> wrapper.orderByDesc(Skill::getDownloadCount);
|
||||
case "rating" -> wrapper.orderByDesc(Skill::getRating);
|
||||
case "price_asc" -> wrapper.orderByAsc(Skill::getPrice);
|
||||
case "price_desc" -> wrapper.orderByDesc(Skill::getPrice);
|
||||
default -> wrapper.orderByDesc(Skill::getCreatedAt);
|
||||
}
|
||||
|
||||
IPage<Skill> result = skillRepository.selectPage(page, wrapper);
|
||||
return result.convert(skill -> toVO(skill, currentUserId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SkillVO getSkillDetail(Long skillId, Long currentUserId) {
|
||||
Skill skill = skillRepository.selectById(skillId);
|
||||
if (skill == null || "offline".equals(skill.getStatus()))
|
||||
throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
return toVO(skill, currentUserId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public SkillVO createSkill(Long userId, SkillCreateDTO dto) {
|
||||
Skill skill = new Skill();
|
||||
skill.setCreatorId(userId);
|
||||
skill.setName(dto.getName());
|
||||
skill.setDescription(dto.getDescription());
|
||||
skill.setCoverImageUrl(dto.getCoverImageUrl());
|
||||
skill.setCategoryId(dto.getCategoryId());
|
||||
skill.setPrice(dto.getPrice());
|
||||
skill.setIsFree(dto.getIsFree());
|
||||
skill.setVersion(dto.getVersion());
|
||||
skill.setFileUrl(dto.getFileUrl());
|
||||
skill.setFileSize(dto.getFileSize());
|
||||
skill.setStatus("pending"); // 提交审核
|
||||
skill.setDownloadCount(0);
|
||||
skillRepository.insert(skill);
|
||||
return toVO(skill, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void submitReview(Long skillId, Long userId, SkillReviewDTO dto) {
|
||||
// 检查是否已购买
|
||||
if (!hasOwned(userId, skillId)) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
|
||||
SkillReview review = new SkillReview();
|
||||
review.setSkillId(skillId);
|
||||
review.setUserId(userId);
|
||||
review.setRating(dto.getRating());
|
||||
review.setContent(dto.getContent());
|
||||
if (dto.getImages() != null) {
|
||||
review.setImages(dto.getImages().toString());
|
||||
}
|
||||
review.setStatus("approved");
|
||||
reviewRepository.insert(review);
|
||||
|
||||
// 更新Skill平均评分
|
||||
updateSkillRating(skillId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasOwned(Long userId, Long skillId) {
|
||||
if (userId == null) return false;
|
||||
return downloadRepository.selectCount(
|
||||
new LambdaQueryWrapper<SkillDownload>()
|
||||
.eq(SkillDownload::getUserId, userId)
|
||||
.eq(SkillDownload::getSkillId, skillId)) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void grantAccess(Long userId, Long skillId, Long orderId, String type) {
|
||||
SkillDownload d = new SkillDownload();
|
||||
d.setUserId(userId);
|
||||
d.setSkillId(skillId);
|
||||
d.setOrderId(orderId);
|
||||
d.setDownloadType(type);
|
||||
downloadRepository.insert(d);
|
||||
// 更新下载次数
|
||||
skillRepository.incrementDownloadCount(skillId);
|
||||
}
|
||||
|
||||
private void updateSkillRating(Long skillId) {
|
||||
// 重新计算平均分
|
||||
Double avg = reviewRepository.avgRatingBySkillId(skillId);
|
||||
Integer cnt = reviewRepository.countBySkillId(skillId);
|
||||
if (avg != null) {
|
||||
skillRepository.updateRating(skillId,
|
||||
java.math.BigDecimal.valueOf(avg).setScale(2, java.math.RoundingMode.HALF_UP), cnt);
|
||||
}
|
||||
}
|
||||
|
||||
private SkillVO toVO(Skill skill, Long currentUserId) {
|
||||
SkillVO vo = new SkillVO();
|
||||
vo.setId(skill.getId());
|
||||
vo.setName(skill.getName());
|
||||
vo.setDescription(skill.getDescription());
|
||||
vo.setCoverImageUrl(skill.getCoverImageUrl());
|
||||
vo.setCategoryId(skill.getCategoryId());
|
||||
vo.setPrice(skill.getPrice());
|
||||
vo.setIsFree(skill.getIsFree());
|
||||
vo.setDownloadCount(skill.getDownloadCount());
|
||||
vo.setRating(skill.getRating());
|
||||
vo.setRatingCount(skill.getRatingCount());
|
||||
vo.setVersion(skill.getVersion());
|
||||
vo.setFileSize(skill.getFileSize());
|
||||
vo.setCreatedAt(skill.getCreatedAt());
|
||||
// 分类名
|
||||
SkillCategory cat = categoryRepository.selectById(skill.getCategoryId());
|
||||
if (cat != null) vo.setCategoryName(cat.getName());
|
||||
// 创建者昵称
|
||||
User creator = userRepository.selectById(skill.getCreatorId());
|
||||
if (creator != null) vo.setCreatorNickname(creator.getNickname());
|
||||
// 是否已拥有
|
||||
vo.setOwned(hasOwned(currentUserId, skill.getId()));
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### SkillController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.service.SkillService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills")
|
||||
@RequiredArgsConstructor
|
||||
public class SkillController {
|
||||
|
||||
private final SkillService skillService;
|
||||
|
||||
/** Skill列表(公开,支持分页/筛选/排序) */
|
||||
@GetMapping
|
||||
public Result<IPage<SkillVO>> listSkills(SkillQueryDTO query) {
|
||||
Long userId = UserContext.getUserId(); // 未登录为null
|
||||
return Result.ok(skillService.listSkills(query, userId));
|
||||
}
|
||||
|
||||
/** Skill详情(公开) */
|
||||
@GetMapping("/{id}")
|
||||
public Result<SkillVO> getDetail(@PathVariable Long id) {
|
||||
return Result.ok(skillService.getSkillDetail(id, UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 上传Skill(需登录) */
|
||||
@PostMapping
|
||||
public Result<SkillVO> createSkill(@Valid @RequestBody SkillCreateDTO dto) {
|
||||
return Result.ok(skillService.createSkill(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 发表评价(需登录且已拥有) */
|
||||
@PostMapping("/{id}/reviews")
|
||||
public Result<Void> submitReview(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody SkillReviewDTO dto) {
|
||||
skillService.submitReview(id, UserContext.getUserId(), dto);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
431
后端架构设计/06-积分服务开发文档.md
Normal file
431
后端架构设计/06-积分服务开发文档.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# 积分服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### UserPoints.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("user_points")
|
||||
public class UserPoints {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Integer availablePoints;
|
||||
private Integer frozenPoints;
|
||||
private Integer totalEarned;
|
||||
private Integer totalConsumed;
|
||||
private LocalDate lastSignInDate;
|
||||
private Integer signInStreak;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### PointsRecord.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("points_records")
|
||||
public class PointsRecord {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String pointsType; // earn / consume / freeze / unfreeze
|
||||
private String source; // register/sign_in/invite/...
|
||||
private Integer amount;
|
||||
private Integer balance;
|
||||
private String description;
|
||||
private Long relatedId;
|
||||
private String relatedType;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
### PointsRule.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("points_rules")
|
||||
public class PointsRule {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private String ruleName;
|
||||
private String source;
|
||||
private Integer pointsAmount;
|
||||
private Integer frequencyLimit;
|
||||
private String frequencyPeriod; // daily/weekly/monthly/unlimited
|
||||
private Boolean enabled;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、VO
|
||||
|
||||
### PointsBalanceVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class PointsBalanceVO {
|
||||
private Integer availablePoints;
|
||||
private Integer frozenPoints;
|
||||
private Integer totalEarned;
|
||||
private Integer totalConsumed;
|
||||
private LocalDate lastSignInDate;
|
||||
private Integer signInStreak;
|
||||
private Boolean signedInToday; // 今日是否已签到
|
||||
}
|
||||
```
|
||||
|
||||
### PointsRecordVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class PointsRecordVO {
|
||||
private Long id;
|
||||
private String pointsType;
|
||||
private String source;
|
||||
private String sourceLabel; // 中文描述
|
||||
private Integer amount;
|
||||
private Integer balance;
|
||||
private String description;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、Service 接口
|
||||
|
||||
### PointsService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface PointsService {
|
||||
/** 初始化用户积分账户(注册时调用) */
|
||||
void initUserPoints(Long userId);
|
||||
|
||||
/** 获取积分余额 */
|
||||
PointsBalanceVO getBalance(Long userId);
|
||||
|
||||
/** 获取积分流水(分页) */
|
||||
IPage<PointsRecordVO> getRecords(Long userId, int pageNum, int pageSize);
|
||||
|
||||
/** 每日签到 */
|
||||
int signIn(Long userId);
|
||||
|
||||
/** 按规则发放积分(注册/邀请/加群/评价等) */
|
||||
void earnPoints(Long userId, String source, Long relatedId, String relatedType);
|
||||
|
||||
/** 消耗积分(购买Skill) */
|
||||
void consumePoints(Long userId, int amount, Long relatedId, String relatedType);
|
||||
|
||||
/** 冻结积分(下单时) */
|
||||
void freezePoints(Long userId, int amount, Long orderId);
|
||||
|
||||
/** 解冻积分(取消订单时) */
|
||||
void unfreezePoints(Long userId, int amount, Long orderId);
|
||||
|
||||
/** 检查积分是否充足 */
|
||||
boolean hasEnoughPoints(Long userId, int required);
|
||||
}
|
||||
```
|
||||
|
||||
## 四、Service 实现
|
||||
|
||||
### PointsServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.PointsService;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PointsServiceImpl implements PointsService {
|
||||
|
||||
private final UserPointsRepository userPointsRepo;
|
||||
private final PointsRecordRepository recordRepo;
|
||||
private final PointsRuleRepository ruleRepo;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void initUserPoints(Long userId) {
|
||||
UserPoints up = new UserPoints();
|
||||
up.setUserId(userId);
|
||||
up.setAvailablePoints(0);
|
||||
up.setFrozenPoints(0);
|
||||
up.setTotalEarned(0);
|
||||
up.setTotalConsumed(0);
|
||||
up.setSignInStreak(0);
|
||||
userPointsRepo.insert(up);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PointsBalanceVO getBalance(Long userId) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
PointsBalanceVO vo = new PointsBalanceVO();
|
||||
if (up == null) return vo;
|
||||
vo.setAvailablePoints(up.getAvailablePoints());
|
||||
vo.setFrozenPoints(up.getFrozenPoints());
|
||||
vo.setTotalEarned(up.getTotalEarned());
|
||||
vo.setTotalConsumed(up.getTotalConsumed());
|
||||
vo.setLastSignInDate(up.getLastSignInDate());
|
||||
vo.setSignInStreak(up.getSignInStreak());
|
||||
vo.setSignedInToday(LocalDate.now().equals(up.getLastSignInDate()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<PointsRecordVO> getRecords(Long userId, int pageNum, int pageSize) {
|
||||
Page<PointsRecord> page = new Page<>(pageNum, pageSize);
|
||||
IPage<PointsRecord> result = recordRepo.selectPage(page,
|
||||
new LambdaQueryWrapper<PointsRecord>()
|
||||
.eq(PointsRecord::getUserId, userId)
|
||||
.orderByDesc(PointsRecord::getCreatedAt));
|
||||
return result.convert(this::toRecordVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int signIn(Long userId) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
// 今日已签到
|
||||
if (today.equals(up.getLastSignInDate())) {
|
||||
throw new BusinessException(ErrorCode.ALREADY_SIGNED_IN);
|
||||
}
|
||||
|
||||
// 计算连续签到天数
|
||||
boolean consecutive = up.getLastSignInDate() != null &&
|
||||
today.minusDays(1).equals(up.getLastSignInDate());
|
||||
int streak = consecutive ? up.getSignInStreak() + 1 : 1;
|
||||
|
||||
// 签到积分:连续签到递增,最高20分
|
||||
int points = Math.min(5 + (streak - 1) * 1, 20);
|
||||
|
||||
up.setLastSignInDate(today);
|
||||
up.setSignInStreak(streak);
|
||||
userPointsRepo.updateById(up);
|
||||
|
||||
addPoints(userId, "earn", "sign_in", points, points, "每日签到", null, null);
|
||||
return points;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void earnPoints(Long userId, String source, Long relatedId, String relatedType) {
|
||||
PointsRule rule = ruleRepo.findBySource(source);
|
||||
if (rule == null || !rule.getEnabled()) return;
|
||||
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
int newBalance = up.getAvailablePoints() + rule.getPointsAmount();
|
||||
addPoints(userId, "earn", source, rule.getPointsAmount(), newBalance,
|
||||
rule.getRuleName(), relatedId, relatedType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void consumePoints(Long userId, int amount, Long relatedId, String relatedType) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
if (up.getAvailablePoints() < amount) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||
int newBalance = up.getAvailablePoints() - amount;
|
||||
addPoints(userId, "consume", "skill_purchase", -amount, newBalance,
|
||||
"兑换Skill", relatedId, relatedType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void freezePoints(Long userId, int amount, Long orderId) {
|
||||
userPointsRepo.freezePoints(userId, amount);
|
||||
addPoints(userId, "freeze", "skill_purchase", -amount,
|
||||
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||
"积分冻结-订单" + orderId, orderId, "order");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void unfreezePoints(Long userId, int amount, Long orderId) {
|
||||
userPointsRepo.unfreezePoints(userId, amount);
|
||||
addPoints(userId, "unfreeze", "skill_purchase", amount,
|
||||
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||
"积分解冻-订单取消" + orderId, orderId, "order");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasEnoughPoints(Long userId, int required) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
return up != null && up.getAvailablePoints() >= required;
|
||||
}
|
||||
|
||||
private void addPoints(Long userId, String type, String source, int amount,
|
||||
int balance, String desc, Long relatedId, String relatedType) {
|
||||
// 更新账户
|
||||
if ("earn".equals(type)) {
|
||||
userPointsRepo.addAvailablePoints(userId, amount);
|
||||
} else if ("consume".equals(type)) {
|
||||
userPointsRepo.addAvailablePoints(userId, amount); // amount为负数
|
||||
userPointsRepo.addTotalConsumed(userId, -amount);
|
||||
}
|
||||
userPointsRepo.addTotalEarned(userId, "earn".equals(type) ? amount : 0);
|
||||
|
||||
// 记录流水
|
||||
PointsRecord r = new PointsRecord();
|
||||
r.setUserId(userId);
|
||||
r.setPointsType(type);
|
||||
r.setSource(source);
|
||||
r.setAmount(amount);
|
||||
r.setBalance(balance);
|
||||
r.setDescription(desc);
|
||||
r.setRelatedId(relatedId);
|
||||
r.setRelatedType(relatedType);
|
||||
recordRepo.insert(r);
|
||||
}
|
||||
|
||||
private PointsRecordVO toRecordVO(PointsRecord r) {
|
||||
PointsRecordVO vo = new PointsRecordVO();
|
||||
vo.setId(r.getId());
|
||||
vo.setPointsType(r.getPointsType());
|
||||
vo.setSource(r.getSource());
|
||||
vo.setSourceLabel(getSourceLabel(r.getSource()));
|
||||
vo.setAmount(r.getAmount());
|
||||
vo.setBalance(r.getBalance());
|
||||
vo.setDescription(r.getDescription());
|
||||
vo.setCreatedAt(r.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void addPointsDirectly(Long userId, int amount, String source,
|
||||
Long relatedId, String desc) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
if (up == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
int newBalance = up.getAvailablePoints() + amount;
|
||||
if (newBalance < 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||
String type = amount >= 0 ? "earn" : "consume";
|
||||
addPoints(userId, type, source, amount, newBalance, desc, relatedId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureNotNegative(Long userId) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
if (up != null && up.getAvailablePoints() < 0) {
|
||||
// 强制归零,记录一条修正流水
|
||||
int diff = -up.getAvailablePoints();
|
||||
addPoints(userId, "admin_correct", "admin_adjust", diff, 0,
|
||||
"积分余额修正(防负)", null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private String getSourceLabel(String source) {
|
||||
return switch (source) {
|
||||
case "register" -> "新用户注册";
|
||||
case "sign_in" -> "每日签到";
|
||||
case "invite" -> "邀请好友";
|
||||
case "join_community" -> "加入社群";
|
||||
case "recharge" -> "充值赠送";
|
||||
case "skill_purchase" -> "兑换Skill";
|
||||
case "review" -> "发表评价";
|
||||
case "activity" -> "活动奖励";
|
||||
case "admin_adjust" -> "管理员调整";
|
||||
default -> source;
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### PointsController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.service.PointsService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/points")
|
||||
@RequiredArgsConstructor
|
||||
public class PointsController {
|
||||
|
||||
private final PointsService pointsService;
|
||||
|
||||
/** 获取积分余额 */
|
||||
@GetMapping("/balance")
|
||||
public Result<PointsBalanceVO> getBalance() {
|
||||
return Result.ok(pointsService.getBalance(UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 获取积分流水 */
|
||||
@GetMapping("/records")
|
||||
public Result<IPage<PointsRecordVO>> getRecords(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return Result.ok(pointsService.getRecords(UserContext.getUserId(), pageNum, pageSize));
|
||||
}
|
||||
|
||||
/** 每日签到 */
|
||||
@PostMapping("/sign-in")
|
||||
public Result<Integer> signIn() {
|
||||
int earned = pointsService.signIn(UserContext.getUserId());
|
||||
return Result.ok(earned);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
238
后端架构设计/07-订单服务开发文档-part1.md
Normal file
238
后端架构设计/07-订单服务开发文档-part1.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# 订单服务开发文档 - Part 1(Entity + DTO + Service接口)
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### Order.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("orders")
|
||||
public class Order {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String orderNo;
|
||||
private Long userId;
|
||||
private BigDecimal totalAmount;
|
||||
private BigDecimal cashAmount;
|
||||
private Integer pointsUsed;
|
||||
private BigDecimal pointsDeductAmount;
|
||||
private String status; // pending/paid/completed/cancelled/refunding/refunded
|
||||
private String paymentMethod; // wechat/alipay/points/mixed
|
||||
private String remark;
|
||||
private String cancelReason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime paidAt;
|
||||
private LocalDateTime expiredAt;
|
||||
}
|
||||
```
|
||||
|
||||
### OrderItem.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("order_items")
|
||||
public class OrderItem {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long orderId;
|
||||
private Long skillId;
|
||||
private String skillName; // 下单时快照
|
||||
private String skillCover; // 下单时快照
|
||||
private BigDecimal unitPrice;
|
||||
private Integer quantity;
|
||||
private BigDecimal totalPrice;
|
||||
}
|
||||
```
|
||||
|
||||
### OrderRefund.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("order_refunds")
|
||||
public class OrderRefund {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long orderId;
|
||||
private String refundNo;
|
||||
private BigDecimal refundAmount;
|
||||
private Integer refundPoints;
|
||||
private String reason;
|
||||
private String images; // JSON
|
||||
private String status; // pending/approved/rejected/completed
|
||||
private String rejectReason;
|
||||
private Long operatorId; // 处理人ID
|
||||
private LocalDateTime processedAt; // 处理时间
|
||||
private String remark; // 处理备注
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### OrderCreateDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class OrderCreateDTO {
|
||||
@NotEmpty(message = "请选择要购买的Skill")
|
||||
private List<Long> skillIds;
|
||||
private Integer pointsToUse = 0; // 使用积分数
|
||||
private String paymentMethod; // wechat/alipay/points/mixed
|
||||
}
|
||||
```
|
||||
|
||||
### RefundApplyDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class RefundApplyDTO {
|
||||
@NotBlank(message = "请填写退款原因")
|
||||
private String reason;
|
||||
private List<String> images; // 腾讯云COS URL
|
||||
}
|
||||
```
|
||||
|
||||
### OrderVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class OrderVO {
|
||||
private Long id;
|
||||
private String orderNo;
|
||||
private BigDecimal totalAmount;
|
||||
private BigDecimal cashAmount;
|
||||
private Integer pointsUsed;
|
||||
private BigDecimal pointsDeductAmount;
|
||||
private String status;
|
||||
private String statusLabel; // 中文状态
|
||||
private String paymentMethod;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime paidAt;
|
||||
private List<OrderItemVO> items;
|
||||
}
|
||||
```
|
||||
|
||||
### OrderItemVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class OrderItemVO {
|
||||
private Long skillId;
|
||||
private String skillName;
|
||||
private String skillCover;
|
||||
private BigDecimal unitPrice;
|
||||
private Integer quantity;
|
||||
private BigDecimal totalPrice;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、Service 接口
|
||||
|
||||
### OrderService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface OrderService {
|
||||
/** 创建订单(含积分抵扣计算) */
|
||||
OrderVO createOrder(Long userId, OrderCreateDTO dto);
|
||||
|
||||
/** 订单详情 */
|
||||
OrderVO getOrderDetail(Long orderId, Long userId);
|
||||
|
||||
/** 订单列表(分页) */
|
||||
IPage<OrderVO> listOrders(Long userId, String status, int pageNum, int pageSize);
|
||||
|
||||
/** 取消订单 */
|
||||
void cancelOrder(Long orderId, Long userId, String reason);
|
||||
|
||||
/** 支付成功回调处理 */
|
||||
void handlePaySuccess(String orderNo, String transactionId);
|
||||
|
||||
/** 申请退款 */
|
||||
void applyRefund(Long orderId, Long userId, RefundApplyDTO dto);
|
||||
}
|
||||
```
|
||||
|
||||
## 四、IdGenerator 工具类
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Component
|
||||
public class IdGenerator {
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private final AtomicInteger seq = new AtomicInteger(1000);
|
||||
|
||||
public String generateOrderNo() {
|
||||
return LocalDateTime.now().format(FMT) + seq.incrementAndGet();
|
||||
}
|
||||
|
||||
public String generateRefundNo() {
|
||||
return "R" + LocalDateTime.now().format(FMT) + seq.incrementAndGet();
|
||||
}
|
||||
|
||||
public String generateRechargeNo() {
|
||||
return "RC" + LocalDateTime.now().format(FMT) + seq.incrementAndGet();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
288
后端架构设计/07-订单服务开发文档-part2.md
Normal file
288
后端架构设计/07-订单服务开发文档-part2.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 订单服务开发文档 - Part 2(Service实现 + Controller)
|
||||
|
||||
## 四、Service 实现
|
||||
|
||||
### OrderServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.*;
|
||||
import com.openclaw.util.IdGenerator;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderServiceImpl implements OrderService {
|
||||
|
||||
private final OrderRepository orderRepo;
|
||||
private final OrderItemRepository itemRepo;
|
||||
private final OrderRefundRepository refundRepo;
|
||||
private final SkillRepository skillRepo;
|
||||
private final SkillDownloadRepository downloadRepo;
|
||||
private final PointsService pointsService;
|
||||
private final IdGenerator idGenerator;
|
||||
|
||||
private static final BigDecimal POINTS_RATE = new BigDecimal("0.01"); // 1积分=0.01元
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OrderVO createOrder(Long userId, OrderCreateDTO dto) {
|
||||
List<Skill> skills = new ArrayList<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
|
||||
for (Long skillId : dto.getSkillIds()) {
|
||||
Skill skill = skillRepo.selectById(skillId);
|
||||
if (skill == null || !"approved".equals(skill.getStatus()))
|
||||
throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
if (downloadRepo.existsByUserIdAndSkillId(userId, skillId))
|
||||
throw new BusinessException(ErrorCode.SKILL_ALREADY_OWNED);
|
||||
skills.add(skill);
|
||||
total = total.add(Boolean.TRUE.equals(skill.getIsFree()) ? BigDecimal.ZERO : skill.getPrice());
|
||||
}
|
||||
|
||||
// 积分抵扣计算
|
||||
int pts = dto.getPointsToUse() == null ? 0 : dto.getPointsToUse();
|
||||
BigDecimal ptsDed = BigDecimal.ZERO;
|
||||
if (pts > 0) {
|
||||
if (!pointsService.hasEnoughPoints(userId, pts))
|
||||
throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||
ptsDed = new BigDecimal(pts).multiply(POINTS_RATE).min(total);
|
||||
}
|
||||
BigDecimal cash = total.subtract(ptsDed);
|
||||
|
||||
// 创建订单主记录
|
||||
Order order = new Order();
|
||||
order.setOrderNo(idGenerator.generateOrderNo());
|
||||
order.setUserId(userId);
|
||||
order.setTotalAmount(total);
|
||||
order.setCashAmount(cash);
|
||||
order.setPointsUsed(pts);
|
||||
order.setPointsDeductAmount(ptsDed);
|
||||
order.setStatus("pending");
|
||||
order.setPaymentMethod(dto.getPaymentMethod());
|
||||
order.setExpiredAt(LocalDateTime.now().plusMinutes(30));
|
||||
orderRepo.insert(order);
|
||||
|
||||
// 创建订单项(快照商品信息)
|
||||
for (Skill s : skills) {
|
||||
OrderItem item = new OrderItem();
|
||||
item.setOrderId(order.getId());
|
||||
item.setSkillId(s.getId());
|
||||
item.setSkillName(s.getName());
|
||||
item.setSkillCover(s.getCoverImageUrl());
|
||||
BigDecimal price = Boolean.TRUE.equals(s.getIsFree()) ? BigDecimal.ZERO : s.getPrice();
|
||||
item.setUnitPrice(price);
|
||||
item.setQuantity(1);
|
||||
item.setTotalPrice(price);
|
||||
itemRepo.insert(item);
|
||||
}
|
||||
|
||||
// 冻结积分
|
||||
if (pts > 0) pointsService.freezePoints(userId, pts, order.getId());
|
||||
|
||||
// 纯免费/纯积分支付直接完成,无需拉起支付
|
||||
if (cash.compareTo(BigDecimal.ZERO) == 0) {
|
||||
handlePaySuccess(order.getOrderNo(), null);
|
||||
}
|
||||
|
||||
return buildOrderVO(order);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrderVO getOrderDetail(Long orderId, Long userId) {
|
||||
Order order = orderRepo.selectById(orderId);
|
||||
if (order == null || !order.getUserId().equals(userId))
|
||||
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
return buildOrderVO(order);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<OrderVO> listOrders(Long userId, String status, int pageNum, int pageSize) {
|
||||
IPage<Order> page = orderRepo.selectPage(
|
||||
new Page<>(pageNum, pageSize),
|
||||
new LambdaQueryWrapper<Order>()
|
||||
.eq(Order::getUserId, userId)
|
||||
.eq(status != null, Order::getStatus, status)
|
||||
.orderByDesc(Order::getCreatedAt));
|
||||
return page.convert(this::buildOrderVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void cancelOrder(Long orderId, Long userId, String reason) {
|
||||
Order order = orderRepo.selectById(orderId);
|
||||
if (order == null || !order.getUserId().equals(userId))
|
||||
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
if (!"pending".equals(order.getStatus()))
|
||||
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
||||
order.setStatus("cancelled");
|
||||
order.setCancelReason(reason);
|
||||
orderRepo.updateById(order);
|
||||
// 解冻积分
|
||||
if (order.getPointsUsed() != null && order.getPointsUsed() > 0)
|
||||
pointsService.unfreezePoints(userId, order.getPointsUsed(), orderId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handlePaySuccess(String orderNo, String transactionId) {
|
||||
Order order = orderRepo.findByOrderNo(orderNo);
|
||||
if (order == null) throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
if ("paid".equals(order.getStatus())) return; // 幂等处理
|
||||
|
||||
order.setStatus("paid");
|
||||
order.setPaidAt(LocalDateTime.now());
|
||||
orderRepo.updateById(order);
|
||||
|
||||
// 消耗冻结积分(正式扣减)
|
||||
if (order.getPointsUsed() != null && order.getPointsUsed() > 0)
|
||||
pointsService.consumePoints(order.getUserId(), order.getPointsUsed(), order.getId(), "order");
|
||||
|
||||
// 授权Skill访问权限
|
||||
String dlType = (order.getPointsUsed() != null && order.getPointsUsed() > 0
|
||||
&& order.getCashAmount().compareTo(BigDecimal.ZERO) == 0) ? "points" : "paid";
|
||||
itemRepo.findByOrderId(order.getId()).forEach(item ->
|
||||
downloadRepo.grantAccess(order.getUserId(), item.getSkillId(), order.getId(), dlType));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void applyRefund(Long orderId, Long userId, RefundApplyDTO dto) {
|
||||
Order order = orderRepo.selectById(orderId);
|
||||
if (order == null || !order.getUserId().equals(userId))
|
||||
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
if (!"paid".equals(order.getStatus()) && !"completed".equals(order.getStatus()))
|
||||
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
||||
order.setStatus("refunding");
|
||||
orderRepo.updateById(order);
|
||||
|
||||
OrderRefund refund = new OrderRefund();
|
||||
refund.setOrderId(orderId);
|
||||
refund.setRefundNo(idGenerator.generateRefundNo());
|
||||
refund.setRefundAmount(order.getCashAmount());
|
||||
refund.setRefundPoints(order.getPointsUsed());
|
||||
refund.setReason(dto.getReason());
|
||||
if (dto.getImages() != null) refund.setImages(dto.getImages().toString());
|
||||
refund.setStatus("pending");
|
||||
refundRepo.insert(refund);
|
||||
}
|
||||
|
||||
private OrderVO buildOrderVO(Order order) {
|
||||
OrderVO vo = new OrderVO();
|
||||
vo.setId(order.getId());
|
||||
vo.setOrderNo(order.getOrderNo());
|
||||
vo.setTotalAmount(order.getTotalAmount());
|
||||
vo.setCashAmount(order.getCashAmount());
|
||||
vo.setPointsUsed(order.getPointsUsed());
|
||||
vo.setPointsDeductAmount(order.getPointsDeductAmount());
|
||||
vo.setStatus(order.getStatus());
|
||||
vo.setStatusLabel(switch (order.getStatus()) {
|
||||
case "pending" -> "待支付";
|
||||
case "paid" -> "已支付";
|
||||
case "completed" -> "已完成";
|
||||
case "cancelled" -> "已取消";
|
||||
case "refunding" -> "退款中";
|
||||
case "refunded" -> "已退款";
|
||||
default -> order.getStatus();
|
||||
});
|
||||
vo.setPaymentMethod(order.getPaymentMethod());
|
||||
vo.setCreatedAt(order.getCreatedAt());
|
||||
vo.setPaidAt(order.getPaidAt());
|
||||
vo.setItems(itemRepo.findByOrderId(order.getId()).stream().map(i -> {
|
||||
OrderItemVO iv = new OrderItemVO();
|
||||
iv.setSkillId(i.getSkillId());
|
||||
iv.setSkillName(i.getSkillName());
|
||||
iv.setSkillCover(i.getSkillCover());
|
||||
iv.setUnitPrice(i.getUnitPrice());
|
||||
iv.setQuantity(i.getQuantity());
|
||||
iv.setTotalPrice(i.getTotalPrice());
|
||||
return iv;
|
||||
}).toList());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### OrderController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.service.OrderService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/orders")
|
||||
@RequiredArgsConstructor
|
||||
public class OrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
|
||||
/** 创建订单 */
|
||||
@PostMapping
|
||||
public Result<OrderVO> createOrder(@Valid @RequestBody OrderCreateDTO dto) {
|
||||
return Result.ok(orderService.createOrder(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 订单详情 */
|
||||
@GetMapping("/{id}")
|
||||
public Result<OrderVO> getDetail(@PathVariable Long id) {
|
||||
return Result.ok(orderService.getOrderDetail(id, UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 订单列表 */
|
||||
@GetMapping
|
||||
public Result<IPage<OrderVO>> listOrders(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return Result.ok(orderService.listOrders(UserContext.getUserId(), status, pageNum, pageSize));
|
||||
}
|
||||
|
||||
/** 取消订单 */
|
||||
@PutMapping("/{id}/cancel")
|
||||
public Result<Void> cancelOrder(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false) String reason) {
|
||||
orderService.cancelOrder(id, UserContext.getUserId(), reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 申请退款 */
|
||||
@PostMapping("/{id}/refund")
|
||||
public Result<Void> applyRefund(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody RefundApplyDTO dto) {
|
||||
orderService.applyRefund(id, UserContext.getUserId(), dto);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
397
后端架构设计/08-支付服务开发文档.md
Normal file
397
后端架构设计/08-支付服务开发文档.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# 支付服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### RechargeOrder.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("recharge_orders")
|
||||
public class RechargeOrder {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String rechargeNo;
|
||||
private Long userId;
|
||||
private BigDecimal amount;
|
||||
private Integer bonusPoints;
|
||||
private Integer totalPoints;
|
||||
private String paymentMethod; // wechat / alipay
|
||||
private String status; // pending/paid/failed/cancelled
|
||||
private String transactionId;
|
||||
private String notifyData; // 回调原始数据
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime paidAt;
|
||||
}
|
||||
```
|
||||
|
||||
### PaymentRecord.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("payment_records")
|
||||
public class PaymentRecord {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String bizType; // order / recharge
|
||||
private Long bizId;
|
||||
private String bizNo;
|
||||
private BigDecimal amount;
|
||||
private String paymentMethod;
|
||||
private String transactionId;
|
||||
private String status; // pending/success/failed
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### RechargeDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class RechargeDTO {
|
||||
@NotNull(message = "充值金额不能为空")
|
||||
@DecimalMin(value = "1.00", message = "最低充值金额1元")
|
||||
private BigDecimal amount;
|
||||
|
||||
@NotBlank(message = "支付方式不能为空")
|
||||
private String paymentMethod; // wechat / alipay
|
||||
}
|
||||
```
|
||||
|
||||
### RechargeVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class RechargeVO {
|
||||
private Long rechargeId;
|
||||
private String rechargeNo;
|
||||
private BigDecimal amount;
|
||||
private Integer bonusPoints;
|
||||
private Integer totalPoints;
|
||||
// 支付参数(前端拉起支付用)
|
||||
private String payParams; // JSON字符串,微信/支付宝支付参数
|
||||
}
|
||||
```
|
||||
|
||||
### PaymentRecordVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class PaymentRecordVO {
|
||||
private Long id;
|
||||
private String bizType;
|
||||
private String bizNo;
|
||||
private BigDecimal amount;
|
||||
private String paymentMethod;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、充值赠送规则配置
|
||||
|
||||
```java
|
||||
package com.openclaw.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "recharge")
|
||||
public class RechargeConfig {
|
||||
|
||||
private List<Tier> tiers;
|
||||
|
||||
@Data
|
||||
public static class Tier {
|
||||
private BigDecimal amount; // 充值金额
|
||||
private Integer bonusPoints; // 赠送积分
|
||||
}
|
||||
|
||||
/** 计算赠送积分 */
|
||||
public Integer calcBonusPoints(BigDecimal amount) {
|
||||
return tiers.stream()
|
||||
.filter(t -> amount.compareTo(t.getAmount()) >= 0)
|
||||
.mapToInt(Tier::getBonusPoints)
|
||||
.max().orElse(0);
|
||||
}
|
||||
|
||||
/** 计算到账总积分(充值金额换算为积分 + 赠送) */
|
||||
public Integer calcTotalPoints(BigDecimal amount) {
|
||||
int base = amount.multiply(BigDecimal.valueOf(100)).intValue(); // 1元=100积分
|
||||
return base + calcBonusPoints(amount);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# application.yml 充值配置
|
||||
recharge:
|
||||
tiers:
|
||||
- amount: 10
|
||||
bonusPoints: 10
|
||||
- amount: 50
|
||||
bonusPoints: 60
|
||||
- amount: 100
|
||||
bonusPoints: 150
|
||||
- amount: 500
|
||||
bonusPoints: 800
|
||||
- amount: 1000
|
||||
bonusPoints: 2000
|
||||
```
|
||||
|
||||
## 四、Service 接口 + 实现
|
||||
|
||||
### PaymentService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.RechargeDTO;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface PaymentService {
|
||||
/** 发起充值,返回支付参数 */
|
||||
RechargeVO createRecharge(Long userId, RechargeDTO dto);
|
||||
|
||||
/** 微信支付回调 */
|
||||
void handleWechatCallback(String xmlBody);
|
||||
|
||||
/** 支付宝支付回调 */
|
||||
void handleAlipayCallback(String body);
|
||||
|
||||
/** 查询充值记录 */
|
||||
IPage<PaymentRecordVO> getPaymentRecords(Long userId, int pageNum, int pageSize);
|
||||
}
|
||||
```
|
||||
|
||||
### PaymentServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.config.RechargeConfig;
|
||||
import com.openclaw.dto.RechargeDTO;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.*;
|
||||
import com.openclaw.util.IdGenerator;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentServiceImpl implements PaymentService {
|
||||
|
||||
private final RechargeOrderRepository rechargeRepo;
|
||||
private final PaymentRecordRepository paymentRecordRepo;
|
||||
private final PointsService pointsService;
|
||||
private final OrderService orderService;
|
||||
private final RechargeConfig rechargeConfig;
|
||||
private final IdGenerator idGenerator;
|
||||
// private final WechatPayClient wechatPayClient; // 微信支付SDK
|
||||
// private final AlipayClient alipayClient; // 支付宝SDK
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RechargeVO createRecharge(Long userId, RechargeDTO dto) {
|
||||
int bonus = rechargeConfig.calcBonusPoints(dto.getAmount());
|
||||
int total = rechargeConfig.calcTotalPoints(dto.getAmount());
|
||||
|
||||
RechargeOrder order = new RechargeOrder();
|
||||
order.setRechargeNo(idGenerator.generateRechargeNo());
|
||||
order.setUserId(userId);
|
||||
order.setAmount(dto.getAmount());
|
||||
order.setBonusPoints(bonus);
|
||||
order.setTotalPoints(total);
|
||||
order.setPaymentMethod(dto.getPaymentMethod());
|
||||
order.setStatus("pending");
|
||||
rechargeRepo.insert(order);
|
||||
|
||||
// TODO: 调用微信/支付宝SDK生成支付参数
|
||||
// String payParams = wechatPayClient.createPayOrder(...);
|
||||
String payParams = "{\"prepay_id\":\"mock_prepay_id\"}";
|
||||
|
||||
RechargeVO vo = new RechargeVO();
|
||||
vo.setRechargeId(order.getId());
|
||||
vo.setRechargeNo(order.getRechargeNo());
|
||||
vo.setAmount(order.getAmount());
|
||||
vo.setBonusPoints(bonus);
|
||||
vo.setTotalPoints(total);
|
||||
vo.setPayParams(payParams);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handleWechatCallback(String xmlBody) {
|
||||
// 1. 解析微信回调XML
|
||||
// 2. 验签
|
||||
// 3. 查找充值订单
|
||||
// 4. 幂等校验(已处理则直接返回)
|
||||
// 5. 更新充值订单状态
|
||||
// 6. 发放积分
|
||||
log.info("收到微信支付回调: {}", xmlBody);
|
||||
// 示例:解析 rechargeNo 后调用 completeRecharge
|
||||
// String rechargeNo = parseXml(xmlBody, "out_trade_no");
|
||||
// String transactionId = parseXml(xmlBody, "transaction_id");
|
||||
// completeRecharge(rechargeNo, transactionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handleAlipayCallback(String body) {
|
||||
log.info("收到支付宝回调: {}", body);
|
||||
// 同上,解析参数后调用 completeRecharge
|
||||
}
|
||||
|
||||
/** 充值完成:更新状态 + 发放积分 */
|
||||
private void completeRecharge(String rechargeNo, String transactionId) {
|
||||
RechargeOrder order = rechargeRepo.findByRechargeNo(rechargeNo);
|
||||
if (order == null || "paid".equals(order.getStatus())) return; // 幂等
|
||||
|
||||
order.setStatus("paid");
|
||||
order.setTransactionId(transactionId);
|
||||
import java.time.LocalDateTime;
|
||||
order.setPaidAt(LocalDateTime.now());
|
||||
rechargeRepo.updateById(order);
|
||||
|
||||
// 发放积分(充值赠送)
|
||||
pointsService.earnPoints(order.getUserId(), "recharge", order.getId(), "recharge");
|
||||
// 注意:earnPoints 里按规则取积分数,但充值积分数量是动态的,需要特殊处理
|
||||
// 可以直接调用底层方法传入 totalPoints
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<PaymentRecordVO> getPaymentRecords(Long userId, int pageNum, int pageSize) {
|
||||
IPage<PaymentRecord> page = paymentRecordRepo.selectPage(
|
||||
new Page<>(pageNum, pageSize),
|
||||
new LambdaQueryWrapper<PaymentRecord>()
|
||||
.eq(PaymentRecord::getUserId, userId)
|
||||
.orderByDesc(PaymentRecord::getCreatedAt));
|
||||
return page.convert(r -> {
|
||||
PaymentRecordVO vo = new PaymentRecordVO();
|
||||
vo.setId(r.getId());
|
||||
vo.setBizType(r.getBizType());
|
||||
vo.setBizNo(r.getBizNo());
|
||||
vo.setAmount(r.getAmount());
|
||||
vo.setPaymentMethod(r.getPaymentMethod());
|
||||
vo.setStatus(r.getStatus());
|
||||
vo.setCreatedAt(r.getCreatedAt());
|
||||
return vo;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### PaymentController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.RechargeDTO;
|
||||
import com.openclaw.service.PaymentService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/payments")
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentController {
|
||||
|
||||
private final PaymentService paymentService;
|
||||
|
||||
/** 发起充值 */
|
||||
@PostMapping("/recharge")
|
||||
public Result<RechargeVO> createRecharge(@Valid @RequestBody RechargeDTO dto) {
|
||||
return Result.ok(paymentService.createRecharge(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 微信支付回调(无需登录) */
|
||||
@PostMapping("/callback/wechat")
|
||||
public String wechatCallback(HttpServletRequest request) throws Exception {
|
||||
String body = new BufferedReader(new InputStreamReader(request.getInputStream()))
|
||||
.lines().collect(Collectors.joining("\n"));
|
||||
paymentService.handleWechatCallback(body);
|
||||
return "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
|
||||
}
|
||||
|
||||
/** 支付宝回调(无需登录) */
|
||||
@PostMapping("/callback/alipay")
|
||||
public String alipayCallback(HttpServletRequest request) throws Exception {
|
||||
String body = new BufferedReader(new InputStreamReader(request.getInputStream()))
|
||||
.lines().collect(Collectors.joining("\n"));
|
||||
paymentService.handleAlipayCallback(body);
|
||||
return "success";
|
||||
}
|
||||
|
||||
/** 支付记录 */
|
||||
@GetMapping("/records")
|
||||
public Result<IPage<PaymentRecordVO>> getRecords(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return Result.ok(paymentService.getPaymentRecords(UserContext.getUserId(), pageNum, pageSize));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
458
后端架构设计/09-邀请服务开发文档.md
Normal file
458
后端架构设计/09-邀请服务开发文档.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# 邀请服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### InviteCode.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("invite_codes")
|
||||
public class InviteCode {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String code; // 邀请码(唯一)
|
||||
private Integer useCount; // 已使用次数
|
||||
private Integer maxUseCount; // 最大使用次数(-1为不限)
|
||||
private Boolean isActive; // 是否启用
|
||||
private LocalDateTime expiredAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### InviteRecord.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("invite_records")
|
||||
public class InviteRecord {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long inviterId; // 邀请人
|
||||
private Long inviteeId; // 被邀请人
|
||||
private String inviteCode; // 使用的邀请码
|
||||
private String status; // pending / rewarded
|
||||
private Integer inviterRewardPoints; // 邀请人获得积分
|
||||
private Integer inviteeRewardPoints; // 被邀请人获得积分
|
||||
private LocalDateTime rewardedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### InviteCodeVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class InviteCodeVO {
|
||||
private String code;
|
||||
private Integer useCount;
|
||||
private Integer maxUseCount;
|
||||
private Boolean isActive;
|
||||
private LocalDateTime expiredAt;
|
||||
// 邀请链接(前端拼接用)
|
||||
private String inviteUrl;
|
||||
}
|
||||
```
|
||||
|
||||
### InviteRecordVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class InviteRecordVO {
|
||||
private Long id;
|
||||
private Long inviteeId;
|
||||
private String inviteeNickname;
|
||||
private String inviteeAvatar;
|
||||
private String status;
|
||||
private Integer inviterPoints; // 对应实体 inviterRewardPoints
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime rewardedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### BindInviteDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class BindInviteDTO {
|
||||
@NotBlank(message = "邀请码不能为空")
|
||||
private String inviteCode;
|
||||
}
|
||||
```
|
||||
|
||||
### InviteStatsVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class InviteStatsVO {
|
||||
private Integer totalInvites; // 累计邀请人数
|
||||
private Integer rewardedInvites; // 已奖励次数
|
||||
private Integer totalEarnedPoints; // 通过邀请获得的总积分
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Repository
|
||||
|
||||
### InviteCodeRepository.java
|
||||
|
||||
```java
|
||||
package com.openclaw.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.openclaw.entity.InviteCode;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface InviteCodeRepository extends BaseMapper<InviteCode> {
|
||||
|
||||
@Select("SELECT * FROM invite_codes WHERE code = #{code} AND is_active = 1 LIMIT 1")
|
||||
InviteCode findActiveByCode(String code);
|
||||
|
||||
@Select("SELECT * FROM invite_codes WHERE user_id = #{userId} LIMIT 1")
|
||||
InviteCode findByUserId(Long userId);
|
||||
}
|
||||
```
|
||||
|
||||
### InviteRecordRepository.java
|
||||
|
||||
```java
|
||||
package com.openclaw.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.openclaw.entity.InviteRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface InviteRecordRepository extends BaseMapper<InviteRecord> {
|
||||
|
||||
@Select("SELECT * FROM invite_records WHERE inviter_id = #{inviterId} AND invitee_id = #{inviteeId} LIMIT 1")
|
||||
InviteRecord findByInviterAndInvitee(Long inviterId, Long inviteeId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM invite_records WHERE invitee_id = #{inviteeId}")
|
||||
int countByInviteeId(Long inviteeId);
|
||||
|
||||
@Select("SELECT SUM(inviter_reward_points) FROM invite_records WHERE inviter_id = #{inviterId} AND status = 'rewarded'")
|
||||
Integer sumEarnedPoints(Long inviterId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Service 接口 + 实现
|
||||
|
||||
### InviteService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.BindInviteDTO;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface InviteService {
|
||||
/** 获取(或生成)我的邀请码 */
|
||||
InviteCodeVO getMyInviteCode(Long userId);
|
||||
|
||||
/** 新用户注册后绑定邀请码(发放奖励) */
|
||||
void bindInviteCode(Long inviteeId, String inviteCode);
|
||||
|
||||
/** 查询邀请记录列表 */
|
||||
IPage<InviteRecordVO> listInviteRecords(Long userId, int pageNum, int pageSize);
|
||||
|
||||
/** 查询邀请统计数据 */
|
||||
InviteStatsVO getInviteStats(Long userId);
|
||||
}
|
||||
```
|
||||
|
||||
### InviteServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.*;
|
||||
import com.openclaw.repository.UserRepository;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InviteServiceImpl implements InviteService {
|
||||
|
||||
private final InviteCodeRepository inviteCodeRepo;
|
||||
private final InviteRecordRepository inviteRecordRepo;
|
||||
private final UserRepository userRepo;
|
||||
private final PointsService pointsService;
|
||||
|
||||
@Value("${invite.inviter-points:50}")
|
||||
private int inviterPoints; // 邀请人奖励积分
|
||||
|
||||
@Value("${invite.invitee-points:30}")
|
||||
private int inviteePoints; // 被邀请人奖励积分
|
||||
|
||||
@Value("${invite.url-prefix:https://app.openclaw.com/invite/}")
|
||||
private String urlPrefix;
|
||||
|
||||
@Override
|
||||
public InviteCodeVO getMyInviteCode(Long userId) {
|
||||
InviteCode code = inviteCodeRepo.findByUserId(userId);
|
||||
if (code == null) {
|
||||
code = new InviteCode();
|
||||
code.setUserId(userId);
|
||||
code.setCode(generateUniqueCode());
|
||||
code.setUseCount(0);
|
||||
code.setMaxUseCount(-1); // 不限次数
|
||||
code.setIsActive(true);
|
||||
inviteCodeRepo.insert(code);
|
||||
}
|
||||
return toVO(code);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void bindInviteCode(Long inviteeId, String inviteCode) {
|
||||
// 1. 检查被邀请人是否已被邀请过
|
||||
if (inviteRecordRepo.countByInviteeId(inviteeId) > 0) {
|
||||
log.warn("用户 {} 已被邀请过,忽略重复绑定", inviteeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 校验邀请码有效性
|
||||
InviteCode code = inviteCodeRepo.findActiveByCode(inviteCode);
|
||||
if (code == null) throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
|
||||
|
||||
// 3. 邀请人不能邀请自己
|
||||
if (code.getUserId().equals(inviteeId))
|
||||
throw new BusinessException(ErrorCode.INVITE_SELF_NOT_ALLOWED);
|
||||
|
||||
// 4. 检查使用次数上限
|
||||
if (code.getMaxUseCount() > 0 && code.getUseCount() >= code.getMaxUseCount())
|
||||
throw new BusinessException(ErrorCode.INVITE_CODE_EXHAUSTED);
|
||||
|
||||
// 5. 更新邀请码使用次数
|
||||
code.setUseCount(code.getUseCount() + 1);
|
||||
inviteCodeRepo.updateById(code);
|
||||
|
||||
// 6. 创建邀请记录
|
||||
InviteRecord record = new InviteRecord();
|
||||
record.setInviterId(code.getUserId());
|
||||
record.setInviteeId(inviteeId);
|
||||
record.setInviteCode(inviteCode);
|
||||
record.setStatus("registered");
|
||||
record.setInviterRewardPoints(inviterPoints);
|
||||
record.setInviteeRewardPoints(inviteePoints);
|
||||
record.setRewardedAt(LocalDateTime.now());
|
||||
inviteRecordRepo.insert(record);
|
||||
|
||||
// 7. 发放积分
|
||||
pointsService.addPointsDirectly(code.getUserId(), inviterPoints, "invite", record.getId(), "邀请好友奖励");
|
||||
pointsService.addPointsDirectly(inviteeId, inviteePoints, "invited", record.getId(), "接受邀请奖励");
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<InviteRecordVO> listInviteRecords(Long userId, int pageNum, int pageSize) {
|
||||
IPage<InviteRecord> page = inviteRecordRepo.selectPage(
|
||||
new Page<>(pageNum, pageSize),
|
||||
new LambdaQueryWrapper<InviteRecord>()
|
||||
.eq(InviteRecord::getInviterId, userId)
|
||||
.orderByDesc(InviteRecord::getCreatedAt));
|
||||
return page.convert(r -> {
|
||||
InviteRecordVO vo = new InviteRecordVO();
|
||||
vo.setId(r.getId());
|
||||
vo.setInviteeId(r.getInviteeId());
|
||||
// 查询被邀请人信息
|
||||
User invitee = userRepo.selectById(r.getInviteeId());
|
||||
if (invitee != null) {
|
||||
vo.setInviteeNickname(invitee.getNickname());
|
||||
vo.setInviteeAvatar(invitee.getAvatarUrl());
|
||||
}
|
||||
vo.setStatus(r.getStatus());
|
||||
vo.setInviterPoints(r.getInviterRewardPoints()) // VO字段名保持简洁;
|
||||
vo.setCreatedAt(r.getCreatedAt());
|
||||
vo.setRewardedAt(r.getRewardedAt());
|
||||
return vo;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public InviteStatsVO getInviteStats(Long userId) {
|
||||
InviteStatsVO stats = new InviteStatsVO();
|
||||
stats.setTotalInvites((int) inviteRecordRepo.selectCount(
|
||||
new LambdaQueryWrapper<InviteRecord>().eq(InviteRecord::getInviterId, userId)));
|
||||
stats.setRewardedInvites((int) inviteRecordRepo.selectCount(
|
||||
new LambdaQueryWrapper<InviteRecord>()
|
||||
.eq(InviteRecord::getInviterId, userId)
|
||||
.eq(InviteRecord::getStatus, "registered")));
|
||||
Integer earned = inviteRecordRepo.sumEarnedPoints(userId);
|
||||
stats.setTotalEarnedPoints(earned == null ? 0 : earned);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// --- 私有方法 ---
|
||||
|
||||
private String generateUniqueCode() {
|
||||
// 取UUID前8位,碰撞概率极低;生产环境可加重试逻辑
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
private InviteCodeVO toVO(InviteCode code) {
|
||||
InviteCodeVO vo = new InviteCodeVO();
|
||||
vo.setCode(code.getCode());
|
||||
vo.setUseCount(code.getUseCount());
|
||||
vo.setMaxUseCount(code.getMaxUseCount());
|
||||
vo.setIsActive(code.getIsActive());
|
||||
vo.setExpiredAt(code.getExpiredAt());
|
||||
vo.setInviteUrl(urlPrefix + code.getCode());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### InviteController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.BindInviteDTO;
|
||||
import com.openclaw.service.InviteService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invites")
|
||||
@RequiredArgsConstructor
|
||||
public class InviteController {
|
||||
|
||||
private final InviteService inviteService;
|
||||
|
||||
/** 获取我的邀请码 */
|
||||
@GetMapping("/my-code")
|
||||
public Result<InviteCodeVO> getMyCode() {
|
||||
return Result.ok(inviteService.getMyInviteCode(UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 新用户绑定邀请码(注册时或注册后调用) */
|
||||
@PostMapping("/bind")
|
||||
public Result<Void> bindCode(@Valid @RequestBody BindInviteDTO dto) {
|
||||
inviteService.bindInviteCode(UserContext.getUserId(), dto.getInviteCode());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 邀请记录列表 */
|
||||
@GetMapping("/records")
|
||||
public Result<IPage<InviteRecordVO>> records(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return Result.ok(inviteService.listInviteRecords(UserContext.getUserId(), pageNum, pageSize));
|
||||
}
|
||||
|
||||
/** 邀请统计概览 */
|
||||
@GetMapping("/stats")
|
||||
public Result<InviteStatsVO> stats() {
|
||||
return Result.ok(inviteService.getInviteStats(UserContext.getUserId()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、配置参数
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
invite:
|
||||
inviter-points: 50 # 邀请人奖励积分
|
||||
invitee-points: 30 # 被邀请人奖励积分
|
||||
url-prefix: https://app.openclaw.com/invite/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、邀请流程说明
|
||||
|
||||
```
|
||||
邀请人 被邀请人 系统
|
||||
| | |
|
||||
| GET /invites/my-code |
|
||||
|--------------->| |
|
||||
|<-- InviteCodeVO(含邀请链接) |
|
||||
| | |
|
||||
| 分享邀请链接 | |
|
||||
|--------------->| |
|
||||
| | 注册成功 |
|
||||
| |-------------------->|
|
||||
| | POST /invites/bind |
|
||||
| |-------------------->|
|
||||
| | 校验邀请码 |
|
||||
| | 创建邀请记录 |
|
||||
| | 发放双方积分 |
|
||||
|<-- +50积分通知 |<-- +30积分通知 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
127
后端架构设计/10-管理后台-part1-权限与DTO.md
Normal file
127
后端架构设计/10-管理后台-part1-权限与DTO.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 管理后台开发文档 - Part 1(权限 + DTO/VO)
|
||||
|
||||
> 管理后台复用主应用 Service/Repository 层,新增 Admin Controller,路由前缀 `/api/admin`,通过角色拦截隔离。
|
||||
|
||||
## 一、角色常量
|
||||
|
||||
```java
|
||||
package com.openclaw.constant;
|
||||
|
||||
public interface AdminRole {
|
||||
String ADMIN = "ROLE_ADMIN"; // 超级管理员
|
||||
String OPERATOR = "ROLE_OPERATOR"; // 运营
|
||||
String AUDITOR = "ROLE_AUDITOR"; // 内容审核
|
||||
String FINANCE = "ROLE_FINANCE"; // 财务
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// SecurityConfig.java 追加
|
||||
http.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/admin/**")
|
||||
.hasAnyRole("ADMIN","OPERATOR","AUDITOR","FINANCE")
|
||||
);
|
||||
```
|
||||
|
||||
## 二、管理端 DTO
|
||||
|
||||
```java
|
||||
// AdminUserQueryDTO.java
|
||||
@Data
|
||||
public class AdminUserQueryDTO {
|
||||
private String keyword; // 手机号/昵称
|
||||
private String status; // active / banned
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 20;
|
||||
}
|
||||
|
||||
// AdminSkillQueryDTO.java
|
||||
@Data
|
||||
public class AdminSkillQueryDTO {
|
||||
private String keyword;
|
||||
private String status; // pending/approved/rejected/offline
|
||||
private Long categoryId;
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 20;
|
||||
}
|
||||
|
||||
// SkillAuditDTO.java
|
||||
@Data
|
||||
public class SkillAuditDTO {
|
||||
@NotNull private Long skillId;
|
||||
@NotBlank private String action; // approve / reject
|
||||
private String rejectReason;
|
||||
}
|
||||
|
||||
// AdminOrderQueryDTO.java
|
||||
@Data
|
||||
public class AdminOrderQueryDTO {
|
||||
private String keyword; // 订单号
|
||||
private String status;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 20;
|
||||
}
|
||||
|
||||
// AdjustPointsDTO.java
|
||||
@Data
|
||||
public class AdjustPointsDTO {
|
||||
@NotNull private Integer delta; // 正数增加,负数扣减
|
||||
private String remark;
|
||||
}
|
||||
|
||||
// RefundProcessDTO.java
|
||||
@Data
|
||||
public class RefundProcessDTO {
|
||||
@NotBlank private String action; // approve / reject
|
||||
private String remark;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、管理端 VO
|
||||
|
||||
```java
|
||||
// AdminUserVO.java
|
||||
@Data
|
||||
public class AdminUserVO {
|
||||
private Long id;
|
||||
private String phone, nickname, avatarUrl, status;
|
||||
private Integer totalPoints, frozenPoints;
|
||||
private LocalDateTime createdAt, lastLoginAt;
|
||||
}
|
||||
|
||||
// AdminSkillVO.java
|
||||
@Data
|
||||
public class AdminSkillVO {
|
||||
private Long id;
|
||||
private String name, coverImageUrl, status, rejectReason;
|
||||
private BigDecimal price;
|
||||
private Boolean isFree;
|
||||
private Long creatorId;
|
||||
private LocalDateTime createdAt, auditedAt;
|
||||
}
|
||||
|
||||
// AdminOrderVO.java
|
||||
@Data
|
||||
public class AdminOrderVO {
|
||||
private Long id;
|
||||
private String orderNo, status, paymentMethod;
|
||||
private Long userId;
|
||||
private BigDecimal totalAmount, cashAmount;
|
||||
private Integer pointsUsed;
|
||||
private LocalDateTime createdAt, paidAt;
|
||||
}
|
||||
|
||||
// DashboardVO.java
|
||||
@Data
|
||||
public class DashboardVO {
|
||||
private Long totalUsers, todayNewUsers, activeUsersLast7d;
|
||||
private BigDecimal totalRevenue, revenueToday;
|
||||
private Long totalOrders, ordersToday;
|
||||
private Long totalSkills, pendingAuditSkills, totalDownloads;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
272
后端架构设计/10-管理后台-part2-Service.md
Normal file
272
后端架构设计/10-管理后台-part2-Service.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 管理后台开发文档 - Part 2(AdminService 接口 + 实现)
|
||||
|
||||
## 一、AdminService 接口
|
||||
|
||||
```java
|
||||
package com.openclaw.service.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.admin.*;
|
||||
import com.openclaw.entity.PointsRule;
|
||||
import com.openclaw.vo.admin.*;
|
||||
import java.util.List;
|
||||
|
||||
public interface AdminService {
|
||||
// 看板
|
||||
DashboardVO getDashboard();
|
||||
|
||||
// 用户
|
||||
IPage<AdminUserVO> listUsers(AdminUserQueryDTO query);
|
||||
AdminUserVO getUserDetail(Long userId);
|
||||
void banUser(Long userId, String reason);
|
||||
void unbanUser(Long userId);
|
||||
void adjustPoints(Long userId, int delta, String remark);
|
||||
|
||||
// Skill 审核
|
||||
IPage<AdminSkillVO> listSkills(AdminSkillQueryDTO query);
|
||||
void auditSkill(SkillAuditDTO dto, Long auditorId);
|
||||
void offlineSkill(Long skillId, String reason);
|
||||
|
||||
// 订单 / 退款
|
||||
IPage<AdminOrderVO> listOrders(AdminOrderQueryDTO query);
|
||||
void processRefund(Long refundId, String action, String remark, Long operatorId);
|
||||
|
||||
// 积分规则
|
||||
List<PointsRule> listPointsRules();
|
||||
void updatePointsRule(Long ruleId, int points);
|
||||
}
|
||||
```
|
||||
|
||||
## 二、AdminServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.admin.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.dto.admin.*;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.PointsService;
|
||||
import com.openclaw.service.admin.AdminService;
|
||||
import com.openclaw.vo.admin.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminServiceImpl implements AdminService {
|
||||
|
||||
private final UserRepository userRepo;
|
||||
private final SkillRepository skillRepo;
|
||||
private final OrderRepository orderRepo;
|
||||
private final OrderRefundRepository refundRepo;
|
||||
private final PointsRuleRepository pointsRuleRepo;
|
||||
private final SkillDownloadRepository downloadRepo;
|
||||
private final PointsService pointsService;
|
||||
|
||||
// ---------- 看板 ----------
|
||||
|
||||
@Override
|
||||
public DashboardVO getDashboard() {
|
||||
DashboardVO vo = new DashboardVO();
|
||||
LocalDateTime dayStart = LocalDate.now().atStartOfDay();
|
||||
|
||||
vo.setTotalUsers(userRepo.selectCount(null));
|
||||
vo.setTodayNewUsers(userRepo.selectCount(
|
||||
new LambdaQueryWrapper<User>().ge(User::getCreatedAt, dayStart)));
|
||||
vo.setActiveUsersLast7d(
|
||||
userRepo.countActiveUsersAfter(LocalDateTime.now().minusDays(7)));
|
||||
|
||||
vo.setTotalOrders(orderRepo.selectCount(null));
|
||||
vo.setOrdersToday(orderRepo.selectCount(
|
||||
new LambdaQueryWrapper<Order>().ge(Order::getCreatedAt, dayStart)));
|
||||
|
||||
BigDecimal rev = orderRepo.sumCashAmount("paid");
|
||||
vo.setTotalRevenue(rev == null ? BigDecimal.ZERO : rev);
|
||||
BigDecimal revToday = orderRepo.sumCashAmountAfter("paid", dayStart);
|
||||
vo.setRevenueToday(revToday == null ? BigDecimal.ZERO : revToday);
|
||||
|
||||
vo.setTotalSkills(skillRepo.selectCount(null));
|
||||
vo.setPendingAuditSkills(skillRepo.selectCount(
|
||||
new LambdaQueryWrapper<Skill>().eq(Skill::getStatus, "pending")));
|
||||
vo.setTotalDownloads(downloadRepo.selectCount(null));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ---------- 用户 ----------
|
||||
|
||||
@Override
|
||||
public IPage<AdminUserVO> listUsers(AdminUserQueryDTO q) {
|
||||
return userRepo.selectPage(new Page<>(q.getPageNum(), q.getPageSize()),
|
||||
new LambdaQueryWrapper<User>()
|
||||
.and(q.getKeyword() != null, w -> w
|
||||
.like(User::getNickname, q.getKeyword()).or()
|
||||
.like(User::getPhone, q.getKeyword()))
|
||||
.eq(q.getStatus() != null, User::getStatus, q.getStatus())
|
||||
.orderByDesc(User::getCreatedAt)
|
||||
).convert(this::toUserVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminUserVO getUserDetail(Long userId) {
|
||||
User u = userRepo.selectById(userId);
|
||||
if (u == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
return toUserVO(u);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void banUser(Long userId, String reason) {
|
||||
User u = requireUser(userId);
|
||||
u.setStatus("banned"); u.setBanReason(reason);
|
||||
userRepo.updateById(u);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void unbanUser(Long userId) {
|
||||
User u = requireUser(userId);
|
||||
u.setStatus("active"); u.setBanReason(null);
|
||||
userRepo.updateById(u);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void adjustPoints(Long userId, int delta, String remark) {
|
||||
String type = delta > 0 ? "admin_add" : "admin_deduct";
|
||||
String desc = remark != null ? remark : (delta > 0 ? "管理员补积分" : "管理员扣积分");
|
||||
pointsService.addPointsDirectly(userId, Math.abs(delta), type, null, desc);
|
||||
}
|
||||
|
||||
// ---------- Skill ----------
|
||||
|
||||
@Override
|
||||
public IPage<AdminSkillVO> listSkills(AdminSkillQueryDTO q) {
|
||||
return skillRepo.selectPage(new Page<>(q.getPageNum(), q.getPageSize()),
|
||||
new LambdaQueryWrapper<Skill>()
|
||||
.like(q.getKeyword() != null, Skill::getName, q.getKeyword())
|
||||
.eq(q.getStatus() != null, Skill::getStatus, q.getStatus())
|
||||
.eq(q.getCategoryId() != null, Skill::getCategoryId, q.getCategoryId())
|
||||
.orderByDesc(Skill::getCreatedAt)
|
||||
).convert(this::toSkillVO);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void auditSkill(SkillAuditDTO dto, Long auditorId) {
|
||||
Skill s = skillRepo.selectById(dto.getSkillId());
|
||||
if (s == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
if (!"pending".equals(s.getStatus())) throw new BusinessException(ErrorCode.SKILL_STATUS_ERROR);
|
||||
switch (dto.getAction()) {
|
||||
case "approve" -> s.setStatus("approved");
|
||||
case "reject" -> { s.setStatus("rejected"); s.setRejectReason(dto.getRejectReason()); }
|
||||
default -> throw new BusinessException(ErrorCode.PARAM_ERROR);
|
||||
}
|
||||
s.setAuditorId(auditorId);
|
||||
s.setAuditedAt(LocalDateTime.now());
|
||||
skillRepo.updateById(s);
|
||||
log.info("Skill审核 id={} action={} auditor={}", dto.getSkillId(), dto.getAction(), auditorId);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void offlineSkill(Long skillId, String reason) {
|
||||
Skill s = skillRepo.selectById(skillId);
|
||||
if (s == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
s.setStatus("offline"); s.setRejectReason(reason);
|
||||
skillRepo.updateById(s);
|
||||
}
|
||||
|
||||
// ---------- 订单 ----------
|
||||
|
||||
@Override
|
||||
public IPage<AdminOrderVO> listOrders(AdminOrderQueryDTO q) {
|
||||
return orderRepo.selectPage(new Page<>(q.getPageNum(), q.getPageSize()),
|
||||
new LambdaQueryWrapper<Order>()
|
||||
.like(q.getKeyword() != null, Order::getOrderNo, q.getKeyword())
|
||||
.eq(q.getStatus() != null, Order::getStatus, q.getStatus())
|
||||
.ge(q.getStartDate() != null, Order::getCreatedAt, q.getStartDate() != null ? q.getStartDate().atStartOfDay() : null)
|
||||
.le(q.getEndDate() != null, Order::getCreatedAt, q.getEndDate() != null ? q.getEndDate().plusDays(1).atStartOfDay() : null)
|
||||
.orderByDesc(Order::getCreatedAt)
|
||||
).convert(this::toOrderVO);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void processRefund(Long refundId, String action, String remark, Long operatorId) {
|
||||
OrderRefund rf = refundRepo.selectById(refundId);
|
||||
if (rf == null) throw new BusinessException(ErrorCode.REFUND_NOT_FOUND);
|
||||
if (!"pending".equals(rf.getStatus())) throw new BusinessException(ErrorCode.REFUND_STATUS_ERROR);
|
||||
Order o = orderRepo.selectById(rf.getOrderId());
|
||||
switch (action) {
|
||||
case "approve" -> {
|
||||
rf.setStatus("approved"); o.setStatus("refunded");
|
||||
if (rf.getRefundPoints() != null && rf.getRefundPoints() > 0)
|
||||
pointsService.addPointsDirectly(
|
||||
o.getUserId(), rf.getRefundPoints(), "refund", rf.getId(), "退款返还积分");
|
||||
// TODO: 调用支付渠道退款
|
||||
}
|
||||
case "reject" -> { rf.setStatus("rejected"); o.setStatus("paid"); }
|
||||
default -> throw new BusinessException(ErrorCode.PARAM_ERROR);
|
||||
}
|
||||
rf.setRemark(remark); rf.setOperatorId(operatorId); rf.setProcessedAt(LocalDateTime.now());
|
||||
refundRepo.updateById(rf); orderRepo.updateById(o);
|
||||
}
|
||||
|
||||
// ---------- 积分规则 ----------
|
||||
|
||||
@Override
|
||||
public List<PointsRule> listPointsRules() { return pointsRuleRepo.selectList(null); }
|
||||
|
||||
@Override @Transactional
|
||||
public void updatePointsRule(Long ruleId, int points) {
|
||||
PointsRule r = pointsRuleRepo.selectById(ruleId);
|
||||
if (r == null) throw new BusinessException(ErrorCode.POINTS_RULE_NOT_FOUND);
|
||||
r.setPoints(points); pointsRuleRepo.updateById(r);
|
||||
}
|
||||
|
||||
// ---------- 私有辅助 ----------
|
||||
|
||||
private User requireUser(Long id) {
|
||||
User u = userRepo.selectById(id);
|
||||
if (u == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
return u;
|
||||
}
|
||||
|
||||
private AdminUserVO toUserVO(User u) {
|
||||
AdminUserVO vo = new AdminUserVO();
|
||||
vo.setId(u.getId()); vo.setPhone(u.getPhone());
|
||||
vo.setNickname(u.getNickname()); vo.setAvatarUrl(u.getAvatarUrl());
|
||||
vo.setStatus(u.getStatus()); vo.setCreatedAt(u.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AdminSkillVO toSkillVO(Skill s) {
|
||||
AdminSkillVO vo = new AdminSkillVO();
|
||||
vo.setId(s.getId()); vo.setName(s.getName());
|
||||
vo.setCoverImageUrl(s.getCoverImageUrl()); vo.setPrice(s.getPrice());
|
||||
vo.setIsFree(s.getIsFree()); vo.setStatus(s.getStatus());
|
||||
vo.setCreatorId(s.getCreatorId()); vo.setCreatedAt(s.getCreatedAt());
|
||||
vo.setAuditedAt(s.getAuditedAt()); vo.setRejectReason(s.getRejectReason());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AdminOrderVO toOrderVO(Order o) {
|
||||
AdminOrderVO vo = new AdminOrderVO();
|
||||
vo.setId(o.getId()); vo.setOrderNo(o.getOrderNo());
|
||||
vo.setUserId(o.getUserId()); vo.setTotalAmount(o.getTotalAmount());
|
||||
vo.setCashAmount(o.getCashAmount()); vo.setPointsUsed(o.getPointsUsed());
|
||||
vo.setStatus(o.getStatus()); vo.setPaymentMethod(o.getPaymentMethod());
|
||||
vo.setCreatedAt(o.getCreatedAt()); vo.setPaidAt(o.getPaidAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
157
后端架构设计/10-管理后台-part3-Controller.md
Normal file
157
后端架构设计/10-管理后台-part3-Controller.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 管理后台开发文档 - Part 3(AdminController)
|
||||
|
||||
## AdminController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.admin.*;
|
||||
import com.openclaw.entity.PointsRule;
|
||||
import com.openclaw.service.admin.AdminService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.admin.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final AdminService adminService;
|
||||
|
||||
// ==================== 数据看板 ====================
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<DashboardVO> dashboard() {
|
||||
return Result.ok(adminService.getDashboard());
|
||||
}
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
@GetMapping("/users")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<IPage<AdminUserVO>> listUsers(AdminUserQueryDTO query) {
|
||||
return Result.ok(adminService.listUsers(query));
|
||||
}
|
||||
|
||||
@GetMapping("/users/{userId}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<AdminUserVO> getUser(@PathVariable Long userId) {
|
||||
return Result.ok(adminService.getUserDetail(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/ban")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> banUser(
|
||||
@PathVariable Long userId,
|
||||
@RequestParam(required = false) String reason) {
|
||||
adminService.banUser(userId, reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/unban")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> unbanUser(@PathVariable Long userId) {
|
||||
adminService.unbanUser(userId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/points")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> adjustPoints(
|
||||
@PathVariable Long userId,
|
||||
@Valid @RequestBody AdjustPointsDTO dto) {
|
||||
adminService.adjustPoints(userId, dto.getDelta(), dto.getRemark());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== Skill 审核 ====================
|
||||
|
||||
@GetMapping("/skills")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR','AUDITOR')")
|
||||
public Result<IPage<AdminSkillVO>> listSkills(AdminSkillQueryDTO query) {
|
||||
return Result.ok(adminService.listSkills(query));
|
||||
}
|
||||
|
||||
@PostMapping("/skills/audit")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','AUDITOR')")
|
||||
public Result<Void> auditSkill(@Valid @RequestBody SkillAuditDTO dto) {
|
||||
adminService.auditSkill(dto, UserContext.getUserId());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/skills/{skillId}/offline")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<Void> offlineSkill(
|
||||
@PathVariable Long skillId,
|
||||
@RequestParam(required = false) String reason) {
|
||||
adminService.offlineSkill(skillId, reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== 订单管理 ====================
|
||||
|
||||
@GetMapping("/orders")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR','FINANCE')")
|
||||
public Result<IPage<AdminOrderVO>> listOrders(AdminOrderQueryDTO query) {
|
||||
return Result.ok(adminService.listOrders(query));
|
||||
}
|
||||
|
||||
@PostMapping("/refunds/{refundId}/process")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','FINANCE')")
|
||||
public Result<Void> processRefund(
|
||||
@PathVariable Long refundId,
|
||||
@Valid @RequestBody RefundProcessDTO dto) {
|
||||
adminService.processRefund(
|
||||
refundId, dto.getAction(), dto.getRemark(), UserContext.getUserId());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== 积分规则 ====================
|
||||
|
||||
@GetMapping("/points-rules")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<List<PointsRule>> listRules() {
|
||||
return Result.ok(adminService.listPointsRules());
|
||||
}
|
||||
|
||||
@PutMapping("/points-rules/{ruleId}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> updateRule(
|
||||
@PathVariable Long ruleId,
|
||||
@RequestParam int points) {
|
||||
adminService.updatePointsRule(ruleId, points);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 汇总
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/admin/dashboard | 数据看板 | ADMIN/OPERATOR |
|
||||
| GET | /api/admin/users | 用户列表 | ADMIN/OPERATOR |
|
||||
| GET | /api/admin/users/{id} | 用户详情 | ADMIN/OPERATOR |
|
||||
| POST | /api/admin/users/{id}/ban | 封禁用户 | ADMIN |
|
||||
| POST | /api/admin/users/{id}/unban | 解封用户 | ADMIN |
|
||||
| POST | /api/admin/users/{id}/points | 调整积分 | ADMIN |
|
||||
| GET | /api/admin/skills | Skill列表 | ADMIN/OPERATOR/AUDITOR |
|
||||
| POST | /api/admin/skills/audit | Skill审核 | ADMIN/AUDITOR |
|
||||
| POST | /api/admin/skills/{id}/offline | Skill下架 | ADMIN/OPERATOR |
|
||||
| GET | /api/admin/orders | 订单列表 | ADMIN/OPERATOR/FINANCE |
|
||||
| POST | /api/admin/refunds/{id}/process | 处理退款 | ADMIN/FINANCE |
|
||||
| GET | /api/admin/points-rules | 积分规则列表 | ADMIN/OPERATOR |
|
||||
| PUT | /api/admin/points-rules/{id} | 更新积分规则 | ADMIN |
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
154
后端架构设计/11-通用基础设施-part1-响应与异常.md
Normal file
154
后端架构设计/11-通用基础设施-part1-响应与异常.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# 通用基础设施开发文档 - Part 1(响应封装 + 异常处理)
|
||||
|
||||
---
|
||||
|
||||
## 一、统一响应封装 Result.java
|
||||
|
||||
```java
|
||||
package com.openclaw.common;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
public class Result<T> {
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private long timestamp;
|
||||
|
||||
public static <T> Result<T> ok(T data) {
|
||||
Result<T> r = new Result<>();
|
||||
r.code = 200; r.message = "success";
|
||||
r.data = data;
|
||||
r.timestamp = Instant.now().toEpochMilli();
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> Result<T> ok() { return ok(null); }
|
||||
|
||||
public static <T> Result<T> fail(int code, String message) {
|
||||
Result<T> r = new Result<>();
|
||||
r.code = code; r.message = message;
|
||||
r.timestamp = Instant.now().toEpochMilli();
|
||||
return r;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、ErrorCode.java
|
||||
|
||||
```java
|
||||
package com.openclaw.constant;
|
||||
|
||||
public interface ErrorCode {
|
||||
// ---------- 通用 ----------
|
||||
int[] PARAM_ERROR = {400, "请求参数错误"};
|
||||
int[] UNAUTHORIZED = {401, "请先登录"};
|
||||
int[] FORBIDDEN = {403, "无权限"};
|
||||
int[] NOT_FOUND = {404, "资源不存在"};
|
||||
|
||||
// ---------- 用户 1xxx ----------
|
||||
int[] USER_NOT_FOUND = {1001, "用户不存在"};
|
||||
int[] WRONG_PASSWORD = {1002, "密码错误"};
|
||||
int[] PHONE_REGISTERED = {1003, "手机号已注册"};
|
||||
int[] USER_BANNED = {1004, "账号已被封禁"};
|
||||
|
||||
// ---------- Skill 2xxx ----------
|
||||
int[] SKILL_NOT_FOUND = {2001, "Skill不存在"};
|
||||
int[] SKILL_ALREADY_OWNED = {2002, "已拥有该Skill"};
|
||||
int[] SKILL_STATUS_ERROR = {2003, "Skill状态不允许此操作"};
|
||||
|
||||
// ---------- 积分 3xxx ----------
|
||||
int[] POINTS_NOT_ENOUGH = {3001, "积分不足"};
|
||||
int[] POINTS_RULE_NOT_FOUND = {3002, "积分规则不存在"};
|
||||
|
||||
// ---------- 订单 4xxx ----------
|
||||
int[] ORDER_NOT_FOUND = {4001, "订单不存在"};
|
||||
int[] ORDER_STATUS_ERROR = {4002, "订单状态不允许此操作"};
|
||||
|
||||
// ---------- 退款 5xxx ----------
|
||||
int[] REFUND_NOT_FOUND = {5001, "退款单不存在"};
|
||||
int[] REFUND_STATUS_ERROR = {5002, "退款状态不允许此操作"};
|
||||
|
||||
// ---------- 邀请 6xxx ----------
|
||||
int[] INVITE_CODE_INVALID = {6001, "邀请码无效"};
|
||||
int[] INVITE_SELF_NOT_ALLOWED = {6002, "不能邀请自己"};
|
||||
int[] INVITE_CODE_EXHAUSTED = {6003, "邀请码已达使用上限"};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、BusinessException.java
|
||||
|
||||
```java
|
||||
package com.openclaw.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
private final int code;
|
||||
private final String msg;
|
||||
|
||||
public BusinessException(int code, String msg) {
|
||||
super(msg);
|
||||
this.code = code;
|
||||
this.msg = msg;
|
||||
}
|
||||
|
||||
/** 接受 int[] {code, message} 格式的 ErrorCode 常量 */
|
||||
public BusinessException(int[] errorCode) {
|
||||
this(errorCode[0], String.valueOf(errorCode[1]));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、GlobalExceptionHandler.java
|
||||
|
||||
```java
|
||||
package com.openclaw.exception;
|
||||
|
||||
import com.openclaw.common.Result;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
public Result<?> handleBusiness(BusinessException e) {
|
||||
return Result.fail(e.getCode(), e.getMsg());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Result<?> handleValidation(MethodArgumentNotValidException e) {
|
||||
String msg = e.getBindingResult().getFieldErrors().stream()
|
||||
.findFirst()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.orElse("参数校验失败");
|
||||
return Result.fail(400, msg);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public Result<?> handleUnknown(Exception e) {
|
||||
log.error("未知异常", e);
|
||||
return Result.fail(500, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
174
后端架构设计/11-通用基础设施-part2-JWT与拦截器.md
Normal file
174
后端架构设计/11-通用基础设施-part2-JWT与拦截器.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 通用基础设施开发文档 - Part 2(JWT + UserContext + 拦截器)
|
||||
|
||||
---
|
||||
|
||||
## 一、JwtUtil.java
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
private final Key key;
|
||||
private final long expireMs;
|
||||
|
||||
public JwtUtil(
|
||||
@Value("${jwt.secret}") String secret,
|
||||
@Value("${jwt.expire-ms}") long expireMs) {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes());
|
||||
this.expireMs = expireMs;
|
||||
}
|
||||
|
||||
public String generate(Long userId, String role) {
|
||||
return Jwts.builder()
|
||||
.setSubject(userId.toString())
|
||||
.claim("role", role)
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expireMs))
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(key).build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
public Long getUserId(String token) {
|
||||
return Long.parseLong(parse(token).getSubject());
|
||||
}
|
||||
|
||||
public String getRole(String token) {
|
||||
return parse(token).get("role", String.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
jwt:
|
||||
secret: change-this-to-a-256-bit-random-secret-in-prod
|
||||
expire-ms: 86400000 # 24 小时
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、UserContext.java
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
public class UserContext {
|
||||
|
||||
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> ROLE = new ThreadLocal<>();
|
||||
|
||||
public static void set(Long userId, String role) {
|
||||
USER_ID.set(userId);
|
||||
ROLE.set(role);
|
||||
}
|
||||
|
||||
public static Long getUserId() { return USER_ID.get(); }
|
||||
public static String getRole() { return ROLE.get(); }
|
||||
|
||||
public static void clear() {
|
||||
USER_ID.remove();
|
||||
ROLE.remove();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、AuthInterceptor.java
|
||||
|
||||
```java
|
||||
package com.openclaw.interceptor;
|
||||
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.util.JwtUtil;
|
||||
import com.openclaw.util.UserContext;
|
||||
import jakarta.servlet.http.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest req,
|
||||
HttpServletResponse res,
|
||||
Object handler) {
|
||||
String auth = req.getHeader("Authorization");
|
||||
if (auth == null || !auth.startsWith("Bearer "))
|
||||
throw new BusinessException(ErrorCode.UNAUTHORIZED);
|
||||
try {
|
||||
String token = auth.substring(7);
|
||||
Long userId = jwtUtil.getUserId(token);
|
||||
String role = jwtUtil.getRole(token);
|
||||
UserContext.set(userId, role);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
|
||||
Object handler, Exception ex) {
|
||||
UserContext.clear(); // 防止 ThreadLocal 内存泄漏
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、WebMvcConfig.java(注册拦截器)
|
||||
|
||||
```java
|
||||
package com.openclaw.config;
|
||||
|
||||
import com.openclaw.interceptor.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.*;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/users/register",
|
||||
"/api/v1/users/login",
|
||||
"/api/v1/users/sms-code",
|
||||
"/api/v1/payments/callback/**",
|
||||
"/api/v1/skills", // 公开浏览
|
||||
"/api/v1/skills/{id}" // 公开详情
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
264
后端架构设计/11-通用基础设施-part3-配置与工具类.md
Normal file
264
后端架构设计/11-通用基础设施-part3-配置与工具类.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 通用基础设施开发文档 - Part 3(Redis + MyBatis-Plus + IdGenerator + pom.xml)
|
||||
|
||||
---
|
||||
|
||||
## 一、RedisConfig.java
|
||||
|
||||
```java
|
||||
package com.openclaw.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.*;
|
||||
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
||||
RedisTemplate<String, Object> tpl = new RedisTemplate<>();
|
||||
tpl.setConnectionFactory(factory);
|
||||
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
om.registerModule(new JavaTimeModule());
|
||||
om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
om.activateDefaultTyping(
|
||||
om.getPolymorphicTypeValidator(),
|
||||
ObjectMapper.DefaultTyping.NON_FINAL);
|
||||
|
||||
Jackson2JsonRedisSerializer<Object> json =
|
||||
new Jackson2JsonRedisSerializer<>(om, Object.class);
|
||||
|
||||
StringRedisSerializer str = new StringRedisSerializer();
|
||||
tpl.setKeySerializer(str);
|
||||
tpl.setHashKeySerializer(str);
|
||||
tpl.setValueSerializer(json);
|
||||
tpl.setHashValueSerializer(json);
|
||||
tpl.afterPropertiesSet();
|
||||
return tpl;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
database: 0
|
||||
timeout: 3000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 20
|
||||
max-idle: 10
|
||||
min-idle: 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、MybatisPlusConfig.java
|
||||
|
||||
```java
|
||||
package com.openclaw.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
// 分页插件
|
||||
interceptor.addInnerInterceptor(
|
||||
new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、IdGenerator.java
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 业务单号生成器(无分布式要求,单机递增序列即可)。
|
||||
* 格式示例:
|
||||
* 订单号 ORD20260316143022000001
|
||||
* 退款号 REF20260316143022000001
|
||||
* 充值号 RCH20260316143022000001
|
||||
*/
|
||||
@Component
|
||||
public class IdGenerator {
|
||||
|
||||
private static final DateTimeFormatter FMT =
|
||||
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
private final AtomicInteger seq = new AtomicInteger(0);
|
||||
|
||||
private String next(String prefix) {
|
||||
int s = seq.incrementAndGet() % 1_000_000;
|
||||
return prefix + LocalDateTime.now().format(FMT) + String.format("%06d", s);
|
||||
}
|
||||
|
||||
public String generateOrderNo() { return next("ORD"); }
|
||||
public String generateRefundNo() { return next("REF"); }
|
||||
public String generateRechargeNo(){ return next("RCH"); }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、核心 pom.xml 依赖
|
||||
|
||||
```xml
|
||||
<dependencies>
|
||||
<!-- Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MyBatis Plus -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>3.5.7</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-pool2</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Jackson Java Time -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、application.yml 完整示例
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/openclaw?useSSL=false&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: your_password
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
global-config:
|
||||
db-config:
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
jwt:
|
||||
secret: change-this-to-a-256-bit-random-secret
|
||||
expire-ms: 86400000
|
||||
|
||||
invite:
|
||||
inviter-points: 50
|
||||
invitee-points: 30
|
||||
url-prefix: https://app.openclaw.com/invite/
|
||||
|
||||
recharge:
|
||||
tiers:
|
||||
- amount: 10
|
||||
bonusPoints: 10
|
||||
- amount: 50
|
||||
bonusPoints: 60
|
||||
- amount: 100
|
||||
bonusPoints: 150
|
||||
- amount: 500
|
||||
bonusPoints: 800
|
||||
- amount: 1000
|
||||
bonusPoints: 2000
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
Reference in New Issue
Block a user