ソースを参照

docs: 开发计划+变更日志+合作协议

Ultraworked with Sisyphus
liaoxg 3 ヶ月 前
コミット
4ddf4aa055

+ 55 - 0
client/_append_changelog.py

@@ -0,0 +1,55 @@
+with open('docs/requirements-changelog.md', 'r', encoding='utf-8') as f:
+    content = f.read()
+
+entry = '''
+
+---
+
+## v20260604.4
+
+**发布日期:** 2026-06-04
+**变更类型:** 新增功能
+**状态:** 🚧 设计待确认
+
+### 版本摘要
+
+新增**打卡赚积分**功能模块。用户每日签到打卡可获得积分,积分用于兑换服务权益(能量盘次数、推广次数、优惠券等)。积分体系独立于佣金和现金提现,纯内部激励代币。
+
+### 变更清单
+
+| # | 类型 | 变更 | 涉及位置 |
+|---|------|------|---------|
+| 1 | 🆕 新增 | 签到打卡基础逻辑(每日一次、连续签到、补签消耗) | 后端 Service + 前端页面 |
+| 2 | 🆕 新增 | 积分规则引擎(签到分数 + 连续奖励 + 特殊日子双倍) | 后端 Service |
+| 3 | 🆕 新增 | 积分消耗场景(兑换能量盘次数/聊天次数/优惠券/抽奖资格) | 商城兑换页 |
+| 4 | 🆕 新增 | 积分流水记录(进账/消耗,支持查询) | 数据库 + API |
+| 5 | 🆕 新增 | 打卡日历 UI(连续天数展示 + 补签入口 + 奖励预览) | 前端 Component |
+| 6 | 🆕 新增 | 补签机制(消耗积分补昨日,VIP 每日免费补签 1 次) | 后端 Service |
+| 7 | 🔧 更新 | 每日自动重置计数器需兼容现有 quota 体系(不相互干扰) | UserService |
+
+### 设计要点(待用户确认)
+
+- **连续打卡奖励阶梯**:连续 3/7/15/30 天额外积分 bonus
+- **补签消耗**:建议 50 积分/次(可配置),VIP 每日免费 1 次
+- **积分过期**:建议次年 1 月 1 日清零上一年度积分
+- **积分用途**:兑换能量盘咨询次数、兑换 AI 聊天次数、抵扣会员费(可选)
+- **防作弊**:同一设备/IP 每日限打卡 1 次;VIP 无此限制
+
+### 关联影响
+
+- 需新增 `user_points` 表和 `points_log` 表
+- 前端「我的」页增加打卡入口 + 积分余额展示
+- 不影响现有佣金提现体系(积分 ≠ 现金)
+- 与「家庭套餐」权益可叠加(家庭套餐用户打卡积分翻倍)
+
+---
+'''
+
+# Append to end
+if 'v20260604.4' not in content:
+    content = content.rstrip() + entry
+    with open('docs/requirements-changelog.md', 'w', encoding='utf-8') as f:
+        f.write(content)
+    print('OK: v20260604.4 appended')
+else:
+    print('SKIP: already exists')

+ 284 - 0
docs/development-plan-v20260605.md

@@ -0,0 +1,284 @@
+# 开发计划 v20260605
+
+**制定日期:** 2026-06-05
+**制定依据:** `docs/requirements-changelog.md` (v20260604.3~v20260604.5), `docs/test-coverage-report.md`, `docs/distribution-model-v2.md`, `docs/points-system.md`, `docs/quota-design.md`
+**当前版本:** v20260604.5
+**测试基线:** 183 tests, 0 failures, 5 skipped (pre-existing @Disabled)
+**Admin 构建:** ✅ 成功 (21.77s)
+
+---
+
+## 一、项目状态总结
+
+### 1.1 已编码(本地 master 领先 origin/master 10+ commits)
+
+| 功能 | 版本 | 状态 | 说明 |
+|------|------|------|------|
+| 关系能量分析重设计 | v20260604.2 | ✅ 完成 | 后端6API+前端3页+Chatflow集成 |
+| 提现管理 | v20260604.3 | ✅ 完成 | WithdrawService/Controller/Entity/前端页+测试 |
+| 主题系统 | v20260604.3 | ✅ 完成 | ThemeToggle+theme.scss+store |
+| 术语统一 | v20260604.3 | ✅ 完成 | 命盘→能量盘,文档+代码 |
+| 关系人CRUD | v20260604.3 | ✅ 完成 | RelationController/Entity/Repository |
+| 配额系统(新) | c5021ee | ✅ 完成 | QuotaService(174行)+UserConsultationQuota实体+Repository |
+| 分销v2文档 | v20260604.5 | ✅ 完成 | 40%统一/间接10积分/删除补签/100分解1次 |
+| SCSS 构建修复 | v20260605 | ✅ 完成 | uni.scss内联theme.scss + 11个页面移除冗余import + 4个darken()/CSS var兼容修复 |
+
+### 1.2 未编码(待实现)
+
+| 功能 | 版本 | 状态 | 优先级 |
+|------|------|------|--------|
+| 分销v2 代码实现 | v20260604.5 | ❌ 未编码 | P1 🔴 |
+| 打卡积分体系 | v20260604.4 | ❌ 未编码(设计待确认) | P1 🔴 |
+| QuotaService 集成 | c5021ee | ⚠️ 已编码未集成 | P1 🟡 |
+| 推送代码到远程 | — | ⚠️ 已编码未推送 | P1 🟡 |
+
+### 1.3 测试缺口
+
+| 模块 | 代码行数 | 测试数 | 优先级 |
+|------|---------|--------|--------|
+| WxPayService | 189 | 0 | 高 🔴 |
+| RelationAnalysisService | 新模块 | 0 | 中 🟡 |
+| ExportService | - | 0 | 低 🟢 |
+| @Disabled 测试 | - | 5个 | 低 🟢 |
+
+### 1.4 本轮已修复(v20260605)
+
+| # | 问题 | 修复内容 | 涉及文件数 |
+|---|------|---------|-----------|
+| 1 | `uni.scss` root stylesheet 相对 import 在页面内联时路径错误 | 内联 `theme.scss` 全部内容到 `uni.scss` | 1 |
+| 2 | 11 个页面的 `<style>` 冗余 `@import "@/styles/theme.scss"`(`@` 别名不解析) | 全部移除(theme 已全局可用) | 11 |
+| 3 | `darken(var(--color-brand), 4%)` — SCSS 颜色函数不能处理 CSS var | 改用 SCSS 字面变量 `$brand-color: #1E3A5F` | 4 (relations/analysis/common) |
+| 4 | `darken($warm-white, 2%)` — 同上,`$warm-white: var(--color-bg)` | `$warm-white` 改用字面量 `#F8F6F1` | 1 |
+| 5 | `QuotaService.existing.isEmpty()` Java 8 不兼容 | `!existing.isPresent()` | 1 |
+| 6 | 微信开发者工具 `__subPageFrameEndTime__` / `__global` 运行时错误 | ⚠️ 环境问题(DevTools 2.01.2510290 + lib 3.15.2 内部错误),非代码问题,需用户侧确认 | — |
+
+---
+
+## 二、Sprint 1 — 基础设施 + 高优测试 (P0-P1)
+
+### 目标
+将本地代码推送到远程,堵住 WxPayService 无测试的风险缺口,将新配额系统接入核心链路。
+
+### 2.1 推送代码到远程
+
+**说明:** 本地 master 领先 origin/master 10+ 个 commit(含 withdraw/theme/quota/relation/SCSS修复等),需立即推送避免丢失。
+
+**操作:** `git push origin master`
+
+**前置条件:** 无
+
+---
+
+### 2.2 WxPayService 单元测试 (189行, 0覆盖)
+
+**说明:** 支付核心服务无测试,一旦出错直接影响收入。需覆盖全部公共方法。
+
+**需要 Mock 的外部依赖:**
+- `WxPayProperties` — 微信支付配置(appId/mchKey/apiV3Key/privateKey/serialNo)
+- `CloseableHttpClient` — HTTP 客户端
+- `ConfigService` — sys_config 读取
+- `OrderService` — 订单状态更新
+- `UserService` — VIP 类型变更
+
+**需要测试的场景:**
+- `createOrder()` — 正常创建微信订单、支付金额计算、签名验证
+- `handleNotify()` — 成功回调、失败回调、重复通知幂等
+- `refund()` — 退款成功/失败/余额不足
+- 异常场景:签名错误、订单不存在、重复回调、微信接口超时
+
+**涉及文件:**
+- `num-server/src/test/java/com/etotem/num/service/WxPayServiceTest.java`(新建)
+
+---
+
+### 2.3 QuotaService 集成到核心链路
+
+**说明:** QuotaService (c5021ee) 已完成,但 ChatService 仍调用旧的 `userService.checkDailyQuota()` 和 `userService.consumeQuota()`。需替换为新的 `quotaService.checkAndConsume()`。
+
+**工作量评估:**
+- `ChatService.sendChatMessage()` — 替换 quota 检查行(~2行改)
+- 验证 UserService 中的旧 `checkDailyQuota()` / `consumeQuota()` 是否被其他调用者使用
+- 如其他调用者已迁移或无其他调用,清理旧方法
+- ChatServiceTest 需验证新配额路径
+
+**涉及文件:**
+- `num-server/.../service/ChatService.java`(替换 quota 调用)
+- `num-server/.../service/ChatServiceTest.java`(更新测试)
+- `num-server/.../service/UserService.java`(视情况清理旧方法)
+
+---
+
+## 三、Sprint 2 — 分销v2 重构 (P1)
+
+### 3.1 CommissionService 重写
+
+**依据:** `docs/distribution-model-v2.md`
+
+**变更清单:**
+
+| # | 变更 | 说明 | 涉及文件 |
+|---|------|------|---------|
+| 1 | 删除二级佣金结算 | `level=2` 的分支全部移除 | `CommissionService.java` |
+| 2 | 统一一级佣金 40% | 从 `commission.level1.rate_unified` 读取,不再按角色区分 | `CommissionService.java` |
+| 3 | 新增间接邀请积分 | 间接邀请人不拿佣金,奖励 10 积分/次 | `CommissionService.java` |
+| 4 | 佣金 `level` 语义更新 | 0=团队分红, 1=一级佣金, 2=预留 | `Commission.java` |
+| 5 | sys_config 种子数据 | 新增 `commission.level1.rate_unified`, `commission.indirect_invite.points` 等 | `ConfigService.java` |
+
+### 3.2 OrderService.paySuccess() 改造
+
+**依据:** `docs/distribution-model-v2.md` §八
+
+| # | 变更 | 说明 |
+|---|------|------|
+| 1 | 删除 `handleSecondLevelCommission()` 调用 | 二级佣金逻辑移除 |
+| 2 | 统一调用一级佣金结算(40%) | 所有付费产品走统一逻辑 |
+| 3 | 间接邀请人积分发放 | 在创建订单时检查上级的上级 |
+
+### 3.3 UserService.hasPermission() 扩展
+
+**变更:** 推广权限从 `practitioner` 扩展为所有付费用户(C端/家庭/超级/能量师)
+
+**涉及文件:** `UserService.java`
+
+### 3.4 前端佣金面板适配
+
+| # | 变更 | 说明 |
+|---|------|------|
+| 1 | 佣金记录标签更新 | `一级佣金`(蓝) / `团队分红`(金) |
+| 2 | 推广码面板可见范围扩展 | 所有付费用户可见,不再限能量师 |
+| 3 | 能量师团队展示 | "我的团队"板块(直接下级数/团队销售额/分红) |
+
+**涉及文件:** `client/pages/commission/index.vue`, `client/pages/profile/index.vue`
+
+---
+
+## 四、Sprint 3 — 打卡积分体系 (P1)
+
+**依据:** `docs/points-system.md` (v20260604.4) + `v20260604.5` 修正(取消补签,100分解1次)
+
+### 4.1 数据库表(3张)
+
+| 表名 | 说明 | 依据 |
+|------|------|------|
+| `points_points` | 积分主表(userId/year/balance/consecutiveDays/lastCheckinDate) | §7.1 |
+| `points_records` | 积分流水表(userId/type/amount/balanceAfter/refId) | §7.2 |
+| `points_rewards` | 兑换奖励表(name/cost/type/isActive) | §7.3 |
+
+### 4.2 后端
+
+| # | 变更 | 说明 |
+|---|------|------|
+| 1 | `PointsPoints` / `PointsRecord` / `PointsReward` 实体 | 对应 3 张表 |
+| 2 | `PointsRepository` / `PointsRecordRepository` / `PointsRewardRepository` | JPA CRUD |
+| 3 | `PointsService` | checkin/balance/records/redeem/expire |
+| 4 | `PointsController` | 6 个 REST 端点 |
+| 5 | sys_config 种子数据 | 签到/邀请/兑换参数 |
+| 6 | 年度清零定时任务 | `@Scheduled` 11月1日提醒/12月31日清零 |
+| 7 | CommissionService 间接积分调用 | 分销v2间接邀请时调用 pointsService |
+
+### 4.3 前端
+
+| # | 页面/组件 | 说明 |
+|---|-----------|------|
+| 1 | 个人中心积分卡片 | 余额+连续天数+签到按钮 |
+| 2 | 签到日历 UI | 连续天数展示+奖励预览 |
+| 3 | 积分商城页面 | 奖励列表+兑换按钮 |
+| 4 | 积分流水页面 | 进账/消耗记录 |
+| 5 | 年底清零横幅 | 11月起展示 |
+
+**涉及文件:** `client/pages/points/*.vue` (新建 3-4 页), `client/pages/profile/index.vue` (新增积分卡片)
+
+---
+
+## 五、Sprint 4 — 测试覆盖 + 低优任务 (P2-P3)
+
+### 5.1 RelationAnalysisService 单元测试
+
+**说明:** 新模块 6 个方法,0 测试覆盖。
+
+**可测试方法:**
+- `startAnalysis()` — 创建会话+首条消息
+- `sendChatMessage()` — 发送消息+调用Dify
+- `getChatHistory()` — 获取历史
+- `listRecords()` / `getRecordDetail()` — 查询
+- `deleteRecord()` — 删除
+
+**涉及文件:** `num-server/src/test/java/com/etotem/num/service/RelationAnalysisServiceTest.java`(新建)
+
+### 5.2 修复 5 个 @Disabled 测试
+
+| 测试类 | 跳过数 | 原因 |
+|--------|--------|------|
+| CalculatorServiceTest | 2 | 外部位置公式/命名验证需领域知识确认 |
+| CommissionServiceTest | 3 | 状态流转测试需修复数据准备(等分销v2重构后一并处理更合理) |
+
+### 5.3 EPIC 9 前端收尾
+
+| # | 任务 | 说明 |
+|---|------|------|
+| 1 | 学业方向 AI 前端入口 | "我的服务→学业方向"激活 |
+| 2 | 人工方案工作台完整流程 | proposal/counter/accept 协商UI |
+| 3 | 方案付费与交付展示 | 已实现后端,前端未完整串联 |
+
+### 5.4 ExportService 单元测试(低优)
+
+---
+
+## 六、依赖关系图
+
+```
+Sprint 1 (P0-P1)
+├── 2.1 Push to remote (无依赖)
+├── 2.2 WxPayService tests (无依赖)
+└── 2.3 Quota integration (依赖 QuotaService 已完成)
+
+Sprint 2 (P1)
+├── 3.1 CommissionService 重写 (无依赖)
+├── 3.2 OrderService 改造 (依赖 3.1)
+├── 3.3 UserService 扩展 (无依赖)
+├── 3.4 前端适配 (依赖 3.1-3.3)
+└── 3.4 间接积分调用 (依赖 Sprint 3 积分系统基础 → 可延迟)
+
+Sprint 3 (P1)
+├── 4.1 数据库表 (无依赖)
+├── 4.2 后端Service+Controller (依赖 4.1)
+└── 4.3 前端页面 (依赖 4.2)
+
+Sprint 4 (P2-P3)
+├── 5.1 RelationAnalysisService tests (无依赖)
+├── 5.2 @Disabled tests (无依赖)
+├── 5.3 EPIC 9 前端 (依赖已有后端代码)
+└── 5.4 ExportService tests (无依赖)
+```
+
+---
+
+## 七、可选/待确认
+
+| 项目 | 说明 | 触发条件 |
+|------|------|---------|
+| 超级会员完整上线 | US-10.1~US-10.6 前端的超级会员引导付费流程 | 产品确认 |
+| 八星号码分析前端优化 | `number-analysis.vue` 现有功能增强 | 产品确认 |
+| 家庭套餐 | 全套 US-11 实现 | 产品决策 |
+| 主题系统动态配置 | 管理员后台可切换主题色 | 待确认需求 |
+| 微信开发者工具运行时错误 | `__subPageFrameEndTime__`/`__global` 错误 | 用户侧确认环境配置 |
+
+---
+
+## 八、风险与建议
+
+### 8.1 微信开发者工具兼容性
+TypeError: Cannot read property '__subPageFrameEndTime__' of null 和 __global 的两个错误均来自微信 `WAServiceMainContext.js` / `WASubContext.js` 运行时。可能原因:
+- DevTools 版本 2.01.2510290 与基础库 3.15.2 组合的内部 bug
+- 旧版 `unpackage/` 构建缓存未清理
+- 建议:清除 `client/unpackage/` 目录后重新构建;如仍出现,尝试切换 DevTools 稳定版
+
+### 8.2 建议执行顺序
+1. **立即**:`git push origin master`(防丢失)
+2. **并行**:WxPayService 测试 + QuotaService 集成(无依赖冲突)
+3. **串行**:分销v2(Sprint 2)→ 积分体系(Sprint 3)
+4. **空闲填充**:Sprint 4 低优测试
+
+---
+
+*最后更新:2026-06-05 by Sisyphus*

+ 10 - 0
num-server/src/main/java/com/etotem/num/common/AuthRequired.java

@@ -0,0 +1,10 @@
+package com.etotem.num.common;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AuthRequired {
+    String[] vip() default {};
+    boolean requireLogin() default true;
+}

+ 177 - 0
num-server/src/main/java/com/etotem/num/controller/NumberAnalysisController.java

@@ -0,0 +1,177 @@
+package com.etotem.num.controller;
+
+import com.etotem.num.common.Result;
+import com.etotem.num.entity.NumberAnalysis;
+import com.etotem.num.repository.NumberAnalysisRepository;
+import com.etotem.num.service.DifyService;
+import com.etotem.num.service.EightStarService;
+import com.etotem.num.service.UserService;
+
+import com.google.gson.Gson;
+
+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.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@RestController
+@RequestMapping("/api/number-analysis")
+public class NumberAnalysisController {
+
+    private final UserService userService;
+    private final DifyService difyService;
+    private final EightStarService eightStarService;
+    private final NumberAnalysisRepository numberAnalysisRepository;
+
+    public NumberAnalysisController(UserService userService,
+                                    DifyService difyService,
+                                    EightStarService eightStarService,
+                                    NumberAnalysisRepository numberAnalysisRepository) {
+        this.userService = userService;
+        this.difyService = difyService;
+        this.eightStarService = eightStarService;
+        this.numberAnalysisRepository = numberAnalysisRepository;
+    }
+
+    /**
+     * Analyze a phone number using Eight Star numerology (EPIC 10).
+     * Validates the phone number, parses it into 2-digit pairs, looks up
+     * star energy for each pair, invokes Dify workflow for AI interpretation,
+     * and persists the analysis record.
+     */
+    @PostMapping("/analyze")
+    public Result<?> analyze(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "userId不能为空");
+            }
+
+            String phoneNumber = (String) body.get("phoneNumber");
+            if (phoneNumber == null || phoneNumber.isEmpty()) {
+                return Result.error(1003, "手机号不能为空");
+            }
+            if (!phoneNumber.matches("\\d{11}")) {
+                return Result.error(1003, "手机号格式不正确,需要11位数字");
+            }
+
+            // Step 2 & 3: Parse phone number into consecutive 2-digit pairs and look up stars
+            List<Map<String, Object>> starCombinations = new ArrayList<>();
+            for (int i = 0; i < phoneNumber.length() - 1; i++) {
+                int pair = Integer.parseInt(phoneNumber.substring(i, i + 2));
+                EightStarService.StarPair starPair = eightStarService.lookup(pair);
+                if (starPair != null) {
+                    Map<String, Object> combo = new HashMap<>();
+                    combo.put("pair", String.valueOf(pair));
+                    combo.put("starType", starPair.star.name());
+                    combo.put("level", starPair.level.level);
+                    starCombinations.add(combo);
+                }
+            }
+
+            // Step 4: Convert to JSON string for Dify input
+            Gson gson = new Gson();
+            String starCombinationsJson = gson.toJson(starCombinations);
+
+            // Step 5: Call Dify workflow
+            Map<String, Object> inputs = new HashMap<>();
+            inputs.put("phone_number", phoneNumber);
+            inputs.put("star_combinations", starCombinationsJson);
+            String workflowResult = difyService.invokeNumberAnalysisWorkflow(inputs, String.valueOf(userId));
+
+            // Step 6: Save to database
+            NumberAnalysis analysis = new NumberAnalysis();
+            analysis.setUserId(userId);
+            analysis.setPhoneNumber(phoneNumber);
+            analysis.setStarCombinations(starCombinationsJson);
+            analysis.setSummary(workflowResult);
+            analysis.setCreateTime(LocalDateTime.now());
+            numberAnalysisRepository.save(analysis);
+
+            // Step 7: Return result
+            Map<String, Object> result = new HashMap<>();
+            result.put("id", analysis.getId());
+            result.put("phoneNumber", analysis.getPhoneNumber());
+            result.put("starCombinations", starCombinations);
+            result.put("summary", workflowResult);
+
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error(500, "分析失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * List all number analysis records for a user, ordered by create time descending.
+     */
+    @PostMapping("/list")
+    public Result<?> list(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "userId不能为空");
+            }
+
+            List<NumberAnalysis> list = numberAnalysisRepository.findByUserIdOrderByCreateTimeDesc(userId);
+            return Result.success(list);
+        } catch (Exception e) {
+            return Result.error(500, "查询失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * Get detail of a specific number analysis record.
+     * Verifies ownership by matching userId.
+     */
+    @PostMapping("/detail")
+    public Result<?> detail(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Object idObj = body.get("id");
+            if (idObj == null) {
+                return Result.error(1003, "id不能为空");
+            }
+            Long id = Long.valueOf(idObj.toString());
+
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "userId不能为空");
+            }
+
+            Optional<NumberAnalysis> opt = numberAnalysisRepository.findById(id);
+            if (!opt.isPresent()) {
+                return Result.error(1004, "记录不存在");
+            }
+
+            NumberAnalysis analysis = opt.get();
+            if (!analysis.getUserId().equals(userId)) {
+                return Result.error(1001, "无权访问该记录");
+            }
+
+            return Result.success(analysis);
+        } catch (Exception e) {
+            return Result.error(500, "查询失败:" + e.getMessage());
+        }
+    }
+
+    // ── Helper methods ──────────────────────────────────────────────────
+
+    private Long resolveUserId(HttpServletRequest request, Map<String, Object> body) {
+        Long authUserId = (Long) request.getAttribute("userId");
+        if (authUserId != null) {
+            return authUserId;
+        }
+        Object userIdObj = body.get("userId");
+        if (userIdObj != null) {
+            return Long.valueOf(userIdObj.toString());
+        }
+        return null;
+    }
+}

+ 351 - 0
num-server/src/main/java/com/etotem/num/controller/RelationController.java

@@ -0,0 +1,351 @@
+package com.etotem.num.controller;
+
+import com.etotem.num.common.Result;
+import com.etotem.num.entity.Relation;
+import com.etotem.num.entity.User;
+import com.etotem.num.repository.RelationRepository;
+import com.etotem.num.service.CalculatorService;
+import com.etotem.num.service.DifyService;
+import com.etotem.num.service.UserService;
+import com.google.gson.Gson;
+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.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+@RestController
+@RequestMapping("/api/relation")
+public class RelationController {
+
+    private final CalculatorService calculatorService;
+    private final DifyService difyService;
+    private final UserService userService;
+    private final RelationRepository relationRepository;
+
+    public RelationController(CalculatorService calculatorService,
+                              DifyService difyService,
+                              UserService userService,
+                              RelationRepository relationRepository) {
+        this.calculatorService = calculatorService;
+        this.difyService = difyService;
+        this.userService = userService;
+        this.relationRepository = relationRepository;
+    }
+
+    /**
+     * Get a single relation record by id.
+     */
+    @PostMapping("/get")
+    public Result<?> getRelation(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "请先登录");
+            }
+
+            Long id = getLong(body, "id");
+            if (id == null) {
+                return Result.error(1003, "id不能为空");
+            }
+
+            Optional<Relation> opt = relationRepository.findByIdAndUserId(id, userId);
+            if (!opt.isPresent()) {
+                return Result.error(1004, "关系记录不存在");
+            }
+
+            return Result.success(opt.get());
+        } catch (Exception e) {
+            return Result.error(500, "获取关系记录失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Create a new relation record.
+     */
+    @PostMapping("/create")
+    public Result<Relation> createRelation(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "请先登录");
+            }
+
+            Relation relation = new Relation();
+            relation.setUserId(userId);
+            relation.setRelationType(getString(body, "relationType"));
+            relation.setRelationTypeLabel(getString(body, "relationTypeLabel"));
+            relation.setName(getString(body, "name"));
+            relation.setBirthDateText(getString(body, "birthDateText"));
+            relation.setGender(getInteger(body, "gender", 0));
+            relation.setTags(getString(body, "tags"));
+            relation.setNote(getString(body, "note"));
+
+            String birthDateStr = getString(body, "birthDate");
+            if (birthDateStr != null && !birthDateStr.isEmpty()) {
+                relation.setBirthDate(LocalDate.parse(birthDateStr));
+            }
+
+            LocalDateTime now = LocalDateTime.now();
+            relation.setCreateTime(now);
+            relation.setUpdateTime(now);
+
+            Relation saved = relationRepository.save(relation);
+            return Result.success(saved);
+        } catch (Exception e) {
+            return Result.error(500, "创建关系记录失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * List all relations for a user, ordered by create time descending.
+     */
+    @PostMapping("/list")
+    public Result<?> listRelations(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "请先登录");
+            }
+            return Result.success(relationRepository.findByUserIdOrderByCreateTimeDesc(userId));
+        } catch (Exception e) {
+            return Result.error(500, "获取关系列表失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Update an existing relation record.
+     */
+    @PostMapping("/update")
+    public Result<?> updateRelation(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "请先登录");
+            }
+
+            Long id = getLong(body, "id");
+            if (id == null) {
+                return Result.error(1003, "id不能为空");
+            }
+
+            Optional<Relation> opt = relationRepository.findByIdAndUserId(id, userId);
+            if (!opt.isPresent()) {
+                return Result.error(1004, "关系记录不存在");
+            }
+
+            Relation relation = opt.get();
+
+            if (body.containsKey("relationType")) {
+                relation.setRelationType(getString(body, "relationType"));
+            }
+            if (body.containsKey("relationTypeLabel")) {
+                relation.setRelationTypeLabel(getString(body, "relationTypeLabel"));
+            }
+            if (body.containsKey("name")) {
+                relation.setName(getString(body, "name"));
+            }
+            if (body.containsKey("birthDate") && body.get("birthDate") != null) {
+                relation.setBirthDate(LocalDate.parse(getString(body, "birthDate")));
+            }
+            if (body.containsKey("birthDateText")) {
+                relation.setBirthDateText(getString(body, "birthDateText"));
+            }
+            if (body.containsKey("gender")) {
+                relation.setGender(getInteger(body, "gender", 0));
+            }
+            if (body.containsKey("tags")) {
+                relation.setTags(getString(body, "tags"));
+            }
+            if (body.containsKey("note")) {
+                relation.setNote(getString(body, "note"));
+            }
+
+            relation.setUpdateTime(LocalDateTime.now());
+
+            Relation saved = relationRepository.save(relation);
+            return Result.success(saved);
+        } catch (Exception e) {
+            return Result.error(500, "更新关系记录失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Delete a relation record by id and userId.
+     */
+    @PostMapping("/delete")
+    public Result<?> deleteRelation(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "请先登录");
+            }
+
+            Long id = getLong(body, "id");
+            if (id == null) {
+                return Result.error(1003, "id不能为空");
+            }
+
+            Optional<Relation> opt = relationRepository.findByIdAndUserId(id, userId);
+            if (!opt.isPresent()) {
+                return Result.error(1004, "关系记录不存在");
+            }
+
+            relationRepository.delete(opt.get());
+            return Result.success("删除成功");
+        } catch (Exception e) {
+            return Result.error(500, "删除关系记录失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Compare the numerology charts of the user and a relation person.
+     */
+    @PostMapping("/compare")
+    public Result<?> compareCharts(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "请先登录");
+            }
+
+            Long relationId = getLong(body, "relationId");
+            if (relationId == null) {
+                return Result.error(1003, "relationId不能为空");
+            }
+
+            Optional<Relation> opt = relationRepository.findByIdAndUserId(relationId, userId);
+            if (!opt.isPresent()) {
+                return Result.error(1004, "关系记录不存在");
+            }
+
+            Relation relation = opt.get();
+            User user = userService.getById(userId);
+
+            // Calculate user chart
+            Map<String, Object> userChart = calculatorService.calculateFullTriangle(
+                    user.getBirthYear(), user.getBirthMonth(), user.getBirthDay());
+
+            // Calculate relation person's chart
+            LocalDate relBirthDate = relation.getBirthDate();
+            if (relBirthDate == null) {
+                return Result.error(1005, "关系人的出生日期未设置");
+            }
+            Map<String, Object> relationChart = calculatorService.calculateFullTriangle(
+                    relBirthDate.getYear(), relBirthDate.getMonthValue(), relBirthDate.getDayOfMonth());
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("userChart", userChart);
+            result.put("relationChart", relationChart);
+            result.put("relationType", relation.getRelationType());
+            result.put("relationName", relation.getName());
+
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error(500, "对比能量盘失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Compare charts and get an AI interpretation of the relationship.
+     */
+    @PostMapping("/compare/interpret")
+    public Result<?> interpretRelation(HttpServletRequest request, @RequestBody Map<String, Object> body) {
+        try {
+            Long userId = resolveUserId(request, body);
+            if (userId == null) {
+                return Result.error(1001, "请先登录");
+            }
+
+            Long relationId = getLong(body, "relationId");
+            if (relationId == null) {
+                return Result.error(1003, "relationId不能为空");
+            }
+
+            Optional<Relation> opt = relationRepository.findByIdAndUserId(relationId, userId);
+            if (!opt.isPresent()) {
+                return Result.error(1004, "关系记录不存在");
+            }
+
+            Relation relation = opt.get();
+            User user = userService.getById(userId);
+
+            // Calculate user chart
+            Map<String, Object> userChart = calculatorService.calculateFullTriangle(
+                    user.getBirthYear(), user.getBirthMonth(), user.getBirthDay());
+
+            // Calculate relation person's chart
+            LocalDate relBirthDate = relation.getBirthDate();
+            if (relBirthDate == null) {
+                return Result.error(1005, "关系人的出生日期未设置");
+            }
+            Map<String, Object> relationChart = calculatorService.calculateFullTriangle(
+                    relBirthDate.getYear(), relBirthDate.getMonthValue(), relBirthDate.getDayOfMonth());
+
+            // Build inputs for Dify workflow: chart_context must be JSON strings
+            Gson gson = new Gson();
+            Map<String, Object> inputs = new HashMap<>();
+            inputs.put("chart_context_user1", gson.toJson(userChart));
+            inputs.put("chart_context_user2", gson.toJson(relationChart));
+            inputs.put("relation_type", relation.getRelationType());
+
+            String interpretation = difyService.invokeRelationWorkflow(inputs, userId.toString());
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("interpretation", interpretation);
+            result.put("userChart", userChart);
+            result.put("relationChart", relationChart);
+
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error(500, "解读关系能量盘失败: " + e.getMessage());
+        }
+    }
+
+    // ── Helper methods ──────────────────────────────────────────────────
+
+    /**
+     * Resolve userId from the authenticated request or fallback to the request body.
+     * Priority: auth filter attribute > request body field.
+     */
+    private Long resolveUserId(HttpServletRequest request, Map<String, Object> body) {
+        Long authUserId = (Long) request.getAttribute("userId");
+        if (authUserId != null) {
+            return authUserId;
+        }
+        return getLong(body, "userId");
+    }
+
+    private String getString(Map<String, Object> map, String key) {
+        Object val = map.get(key);
+        return val != null ? val.toString() : null;
+    }
+
+    private Integer getInteger(Map<String, Object> map, String key, Integer defaultValue) {
+        Object val = map.get(key);
+        if (val == null) return defaultValue;
+        if (val instanceof Number) return ((Number) val).intValue();
+        try {
+            return Integer.parseInt(val.toString());
+        } catch (NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+
+    private Long getLong(Map<String, Object> map, String key) {
+        Object val = map.get(key);
+        if (val == null) return null;
+        if (val instanceof Number) return ((Number) val).longValue();
+        try {
+            return Long.parseLong(val.toString());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}

+ 40 - 0
num-server/src/main/java/com/etotem/num/entity/NumberAnalysis.java

@@ -0,0 +1,40 @@
+package com.etotem.num.entity;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "number_analysis")
+public class NumberAnalysis {
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "user_id")
+    private Long userId;
+
+    @Column(name = "phone_number")
+    private String phoneNumber;
+
+    @Column(name = "star_combinations", columnDefinition = "TEXT")
+    private String starCombinations;
+
+    @Column(columnDefinition = "TEXT")
+    private String summary;
+
+    @Column(name = "create_time")
+    private LocalDateTime createTime = LocalDateTime.now();
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+    public String getPhoneNumber() { return phoneNumber; }
+    public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
+    public String getStarCombinations() { return starCombinations; }
+    public void setStarCombinations(String starCombinations) { this.starCombinations = starCombinations; }
+    public String getSummary() { return summary; }
+    public void setSummary(String summary) { this.summary = summary; }
+    public LocalDateTime getCreateTime() { return createTime; }
+    public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
+}

+ 67 - 0
num-server/src/main/java/com/etotem/num/entity/Relation.java

@@ -0,0 +1,67 @@
+package com.etotem.num.entity;
+
+import javax.persistence.*;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "relations")
+public class Relation {
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "user_id")
+    private Long userId;
+
+    @Column(name = "relation_type")
+    private String relationType;
+
+    @Column(name = "relation_type_label")
+    private String relationTypeLabel;
+
+    private String name;
+
+    @Column(name = "birth_date")
+    private LocalDate birthDate;
+
+    @Column(name = "birth_date_text")
+    private String birthDateText;
+
+    private Integer gender = 0;
+
+    private String tags;
+
+    private String note;
+
+    @Column(name = "create_time")
+    private LocalDateTime createTime = LocalDateTime.now();
+
+    @Column(name = "update_time")
+    private LocalDateTime updateTime = LocalDateTime.now();
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+    public String getRelationType() { return relationType; }
+    public void setRelationType(String relationType) { this.relationType = relationType; }
+    public String getRelationTypeLabel() { return relationTypeLabel; }
+    public void setRelationTypeLabel(String relationTypeLabel) { this.relationTypeLabel = relationTypeLabel; }
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+    public LocalDate getBirthDate() { return birthDate; }
+    public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; }
+    public String getBirthDateText() { return birthDateText; }
+    public void setBirthDateText(String birthDateText) { this.birthDateText = birthDateText; }
+    public Integer getGender() { return gender; }
+    public void setGender(Integer gender) { this.gender = gender; }
+    public String getTags() { return tags; }
+    public void setTags(String tags) { this.tags = tags; }
+    public String getNote() { return note; }
+    public void setNote(String note) { this.note = note; }
+    public LocalDateTime getCreateTime() { return createTime; }
+    public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
+    public LocalDateTime getUpdateTime() { return updateTime; }
+    public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }
+}

+ 16 - 0
num-server/src/main/java/com/etotem/num/repository/NumberAnalysisRepository.java

@@ -0,0 +1,16 @@
+package com.etotem.num.repository;
+
+import com.etotem.num.entity.NumberAnalysis;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface NumberAnalysisRepository extends JpaRepository<NumberAnalysis, Long> {
+    List<NumberAnalysis> findByUserIdOrderByCreateTimeDesc(Long userId);
+    List<NumberAnalysis> findByPhoneNumberAndUserId(String phoneNumber, Long userId, Pageable pageable);
+    Optional<NumberAnalysis> findTopByPhoneNumberAndUserIdOrderByCreateTimeDesc(String phoneNumber, Long userId);
+}

+ 14 - 0
num-server/src/main/java/com/etotem/num/repository/RelationRepository.java

@@ -0,0 +1,14 @@
+package com.etotem.num.repository;
+
+import com.etotem.num.entity.Relation;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface RelationRepository extends JpaRepository<Relation, Long> {
+    List<Relation> findByUserIdOrderByCreateTimeDesc(Long userId);
+    Optional<Relation> findByIdAndUserId(Long id, Long userId);
+}

+ 287 - 0
num-server/src/test/java/com/etotem/num/service/WxPayServiceTest.java

@@ -0,0 +1,287 @@
+package com.etotem.num.service;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for WxPayService covering both dev mode (mock params) and
+ * real mode (WeChat API interaction) plus notification parsing.
+ */
+@SpringBootTest
+class WxPayServiceTest {
+
+    @MockBean
+    private RestTemplate restTemplate;
+
+    @MockBean
+    private OrderService orderService;
+
+    @Autowired
+    private WxPayService wxPayService;
+
+    // ==================== Dev Mode Tests ====================
+    // Default @Value placeholders contain "your_", so dev mode is automatic
+
+    @Nested
+    class DevModeTests {
+        private String savedMchId;
+        private String savedMchKey;
+
+        @BeforeEach
+        void setUpDevMode() {
+            // Test application.yml sets mch-id to test_mch_id, so override to placeholder to trigger dev mode
+            savedMchId = (String) ReflectionTestUtils.getField(wxPayService, "mchId");
+            savedMchKey = (String) ReflectionTestUtils.getField(wxPayService, "mchKey");
+            ReflectionTestUtils.setField(wxPayService, "mchId", "your_mch_id");
+            ReflectionTestUtils.setField(wxPayService, "mchKey", "your_mch_key");
+        }
+
+        @AfterEach
+        void tearDownDevMode() {
+            ReflectionTestUtils.setField(wxPayService, "mchId", savedMchId);
+            ReflectionTestUtils.setField(wxPayService, "mchKey", savedMchKey);
+        }
+
+        @Test
+        void testCreateUnifiedOrder_returnsMockParams() {
+            Map<String, String> result = wxPayService.createUnifiedOrder(1L, 100, "test order", "dev_order_001");
+
+            assertNotNull(result);
+            assertEquals("MD5", result.get("signType"));
+            assertTrue(result.containsKey("appId"));
+            assertTrue(result.containsKey("timeStamp"));
+            assertTrue(result.containsKey("nonceStr"));
+            assertTrue(result.containsKey("paySign"));
+            assertTrue(result.get("package").startsWith("prepay_id=mock_prepay_"));
+        }
+
+        @Test
+        void testCreateUnifiedOrder_containsAllRequiredKeys() {
+            Map<String, String> result = wxPayService.createUnifiedOrder(1L, 100, "test", "dev_order_002");
+
+            String[] expectedKeys = {"appId", "timeStamp", "nonceStr", "package", "signType", "paySign"};
+            for (String key : expectedKeys) {
+                assertTrue(result.containsKey(key), "Missing key: " + key);
+                assertNotNull(result.get(key), "Null value for key: " + key);
+            }
+        }
+
+        @Test
+        void testCreateUnifiedOrder_differentOrdersDifferentNonce() {
+            Map<String, String> order1 = wxPayService.createUnifiedOrder(1L, 100, "test1", "dev_001");
+            Map<String, String> order2 = wxPayService.createUnifiedOrder(1L, 200, "test2", "dev_002");
+
+            assertNotEquals(order1.get("nonceStr"), order2.get("nonceStr"));
+        }
+    }
+
+    // ==================== Real Mode Tests ====================
+    // Override @Value fields via ReflectionTestUtils to trigger real API path
+
+    @Nested
+    class RealModeTests {
+        private String savedMchId;
+        private String savedMchKey;
+
+        @BeforeEach
+        void setUp() {
+            // Save original values, then override to non-placeholder values
+            savedMchId = (String) ReflectionTestUtils.getField(wxPayService, "mchId");
+            savedMchKey = (String) ReflectionTestUtils.getField(wxPayService, "mchKey");
+            ReflectionTestUtils.setField(wxPayService, "mchId", "test_mch_id");
+            ReflectionTestUtils.setField(wxPayService, "mchKey", "test_mch_key");
+        }
+
+        @AfterEach
+        void tearDown() {
+            ReflectionTestUtils.setField(wxPayService, "mchId", savedMchId);
+            ReflectionTestUtils.setField(wxPayService, "mchKey", savedMchKey);
+        }
+
+        @Test
+        void testCreateUnifiedOrder_success() {
+            String successXml = "<xml>" +
+                    "<return_code><![CDATA[SUCCESS]]></return_code>" +
+                    "<result_code><![CDATA[SUCCESS]]></result_code>" +
+                    "<prepay_id><![CDATA[wx_test_prepay_id]]></prepay_id>" +
+                    "<nonce_str><![CDATA[test_nonce]]></nonce_str>" +
+                    "</xml>";
+
+            when(restTemplate.postForObject(anyString(), anyString(), eq(String.class)))
+                    .thenReturn(successXml);
+
+            Map<String, String> result = wxPayService.createUnifiedOrder(1L, 100, "test", "real_order_001");
+
+            assertNotNull(result);
+            assertEquals("MD5", result.get("signType"));
+            assertTrue(result.get("package").startsWith("prepay_id="));
+            assertTrue(result.containsKey("appId"));
+            assertTrue(result.containsKey("timeStamp"));
+            assertTrue(result.containsKey("nonceStr"));
+            assertTrue(result.containsKey("paySign"));
+        }
+
+        @Test
+        void testCreateUnifiedOrder_apiError() {
+            String apiFailXml = "<xml>" +
+                    "<return_code><![CDATA[FAIL]]></return_code>" +
+                    "<return_msg><![CDATA[签名错误]]></return_msg>" +
+                    "</xml>";
+
+            when(restTemplate.postForObject(anyString(), anyString(), eq(String.class)))
+                    .thenReturn(apiFailXml);
+
+            RuntimeException ex = assertThrows(RuntimeException.class,
+                    () -> wxPayService.createUnifiedOrder(1L, 100, "test", "real_order_002"));
+            assertTrue(ex.getMessage().contains("签名错误"));
+        }
+
+        @Test
+        void testCreateUnifiedOrder_bizError() {
+            String bizFailXml = "<xml>" +
+                    "<return_code><![CDATA[SUCCESS]]></return_code>" +
+                    "<result_code><![CDATA[FAIL]]></result_code>" +
+                    "<err_code_des><![CDATA[订单已关闭]]></err_code_des>" +
+                    "</xml>";
+
+            when(restTemplate.postForObject(anyString(), anyString(), eq(String.class)))
+                    .thenReturn(bizFailXml);
+
+            RuntimeException ex = assertThrows(RuntimeException.class,
+                    () -> wxPayService.createUnifiedOrder(1L, 100, "test", "real_order_003"));
+            assertTrue(ex.getMessage().contains("订单已关闭"));
+        }
+
+        @Test
+        void testCreateUnifiedOrder_httpException() {
+            when(restTemplate.postForObject(anyString(), anyString(), eq(String.class)))
+                    .thenThrow(new RuntimeException("Connection refused"));
+
+            RuntimeException ex = assertThrows(RuntimeException.class,
+                    () -> wxPayService.createUnifiedOrder(1L, 100, "test", "real_order_004"));
+            assertTrue(ex.getMessage().contains("Connection refused"));
+        }
+
+        @Test
+        void testCreateUnifiedOrder_nullResponse() {
+            when(restTemplate.postForObject(anyString(), anyString(), eq(String.class)))
+                    .thenReturn(null);
+
+            RuntimeException ex = assertThrows(RuntimeException.class,
+                    () -> wxPayService.createUnifiedOrder(1L, 100, "test", "real_order_005"));
+            assertNotNull(ex);
+        }
+    }
+
+    // ==================== Parse Notify Tests ====================
+
+    @Nested
+    class ParseNotifyTests {
+
+        @Test
+        void testParseNotify_validSignature() throws Exception {
+            // Build params
+            TreeMap<String, String> params = new TreeMap<>();
+            params.put("return_code", "SUCCESS");
+            params.put("out_trade_no", "test123");
+            params.put("transaction_id", "wx123456789");
+
+            String mchKey = (String) ReflectionTestUtils.getField(wxPayService, "mchKey");
+            String sign = computeMd5Sign(params, mchKey);
+
+            // Build XML with computed sign
+            StringBuilder xml = new StringBuilder("<xml>");
+            for (Map.Entry<String, String> entry : params.entrySet()) {
+                xml.append("<").append(entry.getKey()).append(">")
+                        .append("<![CDATA[").append(entry.getValue()).append("]]>")
+                        .append("</").append(entry.getKey()).append(">");
+            }
+            xml.append("<sign><![CDATA[").append(sign).append("]]></sign></xml>");
+
+            Map<String, String> result = wxPayService.parseNotify(xml.toString());
+
+            assertNotNull(result);
+            assertFalse(result.containsKey("error"), "Should not have error for valid signature");
+            assertEquals("SUCCESS", result.get("return_code"));
+            assertEquals("test123", result.get("out_trade_no"));
+            assertEquals("wx123456789", result.get("transaction_id"));
+        }
+
+        @Test
+        void testParseNotify_invalidSignature() {
+            String xml = "<xml>" +
+                    "<return_code><![CDATA[SUCCESS]]></return_code>" +
+                    "<out_trade_no><![CDATA[test123]]></out_trade_no>" +
+                    "<sign><![CDATA[WRONG_SIGN]]></sign>" +
+                    "</xml>";
+
+            Map<String, String> result = wxPayService.parseNotify(xml);
+
+            assertNotNull(result);
+            assertTrue(result.containsKey("error"));
+            assertEquals("signature mismatch", result.get("error"));
+        }
+
+        @Test
+        void testParseNotify_emptyBody() {
+            Map<String, String> result = wxPayService.parseNotify("");
+
+            // parseXml("") returns empty map → data.remove("sign") is null → mismatch
+            assertNotNull(result);
+            assertTrue(result.containsKey("error"));
+        }
+
+        @Test
+        void testParseNotify_nullBody() {
+            // Should handle null gracefully without NPE
+            Map<String, String> result = wxPayService.parseNotify(null);
+
+            assertNotNull(result);
+            assertTrue(result.containsKey("error"));
+        }
+
+        @Test
+        void testParseNotify_malformedXml() {
+            Map<String, String> result = wxPayService.parseNotify("<xml><broken>data");
+
+            assertNotNull(result);
+            assertTrue(result.containsKey("error"));
+        }
+
+        // -- Helper: replicate the MD5 sign algorithm from WxPayService --
+
+        private String computeMd5Sign(Map<String, String> params, String key) throws Exception {
+            StringBuilder sb = new StringBuilder();
+            for (Map.Entry<String, String> entry : params.entrySet()) {
+                if (entry.getValue() != null && !entry.getValue().isEmpty()) {
+                    sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
+                }
+            }
+            sb.append("key=").append(key);
+
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
+            StringBuilder hexString = new StringBuilder();
+            for (byte b : digest) {
+                hexString.append(String.format("%02x", b));
+            }
+            return hexString.toString().toUpperCase();
+        }
+    }
+}

+ 451 - 0
合作协议-数字能量学智能体平台.md

@@ -0,0 +1,451 @@
+# 数字能量学智能体平台项目合作协议
+
+**协议编号:** YTT-SZN-2026-001
+
+**签订地点:** 北京市
+
+**签订日期:** ____年____月____日
+
+---
+
+**甲方(技术方):** 亿图腾(北京)科技有限公司  
+**统一社会信用代码:** ____________________  
+**法定代表人:** ____________________  
+**注册地址:** ____________________  
+**联系电话:** ____________________  
+
+**乙方(运营方):** 张圣甜  
+**身份证号:** ____________________  
+**联系地址:** ____________________  
+**联系电话:** ____________________  
+
+**鉴于:**
+
+1. 甲方拥有软件技术开发能力及平台运营经验,负责"数字能量学智能体平台"(以下简称"平台")的技术搭建与维护;
+2. 乙方在数字能量学领域具备专业理论知识和市场推广资源,负责平台的市场推广及数字能量学体系支持;
+3. 双方本着平等互利、优势互补的原则,拟共同合作运营该平台。
+
+依据《中华人民共和国民法典》及相关法律法规,经友好协商,达成如下协议:
+
+---
+
+## 第一条 合作内容
+
+双方共同合作开发和运营"数字能量学智能体平台",为用户提供数字能量学分析、号码能量评估、个性化报告生成等服务。
+
+---
+
+## 第二条 双方分工
+
+### 2.1 甲方(技术方)职责
+
+甲方负责平台的技术研发、系统运维及产品实现,具体职责如下:
+
+**技术研发:**
+1. 负责平台整体技术架构设计、技术选型及系统设计,确保架构的可扩展性与稳定性;
+2. 负责平台前端(微信小程序/H5)、后端服务及核心算法引擎的开发、测试与部署;
+3. 负责将乙方提供的数字能量学理论体系转化为可执行的算法模型与功能逻辑;
+4. 负责数据库设计、数据存储方案、API接口开发及技术文档编写;
+5. 负责第三方服务集成(微信支付、微信登录、Dify AI、短信服务等)。
+
+**系统运维:**
+6. 负责服务器部署、负载均衡、CDN加速等基础设施搭建与运维;
+7. 负责7×24小时系统监控、故障排查与应急响应,保障平台可用性;
+8. 负责数据备份、容灾恢复及数据安全保障,定期进行安全审计;
+9. 负责平台性能优化,持续提升系统响应速度与并发处理能力。
+
+**产品迭代:**
+10. 负责平台版本规划、迭代开发与发布管理;
+11. 负责平台bug修复及技术问题排查,常规bug在48小时内响应;
+12. 提供平台运营所需的数据报表系统及后台管理工具的开发维护;
+13. 根据运营方提出的产品优化建议,评估技术可行性并安排开发排期。
+
+### 2.2 乙方(运营方)职责
+
+乙方负责平台的市场推广、内容建设及用户运营,具体职责如下:
+
+**市场推广:**
+1. 负责平台整体市场推广策略的制定与执行,制定年度/季度/月度推广计划;
+2. 负责线上线下渠道拓展,包括但不限于微信群、朋友圈、短视频平台、行业论坛等;
+3. 负责推广活动的策划与执行(裂变活动、拼团、限时优惠等);
+4. 负责推广物料的制作,包括推广文案、海报、短视频等;
+5. 负责市场推广预算的规划与控制,确保投入产出比合理。
+
+**内容与知识体系:**
+6. 负责数字能量学理论体系的构建、标准化整理及持续更新,确保内容的专业性与准确性;
+7. 负责平台知识库的内容编写与维护,为AI模型提供高质量的训练语料;
+8. 负责平台内容输出(公众号文章、科普内容、案例分析等),提升平台专业形象;
+9. 负责能量师培训体系的搭建,包括培训课程、考核标准、认证流程。
+
+**用户与能量师运营:**
+10. 负责能量师的招募、筛选、培训、认证及日常运营管理;
+11. 负责能量师社群运营,维护能量师关系,提升能量师活跃度与留存率;
+12. 负责普通用户的社群运营、客户咨询及售后服务体系搭建;
+13. 收集用户与能量师的反馈,定期输出产品优化建议报告。
+
+**品牌与渠道:**
+14. 负责平台品牌定位、品牌形象建设与品牌传播;
+15. 拓展行业合作渠道,引入数字能量学领域的KOL、机构等资源;
+16. 关注行业动态与竞品分析,为平台战略决策提供市场依据。
+
+---
+
+## 第三条 会员定价
+
+### 3.1 收费标准
+
+平台会员服务收费标准如下(均为年费):
+
+| 角色 | 种子价 | 标准价 | 核心权益 |
+|------|:-----:|:-----:|---------|
+| **能量师** | **¥1,314**(限创始人码+名额+有效期) | **¥1,986/年** | 完整能量盘生成+AI解读+客户管理+分销推广+团队分红 |
+| **超级会员** | — | **¥1,314/年** | 完整AI互动+关系能量分析(多人选择+AI对话)+八星号码分析+分销推广 |
+| **C端会员** | — | **¥131/年** | AI互动问答+分销权限 |
+
+### 3.2 定价调整
+
+会员定价的调整需经双方书面同意,任何一方不得单方面变更价格。
+
+### 3.3 种子价管理
+
+种子价(¥1,314)仅限平台上线初期指定数量的能量师名额使用,具体数量及有效期由双方另行书面确认。种子价名额用尽或到期后,能量师统一按标准价¥1,986执行。
+
+---
+
+## 第四条 收入分配模型
+
+### 4.1 收入分配顺序
+
+平台产生的会员付费收入(以下简称"总收入"),按以下顺序进行分配:
+
+| 分配项目 | 比例 | 说明 |
+|---------|:----:|------|
+| 能量师一级分润 | **25%** | 直接分配给提供服务的能量师(含分销佣金) |
+| 能量师分红 | **10%** | 能量师团队分红及激励,按月结算给能量师 |
+| 可分配毛利 | **65%** | 扣除上述两项后的可分配毛利(以下简称"毛利"),由甲乙双方按本协议第五条约定比例分配 |
+
+### 4.2 计算公式
+
+> **乙方当月分成 = 当月总收入 × 65% × 乙方当期分配比例**
+>
+> 其中:
+> - **65%** = 可分配毛利
+> - **乙方当期分配比例** = 按本协议第五条确定的乙方当前阶段占比
+
+### 4.3 计算示例
+
+以第一阶段(乙方占10%)为例,假设当月总收入为 ¥100,000:
+
+| 项目 | 金额 |
+|------|:----:|
+| 当月总收入 | ¥100,000 |
+| 减:能量师一级分润(25%) | −¥25,000 |
+| 减:能量师分红(10%) | −¥10,000 |
+| 可分配毛利(**65%**) | **¥65,000** |
+| 乙方当期分配比例(第一阶段) | **10%** |
+| **乙方当月分成** = ¥65,000 × 10% | **¥6,500** |
+| 甲方当月分成 = ¥65,000 × 90% | ¥58,500 |
+
+---
+
+## 第五条 收入分配比例及阶段划分
+
+### 5.1 阶段划分原则
+
+根据项目发展阶段的投入成本及双方贡献变化,以平台累计**通过实名认证的活跃能量师总数**为晋级节点,设置如下五个阶段:
+
+| 阶段 | 累计活跃能量师数 | 甲方(技术方)占比 | 乙方(运营方)占比 | 阶段特征说明 |
+|------|:---------------:|:-----------------:|:-----------------:|-------------|
+| **第一阶段(启动期)** | **0 ~ 50 人** | **90%** | **10%** | 平台从零开发,技术方承担全部研发成本及服务器基础设施投入;运营方重点招募种子能量师 |
+| **第二阶段(发展初期)** | **51 ~ 200 人** | **80%** | **20%** | 平台功能基本完善,运营方启动市场推广,双方共同投入 |
+| **第三阶段(发展中期)** | **201 ~ 800 人** | **70%** | **30%** | 平台进入稳定运行,品牌初步建立,运营方推广力度加大 |
+| **第四阶段(发展后期)** | **801 ~ 3000 人** | **60%** | **40%** | 平台规模化增长,双方贡献趋于均衡 |
+| **第五阶段(成熟期)** | **3001 人以上** | **50%** | **50%** | 平台进入成熟运营期,双方贡献均等 |
+
+### 5.2 阶段晋级规则
+
+1. **晋级触发**:当累计活跃能量师数达到当前阶段上限时,自次月1日起自动晋级至下一阶段,分配比例按新阶段执行。
+2. **全面适用**:阶段晋级后,当期平台**全部收入**(含存量能量师产生的收入)均按新阶段比例分配,不区分新旧用户。
+3. **只升不降**:阶段级别仅向上晋级,不回溯。即使能量师数量因故减少,已晋级的阶段及分配比例不受影响。
+4. **核算基准**:每月第5个工作日前,双方共同确认截至上月末的累计活跃能量师数,作为当月分配比例的依据。
+
+### 5.3 活跃能量师定义
+
+**活跃能量师**须同时满足以下条件:
+- 通过平台实名认证;
+- 在统计周期内(当月)至少完成 1 次服务或产生 1 笔收入;
+- 未被平台冻结或封禁。
+
+### 5.4 特殊情况处理
+
+如乙方连续 3 个月未能通过市场推广增加新的活跃能量师,或经双方确认运营方的推广投入严重不足,双方可协商调整下一阶段的晋级门槛或分配比例。
+
+---
+
+## 第六条 阶段目标与计划
+
+双方根据项目发展阶段,约定以下阶段目标与计划。各阶段目标作为双方共同努力的方向,将作为定期评估合作进展的依据。
+
+### 6.1 第一阶段:平台上线与种子期(累计能量师 0~50 人)
+
+| 维度 | 目标内容 |
+|------|---------|
+| **预计时间窗口** | 平台正式上线后 0~3 个月 |
+| **技术交付物** | MVP版本上线:能量盘生成与展示、AI五区完整解读、VIP支付订阅、推广码生成、上下级关系绑定、基础分销佣金结算 |
+| **能量师招募目标** | 累计招募并激活 **50 名能量师**(含种子价¥1,314名额) |
+| **C端用户积累目标** | 平台累计注册用户达到 **10,000 人** |
+| **关键运营指标** | 日活跃能量师 ≥ 20 人;能量师7日留存率 ≥ 60% |
+| **双方协作重点** | 乙方负责种子能量师招募及入门培训;甲方负责平台稳定运行及bug修复 |
+
+### 6.2 第二阶段:功能完善与增长期(累计能量师 51~200 人)
+
+| 维度 | 目标内容 |
+|------|---------|
+| **预计时间窗口** | 上线后 3~6 个月 |
+| **技术交付物** | AI多轮对话咨询上线、分销面板(收益统计/团队列表/推广工具)、C端分销佣金功能、升级定价(年费→能量师折算)、管理后台完善、系统配置动态管理 |
+| **能量师招募目标** | 新增 **150 名**能量师,累计达到 **200 人** |
+| **C端用户积累目标** | 累计注册用户达到 **50,000 人** |
+| **关键运营指标** | 日活跃能量师 ≥ 50 人;日能量盘生成量 ≥ 500 次;C端付费转化率 ≥ 3% |
+| **双方协作重点** | 乙方启动线上渠道推广及裂变活动;甲方配合运营需求快速迭代 |
+
+### 6.3 第三阶段:生态构建与规模期(累计能量师 201~800 人)
+
+| 维度 | 目标内容 |
+|------|---------|
+| **预计时间窗口** | 上线后 6~12 个月 |
+| **技术交付物** | 学业方向AI分析上线、人工方案完整流程(工作台/协商/付费)、关系能量分析(超级会员专属)、八星号码分析、能量师团队分红系统 |
+| **能量师招募目标** | 新增 **600 名**能量师,累计达到 **800 人** |
+| **C端用户积累目标** | 累计注册用户达到 **200,000 人** |
+| **关键运营指标** | 日活跃能量师 ≥ 200 人;能量师人均邀请下级 ≥ 3 人;平台月收入突破 **¥50,000** |
+| **双方协作重点** | 乙方引入行业KOL及合作渠道,拓展品牌影响力;甲方持续优化AI解读质量及平台性能 |
+
+### 6.4 第四阶段:多元化增长期(累计能量师 801~3,000 人)
+
+| 维度 | 目标内容 |
+|------|---------|
+| **预计时间窗口** | 上线后 12~24 个月 |
+| **技术交付物** | 超级会员完整权益上线、陌生人匹配与社交功能、数字调理方案(色彩/方位/配饰)、电商模块接入、佣金自动提现、数据API输出 |
+| **能量师招募目标** | 新增 **2,200 名**能量师,累计达到 **3,000 人** |
+| **C端用户积累目标** | 累计注册用户达到 **500,000 人** |
+| **关键运营指标** | 月活跃用户 ≥ 10,000 人;电商转化率 ≥ 5%;平台月收入突破 **¥300,000** |
+| **双方协作重点** | 双方共同探索多元化收入模式(电商/API),持续扩大市场份额 |
+
+### 6.5 第五阶段:成熟运营期(累计能量师 3,001 人以上)
+
+| 维度 | 目标内容 |
+|------|---------|
+| **预计时间窗口** | 上线后 24 个月以上 |
+| **技术交付物** | 平台生态全面完善、企业级API服务、行业标准输出、持续功能创新 |
+| **能量师招募目标** | 累计能量师 **3,000 人以上**,持续增长 |
+| **C端用户积累目标** | 累计注册用户达到 **1,000,000 人以上** |
+| **关键运营指标** | B端营收占比 ≥ 30%;平台进入稳定盈利期 |
+| **双方协作重点** | 双方贡献趋于均衡,共同推动平台向行业基础设施演进 |
+
+### 6.6 目标调整机制
+
+1. 上述目标为双方共同努力的方向性约定,不作为强制性考核指标;
+2. 每季度末双方对阶段目标完成情况进行评估,如因市场环境或客观因素导致目标偏差超过30%,双方可协商调整下一阶段目标;
+3. 如乙方连续两个季度未达成当期能量师招募目标的50%,甲方有权要求重新协商分配比例;
+4. 如甲方连续两个季度未按计划交付约定功能模块,乙方有权要求重新协商分配比例。
+
+---
+
+## 第七条 种子用户与非种子用户的约定
+
+### 7.1 定义
+
+| 类型 | 定义 | 时间窗口 |
+|------|------|---------|
+| **种子用户** | 平台正式上线后 **前50名** 完成注册并通过实名认证的能量师 | 平台正式上线之日起 **60天内**(超期未满50人的,以实际注册人数为准) |
+| **非种子用户** | 种子用户之外,后续注册的能量师 | 种子用户窗口期结束后注册的均为非种子用户 |
+
+### 7.2 种子用户归属
+
+种子用户由乙方(运营方)利用自身行业人脉和渠道资源重点负责招募和引入。甲方配合完成技术入驻流程。
+
+### 7.3 种子用户的分配规则
+
+1. 种子用户阶段的收入分配统一按本协议 **第五条第一阶段(甲方90%、乙方10%)** 的比例执行;
+2. 种子用户计入累计能量师总数,参与后续阶段的晋级计算;
+3. 种子用户如退出平台(注销账户),相应能量师数量从累计计数中扣除,但已晋级阶段不回溯。
+
+### 7.4 种子用户激励政策
+
+为激励乙方在平台上线初期积极引入种子用户,甲方给予乙方以下激励:
+
+> **种子用户引荐奖励:乙方每成功引入并留存超过3个月的种子用户,甲方一次性额外奖励该种子用户在留存期内累计贡献毛利的5%(从甲方分成中列支)。**
+
+### 7.5 双方确认机制
+
+种子用户名单由乙方提供,甲方审核确认,双方共同签字(或书面确认)存档,作为后续结算依据。
+
+### 7.6 非种子用户
+
+非种子用户的收入分配,按照本协议第五条规定的阶段划分及对应比例执行。
+
+---
+
+## 第八条 结算与支付
+
+### 8.1 结算周期
+
+双方按 **自然月** 进行结算。每月 **10日前**(遇节假日顺延),甲方完成上一自然月的收入核算,并向乙方提供详细的结算报表。
+
+### 8.2 结算报表内容
+
+结算报表应包含:
+- 当月平台总收入;
+- 能量师一级分润(25%)金额;
+- 能量师分红(10%)金额;
+- 可分配毛利(65%)金额;
+- 当月适用的分配比例及依据(当前阶段及累计能量师数);
+- 乙方当期应得分成金额;
+- 种子用户引荐奖励(如有)。
+
+### 8.3 支付时间
+
+甲方应在双方确认结算数据无误后 **5个工作日内**,将乙方应得的分成款项支付至乙方指定账户。
+
+### 8.4 乙方指定收款账户
+
+- **开户行:** ____________________
+- **户名:** 张圣甜
+- **账号:** ____________________
+
+### 8.5 税务处理
+
+1. 乙方应得分成款项为税前金额,相关税费由 **乙方自行承担**;
+2. 甲方按 **劳务报酬所得** 为乙方代扣代缴个人所得税,并将税后净额支付至乙方指定账户;
+3. 甲方应在每次支付时向乙方提供完税证明或代扣代缴凭证;
+4. 如国家税收政策调整,双方按新政策执行。
+
+### 8.6 数据透明
+
+甲方应向乙方开放平台数据后台的只读查询权限,或每月出具经双方确认的收入报表,确保乙方可随时核验收入数据。如乙方对数据有异议,双方应共同委托第三方审计,审计费用由差错方承担。
+
+---
+
+## 第九条 合作期限
+
+9.1 本协议有效期为 **三年**,自双方签署之日起生效。
+
+9.2 协议期满前 **三个月**,双方协商续签事宜。在同等条件下,双方享有优先续约权。
+
+9.3 协议到期未续签的,双方按以下方式处理:
+   - 已产生的未结算收入按本协议约定继续结算;
+   - 双方各自归还对方提供的资料、数据及其他财产;
+   - 平台现有用户由双方协商处置方案。
+
+---
+
+## 第十条 知识产权
+
+### 10.1 甲方知识产权
+
+### 10.2 乙方知识产权
+
+### 10.3 共有知识产权
+
+### 10.4 使用限制
+
+任何一方未经对方书面同意,不得将共有知识产权单独许可或转让给第三方。本协议终止后,双方仍可按合作期间的约定继续使用共有知识产权。
+
+---
+
+## 第十一条 保密义务
+
+11.1 双方对在合作过程中知悉的对方商业秘密、技术秘密、客户信息、经营数据等一切未公开信息(以下简称"保密信息")负有保密义务。
+
+11.2 未经对方书面同意,任何一方不得向第三方披露保密信息,法律法规另有规定的除外。
+
+11.3 保密义务在本协议终止后 **三年内** 继续有效。
+
+11.4 违反保密义务的一方应赔偿对方因此遭受的全部损失。
+
+---
+
+## 第十二条 协议的变更与终止
+
+### 12.1 协议变更
+
+本协议的任何修改、补充均需双方书面同意,并作为本协议的组成部分。
+
+### 12.2 协议终止
+
+有下列情形之一的,本协议终止:
+1. 合作期限届满,双方未续签;
+2. 双方书面协商一致终止;
+3. 一方严重违约,经另一方书面催告后 **30日内** 仍未纠正的,守约方有权单方解除本协议;
+4. 因不可抗力导致协议无法继续履行。
+
+### 12.3 终止后的结算
+
+协议终止时,双方应在 **30日内** 完成所有未结算款项的清算。
+
+---
+
+## 第十三条 违约责任
+
+13.1 任何一方违反本协议约定,给对方造成损失的,应承担违约赔偿责任。
+
+13.2 甲方如未按约定时间支付乙方分成,每逾期一日,按应付未付金额的 **万分之五** 向乙方支付违约金。
+
+13.3 乙方如未按约定履行运营推广义务,经甲方书面催告后 **30日内** 仍无明显改善的,甲方有权调整乙方的分配比例或解除本协议。
+
+---
+
+## 第十四条 不可抗力
+
+14.1 因自然灾害、战争、政府行为、法律法规变化等不可抗力因素导致协议无法履行的,受影响方不承担违约责任。
+
+14.2 遭遇不可抗力的一方应在 **7日内** 书面通知对方,并提供相关证明材料。
+
+---
+
+## 第十五条 争议解决
+
+15.1 本协议的订立、履行、解释及争议解决均适用中华人民共和国法律。
+
+15.2 因本协议引起的或与本协议有关的任何争议,双方应首先友好协商解决。
+
+15.3 协商不成的,任何一方均可向 **甲方所在地有管辖权的人民法院** 提起诉讼。
+
+---
+
+## 第十六条 其他约定
+
+16.1 **完整协议**:本协议及其附件构成双方就合作事宜的全部约定,取代之前一切口头或书面的沟通与协议。
+
+16.2 **可分割性**:本协议的任何条款无效或不可执行,不影响其他条款的效力。
+
+16.3 **通知送达**:双方按本协议首部所列地址发送通知,即视为有效送达。地址变更应提前 **3日** 书面通知对方。
+
+16.4 **附件效力**:本协议附件与本协议正文具有同等法律效力。
+
+16.5 **文本**:本协议一式 **两份**,甲乙双方各执 **一份**,每份具有同等法律效力。
+
+---
+
+## 附件清单
+
+**附件一:** 会员定价方案(详见本协议第三条)
+
+---
+
+## 签字页
+
+**甲方(技术方):亿图腾(北京)科技有限公司**
+
+| 项目 | 内容 |
+|------|------|
+| 授权代表签字: | ____________________ |
+| 公司盖章: | ____________________ |
+| 日期: | ____年____月____日 |
+
+**乙方(运营方):张圣甜**
+
+| 项目 | 内容 |
+|------|------|
+| 签字: | ____________________ |
+| 指印: | ____________________ |
+| 日期: | ____年____月____日 |