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

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
}