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>
506 lines
15 KiB
Go
506 lines
15 KiB
Go
package enterprise_card
|
||
|
||
import (
|
||
"context"
|
||
"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"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type Service struct {
|
||
db *gorm.DB
|
||
enterpriseStore *postgres.EnterpriseStore
|
||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
|
||
iotCardStore *postgres.IotCardStore
|
||
}
|
||
|
||
func New(
|
||
db *gorm.DB,
|
||
enterpriseStore *postgres.EnterpriseStore,
|
||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
|
||
iotCardStore *postgres.IotCardStore,
|
||
) *Service {
|
||
return &Service{
|
||
db: db,
|
||
enterpriseStore: enterpriseStore,
|
||
enterpriseCardAuthStore: enterpriseCardAuthStore,
|
||
iotCardStore: iotCardStore,
|
||
}
|
||
}
|
||
|
||
func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, 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, "企业不存在")
|
||
}
|
||
|
||
iotCardPtrs, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
|
||
}
|
||
|
||
cardMap := make(map[string]*model.IotCard)
|
||
cardIDMap := make(map[uint]*model.IotCard)
|
||
cardIDs := make([]uint, 0, len(iotCardPtrs))
|
||
for _, c := range iotCardPtrs {
|
||
cardMap[c.ICCID] = c
|
||
cardIDMap[c.ID] = c
|
||
cardIDs = append(cardIDs, c.ID)
|
||
}
|
||
|
||
var bindings []model.DeviceSimBinding
|
||
if len(cardIDs) > 0 {
|
||
s.db.WithContext(ctx).Where("iot_card_id IN ? AND bind_status = 1", cardIDs).Find(&bindings)
|
||
}
|
||
|
||
cardToDevice := make(map[uint]bool)
|
||
for _, binding := range bindings {
|
||
cardToDevice[binding.IotCardID] = true
|
||
}
|
||
|
||
resp := &dto.AllocateCardsPreviewResp{
|
||
StandaloneCards: make([]dto.StandaloneCard, 0),
|
||
FailedItems: make([]dto.FailedItem, 0),
|
||
}
|
||
|
||
for _, iccid := range req.ICCIDs {
|
||
card, exists := cardMap[iccid]
|
||
if !exists {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
|
||
ICCID: iccid,
|
||
Reason: "卡不存在",
|
||
})
|
||
continue
|
||
}
|
||
|
||
if cardToDevice[card.ID] {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
|
||
ICCID: iccid,
|
||
Reason: "该卡已绑定设备,请使用设备授权功能",
|
||
})
|
||
continue
|
||
}
|
||
|
||
resp.StandaloneCards = append(resp.StandaloneCards, dto.StandaloneCard{
|
||
ICCID: card.ICCID,
|
||
IotCardID: card.ID,
|
||
MSISDN: card.MSISDN,
|
||
CarrierID: card.CarrierID,
|
||
StatusName: constants.GetIotCardStatusName(card.Status),
|
||
})
|
||
}
|
||
|
||
resp.Summary = dto.AllocatePreviewSummary{
|
||
StandaloneCardCount: len(resp.StandaloneCards),
|
||
DeviceCount: 0,
|
||
DeviceCardCount: 0,
|
||
TotalCardCount: len(resp.StandaloneCards),
|
||
FailedCount: len(resp.FailedItems),
|
||
}
|
||
|
||
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 {
|
||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||
if err != nil {
|
||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
resp := &dto.AllocateCardsResp{
|
||
FailedItems: preview.FailedItems,
|
||
FailCount: len(preview.FailedItems),
|
||
}
|
||
|
||
// 构建 cardID -> ICCID 映射,用于组装失败原因
|
||
cardIDToICCID := make(map[uint]string, len(preview.StandaloneCards))
|
||
allCandidateIDs := make([]uint, 0, len(preview.StandaloneCards))
|
||
for _, card := range preview.StandaloneCards {
|
||
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)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询已有授权失败")
|
||
}
|
||
|
||
now := time.Now()
|
||
userType := middleware.GetUserTypeFromContext(ctx)
|
||
auths := make([]*model.EnterpriseCardAuthorization, 0)
|
||
for _, cardID := range cardIDsToAllocate {
|
||
if existingAuths[cardID] {
|
||
continue
|
||
}
|
||
auths = append(auths, &model.EnterpriseCardAuthorization{
|
||
EnterpriseID: enterpriseID,
|
||
CardID: cardID,
|
||
AuthorizedBy: currentUserID,
|
||
AuthorizedAt: now,
|
||
AuthorizerType: userType,
|
||
Remark: req.Remark,
|
||
})
|
||
}
|
||
|
||
if len(auths) > 0 {
|
||
if err := s.enterpriseCardAuthStore.BatchCreate(ctx, auths); err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建授权记录失败")
|
||
}
|
||
}
|
||
|
||
resp.SuccessCount = len(cardIDsToAllocate)
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.RecallCardsReq) (*dto.RecallCardsResp, 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, "企业不存在")
|
||
}
|
||
|
||
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, "查询卡信息失败")
|
||
}
|
||
|
||
cardMap := make(map[string]*model.IotCard)
|
||
cardIDMap := make(map[uint]*model.IotCard)
|
||
cardIDs := make([]uint, 0, len(iotCardPtrs))
|
||
for _, c := range iotCardPtrs {
|
||
cardMap[c.ICCID] = c
|
||
cardIDMap[c.ID] = c
|
||
cardIDs = append(cardIDs, c.ID)
|
||
}
|
||
|
||
existingAuths, err := s.enterpriseCardAuthStore.GetActiveAuthsByCardIDs(ctx, enterpriseID, cardIDs)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询已有授权失败")
|
||
}
|
||
|
||
resp := &dto.RecallCardsResp{
|
||
FailedItems: make([]dto.FailedItem, 0),
|
||
RecalledDevices: make([]dto.RecalledDevice, 0),
|
||
}
|
||
|
||
cardIDsToRecall := make([]uint, 0)
|
||
for _, iccid := range iccids {
|
||
card, exists := cardMap[iccid]
|
||
if !exists {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
|
||
ICCID: iccid,
|
||
Reason: "卡不存在",
|
||
})
|
||
continue
|
||
}
|
||
if !existingAuths[card.ID] {
|
||
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
|
||
ICCID: iccid,
|
||
Reason: "该卡未授权给此企业",
|
||
})
|
||
continue
|
||
}
|
||
cardIDsToRecall = append(cardIDsToRecall, card.ID)
|
||
}
|
||
|
||
if len(cardIDsToRecall) > 0 {
|
||
if err := s.enterpriseCardAuthStore.BatchUpdateStatus(ctx, enterpriseID, cardIDsToRecall, 0); err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "回收授权失败")
|
||
}
|
||
}
|
||
|
||
resp.SuccessCount = len(cardIDsToRecall)
|
||
resp.FailCount = len(resp.FailedItems)
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Service) ListCards(ctx context.Context, enterpriseID uint, req *dto.EnterpriseCardListReq) (*dto.EnterpriseCardPageResult, error) {
|
||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||
if err != nil {
|
||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||
}
|
||
|
||
cardIDs, err := s.enterpriseCardAuthStore.ListCardIDsByEnterprise(ctx, enterpriseID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询授权卡ID失败")
|
||
}
|
||
|
||
if len(cardIDs) == 0 {
|
||
return &dto.EnterpriseCardPageResult{
|
||
Items: make([]dto.EnterpriseCardItem, 0),
|
||
Total: 0,
|
||
Page: req.Page,
|
||
Size: req.PageSize,
|
||
}, nil
|
||
}
|
||
|
||
page := req.Page
|
||
pageSize := req.PageSize
|
||
if page == 0 {
|
||
page = 1
|
||
}
|
||
if pageSize == 0 {
|
||
pageSize = constants.DefaultPageSize
|
||
}
|
||
|
||
query := s.db.WithContext(ctx).Model(&model.IotCard{}).Where("id IN ?", cardIDs)
|
||
|
||
if req.Status != nil {
|
||
query = query.Where("status = ?", *req.Status)
|
||
}
|
||
if req.CarrierID != nil {
|
||
query = query.Where("carrier_id = ?", *req.CarrierID)
|
||
}
|
||
if req.ICCID != "" {
|
||
query = query.Where("iccid LIKE ?", "%"+req.ICCID+"%")
|
||
}
|
||
if req.VirtualNo != "" {
|
||
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
|
||
}
|
||
if req.MSISDN != "" {
|
||
query = query.Where("msisdn LIKE ?", "%"+req.MSISDN+"%")
|
||
}
|
||
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "统计卡数量失败")
|
||
}
|
||
|
||
var cards []model.IotCard
|
||
offset := (page - 1) * pageSize
|
||
if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&cards).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡列表失败")
|
||
}
|
||
|
||
// 批量查询各卡当前生效的主套餐名称(取 package_name 快照,避免 JOIN tb_package)
|
||
pageCardIDs := make([]uint, 0, len(cards))
|
||
for _, card := range cards {
|
||
pageCardIDs = append(pageCardIDs, card.ID)
|
||
}
|
||
packageNameMap := s.buildPackageNameMap(ctx, pageCardIDs)
|
||
|
||
items := make([]dto.EnterpriseCardItem, 0, len(cards))
|
||
for _, card := range cards {
|
||
items = append(items, dto.EnterpriseCardItem{
|
||
ID: card.ID,
|
||
ICCID: card.ICCID,
|
||
MSISDN: card.MSISDN,
|
||
VirtualNo: card.VirtualNo,
|
||
CarrierID: card.CarrierID,
|
||
CarrierName: card.CarrierName,
|
||
PackageName: packageNameMap[card.ID],
|
||
Status: card.Status,
|
||
StatusName: constants.GetIotCardStatusName(card.Status),
|
||
NetworkStatus: card.NetworkStatus,
|
||
NetworkStatusName: constants.GetNetworkStatusName(card.NetworkStatus),
|
||
GatewayExtend: card.GatewayExtend,
|
||
})
|
||
}
|
||
|
||
return &dto.EnterpriseCardPageResult{
|
||
Items: items,
|
||
Total: total,
|
||
Page: page,
|
||
Size: pageSize,
|
||
}, nil
|
||
}
|
||
|
||
// buildPackageNameMap 批量查询卡列表当前生效主套餐名称,返回 map[iotCardID]packageName
|
||
func (s *Service) buildPackageNameMap(ctx context.Context, cardIDs []uint) map[uint]string {
|
||
result := make(map[uint]string, len(cardIDs))
|
||
if len(cardIDs) == 0 {
|
||
return result
|
||
}
|
||
|
||
type usageRow struct {
|
||
IotCardID uint `gorm:"column:iot_card_id"`
|
||
PackageName string `gorm:"column:package_name"`
|
||
}
|
||
|
||
// 每张卡取优先级最低(数字最小)的生效中主套餐,使用 DISTINCT ON 保证每卡仅一条
|
||
var rows []usageRow
|
||
s.db.WithContext(ctx).Raw(`
|
||
SELECT DISTINCT ON (iot_card_id) iot_card_id, package_name
|
||
FROM tb_package_usage
|
||
WHERE iot_card_id IN ?
|
||
AND status = 1
|
||
AND master_usage_id IS NULL
|
||
AND deleted_at IS NULL
|
||
ORDER BY iot_card_id, priority ASC
|
||
`, cardIDs).Scan(&rows)
|
||
|
||
for _, row := range rows {
|
||
result[row.IotCardID] = row.PackageName
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Service) SuspendCard(ctx context.Context, enterpriseID, cardID uint) error {
|
||
return s.updateCardNetworkStatus(ctx, enterpriseID, cardID, 0)
|
||
}
|
||
|
||
func (s *Service) ResumeCard(ctx context.Context, enterpriseID, cardID uint) error {
|
||
return s.updateCardNetworkStatus(ctx, enterpriseID, cardID, 1)
|
||
}
|
||
|
||
func (s *Service) updateCardNetworkStatus(ctx context.Context, enterpriseID, cardID uint, networkStatus int) error {
|
||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||
if currentUserID == 0 {
|
||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||
if err != nil {
|
||
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||
}
|
||
|
||
auth, err := s.enterpriseCardAuthStore.GetByEnterpriseAndCard(ctx, enterpriseID, cardID)
|
||
if err != nil || auth.RevokedAt != nil {
|
||
return errors.New(errors.CodeForbidden, "无权限操作此卡")
|
||
}
|
||
|
||
return s.iotCardStore.UpdateFields(ctx, cardID, map[string]any{
|
||
"network_status": networkStatus,
|
||
})
|
||
}
|