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>
511 lines
16 KiB
Go
511 lines
16 KiB
Go
package enterprise_device
|
||
|
||
import (
|
||
"context"
|
||
stderrors "errors"
|
||
"time"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||
"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/jackc/pgx/v5/pgconn"
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type Service struct {
|
||
db *gorm.DB
|
||
enterpriseStore *postgres.EnterpriseStore
|
||
deviceStore *postgres.DeviceStore
|
||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
|
||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
|
||
logger *zap.Logger
|
||
}
|
||
|
||
func New(
|
||
db *gorm.DB,
|
||
enterpriseStore *postgres.EnterpriseStore,
|
||
deviceStore *postgres.DeviceStore,
|
||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
|
||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
|
||
logger *zap.Logger,
|
||
) *Service {
|
||
return &Service{
|
||
db: db,
|
||
enterpriseStore: enterpriseStore,
|
||
deviceStore: deviceStore,
|
||
deviceSimBindingStore: deviceSimBindingStore,
|
||
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
|
||
enterpriseCardAuthStore: enterpriseCardAuthStore,
|
||
logger: logger,
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
// 验证企业存在
|
||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||
if err != nil {
|
||
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 ?", deviceNos).Find(&devices).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||
}
|
||
|
||
deviceMap := make(map[string]*model.Device)
|
||
deviceIDs := make([]uint, 0, len(devices))
|
||
for i := range devices {
|
||
deviceMap[devices[i].VirtualNo] = &devices[i]
|
||
deviceIDs = append(deviceIDs, devices[i].ID)
|
||
}
|
||
|
||
// 获取当前用户的店铺ID(用于验证设备所有权)
|
||
currentShopID := middleware.GetShopIDFromContext(ctx)
|
||
userType := middleware.GetUserTypeFromContext(ctx)
|
||
|
||
// 检查设备是否已存在有效授权(不区分企业)
|
||
activeAuthEnterpriseMap, err := s.enterpriseDeviceAuthStore.GetActiveAuthEnterpriseByDeviceIDs(ctx, deviceIDs)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询已有授权失败")
|
||
}
|
||
|
||
resp := &dto.AllocateDevicesResp{
|
||
FailedItems: make([]dto.FailedDeviceItem, 0),
|
||
AuthorizedDevices: make([]dto.AuthorizedDeviceItem, 0),
|
||
}
|
||
|
||
devicesToAllocate := make([]*model.Device, 0)
|
||
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,
|
||
Reason: "请求中设备号重复",
|
||
})
|
||
continue
|
||
}
|
||
seenDeviceNos[deviceNo] = struct{}{}
|
||
|
||
device, exists := deviceMap[deviceNo]
|
||
if !exists {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: deviceNo,
|
||
Reason: "设备不存在",
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 验证设备状态(必须是"已分销"状态)
|
||
if device.Status != 2 {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: deviceNo,
|
||
Reason: "设备状态不正确,必须是已分销状态",
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 验证设备所有权(除非是超级管理员或平台用户)
|
||
if userType == constants.UserTypeAgent {
|
||
if device.ShopID == nil || *device.ShopID != currentShopID {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: deviceNo,
|
||
Reason: "无权操作此设备",
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 检查是否已授权(同企业 / 其他企业)
|
||
if authEnterpriseID, exists := activeAuthEnterpriseMap[device.ID]; exists {
|
||
reason := "设备已授权给其他企业"
|
||
if authEnterpriseID == enterpriseID {
|
||
reason = "设备已授权给此企业"
|
||
}
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: deviceNo,
|
||
Reason: reason,
|
||
})
|
||
continue
|
||
}
|
||
|
||
devicesToAllocate = append(devicesToAllocate, device)
|
||
}
|
||
|
||
// 在事务中处理授权
|
||
if len(devicesToAllocate) > 0 {
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
now := time.Now()
|
||
authorizerType := userType
|
||
|
||
// 1. 创建设备授权记录(逐条处理,避免并发冲突导致整批失败)
|
||
deviceAuthIDMap := make(map[uint]uint, len(devicesToAllocate))
|
||
successDevices := make([]*model.Device, 0, len(devicesToAllocate))
|
||
for _, device := range devicesToAllocate {
|
||
deviceAuth := &model.EnterpriseDeviceAuthorization{
|
||
EnterpriseID: enterpriseID,
|
||
DeviceID: device.ID,
|
||
AuthorizedBy: currentUserID,
|
||
AuthorizedAt: now,
|
||
AuthorizerType: authorizerType,
|
||
Remark: req.Remark,
|
||
}
|
||
|
||
if err := tx.Create(deviceAuth).Error; err != nil {
|
||
if isUniqueConstraintViolation(err, "uq_active_device_auth") {
|
||
reason := "设备已授权给其他企业"
|
||
|
||
var existingAuth model.EnterpriseDeviceAuthorization
|
||
queryErr := tx.Select("enterprise_id").
|
||
Where("device_id = ? AND revoked_at IS NULL", device.ID).
|
||
First(&existingAuth).Error
|
||
if queryErr == nil && existingAuth.EnterpriseID == enterpriseID {
|
||
reason = "设备已授权给此企业"
|
||
}
|
||
if queryErr != nil && !stderrors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||
return errors.Wrap(errors.CodeInternalError, queryErr, "查询冲突授权记录失败")
|
||
}
|
||
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: device.VirtualNo,
|
||
Reason: reason,
|
||
})
|
||
continue
|
||
}
|
||
return errors.Wrap(errors.CodeInternalError, err, "创建设备授权记录失败")
|
||
}
|
||
|
||
deviceAuthIDMap[device.ID] = deviceAuth.ID
|
||
successDevices = append(successDevices, device)
|
||
}
|
||
|
||
// 2. 查询所有设备绑定的卡
|
||
deviceIDsToQuery := make([]uint, 0, len(successDevices))
|
||
for _, device := range successDevices {
|
||
deviceIDsToQuery = append(deviceIDsToQuery, device.ID)
|
||
}
|
||
|
||
var bindings []model.DeviceSimBinding
|
||
if len(deviceIDsToQuery) > 0 {
|
||
if err := tx.Where("device_id IN ? AND bind_status = 1", deviceIDsToQuery).Find(&bindings).Error; err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||
}
|
||
}
|
||
|
||
// 3. 为每张绑定的卡创建授权记录
|
||
if len(bindings) > 0 {
|
||
cardAuths := make([]*model.EnterpriseCardAuthorization, 0, len(bindings))
|
||
for _, binding := range bindings {
|
||
deviceAuthID := deviceAuthIDMap[binding.DeviceID]
|
||
cardAuths = append(cardAuths, &model.EnterpriseCardAuthorization{
|
||
EnterpriseID: enterpriseID,
|
||
CardID: binding.IotCardID,
|
||
DeviceAuthID: &deviceAuthID,
|
||
AuthorizedBy: currentUserID,
|
||
AuthorizedAt: now,
|
||
AuthorizerType: authorizerType,
|
||
Remark: req.Remark,
|
||
})
|
||
}
|
||
|
||
if err := tx.Create(cardAuths).Error; err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "创建卡授权记录失败")
|
||
}
|
||
}
|
||
|
||
// 4. 统计每个设备的绑定卡数量
|
||
deviceCardCount := make(map[uint]int)
|
||
for _, binding := range bindings {
|
||
deviceCardCount[binding.DeviceID]++
|
||
}
|
||
|
||
// 5. 构建响应
|
||
for _, device := range successDevices {
|
||
resp.AuthorizedDevices = append(resp.AuthorizedDevices, dto.AuthorizedDeviceItem{
|
||
DeviceID: device.ID,
|
||
VirtualNo: device.VirtualNo,
|
||
CardCount: deviceCardCount[device.ID],
|
||
})
|
||
}
|
||
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
resp.SuccessCount = len(resp.AuthorizedDevices)
|
||
resp.FailCount = len(resp.FailedItems)
|
||
return resp, nil
|
||
}
|
||
|
||
func isUniqueConstraintViolation(err error, constraintName string) bool {
|
||
var pgErr *pgconn.PgError
|
||
if stderrors.As(err, &pgErr) {
|
||
if pgErr.Code != "23505" {
|
||
return false
|
||
}
|
||
if constraintName == "" {
|
||
return true
|
||
}
|
||
return pgErr.ConstraintName == constraintName
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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 {
|
||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
// 验证企业存在
|
||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||
if err != nil {
|
||
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 ?", deviceNos).Find(&devices).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||
}
|
||
|
||
deviceMap := make(map[string]*model.Device)
|
||
deviceIDs := make([]uint, 0, len(devices))
|
||
for i := range devices {
|
||
deviceMap[devices[i].VirtualNo] = &devices[i]
|
||
deviceIDs = append(deviceIDs, devices[i].ID)
|
||
}
|
||
|
||
// 检查授权状态
|
||
existingAuths, err := s.enterpriseDeviceAuthStore.GetActiveAuthsByDeviceIDs(ctx, enterpriseID, deviceIDs)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询授权状态失败")
|
||
}
|
||
|
||
resp := &dto.RecallDevicesResp{
|
||
FailedItems: make([]dto.FailedDeviceItem, 0),
|
||
}
|
||
|
||
deviceAuthsToRevoke := make([]uint, 0)
|
||
for _, deviceNo := range deviceNos {
|
||
device, exists := deviceMap[deviceNo]
|
||
if !exists {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: deviceNo,
|
||
Reason: "设备不存在",
|
||
})
|
||
continue
|
||
}
|
||
|
||
if !existingAuths[device.ID] {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: deviceNo,
|
||
Reason: "设备未授权给此企业",
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 获取授权记录ID
|
||
auth, err := s.enterpriseDeviceAuthStore.GetByDeviceID(ctx, device.ID)
|
||
if err != nil || auth.EnterpriseID != enterpriseID {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||
VirtualNo: deviceNo,
|
||
Reason: "授权记录不存在",
|
||
})
|
||
continue
|
||
}
|
||
|
||
deviceAuthsToRevoke = append(deviceAuthsToRevoke, auth.ID)
|
||
}
|
||
|
||
// 在事务中处理撤销
|
||
if len(deviceAuthsToRevoke) > 0 {
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 1. 撤销设备授权
|
||
if err := s.enterpriseDeviceAuthStore.RevokeByIDs(ctx, deviceAuthsToRevoke, currentUserID); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "撤销设备授权失败")
|
||
}
|
||
|
||
// 2. 级联撤销卡授权
|
||
for _, authID := range deviceAuthsToRevoke {
|
||
if err := s.enterpriseCardAuthStore.RevokeByDeviceAuthID(ctx, authID, currentUserID); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "撤销卡授权失败")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
resp.SuccessCount = len(deviceAuthsToRevoke)
|
||
resp.FailCount = len(resp.FailedItems)
|
||
return resp, nil
|
||
}
|
||
|
||
// ListDevices 查询企业授权设备列表(后台管理)
|
||
func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.EnterpriseDeviceListReq) (*dto.EnterpriseDeviceListResp, error) {
|
||
// 验证企业存在
|
||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||
if err != nil {
|
||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||
}
|
||
|
||
// 查询授权记录
|
||
opts := postgres.DeviceAuthListOptions{
|
||
EnterpriseID: &enterpriseID,
|
||
IncludeRevoked: false,
|
||
Page: req.Page,
|
||
PageSize: req.PageSize,
|
||
}
|
||
|
||
auths, total, err := s.enterpriseDeviceAuthStore.ListByEnterprise(ctx, opts)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询授权记录失败")
|
||
}
|
||
|
||
if len(auths) == 0 {
|
||
return &dto.EnterpriseDeviceListResp{
|
||
List: make([]dto.EnterpriseDeviceItem, 0),
|
||
Total: 0,
|
||
}, nil
|
||
}
|
||
|
||
// 收集设备ID
|
||
deviceIDs := make([]uint, 0, len(auths))
|
||
authMap := make(map[uint]*model.EnterpriseDeviceAuthorization)
|
||
for _, auth := range auths {
|
||
deviceIDs = append(deviceIDs, auth.DeviceID)
|
||
authMap[auth.DeviceID] = auth
|
||
}
|
||
|
||
// 查询设备信息
|
||
var devices []model.Device
|
||
query := s.db.WithContext(ctx).Where("id IN ?", deviceIDs)
|
||
if req.VirtualNo != "" {
|
||
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
|
||
}
|
||
if err := query.Find(&devices).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||
}
|
||
|
||
// 统计每个设备的绑定卡数量
|
||
var bindings []model.DeviceSimBinding
|
||
if err := s.db.WithContext(ctx).
|
||
Where("device_id IN ? AND bind_status = 1", deviceIDs).
|
||
Find(&bindings).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||
}
|
||
|
||
cardCountMap := make(map[uint]int)
|
||
for _, binding := range bindings {
|
||
cardCountMap[binding.DeviceID]++
|
||
}
|
||
|
||
// 构建响应
|
||
items := make([]dto.EnterpriseDeviceItem, 0, len(devices))
|
||
for _, device := range devices {
|
||
auth := authMap[device.ID]
|
||
items = append(items, dto.EnterpriseDeviceItem{
|
||
DeviceID: device.ID,
|
||
VirtualNo: device.VirtualNo,
|
||
DeviceName: device.DeviceName,
|
||
DeviceModel: device.DeviceModel,
|
||
CardCount: cardCountMap[device.ID],
|
||
AuthorizedAt: auth.AuthorizedAt,
|
||
})
|
||
}
|
||
|
||
return &dto.EnterpriseDeviceListResp{
|
||
List: items,
|
||
Total: total,
|
||
}, nil
|
||
}
|