Jelajahi Sumber

chore: auto bump version and changelog [skip ci]

iwt 3 bulan lalu
induk
melakukan
4ec7f83b2a

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -5395,6 +5395,60 @@ try {
     ensureColumn("dan_assessment_executions", "appointment_id",
             "BIGINT COMMENT '关联assessment_appointments.id'");
     log.info("迁移100: 已添加assessor_id和appointment_id字段");
+
+    // 迁移101: 创建 micro_actions 表(12个预定义1秒微行动)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_actions (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...', " +
+            "name VARCHAR(50) NOT NULL COMMENT '行动名称', " +
+            "icon VARCHAR(10) COMMENT '图标emoji', " +
+            "description VARCHAR(200) COMMENT '行动描述', " +
+            "sort_order INT DEFAULT 0 COMMENT '排序号', " +
+            "status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_code (code), " +
+            "INDEX idx_status (status)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)'");
+        log.info("已创建micro_actions表");
+
+        // 插入12个预定义微行动
+        jdbcTemplate.execute("INSERT IGNORE INTO micro_actions (code, name, icon, description, sort_order) VALUES " +
+            "('DRINK_WATER', '喝一杯水', '☕️', '喝一杯温开水,补充水分', 1), " +
+            "('DEEP_BREATH', '深呼吸3次', '🫁', '深吸慢呼3次,放松身心', 2), " +
+            "('STRETCH', '站起来伸懒腰', '🧘', '站起来伸个懒腰,活动筋骨', 3), " +
+            "('LOOK_FAR', '看远处10秒', '🌿', '看远处绿色植物10秒,缓解眼疲劳', 4), " +
+            "('SMILE', '微笑一下', '😊', '对自己微笑,心情会变好', 5), " +
+            "('NECK_ROLL', '转转头颈5圈', '🔄', '缓慢转动头颈5圈,放松颈椎', 6), " +
+            "('FIST_RELAX', '握拳放松3次', '✊', '用力握拳3秒后放松,重复3次', 7), " +
+            "('SIP_WATER', '喝口水润喉', '💧', '小口喝水润润喉咙', 8), " +
+            "('BLINK', '眨眨眼5次', '👁️', '快速眨眼5次,湿润眼球', 9), " +
+            "('ACUPRESSURE', '按揉合谷穴3下', '🖐️', '按揉虎口合谷穴3下,提神醒脑', 10), " +
+            "('TIPTOE', '踮脚尖5次', '⬆️', '踮起脚尖再放下,重复5次', 11), " +
+            "('SHRUG', '耸肩放松3次', '↕️', '耸肩至耳朵再放松,重复3次', 12)");
+        log.info("已初始化12个微行动");
+    } catch (Exception e) {
+        log.warn("创建micro_actions表或初始化数据失败: {}", e.getMessage());
+    }
+
+    // 迁移102: 创建 micro_action_records 表(微行动完成记录)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_action_records (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "action_id BIGINT NOT NULL COMMENT '行动ID', " +
+            "completed_date DATETIME NOT NULL COMMENT '完成日期', " +
+            "earned_energy INT DEFAULT 1 COMMENT '获得能量值', " +
+            "note VARCHAR(200) COMMENT '备注', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, completed_date), " +
+            "INDEX idx_user_id (user_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录'");
+        log.info("已创建micro_action_records表");
+    } catch (Exception e) {
+        log.warn("创建micro_action_records表失败: {}", e.getMessage());
+    }
     }
 
     private void runMigration82() {

+ 84 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MicroActionController.java

@@ -0,0 +1,84 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.MicroAction;
+import com.etotem.cfc.entity.MicroActionRecord;
+import com.etotem.cfc.service.MicroActionService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 微行动接口 — 有效启动:最小行为单元(1秒完成)
+ */
+@Tag(name = "微行动", description = "1秒微行动:健康行为最小启动单元")
+@RestController
+@RequestMapping("/api/micro-action")
+public class MicroActionController {
+
+    @Resource
+    private MicroActionService microActionService;
+
+    @Operation(summary = "获取所有微行动列表")
+    @PostMapping("/list")
+    public Result<List<MicroAction>> list() {
+        return Result.success(microActionService.getAllActions());
+    }
+
+    @Operation(summary = "获取今日推荐微行动")
+    @PostMapping("/today")
+    public Result<Map<String, Object>> today(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        MicroAction action = microActionService.getTodayAction(childId);
+        boolean completed = childId != null && microActionService.hasCompletedToday(childId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("action", action);
+        result.put("completed", completed);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "完成微行动")
+    @PostMapping("/complete")
+    public Result<MicroActionRecord> complete(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        Long actionId = params.get("actionId") != null
+                ? Long.valueOf(params.get("actionId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        if (actionId == null) {
+            return Result.error("actionId不能为空");
+        }
+        try {
+            MicroActionRecord record = microActionService.completeAction(userId, childId, actionId);
+            return Result.success(record);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "检查今日是否已完成")
+    @PostMapping("/check-today")
+    public Result<Map<String, Object>> checkToday(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        boolean completed = childId != null && microActionService.hasCompletedToday(childId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("completed", completed);
+        return Result.success(result);
+    }
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MicroAction.java

@@ -0,0 +1,37 @@
+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("micro_actions")
+public class MicroAction implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 行动编码: DRINK_WATER, DEEP_BREATH, ... */
+    private String code;
+
+    /** 行动名称: 喝一杯水 */
+    private String name;
+
+    /** 图标emoji */
+    private String icon;
+
+    /** 行动描述 */
+    private String description;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 状态: 1启用 0禁用 */
+    private Integer status;
+
+    private Date createdAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MicroActionRecord.java

@@ -0,0 +1,37 @@
+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("micro_action_records")
+public class MicroActionRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID (childId) */
+    private Long childId;
+
+    /** 行动ID (对应micro_actions.id) */
+    private Long actionId;
+
+    /** 完成日期 */
+    private Date completedDate;
+
+    /** 获得能量值 */
+    private Integer earnedEnergy;
+
+    /** 备注 */
+    private String note;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MicroActionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MicroAction;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MicroActionMapper extends BaseMapper<MicroAction> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MicroActionRecordMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MicroActionRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MicroActionRecordMapper extends BaseMapper<MicroActionRecord> {
+}

+ 242 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MicroActionService.java

@@ -0,0 +1,242 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class MicroActionService {
+
+    @Resource
+    private MicroActionMapper microActionMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private OnboardingService onboardingService;
+
+    @Resource
+    private GrowthTaskService growthTaskService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    /**
+     * 获取所有启用的微行动列表
+     */
+    public List<MicroAction> getAllActions() {
+        return microActionMapper.selectList(
+            new LambdaQueryWrapper<MicroAction>()
+                .eq(MicroAction::getStatus, 1)
+                .orderByAsc(MicroAction::getSortOrder)
+        );
+    }
+
+    /**
+     * 获取今日推荐的微行动
+     * 如果有未使用的新手体验配额,返回随机行动
+     * 如果已完成今日配额,返回null
+     */
+    public MicroAction getTodayAction(Long childId) {
+        // Check if already completed for today
+        MicroActionRecord todayRecord = getTodayRecord(childId);
+        if (todayRecord != null) {
+            return null; // Already completed today
+        }
+        // Get all active actions
+        List<MicroAction> actions = getAllActions();
+        if (actions.isEmpty()) return null;
+
+        // Pick one based on the day of month (consistent for the whole day)
+        Calendar cal = Calendar.getInstance();
+        int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH);
+        int index = dayOfMonth % actions.size();
+        return actions.get(index);
+    }
+
+    /**
+     * 检查今日是否已完成微行动
+     */
+    public boolean hasCompletedToday(Long childId) {
+        return getTodayRecord(childId) != null;
+    }
+
+    /**
+     * 获取今日完成记录
+     */
+    private MicroActionRecord getTodayRecord(Long childId) {
+        if (childId == null) return null;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        return microActionRecordMapper.selectOne(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .apply("DATE(completed_date) = {0}", today)
+                .last("LIMIT 1")
+        );
+    }
+
+    /**
+     * 检查今日是否已健康打卡(互斥逻辑)
+     */
+    private boolean hasHealthCheckinToday(Long childId) {
+        if (childId == null) return false;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        HealthCheckin existing = healthCheckinMapper.selectOne(
+            new LambdaQueryWrapper<HealthCheckin>()
+                .eq(HealthCheckin::getChildId, childId)
+                .apply("DATE(checkin_date) = {0}", today)
+                .last("LIMIT 1")
+        );
+        return existing != null;
+    }
+
+    /**
+     * 完成微行动
+     * 与健康打卡互斥:同一天只能选择一种
+     */
+    @Transactional
+    public MicroActionRecord completeAction(Long userId, Long childId, Long actionId) {
+        if (childId == null) {
+            throw new RuntimeException("childId不能为空");
+        }
+
+        // Check if already completed micro action today
+        if (hasCompletedToday(childId)) {
+            throw new RuntimeException("今日微行动已完成,请明天再来");
+        }
+
+        // Check mutual exclusion with health checkin
+        if (hasHealthCheckinToday(childId)) {
+            throw new RuntimeException("今日已健康打卡,已完成今日签到");
+        }
+
+        // Verify the action exists and is active
+        MicroAction action = microActionMapper.selectById(actionId);
+        if (action == null || action.getStatus() != 1) {
+            throw new RuntimeException("微行动不存在或已下架");
+        }
+
+        // Create record
+        MicroActionRecord record = new MicroActionRecord();
+        record.setUserId(userId);
+        record.setChildId(childId);
+        record.setActionId(actionId);
+        record.setCompletedDate(new Date());
+        record.setEarnedEnergy(1);
+        record.setCreatedAt(new Date());
+        microActionRecordMapper.insert(record);
+
+        // Create task in task system (same pattern as health checkin)
+        try {
+            int points = 5;
+            String pointsStr = sysConfigService.getValue("health_checkin_points");
+            if (pointsStr != null && !pointsStr.isEmpty()) {
+                try { points = Integer.parseInt(pointsStr); } catch (NumberFormatException e) { /* use default */ }
+            }
+
+            Task task = new Task();
+            FamilyMember child = familyMemberMapper.selectById(childId);
+            if (child != null) {
+                task.setFamilyId(child.getFamilyId());
+            }
+            task.setCreatorId(userId);
+            task.setExecutorType("child");
+            task.setExecutorId(childId);
+            task.setChildId(childId);
+            task.setTitle("微行动");
+            task.setDescription("自动生成 - " + action.getName());
+            task.setPoints(points);
+            task.setRepeatType("none");
+            task.setCategory("身体类");
+            task.setNeedReview(0);
+            task.setReviewByCategory(0);
+            task.setTaskType("onetime");
+            task.setSourceType("micro_action");
+            task.setSourceId(record.getId());
+            task.setStatus("completed");
+            task.setCompletedAt(new Date());
+            task.setCreatedAt(new Date());
+            task.setUpdatedAt(new Date());
+            Calendar cal = Calendar.getInstance();
+            cal.set(Calendar.HOUR_OF_DAY, 23);
+            cal.set(Calendar.MINUTE, 59);
+            cal.set(Calendar.SECOND, 59);
+            task.setDeadline(cal.getTime());
+            taskMapper.insert(task);
+
+            // Update child totalPoints
+            if (child != null) {
+                child.setTotalPoints(child.getTotalPoints() + points);
+                child.setUpdatedAt(new Date());
+                familyMemberMapper.updateById(child);
+            }
+
+            // Insert PointsLog
+            PointsLog pointsLog = new PointsLog();
+            pointsLog.setChildId(childId);
+            pointsLog.setTaskId(task.getId());
+            pointsLog.setAmount(points);
+            pointsLog.setType("earn");
+            pointsLog.setCategory("task");
+            pointsLog.setDescription("微行动");
+            pointsLog.setCreatedAt(new Date());
+            pointsLogMapper.insert(pointsLog);
+        } catch (Exception e) {
+            log.warn("微行动任务创建失败: recordId={}, error={}", record.getId(), e.getMessage());
+        }
+
+        // Award energy
+        try {
+            energyService.awardEnergy(childId, "micro_action", record.getId(), 1,
+                    "微行动", 30);
+        } catch (Exception e) {
+            log.warn("微行动能量发放失败: recordId={}, error={}", record.getId(), e.getMessage());
+        }
+
+        // Trigger onboarding and growth tasks
+        try { onboardingService.completeTask(userId, "FIRST_CHECKIN"); } catch (Exception e) { /* ignore */ }
+        try { growthTaskService.updateProgress(userId, "DAILY_CHECKIN", 1); } catch (Exception e) { log.warn("成长任务签到进度更新失败: userId={}", userId); }
+
+        return record;
+    }
+
+    /**
+     * 获取最近N天的微行动记录
+     */
+    public List<MicroActionRecord> getRecentRecords(Long childId, int days) {
+        if (childId == null) return Collections.emptyList();
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -days);
+        Date since = cal.getTime();
+        return microActionRecordMapper.selectList(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .ge(MicroActionRecord::getCompletedDate, since)
+                .orderByDesc(MicroActionRecord::getCompletedDate)
+        );
+    }
+}

+ 28 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2922,3 +2922,31 @@ CREATE TABLE IF NOT EXISTS review_orders (
     UNIQUE KEY uk_order_id (order_id),
     INDEX idx_reviewer_id (reviewer_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动订单评价表';
+
+-- 微行动定义表
+CREATE TABLE IF NOT EXISTS micro_actions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...',
+    name VARCHAR(50) NOT NULL COMMENT '行动名称',
+    icon VARCHAR(10) COMMENT '图标emoji',
+    description VARCHAR(200) COMMENT '行动描述',
+    sort_order INT DEFAULT 0 COMMENT '排序号',
+    status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_code (code),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)';
+
+-- 微行动完成记录表
+CREATE TABLE IF NOT EXISTS micro_action_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    action_id BIGINT NOT NULL COMMENT '行动ID',
+    completed_date DATETIME NOT NULL COMMENT '完成日期',
+    earned_energy INT DEFAULT 1 COMMENT '获得能量值',
+    note VARCHAR(200) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, completed_date),
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录';

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-3f5f5b03f0f248a251547ae7b018193ef81c2c71
+cf447fb50d51396f0f3ab0cf3987190c5728f072

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.479",
+  "version": "1.0.480",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.479",
+      "version": "1.0.480",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.480",
+  "version": "1.0.481",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 6 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,12 @@
 
 ---
 
+## v1.0.481 (2026-07-22)
+
+### 其他
+- web/knowledge.html: Fix title padding (margin-top), add disease articles, wealth marker right->65%
+
+
 ## v1.0.480 (2026-07-22)
 
 ### 其他

+ 7 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.480
+> 当前版本: v1.0.481
 
 ## 历史版本
 
@@ -8,6 +8,12 @@
 
 ---
 
+## v1.0.481 (2026-07-22)
+
+### 其他
+- web/knowledge.html: Fix title padding (margin-top), add disease articles, wealth marker right->65%
+
+
 ## v1.0.480 (2026-07-22)
 
 ### 其他