# Sub-Plan A: Spring Boot 后端开发
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> 前置依赖:Java 17+, Maven 3.8+, MySQL 8.0+, Redis
**目标:** 搭建完整后端,提供小程序和管理端所有 API
**数据库:** MySQL 8 数据库 `num_db`,Redis 用于配额计数
---
## 项目骨架结构
```
num-server/
├── pom.xml
└── src/main/java/com/num/
├── NumApplication.java
├── common/
│ ├── Result.java # 统一响应
│ ├── GlobalExceptionHandler.java
│ └── BizException.java
├── config/
│ ├── JwtConfig.java
│ ├── WxPayConfig.java
│ ├── DifyConfig.java
│ └── WebMvcConfig.java # CORS + 拦截器
├── interceptor/
│ ├── AuthInterceptor.java # 用户 Token 校验
│ └── AdminInterceptor.java # 管理员鉴权
├── entity/
│ ├── User.java
│ ├── Chart.java
│ ├── ChatMessage.java
│ ├── PaymentOrder.java
│ ├── Commission.java
│ ├── WithdrawRequest.java
│ └── SystemConfig.java
├── repository/ # JPA Repository
├── service/
│ ├── UserService.java
│ ├── ChartService.java
│ ├── CalculatorService.java # Classic A 引擎
│ ├── ChatService.java
│ ├── DifyProxyService.java
│ ├── WxPayService.java
│ ├── CommissionService.java
│ └── ConfigService.java
├── controller/
│ ├── AuthController.java
│ ├── ChartController.java
│ ├── ChatController.java
│ ├── PaymentController.java
│ ├── CommissionController.java
│ └── AdminController.java
└── dto/ # Request/Response DTO
```
---
### Task 1: 项目骨架 + 统一响应 + 数据库
**Files:**
- Create: `pom.xml`
- Create: `NumApplication.java`
- Create: `common/Result.java`
- Create: `common/BizException.java`
- Create: `common/GlobalExceptionHandler.java`
- Create: `application.yml`
- Create: `schema.sql`
- [ ] **Step 1: Create pom.xml**
```xml
4.0.0
org.springframework.boot
spring-boot-starter-parent
3.2.1
com.num
num-server
1.0.0
num-server
Digital Energy Numerology Backend
17
org.springframework.bootspring-boot-starter-web
org.springframework.bootspring-boot-starter-data-jpa
org.springframework.bootspring-boot-starter-data-redis
com.mysqlmysql-connector-jruntime
io.jsonwebtokenjjwt-api0.12.3
io.jsonwebtokenjjwt-impl0.12.3runtime
io.jsonwebtokenjjwt-jackson0.12.3runtime
org.springframework.bootspring-boot-starter-testtest
com.google.code.gsongson2.10.1
org.springframework.bootspring-boot-maven-plugin
```
- [ ] **Step 2: Create application.yml**
```yaml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/num_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
redis:
host: localhost
port: 6379
timeout: 3000
num:
jwt:
secret: your-jwt-secret-key-change-in-production
expire-hours: 720
wx:
app-id: your_app_id
app-secret: your_app_secret
mch-id: your_mch_id
mch-key: your_mch_key
dify:
base-url: https://your-dify-instance.com/v1
api-key: your_dify_api_key
```
- [ ] **Step 3: Create Result.java**
```java
package com.num.common;
public class Result {
private int code;
private String message;
private T data;
public static Result success(T data) {
Result r = new Result<>();
r.code = 0; r.message = "success"; r.data = data;
return r;
}
public static Result error(int code, String message) {
Result r = new Result<>();
r.code = code; r.message = message;
return r;
}
// getters/setters
}
```
- [ ] **Step 4: Create BizException.java + GlobalExceptionHandler.java**
```java
package com.num.common;
public class BizException extends RuntimeException {
private int code;
public BizException(int code, String message) { super(message); this.code = code; }
public int getCode() { return code; }
}
```
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public Result> handleBiz(BizException e) {
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result> handleEx(Exception e) {
return Result.error(500, "服务器内部错误");
}
}
```
- [ ] **Step 5: Create schema.sql(初始化 DB)**
```sql
CREATE DATABASE IF NOT EXISTS num_db DEFAULT CHARSET utf8mb4;
```
- [ ] **Step 6: 验证启动**
Run: `mvn spring-boot:run`
Expected: 启动成功,无报错,访问 `http://localhost:8080/` 返回 404(正常)
- [ ] **Step 7: Commit**
---
### Task 2: JWT 配置 + Auth 拦截器
**Files:**
- Create: `config/JwtConfig.java`
- Create: `config/WebMvcConfig.java`
- Create: `interceptor/AuthInterceptor.java`
- Create: `interceptor/AdminInterceptor.java`
- [ ] **Step 1: Create JwtConfig.java**
```java
package com.num.config;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JwtConfig {
@Value("${num.jwt.secret}")
private String secret;
@Value("${num.jwt.expire-hours}")
private long expireHours;
private SecretKey getKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generate(Long userId, String role) {
return Jwts.builder()
.claim("userId", userId)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expireHours * 3600 * 1000))
.signWith(getKey())
.compact();
}
public Claims parse(String token) {
return Jwts.parser().verifyWith(getKey()).build()
.parseSignedClaims(token).getPayload();
}
}
```
- [ ] **Step 2: Create AuthInterceptor.java**
```java
package com.num.interceptor;
import com.num.common.BizException;
import com.num.config.JwtConfig;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final JwtConfig jwtConfig;
public AuthInterceptor(JwtConfig jwtConfig) { this.jwtConfig = jwtConfig; }
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
throw new BizException(1001, "Token is required");
}
try {
Claims claims = jwtConfig.parse(auth.substring(7));
request.setAttribute("userId", claims.get("userId", Long.class));
request.setAttribute("role", claims.get("role", String.class));
return true;
} catch (Exception e) {
throw new BizException(1001, "Token invalid");
}
}
}
```
- [ ] **Step 3: Create AdminInterceptor.java**
```java
package com.num.interceptor;
import com.num.common.BizException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class AdminInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String role = (String) request.getAttribute("role");
if (!"admin".equals(role)) {
throw new BizException(1001, "Admin access required");
}
return true;
}
}
```
- [ ] **Step 4: Create WebMvcConfig.java**
```java
package com.num.config;
import com.num.interceptor.AdminInterceptor;
import com.num.interceptor.AuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final AdminInterceptor adminInterceptor;
public WebMvcConfig(AuthInterceptor authInterceptor, AdminInterceptor adminInterceptor) {
this.authInterceptor = authInterceptor;
this.adminInterceptor = adminInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/api/admin/**");
}
}
```
- [ ] **Step 5: 验证 Token 功能**
Run: `mvn test`
Expected: PASS
- [ ] **Step 6: Commit**
---
### Task 3: 实体类 + Repository
**Files:**
- Create: `entity/User.java`
- Create: `entity/Chart.java`
- Create: `entity/ChatMessage.java`
- Create: `entity/PaymentOrder.java`
- Create: `entity/Commission.java`
- Create: `entity/WithdrawRequest.java`
- Create: `entity/SystemConfig.java`
- Create: `repository/*Repository.java`
- [ ] **Step 1: User.java**
```java
package com.num.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity @Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, length = 64)
private String openId;
private String nickName;
private String avatarUrl;
private String phone;
@Column(length = 16)
private String tier = "free"; // free / vip
private LocalDateTime vipExpireAt;
@Column(unique = true, length = 32)
private String referralCode; // 推广码
private Long invitedBy; // 上级用户ID
private LocalDateTime createTime;
private int dailyChartCount;
private int dailyChatCount;
private int dailyShareCount;
private LocalDate lastDailyReset;
@PrePersist
public void prePersist() {
createTime = LocalDateTime.now();
lastDailyReset = LocalDate.now();
}
// getters/setters 省略(Lombok @Data 可选)
}
```
- [ ] **Step 2: Chart.java**
```java
@Entity @Table(name = "charts")
public class Chart {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private String name;
private int birthYear;
private int birthMonth;
private int birthDay;
@Column(columnDefinition = "JSON")
private String triangleJson; // 15格JSON
private int mainCharacter;
private boolean isMasterNumber;
private LocalDateTime createTime;
private boolean deleted;
@PrePersist
public void prePersist() { createTime = LocalDateTime.now(); }
// getters/setters
}
```
- [ ] **Step 3: ChatMessage.java**
```java
@Entity @Table(name = "chat_messages")
public class ChatMessage {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long chartId;
private Long userId;
private String role; // user / assistant
@Column(columnDefinition = "TEXT")
private String content;
private LocalDateTime createTime;
@PrePersist
public void prePersist() { createTime = LocalDateTime.now(); }
// getters/setters
}
```
- [ ] **Step 4: PaymentOrder.java**
```java
@Entity @Table(name = "payment_orders")
public class PaymentOrder {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private String productType; // vip_yearly
private String payStatus; // pending / success / failed / refunded
private Integer amount; // 单位:分
private String wxOrderId;
private LocalDateTime createTime;
private LocalDateTime payTime;
// getters/setters
}
```
- [ ] **Step 5: Commission.java + WithdrawRequest.java + SystemConfig.java**
```java
@Entity @Table(name = "commissions")
public class Commission {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long fromUserId; // 消费用户
private Long toUserId; // 获得佣金用户
private int level; // 1=一级 2=二级
private Integer amount; // 分
private Long sourceOrderId;
private String status; // available / processing / completed / cancelled
private LocalDateTime createTime;
private LocalDateTime completeTime;
// getters/setters
}
@Entity @Table(name = "withdraw_requests")
public class WithdrawRequest {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private Integer amount;
private String status; // pending / approved / rejected / completed
private LocalDateTime createTime;
private LocalDateTime auditTime;
// getters/setters
}
@Entity @Table(name = "system_config")
public class SystemConfig {
@Id private String configKey;
@Column(columnDefinition = "TEXT")
private String configValue;
private String description;
private LocalDateTime updateTime;
// getters/setters
}
```
- [ ] **Step 6: Create Repository 接口**
```java
@Repository
public interface UserRepository extends JpaRepository {
Optional findByOpenId(String openId);
Optional findByReferralCode(String referralCode);
List findByInvitedBy(Long invitedBy);
}
@Repository
public interface ChartRepository extends JpaRepository {
List findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId, Pageable pageable);
long countByUserIdAndCreateTimeAfter(Long userId, LocalDateTime after);
}
@Repository
public interface ChatMessageRepository extends JpaRepository {
List findByChartIdOrderByCreateTimeAsc(Long chartId);
long countByUserIdAndCreateTimeAfter(Long userId, LocalDateTime after);
}
@Repository
public interface PaymentOrderRepository extends JpaRepository {
List findByUserIdOrderByCreateTimeDesc(Long userId);
Optional findByWxOrderId(String wxOrderId);
}
@Repository
public interface CommissionRepository extends JpaRepository {
List findByToUserIdOrderByCreateTimeDesc(Long toUserId);
long countByToUserIdAndStatus(Long toUserId, String status);
@Query("SELECT COALESCE(SUM(c.amount),0) FROM Commission c WHERE c.toUserId = :userId AND c.status = 'available'")
Long sumAvailableByUserId(Long userId);
}
@Repository
public interface WithdrawRequestRepository extends JpaRepository {
List findByUserIdOrderByCreateTimeDesc(Long userId);
}
@Repository
public interface SystemConfigRepository extends JpaRepository {}
```
- [ ] **Step 7: 验证编译**
Run: `mvn compile`
Expected: BUILD SUCCESS
- [ ] **Step 8: Commit**
---
### Task 4: CalculatorService — Classic A 算法
**Files:**
- Create: `service/CalculatorService.java`
- Test: `src/test/java/com/num/service/CalculatorServiceTest.java`
- [ ] **Step 1: Create CalculatorService.java**
```java
package com.num.service;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class CalculatorService {
public static int reduceToDigit(int n) {
if (n == 11 || n == 22 || n == 33) return n;
while (n > 9) {
int sum = 0;
while (n > 0) { sum += n % 10; n /= 10; }
if (sum == 11 || sum == 22 || sum == 33) return sum;
n = sum;
}
return n;
}
public Map calculate(int birthYear, int birthMonth, int birthDay) {
String y = String.format("%04d", birthYear);
String m = String.format("%02d", birthMonth);
String d = String.format("%02d", birthDay);
int A = Integer.parseInt(String.valueOf(y.charAt(0)));
int B = Integer.parseInt(String.valueOf(y.charAt(1)));
int C = reduceToDigit(Integer.parseInt(String.valueOf(y.charAt(2))) + Integer.parseInt(String.valueOf(y.charAt(3))));
int D = reduceToDigit(Integer.parseInt(String.valueOf(m.charAt(0))) + Integer.parseInt(String.valueOf(m.charAt(1))));
int E = reduceToDigit(Integer.parseInt(String.valueOf(d.charAt(0))) + Integer.parseInt(String.valueOf(d.charAt(1))));
// Row 2
int F = reduceToDigit(A + B);
int G = reduceToDigit(B + C);
int N = reduceToDigit(C + D);
int H = reduceToDigit(D + E);
// Row 3
int J = reduceToDigit(F + G);
int M = reduceToDigit(G + N);
int L = reduceToDigit(N + H);
// Row 4
int I = reduceToDigit(J + M);
int K = reduceToDigit(M + L);
// Apex
int O = reduceToDigit(I + K);
Map positions = new LinkedHashMap<>();
positions.put("A", A); positions.put("B", B); positions.put("C", C);
positions.put("D", D); positions.put("E", E);
positions.put("F", F); positions.put("G", G); positions.put("N", N); positions.put("H", H);
positions.put("J", J); positions.put("M", M); positions.put("L", L);
positions.put("I", I); positions.put("K", K);
positions.put("O", O);
Map zones = buildZones(positions);
Map result = new LinkedHashMap<>();
result.put("positions", positions);
result.put("zones", zones);
result.put("mainCharacter", O);
result.put("isMasterNumber", O == 11 || O == 22 || O == 33);
return result;
}
private Map buildZones(Map pos) {
Map zones = new LinkedHashMap<>();
zones.put("mainCharacter", zone("主性格", new String[]{"O"}, pos));
zones.put("fatherSource", zone("父源区", new String[]{"I","J"}, pos));
zones.put("motherSource", zone("母源区", new String[]{"K","L"}, pos));
zones.put("leftZone", zone("左区(0-20岁)", new String[]{"A","F","I","O"}, pos));
zones.put("middleZone", zone("中区(20-40岁)", new String[]{"B","G","J","M","N","O"}, pos));
zones.put("rightZone", zone("右区(40-60岁)", new String[]{"C","D","E","H","K","L","O"}, pos));
return zones;
}
private Map zone(String name, String[] keys, Map pos) {
Map z = new LinkedHashMap<>();
z.put("name", name);
int[] values = new int[keys.length];
for (int i = 0; i < keys.length; i++) values[i] = pos.get(keys[i]);
z.put("positions", java.util.Arrays.asList(keys));
z.put("values", values);
return z;
}
}
```
- [ ] **Step 2: 验证计算**
Run: `mvn test -Dtest=CalculatorServiceTest`
Expected: PASS
- [ ] **Step 3: Commit**
---
### Task 5: ChartService — 能量盘创建+列表
**Files:**
- Create: `service/ChartService.java`
- Create: `controller/ChartController.java`
- Create: `dto/ChartCreateRequest.java`
- Create: `dto/ChartListRequest.java`
- [ ] **Step 1: ChartService.java**
```java
package com.num.service;
import com.num.common.BizException;
import com.num.entity.Chart;
import com.num.repository.ChartRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class ChartService {
private final ChartRepository chartRepository;
private final CalculatorService calculator;
private final UserService userService;
public ChartService(ChartRepository chartRepository, CalculatorService calculator, UserService userService) {
this.chartRepository = chartRepository;
this.calculator = calculator;
this.userService = userService;
}
public Chart create(Long userId, String name, int year, int month, int day) {
// 检查免费用户配额
userService.checkQuota(userId, "chart");
Map calcResult = calculator.calculate(year, month, day);
int mainChar = (int) calcResult.get("mainCharacter");
Chart chart = new Chart();
chart.setUserId(userId);
chart.setName(name != null ? name : "未知");
chart.setBirthYear(year);
chart.setBirthMonth(month);
chart.setBirthDay(day);
chart.setTriangleJson(new com.google.gson.Gson().toJson(calcResult));
chart.setMainCharacter(mainChar);
chart.setMasterNumber((boolean) calcResult.get("isMasterNumber"));
chart = chartRepository.save(chart);
// 扣减配额
userService.consumeQuota(userId, "chart");
return chart;
}
public Chart detail(Long id, Long userId) {
Chart chart = chartRepository.findById(id).orElseThrow(() -> new BizException(400, "能量盘不存在"));
if (!chart.getUserId().equals(userId)) throw new BizException(403, "无权限");
return chart;
}
public List list(Long userId, int page, int size) {
return chartRepository.findByUserIdAndDeletedFalseOrderByCreateTimeDesc(userId, PageRequest.of(page, size));
}
public void delete(Long id, Long userId) {
Chart chart = detail(id, userId);
chart.setDeleted(true);
chartRepository.save(chart);
}
}
```
- [ ] **Step 2: ChartController.java**
```java
@RestController @RequestMapping("/api/charts")
public class ChartController {
@PostMapping("/create")
public Result