Files
邹进 4967f4df43
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat: 卡实名时间以上游 archiveTime 为准并记录实名时间历史,新增二次实名批次
2026-11-08 19:12:46 +09:00

260 lines
11 KiB
Go
Raw Permalink 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 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())
}