diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 6400ac9c..5f66a32a 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -391,6 +391,9 @@ func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error { // UpdatePackageExpiresAt 修改资产套餐过期时间 // PATCH /api/admin/assets/:identifier/packages/:package_usage_id/expires-at func (h *AssetHandler) UpdatePackageExpiresAt(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c) if err != nil { @@ -417,6 +420,9 @@ func (h *AssetHandler) UpdatePackageExpiresAt(c *fiber.Ctx) error { // UpdatePackageUsage 修改资产套餐已用量 // PATCH /api/admin/assets/:identifier/packages/:package_usage_id/used-data func (h *AssetHandler) UpdatePackageUsage(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c) if err != nil { return err @@ -496,6 +502,9 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error { // Deactivate 停用资产 // PATCH /api/admin/assets/:identifier/deactivate func (h *AssetHandler) Deactivate(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } if h.assetLifecycleService == nil { return errors.New(errors.CodeInternalError, "资产生命周期服务未配置") } @@ -590,6 +599,9 @@ func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error { // UpdatePollingStatus 更新资产轮询状态 // PATCH /api/admin/assets/:identifier/polling-status func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } identifier := c.Params("identifier") if identifier == "" { return errors.New(errors.CodeInvalidParam) @@ -678,6 +690,9 @@ func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error { // UpdateRealnameStatus 手动更新资产实名状态 // PATCH /api/admin/assets/:identifier/realname-status func (h *AssetHandler) UpdateRealnameStatus(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } identifier := c.Params("identifier") if identifier == "" { return errors.New(errors.CodeInvalidParam) @@ -770,3 +785,12 @@ func assetIdentifierForAudit(asset *dto.AssetResolveResponse) string { } return asset.Identifier } + +// rejectEnterpriseAssetAdmin 拒绝企业账号执行平台管理类资产写操作(改套餐有效期与已用量、停用、开关轮询、改实名状态)。 +// 企业账号即使对已授权资产也不能执行这些操作;停复机仍按企业授权范围放行(由 Resolve 约束)。 +func rejectEnterpriseAssetAdmin(c *fiber.Ctx) error { + if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise { + return errors.New(errors.CodeForbidden, "企业账号无权执行该资产操作") + } + return nil +} diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 5afa3ef9..1d9a7a9d 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -14,7 +14,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "GET", "/resolve/:identifier", handler.Resolve, RouteSpec{ Summary: "解析资产", - Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。exchange_trace 始终存在,previous_asset/next_asset 无关系时为 null;关联项仅在 can_view=true 且 asset_id 非空时允许跳转。企业账号禁止调用。", + Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。exchange_trace 始终存在,previous_asset/next_asset 无关系时为 null;关联项仅在 can_view=true 且 asset_id 非空时允许跳转。企业账号禁止调用。企业账号只能解析当前授权给本企业的卡或设备,未授权与不存在同样返回未找到;/assets/:identifier/* 下其余接口同此范围。", Tags: []string{"资产管理"}, Input: new(dto.AssetResolveRequest), Output: new(dto.AssetResolveResponse), @@ -58,7 +58,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "PATCH", "/:identifier/packages/:package_usage_id/expires-at", handler.UpdatePackageExpiresAt, RouteSpec{ Summary: "修改资产套餐过期时间", - Description: "修改指定资产下某条套餐使用记录的过期时间。仅允许已认证账号调用,并保留资产与套餐记录归属校验。", + Description: "修改指定资产下某条套餐使用记录的过期时间。仅允许已认证账号调用,并保留资产与套餐记录归属校验。企业账号无权调用。", Tags: []string{"资产管理"}, Input: new(dto.UpdateAssetPackageExpiresAtRequest), Output: new(dto.AssetPackageResponse), @@ -67,7 +67,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "PATCH", "/:identifier/packages/:package_usage_id/used-data", handler.UpdatePackageUsage, RouteSpec{ Summary: "修改资产套餐已用量", - Description: "修改指定资产下某条套餐使用记录的真实已用量(MB)。仅允许已认证账号调用,并保留资产与套餐记录归属校验。", + Description: "修改指定资产下某条套餐使用记录的真实已用量(MB)。仅允许已认证账号调用,并保留资产与套餐记录归属校验。企业账号无权调用。", Tags: []string{"资产管理"}, Input: new(dto.UpdateAssetPackageUsageRequest), Output: new(dto.AssetPackageResponse), @@ -94,7 +94,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "PATCH", "/:identifier/deactivate", handler.Deactivate, RouteSpec{ Summary: "停用资产", - Description: "将资产状态设置为停用(不可逆)。仅支持库存中或已售出的资产。", + Description: "将资产状态设置为停用(不可逆)。仅支持库存中或已售出的资产。企业账号无权调用。", Tags: []string{"资产管理"}, Input: new(dto.AssetIdentifierRequest), Output: nil, @@ -139,7 +139,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "PATCH", "/:identifier/polling-status", handler.UpdatePollingStatus, RouteSpec{ Summary: "更新资产轮询状态", - Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询;同事务写不轮询清单(来源单资产接口)与审计。网关扩展状态为风险停机或已销户的卡不能开启轮询,返回 403。设备开关级联到设备下各卡(开启时跳过风险停机、已销户的卡)。影响说明见 GET /polling-status/impact。", + Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询;同事务写不轮询清单(来源单资产接口)与审计。网关扩展状态为风险停机或已销户的卡不能开启轮询,返回 403。设备开关级联到设备下各卡(开启时跳过风险停机、已销户的卡)。影响说明见 GET /polling-status/impact。企业账号无权调用。", Tags: []string{"资产管理"}, Input: new(dto.UpdateAssetPollingStatusRequest), Output: new(dto.UpdateAssetPollingStatusResponse), @@ -157,7 +157,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "PATCH", "/:identifier/realname-status", handler.UpdateRealnameStatus, RouteSpec{ Summary: "手动更新卡实名状态", - Description: "手动将指定卡资产标记为已实名或未实名。状态更新后会触发停复机评估,并在标记为已实名时触发待实名套餐激活。", + Description: "手动将指定卡资产标记为已实名或未实名。状态更新后会触发停复机评估,并在标记为已实名时触发待实名套餐激活。企业账号无权调用。", Tags: []string{"资产管理"}, Input: new(dto.UpdateAssetRealnameStatusRequest), Output: new(dto.UpdateAssetRealnameStatusResponse), diff --git a/internal/service/asset/enterprise_scope.go b/internal/service/asset/enterprise_scope.go new file mode 100644 index 00000000..833f226a --- /dev/null +++ b/internal/service/asset/enterprise_scope.go @@ -0,0 +1,42 @@ +package asset + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// ensureEnterpriseAssetVisible 校验企业账号只能访问当前授权给本企业的卡或设备。 +// 非企业账号直接放行(由原有店铺数据范围约束)。未授权与不存在返回同一错误,不暴露资产是否存在。 +// 设备授权时会同步为设备内的卡写入卡授权记录,因此卡只看卡授权表。 +func (s *Service) ensureEnterpriseAssetVisible(ctx context.Context, assetType string, assetID uint) error { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeEnterprise { + return nil + } + notFound := errors.New(errors.CodeNotFound, "未找到匹配的资产") + enterpriseID := middleware.GetEnterpriseIDFromContext(ctx) + if enterpriseID == 0 || assetID == 0 { + return notFound + } + table, column := "", "" + switch assetType { + case "card", constants.AssetTypeIotCard: + table, column = "tb_enterprise_card_authorization", "card_id" + case constants.AssetTypeDevice: + table, column = "tb_enterprise_device_authorization", "device_id" + default: + return notFound + } + var count int64 + if err := s.db.WithContext(ctx).Table(table). + Where(column+" = ? AND enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", assetID, enterpriseID). + Count(&count).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "校验企业资产授权失败") + } + if count == 0 { + return notFound + } + return nil +} diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 81e0e532..9b053f7f 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -118,7 +118,19 @@ func (s *Service) SetAccessAudit(writer *infraAudit.Writer) { // 主路径:查注册表(精确匹配 ICCID 或 VirtualNo) // Fallback:原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符) // includeUsageSummary 为 true 时额外填充当前世代流量汇总字段 +// 企业账号只能解析当前授权给本企业的卡或设备,其余一律按"未找到"处理(/assets/:identifier/* 共用此入口)。 func (s *Service) Resolve(ctx context.Context, identifier string, includeUsageSummary bool) (*dto.AssetResolveResponse, error) { + resp, err := s.resolveAsset(ctx, identifier, includeUsageSummary) + if err != nil || resp == nil { + return resp, err + } + if err := s.ensureEnterpriseAssetVisible(ctx, resp.AssetType, resp.AssetID); err != nil { + return nil, err + } + return resp, nil +} + +func (s *Service) resolveAsset(ctx context.Context, identifier string, includeUsageSummary bool) (*dto.AssetResolveResponse, error) { if s.assetIdentifierStore != nil { regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier) if regErr == nil && regRecord != nil {