feat: 实现客户端核心业务接口(client-core-business-api)

新增客户端资产、钱包、订单、实名、设备管理等核心业务 Handler 与 DTO:
- 客户端资产信息查询、套餐列表、套餐历史、资产刷新
- 客户端钱包详情、流水、充值校验、充值订单、充值记录
- 客户端订单创建、列表、详情
- 客户端实名认证链接获取
- 客户端设备卡列表、重启、恢复出厂、WiFi配置、切卡
- 客户端订单服务(含微信/支付宝支付流程)
- 强充自动代购异步任务处理
- 数据库迁移 000084:充值记录增加自动代购状态字段
This commit is contained in:
2026-03-19 13:28:04 +08:00
parent e78f5794b9
commit 9bd55a1695
18 changed files with 5260 additions and 14 deletions

13
.config/dbhub.toml Normal file
View File

@@ -0,0 +1,13 @@
[[sources]]
id = "main"
dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
[[tools]]
name = "search_objects"
source = "main"
[[tools]]
name = "execute_sql"
source = "main"
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
max_rows = 1000 # Limit query results

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
# 客户端核心业务 API — 功能总结
## 概述
本提案为客户端C 端个人客户)提供完整的业务接口,覆盖资产查询、钱包充值、套餐购买、实名跳转、设备操作 5 大模块共 18 个 API 端点,全部挂载在 `/api/c/v1/` 路径下。
**前置依赖**:提案 0数据模型修复、提案 1C 端认证系统)。
## API 端点一览
### 模块 B资产信息4 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/asset/info` | B1 资产基本信息查询 |
| GET | `/api/c/v1/asset/packages` | B2 可购买套餐列表 |
| GET | `/api/c/v1/asset/package-history` | B3 历史套餐列表 |
| POST | `/api/c/v1/asset/refresh` | B4 手动刷新资产状态 |
### 模块 C钱包与充值5 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/wallet/detail` | C1 钱包详情(不存在自动创建) |
| GET | `/api/c/v1/wallet/transactions` | C2 钱包流水列表 |
| GET | `/api/c/v1/wallet/recharge-check` | C3 充值预检(强充检查) |
| POST | `/api/c/v1/wallet/recharge` | C4 创建充值订单JSAPI 支付) |
| GET | `/api/c/v1/wallet/recharges` | C5 充值订单列表 |
### 模块 D套餐购买3 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/c/v1/orders/create` | D1 创建套餐购买订单(含强充分流) |
| GET | `/api/c/v1/orders` | D2 套餐订单列表 |
| GET | `/api/c/v1/orders/:id` | D3 套餐订单详情 |
### 模块 E实名认证1 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/realname/link` | E1 获取实名跳转链接 |
### 模块 F设备能力5 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/device/cards` | F1 设备卡列表 |
| POST | `/api/c/v1/device/reboot` | F2 设备重启 |
| POST | `/api/c/v1/device/factory-reset` | F3 恢复出厂设置 |
| POST | `/api/c/v1/device/wifi` | F4 设置 WiFi |
| POST | `/api/c/v1/device/switch-card` | F5 切卡 |
## 核心设计决策
### 1. 数据权限绕过
客户端调用后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)` 绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
### 2. 归属校验
所有涉及资产操作的接口统一前置归属校验:查询 `PersonalCustomerDevice` 条件 `customer_id = 当前登录客户``virtual_no = 资产虚拟号`,未命中返回 403。
### 3. Generation 过滤
客户端历史查询统一附加 `WHERE generation = 资产当前 generation`,确保转手后数据隔离。
### 4. OpenID 安全规范
支付接口C4/D1所需 OpenID 由后端按 `customer_id + app_type` 查询,客户端禁止传入 OpenID。根据 `app_type` 选择对应的微信 AppID 创建支付实例。
### 5. 强充两阶段
- 第一阶段(同步):充值入账、更新状态
- 第二阶段(异步 Asynq钱包扣款 → 创建订单 → 激活套餐
`AssetRechargeRecord.auto_purchase_status` 字段追踪异步状态pending/success/failed
## 新增文件
```
internal/model/dto/client_asset_dto.go # 资产模块 DTO
internal/model/dto/client_wallet_dto.go # 钱包模块 DTO
internal/model/dto/client_order_dto.go # 订单模块 DTO
internal/model/dto/client_realname_device_dto.go # 实名+设备模块 DTO
internal/handler/app/client_asset.go # 资产 Handler
internal/handler/app/client_wallet.go # 钱包 Handler
internal/handler/app/client_order.go # 订单 Handler
internal/handler/app/client_realname.go # 实名 Handler
internal/handler/app/client_device.go # 设备 Handler
internal/service/client_order/service.go # 客户端订单编排 Service
internal/task/auto_purchase.go # 强充异步自动购买任务
migrations/000084_add_auto_purchase_status_*.sql # 数据库迁移
```
## 修改文件
```
pkg/constants/constants.go # 新增 auto_purchase_status 常量 + 任务类型
pkg/constants/redis.go # 新增客户端购买幂等键
pkg/errors/codes.go # 新增 NEED_REALNAME/OPENID_NOT_FOUND 错误码
internal/model/asset_wallet.go # AssetRechargeRecord 新增字段
internal/bootstrap/types.go # 5 个 Handler 字段
internal/bootstrap/handlers.go # Handler 实例化
internal/routes/personal.go # 18 个路由注册
pkg/openapi/handlers.go # 文档生成 Handler
cmd/api/docs.go # 文档注册
cmd/gendocs/main.go # 文档注册
```
## 新增错误码
| 错误码 | 常量名 | 消息 |
|--------|--------|------|
| 1187 | CodeNeedRealname | 该套餐需实名认证后购买 |
| 1188 | CodeOpenIDNotFound | 未找到微信授权信息,请先完成授权 |
## 数据库变更
- 表:`tb_asset_recharge_record`
- 新增字段:`auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL`
- 迁移版本000084

View File

@@ -0,0 +1,556 @@
package app
import (
"context"
"sort"
"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"
"github.com/break/junhong_cmp_fiber/internal/store"
"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"
)
// ClientAssetHandler C 端资产信息处理器
// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口
type ClientAssetHandler struct {
assetService *asset.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
assetWalletStore *postgres.AssetWalletStore
packageStore *postgres.PackageStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
db *gorm.DB
logger *zap.Logger
}
// NewClientAssetHandler 创建 C 端资产信息处理器
func NewClientAssetHandler(
assetService *asset.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
assetWalletStore *postgres.AssetWalletStore,
packageStore *postgres.PackageStore,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
db *gorm.DB,
logger *zap.Logger,
) *ClientAssetHandler {
return &ClientAssetHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
assetWalletStore: assetWalletStore,
packageStore: packageStore,
shopPackageAllocationStore: shopPackageAllocationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
db: db,
logger: logger,
}
}
type resolvedAssetContext struct {
CustomerID uint
Identifier string
Asset *dto.AssetResolveResponse
Generation int
WalletBalance int64
SkipPermissionCtx context.Context
IsAgentChannel bool
SellerShopID uint
MainPackageActived bool
}
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
// 处理流程:客户鉴权 -> 标识符解析 -> 资产解析 -> 归属校验 -> 世代与钱包信息补齐
func (h *ClientAssetHandler) 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 == "" {
var req dto.AssetRefreshRequest
if err := c.BodyParser(&req); err == nil {
identifier = strings.TrimSpace(req.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
}
walletBalance, walletErr := h.getAssetWalletBalance(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
if walletErr != nil {
return nil, walletErr
}
ctxInfo := &resolvedAssetContext{
CustomerID: customerID,
Identifier: identifier,
Asset: assetInfo,
Generation: generation,
WalletBalance: walletBalance,
SkipPermissionCtx: skipPermissionCtx,
}
if assetInfo.ShopID != nil && *assetInfo.ShopID > 0 {
ctxInfo.IsAgentChannel = true
ctxInfo.SellerShopID = *assetInfo.ShopID
}
return ctxInfo, nil
}
// GetAssetInfo B1 资产信息
// GET /api/c/v1/asset/info
func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
var req dto.AssetInfoRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
resp := &dto.AssetInfoResponse{
AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID,
Identifier: resolved.Identifier,
VirtualNo: resolved.Asset.VirtualNo,
Status: resolved.Asset.Status,
RealNameStatus: resolved.Asset.RealNameStatus,
CarrierName: resolved.Asset.CarrierName,
Generation: strconv.Itoa(resolved.Generation),
WalletBalance: resolved.WalletBalance,
}
return response.Success(c, resp)
}
// GetAvailablePackages B2 资产可购套餐列表
// GET /api/c/v1/asset/packages
func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
var req dto.AssetPackageListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
if resolved.Asset.SeriesID == nil || *resolved.Asset.SeriesID == 0 {
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
}
allUsages, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID)
if err != nil {
return err
}
resolved.MainPackageActived = hasActiveMainPackage(allUsages)
listCtx := resolved.SkipPermissionCtx
if resolved.IsAgentChannel {
listCtx = context.WithValue(listCtx, constants.ContextKeyUserType, constants.UserTypeAgent)
listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID)
}
pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{
Page: 1,
PageSize: constants.MaxPageSize,
OrderBy: "id DESC",
}, map[string]any{
"series_id": *resolved.Asset.SeriesID,
"status": constants.StatusEnabled,
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败")
}
allocationMap := make(map[uint]*model.ShopPackageAllocation)
if resolved.IsAgentChannel {
packageIDs := collectPackageIDs(pkgs)
allocations, allocErr := h.shopPackageAllocationStore.GetByShopAndPackages(
resolved.SkipPermissionCtx,
resolved.SellerShopID,
packageIDs,
)
if allocErr != nil {
return errors.Wrap(errors.CodeDatabaseError, allocErr, "查询套餐分配记录失败")
}
for _, allocation := range allocations {
allocationMap[allocation.PackageID] = allocation
}
}
items := make([]dto.ClientPackageItem, 0, len(pkgs))
for _, pkg := range pkgs {
item, ok := buildClientPackageItem(pkg, resolved, allocationMap)
if !ok {
continue
}
items = append(items, item)
}
if len(items) == 0 {
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
}
sort.Slice(items, func(i, j int) bool {
return items[i].RetailPrice < items[j].RetailPrice
})
return response.Success(c, &dto.AssetPackageListResponse{Packages: items})
}
// GetPackageHistory B3 资产套餐历史
// GET /api/c/v1/asset/package-history
func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
var req dto.AssetPackageHistoryRequest
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.PackageUsage{}).
Where("generation = ?", resolved.Generation)
if resolved.Asset.AssetType == "card" {
query = query.Where("iot_card_id = ?", resolved.Asset.AssetID)
} else {
query = query.Where("device_id = ?", resolved.Asset.AssetID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败")
}
var usages []*model.PackageUsage
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
}
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages)
if err != nil {
return err
}
list := make([]dto.AssetPackageResponse, 0, len(usages))
for _, usage := range usages {
pkg := packageMap[usage.PackageID]
ratio := 1.0
pkgName := ""
pkgType := ""
if pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
pkgType = pkg.PackageType
}
list = append(list, dto.AssetPackageResponse{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: pkgName,
PackageType: pkgType,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
DataLimitMB: usage.DataLimitMB,
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
DataUsageMB: usage.DataUsageMB,
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
VirtualRatio: ratio,
ActivatedAt: nonZeroTimePtr(usage.ActivatedAt),
ExpiresAt: nonZeroTimePtr(usage.ExpiresAt),
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,
})
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
// RefreshAsset B4 资产刷新
// POST /api/c/v1/asset/refresh
func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
var req dto.AssetRefreshRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
if _, err := h.assetService.Refresh(
resolved.SkipPermissionCtx,
resolved.Asset.AssetType,
resolved.Asset.AssetID,
); err != nil {
return err
}
resp := &dto.AssetRefreshResponse{
RefreshType: resolved.Asset.AssetType,
Accepted: true,
CooldownSeconds: 0,
}
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
resp.CooldownSeconds = int(constants.DeviceRefreshCooldownDuration / time.Second)
}
return response.Success(c, resp)
}
func (h *ClientAssetHandler) 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 *ClientAssetHandler) 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 *ClientAssetHandler) getAssetWalletBalance(ctx context.Context, assetType string, assetID uint) (int64, error) {
resourceType := constants.AssetWalletResourceTypeIotCard
if assetType == constants.ResourceTypeDevice {
resourceType = constants.AssetWalletResourceTypeDevice
}
wallet, err := h.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
}
return wallet.Balance, nil
}
func (h *ClientAssetHandler) loadPackageMap(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) {
ids := make([]uint, 0, len(usages))
seen := make(map[uint]struct{}, len(usages))
for _, usage := range usages {
if usage == nil {
continue
}
if _, ok := seen[usage.PackageID]; ok {
continue
}
seen[usage.PackageID] = struct{}{}
ids = append(ids, usage.PackageID)
}
packages, err := h.packageStore.GetByIDsUnscoped(ctx, ids)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
result := make(map[uint]*model.Package, len(packages))
for _, pkg := range packages {
result[pkg.ID] = pkg
}
return result, nil
}
func collectPackageIDs(pkgs []*model.Package) []uint {
ids := make([]uint, 0, len(pkgs))
for _, pkg := range pkgs {
if pkg == nil {
continue
}
ids = append(ids, pkg.ID)
}
return ids
}
func hasActiveMainPackage(usages []*dto.AssetPackageResponse) bool {
for _, usage := range usages {
if usage == nil {
continue
}
if usage.PackageType == constants.PackageTypeFormal && usage.Status == constants.PackageUsageStatusActive {
return true
}
}
return false
}
func buildClientPackageItem(
pkg *model.Package,
resolved *resolvedAssetContext,
allocationMap map[uint]*model.ShopPackageAllocation,
) (dto.ClientPackageItem, bool) {
if pkg == nil || pkg.Status != constants.StatusEnabled {
return dto.ClientPackageItem{}, false
}
isAddon := pkg.PackageType == constants.PackageTypeAddon
if isAddon && !resolved.MainPackageActived {
return dto.ClientPackageItem{}, false
}
retailPrice := pkg.SuggestedRetailPrice
costPrice := pkg.CostPrice
if resolved.IsAgentChannel {
allocation, ok := allocationMap[pkg.ID]
if !ok || allocation == nil {
return dto.ClientPackageItem{}, false
}
if allocation.ShelfStatus != constants.ShelfStatusOn || allocation.Status != constants.StatusEnabled {
return dto.ClientPackageItem{}, false
}
retailPrice = allocation.RetailPrice
costPrice = allocation.CostPrice
} else if pkg.ShelfStatus != constants.ShelfStatusOn {
return dto.ClientPackageItem{}, false
}
if retailPrice < costPrice {
return dto.ClientPackageItem{}, false
}
validityDays := pkg.DurationDays
if validityDays <= 0 && pkg.DurationMonths > 0 {
validityDays = pkg.DurationMonths * 30
}
dataAllowance := pkg.VirtualDataMB
if dataAllowance <= 0 {
dataAllowance = pkg.RealDataMB
}
return dto.ClientPackageItem{
PackageID: pkg.ID,
PackageName: pkg.PackageName,
PackageType: pkg.PackageType,
RetailPrice: retailPrice,
CostPrice: costPrice,
ValidityDays: validityDays,
IsAddon: isAddon,
DataAllowance: dataAllowance,
DataUnit: "MB",
Description: pkg.PackageCode,
}, true
}
func nonZeroTimePtr(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func safeVirtualRatio(ratio float64) float64 {
if ratio <= 0 {
return 1.0
}
return ratio
}
func packageStatusName(status int) string {
switch status {
case constants.PackageUsageStatusPending:
return "待生效"
case constants.PackageUsageStatusActive:
return "生效中"
case constants.PackageUsageStatusDepleted:
return "已用完"
case constants.PackageUsageStatusExpired:
return "已过期"
case constants.PackageUsageStatusInvalidated:
return "已失效"
default:
return "未知"
}
}

View File

@@ -0,0 +1,317 @@
package app
import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
var clientDeviceValidator = validator.New()
// deviceAssetInfo validateDeviceAsset 解析后的设备资产信息
type deviceAssetInfo struct {
DeviceID uint // 设备数据库 ID
IMEI string // 设备 IMEI用于 Gateway API 调用)
VirtualNo string // 设备虚拟号(用于所有权校验)
}
// ClientDeviceHandler C 端设备能力处理器
// 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作
type ClientDeviceHandler struct {
assetService *assetSvc.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client
logger *zap.Logger
}
// NewClientDeviceHandler 创建 C 端设备能力处理器
func NewClientDeviceHandler(
assetService *assetSvc.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *ClientDeviceHandler {
return &ClientDeviceHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
gatewayClient: gatewayClient,
logger: logger,
}
}
// validateDeviceAsset 校验设备资产的所有权和有效性
// 流程:认证 → 资产解析 → 类型校验(仅设备)→ 所有权校验 → IMEI 校验
func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier string) (*deviceAssetInfo, error) {
// 获取当前登录的个人客户 ID
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
ctx := c.UserContext()
// 通过标识符解析资产
asset, err := h.assetService.Resolve(ctx, identifier)
if err != nil {
return nil, err
}
// 仅设备资产支持设备能力操作
if asset.AssetType != "device" {
return nil, errors.New(errors.CodeInvalidParam, "仅设备资产支持该操作")
}
// 校验个人客户对该设备的所有权
owns, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
if err != nil {
h.logger.Error("校验设备所有权失败",
zap.Uint("customer_id", customerID),
zap.String("virtual_no", asset.VirtualNo),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError)
}
if !owns {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 校验设备 IMEI 是否存在Gateway API 调用必需)
if asset.IMEI == "" {
return nil, errors.New(errors.CodeInvalidParam, "设备IMEI缺失")
}
return &deviceAssetInfo{
DeviceID: asset.AssetID,
IMEI: asset.IMEI,
VirtualNo: asset.VirtualNo,
}, nil
}
// GetDeviceCards F1 获取设备卡列表
// GET /api/c/v1/device/cards
func (h *ClientDeviceHandler) GetDeviceCards(c *fiber.Ctx) error {
var req dto.DeviceCardListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备卡列表参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
ctx := c.UserContext()
// 查询设备绑定的所有 SIM 卡
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, info.DeviceID)
if err != nil {
h.logger.Error("查询设备SIM绑定失败",
zap.Uint("device_id", info.DeviceID),
zap.Error(err))
return errors.New(errors.CodeInternalError)
}
// 无绑定卡时返回空列表
if len(bindings) == 0 {
return response.Success(c, &dto.DeviceCardListResponse{Cards: []dto.DeviceCardItem{}})
}
// 收集卡 ID 并记录插槽位置映射
cardIDs := make([]uint, 0, len(bindings))
slotMap := make(map[uint]int, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
slotMap[b.IotCardID] = b.SlotPosition
}
// 批量查询卡详情
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
h.logger.Error("批量查询IoT卡失败",
zap.Uints("card_ids", cardIDs),
zap.Error(err))
return errors.New(errors.CodeInternalError)
}
// 组装响应slot_position == 1 视为当前激活卡
items := make([]dto.DeviceCardItem, 0, len(cards))
for _, card := range cards {
slot := slotMap[card.ID]
items = append(items, dto.DeviceCardItem{
CardID: card.ID,
ICCID: card.ICCID,
MSISDN: card.MSISDN,
CarrierName: card.CarrierName,
NetworkStatus: networkStatusText(card.NetworkStatus),
RealNameStatus: card.RealNameStatus,
SlotPosition: slot,
IsActive: slot == 1,
})
}
return response.Success(c, &dto.DeviceCardListResponse{Cards: items})
}
// RebootDevice F2 设备重启
// POST /api/c/v1/device/reboot
func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
var req dto.DeviceRebootRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备重启参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 重启设备
if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
h.logger.Error("Gateway重启设备失败",
zap.String("imei", info.IMEI),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "设备重启失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// FactoryResetDevice F3 恢复出厂设置
// POST /api/c/v1/device/factory-reset
func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
var req dto.DeviceFactoryResetRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("恢复出厂设置参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 恢复出厂设置
if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
h.logger.Error("Gateway恢复出厂设置失败",
zap.String("imei", info.IMEI),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// SetWiFi F4 设备WiFi配置
// POST /api/c/v1/device/wifi
// 注意WiFiReq.CardNo 字段名具有误导性,实际传入的是设备 IMEI而非卡号
func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
var req dto.DeviceWifiRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("WiFi配置参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 配置 WiFi
// CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI
if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{
CardNo: info.IMEI,
DeviceID: info.IMEI,
SSID: req.SSID,
Password: req.Password,
Enabled: req.Enabled,
}); err != nil {
h.logger.Error("Gateway配置WiFi失败",
zap.String("imei", info.IMEI),
zap.String("ssid", req.SSID),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "WiFi配置失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// SwitchCard F5 设备切卡
// POST /api/c/v1/device/switch-card
func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
var req dto.DeviceSwitchCardRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备切卡参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 切卡CardNo 传设备 IMEI
if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{
CardNo: info.IMEI,
ICCID: req.TargetICCID,
}); err != nil {
h.logger.Error("Gateway切卡失败",
zap.String("imei", info.IMEI),
zap.String("target_iccid", req.TargetICCID),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "设备切卡失败")
}
return response.Success(c, &dto.DeviceSwitchCardResponse{
Accepted: true,
TargetICCID: req.TargetICCID,
})
}
// networkStatusText 将网络状态码转为文本描述
func networkStatusText(status int) string {
switch status {
case 0:
return "停机"
case 1:
return "开机"
default:
return "未知"
}
}

View File

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

View File

@@ -0,0 +1,249 @@
package app
import (
"strings"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"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"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
"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/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
)
var clientRealnameValidator = validator.New()
// ClientRealnameHandler C 端实名认证处理器
type ClientRealnameHandler struct {
assetService *assetService.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
carrierStore *postgres.CarrierStore
gatewayClient *gateway.Client
logger *zap.Logger
}
// NewClientRealnameHandler 创建 C 端实名认证处理器
func NewClientRealnameHandler(
assetSvc *assetService.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
iotCardStore *postgres.IotCardStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
carrierStore *postgres.CarrierStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *ClientRealnameHandler {
return &ClientRealnameHandler{
assetService: assetSvc,
personalDeviceStore: personalDeviceStore,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
carrierStore: carrierStore,
gatewayClient: gatewayClient,
logger: logger,
}
}
// GetRealnameLink E1 获取实名认证链接
// GET /api/c/v1/realname/link
func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
// 1. 获取当前登录客户
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
// 2. 解析请求参数
var req dto.RealnimeLinkRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientRealnameValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("实名链接参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
ctx := c.UserContext()
// 3. 通过标识符解析资产
asset, err := h.assetService.Resolve(ctx, req.Identifier)
if err != nil {
return err
}
// 4. 验证资产归属(个人客户必须绑定过该资产)
owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
if err != nil {
logger.GetAppLogger().Error("查询资产归属失败",
zap.Uint("customer_id", customerID),
zap.String("virtual_no", asset.VirtualNo),
zap.Error(err))
return errors.New(errors.CodeInternalError, "查询资产归属失败")
}
if !owned {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 5. 定位目标卡3 条路径)
var targetCard *model.IotCard
switch {
case asset.AssetType == "card":
// 路径 1资产本身就是卡直接使用
card, cardErr := h.iotCardStore.GetByID(ctx, asset.AssetID)
if cardErr != nil {
return errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
}
targetCard = card
case asset.AssetType == "device" && req.ICCID != "":
// 路径 2资产是设备指定了 ICCID从设备绑定中查找该卡
card, cardErr := h.findCardInDeviceBindings(c, asset.AssetID, req.ICCID)
if cardErr != nil {
return cardErr
}
targetCard = card
case asset.AssetType == "device":
// 路径 3资产是设备未指定 ICCID取第一张绑定卡按插槽位置排序
card, cardErr := h.findFirstBoundCard(c, asset.AssetID)
if cardErr != nil {
return cardErr
}
targetCard = card
default:
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
}
// 6. 检查实名状态
if targetCard.RealNameStatus == 1 {
return errors.New(errors.CodeInvalidStatus, "该卡已完成实名")
}
// 7. 获取运营商信息,根据实名链接类型生成 URL
carrier, err := h.carrierStore.GetByID(ctx, targetCard.CarrierID)
if err != nil {
logger.GetAppLogger().Error("查询运营商失败",
zap.Uint("carrier_id", targetCard.CarrierID),
zap.Error(err))
return errors.New(errors.CodeCarrierNotFound, "运营商信息查询失败")
}
resp := &dto.RealnimeLinkResponse{
CardInfo: dto.CardInfoBrief{
ICCID: targetCard.ICCID,
MSISDN: targetCard.MSISDN,
VirtualNo: targetCard.VirtualNo,
},
}
switch carrier.RealnameLinkType {
case constants.RealnameLinkTypeNone:
// 该运营商不支持在线实名
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
case constants.RealnameLinkTypeTemplate:
// 模板模式:替换占位符生成实名链接
url := carrier.RealnameLinkTemplate
url = strings.ReplaceAll(url, "{iccid}", targetCard.ICCID)
url = strings.ReplaceAll(url, "{msisdn}", targetCard.MSISDN)
url = strings.ReplaceAll(url, "{virtual_no}", targetCard.VirtualNo)
resp.RealnameMode = constants.RealnameLinkTypeTemplate
resp.RealnameURL = url
case constants.RealnameLinkTypeGateway:
// 网关模式:调用 Gateway 接口获取实名链接
linkResp, gwErr := h.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: targetCard.ICCID,
})
if gwErr != nil {
logger.GetAppLogger().Error("Gateway 获取实名链接失败",
zap.String("iccid", targetCard.ICCID),
zap.Error(gwErr))
return errors.Wrap(errors.CodeGatewayError, gwErr, "获取实名链接失败")
}
resp.RealnameMode = constants.RealnameLinkTypeGateway
resp.RealnameURL = linkResp.URL
default:
logger.GetAppLogger().Warn("未知的实名链接类型",
zap.Uint("carrier_id", carrier.ID),
zap.String("realname_link_type", carrier.RealnameLinkType))
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
}
return response.Success(c, resp)
}
// findCardInDeviceBindings 在设备绑定中查找指定 ICCID 的卡
func (h *ClientRealnameHandler) findCardInDeviceBindings(c *fiber.Ctx, deviceID uint, iccid string) (*model.IotCard, error) {
ctx := c.UserContext()
// 查询设备的所有有效绑定
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Error("查询设备绑定失败",
zap.Uint("device_id", deviceID),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
}
// 收集所有绑定卡的 ID
cardIDs := make([]uint, 0, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
}
if len(cardIDs) == 0 {
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
}
// 批量查询卡,匹配指定的 ICCID
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return nil, errors.New(errors.CodeInternalError, "查询卡信息失败")
}
for _, card := range cards {
if card.ICCID == iccid {
return card, nil
}
}
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定指定的 ICCID")
}
// findFirstBoundCard 获取设备第一张绑定卡(按插槽位置排序,取第一张)
func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint) (*model.IotCard, error) {
ctx := c.UserContext()
// ListByDeviceID 返回 bind_status=1 的绑定,按 slot_position ASC 排序
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Error("查询设备绑定失败",
zap.Uint("device_id", deviceID),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
}
if len(bindings) == 0 {
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
}
// 取第一张绑定卡(插槽位置最小的)
card, err := h.iotCardStore.GetByID(ctx, bindings[0].IotCardID)
if err != nil {
return nil, errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
}
return card, nil
}

View File

@@ -0,0 +1,660 @@
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 != "<nil>" {
return text
}
}
}
return ""
}

View File

@@ -0,0 +1,86 @@
package dto
// ========================================
// B1 资产信息
// ========================================
// AssetInfoRequest B1 资产信息请求
type AssetInfoRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// AssetInfoResponse B1 资产信息响应
type AssetInfoResponse struct {
AssetType string `json:"asset_type" description:"资产类型 (card:卡, device:设备)"`
AssetID uint `json:"asset_id" description:"资产ID"`
Identifier string `json:"identifier" description:"资产标识符"`
VirtualNo string `json:"virtual_no" description:"虚拟号"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
Generation string `json:"generation" description:"制式"`
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"`
}
// ========================================
// B2 资产可购套餐列表
// ========================================
// AssetPackageListRequest B2 资产可购套餐列表请求
type AssetPackageListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// ClientPackageItem B2 客户端套餐项
type ClientPackageItem struct {
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
RetailPrice int64 `json:"retail_price" description:"零售价(分)"`
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
ValidityDays int `json:"validity_days" description:"有效天数"`
IsAddon bool `json:"is_addon" description:"是否加油包"`
DataAllowance int64 `json:"data_allowance" description:"流量额度"`
DataUnit string `json:"data_unit" description:"流量单位"`
Description string `json:"description" description:"套餐说明"`
}
// AssetPackageListResponse B2 资产可购套餐列表响应
type AssetPackageListResponse struct {
Packages []ClientPackageItem `json:"packages" description:"套餐列表"`
}
// ========================================
// B3 资产套餐历史
// ========================================
// AssetPackageHistoryRequest B3 资产套餐历史请求
type AssetPackageHistoryRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// AssetPackageHistoryResponse B3 资产套餐历史响应
type AssetPackageHistoryResponse struct {
List []AssetPackageResponse `json:"list" description:"套餐历史列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}
// ========================================
// B4 资产刷新
// ========================================
// AssetRefreshRequest B4 资产刷新请求
type AssetRefreshRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// AssetRefreshResponse B4 资产刷新响应
type AssetRefreshResponse struct {
RefreshType string `json:"refresh_type" description:"刷新类型 (card:卡, device:设备)"`
Accepted bool `json:"accepted" description:"是否已受理"`
CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"`
}

View File

@@ -0,0 +1,113 @@
package dto
// ========================================
// D1 客户端创建订单
// ========================================
// ClientCreateOrderRequest D1 客户端创建订单请求
type ClientCreateOrderRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"`
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
}
// ClientCreateOrderResponse D1 客户端创建订单响应
type ClientCreateOrderResponse struct {
OrderType string `json:"order_type" description:"订单类型 (package:套餐订单, recharge:充值订单)"`
Order *ClientOrderInfo `json:"order,omitempty" description:"套餐订单信息"`
Recharge *ClientRechargeInfo `json:"recharge,omitempty" description:"充值订单信息"`
PayConfig *ClientPayConfig `json:"pay_config" description:"支付配置"`
LinkedPackageInfo *LinkedPackageInfo `json:"linked_package_info,omitempty" description:"关联套餐信息"`
}
// ClientOrderInfo D1 套餐订单信息
type ClientOrderInfo struct {
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
CreatedAt string `json:"created_at" description:"创建时间"`
}
// ClientRechargeInfo D1 充值订单信息
type ClientRechargeInfo struct {
RechargeID uint `json:"recharge_id" description:"充值ID"`
RechargeNo string `json:"recharge_no" description:"充值单号"`
Amount int64 `json:"amount" description:"充值金额(分)"`
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"`
}
// ClientPayConfig D1 支付配置
type ClientPayConfig struct {
AppID string `json:"app_id" description:"应用ID"`
Timestamp string `json:"timestamp" description:"时间戳"`
NonceStr string `json:"nonce_str" description:"随机字符串"`
PackageVal string `json:"package" description:"预支付参数"`
SignType string `json:"sign_type" description:"签名类型"`
PaySign string `json:"pay_sign" description:"支付签名"`
}
// LinkedPackageInfo D1 关联套餐信息
type LinkedPackageInfo struct {
PackageNames []string `json:"package_names" description:"套餐名称列表"`
TotalPackageAmount int64 `json:"total_package_amount" description:"套餐总金额(分)"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
WalletCredit int64 `json:"wallet_credit" description:"钱包抵扣金额(分)"`
}
// ========================================
// D2 客户端订单列表
// ========================================
// ClientOrderListRequest D2 客户端订单列表请求
type ClientOrderListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// ClientOrderListItem D2 客户端订单列表项
type ClientOrderListItem struct {
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
CreatedAt string `json:"created_at" description:"创建时间"`
PackageNames []string `json:"package_names" description:"套餐名称列表"`
}
// ClientOrderListResponse D2 客户端订单列表响应
type ClientOrderListResponse struct {
List []ClientOrderListItem `json:"list" description:"订单列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}
// ========================================
// D3 客户端订单详情
// ========================================
// ClientOrderDetailResponse D3 客户端订单详情响应
type ClientOrderDetailResponse struct {
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
PaymentMethod string `json:"payment_method" description:"支付方式"`
CreatedAt string `json:"created_at" description:"创建时间"`
PaidAt *string `json:"paid_at,omitempty" description:"支付时间"`
CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"`
Packages []ClientOrderPackageItem `json:"packages" description:"订单套餐列表"`
}
// ClientOrderPackageItem D3 订单套餐项
type ClientOrderPackageItem struct {
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
Price int64 `json:"price" description:"单价(分)"`
Quantity int `json:"quantity" description:"数量"`
}

View File

@@ -0,0 +1,104 @@
package dto
// ========================================
// E1 实名链接获取
// ========================================
// RealnimeLinkRequest E1 实名链接请求
type RealnimeLinkRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=30" maxLength:"30" description:"物联网卡ICCID"`
}
// RealnimeLinkResponse E1 实名链接响应
type RealnimeLinkResponse struct {
RealnameMode string `json:"realname_mode" description:"实名模式 (none:无需实名, template:模板实名, gateway:网关实名)"`
RealnameURL string `json:"realname_url" description:"实名链接"`
CardInfo CardInfoBrief `json:"card_info" description:"卡片简要信息"`
ExpireAt *string `json:"expire_at,omitempty" description:"过期时间"`
}
// CardInfoBrief E1 卡片简要信息
type CardInfoBrief struct {
ICCID string `json:"iccid" description:"物联网卡ICCID"`
MSISDN string `json:"msisdn" description:"手机号"`
VirtualNo string `json:"virtual_no" description:"虚拟号"`
}
// ========================================
// F1 设备卡列表
// ========================================
// DeviceCardListRequest F1 设备卡列表请求
type DeviceCardListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// DeviceCardItem F1 设备卡项
type DeviceCardItem struct {
CardID uint `json:"card_id" description:"卡ID"`
ICCID string `json:"iccid" description:"物联网卡ICCID"`
MSISDN string `json:"msisdn" description:"手机号"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
NetworkStatus string `json:"network_status" description:"网络状态"`
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
SlotPosition int `json:"slot_position" description:"插槽位置"`
IsActive bool `json:"is_active" description:"是否当前激活卡"`
}
// DeviceCardListResponse F1 设备卡列表响应
type DeviceCardListResponse struct {
Cards []DeviceCardItem `json:"cards" description:"设备卡列表"`
}
// ========================================
// F2 设备重启
// ========================================
// DeviceRebootRequest F2 设备重启请求
type DeviceRebootRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// DeviceOperationResponse F2/F3/F4 设备操作响应
type DeviceOperationResponse struct {
Accepted bool `json:"accepted" description:"是否已受理"`
RequestID string `json:"request_id,omitempty" description:"请求ID"`
}
// ========================================
// F3 恢复出厂设置
// ========================================
// DeviceFactoryResetRequest F3 恢复出厂设置请求
type DeviceFactoryResetRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// ========================================
// F4 设备WiFi配置
// ========================================
// DeviceWifiRequest F4 设备WiFi配置请求
type DeviceWifiRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
SSID string `json:"ssid" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"WiFi名称"`
Password string `json:"password" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"WiFi密码"`
Enabled bool `json:"enabled" validate:"required" required:"true" description:"是否启用WiFi"`
}
// ========================================
// F5 设备切卡
// ========================================
// DeviceSwitchCardRequest F5 设备切卡请求
type DeviceSwitchCardRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
TargetICCID string `json:"target_iccid" validate:"required,min=1,max=30" required:"true" minLength:"1" maxLength:"30" description:"目标ICCID"`
}
// DeviceSwitchCardResponse F5 设备切卡响应
type DeviceSwitchCardResponse struct {
Accepted bool `json:"accepted" description:"是否已受理"`
TargetICCID string `json:"target_iccid" description:"目标ICCID"`
}

View File

@@ -0,0 +1,138 @@
package dto
// ========================================
// C1 钱包详情
// ========================================
// WalletDetailRequest C1 钱包详情请求
type WalletDetailRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// WalletDetailResponse C1 钱包详情响应
type WalletDetailResponse struct {
WalletID uint `json:"wallet_id" description:"钱包ID"`
ResourceType string `json:"resource_type" description:"资源类型 (iot_card:物联网卡, device:设备)"`
ResourceID uint `json:"resource_id" description:"资源ID"`
Balance int64 `json:"balance" description:"可用余额(分)"`
FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// ========================================
// C2 钱包流水列表
// ========================================
// WalletTransactionListRequest C2 钱包流水列表请求
type WalletTransactionListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
TransactionType string `json:"transaction_type" query:"transaction_type" validate:"omitempty,max=50" maxLength:"50" description:"流水类型"`
StartTime string `json:"start_time" query:"start_time" validate:"omitempty,max=32" maxLength:"32" description:"开始时间"`
EndTime string `json:"end_time" query:"end_time" validate:"omitempty,max=32" maxLength:"32" description:"结束时间"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// WalletTransactionItem C2 钱包流水项
type WalletTransactionItem struct {
TransactionID uint `json:"transaction_id" description:"流水ID"`
Type string `json:"type" description:"流水类型"`
Amount int64 `json:"amount" description:"变动金额(分)"`
BalanceAfter int64 `json:"balance_after" description:"变动后余额(分)"`
CreatedAt string `json:"created_at" description:"创建时间"`
Remark string `json:"remark" description:"备注"`
}
// WalletTransactionListResponse C2 钱包流水列表响应
type WalletTransactionListResponse struct {
List []WalletTransactionItem `json:"list" description:"流水列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}
// ========================================
// C3 充值前校验
// ========================================
// ClientRechargeCheckRequest C3 充值前校验请求
type ClientRechargeCheckRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// ClientRechargeCheckResponse C3 充值前校验响应
type ClientRechargeCheckResponse struct {
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
TriggerType string `json:"trigger_type" description:"触发类型"`
MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"`
MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"`
Message string `json:"message" description:"提示信息"`
}
// ========================================
// C4 创建充值订单
// ========================================
// ClientCreateRechargeRequest C4 创建充值订单请求
type ClientCreateRechargeRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
Amount int64 `json:"amount" validate:"required,min=100,max=10000000" required:"true" minimum:"100" maximum:"10000000" description:"充值金额(分)"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat" required:"true" description:"支付方式 (wechat:微信支付)"`
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
}
// ClientRechargeResponse C4 创建充值订单响应
type ClientRechargeResponse struct {
Recharge ClientRechargeResult `json:"recharge" description:"充值信息"`
PayConfig ClientRechargePayConfig `json:"pay_config" description:"支付配置"`
}
// ClientRechargeResult C4 充值信息
type ClientRechargeResult struct {
RechargeID uint `json:"recharge_id" description:"充值ID"`
RechargeNo string `json:"recharge_no" description:"充值单号"`
Amount int64 `json:"amount" description:"充值金额(分)"`
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
}
// ClientRechargePayConfig C4 支付配置
type ClientRechargePayConfig struct {
AppID string `json:"app_id" description:"应用ID"`
Timestamp string `json:"timestamp" description:"时间戳"`
NonceStr string `json:"nonce_str" description:"随机字符串"`
PackageVal string `json:"package" description:"预支付参数"`
SignType string `json:"sign_type" description:"签名类型"`
PaySign string `json:"pay_sign" description:"支付签名"`
}
// ========================================
// C5 充值记录列表
// ========================================
// ClientRechargeListRequest C5 充值记录列表请求
type ClientRechargeListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"充值状态 (0:待支付, 1:已支付, 2:已关闭)"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// ClientRechargeListItem C5 充值记录项
type ClientRechargeListItem struct {
RechargeID uint `json:"recharge_id" description:"充值ID"`
RechargeNo string `json:"recharge_no" description:"充值单号"`
Amount int64 `json:"amount" description:"充值金额(分)"`
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
PaymentMethod string `json:"payment_method" description:"支付方式"`
CreatedAt string `json:"created_at" description:"创建时间"`
AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"`
}
// ClientRechargeListResponse C5 充值记录列表响应
type ClientRechargeListResponse struct {
List []ClientRechargeListItem `json:"list" description:"充值记录列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}

View File

@@ -0,0 +1,701 @@
// Package client_order 提供 C 端订单下单服务。
package client_order
import (
"context"
"fmt"
"math/rand"
"slices"
"strconv"
"strings"
"time"
"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"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"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"
"gorm.io/datatypes"
"gorm.io/gorm"
)
const (
clientPurchaseIdempotencyTTL = 5 * time.Minute
clientPurchaseLockTTL = 10 * time.Second
)
// WechatConfigServiceInterface 微信配置服务接口。
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
}
// ForceRechargeRequirement 强充要求。
type ForceRechargeRequirement struct {
NeedForceRecharge bool
ForceRechargeAmount int64
}
// Service 客户端订单服务。
type Service struct {
assetService *asset.Service
purchaseValidationService *purchase_validation.Service
orderStore *postgres.OrderStore
rechargeRecordStore *postgres.AssetRechargeStore
walletStore *postgres.AssetWalletStore
personalDeviceStore *postgres.PersonalCustomerDeviceStore
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService WechatConfigServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
redis *redis.Client
logger *zap.Logger
}
// New 创建客户端订单服务。
func New(
assetService *asset.Service,
purchaseValidationService *purchase_validation.Service,
orderStore *postgres.OrderStore,
rechargeRecordStore *postgres.AssetRechargeStore,
walletStore *postgres.AssetWalletStore,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService WechatConfigServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
redisClient *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
assetService: assetService,
purchaseValidationService: purchaseValidationService,
orderStore: orderStore,
rechargeRecordStore: rechargeRecordStore,
walletStore: walletStore,
personalDeviceStore: personalDeviceStore,
openIDStore: openIDStore,
wechatConfigService: wechatConfigService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
redis: redisClient,
logger: logger,
}
}
// CreateOrder 创建客户端订单。
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.redis == nil {
return nil, errors.New(errors.CodeInternalError, "Redis 服务未配置")
}
skipPermissionCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := s.assetService.Resolve(skipPermissionCtx, strings.TrimSpace(req.Identifier))
if err != nil {
return nil, err
}
if err := s.checkAssetOwnership(skipPermissionCtx, customerID, assetInfo.VirtualNo); err != nil {
return nil, err
}
validationResult, err := s.validatePurchase(skipPermissionCtx, assetInfo, req.PackageIDs)
if err != nil {
return nil, err
}
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
return nil, errors.New(errors.CodeNeedRealname)
}
activeConfig, appID, err := s.resolveWechatConfig(skipPermissionCtx, req.AppType)
if err != nil {
return nil, err
}
openID, err := s.resolveCustomerOpenID(skipPermissionCtx, customerID, appID)
if err != nil {
return nil, err
}
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req)
redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey)
lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID)
lockAcquired, err := s.redis.SetNX(skipPermissionCtx, lockKey, time.Now().String(), clientPurchaseLockTTL).Result()
if err != nil {
s.logger.Warn("获取客户端购买分布式锁失败,继续尝试幂等标记",
zap.Error(err),
zap.String("lock_key", lockKey),
)
}
if err == nil && !lockAcquired {
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
claimed, err := s.redis.SetNX(skipPermissionCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result()
if err != nil {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
}
return nil, errors.Wrap(errors.CodeInternalError, err, "设置客户端购买幂等标记失败")
}
if !claimed {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
}
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
created := false
defer func() {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
}
if !created {
_ = s.redis.Del(skipPermissionCtx, redisKey).Err()
}
}()
paymentService, err := s.newPaymentService(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.createPackageOrder(skipPermissionCtx, customerID, appID, openID, validationResult, activeConfig, redisKey, paymentService, &created)
}
func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error {
owned, err := s.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
}
if owned {
return nil
}
records, err := s.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return nil
}
}
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) {
switch assetInfo.AssetType {
case "card":
return s.purchaseValidationService.ValidateCardPurchase(ctx, assetInfo.AssetID, packageIDs)
case constants.ResourceTypeDevice:
return s.purchaseValidationService.ValidateDevicePurchase(ctx, assetInfo.AssetID, packageIDs)
default:
return nil, errors.New(errors.CodeInvalidParam)
}
}
func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败")
}
if activeConfig == nil {
return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置")
}
switch appType {
case "official_account":
if activeConfig.OaAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整")
}
return activeConfig, activeConfig.OaAppID, nil
case "miniapp":
if activeConfig.MiniappAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整")
}
return activeConfig, activeConfig.MiniappAppID, nil
default:
return nil, "", errors.New(errors.CodeInvalidParam)
}
}
func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) {
records, err := s.openIDStore.ListByCustomerID(ctx, customerID)
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
}
for _, record := range records {
if record == nil {
continue
}
if record.AppID == appID && strings.TrimSpace(record.OpenID) != "" {
return record.OpenID, nil
}
}
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,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
order, err := s.buildPendingOrder(customerID, validationResult, activeConfig)
if err != nil {
return nil, err
}
items, err := s.buildOrderItems(ctx, customerID, validationResult)
if err != nil {
return nil, err
}
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo)
*created = true
description := "套餐购买"
if len(items) > 0 && items[0] != nil && items[0].PackageName != "" {
description = items[0].PackageName
}
payResult, err := paymentService.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
if err != nil {
return nil, err
}
return &dto.ClientCreateOrderResponse{
OrderType: "package",
Order: &dto.ClientOrderInfo{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: orderStatusToClientStatus(order.PaymentStatus),
CreatedAt: formatClientServiceTime(order.CreatedAt),
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
}, 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,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
resourceType, resourceID, err := resolveWalletResource(validationResult)
if err != nil {
return nil, err
}
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
}
linkedPackageIDs, err := sonic.Marshal(extractPackageIDs(validationResult.Packages))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化关联套餐失败")
}
carrierID := resourceID
recharge := &model.AssetRechargeRecord{
UserID: customerID,
AssetWalletID: wallet.ID,
ResourceType: resourceType,
ResourceID: resourceID,
RechargeNo: generateClientRechargeNo(),
Amount: forceRecharge.ForceRechargeAmount,
PaymentMethod: model.PaymentMethodWechat,
PaymentConfigID: &activeConfig.ID,
Status: 1,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: "personal_customer",
Generation: resolveGeneration(validationResult),
LinkedPackageIDs: datatypes.JSON(linkedPackageIDs),
LinkedOrderType: resolveOrderType(validationResult),
LinkedCarrierType: assetInfo.AssetType,
LinkedCarrierID: &carrierID,
AutoPurchaseStatus: constants.AutoPurchaseStatusPending,
}
if err := s.rechargeRecordStore.Create(ctx, recharge); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败")
}
s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo)
*created = true
payResult, err := paymentService.CreateJSAPIOrder(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
if err != nil {
return nil, err
}
return &dto.ClientCreateOrderResponse{
OrderType: "recharge",
Recharge: &dto.ClientRechargeInfo{
RechargeID: recharge.ID,
RechargeNo: recharge.RechargeNo,
Amount: recharge.Amount,
Status: rechargeStatusToClientStatus(recharge.Status),
AutoPurchaseStatus: recharge.AutoPurchaseStatus,
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge),
}, nil
}
func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig) (*model.Order, error) {
orderType := resolveOrderType(result)
if orderType == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
now := time.Now()
expiresAt := now.Add(constants.OrderExpireTimeout)
order := &model.Order{
BaseModel: model.BaseModel{
Creator: customerID,
Updater: customerID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: orderType,
BuyerType: model.BuyerTypePersonal,
BuyerID: customerID,
TotalAmount: result.TotalPrice,
PaymentMethod: model.PaymentMethodWechat,
PaymentStatus: model.PaymentStatusPending,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
Source: constants.OrderSourceClient,
Generation: resolveGeneration(result),
ExpiresAt: &expiresAt,
PaymentConfigID: &activeConfig.ID,
}
if result.Card != nil {
order.IotCardID = &result.Card.ID
order.SeriesID = result.Card.SeriesID
order.SellerShopID = result.Card.ShopID
} else if result.Device != nil {
order.DeviceID = &result.Device.ID
order.SeriesID = result.Device.SeriesID
order.SellerShopID = result.Device.ShopID
}
return order, nil
}
func (s *Service) buildOrderItems(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult) ([]*model.OrderItem, error) {
sellerShopID := resolveSellerShopID(result)
items := make([]*model.OrderItem, 0, len(result.Packages))
for _, pkg := range result.Packages {
if pkg == nil {
continue
}
unitPrice, err := s.purchaseValidationService.GetPurchasePrice(ctx, pkg, sellerShopID)
if err != nil {
return nil, err
}
items = append(items, &model.OrderItem{
BaseModel: model.BaseModel{
Creator: customerID,
Updater: customerID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
Quantity: 1,
UnitPrice: unitPrice,
Amount: unitPrice,
})
}
return items, nil
}
func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false}
var seriesID *uint
var sellerShopID uint
if result.Card != nil {
seriesID = result.Card.SeriesID
if result.Card.ShopID != nil {
sellerShopID = *result.Card.ShopID
}
} else if result.Device != nil {
seriesID = result.Device.SeriesID
if result.Device.ShopID != nil {
sellerShopID = *result.Device.ShopID
}
}
if seriesID == nil || *seriesID == 0 {
return defaultResult
}
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
if err != nil {
s.logger.Warn("查询套餐系列失败", zap.Uint("series_id", *seriesID), zap.Error(err))
return defaultResult
}
config, err := series.GetOneTimeCommissionConfig()
if err != nil || config == nil || !config.Enable {
return defaultResult
}
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: config.Threshold,
}
}
if config.EnableForceRecharge {
amount := config.ForceAmount
if amount == 0 {
amount = config.Threshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: amount,
}
}
if sellerShopID > 0 {
allocation, allocErr := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, sellerShopID, *seriesID)
if allocErr == nil && allocation.EnableForceRecharge {
amount := allocation.ForceRechargeAmount
if amount == 0 {
amount = config.Threshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: amount,
}
}
}
return defaultResult
}
func (s *Service) markClientPurchaseCreated(ctx context.Context, redisKey string, value string) {
if err := s.redis.Set(ctx, redisKey, value, clientPurchaseIdempotencyTTL).Err(); err != nil {
s.logger.Warn("设置客户端购买幂等标记失败",
zap.String("redis_key", redisKey),
zap.Error(err),
)
}
}
func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult, forceRecharge *ForceRechargeRequirement) *dto.LinkedPackageInfo {
packageNames := make([]string, 0, len(result.Packages))
for _, pkg := range result.Packages {
if pkg == nil || pkg.PackageName == "" {
continue
}
packageNames = append(packageNames, pkg.PackageName)
}
return &dto.LinkedPackageInfo{
PackageNames: packageNames,
TotalPackageAmount: result.TotalPrice,
ForceRechargeAmount: forceRecharge.ForceRechargeAmount,
WalletCredit: forceRecharge.ForceRechargeAmount,
}
}
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
}
if result.Device != nil {
return constants.AssetWalletResourceTypeDevice, result.Device.ID, nil
}
return "", 0, errors.New(errors.CodeInvalidParam)
}
func resolveOrderType(result *purchase_validation.PurchaseValidationResult) string {
if result.Card != nil {
return model.OrderTypeSingleCard
}
if result.Device != nil {
return model.OrderTypeDevice
}
return ""
}
func resolveGeneration(result *purchase_validation.PurchaseValidationResult) int {
if result.Card != nil && result.Card.Generation > 0 {
return result.Card.Generation
}
if result.Device != nil && result.Device.Generation > 0 {
return result.Device.Generation
}
return 1
}
func resolveSellerShopID(result *purchase_validation.PurchaseValidationResult) uint {
if result.Card != nil && result.Card.ShopID != nil {
return *result.Card.ShopID
}
if result.Device != nil && result.Device.ShopID != nil {
return *result.Device.ShopID
}
return 0
}
func packagesNeedRealname(packages []*model.Package) bool {
for _, pkg := range packages {
if pkg != nil && pkg.EnableRealnameActivation {
return true
}
}
return false
}
func extractPackageIDs(packages []*model.Package) []uint {
ids := make([]uint, 0, len(packages))
for _, pkg := range packages {
if pkg == nil {
continue
}
ids = append(ids, pkg.ID)
}
return ids
}
func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, req *dto.ClientCreateOrderRequest) string {
packageIDs := make([]uint, 0, len(req.PackageIDs))
packageIDs = append(packageIDs, req.PackageIDs...)
slices.Sort(packageIDs)
parts := make([]string, 0, len(packageIDs))
for _, packageID := range packageIDs {
parts = append(parts, strconv.FormatUint(uint64(packageID), 10))
}
return fmt.Sprintf("%d:%s:%d:%s:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, req.AppType, strings.Join(parts, ","))
}
func orderStatusToClientStatus(status int) int {
switch status {
case model.PaymentStatusPending:
return 0
case model.PaymentStatusPaid:
return 1
case model.PaymentStatusCancelled:
return 2
default:
return status
}
}
func rechargeStatusToClientStatus(status int) int {
switch status {
case 1:
return 0
case 2, 3:
return 1
default:
return 2
}
}
func formatClientServiceTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
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)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,556 @@
package task
import (
"context"
"errors"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// AutoPurchasePayload 充值后自动购包任务载荷
type AutoPurchasePayload struct {
RechargeRecordID uint `json:"recharge_record_id"`
}
// AutoPurchaseHandler 充值后自动购包任务处理器
type AutoPurchaseHandler struct {
db *gorm.DB
orderStore *postgres.OrderStore
rechargeRecordStore *postgres.AssetRechargeStore
walletStore *postgres.AssetWalletStore
walletTransactionStore *postgres.AssetWalletTransactionStore
packageUsageStore *postgres.PackageUsageStore
redis *redis.Client
logger *zap.Logger
}
// NewAutoPurchaseHandler 创建充值后自动购包处理器
func NewAutoPurchaseHandler(
db *gorm.DB,
orderStore *postgres.OrderStore,
rechargeRecordStore *postgres.AssetRechargeStore,
walletStore *postgres.AssetWalletStore,
walletTransactionStore *postgres.AssetWalletTransactionStore,
packageUsageStore *postgres.PackageUsageStore,
redisClient *redis.Client,
logger *zap.Logger,
) *AutoPurchaseHandler {
if orderStore == nil {
orderStore = postgres.NewOrderStore(db, redisClient)
}
if rechargeRecordStore == nil {
rechargeRecordStore = postgres.NewAssetRechargeStore(db, redisClient)
}
if walletStore == nil {
walletStore = postgres.NewAssetWalletStore(db, redisClient)
}
if walletTransactionStore == nil {
walletTransactionStore = postgres.NewAssetWalletTransactionStore(db, redisClient)
}
if packageUsageStore == nil {
packageUsageStore = postgres.NewPackageUsageStore(db, redisClient)
}
return &AutoPurchaseHandler{
db: db,
orderStore: orderStore,
rechargeRecordStore: rechargeRecordStore,
walletStore: walletStore,
walletTransactionStore: walletTransactionStore,
packageUsageStore: packageUsageStore,
redis: redisClient,
logger: logger,
}
}
// ProcessTask 处理充值后自动购包任务
func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) error {
var payload AutoPurchasePayload
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
h.logger.Error("解析自动购包任务载荷失败", zap.Error(err))
return asynq.SkipRetry
}
if payload.RechargeRecordID == 0 {
h.logger.Error("自动购包任务载荷无效", zap.Uint("recharge_record_id", payload.RechargeRecordID))
return asynq.SkipRetry
}
rechargeRecord, err := h.rechargeRecordStore.GetByID(ctx, payload.RechargeRecordID)
if err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Warn("充值记录不存在,跳过自动购包", zap.Uint("recharge_record_id", payload.RechargeRecordID))
return asynq.SkipRetry
}
h.logger.Error("查询充值记录失败", zap.Uint("recharge_record_id", payload.RechargeRecordID), zap.Error(err))
return err
}
if rechargeRecord.AutoPurchaseStatus == constants.AutoPurchaseStatusSuccess {
return nil
}
if rechargeRecord.AutoPurchaseStatus == constants.AutoPurchaseStatusFailed {
return nil
}
packageIDs, err := parseLinkedPackageIDs(rechargeRecord.LinkedPackageIDs)
if err != nil {
h.logger.Error("解析关联套餐ID失败", zap.Uint("recharge_record_id", rechargeRecord.ID), zap.Error(err))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID)
return asynq.SkipRetry
}
if len(packageIDs) == 0 {
h.logger.Error("关联套餐ID为空无法自动购包", zap.Uint("recharge_record_id", rechargeRecord.ID))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID)
return asynq.SkipRetry
}
packages, totalAmount, err := h.loadPackages(ctx, packageIDs)
if err != nil {
h.logger.Error("加载关联套餐失败", zap.Uint("recharge_record_id", rechargeRecord.ID), zap.Error(err))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID)
return err
}
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
wallet, walletErr := h.walletStore.GetByID(ctx, rechargeRecord.AssetWalletID)
if walletErr != nil {
if walletErr == gorm.ErrRecordNotFound {
return errors.New("资产钱包不存在")
}
return walletErr
}
if wallet.GetAvailableBalance() < totalAmount {
return errors.New("钱包余额不足")
}
if err = h.walletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, totalAmount, wallet.Version); err != nil {
return err
}
now := time.Now()
order, orderItems, buildErr := h.buildOrderAndItems(rechargeRecord, packages, totalAmount, now)
if buildErr != nil {
return buildErr
}
if err = tx.Create(order).Error; err != nil {
return err
}
for _, item := range orderItems {
item.OrderID = order.ID
}
if err = tx.CreateInBatches(orderItems, 100).Error; err != nil {
return err
}
refType := constants.ReferenceTypeOrder
walletTx := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
UserID: rechargeRecord.UserID,
TransactionType: constants.AssetTransactionTypeDeduct,
Amount: -totalAmount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - totalAmount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceNo: &order.OrderNo,
Creator: rechargeRecord.UserID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err = h.walletTransactionStore.CreateWithTx(ctx, tx, walletTx); err != nil {
return err
}
if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
return err
}
if err = tx.Model(&model.AssetRechargeRecord{}).
Where("id = ?", rechargeRecord.ID).
Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil {
return err
}
return nil
}); err != nil {
h.logger.Error("自动购包任务执行失败",
zap.Uint("recharge_record_id", rechargeRecord.ID),
zap.Error(err),
)
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID)
return err
}
h.logger.Info("自动购包任务执行成功", zap.Uint("recharge_record_id", rechargeRecord.ID))
return nil
}
// NewAutoPurchaseTask 创建充值后自动购包任务
func NewAutoPurchaseTask(rechargeRecordID uint) (*asynq.Task, error) {
payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeRecordID: rechargeRecordID})
if err != nil {
return nil, err
}
return asynq.NewTask(constants.TaskTypeAutoPurchaseAfterRecharge, payloadBytes,
asynq.MaxRetry(3),
asynq.Timeout(2*time.Minute),
asynq.Queue(constants.QueueDefault),
), nil
}
func (h *AutoPurchaseHandler) markAutoPurchaseFailedIfFinalRetry(ctx context.Context, rechargeRecordID uint) {
retryCount, ok := asynq.GetRetryCount(ctx)
if !ok {
return
}
maxRetry, ok := asynq.GetMaxRetry(ctx)
if !ok {
return
}
if retryCount < maxRetry-1 {
return
}
if err := h.db.WithContext(ctx).
Model(&model.AssetRechargeRecord{}).
Where("id = ?", rechargeRecordID).
Update("auto_purchase_status", constants.AutoPurchaseStatusFailed).Error; err != nil {
h.logger.Error("更新自动购包失败状态失败",
zap.Uint("recharge_record_id", rechargeRecordID),
zap.Error(err),
)
return
}
h.logger.Warn("自动购包达到最大重试次数,已标记失败", zap.Uint("recharge_record_id", rechargeRecordID))
}
func (h *AutoPurchaseHandler) loadPackages(ctx context.Context, packageIDs []uint) ([]*model.Package, int64, error) {
packages := make([]*model.Package, 0, len(packageIDs))
if err := h.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil {
return nil, 0, err
}
if len(packages) != len(packageIDs) {
return nil, 0, gorm.ErrRecordNotFound
}
totalAmount := int64(0)
for _, pkg := range packages {
totalAmount += pkg.SuggestedRetailPrice
}
if err := validatePackageTypeMix(packages); err != nil {
return nil, 0, err
}
return packages, totalAmount, nil
}
func (h *AutoPurchaseHandler) buildOrderAndItems(
rechargeRecord *model.AssetRechargeRecord,
packages []*model.Package,
totalAmount int64,
now time.Time,
) (*model.Order, []*model.OrderItem, error) {
orderType, iotCardID, deviceID, err := parseLinkedCarrier(rechargeRecord.LinkedOrderType, rechargeRecord.LinkedCarrierType, rechargeRecord.LinkedCarrierID)
if err != nil {
return nil, nil, err
}
generation := rechargeRecord.Generation
if generation <= 0 {
generation = 1
}
paidAmount := totalAmount
order := &model.Order{
BaseModel: model.BaseModel{
Creator: rechargeRecord.UserID,
Updater: rechargeRecord.UserID,
},
OrderNo: h.orderStore.GenerateOrderNo(),
OrderType: orderType,
BuyerType: model.BuyerTypePersonal,
BuyerID: rechargeRecord.UserID,
IotCardID: iotCardID,
DeviceID: deviceID,
TotalAmount: totalAmount,
PaymentMethod: model.PaymentMethodWallet,
PaymentStatus: model.PaymentStatusPaid,
PaidAt: &now,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
Source: constants.OrderSourceClient,
Generation: generation,
ActualPaidAmount: &paidAmount,
SellerShopID: &rechargeRecord.ShopIDTag,
}
items := make([]*model.OrderItem, 0, len(packages))
for _, pkg := range packages {
items = append(items, &model.OrderItem{
BaseModel: model.BaseModel{
Creator: rechargeRecord.UserID,
Updater: rechargeRecord.UserID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
Quantity: 1,
UnitPrice: pkg.SuggestedRetailPrice,
Amount: pkg.SuggestedRetailPrice,
})
}
return order, items, nil
}
func (h *AutoPurchaseHandler) activatePackages(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
packages []*model.Package,
now time.Time,
) error {
carrierType := constants.AssetWalletResourceTypeIotCard
carrierID := uint(0)
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
carrierID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
carrierType = constants.AssetWalletResourceTypeDevice
carrierID = *order.DeviceID
} else {
return errors.New("无效的订单载体")
}
for _, pkg := range packages {
var existingUsage model.PackageUsage
err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&existingUsage).Error
if err == nil {
continue
}
if err != gorm.ErrRecordNotFound {
return err
}
if pkg.PackageType == constants.PackageTypeFormal {
if err = h.activateMainPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil {
return err
}
continue
}
if pkg.PackageType == constants.PackageTypeAddon {
if err = h.activateAddonPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil {
return err
}
}
}
return nil
}
func (h *AutoPurchaseHandler) activateMainPackage(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
pkg *model.Package,
carrierType string,
carrierID uint,
now time.Time,
) error {
_ = ctx
var activeMainPackage model.PackageUsage
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC").
First(&activeMainPackage).Error
hasActiveMain := err == nil
var status int
var priority int
var activatedAt time.Time
var expiresAt time.Time
var nextResetAt *time.Time
var pendingRealnameActivation bool
if hasActiveMain {
status = constants.PackageUsageStatusPending
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
priority = maxPriority + 1
} else {
status = constants.PackageUsageStatusActive
priority = 1
activatedAt = now
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
}
if pkg.EnableRealnameActivation {
status = constants.PackageUsageStatusPending
pendingRealnameActivation = true
}
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
PackageID: pkg.ID,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
Status: status,
Priority: priority,
DataResetCycle: pkg.DataResetCycle,
PendingRealnameActivation: pendingRealnameActivation,
Generation: order.Generation,
}
if carrierType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = activatedAt
usage.ExpiresAt = expiresAt
usage.NextResetAt = nextResetAt
}
if err = tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return err
}
return tx.Model(usage).Updates(map[string]any{
"status": usage.Status,
"pending_realname_activation": usage.PendingRealnameActivation,
}).Error
}
func (h *AutoPurchaseHandler) activateAddonPackage(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
pkg *model.Package,
carrierType string,
carrierID uint,
now time.Time,
) error {
_ = ctx
var mainPackage model.PackageUsage
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive}).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC").
First(&mainPackage).Error
if err == gorm.ErrRecordNotFound {
return errors.New("必须有主套餐才能购买加油包")
}
if err != nil {
return err
}
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
priority := maxPriority + 1
expiresAt := mainPackage.ExpiresAt
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
PackageID: pkg.ID,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
Status: constants.PackageUsageStatusActive,
Priority: priority,
MasterUsageID: &mainPackage.ID,
ActivatedAt: now,
ExpiresAt: expiresAt,
DataResetCycle: pkg.DataResetCycle,
Generation: order.Generation,
}
if carrierType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
return tx.Create(usage).Error
}
func parseLinkedPackageIDs(raw []byte) ([]uint, error) {
var packageIDs []uint
if len(raw) == 0 {
return nil, nil
}
if err := sonic.Unmarshal(raw, &packageIDs); err != nil {
return nil, err
}
return packageIDs, nil
}
func parseLinkedCarrier(linkedOrderType string, linkedCarrierType string, linkedCarrierID *uint) (string, *uint, *uint, error) {
if linkedCarrierID == nil || *linkedCarrierID == 0 {
return "", nil, nil, errors.New("关联载体ID为空")
}
if linkedOrderType == model.OrderTypeSingleCard || linkedCarrierType == "card" || linkedCarrierType == constants.AssetWalletResourceTypeIotCard {
id := *linkedCarrierID
return model.OrderTypeSingleCard, &id, nil, nil
}
if linkedOrderType == model.OrderTypeDevice || linkedCarrierType == "device" || linkedCarrierType == constants.AssetWalletResourceTypeDevice {
id := *linkedCarrierID
return model.OrderTypeDevice, nil, &id, nil
}
return "", nil, nil, errors.New("关联载体类型无效")
}
func validatePackageTypeMix(packages []*model.Package) error {
hasFormal := false
hasAddon := false
for _, pkg := range packages {
switch pkg.PackageType {
case constants.PackageTypeFormal:
hasFormal = true
case constants.PackageTypeAddon:
hasAddon = true
}
if hasFormal && hasAddon {
return errors.New("不允许在同一订单中同时购买正式套餐和加油包")
}
}
return nil
}

View File

@@ -0,0 +1,3 @@
-- 回滚 tb_asset_recharge_record 的 auto_purchase_status 列
ALTER TABLE tb_asset_recharge_record
DROP COLUMN IF EXISTS auto_purchase_status;

View File

@@ -0,0 +1,5 @@
-- tb_asset_recharge_record 新增 auto_purchase_status 列
ALTER TABLE tb_asset_recharge_record
ADD COLUMN auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL;
COMMENT ON COLUMN tb_asset_recharge_record.auto_purchase_status IS '强充自动代购状态(pending-待处理 success-成功 failed-失败)';

View File

@@ -21,21 +21,15 @@
"enabled": true,
"timeout": 10000
},
"postgres": {
"dbhub": {
"type": "local",
"command": [
"docker",
"run",
"-i",
"--rm",
"-e",
"DATABASE_URI",
"crystaldba/postgres-mcp",
"--access-mode=restricted"
],
"environment": {
"DATABASE_URI": "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
}
"npx",
"-y",
"@bytebase/dbhub@latest",
"--transport", "stdio",
"--config", ".config/dbhub.toml"
]
}
}
}