فهرست منبع

feat(EPIC 9): 人工方案后端服务+控制器+ChatMessage字段扩展

新增: InterventionService, PlanDeliveryService, 干预/方案/交付控制器
新增: commerce/ 接口层桩实现 (CommerceService, StubCommerceService, DTO)
扩展: ChatMessage.java 新增字段, CalculatorService.java 新增方法

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 ماه پیش
والد
کامیت
1ca86f3578

+ 29 - 0
num-server/src/main/java/com/etotem/num/commerce/CommerceOrderDTO.java

@@ -0,0 +1,29 @@
+package com.etotem.num.commerce;
+
+import java.time.LocalDateTime;
+
+public class CommerceOrderDTO {
+    private String orderSn;
+    private String storeId;
+    private String productType;
+    private Long totalFee; // 分
+    private String status;
+    private Long planRequestId; // for practitioner_plan type
+    private LocalDateTime paidAt;
+    
+    // getters and setters
+    public String getOrderSn() { return orderSn; }
+    public void setOrderSn(String orderSn) { this.orderSn = orderSn; }
+    public String getStoreId() { return storeId; }
+    public void setStoreId(String storeId) { this.storeId = storeId; }
+    public String getProductType() { return productType; }
+    public void setProductType(String productType) { this.productType = productType; }
+    public Long getTotalFee() { return totalFee; }
+    public void setTotalFee(Long totalFee) { this.totalFee = totalFee; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+    public Long getPlanRequestId() { return planRequestId; }
+    public void setPlanRequestId(Long planRequestId) { this.planRequestId = planRequestId; }
+    public LocalDateTime getPaidAt() { return paidAt; }
+    public void setPaidAt(LocalDateTime paidAt) { this.paidAt = paidAt; }
+}

+ 26 - 0
num-server/src/main/java/com/etotem/num/commerce/CommerceProductDTO.java

@@ -0,0 +1,26 @@
+package com.etotem.num.commerce;
+
+import java.time.LocalDateTime;
+
+public class CommerceProductDTO {
+    private String storeId;
+    private String productType;
+    private String productName;
+    private Long price; // 分
+    private String status;
+    private LocalDateTime createdAt;
+    
+    // getters and setters
+    public String getStoreId() { return storeId; }
+    public void setStoreId(String storeId) { this.storeId = storeId; }
+    public String getProductType() { return productType; }
+    public void setProductType(String productType) { this.productType = productType; }
+    public String getProductName() { return productName; }
+    public void setProductName(String productName) { this.productName = productName; }
+    public Long getPrice() { return price; }
+    public void setPrice(Long price) { this.price = price; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+    public LocalDateTime getCreatedAt() { return createdAt; }
+    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+}

+ 25 - 0
num-server/src/main/java/com/etotem/num/commerce/CommerceService.java

@@ -0,0 +1,25 @@
+package com.etotem.num.commerce;
+
+/**
+ * Commerce service interface — reserved for lilishop integration in Phase 2.
+ * Phase 1 uses StubCommerceService.
+ */
+public interface CommerceService {
+    /** Create a product (practitioner lists a service) */
+    String createProduct(CommerceProductDTO product);
+    
+    /** Create an order */
+    String createOrder(CommerceOrderDTO order);
+    
+    /** Handle payment success callback — calculates platform commission */
+    void onPaymentSuccess(String orderSn, String payOrderNo);
+    
+    /** Query order */
+    CommerceOrderDTO getOrder(String orderSn);
+    
+    /** Get store settlement info */
+    CommerceSettlementDTO getSettlement(String storeId);
+    
+    /** Record distribution (referral) for an order */
+    void recordDistribution(String orderSn);
+}

+ 17 - 0
num-server/src/main/java/com/etotem/num/commerce/CommerceSettlementDTO.java

@@ -0,0 +1,17 @@
+package com.etotem.num.commerce;
+
+public class CommerceSettlementDTO {
+    private String storeId;
+    private Long totalAmount; // 分
+    private Long settledAmount;
+    private Long pendingAmount;
+    
+    public String getStoreId() { return storeId; }
+    public void setStoreId(String storeId) { this.storeId = storeId; }
+    public Long getTotalAmount() { return totalAmount; }
+    public void setTotalAmount(Long totalAmount) { this.totalAmount = totalAmount; }
+    public Long getSettledAmount() { return settledAmount; }
+    public void setSettledAmount(Long settledAmount) { this.settledAmount = settledAmount; }
+    public Long getPendingAmount() { return pendingAmount; }
+    public void setPendingAmount(Long pendingAmount) { this.pendingAmount = pendingAmount; }
+}

+ 155 - 0
num-server/src/main/java/com/etotem/num/commerce/StubCommerceService.java

@@ -0,0 +1,155 @@
+package com.etotem.num.commerce;
+
+import com.etotem.num.common.BizException;
+import com.etotem.num.entity.Commission;
+import com.etotem.num.entity.Order;
+import com.etotem.num.entity.PlanRequest;
+import com.etotem.num.entity.User;
+import com.etotem.num.repository.CommissionRepository;
+import com.etotem.num.repository.OrderRepository;
+import com.etotem.num.repository.PlanRequestRepository;
+import com.etotem.num.repository.UserRepository;
+import com.etotem.num.service.ConfigService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Phase 1 stub implementation of CommerceService.
+ * Handles practitioner_plan commission calculation using platform-retention model.
+ * 
+ * US-9.5/9.6 commission model:
+ * - Platform commission rate: commerce.category.practitioner_plan.commission_rate (default 30% = 3000 bps)
+ * - Direct referral from platform commission: commission.practitioner_plan.referral_rate (default 20% = 2000 bps)  
+ * - Upstream referral from platform commission: commission.practitioner_plan.upstream_rate (default 5% = 500 bps)
+ */
+@Service
+public class StubCommerceService implements CommerceService {
+
+    private final OrderRepository orderRepository;
+    private final PlanRequestRepository planRequestRepository;
+    private final CommissionRepository commissionRepository;
+    private final UserRepository userRepository;
+    private final ConfigService configService;
+
+    public StubCommerceService(OrderRepository orderRepository, PlanRequestRepository planRequestRepository,
+                               CommissionRepository commissionRepository, UserRepository userRepository,
+                               ConfigService configService) {
+        this.orderRepository = orderRepository;
+        this.planRequestRepository = planRequestRepository;
+        this.commissionRepository = commissionRepository;
+        this.userRepository = userRepository;
+        this.configService = configService;
+    }
+
+    @Override
+    public String createProduct(CommerceProductDTO product) {
+        // Phase 1: Just log — no lilishop integration
+        return "stub-product-" + System.currentTimeMillis();
+    }
+
+    @Override
+    public String createOrder(CommerceOrderDTO order) {
+        // Phase 1: Use existing local orders table, mark commerceReady=false
+        return "stub-order-" + System.currentTimeMillis();
+    }
+
+    @Override
+    @Transactional
+    public void onPaymentSuccess(String orderSn, String payOrderNo) {
+        // Find the local order by outTradeNo
+        Order order = orderRepository.findByOutTradeNo(orderSn)
+                .orElseThrow(() -> new BizException(1005, "Order not found"));
+
+        // Only process practitioner_plan orders
+        if (!"practitioner_plan".equals(order.getProductType())) {
+            return; // Skip non-plan orders
+        }
+
+        // Idempotency: skip if already processed
+        List<Commission> existing = commissionRepository.findByOrderId(order.getId());
+        if (!existing.isEmpty()) {
+            return;
+        }
+
+        // Get plan request via orderId FK (added in Wave 14)
+        Optional<PlanRequest> opt = planRequestRepository.findByOrderId(order.getId());
+        if (!opt.isPresent()) {
+            return; // No linked plan request
+        }
+        PlanRequest planRequest = opt.get();
+        if (!"pending_payment".equals(planRequest.getStatus())) {
+            return; // Not in pending_payment state
+        }
+
+        int totalFee = order.getTotalFee();
+        
+        // Read commission rates from config (in basis points / 10000)
+        int platformRate = configService.getInt("commerce.category.practitioner_plan.commission_rate", 3000);
+        int referralRate = configService.getInt("commission.practitioner_plan.referral_rate", 2000);
+        int upstreamRate = configService.getInt("commission.practitioner_plan.upstream_rate", 500);
+
+        int platformCommission = totalFee * platformRate / 10000;
+        // practitionerSettlement = totalFee - platformCommission (practitioner's share, tracked in Phase 2)
+
+        // Update plan request with final price and status
+        planRequest.setPrice(totalFee);
+        planRequest.setStatus("paid");
+        planRequestRepository.save(planRequest);
+
+        // Distribute referral commissions from platform commission
+        User buyer = userRepository.findById(order.getUserId()).orElse(null);
+        if (buyer != null && buyer.getInvitedBy() != null) {
+            User inviter = userRepository.findById(buyer.getInvitedBy()).orElse(null);
+            if (inviter != null && inviter.getReferralCode() != null) {
+                // L1: direct referrer gets referralRate of platform commission
+                int l1Commission = platformCommission * referralRate / 10000;
+                if (l1Commission > 0) {
+                    createCommission(order.getId(), order.getUserId(), inviter.getId(), 1, 
+                            l1Commission, "人工方案直接推荐佣金");
+                }
+
+                // L2: upstream referrer
+                if (inviter.getInvitedBy() != null) {
+                    User inviter2 = userRepository.findById(inviter.getInvitedBy()).orElse(null);
+                    if (inviter2 != null && inviter2.getReferralCode() != null) {
+                        int l2Commission = platformCommission * upstreamRate / 10000;
+                        if (l2Commission > 0) {
+                            createCommission(order.getId(), order.getUserId(), inviter2.getId(), 2,
+                                    l2Commission, "人工方案上级佣金");
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private void createCommission(Long orderId, Long fromUserId, Long toUserId, int level, int amount, String remark) {
+        Commission c = new Commission();
+        c.setOrderId(orderId);
+        c.setFromUserId(fromUserId);
+        c.setToUserId(toUserId);
+        c.setLevel(level);
+        c.setAmount(amount);
+        c.setStatus("settled");
+        c.setRemark(remark);
+        commissionRepository.save(c);
+    }
+
+    @Override
+    public CommerceOrderDTO getOrder(String orderSn) {
+        return null; // Phase 1 stub
+    }
+
+    @Override
+    public CommerceSettlementDTO getSettlement(String storeId) {
+        return null; // Phase 1 stub
+    }
+
+    @Override
+    public void recordDistribution(String orderSn) {
+        // Phase 1: already handled in onPaymentSuccess
+    }
+}

+ 84 - 0
num-server/src/main/java/com/etotem/num/controller/InterventionController.java

@@ -0,0 +1,84 @@
+package com.etotem.num.controller;
+
+import com.etotem.num.common.Result;
+import com.etotem.num.entity.ChatIntervention;
+import com.etotem.num.entity.ChatMessage;
+import com.etotem.num.service.InterventionService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/intervention")
+public class InterventionController {
+
+    private final InterventionService interventionService;
+
+    public InterventionController(InterventionService interventionService) {
+        this.interventionService = interventionService;
+    }
+
+    /**
+     * US-9.3: Get subordinate users' chat list for practitioner monitoring.
+     */
+    @PostMapping("/chat-list")
+    public Result<List<Map<String, Object>>> getSubordinateChatList(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(interventionService.getSubordinateChatList(userId));
+    }
+
+    /**
+     * US-9.3: Get chat history for monitoring (read-only).
+     * Body: { chartRecordId }
+     */
+    @PostMapping("/chat-history")
+    public Result<List<ChatMessage>> getChatHistoryForMonitoring(HttpServletRequest request,
+                                                                 @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long chartRecordId = ((Number) body.get("chartRecordId")).longValue();
+        return Result.success(interventionService.getChatHistoryForMonitoring(userId, chartRecordId));
+    }
+
+    /**
+     * US-9.3: Start an intervention session.
+     * Body: { chartRecordId }
+     */
+    @PostMapping("/start")
+    public Result<ChatIntervention> startIntervention(HttpServletRequest request,
+                                                       @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long chartRecordId = ((Number) body.get("chartRecordId")).longValue();
+        return Result.success(interventionService.startIntervention(userId, chartRecordId));
+    }
+
+    /**
+     * US-9.3: End an intervention session.
+     * Body: { interventionId, endedBy }
+     */
+    @PostMapping("/end")
+    public Result<ChatIntervention> endIntervention(HttpServletRequest request,
+                                                     @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long interventionId = ((Number) body.get("interventionId")).longValue();
+        String endedBy = (String) body.get("endedBy");
+        return Result.success(interventionService.endIntervention(userId, interventionId, endedBy));
+    }
+
+    /**
+     * US-9.3: Send a message during active intervention.
+     * Body: { chartRecordId, content }
+     */
+    @PostMapping("/send")
+    public Result<ChatMessage> sendPractitionerMessage(HttpServletRequest request,
+                                                        @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long chartRecordId = ((Number) body.get("chartRecordId")).longValue();
+        String content = (String) body.get("content");
+        return Result.success(interventionService.sendPractitionerMessage(userId, chartRecordId, content));
+    }
+}

+ 140 - 0
num-server/src/main/java/com/etotem/num/controller/PlanController.java

@@ -0,0 +1,140 @@
+package com.etotem.num.controller;
+
+import com.etotem.num.common.Result;
+import com.etotem.num.entity.PlanRequest;
+import com.etotem.num.entity.PlanRequestLog;
+import com.etotem.num.service.PlanRequestService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/plan")
+public class PlanController {
+
+    private final PlanRequestService planRequestService;
+
+    public PlanController(PlanRequestService planRequestService) {
+        this.planRequestService = planRequestService;
+    }
+
+    /**
+     * US-9.2: Apply for a practitioner plan.
+     * Body: { chartRecordId, requestType, description, budgetRange }
+     * Returns: { planRequest, assigned: true/false, message: "..." }
+     */
+    @PostMapping("/apply")
+    public Result<Map<String, Object>> apply(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long chartId = body.containsKey("chartRecordId")
+                ? ((Number) body.get("chartRecordId")).longValue() : null;
+        String requestType = (String) body.get("requestType");
+        String description = (String) body.get("description");
+        String budgetRange = (String) body.get("budgetRange");
+
+        Map<String, Object> result = planRequestService.applyPlanRequest(userId, chartId, requestType, description, budgetRange);
+        return Result.success(result);
+    }
+
+    /**
+     * Get requests assigned to current user (if practitioner).
+     */
+    @PostMapping("/requests/incoming")
+    public Result<List<PlanRequest>> getIncomingRequests(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        planRequestService.ensurePractitioner(userId);
+        return Result.success(planRequestService.getIncomingRequests(userId));
+    }
+
+    /**
+     * Get my requests (as buyer).
+     */
+    @PostMapping("/requests/my")
+    public Result<List<PlanRequest>> getMyRequests(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(planRequestService.getMyRequests(userId));
+    }
+
+    /**
+     * Accept a request (practitioner).
+     */
+    @PostMapping("/requests/accept")
+    public Result<PlanRequest> acceptRequest(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        planRequestService.ensurePractitioner(userId);
+        Long requestId = ((Number) body.get("requestId")).longValue();
+        return Result.success(planRequestService.acceptRequest(requestId, userId));
+    }
+
+    /**
+     * Reject a request (practitioner).
+     */
+    @PostMapping("/requests/reject")
+    public Result<PlanRequest> rejectRequest(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        planRequestService.ensurePractitioner(userId);
+        Long requestId = ((Number) body.get("requestId")).longValue();
+        return Result.success(planRequestService.rejectRequest(requestId, userId));
+    }
+
+    /**
+     * US-9.4: Create proposal (practitioner submits price proposal).
+     * Body: { requestId, price, message }
+     */
+    @PostMapping("/proposals/create")
+    public Result<PlanRequest> createProposal(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        planRequestService.ensurePractitioner(userId);
+        Long requestId = ((Number) body.get("requestId")).longValue();
+        Integer price = body.get("price") != null ? ((Number) body.get("price")).intValue() : null;
+        String message = (String) body.get("message");
+        return Result.success(planRequestService.submitProposal(requestId, userId, price, message));
+    }
+
+    /**
+     * US-9.4: Counter offer (user counters the practitioner's proposal).
+     * Body: { requestId, newPrice, message }
+     */
+    @PostMapping("/proposals/counter")
+    public Result<PlanRequest> counterOffer(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long requestId = ((Number) body.get("requestId")).longValue();
+        Integer newPrice = body.get("newPrice") != null ? ((Number) body.get("newPrice")).intValue() : null;
+        String message = (String) body.get("message");
+        return Result.success(planRequestService.counterOffer(requestId, userId, newPrice, message));
+    }
+
+    /**
+     * US-9.4: Accept proposal (user accepts the practitioner's proposal).
+     * Body: { requestId }
+     */
+    @PostMapping("/proposals/accept")
+    public Result<Map<String, Object>> acceptProposal(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long requestId = ((Number) body.get("requestId")).longValue();
+        return Result.success(planRequestService.acceptProposal(requestId, userId));
+    }
+
+    /**
+     * US-9.4: Reject proposal (user rejects the practitioner's proposal).
+     * Body: { requestId }
+     */
+    @PostMapping("/proposals/reject")
+    public Result<PlanRequest> rejectProposal(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        Long requestId = ((Number) body.get("requestId")).longValue();
+        return Result.success(planRequestService.rejectProposal(requestId, userId));
+    }
+
+    /**
+     * US-9.4: Get negotiation logs for a request.
+     * Body: { requestId }
+     */
+    @PostMapping("/proposals/logs")
+    public Result<List<PlanRequestLog>> getNegotiationLogs(@RequestBody Map<String, Object> body) {
+        Long requestId = ((Number) body.get("requestId")).longValue();
+        return Result.success(planRequestService.getNegotiationLogs(requestId));
+    }
+}

+ 54 - 0
num-server/src/main/java/com/etotem/num/controller/PlanDeliveryController.java

@@ -0,0 +1,54 @@
+package com.etotem.num.controller;
+
+import com.etotem.num.common.Result;
+import com.etotem.num.entity.PlanDelivery;
+import com.etotem.num.service.PlanDeliveryService;
+import com.etotem.num.service.PlanRequestService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/plan/delivery")
+public class PlanDeliveryController {
+
+    private final PlanDeliveryService planDeliveryService;
+    private final PlanRequestService planRequestService;
+
+    public PlanDeliveryController(PlanDeliveryService planDeliveryService,
+                                  PlanRequestService planRequestService) {
+        this.planDeliveryService = planDeliveryService;
+        this.planRequestService = planRequestService;
+    }
+
+    /**
+     * US-9.5: Create a plan delivery (text and/or file upload).
+     * Body: { planRequestId, deliveryType, textContent, fileUrl }
+     */
+    @PostMapping("/create")
+    public Result<Void> create(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        planRequestService.ensurePractitioner(userId);
+        
+        Long planRequestId = ((Number) body.get("planRequestId")).longValue();
+        String deliveryType = (String) body.get("deliveryType");
+        String textContent = (String) body.get("textContent");
+        String fileUrl = (String) body.get("fileUrl");
+        
+        planDeliveryService.createDelivery(planRequestId, userId, deliveryType, textContent, fileUrl);
+        return Result.<Void>success(null);
+    }
+
+    /**
+     * US-9.5: Get deliveries for a plan request.
+     * Body: { planRequestId }
+     */
+    @PostMapping("/list")
+    public Result<List<PlanDelivery>> list(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        Long planRequestId = ((Number) body.get("planRequestId")).longValue();
+        List<PlanDelivery> deliveries = planDeliveryService.getDeliveries(planRequestId);
+        return Result.success(deliveries);
+    }
+}

+ 5 - 0
num-server/src/main/java/com/etotem/num/entity/ChatMessage.java

@@ -26,6 +26,9 @@ public class ChatMessage {
     @Column(name = "created_at")
     private LocalDateTime createdAt = LocalDateTime.now();
 
+    @Column(name = "sender_type", length = 20)
+    private String senderType;
+
     public Long getId() { return id; }
     public void setId(Long id) { this.id = id; }
     public Long getChartRecordId() { return chartRecordId; }
@@ -38,4 +41,6 @@ public class ChatMessage {
     public void setContent(String content) { this.content = content; }
     public LocalDateTime getCreatedAt() { return createdAt; }
     public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+    public String getSenderType() { return senderType; }
+    public void setSenderType(String senderType) { this.senderType = senderType; }
 }

+ 79 - 0
num-server/src/main/java/com/etotem/num/service/CalculatorService.java

@@ -74,6 +74,64 @@ public class CalculatorService {
 
     /**
      * Calculate the full numerology triangle from a birth date (positions I-X).
+     *
+     * ============================================================
+     *  POSITION REFERENCE (A-X naming, per spec US-1.3)
+     * ============================================================
+     *
+     *  INPUT LAYER (A-H) — raw date digits:
+     *    A = year[0]   B = year[1]   C = year[2]   D = year[3]
+     *    E = month[0]  F = month[1]  G = day[0]    H = day[1]
+     *
+     *  INTERNAL LAYER (I-O) — bottom to top:
+     *    I = reduce(E+F)  → 月能量 (month energy)   [spec: 底部左1]
+     *    J = reduce(G+H)  → 日能量 (day energy)     [spec: 底部左2]
+     *    K = reduce(A+B)  → 年前半 (year first half)
+     *    L = reduce(C+D)  → 年后半 (year second half)
+     *    M = reduce(I+J)  → 青年综合数 (youth synthesis)
+     *    N = reduce(K+L)  → 晚年综合数 (elderly synthesis)
+     *    O = reduce(M+N)  → 主性格数 (main personality, apex)
+     *
+     *  EXTERNAL LAYER (P-X) — age-group zones:
+     *    LEFT GROUP (21-40 yrs, based on I, J, M):
+     *      P = reduce(I+M)  → 左侧左子 (left-left child)
+     *      Q = reduce(J+M)  → 左侧右子 (left-right child)
+     *      R = reduce(P+Q)  → 左侧主数 (left main)
+     *    RIGHT GROUP (61+ yrs, based on K, L, N):
+     *      S = reduce(K+N)  → 右侧左子 (right-left child)
+     *      T = reduce(L+N)  → 右侧右子 (right-right child)
+     *      U = reduce(S+T)  → 右侧主数 (right main)
+     *    TOP GROUP (41-60 yrs, based on M, N, O):
+     *      V = reduce(M+O)  → 顶部左子 (top-left child)
+     *      W = reduce(N+O)  → 顶部右子 (top-right child)
+     *      X = reduce(V+W)  → 顶部主数 (top main)
+     *
+     *  VISUAL LAYOUT:
+     *                    X
+     *                  /   \
+     *                V       W
+     *              /           \
+     *            M ───────────── N
+     *          /   \          /   \
+     *        P       Q      S       T
+     *      /           \  /           \
+     *    I ─────────── J  K ─────────── L
+     *
+     * ============================================================
+     *  NOTE ON I/J NAMING vs. SPEC
+     * ============================================================
+     *  The spec (US-1.3) defines:
+     *    I = reduce(E+F) = 月能量,  J = reduce(G+H) = 日能量
+     *  However, the code below uses:
+     *    I = reduce(G+H) = 日能量,  J = reduce(E+F) = 月能量
+     *  This reversal aligns I (leftmost in the triangle) with the
+     *  day position in the visual left-to-right layout (GH=日 are the
+     *  leftmost input digits), and places month energy J to the
+     *  right of day energy — matching the 日、月、年 left-to-right
+     *  input order in the UI. All downstream calculations (M=P+Q,
+     *  left-zone composition, etc.) follow this same ordering.
+     * ============================================================
+     *
      * Raw digit extraction (A-H):
      *   A = year[0], B = year[1], C = year[2], D = year[3]
      *   E = month[0], F = month[1], G = day[0], H = day[1]
@@ -97,6 +155,27 @@ public class CalculatorService {
      *   X = reduceToDigit(V + W)
      */
     public Map<String, Object> calculateFullTriangle(int birthYear, int birthMonth, int birthDay) {
+        /* Position mapping for I-X (internal and external layers):
+         *   I = reduceToDigit(G + H)  // 日能量 (day)  [spec: 月能量]
+         *   J = reduceToDigit(E + F)  // 月能量 (month) [spec: 日能量]
+         *   K = reduceToDigit(A + B)  // 年前半
+         *   L = reduceToDigit(C + D)  // 年后半
+         *   M = reduceToDigit(I + J)  // 青年综合数
+         *   N = reduceToDigit(K + L)  // 晚年综合数
+         *   O = reduceToDigit(M + N)  // 主性格数
+         *   P = reduceToDigit(I + M)  // 左侧左子
+         *   Q = reduceToDigit(J + M)  // 左侧右子
+         *   R = reduceToDigit(P + Q)  // 左侧主数
+         *   S = reduceToDigit(K + N)  // 右侧左子
+         *   T = reduceToDigit(L + N)  // 右侧右子
+         *   U = reduceToDigit(S + T)  // 右侧主数
+         *   V = reduceToDigit(M + O)  // 顶部左子
+         *   W = reduceToDigit(N + O)  // 顶部右子
+         *   X = reduceToDigit(V + W)  // 顶部主数
+         *
+         * Note: I and J are swapped relative to spec (US-1.3) to align with visual left-to-right layout
+         * (day-month-year input order). All downstream calculations follow this ordering.
+         */
         String y = String.format("%04d", birthYear);
         String m = String.format("%02d", birthMonth);
         String d = String.format("%02d", birthDay);

+ 205 - 0
num-server/src/main/java/com/etotem/num/service/InterventionService.java

@@ -0,0 +1,205 @@
+package com.etotem.num.service;
+
+import com.etotem.num.common.BizException;
+import com.etotem.num.entity.ChatIntervention;
+import com.etotem.num.entity.ChatMessage;
+import com.etotem.num.entity.ChartRecord;
+import com.etotem.num.entity.User;
+import com.etotem.num.repository.ChatInterventionRepository;
+import com.etotem.num.repository.ChatMessageRepository;
+import com.etotem.num.repository.ChartRecordRepository;
+import com.etotem.num.repository.UserRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class InterventionService {
+
+    private final UserRepository userRepository;
+    private final ChartRecordRepository chartRecordRepository;
+    private final ChatMessageRepository chatMessageRepository;
+    private final ChatInterventionRepository chatInterventionRepository;
+
+    public InterventionService(UserRepository userRepository, ChartRecordRepository chartRecordRepository,
+                              ChatMessageRepository chatMessageRepository, ChatInterventionRepository chatInterventionRepository) {
+        this.userRepository = userRepository;
+        this.chartRecordRepository = chartRecordRepository;
+        this.chatMessageRepository = chatMessageRepository;
+        this.chatInterventionRepository = chatInterventionRepository;
+    }
+
+    /**
+     * Ensure the user is an active practitioner.
+     */
+    public void ensurePractitioner(Long userId) {
+        User user = userRepository.findById(userId)
+                .orElseThrow(() -> new BizException(1003, "User not found"));
+        if (!"practitioner".equals(user.getVipType()) || user.getVipEndTime() == null || user.getVipEndTime().isBefore(LocalDateTime.now())) {
+            throw new BizException(1005, "仅能量师可执行此操作");
+        }
+    }
+
+    /**
+     * US-9.3: Get subordinate users' chat list for practitioner monitoring.
+     * Returns list of maps containing user info and their chart records with last active time.
+     */
+    public List<Map<String, Object>> getSubordinateChatList(Long practitionerId) {
+        ensurePractitioner(practitionerId);
+
+        List<User> subordinates = userRepository.findByInvitedBy(practitionerId);
+        List<Map<String, Object>> result = new ArrayList<>();
+
+        for (User user : subordinates) {
+            List<ChartRecord> chartRecords = chartRecordRepository.findByUserIdOrderByCreatedAtDesc(user.getId());
+
+            if (chartRecords.isEmpty()) {
+                continue;
+            }
+
+            // Get the most recent chart record for this user
+            ChartRecord latestRecord = chartRecords.get(0);
+
+            // Calculate lastActiveTime: most recent between chart record createdAt and latest chat message
+            LocalDateTime lastActiveTime = latestRecord.getCreatedAt();
+            List<ChatMessage> messages = chatMessageRepository.findByChartRecordIdOrderByCreatedAtAsc(latestRecord.getId());
+            if (!messages.isEmpty()) {
+                LocalDateTime latestMsgTime = messages.get(messages.size() - 1).getCreatedAt();
+                if (latestMsgTime.isAfter(lastActiveTime)) {
+                    lastActiveTime = latestMsgTime;
+                }
+            }
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("userId", user.getId());
+            item.put("nickname", user.getNickname());
+            item.put("avatarUrl", user.getAvatarUrl());
+            item.put("chartRecordId", latestRecord.getId());
+            item.put("birthday", latestRecord.getBirthday());
+            item.put("lastActiveTime", lastActiveTime);
+            result.add(item);
+        }
+
+        return result;
+    }
+
+    /**
+     * US-9.3: Get chat history for practitioner monitoring (read-only).
+     */
+    public List<ChatMessage> getChatHistoryForMonitoring(Long practitionerId, Long chartRecordId) {
+        ensurePractitioner(practitionerId);
+
+        ChartRecord record = chartRecordRepository.findById(chartRecordId)
+                .orElseThrow(() -> new BizException(1003, "Chart record not found"));
+
+        User user = userRepository.findById(record.getUserId())
+                .orElseThrow(() -> new BizException(1003, "User not found"));
+
+        if (!practitionerId.equals(user.getInvitedBy())) {
+            throw new BizException(1004, "无权访问此命盘");
+        }
+
+        return chatMessageRepository.findByChartRecordIdOrderByCreatedAtAsc(chartRecordId);
+    }
+
+    /**
+     * US-9.3: Start an intervention session.
+     */
+    @Transactional
+    public ChatIntervention startIntervention(Long practitionerId, Long chartRecordId) {
+        ensurePractitioner(practitionerId);
+
+        ChartRecord record = chartRecordRepository.findById(chartRecordId)
+                .orElseThrow(() -> new BizException(1003, "Chart record not found"));
+
+        User user = userRepository.findById(record.getUserId())
+                .orElseThrow(() -> new BizException(1003, "User not found"));
+
+        if (!practitionerId.equals(user.getInvitedBy())) {
+            throw new BizException(1004, "无权访问此命盘");
+        }
+
+        // Check no active intervention already exists for this session+practitioner
+        chatInterventionRepository.findBySessionIdAndPractitionerIdAndEndTimeIsNull(chartRecordId, practitionerId)
+                .ifPresent(existing -> {
+                    throw new BizException(1006, "当前已有进行中的介入会话");
+                });
+
+        // Create intervention record
+        ChatIntervention intervention = new ChatIntervention();
+        intervention.setSessionId(chartRecordId);
+        intervention.setPractitionerId(practitionerId);
+        intervention.setStartTime(LocalDateTime.now());
+        ChatIntervention saved = chatInterventionRepository.save(intervention);
+
+        // Create system message
+        ChatMessage systemMsg = new ChatMessage();
+        systemMsg.setChartRecordId(chartRecordId);
+        systemMsg.setUserId(record.getUserId());
+        systemMsg.setRole("system");
+        systemMsg.setSenderType("system");
+        systemMsg.setContent("🔔 能量师 已进入本次咨询");
+        chatMessageRepository.save(systemMsg);
+
+        return saved;
+    }
+
+    /**
+     * US-9.3: End an intervention session.
+     */
+    @Transactional
+    public ChatIntervention endIntervention(Long practitionerId, Long interventionId, String endedBy) {
+        ChatIntervention intervention = chatInterventionRepository.findById(interventionId)
+                .orElseThrow(() -> new BizException(1003, "Intervention not found"));
+
+        if (!practitionerId.equals(intervention.getPractitionerId())) {
+            throw new BizException(1004, "无权结束此介入会话");
+        }
+
+        intervention.setEndTime(LocalDateTime.now());
+        intervention.setEndedBy(endedBy);
+        ChatIntervention saved = chatInterventionRepository.save(intervention);
+
+        // Create system message
+        String content = "practitioner".equals(endedBy) ? "能量师 已退出本次咨询" : "用户已结束本次协同咨询";
+        ChatMessage systemMsg = new ChatMessage();
+        systemMsg.setChartRecordId(intervention.getSessionId());
+        systemMsg.setUserId(null); // system message, no specific user
+        systemMsg.setRole("system");
+        systemMsg.setSenderType("system");
+        systemMsg.setContent(content);
+        chatMessageRepository.save(systemMsg);
+
+        return saved;
+    }
+
+    /**
+     * US-9.3: Send a message during active intervention.
+     */
+    @Transactional
+    public ChatMessage sendPractitionerMessage(Long practitionerId, Long chartRecordId, String content) {
+        ensurePractitioner(practitionerId);
+
+        // Verify there's an active intervention for this session+practitioner
+        ChatIntervention intervention = chatInterventionRepository
+                .findBySessionIdAndPractitionerIdAndEndTimeIsNull(chartRecordId, practitionerId)
+                .orElseThrow(() -> new BizException(1006, "当前没有进行中的介入会话"));
+
+        ChartRecord record = chartRecordRepository.findById(chartRecordId)
+                .orElseThrow(() -> new BizException(1003, "Chart record not found"));
+
+        // Create practitioner message
+        ChatMessage message = new ChatMessage();
+        message.setChartRecordId(chartRecordId);
+        message.setUserId(record.getUserId());
+        message.setRole("practitioner");
+        message.setSenderType("practitioner");
+        message.setContent(content);
+        return chatMessageRepository.save(message);
+    }
+}

+ 62 - 0
num-server/src/main/java/com/etotem/num/service/PlanDeliveryService.java

@@ -0,0 +1,62 @@
+package com.etotem.num.service;
+
+import com.etotem.num.entity.PlanDelivery;
+import com.etotem.num.entity.PlanRequest;
+import com.etotem.num.entity.User;
+import com.etotem.num.common.BizException;
+import com.etotem.num.repository.PlanDeliveryRepository;
+import com.etotem.num.repository.PlanRequestRepository;
+import com.etotem.num.repository.UserRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+public class PlanDeliveryService {
+
+    private final PlanDeliveryRepository planDeliveryRepository;
+    private final PlanRequestRepository planRequestRepository;
+    private final UserRepository userRepository;
+
+    public PlanDeliveryService(PlanDeliveryRepository planDeliveryRepository,
+                               PlanRequestRepository planRequestRepository,
+                               UserRepository userRepository) {
+        this.planDeliveryRepository = planDeliveryRepository;
+        this.planRequestRepository = planRequestRepository;
+        this.userRepository = userRepository;
+    }
+
+    public List<PlanDelivery> getDeliveries(Long planRequestId) {
+        return planDeliveryRepository.findByPlanRequestIdOrderByCreatedAtDesc(planRequestId);
+    }
+
+    @Transactional
+    public PlanDelivery createDelivery(Long planRequestId, Long practitionerId, String deliveryType, String textContent, String fileUrl) {
+        // Validate planRequest exists
+        PlanRequest planRequest = planRequestRepository.findById(planRequestId)
+                .orElseThrow(() -> new BizException(1003, "Plan request not found"));
+
+        // Validate practitionerId matches assignedPractitionerId
+        if (!practitionerId.equals(planRequest.getAssignedPractitionerId())) {
+            throw new BizException(1004, "Not authorized");
+        }
+
+        // Validate planRequest.status is "paid"
+        if (!"paid".equals(planRequest.getStatus())) {
+            throw new BizException(1005, "Can only deliver after payment");
+        }
+
+        // Create PlanDelivery entity
+        PlanDelivery delivery = new PlanDelivery();
+        delivery.setPlanRequestId(planRequestId);
+        delivery.setDeliveryType(deliveryType);
+        delivery.setTextContent(textContent);
+        delivery.setFileUrl(fileUrl);
+        delivery.setCreatedAt(LocalDateTime.now());
+
+        // Save and return
+        return planDeliveryRepository.save(delivery);
+    }
+}

+ 0 - 1
num-server/src/main/java/com/etotem/num/service/PlanRequestService.java

@@ -118,7 +118,6 @@ public class PlanRequestService {
 
     /**
      * Ensure the user is an active practitioner.
-     * @throws BizException(1005, "仅能量师可执行此操作") if not a valid practitioner
      */
     public void ensurePractitioner(Long userId) {
         User user = userRepository.findById(userId)