feat: 业务逻辑补全 — 佣金待审记录、C端订单重构、支付抽象、富友支付、卡设备状态联动
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

F-1: 佣金链断裂时创建 status=99 零额待审记录,新增修正接口 POST /commission-records/:id/resolve
F-4: C端订单查询从 Handler 迁移至 Service 层,移除 SkipPermissionCtx
J-1: 富友支付 JSAPI/MiniApp 预下单实现,回调补全签名验证
J-2: 平台钱包代购支持(buyerType 为空时使用资产所属代理钱包)
J-3: 套餐激活后自动更新卡/设备 status=3,最后套餐过期后更新 status=4
支付抽象: 引入 PaymentProvider 接口 + 微信/富友适配器,CreateOrder 支持多支付渠道
修复: 设备/IoT卡响应 DTO 移除 omitempty,空值字段返回 null 而非省略
This commit is contained in:
2026-03-28 16:57:39 +08:00
parent 65e461eff7
commit 623a622298
26 changed files with 970 additions and 447 deletions

View File

@@ -186,7 +186,7 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
}
// 调用服务层获取设备实时 Gateway 数据per D-11
// Gateway 失败时 DeviceRealtime 为 null不阻断主流程per D-06
// Gateway 失败时 DeviceRealtime 仅含 gateway_msg不阻断主流程per D-06
if resp.AssetType == "device" {
realtimeResp, realtimeErr := h.assetService.GetRealtimeStatus(
c.UserContext(), "device", resp.AssetID,
@@ -669,5 +669,6 @@ func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealt
info.DeviceType = g.DeviceType
info.Imei = g.IMEI
info.Imsi = g.IMSI
info.GatewayMsg = g.GatewayMsg
return info
}

View File

@@ -1,58 +1,33 @@
package app
import (
"context"
"strconv"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
clientorder "github.com/break/junhong_cmp_fiber/internal/service/client_order"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ClientOrderHandler C 端订单处理器
// 提供 D1~D3 下单、列表、详情接口。
type ClientOrderHandler struct {
clientOrderService *clientorder.Service
assetService *asset.Service
orderStore *postgres.OrderStore
personalDeviceStore *postgres.PersonalCustomerDeviceStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
logger *zap.Logger
db *gorm.DB
clientOrderService *clientorder.Service
logger *zap.Logger
}
// NewClientOrderHandler 创建 C 端订单处理器。
func NewClientOrderHandler(
clientOrderService *clientorder.Service,
assetService *asset.Service,
orderStore *postgres.OrderStore,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
logger *zap.Logger,
db *gorm.DB,
) *ClientOrderHandler {
return &ClientOrderHandler{
clientOrderService: clientOrderService,
assetService: assetService,
orderStore: orderStore,
personalDeviceStore: personalDeviceStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
logger: logger,
db: db,
clientOrderService: clientOrderService,
logger: logger,
}
}
@@ -95,73 +70,16 @@ func (h *ClientOrderHandler) ListOrders(c *fiber.Ctx) error {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
list, total, err := h.clientOrderService.ListOrders(c.UserContext(), customerID, &req)
if err != nil {
return err
}
query := h.db.WithContext(resolved.SkipPermissionCtx).
Model(&model.Order{}).
Where("generation = ?", resolved.Generation)
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, resolved.Asset.AssetID)
} else {
query = query.Where("order_type = ? AND iot_card_id = ?", model.OrderTypeSingleCard, resolved.Asset.AssetID)
}
if req.PaymentStatus != nil {
query = query.Where("payment_status = ?", *req.PaymentStatus)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单总数失败")
}
var orders []*model.Order
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单列表失败")
}
orderIDs := make([]uint, 0, len(orders))
for _, order := range orders {
if order == nil {
continue
}
orderIDs = append(orderIDs, order.ID)
}
itemMap, err := h.loadOrderItemMap(resolved.SkipPermissionCtx, orderIDs)
if err != nil {
return err
}
list := make([]dto.ClientOrderListItem, 0, len(orders))
for _, order := range orders {
if order == nil {
continue
}
packageNames := make([]string, 0, len(itemMap[order.ID]))
for _, item := range itemMap[order.ID] {
if item == nil || item.PackageName == "" {
continue
}
packageNames = append(packageNames, item.PackageName)
}
list = append(list, dto.ClientOrderListItem{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
CreatedAt: formatClientOrderTime(order.CreatedAt),
PackageNames: packageNames,
})
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
@@ -178,208 +96,10 @@ func (h *ClientOrderHandler) GetOrderDetail(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam)
}
order, items, err := h.orderStore.GetByIDWithItems(c.UserContext(), uint(orderID))
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败")
}
virtualNo, err := h.getOrderVirtualNo(c.UserContext(), order)
resp, err := h.clientOrderService.GetOrderDetail(c.UserContext(), customerID, uint(orderID))
if err != nil {
return err
}
owned, ownErr := h.isCustomerOwnAsset(c.UserContext(), customerID, virtualNo)
if ownErr != nil {
return errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
if !owned {
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
packages := make([]dto.ClientOrderPackageItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
packages = append(packages, dto.ClientOrderPackageItem{
PackageID: item.PackageID,
PackageName: item.PackageName,
Price: item.UnitPrice,
Quantity: item.Quantity,
})
}
resp := &dto.ClientOrderDetailResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
PaymentMethod: order.PaymentMethod,
CreatedAt: formatClientOrderTime(order.CreatedAt),
PaidAt: formatClientOrderTimePtr(order.PaidAt),
CompletedAt: nil,
Packages: packages,
}
return response.Success(c, resp)
}
func (h *ClientOrderHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
identifier = strings.TrimSpace(identifier)
if identifier == "" {
identifier = strings.TrimSpace(c.Query("identifier"))
}
if identifier == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
if err != nil {
return nil, err
}
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
if ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
if genErr != nil {
return nil, genErr
}
return &resolvedAssetContext{
CustomerID: customerID,
Identifier: identifier,
Asset: assetInfo,
Generation: generation,
SkipPermissionCtx: skipPermissionCtx,
}, nil
}
func (h *ClientOrderHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return false, err
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return true, nil
}
}
return false, nil
}
func (h *ClientOrderHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":
card, err := h.iotCardStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.Generation, nil
case constants.ResourceTypeDevice:
device, err := h.deviceStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.Generation, nil
default:
return 0, errors.New(errors.CodeInvalidParam)
}
}
func (h *ClientOrderHandler) loadOrderItemMap(ctx context.Context, orderIDs []uint) (map[uint][]*model.OrderItem, error) {
result := make(map[uint][]*model.OrderItem)
if len(orderIDs) == 0 {
return result, nil
}
var items []*model.OrderItem
if err := h.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Order("id ASC").Find(&items).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
}
for _, item := range items {
if item == nil {
continue
}
result[item.OrderID] = append(result[item.OrderID], item)
}
return result, nil
}
func (h *ClientOrderHandler) getOrderVirtualNo(ctx context.Context, order *model.Order) (string, error) {
if order == nil {
return "", errors.New(errors.CodeNotFound, "订单不存在")
}
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil || *order.IotCardID == 0 {
return "", errors.New(errors.CodeInvalidParam)
}
card, err := h.iotCardStore.GetByID(ctx, *order.IotCardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.VirtualNo, nil
case model.OrderTypeDevice:
if order.DeviceID == nil || *order.DeviceID == 0 {
return "", errors.New(errors.CodeInvalidParam)
}
device, err := h.deviceStore.GetByID(ctx, *order.DeviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.VirtualNo, nil
default:
return "", errors.New(errors.CodeInvalidParam)
}
}
func formatClientOrderTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func formatClientOrderTimePtr(t *time.Time) *string {
if t == nil || t.IsZero() {
return nil
}
formatted := formatClientOrderTime(*t)
return &formatted
}