diff --git a/cmd/api/docs.go b/cmd/api/docs.go index c3bb819..48eb59b 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -28,7 +28,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil) handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil) handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil) handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil) handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index fcad934..e345242 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -37,7 +37,7 @@ func generateAdminDocs(outputPath string) error { handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil) handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil) handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil) handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil) handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 848e794..b53f74d 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index af624a1..efff415 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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( diff --git a/internal/handler/admin/shop_commission.go b/internal/handler/admin/shop_commission.go index e2a4a59..03606fc 100644 --- a/internal/handler/admin/shop_commission.go +++ b/internal/handler/admin/shop_commission.go @@ -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) +} diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 8b2c2a4..3c8d5a0 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -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 } diff --git a/internal/handler/app/client_order.go b/internal/handler/app/client_order.go index 2fbc98a..7c4a21e 100644 --- a/internal/handler/app/client_order.go +++ b/internal/handler/app/client_order.go @@ -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 -} diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index 6333189..76158c4 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -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), ¬ify); 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()) } diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index a9240c0..cf38afd 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -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-info,Gateway 失败时为 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)"` diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index d90117f..34cc1ed 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -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:"电池电量百分比"` diff --git a/internal/model/dto/commission.go b/internal/model/dto/commission.go index 1cf4f20..628706c 100644 --- a/internal/model/dto/commission.go +++ b/internal/model/dto/commission.go @@ -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:"处理备注"` +} diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index c19045b..c6436ce 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -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:"是否为当前使用的卡"` } diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index d8d0daa..5a42dfa 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -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:"创建时间"` diff --git a/internal/model/dto/wechat_dto.go b/internal/model/dto/wechat_dto.go index dfabc25..19f41ec 100644 --- a/internal/model/dto/wechat_dto.go +++ b/internal/model/dto/wechat_dto.go @@ -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:"场景信息"` } diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index 7dda0a6..715c022 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -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 { diff --git a/internal/routes/shop.go b/internal/routes/shop.go index 1dd5931..f3c2b56 100644 --- a/internal/routes/shop.go +++ b/internal/routes/shop.go @@ -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, + }) } diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 1b715ec..697786e 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -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 返回 null(per 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,失败时仅填 GatewayMsg(per 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 // 使用指针字段,未赋值的字段保持 nil(omitempty) func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGatewayInfo { diff --git a/internal/service/client_order/payment_provider.go b/internal/service/client_order/payment_provider.go new file mode 100644 index 0000000..d15041d --- /dev/null +++ b/internal/service/client_order/payment_provider.go @@ -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 "" +} diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index d8030b9..637ec41 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -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 } diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index 40b35a0..5c16c3f 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -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 } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index a4b9aee..8af01fd 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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 } diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index bff087e..7bd4def 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -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)) + } +} diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index 77a0d29..249b867 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -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 + }) +} diff --git a/internal/store/postgres/commission_record_store.go b/internal/store/postgres/commission_record_store.go index be7626b..dc85543 100644 --- a/internal/store/postgres/commission_record_store.go +++ b/internal/store/postgres/commission_record_store.go @@ -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) diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index d08b226..5fecc0d 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -176,10 +176,11 @@ const ( // 分佣状态 const ( - CommissionStatusFrozen = 1 // 已冻结 - CommissionStatusUnfreezing = 2 // 解冻中 - CommissionStatusReleased = 3 // 已发放 - CommissionStatusInvalid = 4 // 已失效 + CommissionStatusFrozen = 1 // 已冻结 + CommissionStatusUnfreezing = 2 // 解冻中 + CommissionStatusReleased = 3 // 已发放 + CommissionStatusInvalid = 4 // 已失效 + CommissionStatusPendingReview = 99 // 待人工修正(链路断裂,需平台处理) ) // 阶梯类型 diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 8202490..6206ad9 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -18,7 +18,7 @@ func BuildDocHandlers() *bootstrap.Handlers { PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil), ClientAsset: app.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil), ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), - ClientOrder: app.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil), + ClientOrder: app.NewClientOrderHandler(nil, nil), ClientExchange: app.NewClientExchangeHandler(nil), ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil), ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil), @@ -47,7 +47,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil), AdminOrder: admin.NewOrderHandler(nil, nil), AdminExchange: admin.NewExchangeHandler(nil, nil), - PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil), + PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil), PollingConfig: admin.NewPollingConfigHandler(nil), PollingConcurrency: admin.NewPollingConcurrencyHandler(nil), PollingMonitoring: admin.NewPollingMonitoringHandler(nil),