Files
junhong_cmp_fiber/internal/handler/admin/asset.go
break 62419d4b17
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
feat(导出时间筛选): AUG26-014 统一时间筛选与临期导出,归档并同步主 Spec 与证据链
统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。

端点改造(13 个入口):IoT 卡导入任务、设备导入任务、导出任务列表、订单列表参数名不变仅收紧解析;换货、分配记录、代理充值、临期列表改名 start_time/end_time(旧参数名显式拒绝);提现记录两处删除解析失败静默跳过,非法参数一律 1001;授权记录由起始闭结束开改为闭区间含两端;临期列表改按当前生效主套餐最终到期时刻比较,保留剩余天数上下限与既有粗放窗口。

临期导出新建:新场景 expiring_asset 与受控入口 POST /api/admin/expiring-assets/export,复用列表候选预筛与最终到期推算,一行一资产、加油包不单独成行,列序与 111 §18.1 逐列一致,店铺/业务员/用户组按执行时当前归属补充且不超出创建时冻结范围。

佣金明细导出新增按创建时间闭区间筛选(原佣金与回溯两条分支各自创建时间列),记录粒度、列定义与余额口径不变。

冻结与遗留任务:创建期把筛选与时间边界规范化为 UTC RFC3339 秒级串写入既有 query_json,无新列无迁移;执行期只按冻结值严格解析,非法冻结值在任何分片与文件动作前落任务失败并写安全摘要,不放行全量;重试沿用原快照。达量预警导出执行期同样纳入严格解析(其入口契约、列定义与触发快照口径不变)。

归档 add-export-time-filter-standards 并新建主 Spec openspec/specs/export-time-filter/spec.md,同步 requirement-evidence.json 与入口能力矩阵,README 导出场景清单更新为 11 个场景。

验证:junhong_cmp_test + 本地隔离 Redis(DB7,测试部署共享队列 DB6 未被占用)实跑 85 PASS / 0 FAIL(接受/拒绝集合、区间与顺序语义、列表与导出同筛选行集一致、代理 HTTP 全链路与范围冻结、遗留旧格式任务安全失败、列与余额口径回归、表头逐字),门禁 gofmt/go build/gendocs 两次一致/openspec validate/doctor/context-health 全绿;无 Schema 变更、无迁移、无运行时开关。
2026-09-17 18:37:02 +08:00

777 lines
25 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
exchangeTraceQuery AssetExchangeTraceResolver
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),
)
}
}
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
type AssetExchangeTraceResolver interface {
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
}
// NewAssetHandler 创建资产管理处理器
func NewAssetHandler(
assetSvc *assetService.Service,
assetAuditService *assetAuditSvc.Service,
deviceSvc *deviceService.Service,
iotCardSvc *iotCardService.Service,
iotCardStopResume *iotCardService.StopResumeService,
assetPolling *pollingSvc.AssetPollingService,
exchangeTraceQuery AssetExchangeTraceResolver,
) *AssetHandler {
return &AssetHandler{
assetService: assetSvc,
assetAuditService: assetAuditService,
deviceService: deviceSvc,
iotCardService: iotCardSvc,
iotCardStopResume: iotCardStopResume,
assetPolling: assetPolling,
exchangeTraceQuery: exchangeTraceQuery,
}
}
// 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{}
if h.exchangeTraceQuery != nil {
result.ExchangeTrace, err = h.exchangeTraceQuery.Resolve(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
}