diff --git a/internal/application/packagetrafficalert/rule.go b/internal/application/packagetrafficalert/rule.go index 17607efe..28daaba5 100644 --- a/internal/application/packagetrafficalert/rule.go +++ b/internal/application/packagetrafficalert/rule.go @@ -86,7 +86,7 @@ func (s *RuleService) Create(ctx context.Context, request *dto.CreatePackageTraf } return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐真流量预警规则失败") } - item := toRuleItem(rule, pkg.PackageName, pkg.RealDataMB) + item := toRuleItem(rule, pkg.PackageName) if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleCreated, "创建套餐真流量预警规则", rule, pkg.PackageName, nil, ruleAuditSnapshot(rule, pkg.PackageName)); auditErr != nil { return auditErr @@ -161,7 +161,7 @@ func (s *RuleService) Update(ctx context.Context, ruleID uint, request *dto.Upda return auditErr } } - response = toRuleItem(rule, packageName, pkg.RealDataMB) + response = toRuleItem(rule, packageName) return nil }) if err != nil { @@ -273,7 +273,8 @@ func ruleAuditIdentity(rule *model.PackageTrafficAlertRule, packageName string) } // toRuleItem 把规则投影为对外响应项。 -func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string, realDataMB int64) *dto.PackageTrafficAlertRuleItem { +// 不返回套餐商品真流量额度:规则按百分比配置,判定分母是资产主套餐与加油包汇总,单个额度会被误读为分母。 +func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string) *dto.PackageTrafficAlertRuleItem { if rule == nil { return nil } @@ -281,7 +282,6 @@ func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string, realDat ID: rule.ID, PackageID: rule.PackageID, PackageName: packageName, - RealDataMB: realDataMB, ThresholdPercent: rule.ThresholdPercent, Enabled: rule.Enabled == constants.StatusEnabled, EnabledName: enabledName(rule.Enabled), diff --git a/internal/domain/packagetrafficalert/threshold.go b/internal/domain/packagetrafficalert/threshold.go index f9abdf93..722300ff 100644 --- a/internal/domain/packagetrafficalert/threshold.go +++ b/internal/domain/packagetrafficalert/threshold.go @@ -46,6 +46,11 @@ func Decide(usedMB, limitMB, thresholdBasisPoints int64) (bool, int64) { return hit, ratioBasisPoints } +// ThresholdMB 返回阈值对应流量:真流量额度汇总 × 阈值百分比 ÷ 100,保留两位小数,口径与额度一致(含加油包)。 +func ThresholdMB(limitMB int64, thresholdPercent float64) float64 { + return math.Round(float64(limitMB)*NormalizeThresholdPercent(thresholdPercent)) / 100 +} + // PercentFromBasisPoints 把万分比换算为保留两位小数的百分比展示值。 func PercentFromBasisPoints(basisPoints int64) float64 { return float64(basisPoints) / 100 diff --git a/internal/exporter/package_traffic_alert_scene.go b/internal/exporter/package_traffic_alert_scene.go index 321c6a56..71518bc8 100644 --- a/internal/exporter/package_traffic_alert_scene.go +++ b/internal/exporter/package_traffic_alert_scene.go @@ -56,9 +56,18 @@ func (s *PackageTrafficAlertDataSource) Headers(context.Context, ExportParams) ( "资产类型", "资产标识", "对应标识符", "卡标识", "设备类型", "设备型号", "套餐名称", "真流量已用量(MB)", "真流量额度(MB)", "比例(%)", "阈值快照(%)", "到期时间", "剩余天数", "触发时间", "店铺", "业务员", "用户组", "通知投递结果", + packageTrafficAlertThresholdMBHeader, }, nil } +// packageTrafficAlertThresholdMBHeader 阈值对应流量列,固定追加在最后一列;历史任务冻结表头没有该列。 +const packageTrafficAlertThresholdMBHeader = "阈值流量(MB)" + +// alertHeadersIncludeThresholdMB 判断冻结表头是否为带“阈值流量(MB)”末列的新表头。 +func alertHeadersIncludeThresholdMB(headers []string) bool { + return len(headers) == 0 || headers[len(headers)-1] == packageTrafficAlertThresholdMBHeader +} + // Fetch 按 offset/limit 查询预警导出数据。 func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { if limit <= 0 { @@ -109,9 +118,10 @@ func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params Export return nil, err } now := time.Now().UTC() + includeThresholdMB := alertHeadersIncludeThresholdMB(params.ResolvedHeaders) rows := make([][]string, 0, len(items)) for _, item := range items { - rows = append(rows, []string{ + row := []string{ assetTypeName(item.AssetType), item.AssetIdentifier, item.CounterpartIdentifier, @@ -130,7 +140,11 @@ func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params Export item.CurrentOwnerName, currentOwnerGroupName(groupNames, item.CurrentOwnerID), constants.GetPackageTrafficAlertNotifyStatusName(resolveAlertNotifyStatus(item)), - }) + } + if includeThresholdMB { + row = append(row, strconv.FormatFloat(packagetrafficalert.ThresholdMB(item.LimitMB, item.ThresholdPercent), 'f', 2, 64)) + } + rows = append(rows, row) } return rows, nil } diff --git a/internal/handler/admin/order.go b/internal/handler/admin/order.go index 2297b250..90a3ad34 100644 --- a/internal/handler/admin/order.go +++ b/internal/handler/admin/order.go @@ -79,7 +79,7 @@ func (h *OrderHandler) Get(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam, "无效的订单ID") } - order, err := h.service.Get(c.UserContext(), uint(id)) + order, err := h.service.GetDetail(c.UserContext(), uint(id)) if err != nil { return err } diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index c1f3ee52..a14de840 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -95,6 +95,32 @@ type OrderResponse struct { // 资产标识符快照 AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"` AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` + + // C 端在线购买的原始支付信息:仅个人客户以微信或支付宝支付、订单已支付或已退款时有值,其余为空串 + ThirdPartyTradeNo string `json:"third_party_trade_no" description:"交易流水号(C 端微信/支付宝在线购买且已支付或已退款时取已支付支付单,其余为空串)"` + PaymentChannel string `json:"payment_channel" description:"支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友);取已支付支付单快照,无快照按原支付配置推断,取不到为空串"` + PaymentMerchantName string `json:"payment_merchant_name" description:"收款商户名称快照,没有为空串"` +} + +// OrderDetailResponse 后台订单详情响应:在订单字段之外返回该订单的全部支付单。 +type OrderDetailResponse struct { + *OrderResponse + Payments []*OrderPaymentResponse `json:"payments" description:"该订单全部支付单(仅个人客户微信/支付宝订单,按创建时间倒序;其他订单为空数组)"` +} + +// OrderPaymentResponse 后台订单详情中的单条支付单;不含二维码、商户号、路由与支付配置等内部字段。 +type OrderPaymentResponse struct { + PaymentNo string `json:"payment_no" description:"支付单号"` + PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信支付, alipay:支付宝支付)"` + PaymentChannel string `json:"payment_channel" description:"支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友),取不到为空串"` + PaymentMerchantName string `json:"payment_merchant_name" description:"收款商户名称快照,没有为空串"` + ThirdPartyTradeNo string `json:"third_party_trade_no" description:"交易流水号,没有为空串"` + Amount int64 `json:"amount" description:"支付金额(分)"` + Status int `json:"status" description:"支付单状态 (0:待支付, 1:已支付, 2:已失败, 3:已退款)"` + StatusName string `json:"status_name" description:"支付单状态名称"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + PaidAt *time.Time `json:"paid_at" description:"支付时间"` + ExpireAt *time.Time `json:"expire_at" description:"过期时间"` } type OrderListResponse struct { diff --git a/internal/model/dto/package_traffic_alert_dto.go b/internal/model/dto/package_traffic_alert_dto.go index 8b52ac1d..ca6be7dc 100644 --- a/internal/model/dto/package_traffic_alert_dto.go +++ b/internal/model/dto/package_traffic_alert_dto.go @@ -38,7 +38,6 @@ type PackageTrafficAlertRuleItem struct { ID uint `json:"id" description:"预警规则ID"` PackageID uint `json:"package_id" description:"套餐商品ID"` PackageName string `json:"package_name" description:"套餐名称"` - RealDataMB int64 `json:"real_data_mb" description:"套餐商品当前真流量额度(MB),仅用于配置校验展示,不作为预警分母"` ThresholdPercent float64 `json:"threshold_percent" description:"真流量预警阈值百分比"` Enabled bool `json:"enabled" description:"是否启用"` EnabledName string `json:"enabled_name" description:"启用状态名称(中文)"` @@ -84,10 +83,11 @@ type PackageTrafficAlertItem struct { CounterpartIdentifier string `json:"counterpart_identifier" description:"对应标识符快照(卡→触发时绑定设备标识,设备→触发时绑定卡 ICCID)"` DeviceType string `json:"device_type" description:"设备类型快照"` DeviceModel string `json:"device_model" description:"设备型号快照"` - UsedMB int64 `json:"used_mb" description:"触发时真已用量汇总快照(MB)"` - LimitMB int64 `json:"limit_mb" description:"触发时真总量快照汇总(MB)"` + UsedMB int64 `json:"used_mb" description:"触发时真流量已用量快照(MB),主套餐与加油包汇总"` + LimitMB int64 `json:"limit_mb" description:"触发时真流量额度快照(MB),主套餐与加油包汇总"` UsagePercent float64 `json:"usage_percent" description:"触发时汇总比例快照(%),可能大于 100"` ThresholdPercent float64 `json:"threshold_percent" description:"触发阈值快照(%)"` + ThresholdMB float64 `json:"threshold_mb" description:"阈值对应流量(MB):真流量额度快照 × 阈值快照 ÷ 100,保留两位小数,口径含加油包"` ExpiresAt *time.Time `json:"expires_at" description:"主套餐到期时间快照,无法推算时为 null"` DaysRemaining *int `json:"days_remaining" description:"按到期时间快照推算的剩余上海自然日天数,负数表示已过期;无到期时间时为 null"` TriggeredAt time.Time `json:"triggered_at" description:"触发时间"` diff --git a/internal/query/packagetrafficalert/query.go b/internal/query/packagetrafficalert/query.go index 7cf096f9..c12e416c 100644 --- a/internal/query/packagetrafficalert/query.go +++ b/internal/query/packagetrafficalert/query.go @@ -57,7 +57,7 @@ func (q *Query) ListRules(ctx context.Context, request dto.ListPackageTrafficAle return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则总数失败") } if err := query.Select("r.id, r.package_id, r.threshold_percent, r.enabled, r.remark, r.updated_at, " + - "COALESCE(p.package_name, '') AS package_name, COALESCE(p.real_data_mb, 0) AS real_data_mb"). + "COALESCE(p.package_name, '') AS package_name"). Order("r.updated_at DESC, r.id DESC"). Offset((page - 1) * pageSize).Limit(pageSize). Scan(&rows).Error; err != nil { @@ -69,7 +69,6 @@ func (q *Query) ListRules(ctx context.Context, request dto.ListPackageTrafficAle ID: row.ID, PackageID: row.PackageID, PackageName: row.PackageName, - RealDataMB: row.RealDataMB, ThresholdPercent: row.ThresholdPercent, Enabled: row.Enabled == constants.StatusEnabled, EnabledName: enabledStatusName(row.Enabled), @@ -84,7 +83,6 @@ type ruleRow struct { ID uint `gorm:"column:id"` PackageID uint `gorm:"column:package_id"` PackageName string `gorm:"column:package_name"` - RealDataMB int64 `gorm:"column:real_data_mb"` ThresholdPercent float64 `gorm:"column:threshold_percent"` Enabled int `gorm:"column:enabled"` Remark string `gorm:"column:remark"` @@ -241,6 +239,7 @@ func (q *Query) projectAlerts(ctx context.Context, alerts []*model.PackageTraffi LimitMB: alert.LimitMBSnapshot, UsagePercent: alert.UsagePercentSnapshot, ThresholdPercent: alert.ThresholdPercentSnapshot, + ThresholdMB: domainpackagetrafficalert.ThresholdMB(alert.LimitMBSnapshot, alert.ThresholdPercentSnapshot), ExpiresAt: alert.ExpiresAtSnapshot, TriggeredAt: alert.TriggeredAt, ShopName: alert.ShopNameSnapshot, diff --git a/internal/routes/business_user_group.go b/internal/routes/business_user_group.go index 38ff7367..d5374996 100644 --- a/internal/routes/business_user_group.go +++ b/internal/routes/business_user_group.go @@ -21,11 +21,11 @@ const shopOwnerImportDoc = `仅超级管理员和平台账号可操作,代理 ### 模板列序(首行表头必须完全一致) -` + "`店铺编码`" + `、` + "`操作类型`" + `、` + "`业务员登录账号`" + `、` + "`备注`" + ` +` + "`店铺编码`" + `、` + "`操作类型`" + `、` + "`平台登录账号`" + `、` + "`备注`" + ` - 店铺编码:以店铺编号唯一定位店铺;不存在或已删除时该行失败 -- 操作类型:取值仅 ` + "`换绑`" + ` 与 ` + "`清空`" + `;换绑必须填写业务员登录账号,清空不得填写 -- 业务员登录账号:以登录账号唯一定位业务员;必须是启用平台用户 +- 操作类型:取值仅 ` + "`换绑`" + ` 与 ` + "`清空`" + `;换绑必须填写平台登录账号,清空不得填写 +- 平台登录账号:填写平台用户的登录手机号(11 位数字),不接受用户名;格式不正确、手机号不存在、匹配多个账号、不是平台用户、账号已禁用时该行失败并给出对应原因。旧表头「业务员登录账号」整批失败 - 备注:可选,填写时写入该行审计 ### 编码要求 diff --git a/internal/routes/device.go b/internal/routes/device.go index a68302cb..c9f3c5fa 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -121,7 +121,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp - ` + "`device_name`" + `: 设备名称 - ` + "`device_model`" + `: 设备型号 - ` + "`device_type`" + `: 设备类型 - - ` + "`imei`" + `: 设备 IMEI + - ` + "`imei`" + `: 设备 IMEI(**必填**,只校验非空;为空或仅空白的行记为失败,原因“设备IMEI(imei)不能为空”) - ` + "`manufacturer`" + `: 制造商 - ` + "`max_sim_slots`" + `: 最大插槽数(默认4,范围1-4) - ` + "`iccid_1`" + ` ~ ` + "`iccid_4`" + `: 固定槽位的卡 ICCID(` + "`iccid_1`" + `=槽1,` + "`iccid_2`" + `=槽2,` + "`iccid_3`" + `=槽3,` + "`iccid_4`" + `=槽4;卡必须已存在且未绑定) diff --git a/internal/routes/order.go b/internal/routes/order.go index 6cc4a3cc..7f164971 100644 --- a/internal/routes/order.go +++ b/internal/routes/order.go @@ -31,7 +31,7 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler, Summary: "获取订单详情", Tags: []string{"订单管理"}, Input: new(dto.GetOrderRequest), - Output: new(dto.OrderResponse), + Output: new(dto.OrderDetailResponse), Auth: true, }) diff --git a/internal/routes/package_traffic_alert.go b/internal/routes/package_traffic_alert.go index 014d2a6f..18956487 100644 --- a/internal/routes/package_traffic_alert.go +++ b/internal/routes/package_traffic_alert.go @@ -29,7 +29,7 @@ func registerPackageTrafficAlertRoutes(router fiber.Router, handler *admin.Packa Register(ruleGroup, doc, rulePath, "GET", "", handler.ListRules, RouteSpec{ Summary: "查询套餐真流量预警规则列表", - Description: "返回套餐、商品当前真流量额度、阈值、启用状态、备注与更新时间;仅超级管理员与平台账号可访问", + Description: "返回套餐、阈值百分比、启用状态、备注与更新时间,不返回套餐流量额度:判定时按资产当前有效的主套餐与加油包真流量汇总计算,规则创建时无法得知汇总额度;仅超级管理员与平台账号可访问", Tags: []string{"套餐管理"}, Input: new(dto.ListPackageTrafficAlertRuleRequest), Output: new(dto.PackageTrafficAlertRuleListResponse), @@ -59,7 +59,7 @@ func registerPackageTrafficAlertRoutes(router fiber.Router, handler *admin.Packa Register(alertGroup, doc, alertPath, "GET", "", handler.ListAlerts, RouteSpec{ Summary: "查询套餐真流量达量预警列表", - Description: "先应用既有资产数据范围;资产、套餐、用量、阈值、到期时间与触发时归属均为触发快照,支持按触发时间闭区间与通知投递结果筛选", + Description: "先应用既有资产数据范围;资产、套餐、用量、阈值、到期时间与触发时归属均为触发快照,真流量已用量与额度为主套餐与加油包汇总,threshold_mb 为额度 × 阈值 ÷ 100;支持按触发时间闭区间与通知投递结果筛选", Tags: []string{"套餐流量预警"}, Input: new(dto.ListPackageTrafficAlertRequest), Output: new(dto.PackageTrafficAlertListResponse), diff --git a/internal/service/order/payment_info.go b/internal/service/order/payment_info.go new file mode 100644 index 00000000..979b3abf --- /dev/null +++ b/internal/service/order/payment_info.go @@ -0,0 +1,139 @@ +package order + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// orderHasOnlinePaymentInfo 判断订单是否为 C 端在线购买且已支付或已退款,只有这类订单返回原始支付信息。 +func orderHasOnlinePaymentInfo(order *model.Order) bool { + if order == nil || order.BuyerType != model.BuyerTypePersonal { + return false + } + if order.PaymentMethod != model.PaymentMethodWechat && order.PaymentMethod != model.PaymentMethodAlipay { + return false + } + return order.PaymentStatus == model.PaymentStatusPaid || order.PaymentStatus == model.PaymentStatusRefunded +} + +// orderIsOnlinePurchase 判断订单是否为个人客户微信或支付宝订单,详情支付单列表按此范围返回。 +func orderIsOnlinePurchase(order *model.Order) bool { + if order == nil || order.BuyerType != model.BuyerTypePersonal { + return false + } + return order.PaymentMethod == model.PaymentMethodWechat || order.PaymentMethod == model.PaymentMethodAlipay +} + +// fillOrderPaymentInfo 批量为订单响应填充交易流水号、支付渠道与商户名称;一次查询支付单、一次查询支付配置。 +func (s *Service) fillOrderPaymentInfo(ctx context.Context, orders []*model.Order, responses []*dto.OrderResponse) error { + orderIDs := make([]uint, 0, len(orders)) + for _, order := range orders { + if orderHasOnlinePaymentInfo(order) { + orderIDs = append(orderIDs, order.ID) + } + } + if len(orderIDs) == 0 { + return nil + } + var payments []*model.Payment + // 带 order_type 过滤:tb_payment 同时存充值类支付,业务单 ID 空间与套餐订单不同。 + if err := s.db.WithContext(ctx). + Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, constants.PaymentRecordStatusPaid). + Order("order_id ASC, paid_at DESC NULLS LAST, id DESC"). + Find(&payments).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付记录失败") + } + channels, err := s.resolvePaymentChannels(ctx, payments) + if err != nil { + return err + } + paidByOrder := make(map[uint]*model.Payment, len(payments)) + for _, payment := range payments { + if _, exists := paidByOrder[payment.OrderID]; !exists { + paidByOrder[payment.OrderID] = payment + } + } + for index, order := range orders { + payment, ok := paidByOrder[order.ID] + if !ok || !orderHasOnlinePaymentInfo(order) { + continue + } + responses[index].ThirdPartyTradeNo = payment.ThirdPartyTradeNo + responses[index].PaymentChannel = paymentChannel(payment, channels) + responses[index].PaymentMerchantName = payment.MerchantNameSnapshot + } + return nil +} + +// listOrderPayments 返回订单详情的全部支付单(任何状态),按创建时间倒序;不适用的订单返回空数组。 +func (s *Service) listOrderPayments(ctx context.Context, order *model.Order) ([]*dto.OrderPaymentResponse, error) { + result := make([]*dto.OrderPaymentResponse, 0) + if !orderIsOnlinePurchase(order) { + return result, nil + } + var payments []*model.Payment + if err := s.db.WithContext(ctx). + Where("order_id = ? AND order_type = ?", order.ID, model.PaymentOrderTypePackage). + Order("created_at DESC, id DESC"). + Find(&payments).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付单失败") + } + channels, err := s.resolvePaymentChannels(ctx, payments) + if err != nil { + return nil, err + } + for _, payment := range payments { + result = append(result, &dto.OrderPaymentResponse{ + PaymentNo: payment.PaymentNo, + PaymentMethod: payment.PaymentMethod, + PaymentChannel: paymentChannel(payment, channels), + PaymentMerchantName: payment.MerchantNameSnapshot, + ThirdPartyTradeNo: payment.ThirdPartyTradeNo, + Amount: payment.Amount, + Status: payment.Status, + StatusName: constants.GetPaymentRecordStatusName(payment.Status), + CreatedAt: payment.CreatedAt, + PaidAt: payment.PaidAt, + ExpireAt: payment.ExpireAt, + }) + } + return result, nil +} + +// resolvePaymentChannels 为缺少渠道快照的历史支付单批量读取原支付配置的渠道类型(含已删除配置)。 +func (s *Service) resolvePaymentChannels(ctx context.Context, payments []*model.Payment) (map[uint]string, error) { + configIDs := make([]uint, 0) + for _, payment := range payments { + if payment.MerchantProviderTypeSnapshot == "" && payment.PaymentConfigID != nil { + configIDs = append(configIDs, *payment.PaymentConfigID) + } + } + channels := make(map[uint]string, len(configIDs)) + if len(configIDs) == 0 { + return channels, nil + } + var configs []model.WechatConfig + if err := s.db.WithContext(ctx).Unscoped().Select("id", "provider_type"). + Where("id IN ?", configIDs).Find(&configs).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史支付配置失败") + } + for _, config := range configs { + channels[config.ID] = config.ProviderType + } + return channels, nil +} + +// paymentChannel 取支付单渠道类型快照,没有快照时按原支付配置推断,推断不出为空串。 +func paymentChannel(payment *model.Payment, channels map[uint]string) string { + if payment.MerchantProviderTypeSnapshot != "" { + return payment.MerchantProviderTypeSnapshot + } + if payment.PaymentConfigID == nil { + return "" + } + return channels[*payment.PaymentConfigID] +} diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 9b3a1e01..b0818719 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -1297,6 +1297,27 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.OrderResponse, error) return s.buildOrderResponse(ctx, order, items), nil } +// GetDetail 返回后台订单详情,含 C 端在线购买的原始支付信息与该订单全部支付单。 +func (s *Service) GetDetail(ctx context.Context, id uint) (*dto.OrderDetailResponse, error) { + order, items, err := s.orderStore.GetByIDWithItems(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "订单不存在") + } + return nil, err + } + + resp := s.buildOrderResponse(ctx, order, items) + if err := s.fillOrderPaymentInfo(ctx, []*model.Order{order}, []*dto.OrderResponse{resp}); err != nil { + return nil, err + } + payments, err := s.listOrderPayments(ctx, order) + if err != nil { + return nil, err + } + return &dto.OrderDetailResponse{OrderResponse: resp, Payments: payments}, nil +} + func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, startTime, endTime *time.Time, buyerType string, buyerID uint) (*dto.OrderListResponse, error) { page := req.Page pageSize := req.PageSize @@ -1387,6 +1408,9 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, startTime for _, o := range orders { list = append(list, s.buildOrderResponse(ctx, o, itemsMap[o.ID])) } + if err := s.fillOrderPaymentInfo(ctx, orders, list); err != nil { + return nil, err + } return &dto.OrderListResponse{ List: list, diff --git a/internal/task/shop_business_owner_import.go b/internal/task/shop_business_owner_import.go index 9b0e93c3..228ba815 100644 --- a/internal/task/shop_business_owner_import.go +++ b/internal/task/shop_business_owner_import.go @@ -195,7 +195,7 @@ type shopBusinessOwnerImportRow struct { ColumnCountMatched bool ShopCode string OperationType string - OwnerUsername string + OwnerPhone string Remark string } @@ -236,7 +236,7 @@ func parseShopBusinessOwnerImportCSV(data []byte) ([]shopBusinessOwnerImportRow, row.ColumnCountMatched = true row.ShopCode = strings.TrimSpace(record[0]) row.OperationType = strings.TrimSpace(record[1]) - row.OwnerUsername = strings.TrimSpace(record[2]) + row.OwnerPhone = strings.TrimSpace(record[2]) row.Remark = strings.TrimSpace(record[3]) rows = append(rows, row) } @@ -303,11 +303,11 @@ func (h *ShopBusinessOwnerImportHandler) processRow(ctx context.Context, taskRec clear := false switch row.OperationType { case constants.ShopBusinessOwnerImportOperationRebind: - if row.OwnerUsername == "" { + if row.OwnerPhone == "" { return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorOwnerRequired), nil } case constants.ShopBusinessOwnerImportOperationClear: - if row.OwnerUsername != "" { + if row.OwnerPhone != "" { return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorOwnerForbidden), nil } clear = true @@ -316,17 +316,14 @@ func (h *ShopBusinessOwnerImportHandler) processRow(ctx context.Context, taskRec } var ownerID *uint if !clear { - var account model.Account - if err := h.db.WithContext(ctx). - Where("username = ? AND user_type = ? AND status = ?", row.OwnerUsername, constants.UserTypePlatform, constants.StatusEnabled). - First(&account).Error; err != nil { - if err != gorm.ErrRecordNotFound { - return item, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询导入目标业务员失败") - } - return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorOwnerInvalid), nil + resolved, reason, err := h.resolveImportOwner(ctx, row.OwnerPhone) + if err != nil { + return item, err } - value := account.ID - ownerID = &value + if reason != "" { + return failedShopBusinessOwnerImportItem(item, reason), nil + } + ownerID = &resolved } // 每行独立事务:成功行提交,失败行回滚并保留原值。 rowErr := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -357,6 +354,46 @@ func (h *ShopBusinessOwnerImportHandler) processRow(ctx context.Context, taskRec return item, nil } +// resolveImportOwner 按手机号定位换绑目标平台账号;返回非空 reason 表示该行失败。 +// 先校验格式再查库;查询不按类型与状态过滤,才能区分不存在、多匹配、非平台与已禁用。 +func (h *ShopBusinessOwnerImportHandler) resolveImportOwner(ctx context.Context, phone string) (uint, string, error) { + if !isShopBusinessOwnerImportPhone(phone) { + return 0, constants.ShopBusinessOwnerImportRowErrorOwnerPhoneFormat, nil + } + var accounts []model.Account + if err := h.db.WithContext(ctx).Select("id", "user_type", "status"). + Where("phone = ?", phone).Limit(2).Find(&accounts).Error; err != nil { + return 0, "", pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询导入目标业务员失败") + } + switch { + case len(accounts) == 0: + return 0, constants.ShopBusinessOwnerImportRowErrorOwnerNotFound, nil + case len(accounts) > 1: + return 0, constants.ShopBusinessOwnerImportRowErrorOwnerAmbiguous, nil + case accounts[0].UserType != constants.UserTypePlatform: + return 0, constants.ShopBusinessOwnerImportRowErrorOwnerNotPlatform, nil + case accounts[0].Status != constants.StatusEnabled: + return 0, constants.ShopBusinessOwnerImportRowErrorOwnerDisabled, nil + } + return accounts[0].ID, "", nil +} + +// isShopBusinessOwnerImportPhone 判断平台登录账号是否为 11 位数字手机号,与项目 DTO 的 len=11,numeric 口径一致。 +func isShopBusinessOwnerImportPhone(value string) bool { + if len(value) != shopBusinessOwnerImportPhoneLength { + return false + } + for i := 0; i < len(value); i++ { + if value[i] < '0' || value[i] > '9' { + return false + } + } + return true +} + +// shopBusinessOwnerImportPhoneLength 平台登录账号手机号位数。 +const shopBusinessOwnerImportPhoneLength = 11 + // appendRowAudit 在行事务内写实际变更审计,含负责人前后值与行备注。 // 行备注写入事件 Metadata,不进入资源前后值字段。 func (h *ShopBusinessOwnerImportHandler) appendRowAudit(ctx context.Context, tx *gorm.DB, taskRecord *model.ShopBusinessOwnerImportTask, shop *model.Shop, before, after *uint, row shopBusinessOwnerImportRow) error { diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/.openspec.yaml b/openspec/changes/fix-august-iteration-follow-up-gaps/.openspec.yaml new file mode 100644 index 00000000..29382a2c --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-29 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/design.md b/openspec/changes/fix-august-iteration-follow-up-gaps/design.md new file mode 100644 index 00000000..60f67998 --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/design.md @@ -0,0 +1,48 @@ +## Context + +本 Change 承接《第1批-8-9月迭代缺陷修复技术方案》(下称“方案”)第 1、2、9、10 节,四项都是导入与展示类,无迁移、互不依赖。旧版本中的 M1、M4~M6 已移到同属第 1 批的 `fix-august-iteration-export-and-password-hints`。 + +| 项 | 现状(已验证) | +|---|---| +| M2 | 导入按 `username = ? AND user_type = 2 AND status = 1` 查业务员(`internal/task/shop_business_owner_import.go:321`),运营填的是手机号,全部失败;查不到、非平台、已禁用合并为一个笼统原因 | +| M3 | 订单响应已有 `payment_method`、`paid_at`;第三方流水号只在导出里有;`tb_payment` 存渠道类型与商户名称快照,历史记录只有 `payment_config_id`;`tb_payment` 同时存充值类支付,ID 空间与套餐订单不同 | +| M16 | 规则项返回 `real_data_mb`(只是主套餐额度),预警列表的 `limit_mb` 是含加油包的汇总快照,两者对不上;列表没有阈值对应流量 | +| M23 | 设备唯一创建入口是导入(`internal/task/device_import.go`),`pkg/utils/excel.go` 只校验虚拟号非空 | + +验证面只有维护者指定的测试环境;自动化测试为 N/A。 + +## Goals / Non-Goals + +**Goals:** +- 批量换绑按手机号定位平台账号,失败原因可定位到具体情形。 +- 后台能看到 C 端在线购买的交易流水号、渠道、商户,详情能看到全部支付单。 +- 预警规则页与列表口径一致、可读。 +- 不再产生缺 IMEI 的新设备。 + +**Non-Goals:** +- 不改 C 端订单接口、支付写入、渠道补查、订单导出现有列。 +- 不改预警判定、唯一键、通知,不处理旧预警记录。 +- 不校验 IMEI 格式与唯一性,不处理存量缺 IMEI 设备。 +- 不做 M1、M4~M6(在 `fix-august-iteration-export-and-password-hints`)。 + +## Decisions + +1. **M2 只认手机号,表头叫“平台登录账号”。** 2026-10-11 确认。项目没有公共手机号校验函数,各处只检查“长度 11、全为数字”(如 DTO `len=11,numeric`),本项格式规则同样定为“去首尾空白后为 11 位数字”。行级失败原因中原“业务员登录账号”文案统一改为“平台登录账号”。按手机号查全部未删除账号、不在 SQL 里过滤类型与状态,再在代码里依次判定,才能把“不存在 / 多匹配 / 非平台 / 已禁用”区分开。表头唯一来源是 `pkg/constants/shop_business_owner_import.go` 的 `ShopBusinessOwnerImportColumns`,失败原因文案也放常量。不做新旧表头并存:并存会让运营继续用旧模板填用户名,问题不收敛。 +2. **M3 支付记录按订单 ID 批量读取,并强制带 `order_type = 'package_order'`。** 不带会把同 ID 的充值支付串进来。已支付取一条时按 `paid_at DESC, id DESC`,保证异常多条时结果确定。`payments` 用专用响应 DTO,不直接序列化 `model.Payment`,避免以后模型加字段被带出。已退款订单仍给值,因为对账需要原交易信息。 +3. **M16 删除 `real_data_mb` 而不是改名或标注。** 规则按百分比配置,任何具体额度都会被误读为分母;服务端 `RealDataMB > 0` 校验保留。`threshold_mb` 由 `limit_mb × threshold_percent ÷ 100` 计算,口径天然与 `limit_mb` 一致。导出列追加在末尾:分片按位置把行截齐到表头(`internal/task/export_common.go` 的 `alignRowsToHeaders`,由 `export_shard.go` 调用),追加在末尾时旧任务只是少一列,插在中间会整体错位。 +4. **M23 在解析层校验。** 与虚拟号为空写法一致,进 `ParseErrors`,由 `device_import.go` 并入失败明细,任务汇总方式不变。 + +## Risks / Trade-offs + +- [旧模板整批失败] → 交付测试环境前通知运营换新模板;失败原因明确提示“请填写手机号”。 +- [前端仍读取 `real_data_mb`] → 测试环境交付时前端同步去掉该字段,任务里单列核对项。 +- [异常的多条已支付支付记录] → 稳定排序取一条,不在读接口修数据。 +- [IMEI 非空但格式错误或重复] → 本批不处理,后续对存量做统计后另行评审。 + +## Migration Plan + +无数据库迁移。部署测试环境的 API 与 Worker 后立即生效;已冻结表头的预警导出任务按旧表头输出。 + +## Open Questions + +无。 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/proposal.md b/openspec/changes/fix-august-iteration-follow-up-gaps/proposal.md new file mode 100644 index 00000000..79d09036 --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/proposal.md @@ -0,0 +1,32 @@ +## Why + +8-9 月迭代第 1 批中有四项导入与展示类缺陷,行为变化小、互不依赖:批量换绑业务员因只认用户名而整批失败;后台订单看不到 C 端在线购买的原始支付信息;真流量预警规则页显示的套餐额度与预警列表汇总口径对不上,且列表缺阈值对应流量;设备导入允许 IMEI 为空,产生的设备在 H5 设备卡列表、实名页报错。依据为《第1批-8-9月迭代缺陷修复技术方案》第 1、2、9、10 节。 + +本 Change 改写自 2026-09-29 的旧内容:原 M1(修改密码提示)、M4~M6(订单、IoT 卡、设备导出列)移到同属第 1 批的 `fix-august-iteration-export-and-password-hints`;原“业务员登录账号支持用户名或手机号”的方案作废,改为只认手机号。 + +## What Changes + +- **BREAKING** 店铺负责人 CSV 导入的固定表头改为「店铺编码、操作类型、平台登录账号、备注」,旧表头整批失败。“平台登录账号”只接受手机号:格式不合法(须为 11 位数字)、不存在、匹配多个账号、不是平台用户、已禁用分别给出行级失败原因(M2)。 +- 后台订单列表与详情始终返回 `third_party_trade_no`、`payment_channel`、`payment_merchant_name` 三个字符串字段,只对个人客户用微信或支付宝支付、状态为已支付或已退款的套餐订单给值,其余为空串;订单详情新增 `payments` 数组,列出该订单全部支付单(任何状态),不含二维码、商户号、路由等敏感或内部字段(M3)。 +- **BREAKING** 真流量预警规则列表与详情不再返回 `real_data_mb`;预警列表、详情、导出新增 `threshold_mb`(阈值对应流量,按触发快照计算,口径含加油包),导出新列追加在最后一列;判定口径不变(M16)。 +- 设备导入中 IMEI 为空(含仅空白)的行记为失败,原因“设备IMEI(imei)不能为空”;只校验非空,不校验格式与唯一性(M23)。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `business-user-group`:店铺负责人 CSV 导入表头改为“平台登录账号”、只认手机号,并细分行级失败原因。 +- `order-refund-exchange`:后台订单列表与详情返回 C 端在线购买的交易流水号、支付渠道、商户名称,详情返回全部支付单。 +- `package-traffic-alert`:规则不再返回套餐真流量额度;预警查询与导出增加阈值流量。 +- `asset-device`:设备导入 IMEI 必填。 + +## Impact + +- 代码:`internal/task/shop_business_owner_import.go`、`pkg/constants/shop_business_owner_import.go`、`internal/routes/business_user_group.go`;`internal/service/order/service.go` 及订单 DTO、支付记录批量读取;`internal/query/packagetrafficalert/`、`internal/model/dto/package_traffic_alert_dto.go`、预警导出数据源;`pkg/utils/excel.go`、`internal/routes/device.go`。 +- 接口:后台订单列表与详情新增字段;预警规则接口删除 `real_data_mb`,测试环境前端须在同一次交付中去掉对该字段的使用;导入接口说明更新;重新生成接口文档。 +- 运营:批量换绑须改用新模板;设备导入文件须填 IMEI。 +- 不涉及数据库迁移、Redis 结构、第三方渠道调用,不改变预警判定与支付写入。 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/specs/asset-device/spec.md b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/asset-device/spec.md new file mode 100644 index 00000000..774ad74a --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/asset-device/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: 设备导入 IMEI 必填 + +设备导入 SHALL 要求每个数据行填写 IMEI。IMEI 去除首尾空白后为空的行 MUST 记为失败,失败原因为“设备IMEI(imei)不能为空”,计入任务失败数并出现在任务详情的逐行失败明细中,MUST NOT 静默跳过;同一文件中 IMEI 齐全的行照常导入。虚拟号与 IMEI 同时为空时只记虚拟号为空一条原因。全部行失败时任务状态为失败。系统 MUST 只校验非空,MUST NOT 校验 IMEI 长度、格式或唯一性。导入模板列名、列数、列序不变;存量缺 IMEI 的设备不在本要求处理范围。 + +#### Scenario: 文件混有空 IMEI 行与正常行 +- **WHEN** 导入文件同时包含 IMEI 为空的行与 IMEI 齐全的行 +- **THEN** 空 IMEI 行以“设备IMEI(imei)不能为空”和正确行号出现在失败明细中,正常行导入成功,任务状态为已完成 + +#### Scenario: 整份文件 IMEI 都为空 +- **WHEN** 导入文件所有行 IMEI 为空或只有空格 +- **THEN** 任务状态为失败,失败明细逐行列出原因 + +#### Scenario: 虚拟号与 IMEI 都为空 +- **WHEN** 某行虚拟号与 IMEI 都为空 +- **THEN** 该行只记录“设备虚拟号(virtual_no)不能为空”一条原因 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/specs/business-user-group/spec.md b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/business-user-group/spec.md new file mode 100644 index 00000000..74066b45 --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/business-user-group/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: 店铺负责人 CSV 导入 +超级管理员和平台用户 SHALL 通过 CSV 导入批量设置或清空店铺负责人;导入 MUST 异步执行并返回成功数、失败数与逐行明细。导入入口仅限超级管理员与平台用户,故导入失败原因不含数据范围无权。 + +导入文件 MUST 为 CSV,编码 MUST 为 UTF-8 且 MAY 带 BOM;非 UTF-8 时系统 SHALL 按既有 GBK 转换能力尝试解码,仍失败时按任务级失败并给出明确原因。文件首行表头 MUST 与固定列序「店铺编码、操作类型、平台登录账号、备注」完全一致,不一致时(含旧表头「业务员登录账号」)为任务级失败,系统 MUST NOT 同时兼容新旧表头。操作类型 MUST 为「换绑」或「清空」;换绑 MUST 填写平台登录账号,清空 MUST NOT 填写平台登录账号。备注可选,填写时 MUST 写入该行审计。店铺 MUST 以店铺编码唯一定位。 + +平台登录账号 MUST 填写平台用户的登录手机号。系统 MUST 去除首尾空白后校验为 11 位数字,不合法时该行失败且 MUST NOT 查询账号;填写用户名按格式不合法处理。格式合法时系统 MUST 按手机号精确查询全部未删除账号(不按用户类型与状态过滤),再依次判定:零条失败为“平台登录账号(手机号)不存在”;多于一条失败为“平台登录账号(手机号)匹配多个账号”;唯一账号不是平台用户失败为“该手机号对应账号不是平台用户”;唯一账号已禁用失败为“该手机号对应账号已禁用”。只有唯一命中启用平台账号时方可执行换绑。 + +每行 MUST 独立校验与执行:有效行成功更新,失败行 MUST 保留原值且不影响其他已成功行;结果 MUST 保存行号(自数据首行起计,表头不计入)、成功或失败状态与失败原因。任务级失败与行级失败 MUST 分开记录,任务级失败不产生行明细。导入 MUST NOT 设置行数硬上限。所有实际变更 MUST 记录店铺负责人前后值与行备注审计。 + +行级失败原因 MUST 限定为:店铺编码不存在或已删除、操作类型非法、换绑必须填写平台登录账号、清空不得填写平台登录账号、平台登录账号请填写手机号且格式不正确、平台登录账号(手机号)不存在、平台登录账号(手机号)匹配多个账号、该手机号对应账号不是平台用户、该手机号对应账号已禁用、行格式错误。任务级失败原因 MUST 覆盖:文件格式或表头不符、编码无法解码、文件无数据行。 + +#### Scenario: 导入包含有效和无效行 +- **WHEN** 店铺负责人 CSV 导入同时包含可成功换绑或清空的行与校验失败的行 +- **THEN** 系统更新每个有效行、保留失败行原值,并返回逐行行号、失败原因及成功与失败汇总 + +#### Scenario: 表头或编码不符整批失败 +- **WHEN** 上传文件的表头与固定列序不一致(含仍使用旧表头「业务员登录账号」),或文件既非合法 UTF-8 又无法按既有 GBK 转换能力解码 +- **THEN** 系统不处理任何行、不产生行明细,并返回明确的任务级失败原因 + +#### Scenario: 换绑与清空字段互斥 +- **WHEN** 某行操作类型为换绑但平台登录账号为空,或操作类型为清空但填写了平台登录账号 +- **THEN** 该行失败并保留店铺原负责人,其他行照常处理 + +#### Scenario: 手机号唯一命中启用平台账号 +- **WHEN** 某换绑行填写的手机号唯一命中一个未删除且启用的平台账号 +- **THEN** 系统将目标店铺负责人更新为该账号,并按既有规则记录逐行结果与审计前后值 + +#### Scenario: 填写用户名或格式不合法的号码 +- **WHEN** 某换绑行的平台登录账号填写用户名或格式不合法的号码 +- **THEN** 该行以“平台登录账号请填写手机号,格式不正确”失败、不查询账号且保留原负责人 + +#### Scenario: 手机号不存在、多匹配或账号类型状态不符 +- **WHEN** 某换绑行的手机号没有命中账号、命中多个账号、唯一账号不是平台用户或唯一账号已禁用 +- **THEN** 该行分别返回“平台登录账号(手机号)不存在”“平台登录账号(手机号)匹配多个账号”“该手机号对应账号不是平台用户”或“该手机号对应账号已禁用”,并保留原负责人 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/specs/order-refund-exchange/spec.md b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/order-refund-exchange/spec.md new file mode 100644 index 00000000..3b7f12ea --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/order-refund-exchange/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: 后台订单返回 C 端在线购买的原始支付信息 + +系统 SHALL 在后台订单列表与 `GET /api/admin/orders/{id}` 的每条订单中始终返回字符串字段 `third_party_trade_no`(交易流水号)、`payment_channel`(支付渠道类型)、`payment_merchant_name`(商户名称)。仅当订单买家为个人客户、支付方式为微信或支付宝、且订单支付状态为已支付或已退款时,系统 MUST 从该订单自身的套餐订单支付记录(`order_type = 'package_order'`)中状态为已支付的一条取值;存在多条已支付记录时 MUST 按支付时间倒序、记录 ID 倒序取第一条。`payment_channel` 取支付记录的渠道类型快照,快照为空时按支付记录关联的支付配置推断,推断不出为空串;`payment_merchant_name` 取商户名称快照,没有为空串。其他订单(代理购买、钱包支付、线下支付、未支付)三个字段 MUST 为空串。交易时间与支付方式沿用既有 `paid_at`、`payment_method`。 + +列表 MUST 按当页订单集合批量读取支付记录,MUST NOT 逐行查询;数据库错误 MUST 按既有数据库错误返回,MUST NOT 当作空值。系统 MUST NOT 调用第三方渠道补查,MUST NOT 改变 C 端订单接口与订单导出现有列。 + +#### Scenario: C 端微信或支付宝已支付订单 +- **WHEN** 后台调用方查询个人客户以微信或支付宝支付成功的套餐订单 +- **THEN** 列表与详情返回的三个字段与该订单已支付支付记录的流水号、渠道类型快照、商户名称快照一致 + +#### Scenario: 已退款订单仍返回原交易信息 +- **WHEN** 上述订单退款成功、订单支付状态变为已退款后再次查询 +- **THEN** 三个字段仍返回原已支付支付记录的值 + +#### Scenario: 非 C 端在线购买订单 +- **WHEN** 后台调用方查询代理购买、钱包支付、线下支付或未支付的订单 +- **THEN** 响应仍包含三个字段且值均为空串 + +#### Scenario: 套餐订单与同 ID 充值单不串 +- **WHEN** 存在一笔客户充值或代理充值支付记录,其业务单 ID 与某套餐订单 ID 相同 +- **THEN** 该套餐订单的三个字段只来自 `order_type = 'package_order'` 的支付记录 + +### Requirement: 后台订单详情返回全部支付单 + +系统 SHALL 在 `GET /api/admin/orders/{id}` 响应中返回 `payments` 数组,列出该订单的全部支付单,用于排查付款后订单未成功、重复支付、更换支付方式或重付。适用范围与“后台订单返回 C 端在线购买的原始支付信息”相同;不适用的订单返回空数组 `[]`。取数 MUST 按该订单且 `order_type = 'package_order'`,包含所有状态,按创建时间倒序、记录 ID 倒序排序。每条 MUST 返回支付单号、支付方式、支付渠道类型(取值规则同上)、商户名称、交易流水号、金额(分)、状态与状态名称、创建时间、支付时间、过期时间;MUST NOT 返回二维码内容、商户号、商户池与路由信息、支付配置 ID、付款凭证存储键。订单列表 MUST NOT 返回 `payments`。 + +#### Scenario: 先微信未付再支付宝已付 +- **WHEN** 个人客户对同一订单先发起微信支付未完成、再用支付宝支付成功,后台查询该订单详情 +- **THEN** `payments` 含两条支付单,按创建时间倒序,状态分别为已支付与待支付或已失败,且不含二维码、商户号与路由字段 + +#### Scenario: 不适用订单返回空数组 +- **WHEN** 后台查询代理购买、钱包支付或线下支付订单的详情 +- **THEN** `payments` 为空数组 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/specs/package-traffic-alert/spec.md b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/package-traffic-alert/spec.md new file mode 100644 index 00000000..337d6abd --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/specs/package-traffic-alert/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: 规则页不返回套餐流量额度 + +真流量预警规则按套餐配置百分比阈值,判定时按资产当前有效的主套餐与加油包汇总计算,规则创建时无法得知资产最终的汇总额度。规则列表与规则详情 MUST NOT 返回套餐商品真流量额度字段 `real_data_mb`,只返回套餐、百分比阈值、启用状态与备注;规则接口说明 MUST 写明上述判定口径。创建或修改规则时对套餐商品真流量额度大于零的服务端校验 MUST 保持不变。 + +#### Scenario: 规则响应不含套餐额度 +- **WHEN** 调用方查询真流量预警规则列表或详情 +- **THEN** 响应不包含 `real_data_mb` + +#### Scenario: 无真流量额度的套餐仍不可配置 +- **WHEN** 管理员为真流量商品额度不大于零的套餐创建或启用规则 +- **THEN** 系统仍拒绝并保持该套餐无有效规则 + +## MODIFIED Requirements + +### Requirement: 预警查询与导出 + +超级管理员和平台用户 SHALL 在既有资产数据范围内查询和导出预警;先应用既有资产数据范围,该范围当前对超级管理员与平台无实际过滤,保留为冻结语义与未来放开的前置。越权与不存在 MUST 统一按既有的资源不可见处理。导出 MUST 复用既有异步任务并在创建时冻结操作者、筛选、时间范围与可见资产范围,执行期间归属变化 MUST NOT 扩大范围,且 MUST 按触发时间筛选。列表、详情与导出 MUST 返回资产类型、资产标识、对应标识符、卡标识、设备类型、设备型号、套餐名称、真流量已用量、真流量额度、比例、阈值快照、阈值对应流量、到期时间、剩余天数、触发时间、店铺、业务员、用户组与通知投递结果,且 MUST NOT 包含运营商通道列。真流量已用量与真流量额度的接口说明 MUST 写明为“主套餐与加油包汇总”。阈值对应流量 `threshold_mb` MUST 等于触发快照的真流量额度乘以阈值百分比除以 100,保留两位小数,口径与真流量额度一致(含加油包)。导出中阈值对应流量列(“阈值流量(MB)”)MUST 追加在最后一列;任务创建时已冻结表头的历史任务 MUST 按冻结表头输出,不插入该列。导出中套餐、用量、总量、阈值、阈值对应流量与到期时间 MUST 使用触发快照,店铺、业务员与用户组 MUST 按执行时当前归属补充;预警行仍冻结店铺与业务员快照供列表、详情与追溯,两种口径 MUST NOT 混用;用户组 MUST NOT 写入店铺表,按既有实时推导。 + +#### Scenario: 受限导出 + +- **WHEN** 平台用户在其资产数据范围内创建预警导出 +- **THEN** 导出仅包含创建时可见预警,即使任务执行期间店铺归属发生变化 + +#### Scenario: 归属变更后导出 + +- **WHEN** 预警记录创建后资产所属店铺或业务员变更,再执行已创建导出任务 +- **THEN** 套餐、用量、总量、阈值、阈值对应流量与到期时间仍使用触发快照,店铺、业务员与用户组使用执行时当前归属,且不超出任务创建时冻结的可见范围 + +#### Scenario: 越权或不存在 + +- **WHEN** 调用者读取不在其可见范围内的预警详情 +- **THEN** 系统按资源不可见处理,不区分越权、不存在与已删除 + +#### Scenario: 阈值对应流量 + +- **WHEN** 某预警触发快照的真流量额度为 103424 MB、阈值为 80% +- **THEN** 列表与详情返回 `threshold_mb` 为 82739.20,新建导出任务的最后一列“阈值流量(MB)”为同一值 + +#### Scenario: 历史导出任务列序不变 + +- **WHEN** Worker 执行本要求生效前已冻结表头的预警导出任务 +- **THEN** 输出仍使用冻结的旧表头与旧列数,不插入阈值流量列 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/tasks.md b/openspec/changes/fix-august-iteration-follow-up-gaps/tasks.md new file mode 100644 index 00000000..e41833f1 --- /dev/null +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/tasks.md @@ -0,0 +1,32 @@ +## 1. M2 批量换绑按平台登录账号(手机号) + +- [x] 1.1 `pkg/constants/shop_business_owner_import.go`:固定表头改为「店铺编码、操作类型、平台登录账号、备注」;行级失败原因常量按 delta spec 改名与拆分(换绑必须填写平台登录账号、清空不得填写平台登录账号、格式不正确、不存在、匹配多个、不是平台用户、已禁用)。 +- [x] 1.2 `internal/task/shop_business_owner_import.go`:值去首尾空白后校验为 11 位数字,不合法直接失败不查库;按手机号查全部未删除账号,代码中依次判定零条、多条、非平台、已禁用。 +- [x] 1.3 同步 `internal/routes/business_user_group.go` 导入说明与模板说明(“填写平台用户的登录手机号”)。 + +## 2. M3 后台订单支付信息 + +- [x] 2.1 新增按订单 ID 集合批量读取 `order_type = 'package_order'` 支付记录的只读查询;已支付取一条按 `paid_at DESC, id DESC`;渠道类型快照为空时按 `payment_config_id` 推断。 +- [x] 2.2 `internal/service/order/service.go` 列表与详情组装三个字段,仅个人客户、微信或支付宝、订单已支付或已退款时给值,其余为空串;数据库错误照常返回。 +- [x] 2.3 详情新增 `payments` 数组与专用响应 DTO(字段与排除项按 delta spec),列表不返回。 + +## 3. M16 预警口径展示 + +- [x] 3.1 从 `PackageTrafficAlertRuleItem` 删除 `real_data_mb`,`internal/query/packagetrafficalert/query.go` 同步去掉取值;保留 `rule.go` 的 `RealDataMB > 0` 校验。 +- [x] 3.2 预警列表、详情 DTO 增加 `threshold_mb`(两位小数);导出数据源追加“阈值流量(MB)”为最后一列,按冻结表头兼容历史任务。 +- [x] 3.3 更新规则与预警接口字段说明(汇总口径)。 + +## 4. M23 设备导入 IMEI 必填 + +- [x] 4.1 `pkg/utils/excel.go` 在虚拟号校验之后增加 IMEI 非空校验,原因文案放常量。 +- [x] 4.2 `internal/routes/device.go` 导入说明中 `imei` 改为必填。 + +## 5. 文档与测试环境验证 + +- [x] 5.1 对变更 Go 文件执行 `gofmt`,运行 `go build ./cmd/api ./cmd/worker` 与 `go run cmd/gendocs/main.go`,核对新字段与说明。 +- [ ] 5.2 按 ENG-TEST-001 在 `junhong_cmp_test` 创建并清理专用账号与店铺:用新表头 CSV 验证启用平台账号手机号换绑成功且审计前后值正确;格式不合法、用户名、不存在、非平台、已禁用分别按 delta spec 失败且原负责人不变;旧表头整批失败。 +- [x] 5.3 订单:C 端微信、支付宝已支付及已退款订单三个字段与 `tb_payment` 一致;代理、钱包、线下、未支付为空串;构造“先微信未付、再支付宝已付”订单核对 `payments`;套餐订单与同 ID 充值单不串;列表无逐行查询。 +- [ ] 5.4 预警:规则列表与详情不含 `real_data_mb`,无额度套餐仍被拒;列表与详情 `threshold_mb` 计算正确;新导出任务最后一列为阈值流量,旧任务列序不变。 +- [ ] 5.5 设备导入:混合文件、整份空 IMEI、仅空格、虚拟号与 IMEI 都为空四种情形按 delta spec 核对。 +- [ ] 5.6 通知测试环境前端去掉 `real_data_mb` 的使用、接入订单新字段;通知运营换新换绑模板、导入须填 IMEI。 +- [ ] 5.7 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/pkg/constants/shop_business_owner_import.go b/pkg/constants/shop_business_owner_import.go index 76d1e69f..e5e50586 100644 --- a/pkg/constants/shop_business_owner_import.go +++ b/pkg/constants/shop_business_owner_import.go @@ -18,7 +18,7 @@ const ( ShopBusinessOwnerImportItemStatusFailed = 4 ) -// 店铺负责人 CSV 导入的操作类型取值,行内互相排斥:换绑必须填业务员登录账号,清空不得填写。 +// 店铺负责人 CSV 导入的操作类型取值,行内互相排斥:换绑必须填平台登录账号,清空不得填写。 const ( // ShopBusinessOwnerImportOperationRebind 表示把店铺负责人换绑到指定平台业务员。 ShopBusinessOwnerImportOperationRebind = "换绑" @@ -27,7 +27,8 @@ const ( ) // ShopBusinessOwnerImportColumns 表示导入文件首行必须完全一致的固定列序。 -var ShopBusinessOwnerImportColumns = []string{"店铺编码", "操作类型", "业务员登录账号", "备注"} +// “平台登录账号”只认平台用户的登录手机号。 +var ShopBusinessOwnerImportColumns = []string{"店铺编码", "操作类型", "平台登录账号", "备注"} // 店铺负责人 CSV 导入的任务级失败原因,与行级失败原因分开记录。 const ( @@ -45,12 +46,20 @@ const ( ShopBusinessOwnerImportRowErrorShopMissing = "店铺编码不存在或已删除" // ShopBusinessOwnerImportRowErrorOperation 表示操作类型不是换绑或清空。 ShopBusinessOwnerImportRowErrorOperation = "操作类型非法" - // ShopBusinessOwnerImportRowErrorOwnerRequired 表示换绑未填写业务员登录账号。 - ShopBusinessOwnerImportRowErrorOwnerRequired = "换绑必须填写业务员登录账号" - // ShopBusinessOwnerImportRowErrorOwnerForbidden 表示清空却填写了业务员登录账号。 - ShopBusinessOwnerImportRowErrorOwnerForbidden = "清空不得填写业务员登录账号" - // ShopBusinessOwnerImportRowErrorOwnerInvalid 表示业务员登录账号不存在或非启用平台用户。 - ShopBusinessOwnerImportRowErrorOwnerInvalid = "业务员登录账号不存在或非启用平台用户" + // ShopBusinessOwnerImportRowErrorOwnerRequired 表示换绑未填写平台登录账号。 + ShopBusinessOwnerImportRowErrorOwnerRequired = "换绑必须填写平台登录账号" + // ShopBusinessOwnerImportRowErrorOwnerForbidden 表示清空却填写了平台登录账号。 + ShopBusinessOwnerImportRowErrorOwnerForbidden = "清空不得填写平台登录账号" + // ShopBusinessOwnerImportRowErrorOwnerPhoneFormat 表示平台登录账号不是 11 位数字手机号(含填写用户名)。 + ShopBusinessOwnerImportRowErrorOwnerPhoneFormat = "平台登录账号请填写手机号,格式不正确" + // ShopBusinessOwnerImportRowErrorOwnerNotFound 表示手机号没有命中未删除账号。 + ShopBusinessOwnerImportRowErrorOwnerNotFound = "平台登录账号(手机号)不存在" + // ShopBusinessOwnerImportRowErrorOwnerAmbiguous 表示手机号命中多个未删除账号。 + ShopBusinessOwnerImportRowErrorOwnerAmbiguous = "平台登录账号(手机号)匹配多个账号" + // ShopBusinessOwnerImportRowErrorOwnerNotPlatform 表示手机号唯一命中的账号不是平台用户。 + ShopBusinessOwnerImportRowErrorOwnerNotPlatform = "该手机号对应账号不是平台用户" + // ShopBusinessOwnerImportRowErrorOwnerDisabled 表示手机号唯一命中的平台账号已禁用。 + ShopBusinessOwnerImportRowErrorOwnerDisabled = "该手机号对应账号已禁用" // ShopBusinessOwnerImportRowErrorFormat 表示该行列数与固定列序不一致。 ShopBusinessOwnerImportRowErrorFormat = "行格式错误" ) diff --git a/pkg/utils/excel.go b/pkg/utils/excel.go index 729a8ce4..04248df6 100644 --- a/pkg/utils/excel.go +++ b/pkg/utils/excel.go @@ -9,6 +9,9 @@ import ( "github.com/xuri/excelize/v2" ) +// DeviceImportIMEIRequiredReason 设备导入行 IMEI 为空(含仅空白)时的失败原因。 +const DeviceImportIMEIRequiredReason = "设备IMEI(imei)不能为空" + // CardInfo 卡信息(ICCID + MSISDN + VirtualNo) type CardInfo struct { ICCID string @@ -185,6 +188,15 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) { continue } + // IMEI 只校验非空,不校验长度、格式与唯一性;虚拟号与 IMEI 都为空时只记虚拟号一条原因。 + if row.IMEI == "" { + parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{ + Line: lineNum, + Reason: DeviceImportIMEIRequiredReason, + }) + continue + } + // 解析最大SIM槽数 if maxSimSlotsStr != "" { if n, err := strconv.Atoi(maxSimSlotsStr); err == nil {