Files
junhong_cmp_fiber/internal/handler/app/client_order.go
huang 9bd55a1695 feat: 实现客户端核心业务接口(client-core-business-api)
新增客户端资产、钱包、订单、实名、设备管理等核心业务 Handler 与 DTO:
- 客户端资产信息查询、套餐列表、套餐历史、资产刷新
- 客户端钱包详情、流水、充值校验、充值订单、充值记录
- 客户端订单创建、列表、详情
- 客户端实名认证链接获取
- 客户端设备卡列表、重启、恢复出厂、WiFi配置、切卡
- 客户端订单服务(含微信/支付宝支付流程)
- 强充自动代购异步任务处理
- 数据库迁移 000084:充值记录增加自动代购状态字段
2026-03-19 13:28:04 +08:00

416 lines
12 KiB
Go

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
}
// 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,
}
}
// CreateOrder D1 创建订单。
// POST /api/c/v1/orders/create
func (h *ClientOrderHandler) CreateOrder(c *fiber.Ctx) error {
var req dto.ClientCreateOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
resp, err := h.clientOrderService.CreateOrder(c.UserContext(), customerID, &req)
if err != nil {
return err
}
return response.Success(c, resp)
}
// ListOrders D2 订单列表。
// GET /api/c/v1/orders
func (h *ClientOrderHandler) ListOrders(c *fiber.Ctx) error {
var req dto.ClientOrderListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
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 {
paymentStatus, ok := clientPaymentStatusToOrderStatus(*req.PaymentStatus)
if !ok {
return errors.New(errors.CodeInvalidParam)
}
query = query.Where("payment_status = ?", 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: orderStatusToClientPaymentStatus(order.PaymentStatus),
CreatedAt: formatClientOrderTime(order.CreatedAt),
PackageNames: packageNames,
})
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
// GetOrderDetail D3 订单详情。
// GET /api/c/v1/orders/:id
func (h *ClientOrderHandler) GetOrderDetail(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
orderID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || orderID == 0 {
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)
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: orderStatusToClientPaymentStatus(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 orderStatusToClientPaymentStatus(status int) int {
switch status {
case model.PaymentStatusPending:
return 0
case model.PaymentStatusPaid:
return 1
case model.PaymentStatusCancelled:
return 2
default:
return status
}
}
func clientPaymentStatusToOrderStatus(status int) (int, bool) {
switch status {
case 0:
return model.PaymentStatusPending, true
case 1:
return model.PaymentStatusPaid, true
case 2:
return model.PaymentStatusCancelled, true
default:
return 0, false
}
}
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
}