Forráskód Böngészése

docs: 新增财富健康设计文档和实现计划(记账打卡+保单管理+能量联动)

User 3 hónapja
szülő
commit
09e58d7e49

+ 991 - 0
docs/superpowers/plans/2026-06-11-wealth-dimension-implementation.md

@@ -0,0 +1,991 @@
+# 富维度(财富健康)实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现财富健康三要素:记账打卡、保单上传、以及商品购买/创收与富维度的能量联动
+
+**Architecture:** 后端新增两张表(finance_checkins + insurance_policies)及对应 CRUD API + 能量发放;小程序端新增记账打卡页和保单管理页;调整富维度默认比例和日上限。
+
+**Tech Stack:** Spring Boot + MyBatis-Plus / uni-app Vue 2
+
+**设计依据:** `docs/superpowers/specs/2026-06-11-wealth-dimension-energy-design.md`
+
+---
+
+## 文件改动总览
+
+| # | 文件 | 操作 | 说明 |
+|:-:|:-----|:----:|:------|
+| 1 | `entity/FinanceCheckin.java` | 新增 | 记账打卡实体 |
+| 2 | `entity/InsurancePolicy.java` | 新增 | 保单实体 |
+| 3 | `mapper/FinanceCheckinMapper.java` | 新增 | 打卡 Mapper |
+| 4 | `mapper/InsurancePolicyMapper.java` | 新增 | 保单 Mapper |
+| 5 | `service/FinanceCheckinService.java` | 新增 | 打卡业务(含能量发放) |
+| 6 | `service/InsurancePolicyService.java` | 新增 | 保单业务(含能量发放) |
+| 7 | `controller/wealth/FinanceCheckinController.java` | 新增 | 打卡 API |
+| 8 | `controller/wealth/InsurancePolicyController.java` | 新增 | 保单 API |
+| 9 | `service/EnergyService.java` | 修改 | 新增富维度能量发放方法 |
+| 10 | `config/DatabaseInitializer.java` | 修改 | 新增两张表 DDL |
+| 11 | `cfc-frontend/utils/api.js` | 修改 | 新增 API |
+| 12 | `cfc-frontend/pages/wealth/checkin.vue` | 新增 | 记账打卡页 |
+| 13 | `cfc-frontend/pages/wealth/insurance-list.vue` | 新增 | 保单列表页 |
+| 14 | `cfc-frontend/pages/wealth/insurance-add.vue` | 新增 | 添加保单页 |
+| 15 | `cfc-frontend/pages/wealth/index.vue` | 新增 | 财富健康首页 |
+| 16 | `cfc-frontend/pages.json` | 修改 | 注册新页面路径 |
+
+---
+
+### Task 1: 数据库 — 新建两张表
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+- [ ] **Step 1: 在 initializeTables() 中添加建表语句**
+
+```java
+// 记账打卡表
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS finance_checkins (" +
+    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+    "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+    "child_id BIGINT DEFAULT NULL COMMENT '孩子ID(可为空)', " +
+    "checkin_date DATE NOT NULL COMMENT '记账日期', " +
+    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+    "UNIQUE KEY uk_user_date (user_id, IFNULL(child_id, 0), checkin_date)" +
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记账打卡'");
+
+// 保单表
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS insurance_policies (" +
+    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+    "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+    "policy_type VARCHAR(50) NOT NULL COMMENT '保单类型:医疗/重疾/意外/寿险/养老/其他', " +
+    "insured_name VARCHAR(100) NOT NULL COMMENT '被保人姓名', " +
+    "insured_relation VARCHAR(20) DEFAULT 'self' COMMENT '关系:self/spouse/child/parent', " +
+    "insurance_company VARCHAR(100) DEFAULT '' COMMENT '保险公司', " +
+    "policy_number VARCHAR(100) DEFAULT '' COMMENT '保单号', " +
+    "coverage_amount DECIMAL(12,2) DEFAULT 0 COMMENT '保额(万元)', " +
+    "premium DECIMAL(10,2) DEFAULT 0 COMMENT '年缴保费', " +
+    "start_date DATE DEFAULT NULL COMMENT '生效日期', " +
+    "end_date DATE DEFAULT NULL COMMENT '到期日期', " +
+    "image_url VARCHAR(500) DEFAULT '' COMMENT '保单照片URL', " +
+    "status TINYINT DEFAULT 1 COMMENT '1有效/0失效', " +
+    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='保单记录'");
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat: add finance_checkins and insurance_policies DDL"
+```
+
+---
+
+### Task 2: 后端实体 + Mapper
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/FinanceCheckin.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/InsurancePolicy.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/FinanceCheckinMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/InsurancePolicyMapper.java`
+
+- [ ] **Step 1: FinanceCheckin.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("finance_checkins")
+public class FinanceCheckin implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long childId;
+    private Date checkinDate;
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 2: InsurancePolicy.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("insurance_policies")
+public class InsurancePolicy implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private String policyType;
+    private String insuredName;
+    private String insuredRelation;
+    private String insuranceCompany;
+    private String policyNumber;
+    private BigDecimal coverageAmount;
+    private BigDecimal premium;
+    private Date startDate;
+    private Date endDate;
+    private String imageUrl;
+    private Integer status;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+- [ ] **Step 3: 两个 Mapper**
+
+```java
+@Mapper
+public interface FinanceCheckinMapper extends BaseMapper<FinanceCheckin> {}
+
+@Mapper
+public interface InsurancePolicyMapper extends BaseMapper<InsurancePolicy> {}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FinanceCheckin.java cfc-backend/src/main/java/com/etotem/cfc/entity/InsurancePolicy.java cfc-backend/src/main/java/com/etotem/cfc/mapper/FinanceCheckinMapper.java cfc-backend/src/main/java/com/etotem/cfc/mapper/InsurancePolicyMapper.java
+git commit -m "feat: add FinanceCheckin and InsurancePolicy entities"
+```
+
+---
+
+### Task 3: 后端 Service + Controller
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/InsurancePolicyService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/wealth/FinanceCheckinController.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/wealth/InsurancePolicyController.java`
+
+- [ ] **Step 1: FinanceCheckinService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FinanceCheckin;
+import com.etotem.cfc.mapper.FinanceCheckinMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+public class FinanceCheckinService {
+
+    @Resource
+    private FinanceCheckinMapper financeCheckinMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    /**
+     * 今日是否已记账
+     */
+    public boolean hasCheckedInToday(Long userId, Long childId) {
+        LocalDate today = LocalDate.now();
+        return financeCheckinMapper.selectCount(new LambdaQueryWrapper<FinanceCheckin>()
+                .eq(FinanceCheckin::getUserId, userId)
+                .eq(childId != null, FinanceCheckin::getChildId, childId)
+                .eq(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(today))) > 0;
+    }
+
+    /**
+     * 记账打卡
+     * @return energy 本次获得的能量值
+     */
+    @Transactional
+    public int checkin(Long userId, Long childId) {
+        if (hasCheckedInToday(userId, childId)) {
+            return 0; // 今天已记过
+        }
+
+        LocalDate today = LocalDate.now();
+        FinanceCheckin record = new FinanceCheckin();
+        record.setUserId(userId);
+        record.setChildId(childId);
+        record.setCheckinDate(java.sql.Date.valueOf(today));
+        financeCheckinMapper.insert(record);
+
+        // 基础能量 +3
+        int energy = 3;
+
+        // 连续7天额外奖励
+        if (isStreak7Days(userId, childId)) {
+            energy += 10;
+        }
+
+        // 调用能量服务发放富维度能量
+        // energyService.awardEnergy(childId != null ? childId : userId, "finance_checkin", record.getId(),
+        //         energy, childId != null ? "child" : "parent", "记账打卡");
+
+        return energy;
+    }
+
+    /**
+     * 判断是否连续记账7天
+     */
+    private boolean isStreak7Days(Long userId, Long childId) {
+        LocalDate today = LocalDate.now();
+        LocalDate sevenDaysAgo = today.minusDays(6);
+
+        long count = financeCheckinMapper.selectCount(new LambdaQueryWrapper<FinanceCheckin>()
+                .eq(FinanceCheckin::getUserId, userId)
+                .eq(childId != null, FinanceCheckin::getChildId, childId)
+                .ge(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(sevenDaysAgo))
+                .le(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(today)));
+
+        return count >= 7;
+    }
+
+    /**
+     * 当月打卡天数
+     */
+    public int getMonthlyCheckinCount(Long userId, Long childId) {
+        LocalDate now = LocalDate.now();
+        LocalDate firstDay = now.withDayOfMonth(1);
+        LocalDate lastDay = now.withDayOfMonth(now.lengthOfMonth());
+
+        return financeCheckinMapper.selectCount(new LambdaQueryWrapper<FinanceCheckin>()
+                .eq(FinanceCheckin::getUserId, userId)
+                .eq(childId != null, FinanceCheckin::getChildId, childId)
+                .ge(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(firstDay))
+                .le(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(lastDay)));
+    }
+
+    /**
+     * 本月打卡日期列表(用于日历展示)
+     */
+    public List<Date> getMonthlyCheckinDates(Long userId, Long childId) {
+        LocalDate now = LocalDate.now();
+        LocalDate firstDay = now.withDayOfMonth(1);
+        LocalDate lastDay = now.withDayOfMonth(now.lengthOfMonth());
+
+        List<FinanceCheckin> records = financeCheckinMapper.selectList(new LambdaQueryWrapper<FinanceCheckin>()
+                .eq(FinanceCheckin::getUserId, userId)
+                .eq(childId != null, FinanceCheckin::getChildId, childId)
+                .ge(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(firstDay))
+                .le(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(lastDay))
+                .orderByAsc(FinanceCheckin::getCheckinDate));
+
+        return records.stream().map(FinanceCheckin::getCheckinDate).collect(java.util.stream.Collectors.toList());
+    }
+}
+```
+
+- [ ] **Step 2: InsurancePolicyService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.InsurancePolicy;
+import com.etotem.cfc.mapper.InsurancePolicyMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.Date;
+
+@Slf4j
+@Service
+public class InsurancePolicyService {
+
+    @Resource
+    private InsurancePolicyMapper insurancePolicyMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    public Page<InsurancePolicy> getList(Long userId, int page, int size) {
+        return insurancePolicyMapper.selectPage(new Page<>(page, size),
+                new LambdaQueryWrapper<InsurancePolicy>()
+                        .eq(InsurancePolicy::getUserId, userId)
+                        .orderByDesc(InsurancePolicy::getCreatedAt));
+    }
+
+    public InsurancePolicy getDetail(Long id, Long userId) {
+        InsurancePolicy policy = insurancePolicyMapper.selectById(id);
+        if (policy != null && !policy.getUserId().equals(userId)) {
+            return null;
+        }
+        return policy;
+    }
+
+    @Transactional
+    public InsurancePolicy create(InsurancePolicy policy, Long userId) {
+        policy.setUserId(userId);
+        policy.setStatus(1);
+        policy.setCreatedAt(new Date());
+        policy.setUpdatedAt(new Date());
+        insurancePolicyMapper.insert(policy);
+
+        // 每份有效保单 +50 能量(一次性)
+        // energyService.awardEnergy(userId, "insurance_policy", policy.getId(), 50, "parent", "上传保单");
+
+        return policy;
+    }
+
+    @Transactional
+    public void update(InsurancePolicy policy) {
+        policy.setUpdatedAt(new Date());
+        insurancePolicyMapper.updateById(policy);
+    }
+
+    @Transactional
+    public void delete(Long id, Long userId) {
+        insurancePolicyMapper.delete(new LambdaQueryWrapper<InsurancePolicy>()
+                .eq(InsurancePolicy::getId, id)
+                .eq(InsurancePolicy::getUserId, userId));
+    }
+
+    /**
+     * 统计用户保单信息
+     */
+    public int countByUser(Long userId) {
+        return insurancePolicyMapper.selectCount(new LambdaQueryWrapper<InsurancePolicy>()
+                .eq(InsurancePolicy::getUserId, userId)
+                .eq(InsurancePolicy::getStatus, 1));
+    }
+}
+```
+
+- [ ] **Step 3: FinanceCheckinController.java**
+
+```java
+package com.etotem.cfc.controller.wealth;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.FinanceCheckinService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/wealth")
+public class FinanceCheckinController {
+
+    @Resource
+    private FinanceCheckinService financeCheckinService;
+
+    /**
+     * 记账打卡
+     */
+    @PostMapping("/checkin")
+    public Result<Integer> checkin(@RequestBody Map<String, Object> body,
+                                    @RequestAttribute("userId") Long userId) {
+        Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
+        int energy = financeCheckinService.checkin(userId, childId);
+        return Result.success(energy);
+    }
+
+    /**
+     * 今日是否已记账
+     */
+    @PostMapping("/checkin/today")
+    public Result<Boolean> todayStatus(@RequestBody Map<String, Object> body,
+                                        @RequestAttribute("userId") Long userId) {
+        Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
+        boolean done = financeCheckinService.hasCheckedInToday(userId, childId);
+        return Result.success(done);
+    }
+
+    /**
+     * 本月打卡数据
+     */
+    @PostMapping("/checkin/monthly")
+    public Result<Map<String, Object>> monthly(@RequestBody Map<String, Object> body,
+                                                 @RequestAttribute("userId") Long userId) {
+        Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
+        int count = financeCheckinService.getMonthlyCheckinCount(userId, childId);
+        List<Date> dates = financeCheckinService.getMonthlyCheckinDates(userId, childId);
+        return Result.success(java.util.Map.of("count", count, "dates", dates));
+    }
+}
+```
+
+- [ ] **Step 4: InsurancePolicyController.java**
+
+```java
+package com.etotem.cfc.controller.wealth;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.InsurancePolicy;
+import com.etotem.cfc.service.InsurancePolicyService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/wealth")
+public class InsurancePolicyController {
+
+    @Resource
+    private InsurancePolicyService insurancePolicyService;
+
+    @PostMapping("/insurance/list")
+    public Result<Page<InsurancePolicy>> list(@RequestBody Map<String, Object> body,
+                                               @RequestAttribute("userId") Long userId) {
+        int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 20;
+        return Result.success(insurancePolicyService.getList(userId, page, size));
+    }
+
+    @PostMapping("/insurance/detail")
+    public Result<InsurancePolicy> detail(@RequestBody Map<String, Object> body,
+                                           @RequestAttribute("userId") Long userId) {
+        Long id = Long.valueOf(body.get("id").toString());
+        InsurancePolicy policy = insurancePolicyService.getDetail(id, userId);
+        if (policy == null) return Result.error("保单不存在");
+        return Result.success(policy);
+    }
+
+    @PostMapping("/insurance/create")
+    public Result<String> create(@RequestBody InsurancePolicy policy,
+                                  @RequestAttribute("userId") Long userId) {
+        insurancePolicyService.create(policy, userId);
+        return Result.success("添加成功");
+    }
+
+    @PostMapping("/insurance/update")
+    public Result<String> update(@RequestBody InsurancePolicy policy) {
+        insurancePolicyService.update(policy);
+        return Result.success("更新成功");
+    }
+
+    @PostMapping("/insurance/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> body,
+                                  @RequestAttribute("userId") Long userId) {
+        Long id = Long.valueOf(body.get("id").toString());
+        insurancePolicyService.delete(id, userId);
+        return Result.success("删除成功");
+    }
+
+    @PostMapping("/insurance/count")
+    public Result<Integer> count(@RequestAttribute("userId") Long userId) {
+        return Result.success(insurancePolicyService.countByUser(userId));
+    }
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java cfc-backend/src/main/java/com/etotem/cfc/service/InsurancePolicyService.java cfc-backend/src/main/java/com/etotem/cfc/controller/wealth/FinanceCheckinController.java cfc-backend/src/main/java/com/etotem/cfc/controller/wealth/InsurancePolicyController.java
+git commit -m "feat: add finance checkin and insurance policy services/controllers"
+```
+
+---
+
+### Task 4: 调整富维度默认比例和日上限
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java`
+
+- [ ] **Step 1: 调整 wealth 日上限**
+
+```java
+// 原:DAILY_LIMIT_MAP.put("wealth", 30);
+// 改为:
+DAILY_LIMIT_MAP.put("wealth", 50);
+```
+
+- [ ] **Step 2: 调整默认比例(若已实现在 EnergyService 中)**
+
+找到 `CATEGORY_DIMENSION_MAP` 或其他默认比例定义:
+
+```java
+// 默认任务中 wealth 从 0.05 改为 0.10
+// "default" → body:0.30, mind:0.20, wisdom:0.15, action:0.25, wealth:0.10
+```
+
+实际修改位置取决于当前 EnergyService 中比例常量定义的位置。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
+git commit -m "chore: adjust wealth daily limit to 50 and default ratio to 10%"
+```
+
+---
+
+### Task 5: 小程序 — 记账打卡页面
+
+**Files:**
+- Create: `cfc-frontend/pages/wealth/checkin.vue`
+- Modify: `cfc-frontend/utils/api.js`
+- Modify: `cfc-frontend/pages.json`
+
+- [ ] **Step 1: 在 api.js 添加 API**
+
+```javascript
+// ===== 财富健康 =====
+export const wealthCheckin = (data) => request('/api/wealth/checkin', 'POST', data)
+export const wealthCheckinToday = (data) => request('/api/wealth/checkin/today', 'POST', data)
+export const wealthCheckinMonthly = (data) => request('/api/wealth/checkin/monthly', 'POST', data)
+export const wealthInsuranceList = (data) => request('/api/wealth/insurance/list', 'POST', data)
+export const wealthInsuranceDetail = (data) => request('/api/wealth/insurance/detail', 'POST', data)
+export const wealthInsuranceCreate = (data) => request('/api/wealth/insurance/create', 'POST', data)
+export const wealthInsuranceUpdate = (data) => request('/api/wealth/insurance/update', 'POST', data)
+export const wealthInsuranceDelete = (data) => request('/api/wealth/insurance/delete', 'POST', data)
+export const wealthInsuranceCount = () => request('/api/wealth/insurance/count', 'POST')
+```
+
+- [ ] **Step 2: 创建记账打卡页面**
+
+```vue
+<template>
+  <view class="container">
+    <view class="header">
+      <text class="title">📒 记账打卡</text>
+      <text class="subtitle">每天记账,培养财商</text>
+    </view>
+
+    <!-- 今日打卡状态 -->
+    <view class="today-card" :class="{ done: todayCheckedIn }">
+      <view class="today-icon">
+        <text class="icon-text">{{ todayCheckedIn ? '✅' : '📝' }}</text>
+      </view>
+      <view class="today-info">
+        <text class="today-label">{{ todayCheckedIn ? '今日已记账' : '今天记账了吗?' }}</text>
+        <text class="today-energy" v-if="!todayCheckedIn">打卡 +3 能量</text>
+        <text class="today-energy" v-else>连续打卡奖励已发放</text>
+      </view>
+      <button v-if="!todayCheckedIn" class="checkin-btn" @click="doCheckin">打卡</button>
+    </view>
+
+    <!-- 本月打卡日历 -->
+    <view class="calendar-section">
+      <text class="section-title">📅 本月打卡 {{ monthlyCount }} 天</text>
+      <view class="calendar-grid">
+        <view v-for="(day, i) in calendarDays" :key="i" class="calendar-day">
+          <text class="day-num">{{ day }}</text>
+          <view v-if="checkedInDates.includes(day)" class="day-dot checked"></view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { wealthCheckin, wealthCheckinToday, wealthCheckinMonthly } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      todayCheckedIn: false,
+      monthlyCount: 0,
+      checkedInDates: [],
+      calendarDays: []
+    }
+  },
+  onLoad() {
+    this.loadData()
+    this.generateCalendar()
+  },
+  methods: {
+    generateCalendar() {
+      const now = new Date()
+      const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
+      this.calendarDays = Array.from({ length: daysInMonth }, (_, i) => i + 1)
+    },
+    async loadData() {
+      const [todayRes, monthlyRes] = await Promise.all([
+        wealthCheckinToday({}),
+        wealthCheckinMonthly({})
+      ])
+      if (todayRes.code === 200) this.todayCheckedIn = todayRes.data
+      if (monthlyRes.code === 200) {
+        this.monthlyCount = monthlyRes.data.count || 0
+        this.checkedInDates = (monthlyRes.data.dates || []).map(d => new Date(d).getDate())
+      }
+    },
+    async doCheckin() {
+      const res = await wealthCheckin({})
+      if (res.code === 200) {
+        this.todayCheckedIn = true
+        uni.showToast({ title: '打卡成功 +' + res.data + '能量', icon: 'success' })
+        this.loadData()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 30rpx; }
+.header { text-align: center; margin-bottom: 40rpx; }
+.title { font-size: 36rpx; font-weight: bold; }
+.subtitle { font-size: 24rpx; color: #999; margin-top: 8rpx; }
+.today-card {
+  display: flex; align-items: center; background: #fff;
+  border-radius: 20rpx; padding: 30rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.today-card.done { background: #f0fdf4; }
+.today-icon { width: 80rpx; height: 80rpx; display: flex; align-items: center; justify-content: center; }
+.icon-text { font-size: 48rpx; }
+.today-info { flex: 1; margin-left: 20rpx; }
+.today-label { font-size: 28rpx; font-weight: bold; color: #333; }
+.today-energy { font-size: 22rpx; color: #F97316; margin-top: 4rpx; }
+.checkin-btn {
+  background: linear-gradient(135deg, #42A5F5, #1565C0);
+  color: #fff; border: none; border-radius: 40rpx;
+  padding: 12rpx 36rpx; font-size: 26rpx; font-weight: bold;
+}
+.calendar-section { margin-top: 40rpx; }
+.section-title { font-size: 28rpx; font-weight: bold; margin-bottom: 20rpx; }
+.calendar-grid {
+  display: flex; flex-wrap: wrap; gap: 8rpx;
+  background: #fff; border-radius: 16rpx; padding: 20rpx;
+}
+.calendar-day {
+  width: calc(100% / 7 - 8rpx); text-align: center; padding: 8rpx 0;
+}
+.day-num { font-size: 24rpx; color: #666; }
+.day-dot { width: 12rpx; height: 12rpx; border-radius: 50%; margin: 4rpx auto 0; }
+.day-dot.checked { background: #4CAF50; }
+</style>
+```
+
+- [ ] **Step 3: 在 pages.json 注册路径**
+
+```json
+{
+  "path": "pages/wealth/checkin",
+  "style": {
+    "navigationBarTitleText": "记账打卡"
+  }
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/wealth/checkin.vue cfc-frontend/pages.json
+git commit -m "feat: add finance checkin page (mini-program)"
+```
+
+---
+
+### Task 6: 小程序 — 保单管理页面
+
+**Files:**
+- Create: `cfc-frontend/pages/wealth/insurance-list.vue`
+- Create: `cfc-frontend/pages/wealth/insurance-add.vue`
+
+- [ ] **Step 1: 保单列表页**
+
+```vue
+<template>
+  <view class="container">
+    <view class="header">
+      <text class="title">🛡️ 我的保单</text>
+      <text class="subtitle">已有 {{ count }} 份保单</text>
+    </view>
+
+    <view v-if="loading" class="loading-wrap">
+      <text class="loading-text">加载中...</text>
+    </view>
+    <view v-else-if="list.length === 0" class="empty-wrap">
+      <text class="empty-icon">📄</text>
+      <text class="empty-text">还没有保单,添加一份吧</text>
+      <button class="add-btn" @click="goAdd">添加保单</button>
+    </view>
+    <scroll-view v-else scroll-y class="list-scroll">
+      <view v-for="item in list" :key="item.id" class="policy-card">
+        <view class="policy-header">
+          <text class="policy-type">{{ typeLabel(item.policyType) }}</text>
+          <text class="policy-status" :class="{ active: item.status === 1 }">
+            {{ item.status === 1 ? '有效' : '已失效' }}
+          </text>
+        </view>
+        <view class="policy-body">
+          <text class="policy-name">被保人:{{ item.insuredName }}</text>
+          <text class="policy-amount">保额:{{ item.coverageAmount }}万</text>
+          <text class="policy-company">{{ item.insuranceCompany }}</text>
+        </view>
+        <view class="policy-footer">
+          <text class="policy-date">{{ item.startDate }} ~ {{ item.endDate }}</text>
+          <button class="del-btn" size="mini" @click="handleDelete(item.id)">删除</button>
+        </view>
+      </view>
+      <view v-if="noMore" class="no-more">— 没有更多了 —</view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { wealthInsuranceList, wealthInsuranceCount, wealthInsuranceDelete } from '@/utils/api.js'
+
+const TYPE_MAP = { medical: '医疗险', critical: '重疾险', accident: '意外险', life: '寿险', pension: '养老险', other: '其他' }
+
+export default {
+  data() {
+    return {
+      list: [],
+      count: 0,
+      page: 1,
+      size: 20,
+      loading: false,
+      noMore: false
+    }
+  },
+  onLoad() { this.load() },
+  methods: {
+    typeLabel(t) { return TYPE_MAP[t] || t },
+    async load() {
+      this.loading = true
+      const [listRes, countRes] = await Promise.all([
+        wealthInsuranceList({ page: this.page, size: this.size }),
+        wealthInsuranceCount()
+      ])
+      this.loading = false
+      if (listRes.code === 200 && listRes.data) {
+        this.list = listRes.data.records || []
+        this.noMore = this.list.length >= (listRes.data.total || 0)
+      }
+      if (countRes.code === 200) this.count = countRes.data || 0
+    },
+    goAdd() { uni.navigateTo({ url: '/pages/wealth/insurance-add' }) },
+    async handleDelete(id) {
+      uni.showModal({
+        title: '提示', content: '确定删除该保单?',
+        success: async (res) => {
+          if (res.confirm) {
+            await wealthInsuranceDelete({ id })
+            uni.showToast({ title: '删除成功', icon: 'success' })
+            this.load()
+          }
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 30rpx; }
+.header { margin-bottom: 30rpx; }
+.title { font-size: 36rpx; font-weight: bold; }
+.subtitle { font-size: 24rpx; color: #999; margin-top: 6rpx; }
+.loading-wrap, .empty-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 120rpx; }
+.empty-icon { font-size: 100rpx; }
+.empty-text { font-size: 28rpx; color: #999; margin: 20rpx 0; }
+.add-btn {
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  color: #fff; border: none; border-radius: 40rpx;
+  padding: 16rpx 60rpx; font-size: 28rpx;
+}
+.policy-card {
+  background: #fff; border-radius: 16rpx; padding: 24rpx;
+  margin-bottom: 20rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+}
+.policy-header { display: flex; justify-content: space-between; align-items: center; }
+.policy-type { font-size: 28rpx; font-weight: bold; color: #333; }
+.policy-status { font-size: 22rpx; color: #999; }
+.policy-status.active { color: #4CAF50; }
+.policy-body { margin: 12rpx 0; }
+.policy-name, .policy-amount, .policy-company { display: block; font-size: 24rpx; color: #666; margin-top: 4rpx; }
+.policy-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12rpx; }
+.policy-date { font-size: 22rpx; color: #bbb; }
+.del-btn { color: #e74c3c; font-size: 22rpx; }
+.no-more { text-align: center; padding: 30rpx; color: #ccc; font-size: 24rpx; }
+</style>
+```
+
+- [ ] **Step 2: 添加保单页**
+
+```vue
+<template>
+  <view class="container">
+    <text class="title">添加保单</text>
+
+    <view class="form">
+      <view class="form-item">
+        <text class="label">保单类型 *</text>
+        <picker :range="typeOptions" range-key="label" @change="e => form.policyType = typeOptions[e.detail.value].value">
+          <view class="picker">{{ typeLabel(form.policyType) || '请选择' }}</view>
+        </picker>
+      </view>
+      <view class="form-item">
+        <text class="label">被保人 *</text>
+        <input v-model="form.insuredName" placeholder="姓名" class="input" />
+      </view>
+      <view class="form-item">
+        <text class="label">与被保人关系</text>
+        <picker :range="relationOptions" range-key="label" @change="e => form.insuredRelation = relationOptions[e.detail.value].value">
+          <view class="picker">{{ relationLabel(form.insuredRelation) || '请选择' }}</view>
+        </picker>
+      </view>
+      <view class="form-item">
+        <text class="label">保险公司</text>
+        <input v-model="form.insuranceCompany" placeholder="如:中国人寿" class="input" />
+      </view>
+      <view class="form-item">
+        <text class="label">保额(万元)</text>
+        <input v-model="form.coverageAmount" type="digit" placeholder="50" class="input" />
+      </view>
+      <view class="form-item">
+        <text class="label">年缴保费</text>
+        <input v-model="form.premium" type="digit" placeholder="5000" class="input" />
+      </view>
+      <view class="form-item">
+        <text class="label">保单照片</text>
+        <button class="upload-btn" @click="chooseImage">上传照片</button>
+        <image v-if="form.imageUrl" :src="form.imageUrl" class="preview" mode="aspectFit" />
+      </view>
+      <button class="submit-btn" @click="handleSubmit">保存并获取能量</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { wealthInsuranceCreate } from '@/utils/api.js'
+
+const TYPE_OPTIONS = [
+  { label: '医疗险', value: 'medical' }, { label: '重疾险', value: 'critical' },
+  { label: '意外险', value: 'accident' }, { label: '寿险', value: 'life' },
+  { label: '养老险', value: 'pension' }, { label: '其他', value: 'other' }
+]
+const RELATION_OPTIONS = [
+  { label: '本人', value: 'self' }, { label: '配偶', value: 'spouse' },
+  { label: '子女', value: 'child' }, { label: '父母', value: 'parent' }
+]
+const TYPE_MAP = { medical: '医疗险', critical: '重疾险', accident: '意外险', life: '寿险', pension: '养老险', other: '其他' }
+const RELATION_MAP = { self: '本人', spouse: '配偶', child: '子女', parent: '父母' }
+
+export default {
+  data() {
+    return {
+      typeOptions: TYPE_OPTIONS,
+      relationOptions: RELATION_OPTIONS,
+      form: {
+        policyType: '', insuredName: '', insuredRelation: 'self',
+        insuranceCompany: '', policyNumber: '', coverageAmount: '',
+        premium: '', imageUrl: ''
+      }
+    }
+  },
+  methods: {
+    typeLabel(v) { return TYPE_MAP[v] },
+    relationLabel(v) { return RELATION_MAP[v] },
+    chooseImage() {
+      uni.chooseImage({ count: 1, sizeType: ['compressed'] }).then(res => {
+        const tempPath = res.tempFilePaths[0]
+        // 上传图片
+        uni.uploadFile({
+          url: 'http://localhost:9080/api/admin/articles/upload/image', // TODO: use config.api
+          filePath: tempPath, name: 'file',
+          success: (uploadRes) => {
+            const data = JSON.parse(uploadRes.data)
+            if (data.code === 200) this.form.imageUrl = data.data
+          }
+        })
+      })
+    },
+    async handleSubmit() {
+      if (!this.form.policyType || !this.form.insuredName) {
+        uni.showToast({ title: '请填写完整信息', icon: 'none' })
+        return
+      }
+      const res = await wealthInsuranceCreate({
+        ...this.form,
+        coverageAmount: parseFloat(this.form.coverageAmount) || 0,
+        premium: parseFloat(this.form.premium) || 0
+      })
+      if (res.code === 200) {
+        uni.showToast({ title: '添加成功 +50能量', icon: 'success' })
+        setTimeout(() => uni.navigateBack(), 1500)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 30rpx; }
+.title { font-size: 32rpx; font-weight: bold; margin-bottom: 30rpx; }
+.form-item { margin-bottom: 24rpx; }
+.label { font-size: 26rpx; color: #666; display: block; margin-bottom: 8rpx; }
+.input, .picker {
+  width: 100%; padding: 16rpx 20rpx; border: 1rpx solid #eee;
+  border-radius: 12rpx; font-size: 26rpx; box-sizing: border-box;
+}
+.picker { color: #333; }
+.upload-btn { font-size: 24rpx; padding: 10rpx 30rpx; background: #f0f0f0; border-radius: 8rpx; display: inline-block; }
+.preview { width: 200rpx; height: 280rpx; margin-top: 12rpx; border-radius: 8rpx; }
+.submit-btn {
+  width: 100%; background: linear-gradient(135deg, #42A5F5, #1565C0);
+  color: #fff; border: none; border-radius: 44rpx;
+  padding: 24rpx 0; font-size: 30rpx; font-weight: bold; margin-top: 40rpx;
+}
+</style>
+```
+
+- [ ] **Step 3: 在 pages.json 注册路径**
+
+```json
+{
+  "path": "pages/wealth/insurance-list",
+  "style": { "navigationBarTitleText": "我的保单" }
+},
+{
+  "path": "pages/wealth/insurance-add",
+  "style": { "navigationBarTitleText": "添加保单" }
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-frontend/pages/wealth/insurance-list.vue cfc-frontend/pages/wealth/insurance-add.vue cfc-frontend/pages.json
+git commit -m "feat: add insurance policy management pages (mini-program)"
+```
+
+---
+
+## 验证清单
+
+- [ ] **记账打卡**
+  - 首次点击打卡 → +3 能量,当天不可重复打卡
+  - 连续7天 → 额外 +10 能量
+  - 当月日历正确显示打卡日
+- [ ] **保单管理**
+  - 添加保单(填写类型/被保人/保额/上传照片) → +50 能量
+  - 同一保单号不可重复添加
+  - 列表/删除正常
+- [ ] **富维度比例**
+  - 默认任务 wealth 比例 10%
+  - 财商类任务 wealth 比例 90%
+  - 日上限 50
+- [ ] **后端编译**: `mvn clean compile` 无错误
+- [ ] **LSP 诊断**: 所有文件无错误

+ 113 - 0
docs/superpowers/specs/2026-06-11-wealth-dimension-energy-design.md

@@ -0,0 +1,113 @@
+# 富维度(财富健康)能量系统设计
+
+**日期:** 2026-06-11
+**状态:** 已确认
+**依赖:** 五维能量总设计 + 内容系统 + 商品系统
+
+---
+
+## 1. 定义
+
+财富健康 = 家庭的财务生命力,包含三个层面:
+
+| 层面 | 含义 | 落地方式 |
+|:-----|:------|:---------|
+| **财商素养** | 记账习惯、理财知识 | 记账打卡 + 理财文章/课程/活动 |
+| **创收能力** | 通过分享推广获得收入 | 现有创客生态(分销/推广/社群运营) |
+| **财务安全** | 保险保障、应急能力 | 保单上传记录 |
+
+---
+
+## 2. 数据来源与能量规则
+
+### 2.1 财商素养 (日常低频)
+
+#### ① 记账打卡
+
+**产品定义:** 只记录"是否记账",不记录收支明细和金额。每天一次。
+
+**数据表:** `finance_checkins`
+
+| 列名 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK | |
+| user_id | BIGINT | 用户ID |
+| child_id | BIGINT NULL | 孩子ID(可选,家长可替孩子记) |
+| checkin_date | DATE | 记账日期(唯一:同一用户同一天只能一条) |
+| created_at | DATETIME | |
+
+UNIQUE KEY: `(user_id, child_id, checkin_date)`
+
+**能量规则:** 每天 +3,连续记账7天额外 +10
+
+#### ② 理财文章/课程/活动
+
+| 来源 | 能量 | 说明 |
+|:-----|:------|:------|
+| 理财类文章阅读 | +5~10/篇 | 复用文章系统,通过`related_dimensions`包含"wealth"过滤 |
+| 理财类课程完成 | +10~20/门 | 复用课程/任务系统,task.category="财商类" |
+| 理财类活动参与 | +10~20/次 | 复用活动系统,活动关联"wealth"维度 |
+
+> 理财内容由运营通过文章发布系统发布,选择关联五维时勾选"富·水"即可。
+
+### 2.2 创收能力 (持续)
+
+| 数据来源 | 能量规则 | 说明 |
+|:---------|:---------|:------|
+| 商品推广佣金 | 按佣金比例换算 | 关联CommissionService,每获得一定佣金释放能量 |
+| 团队管理收益 | 按团队收益换算 | 关联邀请/团队系统 |
+
+> **具体算法:** 每获得 ¥X 佣金 → 获得 Y 能量。公式待定,先预留接口返回0。
+
+### 2.3 财务安全 (一次性)
+
+**数据表:** `insurance_policies`
+
+| 列名 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK | |
+| user_id | BIGINT | 用户ID |
+| policy_type | VARCHAR(50) | 保单类型:医疗/重疾/意外/寿险/养老/其他 |
+| insured_name | VARCHAR(100) | 被保人姓名 |
+| insured_relation | VARCHAR(20) | 与被保人关系:self/spouse/child/parent |
+| insurance_company | VARCHAR(100) | 保险公司 |
+| policy_number | VARCHAR(100) | 保单号 |
+| coverage_amount | DECIMAL(12,2) | 保额(万元) |
+| premium | DECIMAL(10,2) | 年缴保费 |
+| start_date | DATE | 生效日期 |
+| end_date | DATE | 到期日期 |
+| image_url | VARCHAR(500) | 保单照片URL |
+| status | TINYINT DEFAULT 1 | 1有效/0失效 |
+| created_at | DATETIME | |
+
+**能量规则:** 每上传一份有效保单 +50(一次性,同一保单号不可重复)。
+
+---
+
+## 3. 维度比例默认值
+
+现有 task_default 中 wealth 占 5%,调整为:
+
+| source_type | wealth 比例 | 说明 |
+|:------------|:-----------:|:-----|
+| task(默认) | 10% | 从5%提升到10% |
+| task:财商类 | 90% | 不变 |
+| product(默认) | 10% | 从5%提升到10% |
+| activity(默认) | 10% | 从5%提升到10% |
+| consultation | 10% | 从5%提升到10% |
+
+---
+
+## 4. 日上限
+
+| 维度 | 日获取上限 | 说明 |
+|:-----|:---------:|:-----|
+| 富 | 50 | 从30提升到50,支持保单一次性+50 |
+
+---
+
+## 相关文档
+
+- `docs/superpowers/specs/2026-06-08-five-dimension-wuxing-philosophy.md` — 五行哲学
+- `docs/superpowers/specs/2026-06-09-remaining-dimensions-energy-design.md` — 心/智/行/富维度原设计
+- `docs/superpowers/specs/2026-06-05-five-dimension-energy-design.md` — 五维能量总设计