Initial commit

This commit is contained in:
Developer
2026-03-17 12:09:43 +08:00
commit 70bedcf241
211 changed files with 31464 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(cd:*)",
"Bash(cat:*)"
]
}
}

View 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

View 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

View 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

View 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');
```

View 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 |
> 到账总积分 = 充值金额 × 1001元=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修复字段缺失问题

View 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;
}
}
```

View File

@@ -0,0 +1,374 @@
# 用户服务开发文档 - Part 2Controller + 通用工具)
## 五、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.javaJWT认证拦截器
```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

View 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

View 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

View File

@@ -0,0 +1,238 @@
# 订单服务开发文档 - Part 1Entity + 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

View File

@@ -0,0 +1,288 @@
# 订单服务开发文档 - Part 2Service实现 + 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

View 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

View 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

View 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

View File

@@ -0,0 +1,272 @@
# 管理后台开发文档 - Part 2AdminService 接口 + 实现)
## 一、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

View File

@@ -0,0 +1,157 @@
# 管理后台开发文档 - Part 3AdminController
## 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

View 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

View File

@@ -0,0 +1,174 @@
# 通用基础设施开发文档 - Part 2JWT + 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

View File

@@ -0,0 +1,264 @@
# 通用基础设施开发文档 - Part 3Redis + 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