fix: 第1批 M2、M3、M16、M23 导入与展示类缺陷
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8h37m36s

- M2 批量换绑表头改为“平台登录账号”,只认手机号,细分格式、不存在、多匹配、非平台、已禁用失败原因
- M3 后台订单列表与详情返回 C 端在线购买的交易流水号、支付渠道、商户名称,详情新增全部支付单 payments
- M16 预警规则不再返回 real_data_mb,预警列表/详情新增 threshold_mb,导出末列追加“阈值流量(MB)”
- M23 设备导入 IMEI 必填,空 IMEI 行记为失败
- OpenSpec Change fix-august-iteration-follow-up-gaps:13/18,导入任务、导出任务的 Worker 端到端验证与前端/运营通知待部署后完成
This commit is contained in:
邹进
2026-10-11 23:49:29 +09:00
parent 260fec7114
commit 25c081fe60
24 changed files with 553 additions and 42 deletions

View File

@@ -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),

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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:"触发时间"`

View File

@@ -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,

View File

@@ -21,11 +21,11 @@ const shopOwnerImportDoc = `仅超级管理员和平台账号可操作,代理
### 模板列序(首行表头必须完全一致)
` + "`店铺编码`" + `、` + "`操作类型`" + `、` + "`业务员登录账号`" + `、` + "`备注`" + `
` + "`店铺编码`" + `、` + "`操作类型`" + `、` + "`平台登录账号`" + `、` + "`备注`" + `
- 店铺编码:以店铺编号唯一定位店铺;不存在或已删除时该行失败
- 操作类型:取值仅 ` + "`换绑`" + ` 与 ` + "`清空`" + `;换绑必须填写业务员登录账号,清空不得填写
- 业务员登录账号:以登录账号唯一定位业务员;必须是启用平台用户
- 操作类型:取值仅 ` + "`换绑`" + ` 与 ` + "`清空`" + `;换绑必须填写平台登录账号,清空不得填写
- 平台登录账号:填写平台用户的登录手机号(11 位数字),不接受用户名;格式不正确、手机号不存在、匹配多个账号、不是平台用户、账号已禁用时该行失败并给出对应原因。旧表头「业务员登录账号」整批失败
- 备注:可选,填写时写入该行审计
### 编码要求

View File

@@ -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;卡必须已存在且未绑定)

View File

@@ -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,
})

View File

@@ -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),

View File

@@ -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]
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-29

View File

@@ -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
无。

View File

@@ -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 结构、第三方渠道调用,不改变预警判定与支付写入。

View File

@@ -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)不能为空”一条原因

View File

@@ -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** 该行分别返回“平台登录账号(手机号)不存在”“平台登录账号(手机号)匹配多个账号”“该手机号对应账号不是平台用户”或“该手机号对应账号已禁用”,并保留原负责人

View File

@@ -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` 为空数组

View File

@@ -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** 输出仍使用冻结的旧表头与旧列数,不插入阈值流量列

View File

@@ -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`。

View File

@@ -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 = "行格式错误"
)

View File

@@ -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 {