All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
统一时间筛选:新增共享严格解析器 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 变更、无迁移、无运行时开关。
777 lines
25 KiB
Go
777 lines
25 KiB
Go
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
|
||
}
|