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

@@ -37,6 +37,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
svc.WechatConfig,
packageSeriesStore,
shopSeriesAllocationStore,
iotCardStore,
deviceStore,
deps.DB,
deps.Redis,
deps.Logger,
)
@@ -50,7 +53,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, assetRechargeStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, svc.Asset, orderStore, personalCustomerDeviceStore, iotCardStore, deviceStore, deps.Logger, deps.DB),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
@@ -80,7 +83,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, validate),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, deps.Logger),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),

View File

@@ -127,7 +127,7 @@ func initServices(s *stores, deps *Dependencies) *services {
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord, deps.DB, deps.Logger),
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
CommissionCalculation: commissionCalculationSvc.New(

View File

@@ -70,3 +70,23 @@ func (h *ShopCommissionHandler) ListCommissionRecords(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// ResolveCommissionRecord 修正待审佣金记录
// POST /api/admin/commission-records/:id/resolve
func (h *ShopCommissionHandler) ResolveCommissionRecord(c *fiber.Ctx) error {
recordID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || recordID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的佣金记录ID")
}
var req dto.CommissionRecordResolveRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.service.ResolveCommissionRecord(c.UserContext(), uint(recordID), &req); err != nil {
return err
}
return response.Success(c, nil)
}

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
}

View File

@@ -2,14 +2,13 @@ package callback
import (
"context"
"encoding/xml"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
@@ -26,11 +25,18 @@ type AgentRechargeServiceInterface interface {
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error
}
// WechatConfigServiceInterface 支付配置服务接口
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
}
type PaymentHandler struct {
orderService *orderService.Service
rechargeService *rechargeService.Service
agentRechargeService AgentRechargeServiceInterface
wechatPayment wechat.PaymentServiceInterface
wechatConfigService WechatConfigServiceInterface
logger *zap.Logger
}
func NewPaymentHandler(
@@ -38,12 +44,16 @@ func NewPaymentHandler(
rechargeService *rechargeService.Service,
agentRechargeService AgentRechargeServiceInterface,
wechatPayment wechat.PaymentServiceInterface,
wechatConfigService WechatConfigServiceInterface,
logger *zap.Logger,
) *PaymentHandler {
return &PaymentHandler{
orderService: orderService,
rechargeService: rechargeService,
agentRechargeService: agentRechargeService,
wechatPayment: wechatPayment,
wechatConfigService: wechatConfigService,
logger: logger,
}
}
@@ -130,7 +140,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return c.SendString("success")
}
// FuiouPayCallback 富友支付回调
// FuiouPayCallback 富友支付回调(带签名验证)
// POST /api/callback/fuiou-pay
func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
body := c.Body()
@@ -139,63 +149,60 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
}
ctx := c.UserContext()
c.Set("Content-Type", "text/xml; charset=gbk")
// TODO: 按 payment_config_id 加载配置创建 fuiou.Client 验签
// 当前留桩:解析但不验签
// 解析 req= 参数
formValue := string(body)
if strings.HasPrefix(formValue, "req=") {
formValue = formValue[4:]
activeConfig, err := h.wechatConfigService.GetActiveConfig(ctx)
if err != nil || activeConfig == nil {
h.logger.Error("富友回调:加载支付配置失败", zap.Error(err))
return c.Send(fuiou.BuildNotifyFailResponse("payment config unavailable"))
}
decoded, err := url.QueryUnescape(formValue)
client, err := fuiou.NewClient(
activeConfig.FyInsCd,
activeConfig.FyMchntCd,
activeConfig.FyTermID,
activeConfig.FyAPIURL,
activeConfig.FyNotifyURL,
activeConfig.FyPrivateKey,
activeConfig.FyPublicKey,
h.logger,
)
if err != nil {
return errors.New(errors.CodeFuiouCallbackInvalid, "回调数据解码失败")
h.logger.Error("富友回调:构造客户端失败", zap.Error(err))
return c.Send(fuiou.BuildNotifyFailResponse("client init failed"))
}
utf8Data, err := fuiou.GBKToUTF8([]byte(decoded))
notify, err := client.VerifyNotify(body)
if err != nil {
return errors.New(errors.CodeFuiouCallbackInvalid, "GBK 转 UTF-8 失败")
if notify != nil {
h.logger.Warn("富友回调:非成功结果",
zap.String("result_code", notify.ResultCode),
zap.String("result_msg", notify.ResultMsg))
return c.Send(fuiou.BuildNotifySuccessResponse())
}
h.logger.Error("富友回调:验签或解析失败", zap.Error(err))
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
xmlStr := strings.Replace(string(utf8Data), `encoding="GBK"`, `encoding="UTF-8"`, 1)
var notify fuiou.NotifyRequest
if err := xml.Unmarshal([]byte(xmlStr), &notify); err != nil {
return errors.New(errors.CodeFuiouCallbackInvalid, "解析回调 XML 失败")
}
if notify.ResultCode != "000000" {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifySuccessResponse())
}
// 按订单号前缀分发
orderNo := notify.MchntOrderNo
switch {
case strings.HasPrefix(orderNo, "ORD"):
if err := h.orderService.HandlePaymentCallback(ctx, orderNo, "fuiou"); err != nil {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
case strings.HasPrefix(orderNo, constants.AssetRechargeOrderPrefix):
if err := h.rechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
}
default:
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse("unknown order prefix"))
}
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifySuccessResponse())
}

View File

@@ -80,7 +80,7 @@ type AssetRealtimeStatusResponse struct {
LastSyncTime *time.Time `json:"last_sync_time,omitempty" description:"最后同步时间asset_type=card时有效"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态asset_type=device时有效none/stop/start"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表asset_type=device时有效"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime,omitempty" description:"设备实时状态(来自 Gateway sync-infoGateway 失败时为 null"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info始终返回,失败时仅含 gateway_msg"`
}
// AssetPackageResponse 资产套餐信息
@@ -144,8 +144,11 @@ type CardICCIDRequest struct {
// DeviceGatewayInfo B端设备实时状态信息来自 Gateway sync-info
// 与 C 端 DeviceRealtimeInfo 独立,未来可展示更多技术字段
// 所有字段均为可选Gateway 调用失败时整体为 null
// 所有字段均为可选Gateway 调用失败时仅填充 GatewayMsg
type DeviceGatewayInfo struct {
// Gateway 上游消息(成功时为 null失败时为错误信息
GatewayMsg *string `json:"gateway_msg" description:"Gateway 上游消息(成功时为 null失败时为错误信息"`
// 设备状态
OnlineStatus *int `json:"online_status,omitempty" description:"在线状态1=在线2=离线"`
BatteryLevel *int `json:"battery_level,omitempty" description:"电池电量百分比无电池时为null"`

View File

@@ -58,14 +58,16 @@ type AssetInfoResponse struct {
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号"`
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称"`
// === 设备实时状态(来自 Gateway同步接口对接后自动填充,当前返回 null ===
DeviceRealtime *DeviceRealtimeInfo `json:"device_realtime,omitempty" description:"设备实时状态Gateway 同步接口对接后填充,当前为 null"`
// === 设备实时状态(来自 Gateway始终返回,失败时仅含 gateway_msg ===
DeviceRealtime *DeviceRealtimeInfo `json:"device_realtime" description:"设备实时状态(始终返回,Gateway 失败时仅含 gateway_msg"`
}
// DeviceRealtimeInfo 设备实时状态信息
// 全量映射 Gateway DeviceInfoDetail 结构,所有字段均为可选
// 当前 Gateway 同步接口尚未对接,预留结构待后续填充
type DeviceRealtimeInfo struct {
GatewayMsg *string `json:"gateway_msg" description:"Gateway 上游消息(成功时为 null失败时为错误信息"`
// === 设备状态 ===
OnlineStatus *int64 `json:"online_status,omitempty" description:"在线状态1:在线, 2:离线)"`
BatteryLevel *int64 `json:"battery_level,omitempty" description:"电池电量百分比"`

View File

@@ -14,7 +14,7 @@ type CommissionRecordResponse struct {
CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金)"`
Amount int64 `json:"amount" description:"佣金金额(分)"`
BalanceAfter int64 `json:"balance_after" description:"入账后钱包余额(分)"`
Status int `json:"status" description:"状态 (1:已入账, 2:已失效)"`
Status int `json:"status" description:"状态 (1:已入账, 2:已失效, 99:待人工修正)"`
ReleasedAt string `json:"released_at" description:"入账时间"`
Remark string `json:"remark" description:"备注"`
CreatedAt string `json:"created_at" description:"创建时间"`
@@ -28,7 +28,7 @@ type CommissionRecordListRequest struct {
CommissionSource *string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金)"`
StartTime *string `json:"start_time" query:"start_time" validate:"omitempty" description:"开始时间"`
EndTime *string `json:"end_time" query:"end_time" validate:"omitempty" description:"结束时间"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:已入账, 2:已失效)"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2 99" description:"状态 (1:已入账, 2:已失效, 99:待人工修正)"`
}
// CommissionRecordPageResult 佣金记录分页结果
@@ -73,3 +73,10 @@ type DailyCommissionStatsRequest struct {
EndDate *string `json:"end_date" query:"end_date" validate:"omitempty" description:"结束日期YYYY-MM-DD"`
Days *int `json:"days" query:"days" validate:"omitempty,min=1,max=365" minimum:"1" maximum:"365" description:"查询天数默认30天"`
}
// CommissionRecordResolveRequest 佣金记录修正请求
type CommissionRecordResolveRequest struct {
Action string `json:"action" validate:"required,oneof=release invalidate" required:"true" description:"操作 (release:入账, invalidate:作废)"`
Amount *int64 `json:"amount" validate:"omitempty,min=1" minimum:"1" description:"佣金金额release 时必填"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"处理备注"`
}

View File

@@ -28,24 +28,24 @@ type DeviceResponse struct {
MaxSimSlots int `json:"max_sim_slots" description:"最大插槽数"`
Manufacturer string `json:"manufacturer" description:"制造商"`
BatchNo string `json:"batch_no" description:"批次号"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
ShopID *uint `json:"shop_id" description:"店铺ID"`
ShopName string `json:"shop_name" description:"店铺名称"`
Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
StatusName string `json:"status_name" description:"状态名称"`
BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"`
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
SeriesName string `json:"series_name" description:"套餐系列名称"`
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ActivatedAt *time.Time `json:"activated_at" description:"激活时间"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
// 设备实时同步字段(由 sync-info 接口写入)
OnlineStatus int `json:"online_status" description:"在线状态0未知 1在线 2离线"`
LastOnlineTime *time.Time `json:"last_online_time,omitempty" description:"最后在线时间"`
LastOnlineTime *time.Time `json:"last_online_time" description:"最后在线时间"`
SoftwareVersion string `json:"software_version" description:"固件版本号"`
SwitchMode string `json:"switch_mode" description:"切卡模式0=自动1=手动"`
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间"`
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at" description:"最后 sync-info 同步时间"`
}
type ListDeviceResponse struct {
@@ -78,10 +78,10 @@ type DeviceCardBindingResponse struct {
SlotPosition int `json:"slot_position" description:"插槽位置 (1-4)"`
IotCardID uint `json:"iot_card_id" description:"IoT卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
MSISDN string `json:"msisdn,omitempty" description:"接入号"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
MSISDN string `json:"msisdn" description:"接入号"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
Status int `json:"status" description:"卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
BindTime *time.Time `json:"bind_time,omitempty" description:"绑定时间"`
BindTime *time.Time `json:"bind_time" description:"绑定时间"`
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
}

View File

@@ -23,31 +23,31 @@ type ListStandaloneIotCardRequest struct {
type StandaloneIotCardResponse struct {
ID uint `json:"id" description:"卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
VirtualNo string `json:"virtual_no,omitempty" description:"卡虚拟号(用于客服查找资产)"`
VirtualNo string `json:"virtual_no" description:"卡虚拟号(用于客服查找资产)"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID uint `json:"carrier_id" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
IMSI string `json:"imsi,omitempty" description:"IMSI"`
MSISDN string `json:"msisdn,omitempty" description:"卡接入号"`
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
Supplier string `json:"supplier,omitempty" description:"供应商"`
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
IMSI string `json:"imsi" description:"IMSI"`
MSISDN string `json:"msisdn" description:"卡接入号"`
BatchNo string `json:"batch_no" description:"批次号"`
Supplier string `json:"supplier" description:"供应商"`
Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ShopID *uint `json:"shop_id" description:"店铺ID"`
ShopName string `json:"shop_name" description:"店铺名称"`
ActivatedAt *time.Time `json:"activated_at" description:"激活时间"`
ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活)"`
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb" description:"本月已用流量(MB)"`
CurrentMonthStartDate *time.Time `json:"current_month_start_date,omitempty" description:"本月开始日期"`
CurrentMonthStartDate *time.Time `json:"current_month_start_date" description:"本月开始日期"`
LastMonthTotalMB float64 `json:"last_month_total_mb" description:"上月流量总量(MB)"`
LastDataCheckAt *time.Time `json:"last_data_check_at,omitempty" description:"最后流量检查时间"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at,omitempty" description:"最后实名检查时间"`
LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后流量检查时间"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后实名检查时间"`
EnablePolling bool `json:"enable_polling" description:"是否参与轮询"`
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
SeriesName string `json:"series_name" description:"套餐系列名称"`
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`

View File

@@ -19,6 +19,15 @@ type WechatPayJSAPIResponse struct {
PayConfig map[string]interface{} `json:"pay_config" description:"JSSDK支付配置"`
}
type FuiouPayJSAPIResponse struct {
AppID string `json:"app_id" description:"应用ID"`
TimeStamp string `json:"time_stamp" description:"时间戳"`
NonceStr string `json:"nonce_str" description:"随机字符串"`
Package string `json:"package" description:"预支付参数包"`
SignType string `json:"sign_type" description:"签名类型"`
PaySign string `json:"pay_sign" description:"支付签名"`
}
type WechatPayH5Request struct {
SceneInfo WechatH5SceneInfo `json:"scene_info" validate:"required" required:"true" description:"场景信息"`
}

View File

@@ -138,6 +138,13 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
zap.Error(err))
// 不返回错误,继续处理
}
if err := h.updateCarrierSuspendedStatus(ctx, tx, carrierType, carrierID); err != nil {
h.logger.Warn("更新载体停用状态失败",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
}
return nil
@@ -169,6 +176,91 @@ func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gor
return nil
}
func (h *PackageActivationHandler) updateCarrierSuspendedStatus(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
if carrierType == constants.AssetTypeIotCard {
return h.updateIotCardSuspendedStatus(ctx, tx, carrierID)
}
if carrierType == constants.AssetTypeDevice {
return h.updateDeviceSuspendedStatus(ctx, tx, carrierID)
}
return nil
}
func (h *PackageActivationHandler) updateIotCardSuspendedStatus(ctx context.Context, tx *gorm.DB, cardID uint) error {
var activeCount int64
if err := tx.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("iot_card_id = ?", cardID).
Where("status = ?", constants.PackageUsageStatusActive).
Count(&activeCount).Error; err != nil {
return err
}
if activeCount > 0 {
return nil
}
return tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id = ?", cardID).
Update("status", constants.IotCardStatusSuspended).Error
}
func (h *PackageActivationHandler) updateDeviceSuspendedStatus(ctx context.Context, tx *gorm.DB, deviceID uint) error {
var activeCount int64
if err := tx.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("device_id = ?", deviceID).
Where("status = ?", constants.PackageUsageStatusActive).
Count(&activeCount).Error; err != nil {
return err
}
if activeCount > 0 {
return nil
}
var cardIDs []uint
if err := tx.WithContext(ctx).
Model(&model.DeviceSimBinding{}).
Where("device_id = ?", deviceID).
Where("bind_status = ?", constants.BindStatusBound).
Pluck("iot_card_id", &cardIDs).Error; err != nil {
return err
}
if len(cardIDs) > 0 {
if err := tx.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("iot_card_id IN ?", cardIDs).
Where("status = ?", constants.PackageUsageStatusActive).
Count(&activeCount).Error; err != nil {
return err
}
if activeCount > 0 {
return nil
}
}
if err := tx.WithContext(ctx).
Model(&model.Device{}).
Where("id = ?", deviceID).
Update("status", constants.DeviceStatusSuspended).Error; err != nil {
return err
}
if len(cardIDs) > 0 {
if err := tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id IN ?", cardIDs).
Update("status", constants.IotCardStatusSuspended).Error; err != nil {
return err
}
}
return nil
}
// getCarrierInfo 获取载体信息
func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (string, uint) {
if pkg.IotCardID > 0 {

View File

@@ -101,4 +101,14 @@ func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommis
Output: new(dto.ShopCommissionRecordPageResult),
Auth: true,
})
commissionRecords := router.Group("/commission-records")
crPath := basePath + "/commission-records"
Register(commissionRecords, doc, crPath, "POST", "/:id/resolve", handler.ResolveCommissionRecord, RouteSpec{
Summary: "修正待审佣金记录",
Tags: []string{"代理商佣金管理"},
Input: new(dto.CommissionRecordResolveRequest),
Auth: true,
})
}

View File

@@ -311,28 +311,8 @@ func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id ui
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)
// 实时查询 Gateway 设备状态不缓存per D-05
// Gateway 失败不阻断主流程DeviceRealtime 返回 nullper D-06
if s.gatewayClient != nil {
device, devErr := s.deviceStore.GetByID(ctx, id)
if devErr == nil {
// IMEI 优先,无则用 SN与 Refresh 中 updateDeviceFromSyncInfo 保持一致)
cardNo := device.IMEI
if cardNo == "" {
cardNo = device.SN
}
if cardNo != "" {
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
CardNo: cardNo,
}); syncErr == nil {
resp.DeviceRealtime = mapSyncRespToDeviceGatewayInfo(syncResp)
} else {
logger.GetAppLogger().Warn("GetRealtimeStatus: sync-info 调用失败DeviceRealtime 返回 null",
zap.Uint("device_id", id),
zap.Error(syncErr))
}
}
}
}
// Gateway 失败不阻断主流程DeviceRealtime 始终非 nil失败时仅填 GatewayMsgper D-06
resp.DeviceRealtime = s.fetchDeviceGatewayInfo(ctx, id)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
@@ -341,6 +321,44 @@ func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id ui
return resp, nil
}
// fetchDeviceGatewayInfo 查询设备 Gateway 实时状态,始终返回非 nil 对象
// Gateway 成功时填充全量字段,失败时仅填充 GatewayMsg 供前端展示错误原因
func (s *Service) fetchDeviceGatewayInfo(ctx context.Context, deviceID uint) *dto.DeviceGatewayInfo {
if s.gatewayClient == nil {
msg := "Gateway 客户端未配置"
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
msg := "查询设备信息失败: " + err.Error()
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
// IMEI 优先,无则用 SN与 Refresh 中 updateDeviceFromSyncInfo 保持一致)
cardNo := device.IMEI
if cardNo == "" {
cardNo = device.SN
}
if cardNo == "" {
msg := "设备缺少 IMEI 和 SN无法查询 Gateway 实时状态"
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
CardNo: cardNo,
})
if syncErr != nil {
msg := syncErr.Error()
logger.GetAppLogger().Warn("fetchDeviceGatewayInfo: sync-info 调用失败",
zap.Uint("device_id", deviceID),
zap.Error(syncErr))
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
return mapSyncRespToDeviceGatewayInfo(syncResp)
}
// mapSyncRespToDeviceGatewayInfo 将 Gateway SyncDeviceInfoResp 映射为 B 端 DeviceGatewayInfo DTO
// 使用指针字段,未赋值的字段保持 nilomitempty
func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGatewayInfo {

View File

@@ -0,0 +1,146 @@
package client_order
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// PaymentProvider C 端支付提供者接口。
// 抽象不同支付渠道(微信、富友等)的预下单能力。
type PaymentProvider interface {
// CreateJSAPIPayment 创建 JSAPI 预下单,返回前端调起支付的参数。
CreateJSAPIPayment(ctx context.Context, orderNo, description, openID string, amount int) (*PaymentResult, error)
}
// PaymentResult 预下单结果,前端用这些参数调起支付。
type PaymentResult struct {
AppID string
TimeStamp string
NonceStr string
Package string
SignType string
PaySign string
}
// wechatPaymentProvider 微信支付适配器。
type wechatPaymentProvider struct {
paymentService *wechat.PaymentService
}
// CreateJSAPIPayment 创建微信 JSAPI 预下单。
func (w *wechatPaymentProvider) CreateJSAPIPayment(ctx context.Context, orderNo, description, openID string, amount int) (*PaymentResult, error) {
result, err := w.paymentService.CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
if err != nil {
return nil, err
}
return extractPaymentResult(result.PayConfig), nil
}
// fuiouPaymentProvider 富友支付适配器。
type fuiouPaymentProvider struct {
client *fuiou.Client
appID string
}
// CreateJSAPIPayment 创建富友 JSAPI 预下单。
func (f *fuiouPaymentProvider) CreateJSAPIPayment(ctx context.Context, orderNo, description, openID string, amount int) (*PaymentResult, error) {
amountStr := strconv.Itoa(amount)
resp, err := f.client.WxPreCreate(orderNo, amountStr, description, "", "JSAPI", f.appID, openID)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
}
return &PaymentResult{
AppID: resp.SdkAppid,
TimeStamp: resp.SdkTimestamp,
NonceStr: resp.SdkNoncestr,
Package: resp.SdkPackage,
SignType: resp.SdkSigntype,
PaySign: resp.SdkPaysign,
}, nil
}
// newPaymentProvider 根据支付配置创建支付提供者。
func (s *Service) newPaymentProvider(config *model.WechatConfig, appID string) (PaymentProvider, error) {
switch config.ProviderType {
case model.ProviderTypeFuiou:
client, err := fuiou.NewClient(
config.FyInsCd,
config.FyMchntCd,
config.FyTermID,
config.FyAPIURL,
config.FyNotifyURL,
config.FyPrivateKey,
config.FyPublicKey,
s.logger,
)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友客户端失败")
}
return &fuiouPaymentProvider{client: client, appID: appID}, nil
default:
cache := wechat.NewRedisCache(s.redis)
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, s.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付应用失败")
}
return &wechatPaymentProvider{paymentService: wechat.NewPaymentService(paymentApp, s.logger)}, nil
}
}
// buildClientPayConfigFromResult 将预下单结果转换为客户端支付参数。
func buildClientPayConfigFromResult(result *PaymentResult) *dto.ClientPayConfig {
if result == nil {
result = &PaymentResult{}
}
return &dto.ClientPayConfig{
AppID: result.AppID,
Timestamp: result.TimeStamp,
NonceStr: result.NonceStr,
PackageVal: result.Package,
SignType: result.SignType,
PaySign: result.PaySign,
}
}
// extractPaymentResult 从微信 SDK 的 map 配置中提取统一支付参数。
func extractPaymentResult(payConfig any) *PaymentResult {
configMap, _ := payConfig.(map[string]any)
if configMap == nil {
configMap = map[string]any{}
}
return &PaymentResult{
AppID: payFirstNonEmpty(payStringFromAny(configMap["appId"])),
TimeStamp: payFirstNonEmpty(payStringFromAny(configMap["timeStamp"]), payStringFromAny(configMap["timestamp"])),
NonceStr: payStringFromAny(configMap["nonceStr"]),
Package: payStringFromAny(configMap["package"]),
SignType: payStringFromAny(configMap["signType"]),
PaySign: payStringFromAny(configMap["paySign"]),
}
}
func payStringFromAny(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
func payFirstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -17,7 +17,6 @@ import (
"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/wechat"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
@@ -53,6 +52,9 @@ type Service struct {
wechatConfigService WechatConfigServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
db *gorm.DB
redis *redis.Client
logger *zap.Logger
}
@@ -69,6 +71,9 @@ func New(
wechatConfigService WechatConfigServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
db *gorm.DB,
redisClient *redis.Client,
logger *zap.Logger,
) *Service {
@@ -83,6 +88,9 @@ func New(
wechatConfigService: wechatConfigService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
db: db,
redis: redisClient,
logger: logger,
}
@@ -166,17 +174,17 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
}
}()
paymentService, err := s.newPaymentService(activeConfig, appID)
paymentProvider, err := s.newPaymentProvider(activeConfig, appID)
if err != nil {
return nil, err
}
forceRecharge := s.checkForceRechargeRequirement(skipPermissionCtx, validationResult)
if forceRecharge.NeedForceRecharge {
return s.createForceRechargeOrder(skipPermissionCtx, customerID, appID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentService, &created)
return s.createForceRechargeOrder(skipPermissionCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created)
}
return s.createPackageOrder(skipPermissionCtx, customerID, appID, openID, validationResult, activeConfig, redisKey, paymentService, &created)
return s.createPackageOrder(skipPermissionCtx, customerID, openID, validationResult, activeConfig, redisKey, paymentProvider, &created)
}
func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error {
@@ -258,24 +266,14 @@ func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, ap
return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息")
}
func (s *Service) newPaymentService(wechatConfig *model.WechatConfig, appID string) (*wechat.PaymentService, error) {
cache := wechat.NewRedisCache(s.redis)
paymentApp, err := wechat.NewPaymentAppFromConfig(wechatConfig, appID, cache, s.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付应用失败")
}
return wechat.NewPaymentService(paymentApp, s.logger), nil
}
func (s *Service) createPackageOrder(
ctx context.Context,
customerID uint,
appID string,
openID string,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
redisKey string,
paymentService *wechat.PaymentService,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
order, err := s.buildPendingOrder(customerID, validationResult, activeConfig)
@@ -300,7 +298,7 @@ func (s *Service) createPackageOrder(
description = items[0].PackageName
}
payResult, err := paymentService.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
if err != nil {
return nil, err
}
@@ -314,21 +312,20 @@ func (s *Service) createPackageOrder(
PaymentStatus: order.PaymentStatus,
CreatedAt: formatClientServiceTime(order.CreatedAt),
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
PayConfig: buildClientPayConfigFromResult(paymentResult),
}, nil
}
func (s *Service) createForceRechargeOrder(
ctx context.Context,
customerID uint,
appID string,
openID string,
assetInfo *dto.AssetResolveResponse,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
forceRecharge *ForceRechargeRequirement,
redisKey string,
paymentService *wechat.PaymentService,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
resourceType, resourceID, err := resolveWalletResource(validationResult)
@@ -378,7 +375,7 @@ func (s *Service) createForceRechargeOrder(
s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo)
*created = true
payResult, err := paymentService.CreateJSAPIOrder(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
if err != nil {
return nil, err
}
@@ -392,7 +389,7 @@ func (s *Service) createForceRechargeOrder(
Status: rechargeStatusToClientStatus(recharge.Status),
AutoPurchaseStatus: recharge.AutoPurchaseStatus,
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
PayConfig: buildClientPayConfigFromResult(paymentResult),
LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge),
}, nil
}
@@ -561,22 +558,6 @@ func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult
}
}
func buildClientPayConfig(appID string, payConfig any) *dto.ClientPayConfig {
configMap, _ := payConfig.(map[string]any)
if configMap == nil {
configMap = map[string]any{}
}
return &dto.ClientPayConfig{
AppID: firstNonEmpty(stringFromAny(configMap["appId"]), appID),
Timestamp: firstNonEmpty(stringFromAny(configMap["timeStamp"]), stringFromAny(configMap["timestamp"])),
NonceStr: stringFromAny(configMap["nonceStr"]),
PackageVal: stringFromAny(configMap["package"]),
SignType: stringFromAny(configMap["signType"]),
PaySign: stringFromAny(configMap["paySign"]),
}
}
func resolveWalletResource(result *purchase_validation.PurchaseValidationResult) (string, uint, error) {
if result.Card != nil {
return constants.AssetWalletResourceTypeIotCard, result.Card.ID, nil
@@ -663,18 +644,217 @@ func generateClientRechargeNo() string {
return fmt.Sprintf("CRCH%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000))
}
func stringFromAny(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
// ListOrders 查询 C 端订单列表。
func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.ClientOrderListRequest) ([]dto.ClientOrderListItem, int64, error) {
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier))
if err != nil {
return nil, 0, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); err != nil {
return nil, 0, err
}
generation, err := s.getAssetGeneration(skipCtx, assetInfo.AssetType, assetInfo.AssetID)
if err != nil {
return nil, 0, err
}
query := s.db.WithContext(skipCtx).
Model(&model.Order{}).
Where("generation = ?", generation)
if assetInfo.AssetType == constants.ResourceTypeDevice {
query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, assetInfo.AssetID)
} else {
query = query.Where("order_type = ? AND iot_card_id = ?", model.OrderTypeSingleCard, assetInfo.AssetID)
}
if req.PaymentStatus != nil {
query = query.Where("payment_status = ?", *req.PaymentStatus)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, 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 nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询订单列表失败")
}
orderIDs := make([]uint, 0, len(orders))
for _, order := range orders {
if order != nil {
orderIDs = append(orderIDs, order.ID)
}
}
return ""
itemMap, err := s.loadOrderItemMap(skipCtx, orderIDs)
if err != nil {
return nil, 0, err
}
list := make([]dto.ClientOrderListItem, 0, len(orders))
for _, order := range orders {
if order == nil {
continue
}
packageNames := make([]string, 0)
for _, item := range itemMap[order.ID] {
if item != nil && item.PackageName != "" {
packageNames = append(packageNames, item.PackageName)
}
}
list = append(list, dto.ClientOrderListItem{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
CreatedAt: formatClientServiceTime(order.CreatedAt),
PackageNames: packageNames,
})
}
return list, total, nil
}
// GetOrderDetail 查询 C 端订单详情。
func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID uint) (*dto.ClientOrderDetailResponse, error) {
order, items, err := s.orderStore.GetByIDWithItems(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败")
}
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
virtualNo, err := s.getOrderVirtualNo(skipCtx, order)
if err != nil {
return nil, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, virtualNo); err != nil {
return nil, err
}
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,
})
}
return &dto.ClientOrderDetailResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
PaymentMethod: order.PaymentMethod,
CreatedAt: formatClientServiceTime(order.CreatedAt),
PaidAt: formatClientServiceTimePtr(order.PaidAt),
CompletedAt: nil,
Packages: packages,
}, nil
}
func (s *Service) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":
card, err := s.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 := s.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 (s *Service) 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 := s.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 {
result[item.OrderID] = append(result[item.OrderID], item)
}
}
return result, nil
}
func (s *Service) 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 := s.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 := s.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 formatClientServiceTimePtr(t *time.Time) *string {
if t == nil || t.IsZero() {
return nil
}
formatted := formatClientServiceTime(*t)
return &formatted
}

View File

@@ -172,7 +172,30 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, currentShop.ID, packageID)
if err != nil {
s.logger.Warn("上级店铺未分配该套餐,跳过", zap.Uint("shop_id", currentShop.ID), zap.Uint("package_id", packageID))
s.logger.Warn("上级店铺未分配该套餐,佣金链断裂",
zap.Uint("shop_id", currentShop.ID),
zap.Uint("package_id", packageID),
zap.Uint("order_id", order.ID))
pendingRecord := &model.CommissionRecord{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Updater,
},
ShopID: currentShop.ID,
OrderID: order.ID,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
CommissionSource: model.CommissionSourceCostDiff,
Amount: 0,
Status: constants.CommissionStatusPendingReview,
Remark: fmt.Sprintf("套餐系列[%d]未分配给该代理(shop_id=%d),成本价差链路断裂,请人工核查", *order.SeriesID, currentShop.ID),
}
if createErr := s.commissionRecordStore.Create(ctx, pendingRecord); createErr != nil {
s.logger.Warn("创建待审佣金记录失败",
zap.Uint("shop_id", currentShop.ID),
zap.Uint("order_id", order.ID),
zap.Error(createErr))
}
break
}
@@ -503,9 +526,30 @@ func (s *Service) calculateChainOneTimeCommission(ctx context.Context, bottomSho
parentShopID := *currentShop.ParentID
parentSeriesAllocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, parentShopID, seriesID)
if err != nil {
s.logger.Warn("上级店铺未分配该系列,停止链式计算",
s.logger.Warn("上级店铺未分配该系列,佣金链断裂",
zap.Uint("parent_shop_id", parentShopID),
zap.Uint("series_id", seriesID))
zap.Uint("series_id", seriesID),
zap.Uint("order_id", order.ID))
pendingRecord := &model.CommissionRecord{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Updater,
},
ShopID: parentShopID,
OrderID: order.ID,
IotCardID: cardID,
DeviceID: deviceID,
CommissionSource: model.CommissionSourceOneTime,
Amount: 0,
Status: constants.CommissionStatusPendingReview,
Remark: fmt.Sprintf("套餐系列[%d]未分配给该代理(shop_id=%d),一次性佣金链路断裂,请人工核查", seriesID, parentShopID),
}
if createErr := s.commissionRecordStore.Create(ctx, pendingRecord); createErr != nil {
s.logger.Warn("创建待审佣金记录失败",
zap.Uint("shop_id", parentShopID),
zap.Uint("order_id", order.ID),
zap.Error(createErr))
}
break
}

View File

@@ -16,6 +16,7 @@ import (
"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/fuiou"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
@@ -194,6 +195,14 @@ func (s *Service) CreateLegacy(ctx context.Context, req *dto.CreateOrderRequest,
} else if req.PaymentMethod == model.PaymentMethodWallet {
// ==== 场景 2代理钱包支付wallet====
if buyerType == "" {
if resourceShopID == nil {
return nil, errors.New(errors.CodeInvalidParam, "不支持的钱包支付场景")
}
buyerType = model.BuyerTypeAgent
buyerID = *resourceShopID
}
// 只有代理账号可以使用钱包支付
if buyerType != model.BuyerTypeAgent {
return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付")
@@ -2379,14 +2388,126 @@ func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRe
return response, nil
}
// FuiouPayJSAPI 富友公众号 JSAPI 支付发起(留桩)
// TODO: 实现富友支付发起逻辑
func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) error {
return errors.New(errors.CodeFuiouPayFailed, "富友支付发起暂未实现")
func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.FuiouPayJSAPIResponse, error) {
return s.fuiouPreCreate(ctx, orderID, openID, buyerType, buyerID, "JSAPI", false)
}
// FuiouPayMiniApp 富友小程序支付发起(留桩)
// TODO: 实现富友小程序支付发起逻辑
func (s *Service) FuiouPayMiniApp(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) error {
return errors.New(errors.CodeFuiouPayFailed, "富友小程序支付发起暂未实现")
func (s *Service) FuiouPayMiniApp(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.FuiouPayJSAPIResponse, error) {
return s.fuiouPreCreate(ctx, orderID, openID, buyerType, buyerID, "LETPAY", true)
}
func (s *Service) fuiouPreCreate(
ctx context.Context,
orderID uint,
openID string,
buyerType string,
buyerID uint,
tradeType string,
useMiniappAppID bool,
) (*dto.FuiouPayJSAPIResponse, error) {
if strings.TrimSpace(openID) == "" {
return nil, errors.New(errors.CodeInvalidParam, "openID 不能为空")
}
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "查询生效支付配置失败")
}
if activeConfig == nil || activeConfig.ProviderType != model.ProviderTypeFuiou {
return nil, errors.New(errors.CodeFuiouPayFailed, "未找到生效的富友支付配置")
}
subAppID := activeConfig.OaAppID
if useMiniappAppID {
subAppID = activeConfig.MiniappAppID
}
if strings.TrimSpace(activeConfig.FyInsCd) == "" ||
strings.TrimSpace(activeConfig.FyMchntCd) == "" ||
strings.TrimSpace(activeConfig.FyTermID) == "" ||
strings.TrimSpace(activeConfig.FyPrivateKey) == "" ||
strings.TrimSpace(activeConfig.FyPublicKey) == "" ||
strings.TrimSpace(activeConfig.FyAPIURL) == "" ||
strings.TrimSpace(activeConfig.FyNotifyURL) == "" ||
strings.TrimSpace(subAppID) == "" {
return nil, errors.New(errors.CodeFuiouPayFailed, "富友支付配置不完整")
}
client, err := fuiou.NewClient(
activeConfig.FyInsCd,
activeConfig.FyMchntCd,
activeConfig.FyTermID,
activeConfig.FyAPIURL,
activeConfig.FyNotifyURL,
activeConfig.FyPrivateKey,
activeConfig.FyPublicKey,
s.logger,
)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友支付客户端失败")
}
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
}
description := "套餐购买"
if len(items) > 0 {
description = items[0].PackageName
}
termIP := "127.0.0.1"
if ip := middleware.GetIPFromContext(ctx); ip != nil && strings.TrimSpace(*ip) != "" {
termIP = *ip
}
resp, err := client.WxPreCreate(
order.OrderNo,
strconv.FormatInt(order.TotalAmount, 10),
description,
termIP,
tradeType,
subAppID,
openID,
)
if err != nil {
s.logger.Error("富友预下单失败",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("trade_type", tradeType),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
}
s.logger.Info("富友预下单成功",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("trade_type", tradeType),
)
return &dto.FuiouPayJSAPIResponse{
AppID: resp.SdkAppid,
TimeStamp: resp.SdkTimestamp,
NonceStr: resp.SdkNoncestr,
Package: resp.SdkPackage,
SignType: resp.SdkSigntype,
PaySign: resp.SdkPaysign,
}, nil
}

View File

@@ -141,6 +141,8 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
s.logger.Info("套餐已激活",
zap.Uint("usage_id", usage.ID),
zap.Uint("package_id", usage.PackageID),
@@ -300,6 +302,8 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
}
s.syncCarrierStatusActivated(ctx, tx, &nextMain, carrierType, carrierID)
s.logger.Info("排队主套餐已激活",
zap.Uint("usage_id", nextMain.ID),
zap.Uint("package_id", nextMain.PackageID),
@@ -320,3 +324,48 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
return nil
}
func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) {
if usage == nil {
return
}
if usage.UsageType == constants.PackageUsageTypeDevice || usage.DeviceID > 0 || carrierType == constants.AssetTypeDevice {
deviceID := usage.DeviceID
if deviceID == 0 {
deviceID = carrierID
}
if deviceID == 0 {
return
}
if err := tx.WithContext(ctx).
Model(&model.Device{}).
Where("id = ? AND status < ?", deviceID, constants.DeviceStatusActivated).
Update("status", constants.DeviceStatusActivated).Error; err != nil {
s.logger.Warn("套餐激活后更新设备状态失败",
zap.Uint("device_id", deviceID),
zap.Uint("usage_id", usage.ID),
zap.Error(err))
}
return
}
cardID := usage.IotCardID
if cardID == 0 {
cardID = carrierID
}
if cardID == 0 {
return
}
if err := tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id = ? AND status < ?", cardID, constants.IotCardStatusActivated).
Update("status", constants.IotCardStatusActivated).Error; err != nil {
s.logger.Warn("套餐激活后更新卡状态失败",
zap.Uint("iot_card_id", cardID),
zap.Uint("usage_id", usage.ID),
zap.Error(err))
}
}

View File

@@ -11,6 +11,8 @@ import (
"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"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Service struct {
@@ -19,6 +21,8 @@ type Service struct {
agentWalletStore *postgres.AgentWalletStore
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
commissionRecordStore *postgres.CommissionRecordStore
db *gorm.DB
logger *zap.Logger
}
func New(
@@ -27,6 +31,8 @@ func New(
agentWalletStore *postgres.AgentWalletStore,
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore,
commissionRecordStore *postgres.CommissionRecordStore,
db *gorm.DB,
logger *zap.Logger,
) *Service {
return &Service{
shopStore: shopStore,
@@ -34,6 +40,8 @@ func New(
agentWalletStore: agentWalletStore,
commissionWithdrawalReqStore: commissionWithdrawalReqStore,
commissionRecordStore: commissionRecordStore,
db: db,
logger: logger,
}
}
@@ -425,3 +433,77 @@ func contains(s, substr string) bool {
}
return false
}
// ResolveCommissionRecord 修正待审佣金记录status=99
// release: 填入金额并入账到代理佣金钱包
// invalidate: 标记为已失效
func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, req *dto.CommissionRecordResolveRequest) error {
record, err := s.commissionRecordStore.GetByID(ctx, recordID)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "佣金记录不存在")
}
if record.Status != constants.CommissionStatusPendingReview {
return errors.New(errors.CodeInvalidParam, "该记录不是待修正状态")
}
now := time.Now()
resolveRemark := record.Remark
if req.Remark != "" {
resolveRemark += " | 处理备注: " + req.Remark
}
if req.Action == "invalidate" {
return s.commissionRecordStore.UpdateByID(ctx, nil, recordID, map[string]any{
"status": model.CommissionStatusInvalid,
"remark": resolveRemark,
})
}
// release 入账
if req.Amount == nil || *req.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "入账操作必须指定金额")
}
amount := *req.Amount
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, record.ShopID)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "店铺佣金钱包不存在")
}
return s.db.Transaction(func(tx *gorm.DB) error {
// 更新佣金记录
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
"status": model.CommissionStatusReleased,
"amount": amount,
"released_at": now,
"remark": resolveRemark,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
}
// 入账到佣金钱包(乐观锁)
balanceBefore := wallet.Balance
result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance + ?", amount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新佣金钱包余额失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "佣金钱包版本冲突,请重试")
}
// 回写入账后余额
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
"balance_after": balanceBefore + amount,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新入账后余额失败")
}
return nil
})
}

View File

@@ -27,6 +27,14 @@ func (s *CommissionRecordStore) Create(ctx context.Context, record *model.Commis
return s.db.WithContext(ctx).Create(record).Error
}
func (s *CommissionRecordStore) UpdateByID(ctx context.Context, tx *gorm.DB, id uint, updates map[string]any) error {
db := s.db
if tx != nil {
db = tx
}
return db.WithContext(ctx).Model(&model.CommissionRecord{}).Where("id = ?", id).Updates(updates).Error
}
func (s *CommissionRecordStore) GetByID(ctx context.Context, id uint) (*model.CommissionRecord, error) {
var record model.CommissionRecord
query := s.db.WithContext(ctx).Where("id = ?", id)