Ver Fonte

chore: auto bump version and changelog [skip ci]

iwt há 6 dias atrás
pai
commit
f1401ec1b2

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

@@ -8144,6 +8144,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                     "calories_burned INT COMMENT '消耗卡路里', " +
                     "heart_rate_avg INT COMMENT '平均心率', " +
                     "heart_rate_max INT COMMENT '最大心率', " +
+                    "step_count INT COMMENT '微信运动步数', " +
                     "start_time DATETIME COMMENT '开始时间', " +
                     "end_time DATETIME COMMENT '结束时间', " +
                     "remark TEXT COMMENT '备注', " +

+ 76 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java

@@ -1,19 +1,27 @@
 package com.etotem.cfc.controller;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.HealthExerciseRecord;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.HealthExerciseRecordMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.WechatService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
+import com.etotem.cfc.util.ParamUtils;
 
 import javax.annotation.Resource;
+import java.util.Calendar;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import com.etotem.cfc.util.SortUtil;
-import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "运动打卡")
 @RestController
@@ -23,6 +31,15 @@ public class HealthExerciseController {
     @Resource
     private HealthExerciseRecordMapper healthExerciseRecordMapper;
 
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private WechatService wechatService;
+
+    @Value("${wechat.appid}")
+    private String appid;
+
     @Resource
     private com.etotem.cfc.service.ProfileComputeService profileComputeService;
 
@@ -61,4 +78,61 @@ public class HealthExerciseController {
         }
         return Result.success(record);
     }
+
+    @Operation(summary = "同步微信运动步数(返回当日步数与近30天数据)")
+    @PostMapping("/sync-wechat-steps")
+    public Result<Map<String, Object>> syncWechatSteps(@RequestBody Map<String, String> params,
+                                                       @RequestAttribute(value = "userId", required = false) Long userId) {
+        String encryptedData = params.get("encryptedData");
+        String iv = params.get("iv");
+        if (encryptedData == null || encryptedData.trim().isEmpty() || iv == null || iv.trim().isEmpty()) {
+            return Result.error("参数不完整");
+        }
+        if (userId == null) return Result.error("未登录");
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+            return Result.error("登录会话已过期,请重新登录");
+        }
+
+        String plaintext;
+        try {
+            plaintext = wechatService.decryptWeRunData(encryptedData, iv, user.getSessionKey());
+        } catch (Exception e) {
+            return Result.error("步数解密失败,请重新登录后再试");
+        }
+
+        JSONObject json = JSON.parseObject(plaintext);
+        if (json == null) return Result.error("步数数据解析失败");
+
+        JSONObject watermark = json.getJSONObject("watermark");
+        if (watermark == null || !appid.equals(watermark.getString("appid"))) {
+            return Result.error("步数数据校验失败");
+        }
+
+        JSONArray stepInfoList = json.getJSONArray("stepInfoList");
+        int todayStep = 0;
+        if (stepInfoList != null) {
+            Calendar now = Calendar.getInstance();
+            for (int i = 0; i < stepInfoList.size(); i++) {
+                JSONObject item = stepInfoList.getJSONObject(i);
+                Long ts = item.getLong("timestamp");
+                if (ts == null) continue;
+                Calendar c = Calendar.getInstance();
+                c.setTimeInMillis(ts * 1000L);
+                if (now.get(Calendar.YEAR) == c.get(Calendar.YEAR)
+                        && now.get(Calendar.MONTH) == c.get(Calendar.MONTH)
+                        && now.get(Calendar.DAY_OF_MONTH) == c.get(Calendar.DAY_OF_MONTH)) {
+                    Integer step = item.getInteger("step");
+                    if (step != null) todayStep = step;
+                    break;
+                }
+            }
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("stepCount", todayStep);
+        data.put("stepInfoList", stepInfoList);
+        return Result.success(data);
+    }
 }

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -15,6 +15,9 @@ import org.springframework.util.StreamUtils;
 import com.etotem.cfc.service.api.WechatServiceInterface;
 
 import javax.annotation.PostConstruct;
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
@@ -584,4 +587,24 @@ public class WechatService implements WechatServiceInterface {
             log.error("发送模板消息异常", e);
         }
     }
+
+    /**
+     * 解密微信运动步数数据(AES-128-CBC + PKCS5Padding)
+     * @param encryptedData 小程序 wx.getWeRunData 返回的加密数据
+     * @param iv            对应的小程序 iv(base64)
+     * @param sessionKey    用户登录时获取的 session_key(base64)
+     * @return 解密后的 JSON 字符串,包含 stepInfoList 和 watermark
+     * @throws Exception 解密失败时抛出
+     */
+    public String decryptWeRunData(String encryptedData, String iv, String sessionKey) throws Exception {
+        byte[] keyBytes = Base64.getDecoder().decode(sessionKey);
+        byte[] ivBytes = Base64.getDecoder().decode(iv);
+        byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
+        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
+        IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
+        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
+        byte[] decrypted = cipher.doFinal(encryptedBytes);
+        return new String(decrypted, StandardCharsets.UTF_8);
+    }
 }

+ 96 - 2
cfc-frontend/pages/growth/exercise-checkin.vue

@@ -51,6 +51,16 @@
             <text class="form-label">消耗卡路里(选填)</text>
             <input class="form-input" type="number" v-model="form.caloriesBurned" placeholder="估算卡路里" />
           </view>
+          <view class="form-group" v-if="isSelfCheckin">
+            <text class="form-label">微信步数(选填)</text>
+            <view class="step-sync-row">
+              <input class="form-input step-input" type="number" v-model="form.stepCount" placeholder="手动填写步数" />
+              <view class="sync-btn" :class="{'syncing': syncingSteps}" @tap="syncWechatSteps">
+                <text>{{ syncingSteps ? '同步中…' : '同步微信步数' }}</text>
+              </view>
+            </view>
+            <text class="step-hint" v-if="syncedStepText">{{ syncedStepText }}</text>
+          </view>
           <view class="form-group">
             <text class="form-label">备注(选填)</text>
             <input class="form-input" v-model="form.remark" placeholder="运动感受..." />
@@ -126,7 +136,7 @@
             <view class="history-item" v-for="item in history" :key="item.id">
               <view class="history-left">
                 <text class="history-type">{{ exerciseTypeLabel(item.exerciseType) }}</text>
-                <text class="history-content">{{ item.durationMinutes }}分钟{{ item.caloriesBurned ? ' · ' + item.caloriesBurned + '千卡' : '' }}</text>
+                <text class="history-content">{{ item.durationMinutes }}分钟{{ item.caloriesBurned ? ' · ' + item.caloriesBurned + '千卡' : '' }}{{ item.stepCount ? ' · ' + item.stepCount + '步' : '' }}</text>
               </view>
               <view class="history-right">
                 <text class="history-date">{{ formatDate(item.createdAt) }}</text>
@@ -151,7 +161,10 @@ export default {
   data() {
     return {
       memberId: null,
+      isSelfCheckin: false,
       submitting: false,
+      syncingSteps: false,
+      syncedStepText: '',
       isRecording: false,
       recordSeconds: 0,
       recordTimer: null,
@@ -176,6 +189,7 @@ export default {
         durationMinutes: '',
         intensity: 'medium',
         caloriesBurned: '',
+        stepCount: '',
         remark: '',
         photos: [],
         voicePath: '',
@@ -195,6 +209,7 @@ export default {
     if (role !== 'child') {
       this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
     }
+    this.isSelfCheckin = uni.getStorageSync('currentView') !== 'member'
     this.recorderManager = uni.getRecorderManager()
     this.recorderManager.onStart(() => {
       this.isRecording = true
@@ -292,6 +307,77 @@ export default {
     deleteVoice: function() {
       this.form.voicePath = ''
     },
+    syncWechatSteps: function() {
+      var self = this
+      if (self.syncingSteps) return
+      wx.getSetting({
+        success: function(res) {
+          var setting = res.authSetting
+          if (setting && setting['scope.werun'] === false) {
+            uni.showModal({
+              title: '需要授权',
+              content: '请去设置中开启「微信运动」授权后重试',
+              confirmText: '去设置',
+              success: function(m) {
+                if (m.confirm) uni.openSetting()
+              }
+            })
+            return
+          }
+          self.doGetWeRunData()
+        },
+        fail: function() {
+          self.doGetWeRunData()
+        }
+      })
+    },
+    doGetWeRunData: function() {
+      var self = this
+      self.syncingSteps = true
+      wx.getWeRunData({
+        success: function(res) {
+          var token = uni.getStorageSync('token')
+          uni.request({
+            url: config.API_BASE_URL + '/api/health/exercise/sync-wechat-steps',
+            method: 'POST',
+            data: { encryptedData: res.encryptedData, iv: res.iv },
+            header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+            success: function(r) {
+              self.syncingSteps = false
+              if (r.data && r.data.code === 200 && r.data.data) {
+                var step = r.data.data.stepCount
+                self.form.stepCount = step
+                self.syncedStepText = '已同步今日步数:' + step + ' 步'
+                uni.showToast({ title: '同步成功', icon: 'success' })
+              } else {
+                var msg = (r.data && r.data.message) || '同步失败'
+                uni.showToast({ title: msg, icon: 'none' })
+              }
+            },
+            fail: function() {
+              self.syncingSteps = false
+              uni.showToast({ title: '网络错误', icon: 'none' })
+            }
+          })
+        },
+        fail: function(err) {
+          self.syncingSteps = false
+          var errMsg = err && err.errMsg || ''
+          if (errMsg.indexOf('auth') >= 0 || errMsg.indexOf('denied') >= 0) {
+            uni.showModal({
+              title: '需要授权',
+              content: '请在微信「设置-隐私-授权管理」中开启微信运动权限',
+              confirmText: '去设置',
+              success: function(m) {
+                if (m.confirm) uni.openSetting()
+              }
+            })
+          } else {
+            uni.showToast({ title: '获取微信步数失败,请先开启微信运动', icon: 'none' })
+          }
+        }
+      })
+    },
     uploadFile: function(filePath) {
       var self = this
       return new Promise(function(resolve, reject) {
@@ -340,12 +426,14 @@ export default {
             remark: self.form.remark || '',
             photoUrls: photoUrls.join(','),
             voiceUrl: voiceUrl,
-            voiceText: self.form.voiceText || ''
+            voiceText: self.form.voiceText || '',
+            stepCount: self.form.stepCount ? parseInt(self.form.stepCount) : null
           },
           header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
           success: function(res) {
             if (res.data && res.data.code === 200) {
               uni.showToast({ title: '打卡成功', icon: 'success' })
+              self.syncedStepText = ''
               setTimeout(function() {
                 uni.navigateBack()
               }, 800)
@@ -392,6 +480,12 @@ export default {
 .tag-btn text { font-size: 26rpx; color: #666; }
 .tag-active text { color: #F97316; font-weight: 600; }
 .form-input { width: 100%; height: 76rpx; border: 1rpx solid #e8e8e8; border-radius: 14rpx; padding: 0 16rpx; font-size: 26rpx; background: #FAFAFA; box-sizing: border-box; }
+.step-sync-row { display: flex; align-items: center; gap: 12rpx; }
+.step-input { flex: 1; }
+.sync-btn { padding: 12rpx 28rpx; border-radius: 30rpx; border: 2rpx solid #F97316; background: #FFF3E0; transition: all 0.2s ease; flex-shrink: 0; }
+.sync-btn text { font-size: 24rpx; color: #F97316; font-weight: 500; }
+.syncing { opacity: 0.5; }
+.step-hint { font-size: 22rpx; color: #10B981; margin-top: 6rpx; display: block; }
 
 /* ===== 媒体工具栏 ===== */
 .media-toolbar { display: flex; align-items: center; gap: 0; padding: 12rpx 0; }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-130b3d06467542e7f89fefd1f0f566d5495e4fa5
+903c2c7146c2f974b041666f705e2edbd0014f17

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1385",
+  "version": "1.0.1386",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1385",
+      "version": "1.0.1386",
       "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.1386",
+  "version": "1.0.1387",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,17 @@
 
 ---
 
+## v1.0.1387 (2026-09-13)
+
+### 新功能
+- 小程序「我的」页设置菜单新增「我的画像」入口
+
+### 其他
+- - urls 映射添加 6 -> '/pages/profile/portrait-edit'
+- - 用户路径: 我的 -> 设置 -> 我的画像 -> portrait-edit.vue
+- 
+
+
 ## v1.0.1386 (2026-09-13)
 
 ### Bug 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1386
+> 当前版本: v1.0.1387
 
 ## 历史版本
 
@@ -8,6 +8,17 @@
 
 ---
 
+## v1.0.1387 (2026-09-13)
+
+### 新功能
+- 小程序「我的」页设置菜单新增「我的画像」入口
+
+### 其他
+- - urls 映射添加 6 -> '/pages/profile/portrait-edit'
+- - 用户路径: 我的 -> 设置 -> 我的画像 -> portrait-edit.vue
+- 
+
+
 ## v1.0.1386 (2026-09-13)
 
 ### Bug 修复

+ 3 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -581,6 +581,9 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/mind/emotion/analyze-url` | 照片情绪识别(URL方式,走 LangGraph DeepFace) |
 | `POST /api/health-status/get` | 健康现状档案获取 |
 | `POST /api/health-status/save` | 健康现状档案保存 |
+| `POST /api/health/exercise/list` | 运动打卡列表 |
+| `POST /api/health/exercise/create` | 创建运动打卡(支持 stepCount 微信步数) |
+| `POST /api/health/exercise/sync-wechat-steps` | 同步微信运动步数(传 encryptedData+iv,返回当日步数与近30天数据) |
 
 ### 4.8 商品与订单(`/api/product/*`)