Просмотр исходного кода

merge(admin): 家庭运营工作台全局聚合统计接口 + 前端统计带对接 合入 cfclub 主线

Agnes Agent 1 день назад
Родитель
Сommit
cbe6dae1be

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyStatsController.java

@@ -0,0 +1,46 @@
+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;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 家庭运营工作台 - 全局聚合统计接口。
+ *
+ * <p>路由 {@code POST /api/admin/families/stats} 与既有 {@code /families/{id}}、{@code /families/count}
+ * 不冲突:Spring 字面量路径优先于 {@code {id}} 变量路径,且 {@code AdminController} 中已存在同类字面量映射
+ * {@code /families/count};全仓 grep 确认无 {@code families/stats} 占用。</p>
+ */
+@Tag(name = "管理端-家庭运营统计", description = "家庭工作台全局聚合统计带")
+@RestController
+@RequestMapping("/api/admin")
+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,
+                                             @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());
+    }
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyAdminStatsDTO.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 家庭运营工作台 - 全局聚合统计 DTO。
+ * 与 {@code FamilyOverviewDTO}(单家庭概览)刻意区分,避免类/Bean 命名冲突。
+ * 各计数字段均为全表 COUNT 口径(全局聚合),非当前页合计。
+ */
+@Data
+public class FamilyAdminStatsDTO {
+
+    /** 家庭总数:COUNT(*) FROM families */
+    private Long familyCount;
+
+    /** 成员总数:COUNT(*) FROM family_members WHERE status = 'active'(与家庭列表成员数口径一致) */
+    private Long memberCount;
+
+    /** 报告数:COUNT(*) FROM health_reports */
+    private Long reportCount;
+
+    /** 方案数:COUNT(*) FROM health_plans */
+    private Long planCount;
+
+    /** 任务数:COUNT(*) FROM tasks */
+    private Long taskCount;
+
+    /** 优惠券数:COUNT(*) FROM family_coupon(家庭优惠券发放记录总数) */
+    private Long couponCount;
+
+    /** 本月报告数:COUNT(*) FROM health_reports WHERE created_at >= 当月首日 AND created_at < 次月首日 */
+    private Long monthReportCount;
+
+    /**
+     * 家庭总收益:待收益台账接入(本轮返回 null)。
+     *
+     * <p>仓库中现存多个资金/积分口径的候选数据源:family_earnings.total_earned(累计推荐收入,单位分)、
+     * family_platform_balance.total_earned(CF 值)、family_commission.commission_amount(佣金)等,
+     * 但「家庭总收益」的业务口径(币种/单位、是否扣减提现与颁发、是否含 CF 值、以哪张台账为准)尚未确认。
+     * 为避免在统计带上展示误导性数字,本轮不臆造数值,返回 null;前端继续保持「—」并给出说明。
+     * 待收益台账口径确认后,取权威表聚合填充本字段即可,前端无需改动。</p>
+     */
+    private Long earningsAmount;
+}

+ 66 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyAdminStatsService.java

@@ -0,0 +1,66 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.FamilyAdminStatsDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * 家庭运营工作台 - 全局聚合统计服务。
+ * 全部复用已有 Mapper 的 selectCount(全表 COUNT),口径与家庭列表每行的统计列一致,
+ * 只是去掉 groupBy 聚合到全局。不新增表、不改 schema、不碰 DatabaseInitializer。
+ */
+@Service
+public class FamilyAdminStatsService {
+
+    @Resource private FamilyMapper familyMapper;
+    @Resource private FamilyMemberMapper familyMemberMapper;
+    @Resource private HealthReportMapper reportMapper;
+    @Resource private HealthPlanMapper planMapper;
+    @Resource private TaskMapper taskMapper;
+    @Resource private FamilyCouponMapper familyCouponMapper;
+
+    public FamilyAdminStatsDTO buildStats() {
+        FamilyAdminStatsDTO dto = new FamilyAdminStatsDTO();
+        dto.setFamilyCount(familyMapper.selectCount(null));
+        dto.setMemberCount(familyMemberMapper.selectCount(
+                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getStatus, "active")));
+        dto.setReportCount(reportMapper.selectCount(null));
+        dto.setPlanCount(planMapper.selectCount(null));
+        dto.setTaskCount(taskMapper.selectCount(null));
+        dto.setCouponCount(familyCouponMapper.selectCount(null));
+        dto.setMonthReportCount(countCurrentMonthReports());
+        // 家庭总收益:业务口径未确认,暂不臆造数值,返回 null(见 DTO 字段注释)
+        dto.setEarningsAmount(null);
+        return dto;
+    }
+
+    /** 本月(按 created_at 落点)上传的健康报告数;显式 Asia/Shanghai 时区,与 JDBC serverTimezone 对齐。 */
+    private Long countCurrentMonthReports() {
+        // 与 JDBC serverTimezone 对齐;若 DB 时区配置变更需同步调整
+        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);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date monthStart = cal.getTime();
+        cal.add(Calendar.MONTH, 1);
+        Date nextMonthStart = cal.getTime();
+        return reportMapper.selectCount(new LambdaQueryWrapper<HealthReport>()
+                .ge(HealthReport::getCreatedAt, monthStart)
+                .lt(HealthReport::getCreatedAt, nextMonthStart));
+    }
+}

+ 6 - 0
cfc-web/src/api/admin.js

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

+ 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)

+ 53 - 15
cfc-web/src/views/Families.vue

@@ -11,35 +11,35 @@
       </div>
     </div>
 
-    <!-- ① 运营统计带(注:聚合口径为当前页合计;家庭总收益无现成字段,标记为待后端接口) -->
+    <!-- ① 运营统计带:优先取 /api/admin/families/stats 全局聚合;接口不可用/失败时降级为当前页合计(stats),其中「本月报告数」降级为本页累计口径 -->
     <div class="stat-band">
       <div class="stat-card stat-card--primary">
         <div class="stat-label">家庭总数</div>
-        <div class="stat-value stat-value--primary adm-num">{{ stats.total }}</div>
+        <div class="stat-value stat-value--primary adm-num">{{ displayStats.total }}</div>
       </div>
       <div class="stat-card">
-        <div class="stat-label">成员总数(本页合计)</div>
-        <div class="stat-value adm-num">{{ stats.members }}</div>
+        <div class="stat-label">成员总数<span v-if="statsDegraded">(本页合计)</span></div>
+        <div class="stat-value adm-num">{{ displayStats.members }}</div>
       </div>
       <div class="stat-card">
-        <div class="stat-label">报告数(本页合计)</div>
-        <div class="stat-value adm-num">{{ stats.reports }}</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">
-        <div class="stat-label">方案数(本页合计)</div>
-        <div class="stat-value adm-num">{{ stats.plans }}</div>
+        <div class="stat-label">方案数<span v-if="statsDegraded">(本页合计)</span></div>
+        <div class="stat-value adm-num">{{ displayStats.plans }}</div>
       </div>
       <div class="stat-card">
-        <div class="stat-label">任务数(本页合计)</div>
-        <div class="stat-value adm-num">{{ stats.tasks }}</div>
+        <div class="stat-label">任务数<span v-if="statsDegraded">(本页合计)</span></div>
+        <div class="stat-value adm-num">{{ displayStats.tasks }}</div>
       </div>
       <div class="stat-card">
-        <div class="stat-label">优惠券数(本页合计)</div>
-        <div class="stat-value adm-num">{{ stats.coupons }}</div>
+        <div class="stat-label">优惠券数<span v-if="statsDegraded">(本页合计)</span></div>
+        <div class="stat-value adm-num">{{ displayStats.coupons }}</div>
       </div>
       <div class="stat-card">
         <div class="stat-label">家庭总收益</div>
-        <div class="stat-value adm-num" title="需后端新增聚合接口">—</div>
+        <div class="stat-value adm-num" :title="displayStats.earningsAmount == null ? '待收益台账接入:家庭总收益口径未确认,暂不展示数值' : ''">{{ displayStats.earningsAmount == null ? '—' : displayStats.earningsAmount }}</div>
       </div>
     </div>
 
@@ -392,7 +392,7 @@
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
-import { getFamilyList, getFamilyMembers, getFamilyOverview, getFamilyTasks, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
+import { getFamilyList, getFamilyMembers, getFamilyOverview, getFamilyTasks, getFamilyStats, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
 
 export default {
   name: 'Families',
@@ -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 {
@@ -419,6 +421,28 @@ export default {
         tasks: sum('taskCount'),
         coupons: sum('couponCount')
       }
+    },
+    // 降级态:全局聚合未返回 / 失败 / 任一关键字段缺失时,统计带标注降级口径
+    statsDegraded() {
+      const g = this.globalStats
+      return !g || ['familyCount', 'memberCount', 'monthReportCount', 'planCount', 'taskCount', 'couponCount']
+        .some(k => g[k] === null || g[k] === undefined)
+    },
+    // 统计带展示值:优先取全局聚合接口(globalStats);字段缺失或降级时逐项回退到本页合计(stats)
+    displayStats() {
+      const g = this.globalStats
+      const fallback = this.stats
+      const pick = (key, fbKey) =>
+        (g && g[key] !== null && g[key] !== undefined) ? g[key] : fallback[fbKey]
+      return {
+        total: pick('familyCount', 'total'),
+        members: pick('memberCount', 'members'),
+        reports: pick('monthReportCount', 'reports'),
+        plans: pick('planCount', 'plans'),
+        tasks: pick('taskCount', 'tasks'),
+        coupons: pick('couponCount', 'coupons'),
+        earningsAmount: (g && g.earningsAmount !== null && g.earningsAmount !== undefined) ? g.earningsAmount : null
+      }
     }
   },
   data() {
@@ -426,6 +450,8 @@ export default {
       loading: false,
       submitting: false,
       tableData: [],
+      // 全局聚合统计(POST /api/admin/families/stats);null 表示未加载或失败,统计带降级为本页合计
+      globalStats: null,
       sortableColumns: { id: 'ID', name: '家庭名称', inviteCode: '邀请码', createdAt: '创建时间' },
       searchForm: {
         name: '',
@@ -525,11 +551,22 @@ export default {
       this.handleViewMembersById(parseInt(familyId))
     }
     this.loadFamilies()
+    this.loadGlobalStats() // 独立异步加载,不阻塞列表
   },
   methods: {
     loadList() {
       this.loadFamilies()
     },
+    // 全局聚合统计:独立异步加载;失败或返回空时保持 globalStats=null,统计带自动降级
+    async loadGlobalStats() {
+      try {
+        const res = await getFamilyStats()
+        const data = res && res.data
+        this.globalStats = (data && typeof data === 'object') ? data : null
+      } catch (error) {
+        this.globalStats = null
+      }
+    },
     onSelectionChange(val) {
       this.selectedRows = val
     },
@@ -614,6 +651,7 @@ export default {
     },
     handleRefresh() {
       this.loadFamilies()
+      this.loadGlobalStats()
     },
     handleSizeChange(val) {
       this.pagination.size = val

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

@@ -3079,6 +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`)。请求:无请求体;仅平台角色(`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)