diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index a5180bf..7b903e3 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -141,6 +141,10 @@ func initServices(s *stores, deps *Dependencies) *services { ) stopResumeService := iotCardSvc.NewStopResumeService(deps.Redis, s.IotCard, s.PackageUsage, s.DeviceSimBinding, deps.GatewayClient, deps.Logger) + iotCard.SetRealnameActivator(packageActivation) + iotCard.SetStopResumeService(stopResumeService) + iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding) + iotCard.SetRedisClient(deps.Redis) device := deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, s.AssetIdentifier) operationPassword := operationPasswordSvc.New(deps.Redis) diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 35ce3eb..7a9c722 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -341,3 +341,43 @@ func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error { RealnamePolicy: req.RealnamePolicy, }) } + +// UpdateRealnameStatus 手动更新资产实名状态 +// PATCH /api/admin/assets/:identifier/realname-status +func (h *AssetHandler) UpdateRealnameStatus(c *fiber.Ctx) error { + identifier := c.Params("identifier") + if identifier == "" { + return errors.New(errors.CodeInvalidParam) + } + + var req dto.UpdateAssetRealnameStatusRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if req.RealNameStatus != constants.RealNameStatusNotVerified && + req.RealNameStatus != constants.RealNameStatusVerified { + return errors.New(errors.CodeInvalidParam, "无效的实名状态") + } + + asset, err := h.assetService.Resolve(c.UserContext(), identifier) + if err != nil { + return err + } + if asset.AssetType != "card" { + return errors.New(errors.CodeInvalidParam, "仅支持IoT卡资产手动修改实名状态") + } + + card, err := h.iotCardService.ManualUpdateRealnameStatus(c.UserContext(), asset.AssetID, req.RealNameStatus) + if err != nil { + return err + } + + return response.Success(c, &dto.UpdateAssetRealnameStatusResponse{ + AssetType: asset.AssetType, + AssetID: asset.AssetID, + RealNameStatus: card.RealNameStatus, + RealNameStatusName: constants.GetRealNameStatusName(card.RealNameStatus), + FirstRealnameAt: card.FirstRealnameAt, + }) +} diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index c66b38c..f1d74b3 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -301,3 +301,18 @@ type UpdateAssetRealnamePolicyResponse struct { AssetID uint `json:"asset_id" description:"资产ID"` RealnamePolicy string `json:"realname_policy" description:"更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` } + +// UpdateAssetRealnameStatusRequest 手动更新资产实名状态请求 +type UpdateAssetRealnameStatusRequest struct { + Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"` + RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)" required:"true" enum:"0,1"` +} + +// UpdateAssetRealnameStatusResponse 手动更新资产实名状态响应 +type UpdateAssetRealnameStatusResponse struct { + AssetType string `json:"asset_type" description:"资产类型 (card:IoT卡)"` + AssetID uint `json:"asset_id" description:"资产ID"` + RealNameStatus int `json:"real_name_status" description:"更新后的实名状态 (0:未实名, 1:已实名)"` + RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` + FirstRealnameAt *time.Time `json:"first_realname_at" description:"首次实名时间(仅已实名时可能有值)"` +} diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 50afbb3..753e8f1 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -127,4 +127,13 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Output: new(dto.UpdateAssetRealnamePolicyResponse), Auth: true, }) + + Register(assets, doc, groupPath, "PATCH", "/:identifier/realname-status", handler.UpdateRealnameStatus, RouteSpec{ + Summary: "手动更新卡实名状态", + Description: "手动将指定卡资产标记为已实名或未实名。状态更新后会触发停复机评估,并在标记为已实名时触发待实名套餐激活。", + Tags: []string{"资产管理"}, + Input: new(dto.UpdateAssetRealnameStatusRequest), + Output: new(dto.UpdateAssetRealnameStatusResponse), + Auth: true, + }) } diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 3e1d2b3..9900d1e 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -2,6 +2,7 @@ package iot_card import ( "context" + stderrors "errors" "time" "github.com/break/junhong_cmp_fiber/internal/gateway" @@ -12,6 +13,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) @@ -38,6 +40,13 @@ type DataDeductor interface { DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error } +// RealnameActivator 实名激活回调接口 +// 用于在手动实名后触发待实名套餐激活,避免循环依赖 +type RealnameActivator interface { + // ActivateByRealname 根据载体触发待实名套餐激活 + ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error +} + type Service struct { db *gorm.DB iotCardStore *postgres.IotCardStore @@ -50,6 +59,10 @@ type Service struct { logger *zap.Logger pollingCallback PollingCallback dataDeductor DataDeductor + realnameActivator RealnameActivator + stopResumeService StopResumeServiceInterface + deviceSimBindingStore *postgres.DeviceSimBindingStore + redis *redis.Client assetIdentifierStore *postgres.AssetIdentifierStore } @@ -93,6 +106,30 @@ func (s *Service) SetDataDeductor(deductor DataDeductor) { s.dataDeductor = deductor } +// SetRealnameActivator 设置实名激活回调 +// 在应用启动时由 bootstrap 注入套餐激活服务 +func (s *Service) SetRealnameActivator(activator RealnameActivator) { + s.realnameActivator = activator +} + +// SetStopResumeService 设置停复机服务 +// 在应用启动时由 bootstrap 注入,确保手动实名后可立即评估停复机 +func (s *Service) SetStopResumeService(stopResumeService StopResumeServiceInterface) { + s.stopResumeService = stopResumeService +} + +// SetDeviceSimBindingStore 设置设备绑定存储 +// 在手动实名成功后用于补触发设备维度待实名套餐激活 +func (s *Service) SetDeviceSimBindingStore(deviceSimBindingStore *postgres.DeviceSimBindingStore) { + s.deviceSimBindingStore = deviceSimBindingStore +} + +// SetRedisClient 设置 Redis 客户端 +// 用于清理实名逆转计数器,避免历史计数干扰手动实名后的判定 +func (s *Service) SetRedisClient(redisClient *redis.Client) { + s.redis = redisClient +} + func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIotCardRequest) (*dto.ListStandaloneIotCardResponse, error) { page := req.Page pageSize := req.PageSize @@ -1156,3 +1193,119 @@ func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnam return nil } + +// ManualUpdateRealnameStatus 手动更新卡实名状态 +// 用于人工纠偏实名状态,并同步触发实名相关业务(套餐激活、停复机评估、轮询缓存失效) +func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, realNameStatus int) (*model.IotCard, error) { + if realNameStatus != constants.RealNameStatusNotVerified && + realNameStatus != constants.RealNameStatusVerified { + return nil, errors.New(errors.CodeInvalidParam, "无效的实名状态") + } + + card, err := s.iotCardStore.GetByID(ctx, cardID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "IoT卡不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + } + + oldStatus := card.RealNameStatus + statusChanged := oldStatus != realNameStatus + now := time.Now() + + fields := map[string]any{ + "last_real_name_check_at": now, + } + if statusChanged { + fields["real_name_status"] = realNameStatus + } + if statusChanged && + oldStatus != constants.RealNameStatusVerified && + realNameStatus == constants.RealNameStatusVerified { + fields["first_realname_at"] = now + } + + if err := s.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败") + } + + freshCard, err := s.iotCardStore.GetByID(ctx, cardID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询更新后的IoT卡失败") + } + + if statusChanged { + s.handleManualRealnameStatusChanged(ctx, oldStatus, freshCard) + } + + if s.pollingCallback != nil { + s.pollingCallback.OnCardStatusChanged(ctx, cardID) + } + + s.logger.Info("手动更新卡实名状态", + zap.Uint("card_id", cardID), + zap.Int("old_status", oldStatus), + zap.Int("new_status", realNameStatus), + zap.Uint("operator_id", middleware.GetUserIDFromContext(ctx))) + + return freshCard, nil +} + +// handleManualRealnameStatusChanged 处理手动实名状态变更后的联动逻辑 +func (s *Service) handleManualRealnameStatusChanged(ctx context.Context, oldStatus int, card *model.IotCard) { + if card == nil { + return + } + + s.clearRealnameReversalCounter(ctx, card.ID) + + if oldStatus != constants.RealNameStatusVerified && + card.RealNameStatus == constants.RealNameStatusVerified { + s.triggerRealnameActivation(ctx, constants.AssetTypeIotCard, card.ID) + + if s.deviceSimBindingStore != nil { + binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) + if err == nil && binding != nil { + s.triggerRealnameActivation(ctx, constants.AssetTypeDevice, binding.DeviceID) + } else if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) { + s.logger.Warn("手动实名后查询绑定设备失败", + zap.Uint("card_id", card.ID), + zap.Error(err)) + } + } + } + + if s.stopResumeService != nil { + if err := s.stopResumeService.EvaluateAndAct(ctx, card); err != nil { + s.logger.Warn("手动实名后触发停复机评估失败", + zap.Uint("card_id", card.ID), + zap.Error(err)) + } + } +} + +// triggerRealnameActivation 触发待实名套餐激活 +func (s *Service) triggerRealnameActivation(ctx context.Context, carrierType string, carrierID uint) { + if s.realnameActivator == nil { + return + } + if err := s.realnameActivator.ActivateByRealname(ctx, carrierType, carrierID); err != nil { + s.logger.Warn("手动实名后触发待实名套餐激活失败", + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID), + zap.Error(err)) + } +} + +// clearRealnameReversalCounter 清理实名逆转连续计数器 +func (s *Service) clearRealnameReversalCounter(ctx context.Context, cardID uint) { + if s.redis == nil { + return + } + if err := s.redis.Del(ctx, constants.RedisPollingRealnameReversalCountKey(cardID)).Err(); err != nil { + s.logger.Warn("清理实名逆转计数器失败", + zap.Uint("card_id", cardID), + zap.Error(err)) + } +}