feat: 实名即生效套餐沉默期、延期办理与资产出库时间
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s

- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-27 16:32:58 +09:00
parent d2b0d2f534
commit 909f4d0ec2
112 changed files with 10473 additions and 82 deletions

View File

@@ -950,8 +950,8 @@ func (q *Query) loadPaymentFinance(ctx context.Context, filter FinanceFilter, re
appendTypedOrderCondition(&conditions, &args, model.PaymentOrderTypeAgentRecharge, refs.agentRecharges)
appendTypedOrderCondition(&conditions, &args, model.PaymentOrderTypeRecharge, refs.rechargeOrders)
if filter.ShopID != 0 {
conditions = append(conditions, `(order_type = ? AND EXISTS (SELECT 1 FROM tb_order o WHERE o.id = tb_payment.order_id AND o.deleted_at IS NULL AND ((o.buyer_type = ? AND o.buyer_id = ?) OR o.seller_shop_id = ?))) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_payment.order_id AND ar.deleted_at IS NULL AND ar.shop_id = ?)) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_recharge_order ro WHERE ro.id = tb_payment.order_id AND ro.deleted_at IS NULL AND ro.shop_id_tag = ?))`)
args = append(args, model.PaymentOrderTypePackage, model.BuyerTypeAgent, filter.ShopID, filter.ShopID, model.PaymentOrderTypeAgentRecharge, filter.ShopID, model.PaymentOrderTypeRecharge, filter.ShopID)
conditions = append(conditions, `(order_type = ? AND EXISTS (SELECT 1 FROM tb_order o WHERE o.id = tb_payment.order_id AND o.deleted_at IS NULL AND ((o.buyer_type = ? AND o.buyer_id = ?) OR o.seller_shop_id = ?))) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_payment.order_id AND ar.deleted_at IS NULL AND ar.shop_id = ?)) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_recharge_order ro WHERE ro.id = tb_payment.order_id AND ro.deleted_at IS NULL AND ro.shop_id_tag = ?)) OR (order_type = ? AND EXISTS (SELECT 1 FROM tb_package_silence_extension_order so WHERE so.id = tb_payment.order_id AND so.shop_id_tag = ?))`)
args = append(args, model.PaymentOrderTypePackage, model.BuyerTypeAgent, filter.ShopID, filter.ShopID, model.PaymentOrderTypeAgentRecharge, filter.ShopID, model.PaymentOrderTypeRecharge, filter.ShopID, constants.PaymentOrderTypeSilenceExtension, filter.ShopID)
}
query = applyFinanceRelationship(query, filter, conditions, args)
rows, total, err := loadFinanceRows[model.Payment](query, "updated_at DESC, id DESC", limit, "支付资金事实")
@@ -1377,6 +1377,9 @@ func paymentBusinessRefs(row model.Payment) []InvestigationResourceRef {
resourceType = constants.AuditResourceAgentRecharge
case model.PaymentOrderTypeRecharge:
resourceType = constants.AuditResourceRechargeOrder
case constants.PaymentOrderTypeSilenceExtension:
// 沉默期延期订单:延期费单独记账,不分佣、不计入代理业绩
resourceType = constants.AuditResourceSilenceExtensionOrder
}
if resourceType == "" {
return nil

View File

@@ -0,0 +1,382 @@
// Package packagesilence 提供沉默期延期订单、批次、即将过期 / 已过期套餐与出库记录的只读查询。
package packagesilence
import (
"context"
"strings"
"time"
"gorm.io/gorm"
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// Query 是沉默期只读查询。
type Query struct {
db *gorm.DB
now func() time.Time
}
// NewQuery 创建沉默期只读查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db, now: time.Now}
}
// OrderFilter 是延期订单筛选(后台列表、导出与 H5 我的订单共用)。
type OrderFilter struct {
Request dto.SilenceOrderListRequest
CustomerID uint // H5:只看自己的订单
}
// OrderQuery 构造延期订单查询(叠加店铺数据范围)。
func (q *Query) OrderQuery(ctx context.Context, filter OrderFilter) (*gorm.DB, error) {
req := filter.Request
query := q.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).Table("tb_package_silence_extension_order AS o")
if filter.CustomerID > 0 {
query = query.Where("o.customer_id = ?", filter.CustomerID)
} else {
query = applyStrictShop(ctx, query, "o.shop_id")
}
if req.PackageUsageID != nil {
query = query.Where("o.package_usage_id = ?", *req.PackageUsageID)
}
if req.PackageID != nil {
query = query.Where("o.package_id = ?", *req.PackageID)
}
if value := strings.TrimSpace(req.AssetIdentifier); value != "" {
query = query.Where("o.asset_identifier_snapshot = ?", value)
}
if value := strings.TrimSpace(req.OrderNo); value != "" {
query = query.Where("o.order_no = ?", value)
}
if req.Status != "" {
query = query.Where("o.status = ?", req.Status)
}
if req.Source != "" {
query = query.Where("o.source = ?", req.Source)
}
if req.PaymentMethod != "" {
query = query.Where("o.payment_method = ?", req.PaymentMethod)
}
if value := strings.TrimSpace(req.MerchantName); value != "" {
query = query.Where("EXISTS (SELECT 1 FROM tb_payment p WHERE p.id = o.paid_payment_id AND p.merchant_name_snapshot LIKE ?)", "%"+value+"%")
}
if value := strings.TrimSpace(req.ThirdPartyTradeNo); value != "" {
query = query.Where("(o.offline_trade_no = ? OR EXISTS (SELECT 1 FROM tb_payment p WHERE p.id = o.paid_payment_id AND p.third_party_trade_no = ?))", value, value)
}
if req.PaidStart != "" {
start, err := time.Parse(time.RFC3339, req.PaidStart)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "支付时间起格式应为 RFC3339")
}
query = query.Where("o.paid_at >= ?", start)
}
if req.PaidEnd != "" {
end, err := time.Parse(time.RFC3339, req.PaidEnd)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "支付时间止格式应为 RFC3339")
}
query = query.Where("o.paid_at <= ?", end)
}
return query, nil
}
// ListOrders 分页查询延期订单;支付方式、收款商户、交易流水号取已支付或已退款支付单快照。
func (q *Query) ListOrders(ctx context.Context, filter OrderFilter) (*dto.SilenceOrderPageResult, error) {
page, pageSize := normalizePage(filter.Request.Page, filter.Request.PageSize)
query, err := q.OrderQuery(ctx, filter)
if err != nil {
return nil, err
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计延期订单失败")
}
var orders []model.PackageSilenceExtensionOrder
if err := query.Select("o.*").Order("o.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&orders).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
items, err := q.OrderItems(ctx, orders)
if err != nil {
return nil, err
}
return &dto.SilenceOrderPageResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// GetOrder 查询延期订单详情(叠加数据范围或客户归属)。
func (q *Query) GetOrder(ctx context.Context, id uint, customerID uint) (*dto.SilenceExtensionOrderResponse, error) {
query, err := q.OrderQuery(ctx, OrderFilter{CustomerID: customerID})
if err != nil {
return nil, err
}
var order model.PackageSilenceExtensionOrder
if err := query.Select("o.*").Where("o.id = ?", id).First(&order).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
items, err := q.OrderItems(ctx, []model.PackageSilenceExtensionOrder{order})
if err != nil {
return nil, err
}
return items[0], nil
}
// OrderItems 批量补齐延期订单的支付单快照与批次号(列表、详情与导出共用)。
func (q *Query) OrderItems(ctx context.Context, orders []model.PackageSilenceExtensionOrder) ([]*dto.SilenceExtensionOrderResponse, error) {
paymentIDs := make([]uint, 0, len(orders))
batchIDs := make([]uint, 0, len(orders))
for index := range orders {
if orders[index].PaidPaymentID != nil {
paymentIDs = append(paymentIDs, *orders[index].PaidPaymentID)
}
batchIDs = append(batchIDs, orders[index].BatchID)
}
payments := map[uint]model.Payment{}
if len(paymentIDs) > 0 {
var rows []model.Payment
if err := q.db.WithContext(ctx).Where("id IN ?", paymentIDs).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
for _, row := range rows {
payments[row.ID] = row
}
}
batchNos := map[uint]string{}
if len(batchIDs) > 0 {
var rows []model.PackageSilenceExtensionBatch
if err := q.db.WithContext(ctx).Select("id", "batch_no").Where("id IN ?", batchIDs).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
for _, row := range rows {
batchNos[row.ID] = row.BatchNo
}
}
items := make([]*dto.SilenceExtensionOrderResponse, 0, len(orders))
for index := range orders {
order := &orders[index]
item := packagesilenceapp.OrderDTO(order)
item.BatchNo = batchNos[order.BatchID]
if order.PaidPaymentID != nil {
if payment, ok := payments[*order.PaidPaymentID]; ok {
item.PaymentMerchantName = payment.MerchantNameSnapshot
item.ThirdPartyTradeNo = payment.ThirdPartyTradeNo
item.PaymentNo = payment.PaymentNo
}
}
items = append(items, item)
}
return items, nil
}
// ListBatches 分页查询延期批次(平台与有数据范围的账号按批次店铺标签过滤)。
func (q *Query) ListBatches(ctx context.Context, req dto.SilenceBatchListRequest) (*dto.SilenceBatchPageResult, error) {
page, pageSize := normalizePage(req.Page, req.PageSize)
query := applyStrictShop(ctx, q.db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).Table("tb_package_silence_extension_batch AS b"), "b.shop_id_tag")
if value := strings.TrimSpace(req.BatchNo); value != "" {
query = query.Where("b.batch_no = ?", value)
}
if req.Path != "" {
query = query.Where("b.path = ?", req.Path)
}
if req.ExtensionType != "" {
query = query.Where("b.extension_type = ?", req.ExtensionType)
}
if req.Status != "" {
query = query.Where("b.status = ?", req.Status)
}
if req.HasException != nil && *req.HasException {
query = query.Where("EXISTS (SELECT 1 FROM tb_package_silence_extension e WHERE e.batch_id = b.id AND e.status = ?)", constants.SilenceExtensionStatusException)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计延期批次失败")
}
var batches []model.PackageSilenceExtensionBatch
if err := query.Select("b.*").Order("b.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&batches).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
items := make([]*dto.SilenceBatchResponse, 0, len(batches))
for index := range batches {
items = append(items, packagesilenceapp.BatchDTO(&batches[index]))
}
return &dto.SilenceBatchPageResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// usageRow 是即将过期 / 已过期套餐查询行。
type usageRow struct {
model.PackageUsage
CardICCID string `gorm:"column:card_iccid"`
DeviceNo string `gorm:"column:device_no"`
AssetShopID *uint `gorm:"column:asset_shop_id"`
AssetShopNam string `gorm:"column:asset_shop_name"`
}
// UsageQuery 构造即将过期 / 已过期套餐查询(叠加资产店铺数据范围)。
func (q *Query) UsageQuery(ctx context.Context, req dto.SilenceUsageListRequest) (*gorm.DB, error) {
now := q.now()
query := q.db.WithContext(ctx).Table("tb_package_usage AS u").
Joins("LEFT JOIN tb_iot_card AS c ON c.id = u.iot_card_id AND COALESCE(u.device_id, 0) = 0").
Joins("LEFT JOIN tb_device AS d ON d.id = u.device_id AND COALESCE(u.device_id, 0) > 0").
Joins("LEFT JOIN tb_shop AS s ON s.id = COALESCE(d.shop_id, c.shop_id)").
Where("u.deleted_at IS NULL AND u.status = ? AND u.master_usage_id IS NULL", constants.PackageUsageStatusPending)
switch req.View {
case "expired":
query = query.Where("(u.silence_status = ? OR (u.silence_status = ? AND u.silence_deadline_at < ? AND u.silence_adjust_pending = FALSE))",
constants.SilenceStatusExpired, constants.SilenceStatusWaiting, now)
case "all":
query = query.Where("u.silence_status IN ?", []string{constants.SilenceStatusWaiting, constants.SilenceStatusExpired})
default:
within := req.WithinDays
if within <= 0 {
within = constants.SilenceExpiringSoonDaysDefault
}
query = query.Where("u.silence_status = ? AND u.silence_deadline_at >= ? AND u.silence_deadline_at < ?",
constants.SilenceStatusWaiting, now, now.Add(time.Duration(within)*24*time.Hour))
}
if value := strings.TrimSpace(req.AssetIdentifier); value != "" {
query = query.Where("(c.iccid = ? OR c.virtual_no = ? OR d.virtual_no = ? OR d.imei = ?)", value, value, value, value)
}
if req.PackageID != nil {
query = query.Where("u.package_id = ?", *req.PackageID)
}
if req.ShopID != nil {
query = query.Where("COALESCE(d.shop_id, c.shop_id) = ?", *req.ShopID)
}
if req.DeadlineStart != "" {
start, err := time.Parse(time.RFC3339, req.DeadlineStart)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "截止时间起格式应为 RFC3339")
}
query = query.Where("u.silence_deadline_at >= ?", start)
}
if req.DeadlineEnd != "" {
end, err := time.Parse(time.RFC3339, req.DeadlineEnd)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "截止时间止格式应为 RFC3339")
}
query = query.Where("u.silence_deadline_at <= ?", end)
}
return applyStrictShop(ctx, query, "COALESCE(d.shop_id, c.shop_id)"), nil
}
// ListUsages 分页查询即将过期 / 已过期套餐。
func (q *Query) ListUsages(ctx context.Context, req dto.SilenceUsageListRequest) (*dto.SilenceUsagePageResult, error) {
page, pageSize := normalizePage(req.Page, req.PageSize)
query, err := q.UsageQuery(ctx, req)
if err != nil {
return nil, err
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计沉默期套餐失败")
}
rows, err := q.FetchUsages(ctx, query, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
return &dto.SilenceUsagePageResult{Items: rows, Total: total, Page: page, PageSize: pageSize}, nil
}
// FetchUsages 按偏移读取即将过期 / 已过期套餐(列表与导出共用)。
func (q *Query) FetchUsages(ctx context.Context, query *gorm.DB, offset, limit int) ([]*dto.SilenceUsageItem, error) {
var rows []usageRow
if err := query.Select("u.*, c.iccid AS card_iccid, COALESCE(NULLIF(d.virtual_no, ''), d.imei) AS device_no, COALESCE(d.shop_id, c.shop_id) AS asset_shop_id, COALESCE(s.shop_name, '') AS asset_shop_name").
Order("u.silence_deadline_at ASC, u.id ASC").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期套餐失败")
}
now := q.now()
items := make([]*dto.SilenceUsageItem, 0, len(rows))
for index := range rows {
row := &rows[index]
assetType, assetID, identifier := constants.SilenceAssetTypeIotCard, row.IotCardID, row.CardICCID
if row.DeviceID > 0 {
assetType, assetID, identifier = constants.SilenceAssetTypeDevice, row.DeviceID, row.DeviceNo
}
items = append(items, &dto.SilenceUsageItem{
PackageUsageID: row.ID, OrderNo: row.OrderNo, PackageID: row.PackageID, PackageName: row.PackageName,
AssetType: assetType, AssetID: assetID, AssetIdentifier: identifier, ShopID: row.AssetShopID,
ShopName: row.AssetShopNam, CreatedAt: row.CreatedAt, Silence: packagesilenceapp.BuildView(&row.PackageUsage, now),
})
}
return items, nil
}
// ListOutboundRecords 按资产查询各世代出库记录(叠加资产数据范围)。
func (q *Query) ListOutboundRecords(ctx context.Context, assetType string, assetID uint) ([]dto.AssetOutboundRecordResponse, error) {
if assetType == "card" {
assetType = constants.OutboundAssetTypeIotCard
}
table := "tb_iot_card"
if assetType == constants.OutboundAssetTypeDevice {
table = "tb_device"
}
var count int64
if err := middleware.ApplyStrictShopFilter(ctx, q.db.WithContext(ctx).Table(table).Where("id = ? AND deleted_at IS NULL", assetID)).Count(&count).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败")
}
if count == 0 {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
var records []model.AssetOutboundRecord
if err := q.db.WithContext(ctx).Where("asset_type = ? AND asset_id = ?", assetType, assetID).Order("generation DESC").Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询出库记录失败")
}
items := make([]dto.AssetOutboundRecordResponse, 0, len(records))
for _, record := range records {
items = append(items, dto.AssetOutboundRecordResponse{
ID: record.ID, AssetType: record.AssetType, AssetID: record.AssetID, AssetIdentifier: record.AssetIdentifierSnapshot,
Generation: record.Generation, OutboundAt: record.OutboundAt, Source: record.Source, SourceName: outboundSourceName(record.Source),
OrderNo: record.OrderNo, ExchangeNo: record.ExchangeNo, EndedAt: record.EndedAt, EndReason: record.EndReason, CreatedAt: record.CreatedAt,
})
}
return items, nil
}
func outboundSourceName(source string) string {
switch source {
case constants.OutboundSourceOrder:
return "首次购买套餐"
case constants.OutboundSourceExchange:
return "换货新资产"
case constants.OutboundSourceBackfill:
return "历史回填"
default:
return source
}
}
// applyStrictShop 按严格店铺数据范围过滤:超管与平台不限;代理只看自己及下级店铺;其他账号无数据。
func applyStrictShop(ctx context.Context, query *gorm.DB, column string) *gorm.DB {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return query
case constants.UserTypeAgent:
if shopIDs := middleware.GetSubordinateShopIDs(ctx); len(shopIDs) > 0 {
return query.Where(column+" IN ?", shopIDs)
}
if shopID := middleware.GetShopIDFromContext(ctx); shopID > 0 {
return query.Where(column+" = ?", shopID)
}
}
return query.Where("1 = 0")
}
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}