企业授权增强与资产列表扩展
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m9s

- 企业卡授权唯一约束:新增 DB 迁移(000154),卡级部分唯一索引防止同一张卡被多个企业同时持有,Service 层新增跨企业冲突检测
- 单卡列表新增 network_status 过滤参数
- 单卡/设备列表新增 asset_status、asset_status_name、generation 响应字段
- 单卡/设备列表新增企业维度过滤(authorized_enterprise_id、is_authorized_to_enterprise)及响应中企业授权信息(批量加载,无 N+1)
- 主钱包流水/退款列表新增 asset_identifier 精确过滤参数
- 企业卡授权/收回接口升级为三模式(list/range/filter),企业设备授权/收回升级为双模式(list/filter)
- 升级 sonic v1.14.2 → v1.15.2 以兼容 Go 1.26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:23:03 +09:00
parent cf36f1447f
commit 3b7b856e48
37 changed files with 1260 additions and 84 deletions

View File

@@ -20,19 +20,21 @@ import (
)
type Service struct {
db *gorm.DB
redis *redis.Client
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService
db *gorm.DB
redis *redis.Client
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
}
func New(
@@ -49,21 +51,25 @@ func New(
gatewayClient *gateway.Client,
assetIdentifierStore *postgres.AssetIdentifierStore,
assetAuditService AssetAuditService,
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
enterpriseStore *postgres.EnterpriseStore,
) *Service {
return &Service{
db: db,
redis: rds,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService,
db: db,
redis: rds,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService,
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore,
}
}
@@ -133,6 +139,12 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
if req.HasActivePackage != nil {
filters["has_active_package"] = *req.HasActivePackage
}
if req.AuthorizedEnterpriseID != nil {
filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID
}
if req.IsAuthorizedToEnterprise != nil {
filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise
}
devices, total, err := s.deviceStore.List(ctx, opts, filters)
if err != nil {
@@ -151,9 +163,31 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
return nil, err
}
// 批量查询有效企业授权(避免 N+1 查询)
deviceEnterpriseMap, err := s.enterpriseDeviceAuthStore.GetActiveAuthEnterpriseByDeviceIDs(ctx, deviceIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备企业授权失败")
}
enterpriseIDs := make([]uint, 0)
seen := make(map[uint]struct{})
for _, eid := range deviceEnterpriseMap {
if _, ok := seen[eid]; !ok {
seen[eid] = struct{}{}
enterpriseIDs = append(enterpriseIDs, eid)
}
}
enterpriseNameMap, err := s.enterpriseStore.GetNameMapByIDs(ctx, enterpriseIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询企业名称失败")
}
list := make([]*dto.DeviceResponse, 0, len(devices))
for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses)
if eid, ok := deviceEnterpriseMap[device.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]
}
list = append(list, item)
}
@@ -941,6 +975,9 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string
SoftwareVersion: device.SoftwareVersion,
SwitchMode: device.SwitchMode,
LastGatewaySyncAt: device.LastGatewaySyncAt,
AssetStatus: device.AssetStatus,
AssetStatusName: constants.GetAssetStatusName(device.AssetStatus),
Generation: device.Generation,
}
if device.ShopID != nil && *device.ShopID > 0 {

View File

@@ -112,6 +112,92 @@ func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, r
return resp, nil
}
// resolveICCIDsForAllocate 根据选取模式解析待授权的 ICCID 列表
func (s *Service) resolveICCIDsForAllocate(ctx context.Context, req *dto.AllocateCardsReq) ([]string, error) {
switch req.SelectionType {
case "list":
return req.ICCIDs, nil
case "range":
cards, err := s.iotCardStore.GetStandaloneByICCIDRangeForAuth(ctx, req.ICCIDStart, req.ICCIDEnd)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按号段查询卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
case "filter":
filters := map[string]any{}
if req.ICCID != "" {
filters["iccid"] = req.ICCID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if len(req.ShopIDs) > 0 {
filters["shop_ids"] = req.ShopIDs
}
cards, err := s.iotCardStore.GetStandaloneByAuthFilters(ctx, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选取模式")
}
}
// resolveICCIDsForRecall 根据选取模式解析待回收的 ICCID 列表
func (s *Service) resolveICCIDsForRecall(ctx context.Context, enterpriseID uint, req *dto.RecallCardsReq) ([]string, error) {
switch req.SelectionType {
case "list":
return req.ICCIDs, nil
case "range":
cards, err := s.iotCardStore.GetAuthorizedStandaloneByICCIDRange(ctx, enterpriseID, req.ICCIDStart, req.ICCIDEnd)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按号段查询已授权卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
case "filter":
filters := map[string]any{}
if req.ICCID != "" {
filters["iccid"] = req.ICCID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
cards, err := s.iotCardStore.GetAuthorizedStandaloneByFilters(ctx, enterpriseID, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询已授权卡失败")
}
iccids := make([]string, 0, len(cards))
for _, c := range cards {
iccids = append(iccids, c.ICCID)
}
return iccids, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选取模式")
}
}
func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsReq) (*dto.AllocateCardsResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
@@ -123,7 +209,12 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs})
iccids, err := s.resolveICCIDsForAllocate(ctx, req)
if err != nil {
return nil, err
}
preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: iccids})
if err != nil {
return nil, err
}
@@ -133,9 +224,31 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
FailCount: len(preview.FailedItems),
}
cardIDsToAllocate := make([]uint, 0)
// 构建 cardID -> ICCID 映射,用于组装失败原因
cardIDToICCID := make(map[uint]string, len(preview.StandaloneCards))
allCandidateIDs := make([]uint, 0, len(preview.StandaloneCards))
for _, card := range preview.StandaloneCards {
cardIDsToAllocate = append(cardIDsToAllocate, card.IotCardID)
cardIDToICCID[card.IotCardID] = card.ICCID
allCandidateIDs = append(allCandidateIDs, card.IotCardID)
}
// 检测已被其他企业授权的卡,阻止重复授权
conflictAuths, err := s.enterpriseCardAuthStore.GetConflictingAuthsByCardIDs(ctx, enterpriseID, allCandidateIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询冲突授权失败")
}
cardIDsToAllocate := make([]uint, 0, len(allCandidateIDs))
for _, cardID := range allCandidateIDs {
if _, conflict := conflictAuths[cardID]; conflict {
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
ICCID: cardIDToICCID[cardID],
Reason: "卡已授权给其他企业,请先收回",
})
resp.FailCount++
continue
}
cardIDsToAllocate = append(cardIDsToAllocate, cardID)
}
existingAuths, err := s.enterpriseCardAuthStore.GetActiveAuthsByCardIDs(ctx, enterpriseID, cardIDsToAllocate)
@@ -181,7 +294,12 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
iotCardPtrs, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
iccids, err := s.resolveICCIDsForRecall(ctx, enterpriseID, req)
if err != nil {
return nil, err
}
iotCardPtrs, err := s.iotCardStore.GetByICCIDs(ctx, iccids)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
}
@@ -206,7 +324,7 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
}
cardIDsToRecall := make([]uint, 0)
for _, iccid := range req.ICCIDs {
for _, iccid := range iccids {
card, exists := cardMap[iccid]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{

View File

@@ -46,7 +46,7 @@ func New(
}
}
// AllocateDevices 授权设备给企业
// AllocateDevices 授权设备给企业,支持 list/filter 两种模式
func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *dto.AllocateDevicesReq) (*dto.AllocateDevicesResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
@@ -59,9 +59,15 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForAllocate(ctx, req)
if err != nil {
return nil, err
}
// 查询所有设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -88,8 +94,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
}
devicesToAllocate := make([]*model.Device, 0)
seenDeviceNos := make(map[string]struct{}, len(req.DeviceNos))
for _, deviceNo := range req.DeviceNos {
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
for _, deviceNo := range deviceNos {
if _, exists := seenDeviceNos[deviceNo]; exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
@@ -267,7 +273,62 @@ func isUniqueConstraintViolation(err error, constraintName string) bool {
return false
}
// RecallDevices 撤销设备授权
// resolveDeviceNosForAllocate 根据选取模式解析授权候选设备号列表
// list 模式:直接使用请求中的 device_nos
// filter 模式:按过滤条件从 store 查询,代理用户的店铺限制由 ApplyShopFilter 自动应用
func (s *Service) resolveDeviceNosForAllocate(ctx context.Context, req *dto.AllocateDevicesReq) ([]string, error) {
if req.SelectionType == "list" {
return req.DeviceNos, nil
}
filters := make(map[string]any)
if req.VirtualNo != "" {
filters["virtual_no"] = req.VirtualNo
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.ShopID != nil {
filters["shop_id"] = req.ShopID
}
devices, err := s.deviceStore.GetByFilters(ctx, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询设备失败")
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
nos = append(nos, d.VirtualNo)
}
return nos, nil
}
// resolveDeviceNosForRecall 根据选取模式解析收回候选设备号列表
// filter 模式仅返回当前企业有效授权的设备(通过子查询限定)
func (s *Service) resolveDeviceNosForRecall(ctx context.Context, enterpriseID uint, req *dto.RecallDevicesReq) ([]string, error) {
if req.SelectionType == "list" {
return req.DeviceNos, nil
}
// filter 模式:只查询已授权给该企业的设备
filters := make(map[string]any)
if req.VirtualNo != "" {
filters["virtual_no"] = req.VirtualNo
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
// 仅返回有效授权给该企业的设备
filters["authorized_enterprise_id"] = enterpriseID
devices, err := s.deviceStore.GetByFilters(ctx, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "按条件查询设备失败")
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
nos = append(nos, d.VirtualNo)
}
return nos, nil
}
// RecallDevices 撤销设备授权,支持 list/filter 两种模式
func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto.RecallDevicesReq) (*dto.RecallDevicesResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
@@ -280,9 +341,15 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForRecall(ctx, enterpriseID, req)
if err != nil {
return nil, err
}
// 查询设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -304,7 +371,7 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
}
deviceAuthsToRevoke := make([]uint, 0)
for _, deviceNo := range req.DeviceNos {
for _, deviceNo := range deviceNos {
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{

View File

@@ -69,6 +69,8 @@ type Service struct {
redis *redis.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
}
func New(
@@ -107,6 +109,16 @@ func (s *Service) SetAssetIdentifierStore(store *postgres.AssetIdentifierStore)
s.assetIdentifierStore = store
}
// SetEnterpriseCardAuthStore 注入企业卡授权 store用于列表响应回填企业信息
func (s *Service) SetEnterpriseCardAuthStore(store *postgres.EnterpriseCardAuthorizationStore) {
s.enterpriseCardAuthStore = store
}
// SetEnterpriseStore 注入企业 store用于批量加载企业名称
func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) {
s.enterpriseStore = store
}
// SetDataDeductor 设置流量扣减回调
// 在应用启动时由 bootstrap 调用,注入套餐扣减服务
func (s *Service) SetDataDeductor(deductor DataDeductor) {
@@ -229,6 +241,15 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
if req.Keyword != "" {
filters["keyword"] = req.Keyword
}
if req.NetworkStatus != nil {
filters["network_status"] = *req.NetworkStatus
}
if req.AuthorizedEnterpriseID != nil {
filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID
}
if req.IsAuthorizedToEnterprise != nil {
filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise
}
// 代理用户注入 subordinate_shop_ids让 Store 层走并行查询路径
// 避免 PG 对 shop_id IN (...) + ORDER BY 选择全表扫描
@@ -260,9 +281,45 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
//TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的
seriesMap := s.loadSeriesNames(ctx, cards)
// 批量加载企业授权信息
cardAuthMap := make(map[uint]uint)
enterpriseNameMap := make(map[uint]string)
if s.enterpriseCardAuthStore != nil && len(cards) > 0 {
cardIDs := make([]uint, 0, len(cards))
for _, card := range cards {
cardIDs = append(cardIDs, card.ID)
}
authMap, err := s.enterpriseCardAuthStore.GetActiveAuthEnterpriseByCardIDs(ctx, cardIDs)
if err != nil {
s.logger.Error("批量加载卡企业授权失败", zap.Error(err))
} else {
cardAuthMap = authMap
}
if s.enterpriseStore != nil && len(cardAuthMap) > 0 {
eIDs := make([]uint, 0, len(cardAuthMap))
seen := make(map[uint]struct{})
for _, eid := range cardAuthMap {
if _, ok := seen[eid]; !ok {
seen[eid] = struct{}{}
eIDs = append(eIDs, eid)
}
}
nameMap, err := s.enterpriseStore.GetNameMapByIDs(ctx, eIDs)
if err != nil {
s.logger.Error("批量加载企业名称失败", zap.Error(err))
} else {
enterpriseNameMap = nameMap
}
}
}
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
for _, card := range cards {
item := s.toStandaloneResponse(card, shopMap, seriesMap)
if eid, ok := cardAuthMap[card.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]
}
list = append(list, item)
}
@@ -411,6 +468,9 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str
EnablePolling: card.EnablePolling,
SeriesID: card.SeriesID,
DeviceVirtualNo: card.DeviceVirtualNo,
AssetStatus: card.AssetStatus,
AssetStatusName: constants.GetAssetStatusName(card.AssetStatus),
Generation: card.Generation,
CreatedAt: card.CreatedAt,
UpdatedAt: card.UpdatedAt,
}

View File

@@ -160,9 +160,10 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
}
filters := &postgres.RefundListFilters{
Status: req.Status,
OrderID: req.OrderID,
ShopID: req.ShopID,
Status: req.Status,
OrderID: req.OrderID,
ShopID: req.ShopID,
AssetIdentifier: req.AssetIdentifier,
}
requests, total, err := s.refundStore.List(ctx, opts, filters)

View File

@@ -709,6 +709,7 @@ func (s *Service) ListMainWalletTransactions(ctx context.Context, shopID uint, r
TransactionType: req.TransactionType,
StartDate: req.StartDate,
EndDate: req.EndDate,
AssetIdentifier: req.AssetIdentifier,
}
offset := (page - 1) * pageSize