Files
junhong_cmp_fiber/internal/handler/admin/asset.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

787 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"context"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/hibiken/asynq"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"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/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"go.uber.org/zap"
)
// AssetHandler 资产管理处理器
// 提供统一的资产解析、实时状态、套餐查询、停复机、停用、轮询管控等接口
type AssetHandler struct {
assetService *assetService.Service
assetAuditService *assetAuditSvc.Service
deviceService *deviceService.Service
iotCardService *iotCardService.Service
iotCardStopResume *iotCardService.StopResumeService
assetPolling *pollingSvc.AssetPollingService
assetLifecycleService AssetLifecycleService
detailProjection AssetDetailProjectionResolver
observationSeries cardObservationApp.BestEffortSeriesDispatcher
packageExpiryQuery *packageExpiryQuery.Query
packageExpiryTrigger func(context.Context) error
assetExportService AssetExportTaskCreator
validator *validator.Validate
}
// AssetExportTaskCreator 定义创建导出任务的用例,供临期导出受控入口调用。
type AssetExportTaskCreator interface {
CreateTask(ctx context.Context, req *dto.CreateExportTaskRequest) (*dto.CreateExportTaskResponse, error)
}
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// SetPackageExpiryQuery 注入临期资产分页查询。
func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) {
h.packageExpiryQuery = query
}
// SetExportTaskService 注入导出任务创建用例,供临期资产导出受控入口使用。
func (h *AssetHandler) SetExportTaskService(creator AssetExportTaskCreator) {
h.assetExportService = creator
}
// SetValidator 注入请求参数校验器,供新增的受控入口按 DTO 规则校验并返回字段级中文提示。
func (h *AssetHandler) SetValidator(v *validator.Validate) {
h.validator = v
}
// SetPackageExpiryQueue 注入套餐临期提醒任务队列。
func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) {
if client == nil {
h.packageExpiryTrigger = nil
return
}
h.packageExpiryTrigger = func(ctx context.Context) error {
return client.EnqueueTask(
ctx,
constants.TaskTypePackageExpiryReminder,
struct{}{},
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
)
}
}
// AssetDetailProjectionResolver 定义资产详情的附加只读投影读取用例:
// 换货链路与优先轮询状态。两者都是只读查询,不修改任何业务状态。
type AssetDetailProjectionResolver interface {
// Resolve 查询资产单节点换货链路。
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
// ResolvePriorityPolling 查询资产的优先轮询状态投影(只读,不改变队列)。
ResolvePriorityPolling(ctx context.Context, assetType string, assetID uint) (*dto.AssetPriorityPollingProjection, error)
}
// NewAssetHandler 创建资产管理处理器
func NewAssetHandler(
assetSvc *assetService.Service,
assetAuditService *assetAuditSvc.Service,
deviceSvc *deviceService.Service,
iotCardSvc *iotCardService.Service,
iotCardStopResume *iotCardService.StopResumeService,
assetPolling *pollingSvc.AssetPollingService,
detailProjection AssetDetailProjectionResolver,
) *AssetHandler {
return &AssetHandler{
assetService: assetSvc,
assetAuditService: assetAuditService,
deviceService: deviceSvc,
iotCardService: iotCardSvc,
iotCardStopResume: iotCardStopResume,
assetPolling: assetPolling,
detailProjection: detailProjection,
}
}
// SetLifecycleService 注入生命周期服务(用于 Deactivate 接口)
func (h *AssetHandler) SetLifecycleService(svc AssetLifecycleService) {
h.assetLifecycleService = svc
}
// resolveByIdentifier 通过标识符解析资产,用于各处理器的公共逻辑
// 不传递 include_usage_summary始终为 false不影响其他 Handler 性能
func (h *AssetHandler) resolveByIdentifier(c *fiber.Ctx) (*dto.AssetResolveResponse, error) {
identifier := c.Params("identifier")
if identifier == "" {
return nil, errors.New(errors.CodeInvalidParam, "标识符不能为空")
}
return h.assetService.Resolve(c.UserContext(), identifier, false)
}
// Resolve 通过任意标识符解析资产(设备或卡)
// GET /api/admin/assets/resolve/:identifier
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
}
includeUsageSummary := c.QueryBool("include_usage_summary", false)
result, err := h.assetService.Resolve(c.UserContext(), identifier, includeUsageSummary)
if err != nil {
return err
}
result.ExchangeTrace = &dto.AssetExchangeTrace{}
result.PriorityPolling = &dto.AssetPriorityPollingProjection{}
if h.detailProjection != nil {
result.ExchangeTrace, err = h.detailProjection.Resolve(c.UserContext(), result.AssetType, result.AssetID)
if err != nil {
return err
}
// 优先轮询投影只读:读取不改变队列状态,也不触发任何上游调用。
result.PriorityPolling, err = h.detailProjection.ResolvePriorityPolling(c.UserContext(), result.AssetType, result.AssetID)
if err != nil {
return err
}
}
return response.Success(c, result)
}
// ListExpiring 查询当前权限范围内的临期资产列表和数量汇总。
// GET /api/admin/expiring-assets
func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
var request dto.ExpiringAssetListRequest
if err := rejectLegacyTimeParams(c, "expires_from", "expires_to"); err != nil {
return err
}
if err := c.QueryParser(&request); err != nil {
logger.GetAppLogger().Warn("临期资产列表参数解析失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
if h.packageExpiryQuery == nil {
return errors.New(errors.CodeInternalError, "套餐临期查询未配置")
}
startTime, endTime, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return err
}
result, err := h.packageExpiryQuery.List(c.UserContext(), request, startTime, endTime)
if err != nil {
return err
}
return response.Success(c, dto.ExpiringAssetListResponse{
Items: result.Items, Total: result.Total, Page: result.Page, Size: result.Size, Summary: result.Summary,
})
}
// ExportExpiring 创建临期资产异步导出任务。
// POST /api/admin/expiring-assets/export
// 只暴露受控入口;导出任务创建时冻结当页全部筛选、时间边界、操作者与可见店铺范围。
func (h *AssetHandler) ExportExpiring(c *fiber.Ctx) error {
var req dto.ExportExpiringAssetRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("导出临期资产参数不合法", &req, err))
}
if h.assetExportService == nil {
return errors.New(errors.CodeInternalError, "导出任务服务未配置")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
createRequest := dto.CreateExportTaskRequest{
Scene: constants.ExportTaskSceneExpiringAsset,
Format: req.Format,
Query: map[string]interface{}{"filters": expiringAssetExportFilters(req, startTime, endTime)},
}
result, err := h.assetExportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// expiringAssetExportFilters 把临期导出请求转换为导出任务的筛选快照。
// 筛选集合与临期列表一致;时间边界在创建时规范化为 UTC RFC3339 秒级字符串后冻结。
func expiringAssetExportFilters(req dto.ExportExpiringAssetRequest, startTime, endTime *time.Time) map[string]interface{} {
filters := make(map[string]interface{})
if req.AssetType != "" {
filters["asset_type"] = req.AssetType
}
if req.Keyword != "" {
filters["keyword"] = req.Keyword
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if req.PackageID != nil {
filters["package_id"] = *req.PackageID
}
if req.DaysMin != nil {
filters["days_min"] = *req.DaysMin
}
if req.DaysMax != nil {
filters["days_max"] = *req.DaysMax
}
if startTime != nil {
filters["start_time"] = utils.FormatTimeFilterValue(*startTime)
}
if endTime != nil {
filters["end_time"] = utils.FormatTimeFilterValue(*endTime)
}
return filters
}
// TriggerPackageExpiryReminder 手动提交每日临期提醒扫描任务。
// POST /api/admin/expiring-assets/reminder-scan
func (h *AssetHandler) TriggerPackageExpiryReminder(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden)
}
if h.packageExpiryTrigger == nil {
return errors.New(errors.CodeServiceUnavailable, "每日临期提醒扫描任务队列未配置")
}
if err := h.packageExpiryTrigger(c.UserContext()); err != nil {
logger.GetAppLogger().Error("手动提交每日临期提醒扫描任务失败", zap.Error(err))
return errors.Wrap(errors.CodeTaskQueueError, err, "提交每日临期提醒扫描任务失败")
}
logger.GetAppLogger().Info("已手动提交每日临期提醒扫描任务",
zap.Uint("operator_id", middleware.GetUserIDFromContext(c.UserContext())))
return response.Success(c, dto.TriggerPackageExpiryReminderResponse{
TaskType: constants.TaskTypePackageExpiryReminder,
Message: "每日临期提醒扫描任务已提交",
})
}
// RealtimeStatus 获取资产实时状态
// GET /api/admin/assets/:identifier/realtime-status
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
result, err := h.assetService.GetRealtimeStatus(c.UserContext(), asset.AssetType, asset.AssetID)
if err != nil {
return err
}
h.dispatchRealtimeObservations(c.UserContext(), result)
return response.Success(c, result)
}
func (h *AssetHandler) dispatchRealtimeObservations(ctx context.Context, result *dto.AssetRealtimeStatusResponse) {
if h.observationSeries == nil || result == nil {
return
}
cardIDs := make([]uint, 0, len(result.Cards)+1)
if result.AssetType == "card" {
cardIDs = append(cardIDs, result.AssetID)
} else {
for _, card := range result.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork} {
h.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: constants.CardObservationSceneAdminAssetRead,
ResourceType: constants.CardObservationResourceTypeCard, ResourceID: resourceID,
SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// Refresh 刷新资产状态(调网关同步)
// POST /api/admin/assets/:identifier/refresh
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
}
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
result, err := h.assetService.Refresh(c.UserContext(), asset.AssetType, asset.AssetID)
if err != nil {
return err
}
return response.Success(c, result)
}
// Packages 获取资产所有套餐列表(支持分页,默认 page=1 pageSize=50
// GET /api/admin/assets/:identifier/packages
func (h *AssetHandler) Packages(c *fiber.Ctx) error {
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
page := c.QueryInt("page", 1)
pageSize := c.QueryInt("page_size", 50)
statusRaw := c.Query("status")
var status *int
if statusRaw != "" {
parsedStatus, parseErr := strconv.Atoi(statusRaw)
if parseErr != nil || parsedStatus < constants.PackageUsageStatusPending || parsedStatus > constants.PackageUsageStatusInvalidated {
return errors.New(errors.CodeInvalidParam)
}
status = &parsedStatus
}
callerAccountType := resolveCallerAccountType(c)
result, err := h.assetService.GetPackages(c.UserContext(), asset.AssetType, asset.AssetID, page, pageSize, status, callerAccountType)
if err != nil {
return err
}
return response.Success(c, result)
}
// CurrentPackage 获取资产当前生效套餐
// GET /api/admin/assets/:identifier/current-package
func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error {
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
callerAccountType := resolveCallerAccountType(c)
result, err := h.assetService.GetCurrentPackage(c.UserContext(), asset.AssetType, asset.AssetID, callerAccountType)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdatePackageExpiresAt 修改资产套餐过期时间
// PATCH /api/admin/assets/:identifier/packages/:package_usage_id/expires-at
func (h *AssetHandler) UpdatePackageExpiresAt(c *fiber.Ctx) error {
if err := ensureAssetPackageAdjuster(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
return err
}
var req dto.UpdateAssetPackageExpiresAtRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
expiresAt, err := parseAssetPackageTime(req.ExpiresAt)
if err != nil {
return errors.New(errors.CodeInvalidParam, "过期时间格式无效")
}
result, err := h.assetService.UpdatePackageExpiresAt(c.UserContext(), asset.AssetType, asset.AssetID, assetIdentifierForAudit(asset), packageUsageID, expiresAt)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdatePackageUsage 修改资产套餐已用量
// PATCH /api/admin/assets/:identifier/packages/:package_usage_id/used-data
func (h *AssetHandler) UpdatePackageUsage(c *fiber.Ctx) error {
if err := ensureAssetPackageAdjuster(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
return err
}
var req dto.UpdateAssetPackageUsageRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.DataUsageMB == nil || *req.DataUsageMB < 0 {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.assetService.UpdatePackageUsage(c.UserContext(), asset.AssetType, asset.AssetID, assetIdentifierForAudit(asset), packageUsageID, *req.DataUsageMB)
if err != nil {
return err
}
return response.Success(c, result)
}
// Stop 停机(设备批量停机,单卡停机)
// POST /api/admin/assets/:identifier/stop
func (h *AssetHandler) Stop(c *fiber.Ctx) error {
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
switch asset.AssetType {
case "device":
result, devErr := h.deviceService.StopDevice(c.UserContext(), asset.AssetID)
if devErr != nil {
return devErr
}
return response.Success(c, result)
case "card":
if asset.ICCID == "" {
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法停机")
}
if cardErr := h.iotCardStopResume.ManualStopCard(c.UserContext(), asset.ICCID); cardErr != nil {
return cardErr
}
return response.Success(c, nil)
default:
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
}
}
// Start 复机(设备批量复机,单卡复机)
// POST /api/admin/assets/:identifier/start
func (h *AssetHandler) Start(c *fiber.Ctx) error {
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
switch asset.AssetType {
case "device":
if devErr := h.deviceService.StartDevice(c.UserContext(), asset.AssetID); devErr != nil {
return devErr
}
return response.Success(c, nil)
case "card":
if asset.ICCID == "" {
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法复机")
}
if cardErr := h.iotCardStopResume.ManualStartCard(c.UserContext(), asset.ICCID); cardErr != nil {
return cardErr
}
return response.Success(c, nil)
default:
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
}
}
// Deactivate 停用资产
// PATCH /api/admin/assets/:identifier/deactivate
func (h *AssetHandler) Deactivate(c *fiber.Ctx) error {
if h.assetLifecycleService == nil {
return errors.New(errors.CodeInternalError, "资产生命周期服务未配置")
}
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
switch asset.AssetType {
case "device":
if devErr := h.assetLifecycleService.DeactivateDevice(c.UserContext(), asset.AssetID); devErr != nil {
return devErr
}
case "card":
if cardErr := h.assetLifecycleService.DeactivateIotCard(c.UserContext(), asset.AssetID); cardErr != nil {
return cardErr
}
default:
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
}
return response.Success(c, nil)
}
// Orders 查询资产历史订单(支持跨代追溯)
// GET /api/admin/assets/:identifier/orders
func (h *AssetHandler) Orders(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
}
page := c.QueryInt("page", 1)
pageSize := c.QueryInt("page_size", 20)
includePrevious := c.QueryBool("include_previous", false)
result, err := h.assetService.GetOrders(c.UserContext(), identifier, page, pageSize, includePrevious)
if err != nil {
return err
}
return response.Success(c, result)
}
// OperationLogs 查询资产操作审计日志
// GET /api/admin/assets/:identifier/operation-logs
func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if h.assetAuditService == nil {
return errors.New(errors.CodeInternalError, "资产审计服务未配置")
}
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
page := c.QueryInt("page", 1)
pageSize := c.QueryInt("page_size", 20)
if page <= 0 || pageSize <= 0 || pageSize > 100 {
return errors.New(errors.CodeInvalidParam, "分页参数无效")
}
operationType := strings.TrimSpace(c.Query("operation_type"))
resultStatus := strings.TrimSpace(c.Query("result_status"))
if resultStatus != "" &&
resultStatus != constants.AssetAuditResultSuccess &&
resultStatus != constants.AssetAuditResultFailed &&
resultStatus != constants.AssetAuditResultDenied {
return errors.New(errors.CodeInvalidParam, "无效的结果状态")
}
result, err := h.assetAuditService.ListByAsset(c.UserContext(), assetAuditSvc.ListByAssetParams{
AssetType: asset.AssetType,
AssetID: asset.AssetID,
Page: page,
PageSize: pageSize,
OperationType: operationType,
ResultStatus: resultStatus,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdatePollingStatus 更新资产轮询状态
// PATCH /api/admin/assets/:identifier/polling-status
func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam)
}
var req dto.UpdateAssetPollingStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
asset, err := h.assetService.Resolve(c.UserContext(), identifier, false)
if err != nil {
return err
}
_ = middleware.GetUserIDFromContext(c.UserContext())
if h.assetPolling == nil {
return errors.New(errors.CodeInternalError, "轮询管控服务未配置")
}
pollingAssetType := asset.AssetType
if pollingAssetType == "card" {
pollingAssetType = "iot_card"
}
if err := h.assetPolling.UpdatePollingStatus(c.UserContext(), pollingAssetType, asset.AssetID, req.EnablePolling); err != nil {
return err
}
return response.Success(c, &dto.UpdateAssetPollingStatusResponse{
AssetType: asset.AssetType,
AssetID: asset.AssetID,
EnablePolling: req.EnablePolling,
})
}
// UpdateRealnamePolicy 更新资产实名认证策略
// PATCH /api/admin/assets/:identifier/realname-mode
func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权修改资产实名认证策略")
}
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam)
}
var req dto.UpdateAssetRealnamePolicyRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.RealnamePolicy != constants.RealnamePolicyNone &&
req.RealnamePolicy != constants.RealnamePolicyBeforeOrder &&
req.RealnamePolicy != constants.RealnamePolicyAfterOrder {
return errors.New(errors.CodeInvalidParam, "无效的实名认证策略")
}
asset, err := h.assetService.Resolve(c.UserContext(), identifier, false)
if err != nil {
return err
}
switch asset.AssetType {
case "card":
if err := h.iotCardService.UpdateRealnamePolicy(c.UserContext(), asset.AssetID, req.RealnamePolicy); err != nil {
return err
}
case "device":
if err := h.deviceService.UpdateRealnamePolicy(c.UserContext(), asset.AssetID, req.RealnamePolicy); err != nil {
return err
}
default:
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
}
return response.Success(c, &dto.UpdateAssetRealnamePolicyResponse{
AssetType: asset.AssetType,
AssetID: asset.AssetID,
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, false)
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,
})
}
// resolveCallerAccountType 从 middleware 上下文中提取调用方账号类型
// 平台账号SuperAdmin/Platform返回 "platform",其他返回 "agent"
func resolveCallerAccountType(c *fiber.Ctx) string {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return constants.OwnerTypePlatform
}
return "agent"
}
func (h *AssetHandler) resolveAssetPackageUsagePath(c *fiber.Ctx) (*dto.AssetResolveResponse, uint, error) {
asset, err := h.resolveByIdentifier(c)
if err != nil {
return nil, 0, err
}
packageUsageID, parseErr := strconv.ParseUint(c.Params("package_usage_id"), 10, 64)
if parseErr != nil || packageUsageID == 0 {
return nil, 0, errors.New(errors.CodeInvalidParam)
}
return asset, uint(packageUsageID), nil
}
func ensureAssetPackageAdjuster(c *fiber.Ctx) error {
if middleware.GetUserIDFromContext(c.UserContext()) != 1124 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil
}
func parseAssetPackageTime(value string) (time.Time, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return time.Time{}, errors.New(errors.CodeInvalidParam)
}
layouts := []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, errors.New(errors.CodeInvalidParam)
}
func assetIdentifierForAudit(asset *dto.AssetResolveResponse) string {
if asset == nil {
return ""
}
if asset.AssetType == "card" && strings.TrimSpace(asset.ICCID) != "" {
return asset.ICCID
}
if strings.TrimSpace(asset.VirtualNo) != "" {
return asset.VirtualNo
}
return asset.Identifier
}