Files
邹进 b2fc74c021
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat: H5 沉默期延期微信支付改为微信内直接拉起(JSAPI)
- H5 下单带 app_type 且选微信、商户为微信直连时,复用套餐购买的 JSAPI 预下单,返回 pay_config
- 调起参数保存在支付单,延期订单详情可继续支付;查单、回调、超时关单、作废沿用原流程
- 后台生成二维码、支付宝、富友商户保持原方式
2026-11-07 21:57:22 +09:00

396 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package 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
}
item := items[0]
// 待支付且未过支付截止的订单返回支付内容,前端据此展示二维码 / 继续支付;列表不返回。
if order.Status == constants.SilenceOrderStatusPendingPayment && (order.ExpireAt == nil || time.Now().Before(*order.ExpireAt)) {
var payment model.Payment
err := q.db.WithContext(ctx).Select("payment_no", "qr_content").
Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
Order("id DESC").First(&payment).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
if err == nil {
packagesilenceapp.ApplyPaymentContent(item, payment.QRContent)
item.PaymentNo = payment.PaymentNo
}
}
return item, 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
}
// ListFirstPurchaseRecords 按资产查询各世代首次购买套餐记录(叠加资产数据范围)。
func (q *Query) ListFirstPurchaseRecords(ctx context.Context, assetType string, assetID uint) ([]dto.AssetFirstPurchaseRecordResponse, error) {
if assetType == "card" {
assetType = constants.FirstPurchaseAssetTypeIotCard
}
table := "tb_iot_card"
if assetType == constants.FirstPurchaseAssetTypeDevice {
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.AssetFirstPurchaseRecord
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.AssetFirstPurchaseRecordResponse, 0, len(records))
for _, record := range records {
items = append(items, dto.AssetFirstPurchaseRecordResponse{
ID: record.ID, AssetType: record.AssetType, AssetID: record.AssetID, AssetIdentifier: record.AssetIdentifierSnapshot,
Generation: record.Generation, FirstPurchaseAt: record.FirstPurchaseAt, Source: record.Source, SourceName: firstPurchaseSourceName(record.Source),
OrderNo: record.OrderNo, EndedAt: record.EndedAt, EndReason: record.EndReason, CreatedAt: record.CreatedAt,
})
}
return items, nil
}
func firstPurchaseSourceName(source string) string {
switch source {
case constants.FirstPurchaseSourceOrder:
return "首次购买套餐"
case constants.FirstPurchaseSourceBackfill:
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
}