package app import ( "context" "fmt" "math/rand" "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" rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge" wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config" "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/break/junhong_cmp_fiber/pkg/wechat" "github.com/gofiber/fiber/v2" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) // ClientWalletHandler C 端钱包处理器 // 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口 type ClientWalletHandler struct { assetService *asset.Service personalDeviceStore *postgres.PersonalCustomerDeviceStore walletStore *postgres.AssetWalletStore transactionStore *postgres.AssetWalletTransactionStore rechargeStore *postgres.AssetRechargeStore rechargeService *rechargeSvc.Service openIDStore *postgres.PersonalCustomerOpenIDStore wechatConfigService *wechatConfigSvc.Service redis *redis.Client logger *zap.Logger db *gorm.DB iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore } // NewClientWalletHandler 创建 C 端钱包处理器 func NewClientWalletHandler( assetService *asset.Service, personalDeviceStore *postgres.PersonalCustomerDeviceStore, walletStore *postgres.AssetWalletStore, transactionStore *postgres.AssetWalletTransactionStore, rechargeStore *postgres.AssetRechargeStore, rechargeService *rechargeSvc.Service, openIDStore *postgres.PersonalCustomerOpenIDStore, wechatConfigService *wechatConfigSvc.Service, redisClient *redis.Client, logger *zap.Logger, db *gorm.DB, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, ) *ClientWalletHandler { return &ClientWalletHandler{ assetService: assetService, personalDeviceStore: personalDeviceStore, walletStore: walletStore, transactionStore: transactionStore, rechargeStore: rechargeStore, rechargeService: rechargeService, openIDStore: openIDStore, wechatConfigService: wechatConfigService, redis: redisClient, logger: logger, db: db, iotCardStore: iotCardStore, deviceStore: deviceStore, } } type resolvedWalletAssetContext struct { CustomerID uint Identifier string Asset *dto.AssetResolveResponse Generation int ResourceType string SkipPermissionCtx context.Context } // GetWalletDetail C1 钱包详情 // GET /api/c/v1/wallet/detail func (h *ClientWalletHandler) GetWalletDetail(c *fiber.Ctx) error { var req dto.WalletDetailRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } wallet, err := h.getOrCreateWallet(resolved) if err != nil { return err } resp := &dto.WalletDetailResponse{ WalletID: wallet.ID, ResourceType: wallet.ResourceType, ResourceID: wallet.ResourceID, Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance, UpdatedAt: wallet.UpdatedAt.Format(time.RFC3339), } return response.Success(c, resp) } // GetWalletTransactions C2 钱包流水列表 // GET /api/c/v1/wallet/transactions func (h *ClientWalletHandler) GetWalletTransactions(c *fiber.Ctx) error { var req dto.WalletTransactionListRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 { req.PageSize = constants.DefaultPageSize } if req.PageSize > constants.MaxPageSize { req.PageSize = constants.MaxPageSize } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) if err != nil { if err == gorm.ErrRecordNotFound { return response.SuccessWithPagination(c, []dto.WalletTransactionItem{}, 0, req.Page, req.PageSize) } return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") } var txType *string if strings.TrimSpace(req.TransactionType) != "" { v := strings.TrimSpace(req.TransactionType) txType = &v } startTime, err := parseOptionalTime(req.StartTime) if err != nil { return errors.New(errors.CodeInvalidParam) } endTime, err := parseOptionalTime(req.EndTime) if err != nil { return errors.New(errors.CodeInvalidParam) } if startTime != nil && endTime != nil && endTime.Before(*startTime) { return errors.New(errors.CodeInvalidParam) } offset := (req.Page - 1) * req.PageSize list, err := h.transactionStore.ListByResourceIDWithFilter( resolved.SkipPermissionCtx, wallet.ResourceType, wallet.ResourceID, txType, startTime, endTime, offset, req.PageSize, ) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水失败") } total, err := h.transactionStore.CountByResourceIDWithFilter( resolved.SkipPermissionCtx, wallet.ResourceType, wallet.ResourceID, txType, startTime, endTime, ) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水总数失败") } items := make([]dto.WalletTransactionItem, 0, len(list)) for _, tx := range list { if tx == nil { continue } remark := "" if tx.Remark != nil { remark = *tx.Remark } items = append(items, dto.WalletTransactionItem{ TransactionID: tx.ID, Type: tx.TransactionType, Amount: tx.Amount, BalanceAfter: tx.BalanceAfter, CreatedAt: tx.CreatedAt.Format(time.RFC3339), Remark: remark, }) } return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize) } // GetRechargeCheck C3 充值前校验 // GET /api/c/v1/wallet/recharge-check func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error { var req dto.ClientRechargeCheckRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) if err != nil { return err } resp := &dto.ClientRechargeCheckResponse{ NeedForceRecharge: check.NeedForceRecharge, ForceRechargeAmount: check.ForceRechargeAmount, TriggerType: check.TriggerType, MinAmount: check.MinAmount, MaxAmount: check.MaxAmount, Message: check.Message, } return response.Success(c, resp) } // CreateRecharge C4 创建充值订单 // POST /api/c/v1/wallet/recharge func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { var req dto.ClientCreateRechargeRequest if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } if req.PaymentMethod != constants.RechargeMethodWechat { return errors.New(errors.CodeInvalidParam) } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } wallet, err := h.getOrCreateWallet(resolved) if err != nil { return err } config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx) if err != nil { return err } if config == nil { return errors.New(errors.CodeWechatConfigUnavailable) } appID, err := pickAppIDByType(config, req.AppType) if err != nil { return err } openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID) if err != nil { return err } rechargeNo := generateClientRechargeNo() recharge := &model.AssetRechargeRecord{ UserID: resolved.CustomerID, AssetWalletID: wallet.ID, ResourceType: resolved.ResourceType, ResourceID: resolved.Asset.AssetID, RechargeNo: rechargeNo, Amount: req.Amount, PaymentMethod: constants.RechargeMethodWechat, PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, OperatorType: constants.OperatorTypePersonalCustomer, Generation: resolved.Generation, } if err := h.rechargeStore.Create(resolved.SkipPermissionCtx, recharge); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败") } cache := wechat.NewRedisCache(h.redis) paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger) if err != nil { return errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败") } paymentService := wechat.NewPaymentService(paymentApp, h.logger) payResult, err := paymentService.CreateJSAPIOrder( resolved.SkipPermissionCtx, recharge.RechargeNo, "资产钱包充值", openID, int(req.Amount), ) if err != nil { return err } payConfig := buildClientRechargePayConfig(appID, payResult) resp := &dto.ClientRechargeResponse{ Recharge: dto.ClientRechargeResult{ RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, Amount: recharge.Amount, Status: recharge.Status, }, PayConfig: payConfig, } return response.Success(c, resp) } // GetRechargeList C5 充值记录列表 // GET /api/c/v1/wallet/recharges func (h *ClientWalletHandler) GetRechargeList(c *fiber.Ctx) error { var req dto.ClientRechargeListRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 { req.PageSize = constants.DefaultPageSize } if req.PageSize > constants.MaxPageSize { req.PageSize = constants.MaxPageSize } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } query := h.db.WithContext(resolved.SkipPermissionCtx). Model(&model.AssetRechargeRecord{}). Where("resource_type = ? AND resource_id = ? AND generation = ?", resolved.ResourceType, resolved.Asset.AssetID, resolved.Generation) if req.Status != nil { query = query.Where("status = ?", *req.Status) } var total int64 if err := query.Count(&total).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败") } var records []*model.AssetRechargeRecord offset := (req.Page - 1) * req.PageSize if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&records).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败") } items := make([]dto.ClientRechargeListItem, 0, len(records)) for _, record := range records { if record == nil { continue } items = append(items, dto.ClientRechargeListItem{ RechargeID: record.ID, RechargeNo: record.RechargeNo, Amount: record.Amount, Status: record.Status, PaymentMethod: record.PaymentMethod, CreatedAt: record.CreatedAt.Format(time.RFC3339), AutoPurchaseStatus: record.AutoPurchaseStatus, }) } return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize) } // resolveAssetFromIdentifier 统一执行资产解析与归属校验 func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedWalletAssetContext, 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, "无权限操作该资产或资源不存在") } resourceType, mapErr := mapAssetTypeToWalletResource(assetInfo.AssetType) if mapErr != nil { return nil, mapErr } generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID) if genErr != nil { return nil, genErr } return &resolvedWalletAssetContext{ CustomerID: customerID, Identifier: identifier, Asset: assetInfo, Generation: generation, ResourceType: resourceType, SkipPermissionCtx: skipPermissionCtx, }, nil } func (h *ClientWalletHandler) 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 *ClientWalletHandler) 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 "device": 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 *ClientWalletHandler) getOrCreateWallet(resolved *resolvedWalletAssetContext) (*model.AssetWallet, error) { wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) if err == nil { return wallet, nil } if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") } shopIDTag := uint(0) if resolved.Asset.ShopID != nil { shopIDTag = *resolved.Asset.ShopID } newWallet := &model.AssetWallet{ ResourceType: resolved.ResourceType, ResourceID: resolved.Asset.AssetID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopIDTag, } if createErr := h.walletStore.Create(resolved.SkipPermissionCtx, newWallet); createErr != nil { return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "创建钱包失败") } wallet, err = h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") } return wallet, nil } func (h *ClientWalletHandler) findOpenIDByCustomerAndAppID(ctx context.Context, customerID uint, appID string) (string, error) { list, err := h.openIDStore.ListByCustomerID(ctx, customerID) if err != nil { return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败") } for _, item := range list { if item == nil { continue } if item.AppID == appID && strings.TrimSpace(item.OpenID) != "" { return item.OpenID, nil } } return "", errors.New(errors.CodeOpenIDNotFound) } func mapAssetTypeToWalletResource(assetType string) (string, error) { switch assetType { case "card": return constants.AssetWalletResourceTypeIotCard, nil case "device": return constants.AssetWalletResourceTypeDevice, nil default: return "", errors.New(errors.CodeInvalidParam) } } func parseOptionalTime(value string) (*time.Time, error) { v := strings.TrimSpace(value) if v == "" { return nil, nil } layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} for _, layout := range layouts { t, err := time.Parse(layout, v) if err == nil { return &t, nil } } return nil, fmt.Errorf("invalid time format") } func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) { switch appType { case "official_account": if strings.TrimSpace(config.OaAppID) == "" { return "", errors.New(errors.CodeWechatConfigUnavailable) } return config.OaAppID, nil case "miniapp": if strings.TrimSpace(config.MiniappAppID) == "" { return "", errors.New(errors.CodeWechatConfigUnavailable) } return config.MiniappAppID, nil default: return "", errors.New(errors.CodeInvalidParam) } } func generateClientRechargeNo() string { timestamp := time.Now().Format("20060102150405") randomNum := rand.Intn(1000000) return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum) } func buildClientRechargePayConfig(appID string, result *wechat.JSAPIPayResult) dto.ClientRechargePayConfig { resp := dto.ClientRechargePayConfig{AppID: appID} if result == nil || result.PayConfig == nil { return resp } if cfg, ok := result.PayConfig.(map[string]any); ok { resp.Timestamp = getStringFromAnyMap(cfg, "timeStamp", "timestamp") resp.NonceStr = getStringFromAnyMap(cfg, "nonceStr", "nonce_str") resp.PackageVal = getStringFromAnyMap(cfg, "package") resp.SignType = getStringFromAnyMap(cfg, "signType", "sign_type") resp.PaySign = getStringFromAnyMap(cfg, "paySign", "pay_sign") if appIDVal := getStringFromAnyMap(cfg, "appId", "app_id"); appIDVal != "" { resp.AppID = appIDVal } return resp } if cfg, ok := result.PayConfig.(map[string]string); ok { resp.Timestamp = cfg["timeStamp"] if resp.Timestamp == "" { resp.Timestamp = cfg["timestamp"] } resp.NonceStr = cfg["nonceStr"] if resp.NonceStr == "" { resp.NonceStr = cfg["nonce_str"] } resp.PackageVal = cfg["package"] resp.SignType = cfg["signType"] if resp.SignType == "" { resp.SignType = cfg["sign_type"] } resp.PaySign = cfg["paySign"] if resp.PaySign == "" { resp.PaySign = cfg["pay_sign"] } if cfg["appId"] != "" { resp.AppID = cfg["appId"] } else if cfg["app_id"] != "" { resp.AppID = cfg["app_id"] } } return resp } func getStringFromAnyMap(m map[string]any, keys ...string) string { for _, key := range keys { val, ok := m[key] if !ok || val == nil { continue } switch v := val.(type) { case string: if v != "" { return v } case fmt.Stringer: text := v.String() if text != "" { return text } default: text := fmt.Sprintf("%v", v) if text != "" && text != "" { return text } } } return "" }