All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m42s
555 lines
16 KiB
Go
555 lines
16 KiB
Go
package admin
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
|
||
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
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/middleware"
|
||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||
)
|
||
|
||
// AssetHandler 资产管理处理器
|
||
// 提供统一的资产解析、实时状态、套餐查询、停复机、停用、轮询管控等接口
|
||
type AssetHandler struct {
|
||
assetService *assetService.Service
|
||
assetAuditService *assetAuditSvc.Service
|
||
deviceService *deviceService.Service
|
||
iotCardService *iotCardService.Service
|
||
iotCardStopResume *iotCardService.StopResumeService
|
||
assetPolling *pollingSvc.AssetPollingService
|
||
assetLifecycleService AssetLifecycleService
|
||
}
|
||
|
||
// NewAssetHandler 创建资产管理处理器
|
||
func NewAssetHandler(
|
||
assetSvc *assetService.Service,
|
||
assetAuditService *assetAuditSvc.Service,
|
||
deviceSvc *deviceService.Service,
|
||
iotCardSvc *iotCardService.Service,
|
||
iotCardStopResume *iotCardService.StopResumeService,
|
||
assetPolling *pollingSvc.AssetPollingService,
|
||
) *AssetHandler {
|
||
return &AssetHandler{
|
||
assetService: assetSvc,
|
||
assetAuditService: assetAuditService,
|
||
deviceService: deviceSvc,
|
||
iotCardService: iotCardSvc,
|
||
iotCardStopResume: iotCardStopResume,
|
||
assetPolling: assetPolling,
|
||
}
|
||
}
|
||
|
||
// SetLifecycleService 注入生命周期服务(用于 Deactivate 接口)
|
||
func (h *AssetHandler) SetLifecycleService(svc AssetLifecycleService) {
|
||
h.assetLifecycleService = svc
|
||
}
|
||
|
||
// resolveByIdentifier 通过标识符解析资产,用于各处理器的公共逻辑
|
||
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)
|
||
}
|
||
|
||
// 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, "标识符不能为空")
|
||
}
|
||
|
||
result, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.Success(c, result)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
return response.Success(c, result)
|
||
}
|
||
|
||
// 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 {
|
||
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)
|
||
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 {
|
||
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)
|
||
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)
|
||
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()) != 2 && 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
|
||
}
|