Compare commits

..

10 Commits

Author SHA1 Message Date
c10b70757f fix: 资产信息接口 device_realtime 字段返回固定假数据,避免前端因 nil 报错
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m58s
Gateway 同步接口尚未对接,临时为设备类型资产返回 mock 数据,
后续对接后搜索 buildMockDeviceRealtime 替换为真实数据
2026-03-21 14:42:48 +08:00
4d1e714366 fix: 补齐迁移 000076 遗漏的列名重命名(card_wallet_id → asset_wallet_id)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
迁移 000076 只将表名从 card_wallet 改为 asset_wallet,但遗漏了表内
card_wallet_id 列的重命名,导致 Model 中 column:asset_wallet_id 与数据库
实际列名不匹配,所有涉及该字段的 INSERT/SELECT 均报错 2002。

影响范围:
- tb_asset_recharge_record.card_wallet_id → asset_wallet_id
- tb_asset_wallet_transaction.card_wallet_id → asset_wallet_id
2026-03-21 14:30:29 +08:00
d2b765327c 完整的字段返回
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m52s
2026-03-21 13:41:44 +08:00
7dfcf41b41 fix: 修复卡类型资产绑定键错误导致归属校验永远失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m48s
resolveAssetBindingKey 对卡类型错误地返回 card.ICCID 作为绑定键,
但归属校验 isCustomerOwnAsset 使用 card.VirtualNo 比对,二者不一致
导致所有卡资产的 C 端接口返回 403 无权限。

修复:卡类型绑定键改为 card.VirtualNo,与设计文档一致。
附带数据迁移修正已有的错误绑定记录。
2026-03-21 11:33:57 +08:00
ed334b946b refactor: 清理重构遗留的死代码
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- personal_customer.Service: 删除已迁移到 client_auth 的死方法
  (GetProfile/SendVerificationCode/VerifyCode),移除多余的
  verificationService/jwtManager 依赖
- 删除 internal/service/customer/ 整个目录(零引用的早期残留)
2026-03-21 11:33:06 +08:00
95b2334658 feat: 资产套餐历史接口新增 package_type 和 status 筛选条件
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m10s
GET /api/c/v1/asset/package-history 支持可选参数:
- package_type: formal(正式套餐) / addon(加油包)
- status: 0(待生效) / 1(生效中) / 2(已用完) / 3(已过期) / 4(已失效)
不传则返回全部,保持向后兼容。
2026-03-21 11:01:21 +08:00
da66e673fe feat: 接入短信服务,修复 SMS 客户端 API 路径
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- cmd/api/main.go: 新增 initSMS() 初始化短信客户端并注入 verificationService
- pkg/sms/client.go: 修复 API 路径缺少 /sms 前缀(/api/... → /sms/api/...)
- docker-compose.prod.yml: 添加线上短信服务环境变量
2026-03-21 10:51:43 +08:00
284f6c15c7 fix: 修复个人客户设备绑定查询使用已废弃的 device_no 列名
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m4s
数据库列已重命名为 virtual_no,但 Store 层 3 处原始 SQL 仍使用旧列名 device_no,
导致小程序登录时查询客户资产绑定关系报 column device_no does not exist。
2026-03-20 18:20:24 +08:00
55918a0b88 fix: 修复 C 端公开路由被认证中间件拦截的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m51s
Fiber 的 Group.Use() 在路由表中注册全局 USE 处理器,不区分 Group 对象。
原代码先调用 authProtectedGroup.Use() 再注册公开路由,导致 verify-asset、
wechat-login、miniapp-login、send-code 四个无需认证的接口被拦截返回 1004。

修复方式:公开路由直接注册在 router 上且在任何 Use() 之前,
利用 Fiber 按注册顺序匹配的机制确保公开路由优先命中。
2026-03-20 18:01:12 +08:00
d2494798aa fix: 修正停复机接口错误码,网关失败不再返回模糊的内部服务器错误
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m13s
- 单卡停复机:网关错误从 CodeInternalError(2001) 改为 CodeGatewayError(1110),前端可看到具体失败原因
- 单卡停复机:DB 更新裸返 GORM error 改为 CodeDatabaseError(2002) 包装
- 设备复机:全部卡失败时错误码从 CodeInternalError 改为 CodeGatewayError
2026-03-19 18:37:03 +08:00
17 changed files with 300 additions and 208 deletions

View File

@@ -27,6 +27,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/database" "github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/queue" "github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/sms"
"github.com/break/junhong_cmp_fiber/pkg/storage" "github.com/break/junhong_cmp_fiber/pkg/storage"
) )
@@ -306,11 +307,42 @@ func initAuthComponents(cfg *config.Config, redisClient *redis.Client, appLogger
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL) tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
verificationSvc := verification.NewService(redisClient, nil, appLogger) smsClient := initSMS(cfg, appLogger)
verificationSvc := verification.NewService(redisClient, smsClient, appLogger)
return jwtManager, tokenManager, verificationSvc return jwtManager, tokenManager, verificationSvc
} }
func initSMS(cfg *config.Config, appLogger *zap.Logger) *sms.Client {
if cfg.SMS.GatewayURL == "" {
appLogger.Info("短信服务未配置,跳过初始化")
return nil
}
timeout := cfg.SMS.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
httpClient := sms.NewStandardHTTPClient(0)
client := sms.NewClient(
cfg.SMS.GatewayURL,
cfg.SMS.Username,
cfg.SMS.Password,
cfg.SMS.Signature,
timeout,
appLogger,
httpClient,
)
appLogger.Info("短信服务已初始化",
zap.String("gateway_url", cfg.SMS.GatewayURL),
zap.String("signature", cfg.SMS.Signature),
)
return client
}
func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service { func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" { if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" {
appLogger.Info("对象存储未配置,跳过初始化") appLogger.Info("对象存储未配置,跳过初始化")

View File

@@ -67,6 +67,11 @@ services:
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0 - JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30 - JUNHONG_GATEWAY_TIMEOUT=30
# 短信服务配置
- JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
- JUNHONG_SMS_USERNAME=JH0001
- JUNHONG_SMS_PASSWORD=wwR8E4qnL6F0
- JUNHONG_SMS_SIGNATURE=【JHFTIOT】
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
networks: networks:

View File

@@ -110,7 +110,7 @@ func initServices(s *stores, deps *Dependencies) *services {
AccountAudit: accountAudit, AccountAudit: accountAudit,
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission), Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis), Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.Logger), PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
ClientAuth: clientAuthSvc.New( ClientAuth: clientAuthSvc.New(
deps.DB, deps.DB,
s.PersonalCustomerOpenID, s.PersonalCustomerOpenID,

View File

@@ -149,15 +149,45 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
} }
resp := &dto.AssetInfoResponse{ resp := &dto.AssetInfoResponse{
AssetType: resolved.Asset.AssetType, AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID, AssetID: resolved.Asset.AssetID,
Identifier: resolved.Identifier, Identifier: resolved.Identifier,
VirtualNo: resolved.Asset.VirtualNo, VirtualNo: resolved.Asset.VirtualNo,
Status: resolved.Asset.Status, Status: resolved.Asset.Status,
RealNameStatus: resolved.Asset.RealNameStatus, RealNameStatus: resolved.Asset.RealNameStatus,
CarrierName: resolved.Asset.CarrierName, CarrierName: resolved.Asset.CarrierName,
Generation: strconv.Itoa(resolved.Generation), Generation: strconv.Itoa(resolved.Generation),
WalletBalance: resolved.WalletBalance, WalletBalance: resolved.WalletBalance,
ActivatedAt: resolved.Asset.ActivatedAt,
CurrentPackage: resolved.Asset.CurrentPackage,
PackageTotalMB: resolved.Asset.PackageTotalMB,
PackageUsedMB: resolved.Asset.PackageUsedMB,
PackageRemainMB: resolved.Asset.PackageRemainMB,
DeviceName: resolved.Asset.DeviceName,
IMEI: resolved.Asset.IMEI,
SN: resolved.Asset.SN,
DeviceModel: resolved.Asset.DeviceModel,
DeviceType: resolved.Asset.DeviceType,
Manufacturer: resolved.Asset.Manufacturer,
MaxSimSlots: resolved.Asset.MaxSimSlots,
BoundCardCount: resolved.Asset.BoundCardCount,
Cards: resolved.Asset.Cards,
DeviceProtectStatus: resolved.Asset.DeviceProtectStatus,
ICCID: resolved.Asset.ICCID,
MSISDN: resolved.Asset.MSISDN,
CarrierID: resolved.Asset.CarrierID,
CarrierType: resolved.Asset.CarrierType,
NetworkStatus: resolved.Asset.NetworkStatus,
ActivationStatus: resolved.Asset.ActivationStatus,
CardCategory: resolved.Asset.CardCategory,
BoundDeviceID: resolved.Asset.BoundDeviceID,
BoundDeviceNo: resolved.Asset.BoundDeviceNo,
BoundDeviceName: resolved.Asset.BoundDeviceName,
}
// TODO: Gateway 同步接口对接后,替换为真实设备实时数据
if resp.AssetType == "device" {
resp.DeviceRealtime = buildMockDeviceRealtime()
} }
return response.Success(c, resp) return response.Success(c, resp)
@@ -270,6 +300,13 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
} else { } else {
query = query.Where("device_id = ?", resolved.Asset.AssetID) query = query.Where("device_id = ?", resolved.Asset.AssetID)
} }
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.PackageType != nil {
query = query.Where("package_id IN (?)",
h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType))
}
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
@@ -554,3 +591,45 @@ func packageStatusName(status int) string {
return "未知" return "未知"
} }
} }
// buildMockDeviceRealtime 构建设备实时状态假数据
// TODO: Gateway 同步接口对接后移除此函数,改为调用 Gateway 接口获取真实数据
func buildMockDeviceRealtime() *dto.DeviceRealtimeInfo {
onlineStatus := int64(1)
batteryLevel := int64(85)
deviceStatus := int64(1)
runTime := "3600"
connectTime := "3500"
rsrp := int64(-80)
rsrq := int64(-10)
rssi := "-65"
sinr := int64(15)
ssid := "JunHong-WiFi"
wifiEnabled := true
wifiPassword := "12345678"
ipAddress := "192.168.1.1"
lanIP := "192.168.1.1"
dailyUsage := "0"
maxClients := int64(32)
switchMode := 0
return &dto.DeviceRealtimeInfo{
OnlineStatus: &onlineStatus,
BatteryLevel: &batteryLevel,
Status: &deviceStatus,
RunTime: &runTime,
ConnectTime: &connectTime,
Rsrp: &rsrp,
Rsrq: &rsrq,
Rssi: &rssi,
Sinr: &sinr,
SSID: &ssid,
WifiEnabled: &wifiEnabled,
WifiPassword: &wifiPassword,
IPAddress: &ipAddress,
LANIP: &lanIP,
DailyUsage: &dailyUsage,
MaxClients: &maxClients,
SwitchMode: &switchMode,
}
}

View File

@@ -1,5 +1,7 @@
package dto package dto
import "time"
// ======================================== // ========================================
// B1 资产信息 // B1 资产信息
// ======================================== // ========================================
@@ -10,16 +12,107 @@ type AssetInfoRequest struct {
} }
// AssetInfoResponse B1 资产信息响应 // AssetInfoResponse B1 资产信息响应
// 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit
type AssetInfoResponse struct { type AssetInfoResponse struct {
AssetType string `json:"asset_type" description:"资产类型 (card:卡, device:设备)"` // === 基础信息(通用) ===
AssetID uint `json:"asset_id" description:"资产ID"` AssetType string `json:"asset_type" description:"资产类型card:卡, device:设备)"`
Identifier string `json:"identifier" description:"资产标识符"` AssetID uint `json:"asset_id" description:"资产ID"`
VirtualNo string `json:"virtual_no" description:"虚拟号"` Identifier string `json:"identifier" description:"资产标识符"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` VirtualNo string `json:"virtual_no" description:"虚拟号"`
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` Status int `json:"status" description:"状态1:在库, 2:已分销, 3:已激活, 4:已停用)"`
CarrierName string `json:"carrier_name" description:"运营商名称"` RealNameStatus int `json:"real_name_status" description:"实名状态0:未实名, 1:已实名)"`
Generation string `json:"generation" description:"制式"` CarrierName string `json:"carrier_name" description:"运营商名称"`
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"` Generation string `json:"generation" description:"世代"`
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
// === 套餐信息(通用) ===
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按虚流量比例换算"`
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按虚流量比例换算"`
PackageRemainMB float64 `json:"package_remain_mb" description:"当前剩余虚流量(MB),已按虚流量比例换算"`
// === 设备专属字段asset_type=device 时有效) ===
DeviceName string `json:"device_name,omitempty" description:"设备名称"`
IMEI string `json:"imei,omitempty" description:"设备IMEI"`
SN string `json:"sn,omitempty" description:"设备序列号"`
DeviceModel string `json:"device_model,omitempty" description:"设备型号"`
DeviceType string `json:"device_type,omitempty" description:"设备类型"`
Manufacturer string `json:"manufacturer,omitempty" description:"制造商"`
MaxSimSlots int `json:"max_sim_slots,omitempty" description:"最大插槽数"`
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定卡数量"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡列表含每张卡的ICCID/MSISDN/网络状态/实名状态/插槽位置)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态none:无, stop:停机保护, start:开机保护)"`
// === 卡专属字段asset_type=card 时有效) ===
ICCID string `json:"iccid,omitempty" description:"卡ICCID"`
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型CMCC/CUCC/CTCC/CBN"`
NetworkStatus int `json:"network_status,omitempty" description:"网络状态0:停机, 1:开机)"`
ActivationStatus int `json:"activation_status,omitempty" description:"激活状态0:未激活, 1:已激活)"`
CardCategory string `json:"card_category,omitempty" description:"卡业务类型normal:普通卡, industry:行业卡)"`
// === 卡绑定设备信息asset_type=card 且绑定了设备时有效) ===
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID"`
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号"`
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称"`
// === 设备实时状态(来自 Gateway同步接口对接后自动填充当前返回 null ===
DeviceRealtime *DeviceRealtimeInfo `json:"device_realtime,omitempty" description:"设备实时状态Gateway 同步接口对接后填充,当前为 null"`
}
// DeviceRealtimeInfo 设备实时状态信息
// 全量映射 Gateway DeviceInfoDetail 结构,所有字段均为可选
// 当前 Gateway 同步接口尚未对接,预留结构待后续填充
type DeviceRealtimeInfo struct {
// === 设备状态 ===
OnlineStatus *int64 `json:"online_status,omitempty" description:"在线状态1:在线, 2:离线)"`
BatteryLevel *int64 `json:"battery_level,omitempty" description:"电池电量百分比"`
Status *int64 `json:"status,omitempty" description:"设备状态1:正常, 0:禁用)"`
RunTime *string `json:"run_time,omitempty" description:"设备本次开机运行时间(秒)"`
ConnectTime *string `json:"connect_time,omitempty" description:"设备本次联网时间(秒)"`
LastOnlineTime *string `json:"last_online_time,omitempty" description:"设备最后在线时间"`
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
// === 信号相关 ===
Rsrp *int64 `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
Rsrq *int64 `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
Sinr *int64 `json:"sinr,omitempty" description:"信噪比(dB)"`
// === WiFi 相关 ===
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`
WifiEnabled *bool `json:"wifi_enabled,omitempty" description:"WiFi开关状态"`
WifiPassword *string `json:"wifi_password,omitempty" description:"WiFi密码"`
// === 网络相关 ===
IPAddress *string `json:"ip_address,omitempty" description:"IP地址"`
WANIP *string `json:"wan_ip,omitempty" description:"基站分配IPv4地址"`
LANIP *string `json:"lan_ip,omitempty" description:"局域网网关IP地址"`
MACAddress *string `json:"mac_address,omitempty" description:"MAC地址"`
// === 流量与速率 ===
DailyUsage *string `json:"daily_usage,omitempty" description:"日使用流量(字节)"`
DLStats *string `json:"dl_stats,omitempty" description:"本次开机下载流量(字节)"`
ULStats *string `json:"ul_stats,omitempty" description:"本次开机上传流量(字节)"`
LimitSpeed *int64 `json:"limit_speed,omitempty" description:"限速速率(KB/s)"`
// === 设备属性 ===
CurrentIccid *string `json:"current_iccid,omitempty" description:"当前使用的ICCID"`
MaxClients *int64 `json:"max_clients,omitempty" description:"最大连接客户端数"`
SoftwareVersion *string `json:"software_version,omitempty" description:"软件版本号"`
SwitchMode *int `json:"switch_mode,omitempty" description:"切卡模式0:自动, 1:手动)"`
SyncInterval *int64 `json:"sync_interval,omitempty" description:"信息上报周期(秒)"`
// === Gateway 原始标识字段 ===
DeviceID *string `json:"device_id,omitempty" description:"Gateway设备ID(IMEI/SN)"`
DeviceName *string `json:"device_name,omitempty" description:"Gateway返回的设备名称"`
DeviceType *string `json:"device_type,omitempty" description:"Gateway返回的设备型号"`
Imei *string `json:"imei,omitempty" description:"Gateway返回的IMEI号"`
Imsi *string `json:"imsi,omitempty" description:"Gateway返回的IMSI"`
CreatedAt *int64 `json:"created_at,omitempty" description:"Gateway创建时间(Unix时间戳)"`
UpdatedAt *int64 `json:"updated_at,omitempty" description:"Gateway更新时间(Unix时间戳)"`
} }
// ======================================== // ========================================
@@ -56,9 +149,11 @@ type AssetPackageListResponse struct {
// AssetPackageHistoryRequest B3 资产套餐历史请求 // AssetPackageHistoryRequest B3 资产套餐历史请求
type AssetPackageHistoryRequest struct { 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"` 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:"页码"` PackageType *string `json:"package_type" query:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"` Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=4" minimum:"0" maximum:"4" description:"套餐状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)"`
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 资产套餐历史响应 // AssetPackageHistoryResponse B3 资产套餐历史响应

View File

@@ -12,13 +12,16 @@ import (
// RegisterPersonalCustomerRoutes 注册个人客户路由 // RegisterPersonalCustomerRoutes 注册个人客户路由
// 路由挂载在 /api/c/v1 下 // 路由挂载在 /api/c/v1 下
//
// 重要Fiber 的 Group.Use() 会在路由表中注册全局 USE 处理器,
// 匹配该前缀下的所有请求(不区分 Group 对象)。
// 因此公开路由必须在任何 Use() 调用之前注册,利用 Fiber 按注册顺序匹配的机制,
// 确保公开路由优先命中并直接返回,不会被后续的认证中间件拦截。
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) { func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
authBasePath := "/auth" authBasePath := "/auth"
authPublicGroup := router.Group(authBasePath)
authProtectedGroup := router.Group(authBasePath)
authProtectedGroup.Use(personalAuthMiddleware.Authenticate())
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/verify-asset", handlers.ClientAuth.VerifyAsset, RouteSpec{ // === 公开路由(无需认证)===
Register(router, doc, basePath, "POST", authBasePath+"/verify-asset", handlers.ClientAuth.VerifyAsset, RouteSpec{
Summary: "资产验证", Summary: "资产验证",
Tags: []string{"个人客户 - 认证"}, Tags: []string{"个人客户 - 认证"},
Auth: false, Auth: false,
@@ -26,7 +29,7 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Output: &dto.VerifyAssetResponse{}, Output: &dto.VerifyAssetResponse{},
}) })
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/wechat-login", handlers.ClientAuth.WechatLogin, RouteSpec{ Register(router, doc, basePath, "POST", authBasePath+"/wechat-login", handlers.ClientAuth.WechatLogin, RouteSpec{
Summary: "公众号登录", Summary: "公众号登录",
Tags: []string{"个人客户 - 认证"}, Tags: []string{"个人客户 - 认证"},
Auth: false, Auth: false,
@@ -34,7 +37,7 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Output: &dto.WechatLoginResponse{}, Output: &dto.WechatLoginResponse{},
}) })
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/miniapp-login", handlers.ClientAuth.MiniappLogin, RouteSpec{ Register(router, doc, basePath, "POST", authBasePath+"/miniapp-login", handlers.ClientAuth.MiniappLogin, RouteSpec{
Summary: "小程序登录", Summary: "小程序登录",
Tags: []string{"个人客户 - 认证"}, Tags: []string{"个人客户 - 认证"},
Auth: false, Auth: false,
@@ -42,7 +45,7 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Output: &dto.WechatLoginResponse{}, Output: &dto.WechatLoginResponse{},
}) })
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/send-code", handlers.ClientAuth.SendCode, RouteSpec{ Register(router, doc, basePath, "POST", authBasePath+"/send-code", handlers.ClientAuth.SendCode, RouteSpec{
Summary: "发送验证码", Summary: "发送验证码",
Tags: []string{"个人客户 - 认证"}, Tags: []string{"个人客户 - 认证"},
Auth: false, Auth: false,
@@ -50,6 +53,10 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Output: &dto.ClientSendCodeResponse{}, Output: &dto.ClientSendCodeResponse{},
}) })
// === 需要认证的 auth 路由 ===
authProtectedGroup := router.Group(authBasePath)
authProtectedGroup.Use(personalAuthMiddleware.Authenticate())
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/bind-phone", handlers.ClientAuth.BindPhone, RouteSpec{ Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/bind-phone", handlers.ClientAuth.BindPhone, RouteSpec{
Summary: "绑定手机号", Summary: "绑定手机号",
Tags: []string{"个人客户 - 认证"}, Tags: []string{"个人客户 - 认证"},

View File

@@ -640,7 +640,7 @@ func (s *Service) resolveAssetBindingKey(ctx context.Context, tx *gorm.DB, asset
} }
return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
} }
return card.ICCID, nil return card.VirtualNo, nil
} }
if assetType == assetTypeDevice { if assetType == assetTypeDevice {

View File

@@ -1,126 +0,0 @@
// Package customer 提供客户管理的业务逻辑服务
// 包含客户信息管理、客户查询等功能
package customer
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
)
// Service 个人客户业务服务
type Service struct {
customerStore *postgres.PersonalCustomerStore
}
// New 创建个人客户服务
func New(customerStore *postgres.PersonalCustomerStore) *Service {
return &Service{
customerStore: customerStore,
}
}
// Create 创建个人客户
func (s *Service) Create(ctx context.Context, req *dto.CreatePersonalCustomerRequest) (*model.PersonalCustomer, error) {
// 检查手机号唯一性
if req.Phone != "" {
existing, err := s.customerStore.GetByPhone(ctx, req.Phone)
if err == nil && existing != nil {
return nil, errors.New(errors.CodeCustomerPhoneExists, "手机号已存在")
}
}
// 创建个人客户
// 注意:根据新的数据模型,手机号应该存储在 PersonalCustomerPhone 表中
// 这里暂时先创建客户记录,手机号的存储后续通过 PersonalCustomerPhoneStore 实现
customer := &model.PersonalCustomer{
Nickname: req.Nickname,
AvatarURL: req.AvatarURL,
WxOpenID: req.WxOpenID,
WxUnionID: req.WxUnionID,
Status: constants.StatusEnabled,
}
if err := s.customerStore.Create(ctx, customer); err != nil {
return nil, err
}
// TODO: 创建 PersonalCustomerPhone 记录,需要通过 PersonalCustomerPhoneStore 创建手机号关联
return customer, nil
}
// Update 更新个人客户信息
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePersonalCustomerRequest) (*model.PersonalCustomer, error) {
// 查询客户
customer, err := s.customerStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
// TODO: 手机号的更新逻辑需要通过 PersonalCustomerPhoneStore 更新或创建手机号记录
// 更新字段
if req.Nickname != nil {
customer.Nickname = *req.Nickname
}
if req.AvatarURL != nil {
customer.AvatarURL = *req.AvatarURL
}
if err := s.customerStore.Update(ctx, customer); err != nil {
return nil, err
}
return customer, nil
}
// BindWeChat 绑定微信信息
func (s *Service) BindWeChat(ctx context.Context, id uint, wxOpenID, wxUnionID string) error {
customer, err := s.customerStore.GetByID(ctx, id)
if err != nil {
return errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
customer.WxOpenID = wxOpenID
customer.WxUnionID = wxUnionID
return s.customerStore.Update(ctx, customer)
}
// GetByID 获取个人客户详情
func (s *Service) GetByID(ctx context.Context, id uint) (*model.PersonalCustomer, error) {
customer, err := s.customerStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
return customer, nil
}
// GetByPhone 根据手机号获取个人客户
func (s *Service) GetByPhone(ctx context.Context, phone string) (*model.PersonalCustomer, error) {
customer, err := s.customerStore.GetByPhone(ctx, phone)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
return customer, nil
}
// GetByWxOpenID 根据微信 OpenID 获取个人客户
func (s *Service) GetByWxOpenID(ctx context.Context, wxOpenID string) (*model.PersonalCustomer, error) {
customer, err := s.customerStore.GetByWxOpenID(ctx, wxOpenID)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
return customer, nil
}
// List 查询个人客户列表
func (s *Service) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.PersonalCustomer, int64, error) {
return s.customerStore.List(ctx, opts, filters)
}

View File

@@ -973,7 +973,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
// 全部失败时返回 error // 全部失败时返回 error
if successCount == 0 && lastErr != nil { if successCount == 0 && lastErr != nil {
return errors.Wrap(errors.CodeInternalError, lastErr, "设备复机失败") return errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败")
} }
return nil return nil

View File

@@ -264,15 +264,18 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
} }
if err := s.stopCardWithRetry(ctx, card); err != nil { if err := s.stopCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关停机失败") return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试")
} }
now := time.Now() now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{ if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOffline, "network_status": constants.NetworkStatusOffline,
"stopped_at": now, "stopped_at": now,
"stop_reason": constants.StopReasonManual, "stop_reason": constants.StopReasonManual,
}).Error }).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
}
return nil
} }
// ManualStartCard 手动复机单张卡通过ICCID // ManualStartCard 手动复机单张卡通过ICCID
@@ -300,13 +303,16 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
} }
if err := s.resumeCardWithRetry(ctx, card); err != nil { if err := s.resumeCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关复机失败") return errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
} }
now := time.Now() now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{ if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOnline, "network_status": constants.NetworkStatusOnline,
"resumed_at": now, "resumed_at": now,
"stop_reason": "", "stop_reason": "",
}).Error }).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
}
return nil
} }

View File

@@ -1,14 +1,11 @@
// Package personal_customer 提供个人客户管理的业务逻辑服务 // Package personal_customer 提供个人客户资料管理的业务逻辑服务
// 包含个人客户注册、登录、微信绑定、短信验证等功能
package personal_customer package personal_customer
import ( import (
"context" "context"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
@@ -16,40 +13,24 @@ import (
// Service 个人客户服务 // Service 个人客户服务
type Service struct { type Service struct {
store *postgres.PersonalCustomerStore store *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore phoneStore *postgres.PersonalCustomerPhoneStore
verificationService *verification.Service logger *zap.Logger
jwtManager *auth.JWTManager
logger *zap.Logger
} }
// NewService 创建个人客户服务实例 // NewService 创建个人客户服务实例
func NewService( func NewService(
store *postgres.PersonalCustomerStore, store *postgres.PersonalCustomerStore,
phoneStore *postgres.PersonalCustomerPhoneStore, phoneStore *postgres.PersonalCustomerPhoneStore,
verificationService *verification.Service,
jwtManager *auth.JWTManager,
logger *zap.Logger, logger *zap.Logger,
) *Service { ) *Service {
return &Service{ return &Service{
store: store, store: store,
phoneStore: phoneStore, phoneStore: phoneStore,
verificationService: verificationService, logger: logger,
jwtManager: jwtManager,
logger: logger,
} }
} }
// SendVerificationCode 发送验证码
func (s *Service) SendVerificationCode(ctx context.Context, phone string) error {
return s.verificationService.SendCode(ctx, phone)
}
// VerifyCode 验证验证码
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error {
return s.verificationService.VerifyCode(ctx, phone, code)
}
// UpdateProfile 更新个人资料 // UpdateProfile 更新个人资料
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error { func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
customer, err := s.store.GetByID(ctx, customerID) customer, err := s.store.GetByID(ctx, customerID)
@@ -84,20 +65,6 @@ func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname,
return nil return nil
} }
// GetProfile 获取个人资料
func (s *Service) GetProfile(ctx context.Context, customerID uint) (*model.PersonalCustomer, error) {
customer, err := s.store.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("查询个人客户失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
return customer, nil
}
// GetProfileWithPhone 获取个人资料(包含主手机号) // GetProfileWithPhone 获取个人资料(包含主手机号)
func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) { func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) {
// 获取客户信息 // 获取客户信息

View File

@@ -44,7 +44,7 @@ func (s *PersonalCustomerDeviceStore) GetByCustomerID(ctx context.Context, custo
func (s *PersonalCustomerDeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) { func (s *PersonalCustomerDeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) {
var records []*model.PersonalCustomerDevice var records []*model.PersonalCustomerDevice
if err := s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("device_no = ?", deviceNo). Where("virtual_no = ?", deviceNo).
Order("last_used_at DESC"). Order("last_used_at DESC").
Find(&records).Error; err != nil { Find(&records).Error; err != nil {
return nil, err return nil, err
@@ -56,7 +56,7 @@ func (s *PersonalCustomerDeviceStore) GetByDeviceNo(ctx context.Context, deviceN
func (s *PersonalCustomerDeviceStore) GetByCustomerAndDevice(ctx context.Context, customerID uint, deviceNo string) (*model.PersonalCustomerDevice, error) { func (s *PersonalCustomerDeviceStore) GetByCustomerAndDevice(ctx context.Context, customerID uint, deviceNo string) (*model.PersonalCustomerDevice, error) {
var record model.PersonalCustomerDevice var record model.PersonalCustomerDevice
if err := s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("customer_id = ? AND device_no = ?", customerID, deviceNo). Where("customer_id = ? AND virtual_no = ?", customerID, deviceNo).
First(&record).Error; err != nil { First(&record).Error; err != nil {
return nil, err return nil, err
} }
@@ -89,7 +89,7 @@ func (s *PersonalCustomerDeviceStore) ExistsByCustomerAndDevice(ctx context.Cont
var count int64 var count int64
if err := s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Model(&model.PersonalCustomerDevice{}). Model(&model.PersonalCustomerDevice{}).
Where("customer_id = ? AND device_no = ? AND status = ?", customerID, deviceNo, 1). Where("customer_id = ? AND virtual_no = ? AND status = ?", customerID, deviceNo, 1).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return false, err return false, err
} }

View File

@@ -0,0 +1,8 @@
-- 回滚:将已修复的 VirtualNo 还原为 ICCID
-- 注意:只还原那些 virtual_no 能匹配到 iot_card.virtual_no 的记录
UPDATE tb_personal_customer_device pcd
SET virtual_no = ic.iccid
FROM tb_iot_card ic
WHERE pcd.virtual_no = ic.virtual_no
AND pcd.deleted_at IS NULL
AND ic.virtual_no != ic.iccid;

View File

@@ -0,0 +1,9 @@
-- 修复个人客户设备绑定表中卡类型资产的绑定键
-- 问题resolveAssetBindingKey 对卡类型错误地使用了 card.ICCID应使用 card.VirtualNo
-- 影响:所有通过卡 ICCID 登录的个人客户绑定记录的 virtual_no 字段存的是 ICCID 而非资产虚拟号
-- 导致:归属校验 isCustomerOwnAsset 比对 VirtualNo 时永远不匹配,返回 403
UPDATE tb_personal_customer_device pcd
SET virtual_no = ic.virtual_no
FROM tb_iot_card ic
WHERE pcd.virtual_no = ic.iccid
AND pcd.deleted_at IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE tb_asset_recharge_record RENAME COLUMN asset_wallet_id TO card_wallet_id;
ALTER TABLE tb_asset_wallet_transaction RENAME COLUMN asset_wallet_id TO card_wallet_id;

View File

@@ -0,0 +1,8 @@
-- 修复迁移 000076 遗漏:表名已从 card_wallet 改为 asset_wallet但列名 card_wallet_id 未同步更名
-- 导致 Model 中 column:asset_wallet_id 与数据库实际列名 card_wallet_id 不匹配INSERT/SELECT 均失败
-- tb_asset_recharge_record: card_wallet_id → asset_wallet_id
ALTER TABLE tb_asset_recharge_record RENAME COLUMN card_wallet_id TO asset_wallet_id;
-- tb_asset_wallet_transaction: card_wallet_id → asset_wallet_id
ALTER TABLE tb_asset_wallet_transaction RENAME COLUMN card_wallet_id TO asset_wallet_id;

View File

@@ -80,7 +80,7 @@ func (c *Client) SendMessage(ctx context.Context, content string, phones []strin
) )
// 发送请求 // 发送请求
url := c.gatewayURL + "/api/sendMessageMass" url := c.gatewayURL + "/sms/api/sendMessageMass"
// 创建带超时的上下文 // 创建带超时的上下文
reqCtx, cancel := context.WithTimeout(ctx, c.timeout) reqCtx, cancel := context.WithTimeout(ctx, c.timeout)