Files
junhong_cmp_fiber/internal/service/carrier/service.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

448 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package carrier
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
carrierthreshold "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
carrierthresholdquery "github.com/break/junhong_cmp_fiber/internal/query/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
type Service struct {
carrierStore *postgres.CarrierStore
audit systemconfigapp.AuditWriter
// thresholdHits 是通道阈值命中记录只读查询;与运营商配置共用同一数据库连接。
thresholdHits *carrierthresholdquery.Query
}
func New(carrierStore *postgres.CarrierStore, audit systemconfigapp.AuditWriter) *Service {
service := &Service{carrierStore: carrierStore, audit: audit}
if carrierStore != nil {
service.thresholdHits = carrierthresholdquery.NewQuery(carrierStore.DB())
}
return service
}
// ListThresholdHits 分页查询通道阈值命中记录。
//
// 账号门禁与卡数据范围在查询侧判定ENG-AUTHZ-001本方法只做装配
// 权限失败与资源不可见都按查询侧的统一拒绝返回。
func (s *Service) ListThresholdHits(ctx context.Context, req *dto.CarrierThresholdHitListRequest) (*dto.CarrierThresholdHitPageResult, error) {
if s == nil || s.thresholdHits == nil {
return nil, errors.New(errors.CodeInternalError, "通道阈值命中记录查询未配置")
}
return s.thresholdHits.List(ctx, *req)
}
func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*dto.CarrierResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 阈值字段只由超级管理员与平台账号配置:非平台账号提交即整体拒绝,不落任何配置。
if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝非平台账号配置运营商通道流量阈值",
thresholdCreateProbe(req), errors.CodeForbidden)
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
thresholdEnabled, thresholdValue, thresholdUnit := 0, req.TrafficThresholdValue, ""
if req.TrafficThresholdEnabled != nil {
thresholdEnabled = *req.TrafficThresholdEnabled
}
if req.TrafficThresholdUnit != nil {
thresholdUnit = *req.TrafficThresholdUnit
}
if err := validateThresholdConfig(thresholdEnabled, thresholdValue, thresholdUnit); err != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝保存非法运营商通道流量阈值配置",
thresholdCreateProbe(req), errors.CodeInvalidParam)
return nil, err
}
existing, _ := s.carrierStore.GetByCode(ctx, req.CarrierCode)
if existing != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝创建重复运营商配置", existing, errors.CodeCarrierCodeExists)
return nil, errors.New(errors.CodeCarrierCodeExists, "运营商编码已存在")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
carrier := &model.Carrier{
CarrierCode: req.CarrierCode,
CarrierName: req.CarrierName,
CarrierType: req.CarrierType,
Description: req.Description,
Status: constants.StatusEnabled,
DataResetDay: 1,
TrafficThresholdEnabled: thresholdEnabled,
TrafficThresholdValue: thresholdValue,
TrafficThresholdUnit: thresholdUnit,
}
if req.DataResetDay != nil {
carrier.DataResetDay = *req.DataResetDay
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
carrier.Creator = currentUserID
err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Create(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierCreate, "创建运营商配置", nil, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierCreate, "创建运营商配置失败", carrier)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建运营商失败")
}
return s.toResponse(ctx, carrier), nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error) {
carrier, err := s.carrierStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeCarrierNotFound, "运营商不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
return s.toResponse(ctx, carrier), nil
}
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierRequest) (*dto.CarrierResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
carrier, err := s.carrierStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeCarrierNotFound, "运营商不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
before := *carrier
// 阈值字段只由超级管理员与平台账号修改:非平台账号提交即整体拒绝。
if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) {
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝非平台账号配置运营商通道流量阈值",
&before, errors.CodeForbidden)
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
// 阈值按「请求覆盖既有配置」后的结果校验,避免保存出启用但缺数值/单位的半配置。
thresholdEnabled, thresholdValue, thresholdUnit := carrier.TrafficThresholdEnabled, carrier.TrafficThresholdValue, carrier.TrafficThresholdUnit
if req.TrafficThresholdEnabled != nil {
thresholdEnabled = *req.TrafficThresholdEnabled
}
if req.TrafficThresholdValue != nil {
thresholdValue = req.TrafficThresholdValue
}
if req.TrafficThresholdUnit != nil {
thresholdUnit = *req.TrafficThresholdUnit
}
if err := validateThresholdConfig(thresholdEnabled, thresholdValue, thresholdUnit); err != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝保存非法运营商通道流量阈值配置",
&before, errors.CodeInvalidParam)
return nil, err
}
carrier.TrafficThresholdEnabled = thresholdEnabled
carrier.TrafficThresholdValue = thresholdValue
carrier.TrafficThresholdUnit = thresholdUnit
if req.CarrierName != nil {
carrier.CarrierName = *req.CarrierName
}
if req.Description != nil {
carrier.Description = *req.Description
}
if req.DataResetDay != nil {
carrier.DataResetDay = *req.DataResetDay
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝保存非法运营商实名链接配置", &before, errors.CodeInvalidParam)
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
}
carrier.Updater = currentUserID
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierUpdate, "更新运营商配置", &before, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierUpdate, "更新运营商配置失败", carrier)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新运营商失败")
}
return s.toResponse(ctx, carrier), nil
}
func (s *Service) Delete(ctx context.Context, id uint) error {
carrier, err := s.carrierStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeCarrierNotFound, "运营商不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Delete(ctx, id); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierDelete, "删除运营商配置", carrier, nil)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierDelete, "删除运营商配置失败", carrier)
return errors.Wrap(errors.CodeInternalError, err, "删除运营商失败")
}
return nil
}
func (s *Service) List(ctx context.Context, req *dto.CarrierListRequest) ([]*dto.CarrierResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.CarrierType != nil {
filters["carrier_type"] = *req.CarrierType
}
if req.CarrierName != nil {
filters["carrier_name"] = *req.CarrierName
}
if req.Status != nil {
filters["status"] = *req.Status
}
carriers, total, err := s.carrierStore.List(ctx, opts, filters)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询运营商列表失败")
}
responses := make([]*dto.CarrierResponse, len(carriers))
for i, c := range carriers {
responses[i] = s.toResponse(ctx, c)
}
return responses, total, nil
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
carrier, err := s.carrierStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeCarrierNotFound, "运营商不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
before := *carrier
carrier.Status = status
carrier.Updater = currentUserID
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态", &before, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态失败", carrier)
return errors.Wrap(errors.CodeInternalError, err, "更新运营商状态失败")
}
return nil
}
func (s *Service) writeAudit(ctx context.Context, tx *gorm.DB, operation, description string, before, after *model.Carrier) error {
carrier := after
if carrier == nil {
carrier = before
}
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier),
BeforeData: carrierAuditSnapshot(before), AfterData: carrierAuditSnapshot(after),
RequestID: requestID, CorrelationID: requestID,
})
}
func (s *Service) recordDenied(ctx context.Context, operation, description string, carrier *model.Carrier, code int) {
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultDenied, code)
}
func (s *Service) recordFailure(ctx context.Context, operation, description string, carrier *model.Carrier) {
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultFailed, errors.CodeDatabaseError)
}
func (s *Service) recordAuditResult(ctx context.Context, operation, description string, carrier *model.Carrier, result string, code int) {
if s.audit == nil || carrier == nil || s.carrierStore == nil || s.carrierStore.DB() == nil {
return
}
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
audit := systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier), BeforeData: carrierAuditSnapshot(carrier),
Result: result, ErrorCode: strconv.Itoa(code), ErrorSummary: description,
RequestID: requestID, CorrelationID: requestID,
}
if err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteConfigChange(ctx, tx, audit)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(operation, audit.ConfigKey, requestID, requestID, audit.ErrorCode, err)
}
}
func carrierIdentity(carrier *model.Carrier) map[string]any {
if carrier == nil {
return nil
}
return map[string]any{
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
"carrier_type": carrier.CarrierType, "status": carrier.Status,
}
}
func carrierAuditSnapshot(carrier *model.Carrier) map[string]any {
if carrier == nil {
return nil
}
return map[string]any{
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
"carrier_type": carrier.CarrierType, "description": carrier.Description, "status": carrier.Status,
"realname_link_type": carrier.RealnameLinkType, "realname_link_template": carrier.RealnameLinkTemplate,
"data_reset_day": carrier.DataResetDay,
// 通道流量阈值的新增、修改、启用与停用都并入既有运营商更新审计的前后值快照,不另建审计动作。
"traffic_threshold_enabled": carrier.TrafficThresholdEnabled,
"traffic_threshold_value": carrier.TrafficThresholdValue,
"traffic_threshold_unit": carrier.TrafficThresholdUnit,
}
}
// canManageThreshold 判断当前账号是否具备通道流量阈值字段的读写权限。
// 只有超级管理员与平台账号可见可写,与 requirePlatformManagement 的判定一致(引用同一用户类型枚举)。
func canManageThreshold(ctx context.Context) bool {
userType := middleware.GetUserTypeFromContext(ctx)
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
}
// validateThresholdConfig 校验通道流量阈值配置:启停仅 0/1、数值必须为正、单位仅 MB/GB、配置齐备。
// 「启用但无数值/单位」与「只给数值不给单位」都会让达量判定失去唯一口径;
// 非 0/1 的启停取值会被判定端按「未启用」静默处理,因此必须显式拒绝。
func validateThresholdConfig(enabled int, value *float64, unit string) error {
if enabled != constants.StatusDisabled && enabled != constants.StatusEnabled {
return errors.New(errors.CodeInvalidParam, "通道流量阈值是否启用仅支持 0 或 1")
}
if value != nil && *value <= 0 {
return errors.New(errors.CodeInvalidParam, "通道流量阈值数值必须为正数")
}
if unit != "" && unit != carrierthreshold.UnitMB && unit != carrierthreshold.UnitGB {
return errors.New(errors.CodeInvalidParam, "通道流量阈值单位仅支持 MB 或 GB")
}
configured := value != nil || unit != ""
if configured && (value == nil || unit == "") {
return errors.New(errors.CodeInvalidParam, "通道流量阈值数值与流量单位必须同时提供")
}
if enabled == constants.StatusEnabled && !configured {
return errors.New(errors.CodeInvalidParam, "启用通道流量阈值必须同时提供正的阈值数值与流量单位")
}
return nil
}
// thresholdCreateProbe 构造仅供拒绝审计使用的运营商身份,不写库。
func thresholdCreateProbe(req *dto.CreateCarrierRequest) *model.Carrier {
if req == nil {
return nil
}
return &model.Carrier{
CarrierCode: req.CarrierCode, CarrierName: req.CarrierName, CarrierType: req.CarrierType,
}
}
// toResponse 把运营商事实投影为响应;阈值字段只对超级管理员与平台账号返回。
func (s *Service) toResponse(ctx context.Context, c *model.Carrier) *dto.CarrierResponse {
result := &dto.CarrierResponse{
ID: c.ID,
CarrierCode: c.CarrierCode,
CarrierName: c.CarrierName,
CarrierType: c.CarrierType,
Description: c.Description,
DataResetDay: c.DataResetDay,
RealnameLinkType: c.RealnameLinkType,
RealnameLinkTemplate: c.RealnameLinkTemplate,
Status: c.Status,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
if !canManageThreshold(ctx) {
return result
}
enabled := c.TrafficThresholdEnabled
result.TrafficThresholdEnabled = &enabled
result.TrafficThresholdValue = c.TrafficThresholdValue
if c.TrafficThresholdUnit != "" {
unit := c.TrafficThresholdUnit
result.TrafficThresholdUnit = &unit
}
return result
}