2026-05-26-backend-springboot.md 40 KB

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 version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.1</version>
    </parent>
    <groupId>com.num</groupId>
    <artifactId>num-server</artifactId>
    <version>1.0.0</version>
    <name>num-server</name>
    <description>Digital Energy Numerology Backend</description>
    <properties><java.version>17</java.version></properties>
    <dependencies>
        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
        <dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
        <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.12.3</version></dependency>
        <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.12.3</version><scope>runtime</scope></dependency>
        <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.12.3</version><scope>runtime</scope></dependency>
        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
        <dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.10.1</version></dependency>
    </dependencies>
    <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
    </project>
    
  • [ ] Step 2: Create application.yml

    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

    package com.num.common;
    
    public class Result<T> {
    private int code;
    private String message;
    private T data;
    
    public static <T> Result<T> success(T data) {
        Result<T> r = new Result<>();
        r.code = 0; r.message = "success"; r.data = data;
        return r;
    }
    public static <T> Result<T> error(int code, String message) {
        Result<T> r = new Result<>();
        r.code = code; r.message = message;
        return r;
    }
    // getters/setters
    }
    
  • [ ] Step 4: Create BizException.java + GlobalExceptionHandler.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; }
    }
    
    @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)

    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

    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

    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

    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

    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

    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

    @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

    @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

    @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

    @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 接口

    @Repository
    public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByOpenId(String openId);
    Optional<User> findByReferralCode(String referralCode);
    List<User> findByInvitedBy(Long invitedBy);
    }
    
    @Repository
    public interface ChartRepository extends JpaRepository<Chart, Long> {
    List<Chart> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId, Pageable pageable);
    long countByUserIdAndCreateTimeAfter(Long userId, LocalDateTime after);
    }
    
    @Repository
    public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
    List<ChatMessage> findByChartIdOrderByCreateTimeAsc(Long chartId);
    long countByUserIdAndCreateTimeAfter(Long userId, LocalDateTime after);
    }
    
    @Repository
    public interface PaymentOrderRepository extends JpaRepository<PaymentOrder, Long> {
    List<PaymentOrder> findByUserIdOrderByCreateTimeDesc(Long userId);
    Optional<PaymentOrder> findByWxOrderId(String wxOrderId);
    }
    
    @Repository
    public interface CommissionRepository extends JpaRepository<Commission, Long> {
    List<Commission> 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<WithdrawRequest, Long> {
    List<WithdrawRequest> findByUserIdOrderByCreateTimeDesc(Long userId);
    }
    
    @Repository
    public interface SystemConfigRepository extends JpaRepository<SystemConfig, String> {}
    
  • [ ] 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

    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<String, Object> 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<String, Integer> 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<String, Object> zones = buildZones(positions);
    
        Map<String, Object> 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<String, Object> buildZones(Map<String, Integer> pos) {
        Map<String, Object> 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<String, Object> zone(String name, String[] keys, Map<String, Integer> pos) {
        Map<String, Object> 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

    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<String, Object> 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<Chart> 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

    @RestController @RequestMapping("/api/charts")
    public class ChartController {
    @PostMapping("/create")
    public Result<Map<String, Object>> create(HttpServletRequest req, @RequestBody ChartCreateRequest r) {
        Long userId = (Long) req.getAttribute("userId");
        Chart chart = chartService.create(userId, r.getName(), r.getBirthYear(), r.getBirthMonth(), r.getBirthDay());
        return Result.success(Map.of("id", chart.getId(), "mainCharacter", chart.getMainCharacter(),
            "triangleJson", chart.getTriangleJson(), "createTime", chart.getCreateTime().toString()));
    }
    
    @PostMapping("/detail")
    public Result<Chart> detail(HttpServletRequest req, @RequestBody Map<String, Long> body) {
        return Result.success(chartService.detail(body.get("id"), (Long) req.getAttribute("userId")));
    }
    
    @PostMapping("/list")
    public Result<List<Chart>> list(HttpServletRequest req, @RequestBody Map<String, Integer> body) {
        return Result.success(chartService.list((Long) req.getAttribute("userId"),
            body.getOrDefault("page", 0), body.getOrDefault("size", 20)));
    }
    
    @PostMapping("/delete")
    public Result<Void> delete(HttpServletRequest req, @RequestBody Map<String, Long> body) {
        chartService.delete(body.get("id"), (Long) req.getAttribute("userId"));
        return Result.success(null);
    }
    }
    
  • [ ] Step 3: 验证编译

Run: mvn compile Expected: BUILD SUCCESS

  • Step 4: Commit

Task 6: UserService — 登录 + 配额 + 推广码

Files:

  • Create: service/UserService.java
  • Create: dto/LoginRequest.java
  • Create: controller/AuthController.java

  • [ ] Step 1: UserService.java

    @Service
    public class UserService {
    private final UserRepository userRepository;
    private final JwtConfig jwtConfig;
    private final RedisTemplate<String, Integer> redis;
    private final ConfigService configService;
    
    public Map<String, Object> login(String code, String referrerCode) {
        // 调微信API获取 openId(简化示例,实际需调用 https://api.weixin.qq.com/sns/jscode2session)
        String openId = wxCodeToOpenId(code);
    
        User user = userRepository.findByOpenId(openId).orElseGet(() -> register(openId, referrerCode));
    
        String token = jwtConfig.generate(user.getId(), user.getTier());
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("token", token);
        data.put("user", Map.of("id", user.getId(), "nickName", user.getNickName(),
            "tier", user.getTier(), "vipExpireAt", user.getVipExpireAt()));
        return data;
    }
    
    private User register(String openId, String referrerCode) {
        User user = new User();
        user.setOpenId(openId);
        user.setTier("free");
        user.setReferralCode(generateReferralCode());
        if (referrerCode != null) {
            userRepository.findByReferralCode(referrerCode).ifPresent(ref -> user.setInvitedBy(ref.getId()));
        }
        return userRepository.save(user);
    }
    
    private String generateReferralCode() {
        return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
    }
    
    public void checkQuota(Long userId, String type) {
        User user = userRepository.findById(userId).orElseThrow();
        if ("vip".equals(user.getTier())) return;
        resetDailyIfNeeded(user);
        int max = configService.getQuota(type);
        int used = type.equals("chart") ? user.getDailyChartCount()
                : type.equals("chat") ? user.getDailyChatCount() : user.getDailyShareCount();
        if (used >= max) throw new BizException(2001, "每日" + type + "次数已达上限");
    }
    
    public void consumeQuota(Long userId, String type) {
        User user = userRepository.findById(userId).orElseThrow();
        if ("vip".equals(user.getTier())) return;
        resetDailyIfNeeded(user);
        if (type.equals("chart")) user.setDailyChartCount(user.getDailyChartCount() + 1);
        else if (type.equals("chat")) user.setDailyChatCount(user.getDailyChatCount() + 1);
        else user.setDailyShareCount(user.getDailyShareCount() + 1);
        userRepository.save(user);
    }
    
    private void resetDailyIfNeeded(User user) {
        LocalDate today = LocalDate.now();
        if (!today.equals(user.getLastDailyReset())) {
            user.setDailyChartCount(0);
            user.setDailyChatCount(0);
            user.setDailyShareCount(0);
            user.setLastDailyReset(today);
        }
    }
    }
    
  • [ ] Step 2: AuthController.java

    @RestController @RequestMapping("/api/auth")
    public class AuthController {
    @PostMapping("/login")
    public Result<Map<String, Object>> login(@RequestBody Map<String, String> body) {
        return Result.success(userService.login(body.get("code"), body.get("referrerCode")));
    }
    }
    
  • [ ] Step 3: 验证

Run: mvn compile Expected: BUILD SUCCESS

  • Step 4: Commit

Task 7: ChatService + DifyProxyService

Files:

  • Create: service/DifyProxyService.java
  • Create: service/ChatService.java
  • Create: config/DifyConfig.java
  • Create: controller/ChatController.java
  • Create: dto/ChatSendRequest.java

  • [ ] Step 1: DifyConfig.java

    @Configuration
    public class DifyConfig {
    @Value("${num.dify.base-url}")
    private String baseUrl;
    @Value("${num.dify.api-key}")
    private String apiKey;
    public String getBaseUrl() { return baseUrl; }
    public String getApiKey() { return apiKey; }
    }
    
  • [ ] Step 2: DifyProxyService.java

    @Service
    public class DifyProxyService {
    private final DifyConfig difyConfig;
    private final RestTemplate restTemplate = new RestTemplate();
    
    public String sendMessage(Map<String, Object> chartData, String userMessage, List<Map<String, String>> history) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer " + difyConfig.getApiKey());
    
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("inputs", Map.of("chart_data", chartData));
        body.put("query", userMessage);
        body.put("response_mode", "blocking");
        body.put("conversation_id", "");
    
        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
        ResponseEntity<Map> resp = restTemplate.postForEntity(difyConfig.getBaseUrl() + "/chat-messages", entity, Map.class);
    
        Map<String, Object> respBody = resp.getBody();
        if (respBody != null && respBody.containsKey("answer")) {
            return (String) respBody.get("answer");
        }
        throw new BizException(5001, "Dify service error");
    }
    }
    
  • [ ] Step 3: ChatService.java

    @Service
    public class ChatService {
    private final ChatMessageRepository chatMessageRepository;
    private final DifyProxyService difyProxy;
    private final ChartService chartService;
    private final UserService userService;
    
    public String sendMessage(Long userId, Long chartId, String message) {
        userService.checkQuota(userId, "chat");
    
        // Save user message
        ChatMessage userMsg = new ChatMessage();
        userMsg.setChartId(chartId); userMsg.setUserId(userId);
        userMsg.setRole("user"); userMsg.setContent(message);
        chatMessageRepository.save(userMsg);
    
        // Load chart data
        Chart chart = chartService.detail(chartId, userId);
        Map<String, Object> chartData = new com.google.gson.Gson().fromJson(chart.getTriangleJson(), Map.class);
    
        // Load recent history
        List<ChatMessage> recent = chatMessageRepository.findByChartIdOrderByCreateTimeAsc(chartId);
        List<Map<String, String>> history = new java.util.ArrayList<>();
        for (ChatMessage m : recent.subList(Math.max(0, recent.size() - 11), recent.size() - 1)) {
            history.add(Map.of("role", m.getRole(), "content", m.getContent()));
        }
    
        // Call Dify
        String reply = difyProxy.sendMessage(chartData, message, history);
    
        // Save assistant message
        ChatMessage aiMsg = new ChatMessage();
        aiMsg.setChartId(chartId); aiMsg.setUserId(userId);
        aiMsg.setRole("assistant"); aiMsg.setContent(reply);
        chatMessageRepository.save(aiMsg);
    
        userService.consumeQuota(userId, "chat");
        return reply;
    }
    }
    
  • [ ] Step 4: ChatController.java

    @RestController @RequestMapping("/api/chat")
    public class ChatController {
    @PostMapping("/send")
    public Result<Map<String, Object>> send(HttpServletRequest req, @RequestBody ChatSendRequest r) {
        Long userId = (Long) req.getAttribute("userId");
        String reply = chatService.sendMessage(userId, r.getChartId(), r.getMessage());
        // 返回剩余配额
        return Result.success(Map.of("reply", reply));
    }
    
    @PostMapping("/history")
    public Result<List<ChatMessage>> history(@RequestBody Map<String, Long> body) {
        return Result.success(chatMessageRepository.findByChartIdOrderByCreateTimeAsc(body.get("chartId")));
    }
    }
    
  • [ ] Step 5: Commit


Task 8: WxPayService + PaymentController

Files:

  • Create: service/WxPayService.java
  • Create: config/WxPayConfig.java
  • Create: controller/PaymentController.java

  • [ ] Step 1: WxPayConfig.java

    @Configuration
    public class WxPayConfig {
    @Value("${num.wx.app-id}") private String appId;
    @Value("${num.wx.mch-id}") private String mchId;
    @Value("${num.wx.mch-key}") private String mchKey;
    // getters
    }
    
  • [ ] Step 2: WxPayService.java(简化,微信支付统一下单)

    @Service
    public class WxPayService {
    private final WxPayConfig wxPayConfig;
    private final PaymentOrderRepository orderRepository;
    private final UserRepository userRepository;
    private final CommissionService commissionService;
    
    public Map<String, Object> unifiedOrder(Long userId, String productType) {
        int amount = 39800; // ¥398 in 分
        String outTradeNo = System.currentTimeMillis() + "_" + userId;
    
        PaymentOrder order = new PaymentOrder();
        order.setUserId(userId);
        order.setProductType(productType);
        order.setPayStatus("pending");
        order.setAmount(amount);
        order.setCreateTime(LocalDateTime.now());
        order = orderRepository.save(order);
    
        // 实际应调微信支付统一下单API,此处返回模拟参数
        Map<String, Object> payParams = new LinkedHashMap<>();
        payParams.put("orderId", order.getId());
        payParams.put("outTradeNo", outTradeNo);
        payParams.put("totalFee", amount);
        return payParams;
    }
    
    @Transactional
    public void handlePayNotify(String outTradeNo, String wxOrderId) {
        PaymentOrder order = orderRepository.findByWxOrderId(wxOrderId)
            .orElseThrow(() -> new BizException(3001, "订单不存在"));
        order.setPayStatus("success");
        order.setPayTime(LocalDateTime.now());
        orderRepository.save(order);
    
        // 更新用户为VIP
        User user = userRepository.findById(order.getUserId()).orElseThrow();
        user.setTier("vip");
        user.setVipExpireAt(LocalDateTime.now().plusYears(1));
        userRepository.save(user);
    
        // 处理分销佣金
        commissionService.handleUpgradeCommission(user.getId(), order.getId(), order.getAmount());
    }
    }
    
  • [ ] Step 3: PaymentController.java

    @RestController @RequestMapping("/api/pay")
    public class PaymentController {
    @PostMapping("/unified-order")
    public Result<Map<String, Object>> unifiedOrder(HttpServletRequest req, @RequestBody Map<String, String> body) {
        return Result.success(wxPayService.unifiedOrder((Long) req.getAttribute("userId"), body.get("productType")));
    }
    
    @PostMapping("/notify")   // 微信支付回调
    public Result<Void> payNotify(@RequestBody Map<String, String> notifyData) {
        wxPayService.handlePayNotify(notifyData.get("outTradeNo"), notifyData.get("wxOrderId"));
        return Result.success(null);
    }
    }
    
  • [ ] Step 4: Commit


Task 9: CommissionService + CommissionController

Files:

  • Create: service/CommissionService.java
  • Create: controller/CommissionController.java

  • [ ] Step 1: CommissionService.java

    @Service
    public class CommissionService {
    private final UserRepository userRepository;
    private final CommissionRepository commissionRepository;
    
    @Transactional
    public void handleUpgradeCommission(Long userId, Long orderId, int orderAmount) {
        User user = userRepository.findById(userId).orElseThrow();
        Long inviterId = user.getInvitedBy();
        if (inviterId == null) return;
    
        // 一级佣金 30%(需减去平台固定比例)
        int level1 = (int) (orderAmount * 0.30);
        createCommission(userId, inviterId, 1, level1, orderId);
    
        // 二级佣金 10%
        userRepository.findById(inviterId).ifPresent(inviter -> {
            if (inviter.getInvitedBy() != null) {
                int level2 = (int) (orderAmount * 0.10);
                createCommission(userId, inviter.getInvitedBy(), 2, level2, orderId);
            }
        });
    }
    
    private void createCommission(Long fromUserId, Long toUserId, int level, int amount, Long orderId) {
        Commission c = new Commission();
        c.setFromUserId(fromUserId); c.setToUserId(toUserId);
        c.setLevel(level); c.setAmount(amount);
        c.setSourceOrderId(orderId); c.setStatus("available");
        commissionRepository.save(c);
    }
    
    public Map<String, Object> stats(Long userId) {
        Long available = commissionRepository.sumAvailableByUserId(userId);
        List<Commission> list = commissionRepository.findByToUserIdOrderByCreateTimeDesc(userId);
        long total = list.stream().mapToLong(c -> c.getAmount()).sum();
        Map<String, Object> stats = new LinkedHashMap<>();
        stats.put("totalEarnings", total);
        stats.put("available", available != null ? available : 0);
        stats.put("count", list.size());
        return stats;
    }
    }
    
  • [ ] Step 2: CommissionController.java

    @RestController @RequestMapping("/api/commission")
    public class CommissionController {
    @PostMapping("/stats")
    public Result<Map<String, Object>> stats(HttpServletRequest req) {
        return Result.success(commissionService.stats((Long) req.getAttribute("userId")));
    }
    
    @PostMapping("/list")
    public Result<List<Commission>> list(HttpServletRequest req, @RequestBody Map<String, Integer> body) {
        Long userId = (Long) req.getAttribute("userId");
        List<Commission> list = commissionRepository.findByToUserIdOrderByCreateTimeDesc(userId);
        return Result.success(list);
    }
    }
    
    @RestController @RequestMapping("/api/withdraw")
    public class WithdrawController {
    @PostMapping("/create")
    public Result<Void> create(HttpServletRequest req, @RequestBody Map<String, Integer> body) {
        Long userId = (Long) req.getAttribute("userId");
        int amount = body.get("amount");
        if (amount < 10000) throw new BizException(400, "最低提现 ¥100");
        Long available = commissionRepository.sumAvailableByUserId(userId);
        if (available == null || available < amount) throw new BizException(400, "可提现金额不足");
    
        WithdrawRequest wr = new WithdrawRequest();
        wr.setUserId(userId); wr.setAmount(amount); wr.setStatus("pending");
        withdrawRequestRepository.save(wr);
        return Result.success(null);
    }
    
    @PostMapping("/list")
    public Result<List<WithdrawRequest>> list(HttpServletRequest req) {
        return Result.success(withdrawRequestRepository.findByUserIdOrderByCreateTimeDesc((Long) req.getAttribute("userId")));
    }
    }
    
  • [ ] Step 3: Commit


Task 10: AdminController — 管理端 API

Files:

  • Create: controller/AdminController.java
  • Create: service/ConfigService.java

  • [ ] Step 1: ConfigService.java

    @Service
    public class ConfigService {
    private final SystemConfigRepository configRepository;
    private static final Map<String, Integer> DEFAULT_QUOTA = Map.of("chart", 3, "chat", 5, "share", 1);
    
    public int getQuota(String type) {
        return configRepository.findById("quota_" + type)
            .map(c -> Integer.parseInt(c.getConfigValue()))
            .orElse(DEFAULT_QUOTA.getOrDefault(type, 3));
    }
    
    public Map<String, String> getAll() {
        return configRepository.findAll().stream()
            .collect(Collectors.toMap(SystemConfig::getConfigKey, SystemConfig::getConfigValue));
    }
    
    public void update(String key, String value) {
        SystemConfig config = configRepository.findById(key).orElse(new SystemConfig());
        config.setConfigKey(key); config.setConfigValue(value);
        config.setUpdateTime(LocalDateTime.now());
        configRepository.save(config);
    }
    }
    
  • [ ] Step 2: 验证编译 + 启动测试

Run: mvn clean compile Expected: BUILD SUCCESS

  • Step 3: Commit