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

feat(admin): 新增家庭运营全局聚合统计接口并对接工作台统计带

Agnes Agent 2 дней назад
Родитель
Сommit
8f9b29a427

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

@@ -0,0 +1,35 @@
+package com.etotem.cfc.controller.admin;
+
+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;
+
+/**
+ * 家庭运营工作台 - 全局聚合统计接口。
+ *
+ * <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;
+
+    @Operation(summary = "家庭运营全局聚合统计")
+    @PostMapping("/families/stats")
+    public Result<FamilyAdminStatsDTO> stats(@RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        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;
+}

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

@@ -0,0 +1,64 @@
+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;
+
+/**
+ * 家庭运营工作台 - 全局聚合统计服务。
+ * 全部复用已有 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 落点)上传的健康报告数。 */
+    private Long countCurrentMonthReports() {
+        Calendar cal = Calendar.getInstance();
+        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));
+    }
+}

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

@@ -128,6 +128,11 @@ export function getFamilyTasks(id, params) {
   return request({ url: `/api/admin/families/${id}/tasks`, method: 'post', data: params })
 }
 
+// 家庭运营工作台:全局聚合统计带数据源(POST /api/admin/families/stats,无请求体)
+export function getFamilyStats() {
+  return request({ url: '/api/admin/families/stats', method: 'post' })
+}
+
 export function setMemberAdminRole(familyId, memberId, isAdmin) {
   return request({
     url: `/api/admin/families/${familyId}/members/set-admin`,

+ 49 - 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,7 @@ export default {
     currentPath() {
       return this.$route.path
     },
-    // 统计带:基于当前页 tableData 合计(真实可读,按"本页"口径标注)
+    // 统计带兜底:基于当前页 tableData 合计(全局聚合接口不可用时降级使用,勿删)
     stats() {
       const sum = (key) => this.tableData.reduce((acc, r) => acc + (r[key] || 0), 0)
       return {
@@ -419,6 +419,26 @@ export default {
         tasks: sum('taskCount'),
         coupons: sum('couponCount')
       }
+    },
+    // 降级态:全局聚合接口尚未返回或失败时,统计带标注「(本页合计)」
+    statsDegraded() {
+      return this.globalStats == null
+    },
+    // 统计带展示值:优先取全局聚合接口(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('reportCount', '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 +446,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 +547,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 +647,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`)。请求:无请求体,`@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` 字面量路径不冲突 |
 
 #### 4.60.12 订单/退款人工处理(`AdminProductOrderController` `/api/admin/product/order`,SC-A-12)