Files
junhong_cmp_fiber/internal/service/enterprise_card/service.go
break 3b7b856e48
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>
2026-06-17 16:23:03 +09:00

506 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
})
}