Răsfoiți Sursa

fix(admin): 对齐家庭统计带口径/平台权限/silent 降级并显式时区

Agnes Agent 2 zile în urmă
părinte
comite
5f7cb6b013

+ 13 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyStatsController.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.controller.admin;
 
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.FamilyAdminStatsDTO;
 import com.etotem.cfc.service.FamilyAdminStatsService;
@@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.List;
 
 /**
  * 家庭运营工作台 - 全局聚合统计接口。
@@ -26,10 +28,19 @@ public class AdminFamilyStatsController {
 
     @Resource private FamilyAdminStatsService familyAdminStatsService;
 
+    /**
+     * 全局聚合是全表 COUNT 的「全平台」口径,仅放行平台角色(判定与 AdminController#getFamilies 的
+     * platform 变量完全一致)。管家 / 规划师 / 营养师等非平台角色若放行,会看到全平台总数,构成数据越权,
+     * 故不下发聚合值;改为返回 {@code Result.success(null)}(非 error,避免前端弹全局错误提示),前端据此
+     * 降级为「本页合计」——本页列表本就是 B-5 行级隔离后的数据,降级口径对非平台角色恰好正确。
+     */
     @Operation(summary = "家庭运营全局聚合统计")
     @PostMapping("/families/stats")
-    public Result<FamilyAdminStatsDTO> stats(@RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) return Result.error("无权限");
+    public Result<FamilyAdminStatsDTO> stats(@RequestAttribute("role") String role,
+                                             @RequestAttribute("roles") List<String> roleList) {
+        boolean platform = AdminRoles.hasAnyPlatformRole(roleList) || "admin".equals(role)
+                || AdminRoles.hasRole(roleList, "operator") || AdminRoles.hasRole(roleList, "finance");
+        if (!platform) return Result.<FamilyAdminStatsDTO>success(null);
         return Result.success(familyAdminStatsService.buildStats());
     }
 }

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyAdminStatsService.java

@@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.TimeZone;
 
 /**
  * 家庭运营工作台 - 全局聚合统计服务。
@@ -46,9 +47,9 @@ public class FamilyAdminStatsService {
         return dto;
     }
 
-    /** 本月(按 created_at 落点)上传的健康报告数。 */
+    /** 本月(按 created_at 落点)上传的健康报告数;显式 Asia/Shanghai 时区,与 JDBC serverTimezone 对齐。 */
     private Long countCurrentMonthReports() {
-        Calendar cal = Calendar.getInstance();
+        Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Asia/Shanghai"));
         cal.set(Calendar.DAY_OF_MONTH, 1);
         cal.set(Calendar.HOUR_OF_DAY, 0);
         cal.set(Calendar.MINUTE, 0);

+ 2 - 1
cfc-web/src/api/admin.js

@@ -129,8 +129,9 @@ export function getFamilyTasks(id, params) {
 }
 
 // 家庭运营工作台:全局聚合统计带数据源(POST /api/admin/families/stats,无请求体)
+// silent: true —— 该接口失败时由页面自行降级为本页合计,不弹全局错误提示
 export function getFamilyStats() {
-  return request({ url: '/api/admin/families/stats', method: 'post' })
+  return request({ url: '/api/admin/families/stats', method: 'post', silent: true })
 }
 
 export function setMemberAdminRole(familyId, memberId, isAdmin) {

+ 19 - 8
cfc-web/src/utils/request.js

@@ -29,28 +29,39 @@ service.interceptors.response.use(
     if (res.code === 200) {
       return res
     } else {
-      Message.error(res.message || '请求失败')
+      // silent 请求(如全局统计带)由调用方自行降级,不弹全局错误提示
+      if (!response.config.silent) {
+        Message.error(res.message || '请求失败')
+      }
       return Promise.reject(new Error(res.message || '请求失败'))
     }
   },
   error => {
     console.error('响应错误:', error)
+    const silent = !!(error.config && error.config.silent)
     if (error.response) {
       if (error.response.status === 401) {
-        Message.error('登录已过期,请重新登录')
+        if (!silent) {
+          Message.error('登录已过期,请重新登录')
+        }
+        // 登录过期处理(清登录态 / 跳登录页)不受 silent 影响,始终执行
         localStorage.clear()
         router.push('/login')
       } else if (error.response.status === 504) {
-        Message.error('请求处理中(服务器繁忙),请勿重复提交,稍后刷新列表查看')
-      } else {
+        if (!silent) {
+          Message.error('请求处理中(服务器繁忙),请勿重复提交,稍后刷新列表查看')
+        }
+      } else if (!silent) {
         Message.error(error.response.data?.message || '请求失败')
       }
     } else {
       // 网络层错误(含超时 ECONNABORTED)
-      if (error.code === 'ECONNABORTED' || (error.message && error.message.includes('timeout'))) {
-        Message.error('请求超时,内容可能已保存成功,请勿重复提交,稍后刷新列表查看')
-      } else {
-        Message.error('网络请求失败')
+      if (!silent) {
+        if (error.code === 'ECONNABORTED' || (error.message && error.message.includes('timeout'))) {
+          Message.error('请求超时,内容可能已保存成功,请勿重复提交,稍后刷新列表查看')
+        } else {
+          Message.error('网络请求失败')
+        }
       }
     }
     return Promise.reject(error)

+ 10 - 6
cfc-web/src/views/Families.vue

@@ -11,7 +11,7 @@
       </div>
     </div>
 
-    <!-- ① 运营统计带:优先取 /api/admin/families/stats 全局聚合;接口不可用/失败时降级为当前页合计(stats) -->
+    <!-- ① 运营统计带:优先取 /api/admin/families/stats 全局聚合;接口不可用/失败时降级为当前页合计(stats),其中「本月报告数」降级为本页累计口径 -->
     <div class="stat-band">
       <div class="stat-card stat-card--primary">
         <div class="stat-label">家庭总数</div>
@@ -22,7 +22,7 @@
         <div class="stat-value adm-num">{{ displayStats.members }}</div>
       </div>
       <div class="stat-card">
-        <div class="stat-label">报告数<span v-if="statsDegraded">(本页合计)</span></div>
+        <div class="stat-label">本月报告数<span v-if="statsDegraded">(本页累计)</span></div>
         <div class="stat-value adm-num">{{ displayStats.reports }}</div>
       </div>
       <div class="stat-card">
@@ -408,7 +408,9 @@ export default {
     currentPath() {
       return this.$route.path
     },
-    // 统计带兜底:基于当前页 tableData 合计(全局聚合接口不可用时降级使用,勿删)
+    // 统计带兜底:基于当前页 tableData 合计(仅降级时使用,勿删)
+    // 注意:reports 为「本页累计」口径(sum reportCount),与全局「本月报告数」口径不同,
+    // 故降级时该卡片单独标注「(本页累计)」,其余卡片仍为「(本页合计)」
     stats() {
       const sum = (key) => this.tableData.reduce((acc, r) => acc + (r[key] || 0), 0)
       return {
@@ -420,9 +422,11 @@ export default {
         coupons: sum('couponCount')
       }
     },
-    // 降级态:全局聚合接口尚未返回或失败时,统计带标注「(本页合计)」
+    // 降级态:全局聚合未返回 / 失败 / 任一关键字段缺失时,统计带标注降级口径
     statsDegraded() {
-      return this.globalStats == null
+      const g = this.globalStats
+      return !g || ['familyCount', 'memberCount', 'monthReportCount', 'planCount', 'taskCount', 'couponCount']
+        .some(k => g[k] === null || g[k] === undefined)
     },
     // 统计带展示值:优先取全局聚合接口(globalStats);字段缺失或降级时逐项回退到本页合计(stats)
     displayStats() {
@@ -433,7 +437,7 @@ export default {
       return {
         total: pick('familyCount', 'total'),
         members: pick('memberCount', 'members'),
-        reports: pick('reportCount', 'reports'),
+        reports: pick('monthReportCount', 'reports'),
         plans: pick('planCount', 'plans'),
         tasks: pick('taskCount', 'tasks'),
         coupons: pick('couponCount', 'coupons'),

+ 1 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -3079,7 +3079,7 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 | `POST /api/admin/children`、`/children/{id}`、`/children/{id}/update`、`/children/{id}/adjust-points` | 孩子视角管理 |
 | `POST /api/admin/org/member/list`、`/add`、`/remove`、`/change-type` | 组织成员治理(`AdminOrgMemberController`) |
 | `POST /api/admin/families/{id}/overview`、`/families/{id}/tasks` | 家庭概览(`AdminFamilyOverviewController`) |
-| `POST /api/admin/families/stats` | 家庭运营工作台全局聚合统计(`AdminFamilyStatsController`)。请求:无请求体,`@RequestAttribute("role")` 须为 admin;返回 `Result<FamilyAdminStatsDTO>`:`familyCount`=families COUNT、`memberCount`=family_members(status='active') COUNT、`reportCount`=health_reports COUNT、`planCount`=health_plans COUNT、`taskCount`=tasks COUNT、`couponCount`=family_coupon COUNT、`monthReportCount`=当月 created_at 落点的 health_reports COUNT、`earningsAmount`=null(待收益台账接入,口径未确认不臆造数值)。与 `/families/{id}`、`/families/count` 字面量路径不冲突 |
+| `POST /api/admin/families/stats` | 家庭运营工作台全局聚合统计(`AdminFamilyStatsController`)。请求:无请求体;仅平台角色(`AdminRoles.hasAnyPlatformRole` / admin / operator / finance,与 `/families` 的 platform 判定一致)返回全局聚合,非平台角色返回 `Result.success(null)`,前端降级至「本页合计」(本页列表本就行级隔离,口径正确)。返回 `Result<FamilyAdminStatsDTO>`:`familyCount`=families COUNT、`memberCount`=family_members(status='active') COUNT、`reportCount`=health_reports 累计 COUNT、`planCount`=health_plans COUNT、`taskCount`=tasks COUNT、`couponCount`=family_coupon COUNT、`monthReportCount`=当月 created_at 落点的 health_reports COUNT(前端「本月报告数」映射此字段)、`earningsAmount` 本轮恒为 null(家庭总收益口径未定:family_earnings.total_earned 单位分 / family_platform_balance 已停写且为 CF 值 / family_commission 佣金 / family_earnings_record 流水),前端显示「—」,待业务确认口径后接入。与 `/families/{id}`、`/families/count` 字面量路径不冲突 |
 
 #### 4.60.12 订单/退款人工处理(`AdminProductOrderController` `/api/admin/product/order`,SC-A-12)