云联一号 2 هفته پیش
والد
کامیت
18a0e5340d

+ 49 - 0
fs-admin/src/main/java/com/fs/admin/controller/AdminCompanyBridgeController.java

@@ -9,6 +9,8 @@ import com.fs.company.domain.*;
 import com.fs.company.param.*;
 import com.fs.company.service.*;
 import com.fs.company.vo.*;
+import com.fs.proxy.domain.TenantTrafficPricing;
+import com.fs.proxy.service.ITenantTrafficPricingService;
 import com.fs.qw.service.IQwIpadServerService;
 import com.fs.qw.domain.QwIpadServer;
 import com.fs.qw.vo.IpadTenantStatsVO;
@@ -36,6 +38,8 @@ public class AdminCompanyBridgeController extends BaseController {
     @Autowired(required = false)
     private ICompanyVoiceApiTenantService companyVoiceApiTenantService;
     @Autowired(required = false)
+    private ITenantTrafficPricingService tenantTrafficPricingService;
+    @Autowired(required = false)
     private ICompanyVoicePackageOrderService companyVoicePackageOrderService;
     @Autowired(required = false)
     private ICompanySmsService companySmsService;
@@ -172,6 +176,51 @@ public class AdminCompanyBridgeController extends BaseController {
         return toAjax(companyVoiceApiTenantService.updateCompanyVoiceApiTenant(data));
     }
 
+    // ========== [Admin] 租户流量定价管理 /admin/traffic-pricing ==========
+
+    /** 分页查询流量定价列表 */
+    @GetMapping("/admin/traffic-pricing/list")
+    public TableDataInfo trafficPricingList(TenantTrafficPricing param) {
+        startPage();
+        List<TenantTrafficPricing> list = tenantTrafficPricingService != null ?
+            tenantTrafficPricingService.selectList(param) : new ArrayList<>();
+        return getDataTable(list);
+    }
+
+    /** 查询流量定价详情 */
+    @GetMapping("/admin/traffic-pricing/{id}")
+    public AjaxResult trafficPricingGet(@PathVariable Long id) {
+        if (tenantTrafficPricingService == null) return AjaxResult.error("服务未就绪");
+        return AjaxResult.success(tenantTrafficPricingService.selectById(id));
+    }
+
+    /** 新增流量定价 */
+    @PreAuthorize("@ss.hasPermi('platform:trafficPricing:add')")
+    @Log(title = "新增流量定价", businessType = BusinessType.INSERT)
+    @PostMapping("/admin/traffic-pricing")
+    public AjaxResult trafficPricingAdd(@RequestBody TenantTrafficPricing data) {
+        if (tenantTrafficPricingService == null) return AjaxResult.error("服务未就绪");
+        return toAjax(tenantTrafficPricingService.insert(data));
+    }
+
+    /** 更新流量定价 */
+    @PreAuthorize("@ss.hasPermi('platform:trafficPricing:edit')")
+    @Log(title = "更新流量定价", businessType = BusinessType.UPDATE)
+    @PutMapping("/admin/traffic-pricing")
+    public AjaxResult trafficPricingUpdate(@RequestBody TenantTrafficPricing data) {
+        if (tenantTrafficPricingService == null) return AjaxResult.error("服务未就绪");
+        return toAjax(tenantTrafficPricingService.update(data));
+    }
+
+    /** 删除流量定价 */
+    @PreAuthorize("@ss.hasPermi('platform:trafficPricing:remove')")
+    @Log(title = "删除流量定价", businessType = BusinessType.DELETE)
+    @DeleteMapping("/admin/traffic-pricing/{id}")
+    public AjaxResult trafficPricingDelete(@PathVariable Long id) {
+        if (tenantTrafficPricingService == null) return AjaxResult.error("服务未就绪");
+        return toAjax(tenantTrafficPricingService.deleteById(id));
+    }
+
     // ========== 坐席管理 /company/companyVoiceCaller ==========
     @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:list')")
     @GetMapping("/company/companyVoiceCaller/list")

+ 78 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceApiTenant.java

@@ -3,6 +3,7 @@ package com.fs.company.domain;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.core.domain.BaseEntity;
 
+import java.math.BigDecimal;
 import java.util.Date;
 
 /**
@@ -24,6 +25,18 @@ public class CompanyVoiceApiTenant extends BaseEntity
     /** 租户ID */
     private Long companyId;
 
+    /** 售价(元/分钟) */
+    private BigDecimal price;
+
+    /** 优先级 */
+    private Integer priority;
+
+    /** 是否主线路 1是 0否 */
+    private Integer isPrimary;
+
+    /** 是否允许手动选择 1允许 0禁止 */
+    private Integer allowManual;
+
     /** 状态 1启用 0禁用 */
     private Integer status;
 
@@ -37,6 +50,15 @@ public class CompanyVoiceApiTenant extends BaseEntity
     /** 接口名(非表字段,关联查询用) */
     private String apiName;
 
+    /** 成本价(非表字段,关联查询用) */
+    private BigDecimal costPrice;
+
+    /** 接口类型(非表字段,关联查询用) */
+    private Integer apiType;
+
+    /** 服务商(非表字段,关联查询用) */
+    private String provider;
+
     public Long getId() {
         return id;
     }
@@ -94,4 +116,60 @@ public class CompanyVoiceApiTenant extends BaseEntity
     public void setApiName(String apiName) {
         this.apiName = apiName;
     }
+
+    public BigDecimal getPrice() {
+        return price;
+    }
+
+    public void setPrice(BigDecimal price) {
+        this.price = price;
+    }
+
+    public Integer getPriority() {
+        return priority;
+    }
+
+    public void setPriority(Integer priority) {
+        this.priority = priority;
+    }
+
+    public Integer getIsPrimary() {
+        return isPrimary;
+    }
+
+    public void setIsPrimary(Integer isPrimary) {
+        this.isPrimary = isPrimary;
+    }
+
+    public Integer getAllowManual() {
+        return allowManual;
+    }
+
+    public void setAllowManual(Integer allowManual) {
+        this.allowManual = allowManual;
+    }
+
+    public BigDecimal getCostPrice() {
+        return costPrice;
+    }
+
+    public void setCostPrice(BigDecimal costPrice) {
+        this.costPrice = costPrice;
+    }
+
+    public Integer getApiType() {
+        return apiType;
+    }
+
+    public void setApiType(Integer apiType) {
+        this.apiType = apiType;
+    }
+
+    public String getProvider() {
+        return provider;
+    }
+
+    public void setProvider(String provider) {
+        this.provider = provider;
+    }
 }

+ 54 - 0
fs-service/src/main/java/com/fs/proxy/domain/TenantTrafficPricing.java

@@ -0,0 +1,54 @@
+package com.fs.proxy.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 租户流量定价对象 tenant_traffic_pricing
+ * 覆盖全局 service_fee_config,实现按租户独立定价
+ *
+ * @author fs
+ * @date 2026-05-24
+ */
+@Data
+public class TenantTrafficPricing extends BaseEntity
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    private Long id;
+
+    /** 租户ID */
+    private Long tenantId;
+
+    /** 服务类型: 2=课程流量 3=直播流量 */
+    private Integer serviceType;
+
+    /** 租户售价(元/GB) */
+    private BigDecimal price;
+
+    /** 平台成本价(可覆盖全局) */
+    private BigDecimal costPrice;
+
+    /** 状态: 0禁用 1启用 */
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+
+    // ========== 关联查询字段 ==========
+
+    /** 租户名 */
+    private String tenantName;
+
+    /** 服务类型名称 */
+    private String serviceTypeName;
+
+    /** 全局售价(参考) */
+    private BigDecimal globalPrice;
+
+    /** 全局成本价(参考) */
+    private BigDecimal globalCost;
+}

+ 46 - 0
fs-service/src/main/java/com/fs/proxy/mapper/TenantTrafficPricingMapper.java

@@ -0,0 +1,46 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.TenantTrafficPricing;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 租户流量定价 Mapper 接口
+ *
+ * @author fs
+ * @date 2026-05-24
+ */
+public interface TenantTrafficPricingMapper
+{
+    /**
+     * 按租户ID+服务类型查询定价
+     */
+    TenantTrafficPricing selectByTenantAndType(@Param("tenantId") Long tenantId,
+                                                @Param("serviceType") Integer serviceType);
+
+    /**
+     * 分页查询列表(联查租户名+全局参考价)
+     */
+    List<TenantTrafficPricing> selectList(TenantTrafficPricing param);
+
+    /**
+     * 按ID查询
+     */
+    TenantTrafficPricing selectById(Long id);
+
+    /**
+     * 新增
+     */
+    int insert(TenantTrafficPricing record);
+
+    /**
+     * 更新
+     */
+    int update(TenantTrafficPricing record);
+
+    /**
+     * 删除
+     */
+    int deleteById(Long id);
+}

+ 44 - 0
fs-service/src/main/java/com/fs/proxy/service/ITenantTrafficPricingService.java

@@ -0,0 +1,44 @@
+package com.fs.proxy.service;
+
+import com.fs.proxy.domain.TenantTrafficPricing;
+
+import java.util.List;
+
+/**
+ * 租户流量定价 Service 接口
+ *
+ * @author fs
+ * @date 2026-05-24
+ */
+public interface ITenantTrafficPricingService
+{
+    /**
+     * 按租户ID+服务类型查询启用定价
+     */
+    TenantTrafficPricing selectByTenantAndType(Long tenantId, Integer serviceType);
+
+    /**
+     * 分页查询列表
+     */
+    List<TenantTrafficPricing> selectList(TenantTrafficPricing param);
+
+    /**
+     * 按ID查询
+     */
+    TenantTrafficPricing selectById(Long id);
+
+    /**
+     * 新增
+     */
+    int insert(TenantTrafficPricing record);
+
+    /**
+     * 更新
+     */
+    int update(TenantTrafficPricing record);
+
+    /**
+     * 删除
+     */
+    int deleteById(Long id);
+}

+ 56 - 1
fs-service/src/main/java/com/fs/proxy/service/impl/BalanceServiceImpl.java

@@ -8,7 +8,11 @@ import com.fs.proxy.enums.ConsumeTypeEnum;
 import com.fs.proxy.mapper.TenantBalanceMapper;
 import com.fs.proxy.mapper.TenantConsumeRecordMapper;
 import com.fs.proxy.mapper.ServiceFeeConfigMapper;
+import com.fs.proxy.domain.TenantTrafficPricing;
+import com.fs.proxy.mapper.TenantTrafficPricingMapper;
 import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
+import com.fs.company.domain.CompanyVoiceApiTenant;
+import com.fs.company.mapper.CompanyVoiceApiTenantMapper;
 import com.fs.proxy.service.BalanceService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -41,6 +45,12 @@ public class BalanceServiceImpl implements BalanceService {
     @Autowired
     private CompanySmsApiTenantMapper smsApiTenantMapper;
 
+    @Autowired
+    private TenantTrafficPricingMapper trafficPricingMapper;
+
+    @Autowired
+    private CompanyVoiceApiTenantMapper voiceApiTenantMapper;
+
     @Override
     public TenantBalance getTenantBalance(Long tenantId) {
         return balanceMapper.selectBalanceByTenantId(tenantId);
@@ -153,6 +163,11 @@ public class BalanceServiceImpl implements BalanceService {
             return false;
         }
 
+        // 手拨外呼不再单独计费,由通话接口定价体系(company_voice_api_tenant)覆盖
+        if (consumeType == ConsumeTypeEnum.MANUAL_CALL) {
+            return true;
+        }
+
         ServiceFeeConfig config = getFeeConfig(consumeType.getCode());
         if (config == null) {
             return false;
@@ -174,6 +189,47 @@ public class BalanceServiceImpl implements BalanceService {
                 }
             }
         }
+
+        // 通用租户定价覆盖:优先查 tenant_traffic_pricing,未配置则回退全局 service_fee_config
+        // 注意:SMS_SEND 使用专属 company_sms_api_tenant,AI_CALL 使用复合定价
+        if (consumeType != ConsumeTypeEnum.SMS_SEND && consumeType != ConsumeTypeEnum.AI_CALL) {
+            TenantTrafficPricing trafficPricing = trafficPricingMapper
+                .selectByTenantAndType(tenantId, consumeType.getCode());
+            if (trafficPricing != null && trafficPricing.getPrice() != null
+                && trafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                unitPrice = trafficPricing.getPrice();
+                if (trafficPricing.getCostPrice() != null) {
+                    platformCost = trafficPricing.getCostPrice();
+                }
+            }
+        }
+
+        // AI外呼:复合定价 = 语音单价(租户绑定) + AI附加费(租户 > 全局 service_fee_config)
+        if (consumeType == ConsumeTypeEnum.AI_CALL) {
+            List<CompanyVoiceApiTenant> voiceBindings = voiceApiTenantMapper.selectApisByCompanyId(tenantId);
+            if (voiceBindings != null && !voiceBindings.isEmpty()) {
+                CompanyVoiceApiTenant voiceBinding = voiceBindings.get(0);
+                if (voiceBinding.getPrice() != null && voiceBinding.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                    BigDecimal voicePrice = voiceBinding.getPrice();
+                    BigDecimal voiceCost = voiceBinding.getCostPrice() != null ? voiceBinding.getCostPrice() : BigDecimal.ZERO;
+                    // AI附加费:优先查租户定价,未配置则使用全局 service_fee_config
+                    BigDecimal aiSurcharge = config.getFeeStandard();
+                    BigDecimal aiCost = config.getPlatformCost() != null ? config.getPlatformCost() : BigDecimal.ZERO;
+                    TenantTrafficPricing aiTrafficPricing = trafficPricingMapper
+                        .selectByTenantAndType(tenantId, ConsumeTypeEnum.AI_CALL.getCode());
+                    if (aiTrafficPricing != null && aiTrafficPricing.getPrice() != null
+                        && aiTrafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                        aiSurcharge = aiTrafficPricing.getPrice();
+                        if (aiTrafficPricing.getCostPrice() != null) {
+                            aiCost = aiTrafficPricing.getCostPrice();
+                        }
+                    }
+                    unitPrice = voicePrice.add(aiSurcharge);
+                    platformCost = voiceCost.add(aiCost);
+                }
+            }
+        }
+
         BigDecimal totalCost = unitPrice.multiply(BigDecimal.valueOf(quantity));
 
         if (isPayAsYouGo(consumeType)) {
@@ -247,7 +303,6 @@ public class BalanceServiceImpl implements BalanceService {
             case LIVE_FLOW:
             case AI_TOKEN:
             case SMS_SEND:
-            case MANUAL_CALL:
             case AI_CALL:
             case WECHAT_HELPER:
                 return true;

+ 58 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/TenantTrafficPricingServiceImpl.java

@@ -0,0 +1,58 @@
+package com.fs.proxy.service.impl;
+
+import com.fs.proxy.domain.TenantTrafficPricing;
+import com.fs.proxy.mapper.TenantTrafficPricingMapper;
+import com.fs.proxy.service.ITenantTrafficPricingService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 租户流量定价 Service 实现
+ *
+ * @author fs
+ * @date 2026-05-24
+ */
+@Service
+public class TenantTrafficPricingServiceImpl implements ITenantTrafficPricingService
+{
+    @Autowired
+    private TenantTrafficPricingMapper trafficPricingMapper;
+
+    @Override
+    public TenantTrafficPricing selectByTenantAndType(Long tenantId, Integer serviceType)
+    {
+        return trafficPricingMapper.selectByTenantAndType(tenantId, serviceType);
+    }
+
+    @Override
+    public List<TenantTrafficPricing> selectList(TenantTrafficPricing param)
+    {
+        return trafficPricingMapper.selectList(param);
+    }
+
+    @Override
+    public TenantTrafficPricing selectById(Long id)
+    {
+        return trafficPricingMapper.selectById(id);
+    }
+
+    @Override
+    public int insert(TenantTrafficPricing record)
+    {
+        return trafficPricingMapper.insert(record);
+    }
+
+    @Override
+    public int update(TenantTrafficPricing record)
+    {
+        return trafficPricingMapper.update(record);
+    }
+
+    @Override
+    public int deleteById(Long id)
+    {
+        return trafficPricingMapper.deleteById(id);
+    }
+}

+ 44 - 20
fs-service/src/main/resources/mapper/company/CompanyVoiceApiTenantMapper.xml

@@ -5,17 +5,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 <mapper namespace="com.fs.company.mapper.CompanyVoiceApiTenantMapper">
 
     <resultMap type="CompanyVoiceApiTenant" id="CompanyVoiceApiTenantResult">
-        <result property="id"         column="id"          />
-        <result property="apiId"      column="api_id"      />
-        <result property="companyId"  column="company_id"  />
-        <result property="status"     column="status"      />
-        <result property="createTime" column="create_time" />
-        <result property="companyName" column="company_name" />
-        <result property="apiName"    column="api_name"    />
+        <result property="id"          column="id"          />
+        <result property="apiId"       column="api_id"      />
+        <result property="companyId"   column="company_id"  />
+        <result property="price"       column="price"       />
+        <result property="priority"    column="priority"    />
+        <result property="isPrimary"   column="is_primary"  />
+        <result property="allowManual" column="allow_manual"/>
+        <result property="status"      column="status"      />
+        <result property="createTime"  column="create_time" />
+        <result property="companyName" column="company_name"/>
+        <result property="apiName"     column="api_name"    />
+        <result property="costPrice"   column="cost_price"  />
+        <result property="apiType"     column="api_type"    />
+        <result property="provider"    column="provider"    />
     </resultMap>
 
     <sql id="selectCompanyVoiceApiTenantVo">
-        select t.id, t.api_id, t.company_id, t.status, t.create_time
+        select t.id, t.api_id, t.company_id, t.price, t.priority, t.is_primary, t.allow_manual, t.status, t.create_time
         from company_voice_api_tenant t
     </sql>
 
@@ -29,34 +36,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where t.api_id = #{apiId} and t.company_id = #{companyId}
     </select>
 
-    <!-- 查询接口已分配的租户列表(含租户名) -->
+    <!-- 查询接口已分配的租户列表(含租户名、定价信息) -->
     <select id="selectTenantsByApiId" resultMap="CompanyVoiceApiTenantResult">
-        select t.id, t.api_id, t.company_id, t.status, t.create_time,
-               c.company_name
+        select t.id, t.api_id, t.company_id, t.price, t.priority, t.is_primary, t.allow_manual, t.status, t.create_time,
+               c.company_name, a.cost_price, a.provider, a.api_type
         from company_voice_api_tenant t
         left join company c on c.company_id = t.company_id
+        left join company_voice_api a on a.api_id = t.api_id
         where t.api_id = #{apiId}
-        order by t.id desc
+        order by t.priority asc, t.id desc
     </select>
 
-    <!-- 查询租户已分配的接口列表(含接口名) -->
+    <!-- 查询租户已分配的接口列表(含接口名、成本价、服务商) -->
     <select id="selectApisByCompanyId" resultMap="CompanyVoiceApiTenantResult">
-        select t.id, t.api_id, t.company_id, t.status, t.create_time,
-               a.api_name
+        select t.id, t.api_id, t.company_id, t.price, t.priority, t.is_primary, t.allow_manual, t.status, t.create_time,
+               a.api_name, a.cost_price, a.provider, a.api_type
         from company_voice_api_tenant t
         left join company_voice_api a on a.api_id = t.api_id
         where t.company_id = #{companyId}
-        order by t.id desc
+        order by t.priority asc, t.id desc
     </select>
 
     <select id="selectCompanyVoiceApiTenantList" resultMap="CompanyVoiceApiTenantResult">
-        <include refid="selectCompanyVoiceApiTenantVo"/>
+        select t.id, t.api_id, t.company_id, t.price, t.priority, t.is_primary, t.allow_manual, t.status, t.create_time,
+               c.company_name, a.api_name, a.cost_price, a.provider, a.api_type
+        from company_voice_api_tenant t
+        left join company c on c.company_id = t.company_id
+        left join company_voice_api a on a.api_id = t.api_id
         <where>
             <if test="apiId != null"> and t.api_id = #{apiId}</if>
             <if test="companyId != null"> and t.company_id = #{companyId}</if>
             <if test="status != null"> and t.status = #{status}</if>
         </where>
-        order by t.id desc
+        order by t.priority asc, t.id desc
     </select>
 
     <insert id="insertCompanyVoiceApiTenant" useGeneratedKeys="true" keyProperty="id">
@@ -64,22 +76,30 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="apiId != null">api_id,</if>
             <if test="companyId != null">company_id,</if>
+            <if test="price != null">price,</if>
+            <if test="priority != null">priority,</if>
+            <if test="isPrimary != null">is_primary,</if>
+            <if test="allowManual != null">allow_manual,</if>
             <if test="status != null">status,</if>
             <if test="createTime != null">create_time,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="apiId != null">#{apiId},</if>
             <if test="companyId != null">#{companyId},</if>
+            <if test="price != null">#{price},</if>
+            <if test="priority != null">#{priority},</if>
+            <if test="isPrimary != null">#{isPrimary},</if>
+            <if test="allowManual != null">#{allowManual},</if>
             <if test="status != null">#{status},</if>
             <if test="createTime != null">#{createTime},</if>
         </trim>
     </insert>
 
     <insert id="batchInsertCompanyVoiceApiTenant">
-        insert into company_voice_api_tenant (api_id, company_id, status, create_time)
+        insert into company_voice_api_tenant (api_id, company_id, price, priority, is_primary, allow_manual, status, create_time)
         values
         <foreach item="item" collection="list" separator=",">
-            (#{item.apiId}, #{item.companyId}, #{item.status}, NOW())
+            (#{item.apiId}, #{item.companyId}, #{item.price}, #{item.priority}, #{item.isPrimary}, #{item.allowManual}, #{item.status}, NOW())
         </foreach>
     </insert>
 
@@ -88,6 +108,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <trim prefix="SET" suffixOverrides=",">
             <if test="apiId != null">api_id = #{apiId},</if>
             <if test="companyId != null">company_id = #{companyId},</if>
+            <if test="price != null">price = #{price},</if>
+            <if test="priority != null">priority = #{priority},</if>
+            <if test="isPrimary != null">is_primary = #{isPrimary},</if>
+            <if test="allowManual != null">allow_manual = #{allowManual},</if>
             <if test="status != null">status = #{status},</if>
         </trim>
         where id = #{id}

+ 109 - 0
fs-service/src/main/resources/mapper/proxy/TenantTrafficPricingMapper.xml

@@ -0,0 +1,109 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.proxy.mapper.TenantTrafficPricingMapper">
+
+    <resultMap type="TenantTrafficPricing" id="TenantTrafficPricingResult">
+        <result property="id"              column="id"             />
+        <result property="tenantId"        column="tenant_id"      />
+        <result property="serviceType"     column="service_type"   />
+        <result property="price"           column="price"          />
+        <result property="costPrice"       column="cost_price"     />
+        <result property="status"          column="status"         />
+        <result property="remark"          column="remark"         />
+        <result property="createTime"      column="create_time"    />
+        <result property="updateTime"      column="update_time"    />
+        <result property="tenantName"      column="tenant_name"    />
+        <result property="serviceTypeName" column="service_type_name"/>
+        <result property="globalPrice"     column="global_price"   />
+        <result property="globalCost"      column="global_cost"    />
+    </resultMap>
+
+    <sql id="selectVo">
+        select t.id, t.tenant_id, t.service_type, t.price, t.cost_price,
+               t.status, t.remark, t.create_time, t.update_time
+        from tenant_traffic_pricing t
+    </sql>
+
+    <!-- 按租户ID+服务类型查单个 -->
+    <select id="selectByTenantAndType" resultMap="TenantTrafficPricingResult">
+        <include refid="selectVo"/>
+        where t.tenant_id = #{tenantId} and t.service_type = #{serviceType} and t.status = 1
+    </select>
+
+    <!-- 分页列表(联查全局参考价) -->
+    <select id="selectList" resultMap="TenantTrafficPricingResult">
+        select t.id, t.tenant_id, t.service_type, t.price, t.cost_price,
+               t.status, t.remark, t.create_time, t.update_time,
+               c.company_name as tenant_name,
+               s.config_name as service_type_name,
+               s.fee_standard as global_price,
+               s.platform_cost as global_cost
+        from tenant_traffic_pricing t
+        left join company c on c.company_id = t.tenant_id
+        left join service_fee_config s on s.service_type = t.service_type
+        <where>
+            <if test="tenantId != null"> and t.tenant_id = #{tenantId}</if>
+            <if test="serviceType != null"> and t.service_type = #{serviceType}</if>
+            <if test="status != null"> and t.status = #{status}</if>
+        </where>
+        order by t.tenant_id asc, t.service_type asc
+    </select>
+
+    <!-- 按ID查 -->
+    <select id="selectById" resultMap="TenantTrafficPricingResult">
+        select t.id, t.tenant_id, t.service_type, t.price, t.cost_price,
+               t.status, t.remark, t.create_time, t.update_time,
+               c.company_name as tenant_name,
+               s.config_name as service_type_name,
+               s.fee_standard as global_price,
+               s.platform_cost as global_cost
+        from tenant_traffic_pricing t
+        left join company c on c.company_id = t.tenant_id
+        left join service_fee_config s on s.service_type = t.service_type
+        where t.id = #{id}
+    </select>
+
+    <!-- 新增 -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into tenant_traffic_pricing
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="tenantId != null">tenant_id,</if>
+            <if test="serviceType != null">service_type,</if>
+            <if test="price != null">price,</if>
+            <if test="costPrice != null">cost_price,</if>
+            <if test="status != null">status,</if>
+            <if test="remark != null">remark,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="tenantId != null">#{tenantId},</if>
+            <if test="serviceType != null">#{serviceType},</if>
+            <if test="price != null">#{price},</if>
+            <if test="costPrice != null">#{costPrice},</if>
+            <if test="status != null">#{status},</if>
+            <if test="remark != null">#{remark},</if>
+            NOW(),
+        </trim>
+    </insert>
+
+    <!-- 更新 -->
+    <update id="update">
+        update tenant_traffic_pricing
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="price != null">price = #{price},</if>
+            <if test="costPrice != null">cost_price = #{costPrice},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            update_time = NOW(),
+        </trim>
+        where id = #{id}
+    </update>
+
+    <!-- 删除 -->
+    <delete id="deleteById">
+        delete from tenant_traffic_pricing where id = #{id}
+    </delete>
+
+</mapper>

+ 21 - 0
sql/add_traffic_pricing.sql

@@ -0,0 +1,21 @@
+-- ============================================================
+-- 租户服务定价 DDL
+-- tenant_traffic_pricing: 按租户覆盖全服务类型定价(全局 service_fee_config 兜底)
+-- service_type: 2课程流量/3直播流量/4AI TOKEN/7AI外呼附加费/8微助手
+-- 未配置时自动回退 service_fee_config 全局定价
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS `tenant_traffic_pricing` (
+  `id`           BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `tenant_id`    BIGINT NOT NULL COMMENT '租户ID',
+  `service_type` TINYINT NOT NULL COMMENT '服务类型:2课程流量/3直播流量/4AI TOKEN/7AI外呼附加费/8微助手',
+  `price`        DECIMAL(10,4) DEFAULT NULL COMMENT '租户售价(元/GB)',
+  `cost_price`   DECIMAL(10,4) DEFAULT NULL COMMENT '平台成本价(可覆盖全局)',
+  `status`       TINYINT DEFAULT 1 COMMENT '0禁用/1启用',
+  `remark`       VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_time`  DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time`  DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_tenant_type` (`tenant_id`, `service_type`),
+  KEY `idx_tenant_id` (`tenant_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户流量定价(覆盖全局service_fee_config)';

+ 24 - 0
sql/add_traffic_pricing_menu.sql

@@ -0,0 +1,24 @@
+-- ============================================================
+-- adminUI 总后台菜单补充 - 租户流量定价管理(trafficPricing)
+-- 挂载到 通信管理(2400) 分组下
+-- ============================================================
+
+-- 租户流量定价管理 (menu_id=2415)
+INSERT INTO fs_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (2415, '流量定价管理', 2400, 4, 'trafficPricing', 'admin/trafficPricing/index', 'C', 'el-icon-data-line', '0', '0', 0, 0, 'admin', NOW(), '按租户覆盖课程/直播流量定价');
+
+-- 流量定价按钮权限
+INSERT INTO fs_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon, visible, status, is_frame, is_cache, create_by, create_time)
+VALUES
+(24151, '定价查询', 2415, 1, '', '', 'F', 'platform:trafficPricing:list',   '#', '0', '0', 0, 0, 'admin', NOW()),
+(24152, '定价新增', 2415, 2, '', '', 'F', 'platform:trafficPricing:add',    '#', '0', '0', 0, 0, 'admin', NOW()),
+(24153, '定价编辑', 2415, 3, '', '', 'F', 'platform:trafficPricing:edit',   '#', '0', '0', 0, 0, 'admin', NOW()),
+(24154, '定价删除', 2415, 4, '', '', 'F', 'platform:trafficPricing:remove', '#', '0', '0', 0, 0, 'admin', NOW());
+
+-- 将新菜单关联到admin角色(role_id=1)
+INSERT INTO fs_role_menu (role_id, menu_id) VALUES
+(1, 2415),
+(1, 24151),
+(1, 24152),
+(1, 24153),
+(1, 24154);

+ 20 - 0
sql/add_voice_tenant_pricing_menu.sql

@@ -0,0 +1,20 @@
+-- ============================================================
+-- adminUI 总后台菜单补充 - 外呼租户定价管理(voiceApiTenant)
+-- 挂载到 通信管理(2400) 分组下,在通话接口管理后面
+-- ============================================================
+
+-- 外呼租户定价管理 (menu_id=2414)
+INSERT INTO fs_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (2414, '外呼租户定价', 2400, 3, 'voiceApiTenant', 'admin/voiceApiTenant/index', 'C', 'el-icon-money', '0', '0', 0, 0, 'admin', NOW(), '外呼接口-租户定价管理');
+
+-- 外呼租户定价按钮权限
+INSERT INTO fs_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon, visible, status, is_frame, is_cache, create_by, create_time)
+VALUES
+(24141, '定价查询', 2414, 1, '', '', 'F', 'company:companyVoiceApi:list', '#', '0', '0', 0, 0, 'admin', NOW()),
+(24142, '定价编辑', 2414, 2, '', '', 'F', 'company:companyVoiceApi:edit', '#', '0', '0', 0, 0, 'admin', NOW());
+
+-- 将新菜单关联到admin角色(role_id=1)
+INSERT INTO fs_role_menu (role_id, menu_id) VALUES
+(1, 2414),
+(1, 24141),
+(1, 24142);