boss 2 tygodni temu
rodzic
commit
a1dd68470f

+ 36 - 0
fs-admin/src/main/java/com/fs/admin/controller/AdminModuleConsumptionController.java

@@ -0,0 +1,36 @@
+package com.fs.admin.controller;
+
+import com.fs.billing.service.ModuleConsumptionService;
+import com.fs.billing.vo.ModuleConsumptionVo;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * Admin总后台-租户模块消费统计控制器
+ * 数据来源:tenant_wallet_txn(txn_type='CONSUME') 按 biz_type 聚合
+ */
+@RestController
+@RequestMapping({"/admin/module-consumption", "/admin/moduleConsumption"})
+public class AdminModuleConsumptionController extends BaseController {
+
+    @Autowired
+    private ModuleConsumptionService moduleConsumptionService;
+
+    /**
+     * 获取模块消费统计报告
+     * @param beginTime 开始时间 yyyy-MM-dd
+     * @param endTime   结束时间 yyyy-MM-dd
+     * @param tenantName 租户名称(可选,为空则全部)
+     */
+    @PreAuthorize("@ss.hasPermi('admin:moduleUsage:list')")
+    @GetMapping("/report")
+    public AjaxResult report(@RequestParam(required = false) String beginTime,
+                             @RequestParam(required = false) String endTime,
+                             @RequestParam(required = false) String tenantName) {
+        ModuleConsumptionVo vo = moduleConsumptionService.reportForAdmin(beginTime, endTime, tenantName);
+        return AjaxResult.success(vo);
+    }
+}

+ 37 - 0
fs-admin/src/main/java/com/fs/proxy/controller/ProxyModuleConsumptionController.java

@@ -0,0 +1,37 @@
+package com.fs.proxy.controller;
+
+import com.fs.billing.service.ModuleConsumptionService;
+import com.fs.billing.vo.ModuleConsumptionVo;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.SecurityUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * Agent代理端-租户模块消费统计控制器
+ * 代理登录后可查看其下所有租户的模块消费情况
+ */
+@RestController
+@RequestMapping("/proxy/module-consumption")
+public class ProxyModuleConsumptionController extends BaseController {
+
+    @Autowired
+    private ModuleConsumptionService moduleConsumptionService;
+
+    /**
+     * 获取归属代理下所有租户的模块消费统计报告
+     */
+    @PreAuthorize("@ss.hasPermi('proxy:moduleUsage:list')")
+    @GetMapping("/report")
+    public AjaxResult report(@RequestParam(required = false) String beginTime,
+                             @RequestParam(required = false) String endTime) {
+        Long proxyId = SecurityUtils.getLoginUser().getProxyId();
+        if (proxyId == null) {
+            return AjaxResult.error("当前用户无代理身份");
+        }
+        ModuleConsumptionVo vo = moduleConsumptionService.reportForProxy(proxyId, beginTime, endTime);
+        return AjaxResult.success(vo);
+    }
+}

+ 93 - 0
fs-company/src/main/java/com/fs/company/QueryMenus.java

@@ -0,0 +1,93 @@
+package com.fs.company;
+
+import java.sql.*;
+import java.io.*;
+
+public class QueryMenus {
+    public static void main(String[] args) {
+        String url = "jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas_tenant_1?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false";
+        String user = "root";
+        String password = "Ylrz_1q2w3e4r5t6y";
+        
+        String outFile = args.length > 0 ? args[0] : "d:/AICODE/saas/tenant_menus.txt";
+        PrintWriter pw = null;
+        try {
+            pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8"));
+        } catch (Exception e) {
+            // 如果无法写入指定路径,尝试写入当前目录
+            try {
+                outFile = "tenant_menus.txt";
+                pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8"));
+            } catch (Exception e2) {
+                System.err.println("无法创建输出文件: " + e2.getMessage());
+                return;
+            }
+        }
+        
+        try {
+            Class.forName("com.mysql.cj.jdbc.Driver");
+            pw.println("DEBUG: Driver loaded");
+            pw.flush();
+            
+            Connection conn = DriverManager.getConnection(url, user, password);
+            pw.println("DEBUG: Connected to database");
+            pw.flush();
+            
+            // 查询 company_menu 表
+            String sql = "SELECT menu_id, menu_name, parent_id, order_num, path, component, menu_type, visible, status FROM company_menu WHERE menu_type IN ('M','C') AND status = '0' ORDER BY parent_id, order_num";
+            
+            try {
+                Statement stmt = conn.createStatement();
+                ResultSet rs = stmt.executeQuery(sql);
+                
+                pw.println("menu_id|menu_name|parent_id|order_num|path|component|menu_type|visible|status");
+                pw.println("---");
+                while (rs.next()) {
+                    String line = rs.getLong("menu_id") + "|" +
+                        rs.getString("menu_name") + "|" +
+                        rs.getLong("parent_id") + "|" +
+                        rs.getInt("order_num") + "|" +
+                        rs.getString("path") + "|" +
+                        rs.getString("component") + "|" +
+                        rs.getString("menu_type") + "|" +
+                        rs.getString("visible") + "|" +
+                        rs.getString("status");
+                    pw.println(line);
+                }
+                rs.close();
+                stmt.close();
+            } catch (SQLException e) {
+                pw.println("company_menu查询失败: " + e.getMessage());
+                pw.flush();
+                // 尝试 sys_menu
+                pw.println("尝试 sys_menu 表...");
+                String sql2 = "SELECT menu_id, menu_name, parent_id, order_num, path, component, menu_type, visible, status FROM sys_menu WHERE menu_type IN ('M','C') AND status = '0' ORDER BY parent_id, order_num";
+                Statement stmt2 = conn.createStatement();
+                ResultSet rs2 = stmt2.executeQuery(sql2);
+                pw.println("menu_id|menu_name|parent_id|order_num|path|component|menu_type|visible|status");
+                pw.println("---");
+                while (rs2.next()) {
+                    String line = rs2.getLong("menu_id") + "|" +
+                        rs2.getString("menu_name") + "|" +
+                        rs2.getLong("parent_id") + "|" +
+                        rs2.getInt("order_num") + "|" +
+                        rs2.getString("path") + "|" +
+                        rs2.getString("component") + "|" +
+                        rs2.getString("menu_type") + "|" +
+                        rs2.getString("visible") + "|" +
+                        rs2.getString("status");
+                    pw.println(line);
+                }
+                rs2.close();
+                stmt2.close();
+            }
+            
+            conn.close();
+        } catch (Exception e) {
+            pw.println("ERROR: " + e.getMessage());
+            e.printStackTrace(pw);
+        }
+        
+        pw.close();
+    }
+}

+ 37 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyModuleConsumptionController.java.bak

@@ -0,0 +1,37 @@
+package com.fs.company.controller.company;
+
+import com.fs.billing.service.ModuleConsumptionService;
+import com.fs.billing.vo.ModuleConsumptionVo;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.SecurityUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 分公司端-模块消费统计控制器
+ * 分公司查看自身的模块消费情况
+ */
+@RestController
+@RequestMapping("/company/module-consumption")
+public class CompanyModuleConsumptionController extends BaseController {
+
+    @Autowired
+    private ModuleConsumptionService moduleConsumptionService;
+
+    /**
+     * 获取当前分公司的模块消费统计报告
+     */
+    @PreAuthorize("@ss.hasPermi('company:consumeRecord:my')")
+    @GetMapping("/report")
+    public AjaxResult report(@RequestParam(required = false) String beginTime,
+                             @RequestParam(required = false) String endTime) {
+        Long tenantId = SecurityUtils.getTenantId();
+        if (tenantId == null) {
+            return AjaxResult.error("无法获取租户信息");
+        }
+        ModuleConsumptionVo vo = moduleConsumptionService.reportForCompany(tenantId, beginTime, endTime);
+        return AjaxResult.success(vo);
+    }
+}

+ 71 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyModuleUsageController.java

@@ -0,0 +1,71 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.proxy.domain.TenantModuleUsage;
+import com.fs.proxy.service.TenantModuleUsageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 分公司端-模块使用统计控制器
+ * 分公司查看自身的模块使用情况
+ */
+@RestController
+@RequestMapping("/company/module-usage")
+public class CompanyModuleUsageController extends BaseController {
+
+    @Autowired
+    private TenantModuleUsageService tenantModuleUsageService;
+
+    /**
+     * 获取当前分公司的模块使用统计
+     */
+    @PreAuthorize("@ss.hasPermi('company:moduleUsage:my')")
+    @GetMapping("/my")
+    public AjaxResult my() {
+        Long tenantId = SecurityUtils.getTenantId();
+        if (tenantId == null) {
+            return AjaxResult.error("无法获取租户信息");
+        }
+        TenantModuleUsage query = new TenantModuleUsage();
+        query.setTenantId(tenantId);
+        List<TenantModuleUsage> list = tenantModuleUsageService.selectTenantModuleUsageList(query);
+        if (list.isEmpty()) {
+            // 返回空数据但结构完整
+            return AjaxResult.success(new TenantModuleUsage());
+        }
+        // 取最新一条
+        TenantModuleUsage usage = list.get(0);
+        // 组装扩展字段
+        Map<String, Object> result = new HashMap<>();
+        result.put("outboundCallCount", usage.getOutboundCallCount());
+        result.put("smsSentCount", usage.getSmsSentCount());
+        result.put("wxUserCount", usage.getWxUserCount());
+        result.put("wxAccountCount", usage.getWxAccountCount());
+        result.put("qwUserCount", usage.getQwUserCount());
+        result.put("qwAccountCount", usage.getQwAccountCount());
+        result.put("deptCount", usage.getDeptCount());
+        result.put("employeeCount", usage.getEmployeeCount());
+        result.put("voiceCallMinutes", usage.getVoiceCallMinutes());
+        result.put("flowUsageGB", usage.getFlowUsageGB());
+        result.put("aiTokenUsed", usage.getAiTokenUsed());
+        result.put("salesAccountCount", usage.getSalesAccountCount());
+        result.put("cidAddWxCount", usage.getCidAddWxCount());
+        result.put("cidAddQwCount", usage.getCidAddQwCount());
+        result.put("sopCount", usage.getSopCount());
+        result.put("courseCount", usage.getCourseCount());
+        result.put("liveCount", usage.getLiveCount());
+        result.put("productCount", usage.getProductCount());
+        result.put("workflowCount", usage.getWorkflowCount());
+        result.put("activeModuleCount", usage.getActiveModuleCount());
+        result.put("totalConsumeAmount", usage.getTotalConsumeAmount());
+        return AjaxResult.success(result);
+    }
+}

+ 25 - 0
fs-service/src/main/java/com/fs/billing/service/ModuleConsumptionService.java

@@ -0,0 +1,25 @@
+package com.fs.billing.service;
+
+import com.fs.billing.vo.ModuleConsumptionVo;
+
+/**
+ * 模块消费统计服务
+ * 从 tenant_wallet_txn(CONSUME) 按 biz_type 聚合消费金额
+ */
+public interface ModuleConsumptionService {
+
+    /**
+     * 总后台:全部租户的模块消费统计
+     */
+    ModuleConsumptionVo reportForAdmin(String beginTime, String endTime, String tenantName);
+
+    /**
+     * 代理端:归属代理下所有租户的模块消费统计
+     */
+    ModuleConsumptionVo reportForProxy(Long proxyId, String beginTime, String endTime);
+
+    /**
+     * 分公司端:指定租户的模块消费统计
+     */
+    ModuleConsumptionVo reportForCompany(Long tenantId, String beginTime, String endTime);
+}

+ 205 - 0
fs-service/src/main/java/com/fs/billing/service/impl/ModuleConsumptionServiceImpl.java

@@ -0,0 +1,205 @@
+package com.fs.billing.service.impl;
+
+import com.fs.billing.vo.ModuleConsumptionVo;
+import com.fs.billing.service.ModuleConsumptionService;
+import com.fs.company.domain.Company;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.proxy.domain.ProxyTenantRel;
+import com.fs.proxy.service.ProxyTenantRelService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 模块消费统计服务实现
+ * 从 tenant_wallet_txn(CONSUME) 按 biz_type 聚合消费金额
+ */
+@Service
+public class ModuleConsumptionServiceImpl implements ModuleConsumptionService {
+
+    private static final Logger log = LoggerFactory.getLogger(ModuleConsumptionServiceImpl.class);
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    @Resource
+    private CompanyMapper companyMapper;
+
+    @Resource
+    private ProxyTenantRelService proxyTenantRelService;
+
+    @Override
+    public ModuleConsumptionVo reportForAdmin(String beginTime, String endTime, String tenantName) {
+        List<Long> tenantIds = null;
+        if (tenantName != null && !tenantName.isEmpty()) {
+            Company query = new Company();
+            query.setCompanyName(tenantName);
+            List<Company> companies = companyMapper.selectCompanyList(query);
+            tenantIds = companies.stream().map(Company::getCompanyId).collect(Collectors.toList());
+            if (tenantIds.isEmpty()) {
+                return buildEmptyReport();
+            }
+        }
+        return buildReport(tenantIds, beginTime, endTime);
+    }
+
+    @Override
+    public ModuleConsumptionVo reportForProxy(Long proxyId, String beginTime, String endTime) {
+        List<ProxyTenantRel> rels = proxyTenantRelService.selectProxyTenantRelByProxyId(proxyId);
+        if (rels == null || rels.isEmpty()) {
+            return buildEmptyReport();
+        }
+        List<Long> tenantIds = rels.stream().map(ProxyTenantRel::getTenantId).distinct().collect(Collectors.toList());
+        return buildReport(tenantIds, beginTime, endTime);
+    }
+
+    @Override
+    public ModuleConsumptionVo reportForCompany(Long tenantId, String beginTime, String endTime) {
+        return buildReport(Collections.singletonList(tenantId), beginTime, endTime);
+    }
+
+    private ModuleConsumptionVo buildReport(List<Long> tenantIds, String beginTime, String endTime) {
+        ModuleConsumptionVo vo = new ModuleConsumptionVo();
+
+        // 1. 按 biz_type 聚合
+        String sql = "SELECT COALESCE(t.biz_type, 'OTHER') as biz_type, " +
+                "COUNT(*) as txn_count, " +
+                "SUM(COALESCE(ABS(t.amount), 0)) as total_amount " +
+                "FROM tenant_wallet_txn t " +
+                "WHERE t.txn_type = 'CONSUME' ";
+        List<Object> params = new ArrayList<>();
+
+        if (tenantIds != null && !tenantIds.isEmpty()) {
+            sql += "AND t.tenant_id IN (" + tenantIds.stream().map(id -> "?").collect(Collectors.joining(",")) + ") ";
+            params.addAll(tenantIds);
+        }
+        if (beginTime != null && !beginTime.isEmpty()) {
+            sql += "AND t.create_time >= ? ";
+            params.add(beginTime + " 00:00:00");
+        }
+        if (endTime != null && !endTime.isEmpty()) {
+            sql += "AND t.create_time <= ? ";
+            params.add(endTime + " 23:59:59");
+        }
+        sql += "GROUP BY COALESCE(t.biz_type, 'OTHER') ORDER BY total_amount DESC";
+
+        List<Map<String, Object>> moduleRows = jdbcTemplate.queryForList(sql, params.toArray());
+
+        // 构建模块明细
+        List<ModuleConsumptionVo.ModuleItem> moduleBreakdown = new ArrayList<>();
+        BigDecimal totalAmount = BigDecimal.ZERO;
+        long totalCount = 0;
+
+        for (Map<String, Object> row : moduleRows) {
+            String bizType = (String) row.get("biz_type");
+            BigDecimal amount = toBigDecimal(row.get("total_amount"));
+            Long count = toLong(row.get("txn_count"));
+
+            ModuleConsumptionVo.ModuleItem item = new ModuleConsumptionVo.ModuleItem();
+            item.setModuleCode(bizType);
+            item.setModuleName(ModuleConsumptionVo.MODULE_NAME_MAP.getOrDefault(bizType, bizType));
+            item.setTotalAmount(amount);
+            item.setCount(count);
+            moduleBreakdown.add(item);
+
+            totalAmount = totalAmount.add(amount);
+            totalCount += count;
+        }
+        vo.setModuleBreakdown(moduleBreakdown);
+
+        // 2. 按租户聚合
+        String tenantSql = "SELECT t.tenant_id, COALESCE(t.biz_type, 'OTHER') as biz_type, " +
+                "SUM(COALESCE(ABS(t.amount), 0)) as total_amount " +
+                "FROM tenant_wallet_txn t " +
+                "WHERE t.txn_type = 'CONSUME' ";
+        List<Object> tenantParams = new ArrayList<>();
+        if (tenantIds != null && !tenantIds.isEmpty()) {
+            tenantSql += "AND t.tenant_id IN (" + tenantIds.stream().map(id -> "?").collect(Collectors.joining(",")) + ") ";
+            tenantParams.addAll(tenantIds);
+        }
+        if (beginTime != null && !beginTime.isEmpty()) {
+            tenantSql += "AND t.create_time >= ? ";
+            tenantParams.add(beginTime + " 00:00:00");
+        }
+        if (endTime != null && !endTime.isEmpty()) {
+            tenantSql += "AND t.create_time <= ? ";
+            tenantParams.add(endTime + " 23:59:59");
+        }
+        tenantSql += "GROUP BY t.tenant_id, COALESCE(t.biz_type, 'OTHER') ORDER BY t.tenant_id";
+
+        List<Map<String, Object>> tenantRows = jdbcTemplate.queryForList(tenantSql, tenantParams.toArray());
+
+        // 加载租户名称
+        Set<Long> allTenantIds = tenantRows.stream()
+                .map(r -> toLong(r.get("tenant_id")))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Long, String> nameMap = new HashMap<>();
+        if (!allTenantIds.isEmpty()) {
+            List<Company> companies = companyMapper.selectCompanyByIds(new ArrayList<>(allTenantIds));
+            if (companies != null) {
+                for (Company c : companies) {
+                    nameMap.put(c.getCompanyId(), c.getCompanyName());
+                }
+            }
+        }
+
+        // 按租户ID分组
+        Map<Long, ModuleConsumptionVo.TenantItem> tenantMap = new LinkedHashMap<>();
+        for (Map<String, Object> row : tenantRows) {
+            Long tid = toLong(row.get("tenant_id"));
+            String bizType = (String) row.get("biz_type");
+            BigDecimal amount = toBigDecimal(row.get("total_amount"));
+
+            ModuleConsumptionVo.TenantItem ti = tenantMap.computeIfAbsent(tid, k -> {
+                ModuleConsumptionVo.TenantItem t = new ModuleConsumptionVo.TenantItem();
+                t.setTenantId(k);
+                t.setTenantName(nameMap.getOrDefault(k, "未知(" + k + ")"));
+                return t;
+            });
+            ti.getModules().put(bizType, amount);
+            ti.setTotalAmount(ti.getTotalAmount().add(amount));
+        }
+
+        // 按总消费金额降序排列
+        List<ModuleConsumptionVo.TenantItem> tenantBreakdown = new ArrayList<>(tenantMap.values());
+        tenantBreakdown.sort((a, b) -> b.getTotalAmount().compareTo(a.getTotalAmount()));
+        vo.setTenantBreakdown(tenantBreakdown);
+
+        // 3. 汇总
+        ModuleConsumptionVo.Summary summary = new ModuleConsumptionVo.Summary();
+        summary.setTotalAmount(totalAmount);
+        summary.setTotalCount(totalCount);
+        summary.setTenantCount((long) tenantMap.size());
+        summary.setModuleCount(moduleBreakdown.size());
+        vo.setSummary(summary);
+
+        return vo;
+    }
+
+    private ModuleConsumptionVo buildEmptyReport() {
+        ModuleConsumptionVo vo = new ModuleConsumptionVo();
+        vo.setSummary(new ModuleConsumptionVo.Summary());
+        vo.setModuleBreakdown(new ArrayList<>());
+        vo.setTenantBreakdown(new ArrayList<>());
+        return vo;
+    }
+
+    private BigDecimal toBigDecimal(Object v) {
+        if (v == null) return BigDecimal.ZERO;
+        if (v instanceof BigDecimal) return (BigDecimal) v;
+        return new BigDecimal(String.valueOf(v));
+    }
+
+    private Long toLong(Object v) {
+        if (v == null) return null;
+        if (v instanceof Number) return ((Number) v).longValue();
+        return Long.parseLong(String.valueOf(v));
+    }
+}

+ 95 - 0
fs-service/src/main/java/com/fs/billing/vo/ModuleConsumptionVo.java

@@ -0,0 +1,95 @@
+package com.fs.billing.vo;
+
+import java.math.BigDecimal;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 模块消费统计报告VO
+ * 按模块维度聚合 tenant_wallet_txn(CONSUME) 的消费金额
+ */
+public class ModuleConsumptionVo {
+
+    /** 汇总信息 */
+    private Summary summary;
+    /** 模块维度明细 */
+    private java.util.List<ModuleItem> moduleBreakdown;
+    /** 租户维度明细 */
+    private java.util.List<TenantItem> tenantBreakdown;
+
+    public Summary getSummary() { return summary; }
+    public void setSummary(Summary summary) { this.summary = summary; }
+    public java.util.List<ModuleItem> getModuleBreakdown() { return moduleBreakdown; }
+    public void setModuleBreakdown(java.util.List<ModuleItem> moduleBreakdown) { this.moduleBreakdown = moduleBreakdown; }
+    public java.util.List<TenantItem> getTenantBreakdown() { return tenantBreakdown; }
+    public void setTenantBreakdown(java.util.List<TenantItem> tenantBreakdown) { this.tenantBreakdown = tenantBreakdown; }
+
+    public static class Summary {
+        private BigDecimal totalAmount = BigDecimal.ZERO;
+        private Long totalCount = 0L;
+        private Long tenantCount = 0L;
+        private Integer moduleCount = 0;
+
+        public BigDecimal getTotalAmount() { return totalAmount; }
+        public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
+        public Long getTotalCount() { return totalCount; }
+        public void setTotalCount(Long totalCount) { this.totalCount = totalCount; }
+        public Long getTenantCount() { return tenantCount; }
+        public void setTenantCount(Long tenantCount) { this.tenantCount = tenantCount; }
+        public Integer getModuleCount() { return moduleCount; }
+        public void setModuleCount(Integer moduleCount) { this.moduleCount = moduleCount; }
+    }
+
+    public static class ModuleItem {
+        private String moduleCode;
+        private String moduleName;
+        private BigDecimal totalAmount = BigDecimal.ZERO;
+        private Long count = 0L;
+
+        public String getModuleCode() { return moduleCode; }
+        public void setModuleCode(String moduleCode) { this.moduleCode = moduleCode; }
+        public String getModuleName() { return moduleName; }
+        public void setModuleName(String moduleName) { this.moduleName = moduleName; }
+        public BigDecimal getTotalAmount() { return totalAmount; }
+        public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
+        public Long getCount() { return count; }
+        public void setCount(Long count) { this.count = count; }
+    }
+
+    public static class TenantItem {
+        private Long tenantId;
+        private String tenantName;
+        private BigDecimal totalAmount = BigDecimal.ZERO;
+        /** 模块->金额映射,保持插入顺序 */
+        private Map<String, BigDecimal> modules = new LinkedHashMap<>();
+
+        public Long getTenantId() { return tenantId; }
+        public void setTenantId(Long tenantId) { this.tenantId = tenantId; }
+        public String getTenantName() { return tenantName; }
+        public void setTenantName(String tenantName) { this.tenantName = tenantName; }
+        public BigDecimal getTotalAmount() { return totalAmount; }
+        public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
+        public Map<String, BigDecimal> getModules() { return modules; }
+        public void setModules(Map<String, BigDecimal> modules) { this.modules = modules; }
+    }
+
+    /** biz_type → 中文名称映射 */
+    public static final Map<String, String> MODULE_NAME_MAP = new LinkedHashMap<>();
+    static {
+        MODULE_NAME_MAP.put("CALL", "语音通话");
+        MODULE_NAME_MAP.put("SMS", "短信");
+        MODULE_NAME_MAP.put("FLOW", "流量");
+        MODULE_NAME_MAP.put("TOKEN_SOP", "SOP Token");
+        MODULE_NAME_MAP.put("TOKEN_AI_REPLY", "AI回复Token");
+        MODULE_NAME_MAP.put("ADD_WECHAT", "CID加个微");
+        MODULE_NAME_MAP.put("OPEN_ACCOUNT", "销售账户");
+        MODULE_NAME_MAP.put("QW_SERVICE", "企微服务");
+        MODULE_NAME_MAP.put("WX_SERVICE", "个微服务");
+        MODULE_NAME_MAP.put("AI_MODEL", "AI模型");
+        MODULE_NAME_MAP.put("LOBSTER", "龙虾引擎");
+        MODULE_NAME_MAP.put("LIVE", "直播");
+        MODULE_NAME_MAP.put("COURSE", "课程");
+        MODULE_NAME_MAP.put("PRODUCT", "商品");
+        MODULE_NAME_MAP.put("OTHER", "其他");
+    }
+}

+ 18 - 0
fs-service/src/main/java/com/fs/proxy/domain/TenantModuleUsage.java

@@ -121,6 +121,24 @@ public class TenantModuleUsage extends BaseEntity
     @Excel(name = "当月消费总金额")
     private BigDecimal totalConsumeAmount;
 
+    @Excel(name = "语音通话分钟数")
+    private Integer voiceCallMinutes;
+
+    @Excel(name = "流量使用量GB")
+    private BigDecimal flowUsageGB;
+
+    @Excel(name = "AI Token使用量")
+    private Long aiTokenUsed;
+
+    @Excel(name = "销售账户数")
+    private Integer salesAccountCount;
+
+    @Excel(name = "CID加个微数")
+    private Integer cidAddWxCount;
+
+    @Excel(name = "CID加企微数")
+    private Integer cidAddQwCount;
+
     /** 查询参数:代理ID(用于Admin总后台按代理筛选) */
     private Long queryProxyId;
 

+ 108 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/TenantModuleUsageServiceImpl.java

@@ -105,6 +105,12 @@ public class TenantModuleUsageServiceImpl implements TenantModuleUsageService {
         fillAdModule(usage, tenantId);
         fillAiModule(usage, tenantId);
         fillConsumeAmount(usage, tenantId, today);
+        fillVoiceCallMinutes(usage, tenantId, today);
+        fillFlowUsage(usage, tenantId, today);
+        fillAiToken(usage, tenantId, today);
+        fillSalesAccount(usage, tenantId);
+        fillCidAddWxCount(usage, tenantId, today);
+        fillCidAddQwCount(usage, tenantId, today);
         calculateActiveModuleCount(usage);
 
         if (existing != null) {
@@ -403,6 +409,103 @@ public class TenantModuleUsageServiceImpl implements TenantModuleUsageService {
         }
     }
 
+    /**
+     * 语音通话分钟数:当月语音通话总分钟数
+     */
+    private void fillVoiceCallMinutes(TenantModuleUsage usage, Long tenantId, String today) {
+        try {
+            String monthStart = today.substring(0, 8) + "01";
+            Long totalSeconds = jdbcTemplate.queryForObject(
+                    "SELECT COALESCE(SUM(call_time), 0) FROM company_voice_logs WHERE company_id = ? AND create_time >= ?",
+                    Long.class, tenantId, monthStart);
+            usage.setVoiceCallMinutes(totalSeconds != null ? (int)(totalSeconds / 60) : 0);
+        } catch (Exception e) {
+            logger.debug("[ModuleUsage] 语音通话分钟数统计失败: tenantId={}", tenantId);
+            usage.setVoiceCallMinutes(0);
+        }
+    }
+
+    /**
+     * 流量使用量(GB):当月课程/直播等产生的流量,从 usage_event 或 billing_detail 聚合
+     */
+    private void fillFlowUsage(TenantModuleUsage usage, Long tenantId, String today) {
+        try {
+            String monthStart = today.substring(0, 8) + "01";
+            BigDecimal flow = jdbcTemplate.queryForObject(
+                    "SELECT COALESCE(SUM(usage_value), 0) FROM usage_event WHERE tenant_id = ? AND event_type = 'FLOW' AND occurred_at >= ?",
+                    BigDecimal.class, tenantId, monthStart);
+            usage.setFlowUsageGB(flow != null ? flow : BigDecimal.ZERO);
+        } catch (Exception e) {
+            logger.debug("[ModuleUsage] 流量使用量统计失败: tenantId={}", tenantId);
+            usage.setFlowUsageGB(BigDecimal.ZERO);
+        }
+    }
+
+    /**
+     * AI Token使用量:当月SOP Token和AI回复Token总量
+     */
+    private void fillAiToken(TenantModuleUsage usage, Long tenantId, String today) {
+        try {
+            String monthStart = today.substring(0, 8) + "01";
+            Long tokens = jdbcTemplate.queryForObject(
+                    "SELECT COALESCE(SUM(usage_value), 0) FROM usage_event WHERE tenant_id = ? AND event_type IN ('TOKEN_SOP', 'TOKEN_AI_REPLY') AND occurred_at >= ?",
+                    Long.class, tenantId, monthStart);
+            usage.setAiTokenUsed(tokens != null ? tokens : 0L);
+        } catch (Exception e) {
+            logger.debug("[ModuleUsage] AI Token统计失败: tenantId={}", tenantId);
+            usage.setAiTokenUsed(0L);
+        }
+    }
+
+    /**
+     * 销售账户数:公司下开通的销售人员(非管理员、非系统用户)
+     */
+    private void fillSalesAccount(TenantModuleUsage usage, Long tenantId) {
+        try {
+            Integer salesCount = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM company_user WHERE company_id = ? AND (is_del = 0 OR is_del IS NULL) AND user_type != '00'",
+                    Integer.class, tenantId);
+            usage.setSalesAccountCount(salesCount != null ? salesCount : 0);
+        } catch (Exception e) {
+            logger.debug("[ModuleUsage] 销售账户数统计失败: tenantId={}", tenantId);
+            usage.setSalesAccountCount(0);
+        }
+    }
+
+    /**
+     * CID加个微数:当月通过CID语音机器人任务添加的个人微信数
+     */
+    private void fillCidAddWxCount(TenantModuleUsage usage, Long tenantId, String today) {
+        try {
+            String monthStart = today.substring(0, 8) + "01";
+            Integer count = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM company_voice_robotic_call_log_addwx l " +
+                    "INNER JOIN company_voice_robotic r ON l.robotic_id = r.id " +
+                    "WHERE r.company_id = ? AND l.create_time >= ? AND l.status = 2",
+                    Integer.class, tenantId, monthStart);
+            usage.setCidAddWxCount(count != null ? count : 0);
+        } catch (Exception e) {
+            logger.debug("[ModuleUsage] CID加个微数统计失败: tenantId={}", tenantId);
+            usage.setCidAddWxCount(0);
+        }
+    }
+
+    /**
+     * CID加企微数:当月通过企微外部联系人添加数
+     */
+    private void fillCidAddQwCount(TenantModuleUsage usage, Long tenantId, String today) {
+        try {
+            String monthStart = today.substring(0, 8) + "01";
+            Integer count = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM qw_external_contact WHERE company_id = ? AND add_time >= ?",
+                    Integer.class, tenantId, monthStart);
+            usage.setCidAddQwCount(count != null ? count : 0);
+        } catch (Exception e) {
+            logger.debug("[ModuleUsage] CID加企微数统计失败: tenantId={}", tenantId);
+            usage.setCidAddQwCount(0);
+        }
+    }
+
     /**
      * 计算活跃模块数量
      * 规则:某个指标>0则计为活跃
@@ -421,6 +524,11 @@ public class TenantModuleUsageServiceImpl implements TenantModuleUsageService {
         if (usage.getHasAdPlatform() != null && usage.getHasAdPlatform() == 1) count++;
         if (usage.getWorkflowCount() != null && usage.getWorkflowCount() > 0) count++;
         if (usage.getLobsterTemplateCount() != null && usage.getLobsterTemplateCount() > 0) count++;
+        if (usage.getVoiceCallMinutes() != null && usage.getVoiceCallMinutes() > 0) count++;
+        if (usage.getFlowUsageGB() != null && usage.getFlowUsageGB().compareTo(BigDecimal.ZERO) > 0) count++;
+        if (usage.getAiTokenUsed() != null && usage.getAiTokenUsed() > 0) count++;
+        if (usage.getCidAddWxCount() != null && usage.getCidAddWxCount() > 0) count++;
+        if (usage.getCidAddQwCount() != null && usage.getCidAddQwCount() > 0) count++;
         usage.setActiveModuleCount(count);
     }
 }

+ 18 - 0
fs-service/src/main/resources/mapper/proxy/TenantModuleUsageMapper.xml

@@ -34,6 +34,12 @@
         <result property="wxLobsterBindCount"    column="wx_lobster_bind_count"/>
         <result property="activeModuleCount"     column="active_module_count"/>
         <result property="totalConsumeAmount"    column="total_consume_amount"/>
+        <result property="voiceCallMinutes"     column="voice_call_minutes"/>
+        <result property="flowUsageGB"          column="flow_usage_gb"/>
+        <result property="aiTokenUsed"          column="ai_token_used"/>
+        <result property="salesAccountCount"    column="sales_account_count"/>
+        <result property="cidAddWxCount"        column="cid_add_wx_count"/>
+        <result property="cidAddQwCount"        column="cid_add_qw_count"/>
         <result property="createTime"            column="create_time"/>
         <result property="updateTime"            column="update_time"/>
         <result property="createBy"              column="create_by"/>
@@ -53,6 +59,8 @@
                u.workflow_count, u.lobster_template_count, u.lobster_task_count,
                u.qw_lobster_bind_count, u.wx_lobster_bind_count,
                u.active_module_count, u.total_consume_amount,
+               u.voice_call_minutes, u.flow_usage_gb, u.ai_token_used,
+               u.sales_account_count, u.cid_add_wx_count, u.cid_add_qw_count,
                u.create_time, u.update_time, u.create_by, u.update_by, u.remark
         from tenant_module_usage u
     </sql>
@@ -95,6 +103,8 @@
             workflow_count, lobster_template_count, lobster_task_count,
             qw_lobster_bind_count, wx_lobster_bind_count,
             active_module_count, total_consume_amount,
+            voice_call_minutes, flow_usage_gb, ai_token_used,
+            sales_account_count, cid_add_wx_count, cid_add_qw_count,
             create_by, remark
         ) values (
             #{tenantId}, #{tenantName}, #{proxyId}, #{proxyName}, #{statDate},
@@ -108,6 +118,8 @@
             #{workflowCount}, #{lobsterTemplateCount}, #{lobsterTaskCount},
             #{qwLobsterBindCount}, #{wxLobsterBindCount},
             #{activeModuleCount}, #{totalConsumeAmount},
+            #{voiceCallMinutes}, #{flowUsageGB}, #{aiTokenUsed},
+            #{salesAccountCount}, #{cidAddWxCount}, #{cidAddQwCount},
             #{createBy}, #{remark}
         )
     </insert>
@@ -143,6 +155,12 @@
             <if test="wxLobsterBindCount != null">wx_lobster_bind_count = #{wxLobsterBindCount},</if>
             <if test="activeModuleCount != null">active_module_count = #{activeModuleCount},</if>
             <if test="totalConsumeAmount != null">total_consume_amount = #{totalConsumeAmount},</if>
+            <if test="voiceCallMinutes != null">voice_call_minutes = #{voiceCallMinutes},</if>
+            <if test="flowUsageGB != null">flow_usage_gb = #{flowUsageGB},</if>
+            <if test="aiTokenUsed != null">ai_token_used = #{aiTokenUsed},</if>
+            <if test="salesAccountCount != null">sales_account_count = #{salesAccountCount},</if>
+            <if test="cidAddWxCount != null">cid_add_wx_count = #{cidAddWxCount},</if>
+            <if test="cidAddQwCount != null">cid_add_qw_count = #{cidAddQwCount},</if>
             <if test="updateBy != null">update_by = #{updateBy},</if>
             <if test="remark != null">remark = #{remark},</if>
         </set>

+ 8 - 0
sql/extend_tenant_module_usage.sql

@@ -0,0 +1,8 @@
+-- 扩展 tenant_module_usage 表,增加消费统计相关维度字段
+ALTER TABLE tenant_module_usage
+    ADD COLUMN voice_call_minutes INT DEFAULT 0 COMMENT '语音通话分钟数(当月)',
+    ADD COLUMN flow_usage_gb DECIMAL(12,4) DEFAULT 0 COMMENT '流量使用量GB(当月)',
+    ADD COLUMN ai_token_used BIGINT DEFAULT 0 COMMENT 'AI Token使用量(当月)',
+    ADD COLUMN sales_account_count INT DEFAULT 0 COMMENT '销售账户数',
+    ADD COLUMN cid_add_wx_count INT DEFAULT 0 COMMENT 'CID加个微数(当月)',
+    ADD COLUMN cid_add_qw_count INT DEFAULT 0 COMMENT 'CID加企微数(当月)';