260 lines
11 KiB
Go
260 lines
11 KiB
Go
// Package realnamereset 提供二次实名批次的导入与撤销用例。
|
||
// 二次实名:清除卡的实名状态与当前实名时间,之后只有上游 archiveTime 晚于清除前时间才算重新实名(见 add-realname-time-history)。
|
||
package realnamereset
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"fmt"
|
||
"math/big"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/bytedance/sonic"
|
||
"gorm.io/gorm"
|
||
|
||
cardobservationapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
)
|
||
|
||
// CardResetter 执行单卡二次实名清除与撤销。
|
||
type CardResetter interface {
|
||
ResetRealname(ctx context.Context, command cardobservationapp.RealnameResetCommand) error
|
||
RevertRealnameReset(ctx context.Context, command cardobservationapp.RealnameResetCommand) error
|
||
}
|
||
|
||
// PermissionChecker 校验后台账号权限点。
|
||
type PermissionChecker interface {
|
||
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||
}
|
||
|
||
// Service 是二次实名批次用例。
|
||
type Service struct {
|
||
db *gorm.DB
|
||
resetter CardResetter
|
||
perms PermissionChecker
|
||
now func() time.Time
|
||
}
|
||
|
||
// NewService 创建二次实名批次用例。
|
||
func NewService(db *gorm.DB, resetter CardResetter, perms PermissionChecker) *Service {
|
||
return &Service{db: db, resetter: resetter, perms: perms, now: time.Now}
|
||
}
|
||
|
||
// resetTarget 是一行资产号展开出的一张卡。
|
||
type resetTarget struct {
|
||
CardID uint `gorm:"column:id"`
|
||
ICCID string `gorm:"column:iccid"`
|
||
}
|
||
|
||
// Import 导入二次实名名单:逐行展开为卡并逐卡清除,不合格的行记录原因跳过,不影响其他行。
|
||
func (s *Service) Import(ctx context.Context, req dto.RealnameResetImportRequest) (*dto.RealnameResetBatchResponse, error) {
|
||
operatorID, err := s.authorize(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
identifiers := normalizeIdentifiers(req.Identifiers)
|
||
if len(identifiers) == 0 {
|
||
return nil, errors.New(errors.CodeInvalidParam, "请导入至少一个资产号")
|
||
}
|
||
if len(identifiers) > constants.RealnameResetBatchMaxRows {
|
||
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("单批最多导入 %d 个资产号", constants.RealnameResetBatchMaxRows))
|
||
}
|
||
now := s.now()
|
||
batch := &model.IotCardRealnameResetBatch{
|
||
BatchNo: generateBatchNo(now), ImportFileKey: strings.TrimSpace(req.ImportFileKey), Reason: strings.TrimSpace(req.Reason),
|
||
Items: []byte("[]"), OperatorID: operatorID, OperatorName: middleware.GetUsernameFromContext(ctx), CreatedAt: now, UpdatedAt: now,
|
||
}
|
||
if err := s.db.WithContext(ctx).Create(batch).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建二次实名批次失败")
|
||
}
|
||
items := make([]dto.RealnameResetItem, 0, len(identifiers))
|
||
seen := make(map[uint]bool)
|
||
succeeded, skipped := 0, 0
|
||
for index, identifier := range identifiers {
|
||
rowNo := index + 1
|
||
targets, resolveErr := s.resolveTargets(ctx, identifier)
|
||
if resolveErr != nil {
|
||
return nil, resolveErr
|
||
}
|
||
if len(targets) == 0 {
|
||
items = append(items, skippedItem(rowNo, identifier, resetTarget{}, "资产不存在或设备未绑定卡"))
|
||
skipped++
|
||
continue
|
||
}
|
||
for _, target := range targets {
|
||
if seen[target.CardID] {
|
||
items = append(items, skippedItem(rowNo, identifier, target, "同一张卡在名单中重复"))
|
||
skipped++
|
||
continue
|
||
}
|
||
seen[target.CardID] = true
|
||
resetErr := s.resetter.ResetRealname(ctx, cardobservationapp.RealnameResetCommand{CardID: target.CardID, BatchID: batch.ID, OperatorID: operatorID})
|
||
switch {
|
||
case resetErr == nil:
|
||
items = append(items, dto.RealnameResetItem{RowNo: rowNo, Input: identifier, CardID: target.CardID, ICCID: target.ICCID,
|
||
Result: constants.RealnameResetItemPending})
|
||
succeeded++
|
||
case isExpectedResetError(resetErr):
|
||
items = append(items, skippedItem(rowNo, identifier, target, errorMessage(resetErr)))
|
||
skipped++
|
||
default:
|
||
items = append(items, dto.RealnameResetItem{RowNo: rowNo, Input: identifier, CardID: target.CardID, ICCID: target.ICCID,
|
||
Result: constants.RealnameResetItemFailed, Reason: errorMessage(resetErr)})
|
||
skipped++
|
||
}
|
||
}
|
||
}
|
||
encoded, err := sonic.Marshal(items)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码二次实名批次结果失败")
|
||
}
|
||
batch.Items, batch.TotalCount, batch.SucceededCount, batch.SkippedCount = encoded, len(items), succeeded, skipped
|
||
if err := s.db.WithContext(ctx).Model(&model.IotCardRealnameResetBatch{}).Where("id = ?", batch.ID).Updates(map[string]any{
|
||
"items": encoded, "total_count": len(items), "succeeded_count": succeeded, "skipped_count": skipped, "updated_at": s.now(),
|
||
}).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新二次实名批次结果失败")
|
||
}
|
||
return &dto.RealnameResetBatchResponse{
|
||
ID: batch.ID, BatchNo: batch.BatchNo, Reason: batch.Reason, ImportFileKey: batch.ImportFileKey,
|
||
TotalCount: len(items), SucceededCount: succeeded, SkippedCount: skipped, PendingCount: succeeded,
|
||
OperatorID: operatorID, OperatorName: batch.OperatorName, CreatedAt: batch.CreatedAt, Items: withResultNames(items),
|
||
}, nil
|
||
}
|
||
|
||
// Revert 撤销批次中仍在等待二次实名的卡;card_ids 为空时撤销整批。
|
||
func (s *Service) Revert(ctx context.Context, req dto.RealnameResetRevertRequest) (*dto.RealnameResetRevertResponse, error) {
|
||
operatorID, err := s.authorize(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var batch model.IotCardRealnameResetBatch
|
||
if err := s.db.WithContext(ctx).Select("id").Where("id = ?", req.ID).Take(&batch).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "二次实名批次不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询二次实名批次失败")
|
||
}
|
||
query := s.db.WithContext(ctx).Model(&model.IotCardRealname{}).
|
||
Where("reset_batch_id = ? AND reset_status = ?", batch.ID, constants.RealnameResetStatusPending)
|
||
if len(req.CardIDs) > 0 {
|
||
query = query.Where("card_id IN ?", req.CardIDs)
|
||
}
|
||
var cardIDs []uint
|
||
if err := query.Pluck("card_id", &cardIDs).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询等待二次实名的卡失败")
|
||
}
|
||
result := &dto.RealnameResetRevertResponse{}
|
||
for _, cardID := range cardIDs {
|
||
if err := s.resetter.RevertRealnameReset(ctx, cardobservationapp.RealnameResetCommand{CardID: cardID, BatchID: batch.ID, OperatorID: operatorID}); err != nil {
|
||
result.FailedCount++
|
||
result.Failures = append(result.Failures, fmt.Sprintf("卡 %d:%s", cardID, errorMessage(err)))
|
||
continue
|
||
}
|
||
result.RevertedCount++
|
||
}
|
||
if result.RevertedCount > 0 {
|
||
if err := s.db.WithContext(ctx).Model(&model.IotCardRealnameResetBatch{}).Where("id = ?", batch.ID).Updates(map[string]any{
|
||
"reverted_count": gorm.Expr("reverted_count + ?", result.RevertedCount), "updated_at": s.now(),
|
||
}).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新二次实名批次撤销数失败")
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// authorize 二次实名只允许平台账号,且需要 iot_card:realname_reset 权限(超级管理员不受限)。
|
||
func (s *Service) authorize(ctx context.Context) (uint, error) {
|
||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||
switch middleware.GetUserTypeFromContext(ctx) {
|
||
case constants.UserTypeSuperAdmin:
|
||
return operatorID, nil
|
||
case constants.UserTypePlatform:
|
||
default:
|
||
return 0, errors.New(errors.CodeForbidden, "仅平台账号可办理二次实名")
|
||
}
|
||
if s.perms == nil {
|
||
return 0, errors.New(errors.CodeForbidden)
|
||
}
|
||
allowed, err := s.perms.CheckPermission(ctx, operatorID, constants.PermissionIotCardRealnameReset, constants.PlatformWeb)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if !allowed {
|
||
return 0, errors.New(errors.CodeForbidden, "无二次实名权限")
|
||
}
|
||
return operatorID, nil
|
||
}
|
||
|
||
// resolveTargets 把资产号展开为卡:先按卡 ICCID / 虚拟号,再按设备虚拟号 / IMEI 取设备当前全部绑定卡。
|
||
func (s *Service) resolveTargets(ctx context.Context, identifier string) ([]resetTarget, error) {
|
||
var cards []resetTarget
|
||
if err := s.db.WithContext(ctx).Table("tb_iot_card").Select("id, iccid").
|
||
Where("deleted_at IS NULL AND (iccid = ? OR virtual_no = ?)", identifier, identifier).
|
||
Order("id").Limit(1).Scan(&cards).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产号查询卡失败")
|
||
}
|
||
if len(cards) > 0 {
|
||
return cards, nil
|
||
}
|
||
if err := s.db.WithContext(ctx).Table("tb_device AS d").Select("c.id, c.iccid").
|
||
Joins("JOIN tb_device_sim_binding AS b ON b.device_id = d.id AND b.bind_status = ? AND b.deleted_at IS NULL", constants.BindStatusBound).
|
||
Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL").
|
||
Where("d.deleted_at IS NULL AND (d.virtual_no = ? OR d.imei = ?)", identifier, identifier).
|
||
Order("c.id").Scan(&cards).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产号查询设备绑定卡失败")
|
||
}
|
||
return cards, nil
|
||
}
|
||
|
||
func normalizeIdentifiers(raw []string) []string {
|
||
result := make([]string, 0, len(raw))
|
||
seen := make(map[string]bool, len(raw))
|
||
for _, value := range raw {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || seen[value] {
|
||
continue
|
||
}
|
||
seen[value] = true
|
||
result = append(result, value)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func isExpectedResetError(err error) bool {
|
||
return err == cardobservationapp.ErrRealnameResetNotVerified || err == cardobservationapp.ErrRealnameResetNoUpstream ||
|
||
err == cardobservationapp.ErrRealnameResetAlreadyReset
|
||
}
|
||
|
||
func errorMessage(err error) string {
|
||
if appErr, ok := err.(*errors.AppError); ok && appErr.Message != "" {
|
||
return appErr.Message
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
func skippedItem(rowNo int, input string, target resetTarget, reason string) dto.RealnameResetItem {
|
||
return dto.RealnameResetItem{RowNo: rowNo, Input: input, CardID: target.CardID, ICCID: target.ICCID,
|
||
Result: constants.RealnameResetItemSkipped, Reason: reason}
|
||
}
|
||
|
||
// withResultNames 补结果中文名。
|
||
func withResultNames(items []dto.RealnameResetItem) []dto.RealnameResetItem {
|
||
for index := range items {
|
||
items[index].ResultName = constants.RealnameResetItemName(items[index].Result)
|
||
}
|
||
return items
|
||
}
|
||
|
||
func generateBatchNo(now time.Time) string {
|
||
suffix, err := rand.Int(rand.Reader, big.NewInt(10000))
|
||
if err != nil {
|
||
suffix = big.NewInt(now.UnixNano() % 10000)
|
||
}
|
||
return fmt.Sprintf("%s%s%04d", constants.RealnameResetBatchNoPrefix, now.Format("20060102150405"), suffix.Int64())
|
||
}
|