Files
iotlcm/internal/service/allocate/repository.go
T
MiFaZhan 2bec257289 chore: 初始开源发布
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
2026-10-22 16:37:56 +08:00

1017 lines
36 KiB
Go

package allocate
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
)
type Repository struct {
db gdb.DB
results batchresult.Generator
}
const (
allocateCardQueryBatchSize = 500
allocateCardUpdateBatchSize = 500
allocateDetailUpdateBatchSize = 500
)
type allocateParentAggregate struct {
ChildCount int `orm:"child_count"`
FinishedCount int `orm:"finished_count"`
TotalCount int `orm:"total_count"`
ProcessedCount int `orm:"processed_count"`
SuccessCount int `orm:"success_count"`
FailCount int `orm:"fail_count"`
EarliestStartTime *gtime.Time `orm:"earliest_start_time"`
}
func NewRepository() *Repository {
return &Repository{results: batchresult.New()}
}
func (r *Repository) database() gdb.DB {
if r.db != nil {
return r.db
}
return g.DB()
}
func (r *Repository) LoadMain(ctx context.Context, batchNo string) (*entity.BatchMain, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, gerror.New("allocate batch number is required")
}
main := &entity.BatchMain{}
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Scan(main); err != nil {
return nil, gerror.Wrap(err, "load allocate batch main failed")
}
if main.Id == 0 {
return nil, gerror.New("allocate batch does not exist")
}
return main, nil
}
func (r *Repository) Load(ctx context.Context, batchNo string) (*Task, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, gerror.New("划拨批次号不能为空")
}
var main *entity.BatchMain
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeAllocate).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Scan(&main); err != nil {
return nil, gerror.Wrap(err, "查询划拨批次主表失败")
}
if main == nil || main.Id == 0 {
return nil, gerror.New("处理中划拨批次不存在")
}
var batchDetails []*entity.BatchDetail
if err := r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeAllocate).
OrderAsc(dao.BatchDetail.Columns().RowNo).
Scan(&batchDetails); err != nil {
return nil, gerror.Wrap(err, "查询划拨批次明细失败")
}
if len(batchDetails) == 0 {
return nil, gerror.New("划拨批次没有运行中明细")
}
task := &Task{
Batch: Batch{
ID: main.Id,
BatchNo: main.BatchNo,
BatchType: main.BatchType,
ParentBatchNo: strings.TrimSpace(main.SourceBatchNo),
TenantID: main.TenantId,
TargetDeptID: targetDeptIDFromBatch(main),
CreatedDept: main.CreatedDept,
CreatedBy: main.CreatedBy,
StartTime: main.StartTime,
BatchInfo: main.BatchInfo,
},
Details: make([]*Detail, 0, len(batchDetails)),
}
for _, batchDetail := range batchDetails {
if batchDetail == nil {
continue
}
detail := &Detail{OriginalSnapshot: batchDetail.Snapshot,
ID: batchDetail.Id,
RowNo: batchDetail.RowNo,
DetailStatus: batchDetail.DetailStatus,
ICCID: strings.TrimSpace(batchDetail.ItemId),
Reason: strings.TrimSpace(batchDetail.ErrorReason),
}
if detail.DetailStatus != consts.BatchDetailStatusPending &&
detail.DetailStatus != consts.BatchDetailStatusRunning &&
detail.Reason == "" {
detail.Reason = "批次明细已处理,不能重复执行"
}
if batchDetail.Snapshot != nil {
if value := firstSnapshot(batchDetail.Snapshot, "ICCID", "iccid"); value != "" {
detail.ICCID = value
}
detail.MSISDN = firstSnapshot(batchDetail.Snapshot, "MSISDN", "msisdn")
detail.IMSI = firstSnapshot(batchDetail.Snapshot, "IMSI", "imsi")
detail.TargetDeptID = batchDetail.Snapshot.Get("targetDeptId").Int64()
detail.TargetDeptKey = firstSnapshot(batchDetail.Snapshot, "targetDeptKey", "target_dept_key")
detail.TargetDeptName = firstSnapshot(batchDetail.Snapshot, "targetDeptName", "target_dept_name")
detail.Remark = firstSnapshot(batchDetail.Snapshot, "remark")
}
if detail.TargetDeptID <= 0 {
detail.TargetDeptID = task.Batch.TargetDeptID
}
task.Details = append(task.Details, detail)
}
if len(task.Details) == 0 {
return nil, gerror.New("划拨批次没有可读取明细")
}
if err := r.loadCards(ctx, task); err != nil {
return nil, err
}
if err := r.loadTargets(ctx, task); err != nil {
return nil, err
}
return task, nil
}
func targetDeptIDFromBatch(main *entity.BatchMain) int64 {
if main == nil {
return 0
}
if main.TargetDeptId > 0 {
return main.TargetDeptId
}
if main.BatchInfo != nil {
return main.BatchInfo.Get("targetDeptId").Int64()
}
return 0
}
func firstSnapshot(snapshot *gjson.Json, keys ...string) string {
if snapshot == nil {
return ""
}
for _, key := range keys {
if value := strings.TrimSpace(snapshot.Get(key).String()); value != "" {
return value
}
}
return ""
}
func (r *Repository) loadCards(ctx context.Context, task *Task) error {
iccids := make([]string, 0, len(task.Details))
for _, detail := range task.Details {
if detail != nil && strings.TrimSpace(detail.ICCID) != "" {
iccids = append(iccids, strings.TrimSpace(detail.ICCID))
}
}
iccids = uniqueStrings(iccids)
if len(iccids) == 0 {
return nil
}
columns := dao.TSimCardInfo.Columns()
cards := make([]*entity.TSimCardInfo, 0, len(iccids))
for start := 0; start < len(iccids); start += allocateCardQueryBatchSize {
end := min(start+allocateCardQueryBatchSize, len(iccids))
var chunk []*entity.TSimCardInfo
if err := r.database().Model(dao.TSimCardInfo.Table()).Safe().Ctx(ctx).
Where(columns.TenantId, task.Batch.TenantID).
WhereIn(columns.Iccid, iccids[start:end]).
Scan(&chunk); err != nil {
return gerror.Wrapf(err, "查询SIM卡档案失败,范围:%d-%d", start+1, end)
}
cards = append(cards, chunk...)
}
byICCID := make(map[string]*entity.TSimCardInfo, len(cards))
for _, card := range cards {
if card != nil {
byICCID[strings.TrimSpace(card.Iccid)] = card
}
}
for _, detail := range task.Details {
if detail == nil {
continue
}
card := byICCID[strings.TrimSpace(detail.ICCID)]
if card == nil {
continue
}
detail.Card = &Card{
ID: card.Id,
ICCID: strings.TrimSpace(card.Iccid),
MSISDN: strings.TrimSpace(card.Msisdn),
IMSI: strings.TrimSpace(card.Imsi),
DeptID: card.DeptId,
AllocationBatchNo: strings.TrimSpace(card.AllocationBatchNo),
PackageStatus: card.PackageStatus,
BossCardStatus: card.BossCardStatus,
FlowPkgEndAt: card.FlowPkgEndAt,
}
}
return nil
}
func (r *Repository) loadTargets(ctx context.Context, task *Task) error {
task.TargetsByID = make(map[int64]*Target)
task.TargetsByKey = make(map[string]*Target)
task.TargetsByName = make(map[string][]*Target)
if task.Batch.CreatedDept <= 0 {
return nil
}
var depts []*entity.SysDept
columns := dao.SysDept.Columns()
if err := r.database().Model(dao.SysDept.Table()).Safe().Ctx(ctx).
Fields(columns.DeptId, columns.ParentId, columns.DeptKey, columns.DeptName).
Where(columns.TenantId, task.Batch.TenantID).
Where(columns.ParentId, task.Batch.CreatedDept).
Where(columns.Status, "0").
Where(columns.DeletedAt, nil).
Scan(&depts); err != nil {
return gerror.Wrap(err, "查询划拨目标客户失败")
}
for _, dept := range depts {
if dept == nil || dept.DeptId <= 0 {
continue
}
target := &Target{
DeptID: dept.DeptId,
ParentID: dept.ParentId,
DeptKey: strings.TrimSpace(dept.DeptKey),
DeptName: strings.TrimSpace(dept.DeptName),
}
task.TargetsByID[target.DeptID] = target
if target.DeptKey != "" {
task.TargetsByKey[target.DeptKey] = target
}
if target.DeptName != "" {
task.TargetsByName[target.DeptName] = append(task.TargetsByName[target.DeptName], target)
}
}
return nil
}
func (r *Repository) Commit(ctx context.Context, task *Task) error {
if task == nil {
return gerror.New("allocate task is nil")
}
if _, failCount := countResult(task); failCount > 0 {
markAtomicFailure(task, "划拨批次存在失败明细,整批未执行")
}
successCount, failCount := countResult(task)
autoSplitChild := strings.TrimSpace(task.Batch.ParentBatchNo) != ""
var artifact batchresult.Artifact
var artifactErr error
if !autoSplitChild {
artifact, artifactErr = r.createResultFile(ctx, task)
}
if artifactErr != nil && !autoSplitChild {
g.Log().Warningf(ctx, "allocate result file generation failed, batchNo:%s, err:%+v", task.Batch.BatchNo, artifactErr)
}
now := gtime.Now()
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if successCount > 0 && !autoSplitChild {
if err := r.updateCardOwners(ctx, tx, task, now); err != nil {
return err
}
}
if err := r.updateDetails(ctx, tx, task, now); err != nil {
return err
}
var ossID, downloadID int64
if !autoSplitChild && artifactErr == nil {
var err error
ossID, downloadID, err = r.persistArtifact(ctx, tx, task, artifact, successCount, failCount, now)
if err != nil {
return err
}
}
return r.finalizeBatch(ctx, tx, task, successCount, failCount, ossID, downloadID, now, !autoSplitChild && artifactErr != nil)
})
if err != nil && artifactErr == nil && artifact.Path != "" {
batchresult.RemoveLocalFile(artifact)
}
if err != nil {
return gerror.Wrap(err, "commit allocate batch failed")
}
if autoSplitChild {
if err := r.AggregateParent(ctx, task.Batch.ParentBatchNo); err != nil {
return gerror.Wrap(err, "aggregate allocate parent batch failed")
}
}
return nil
}
func (r *Repository) AggregateParent(ctx context.Context, parentBatchNo string) error {
parentBatchNo = strings.TrimSpace(parentBatchNo)
if parentBatchNo == "" {
return gerror.New("allocate parent batch number is required")
}
release := acquireAllocateParentLock(parentBatchNo)
defer release()
maxRetries := allocateDeadlockMaxRetries(ctx)
for attempt := 0; ; attempt++ {
err := r.aggregateParentOnce(ctx, parentBatchNo)
if err == nil || !isAllocateDeadlock(err) || attempt >= maxRetries {
return err
}
if err := waitAllocateDeadlockRetry(ctx, attempt); err != nil {
return err
}
g.Log().Warningf(ctx, "allocate parent aggregation deadlock, retrying batchNo:%s attempt:%d/%d", parentBatchNo, attempt+1, maxRetries)
}
}
func (r *Repository) aggregateParentOnce(ctx context.Context, parentBatchNo string) error {
parentBatchNo = strings.TrimSpace(parentBatchNo)
if parentBatchNo == "" {
return gerror.New("allocate parent batch number is required")
}
now := gtime.Now()
var committedTask *Task
var parentArtifact batchresult.Artifact
parentArtifactReady := false
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
parent := &entity.BatchMain{}
mainColumns := dao.BatchMain.Columns()
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(mainColumns.BatchNo, parentBatchNo).Where(mainColumns.BatchType, consts.BatchTypeAllocate).LockUpdate().Scan(parent); err != nil {
return gerror.Wrap(err, "load allocate parent batch failed")
}
if parent.Id == 0 {
return gerror.New("allocate parent batch does not exist")
}
if !isAllocateParent(parent) {
return gerror.New("allocate parent batch metadata is invalid")
}
if parent.BatchAsyncStatus == consts.BatchAsyncStatusSuccess || parent.BatchAsyncStatus == consts.BatchAsyncStatusFailed || parent.BatchAsyncStatus == consts.BatchAsyncStatusPartialSuccess {
return nil
}
childColumns := dao.BatchMain.Columns()
aggregate := &allocateParentAggregate{}
finishedStatus := fmt.Sprintf("%s IN (%d, %d, %d)", childColumns.BatchAsyncStatus, consts.BatchAsyncStatusSuccess, consts.BatchAsyncStatusFailed, consts.BatchAsyncStatusPartialSuccess)
fields := fmt.Sprintf("COUNT(*) AS child_count, COALESCE(SUM(CASE WHEN %s THEN 1 ELSE 0 END), 0) AS finished_count, COALESCE(SUM(%s), 0) AS total_count, COALESCE(SUM(CASE WHEN %s THEN %s ELSE 0 END), 0) AS processed_count, COALESCE(SUM(%s), 0) AS success_count, COALESCE(SUM(CASE WHEN %s THEN %s - %s ELSE 0 END), 0) AS fail_count, MIN(%s) AS earliest_start_time", finishedStatus, childColumns.TotalCount, finishedStatus, childColumns.TotalCount, childColumns.SuccessCount, finishedStatus, childColumns.TotalCount, childColumns.SuccessCount, childColumns.StartTime)
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields(fields).Where(childColumns.BatchType, consts.BatchTypeAllocate).Where(childColumns.SourceBatchNo, parentBatchNo).Scan(aggregate); err != nil {
return gerror.Wrap(err, "aggregate allocate child batches failed")
}
info := mergeAllocateParentInfo(parent.BatchInfo, aggregate)
splitCompleted := parent.BatchInfo != nil && parent.BatchInfo.Get("splitCompleted").Bool()
allChildrenFinished := splitCompleted && aggregate.ChildCount > 0 && aggregate.ChildCount == aggregate.FinishedCount
if !allChildrenFinished {
startTime := resolveAllocateParentStartTime(parent.StartTime, aggregate.EarliestStartTime, now)
infoData := info.Map()
infoData["successCount"] = 0
infoData["failCount"] = 0
data := do.BatchMain{StartTime: startTime, TotalCount: aggregate.TotalCount, ValidCount: 0, SuccessCount: 0, FailCount: 0, BatchInfo: gjson.New(infoData), BatchAsyncStatus: consts.BatchAsyncStatusProcessing, RunResult: consts.BatchRunResultRunning, UpdatedBy: parent.CreatedBy, UpdatedAt: now}
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(mainColumns.Id, parent.Id).WhereIn(mainColumns.BatchAsyncStatus, []int{consts.BatchAsyncStatusPending, consts.BatchAsyncStatusProcessing}).Data(data).Update()
if err != nil {
return gerror.Wrap(err, "update allocate parent progress failed")
}
return nil
}
task, err := r.loadParentTaskTx(ctx, tx, parent)
if err != nil {
return err
}
if len(task.Details) != aggregate.TotalCount {
return gerror.Newf("allocate parent detail count mismatch, expected:%d actual:%d", aggregate.TotalCount, len(task.Details))
}
if aggregate.FailCount > 0 {
markAtomicFailure(task, "至少一个划拨子批次校验失败,整批未执行")
} else {
validateTask(task)
if _, failures := countResult(task); failures > 0 {
markAtomicFailure(task, "划拨明细复核失败,整批未执行")
}
}
successCount, failCount := normalizeAtomicParentResult(task)
if successCount > 0 {
if err := r.updateCardOwners(ctx, tx, task, now); err != nil {
return err
}
}
if err := r.updateParentDetails(ctx, tx, task, now); err != nil {
return err
}
artifact, artifactErr := r.createResultFile(ctx, task)
if artifactErr == nil {
parentArtifact, parentArtifactReady = artifact, true
}
if artifactErr != nil {
g.Log().Warningf(ctx, "allocate parent result file generation failed, batchNo:%s, err:%+v", parentBatchNo, artifactErr)
}
var ossID, downloadID int64
if artifactErr == nil {
ossID, downloadID, err = r.persistArtifact(ctx, tx, task, artifact, successCount, failCount, now)
if err != nil {
batchresult.RemoveLocalFile(artifact)
return err
}
}
status, runResult, failReason := parentBatchResult(successCount, failCount)
if failCount > 0 {
failReason = coalesce(task.FailureReason, failReason)
}
finalInfo := info.Map()
finalInfo["processedCount"] = len(task.Details)
finalInfo["successCount"] = successCount
finalInfo["failCount"] = failCount
startTime := resolveAllocateParentStartTime(parent.StartTime, aggregate.EarliestStartTime, now)
data := do.BatchMain{StartTime: startTime, BatchAsyncStatus: status, RunResult: runResult, TotalCount: len(task.Details), ValidCount: successCount, SuccessCount: successCount, FailCount: failCount, FailReason: failReason, BatchInfo: mergeBatchInfo(gjson.New(finalInfo), ossID, downloadID, artifactErr != nil), EndTime: now, UpdatedBy: parent.CreatedBy, UpdatedAt: now}
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(mainColumns.Id, parent.Id).WhereIn(mainColumns.BatchAsyncStatus, []int{consts.BatchAsyncStatusPending, consts.BatchAsyncStatusProcessing}).Data(data).Update()
if err != nil {
if artifactErr == nil {
batchresult.RemoveLocalFile(artifact)
}
return gerror.Wrap(err, "finalize allocate parent batch failed")
}
affected, err := result.RowsAffected()
if err != nil {
if artifactErr == nil {
batchresult.RemoveLocalFile(artifact)
}
return err
}
if affected != 1 {
if artifactErr == nil {
batchresult.RemoveLocalFile(artifact)
}
return gerror.New("allocate parent batch is no longer processing")
}
if successCount > 0 {
committedTask = task
}
return nil
})
if err != nil {
if parentArtifactReady {
batchresult.RemoveLocalFile(parentArtifact)
}
return err
}
if committedTask != nil {
publishPackageCustomerSync(ctx, committedTask)
}
return nil
}
func resolveAllocateParentStartTime(parentStartTime, earliestChildStartTime, fallback *gtime.Time) *gtime.Time {
if parentStartTime != nil && !parentStartTime.IsZero() {
return parentStartTime
}
if earliestChildStartTime != nil && !earliestChildStartTime.IsZero() {
return earliestChildStartTime
}
return fallback
}
func mergeAllocateParentInfo(info *gjson.Json, aggregate *allocateParentAggregate) *gjson.Json {
data := make(map[string]any)
if info != nil {
for key, value := range info.Map() {
data[key] = value
}
}
completed := 0
totalChunks := 0
if aggregate != nil {
completed = aggregate.FinishedCount
}
if info != nil {
totalChunks = info.Get("chunkCount").Int()
}
if totalChunks == 0 && aggregate != nil {
totalChunks = aggregate.ChildCount
}
data["completedChunkCount"] = completed
data["chunkCount"] = totalChunks
if aggregate != nil {
data["processedCount"] = aggregate.ProcessedCount
data["successCount"] = aggregate.SuccessCount
data["failCount"] = aggregate.FailCount
}
return gjson.New(data)
}
func parentBatchResult(successCount, failCount int) (int, int, string) {
if failCount > 0 || successCount == 0 {
return consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed, "allocate child batches failed"
}
return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess, ""
}
func normalizeAtomicParentResult(task *Task) (int, int) {
successCount, failCount := countResult(task)
if failCount == 0 {
return successCount, 0
}
if task == nil {
return 0, 0
}
task.FailureReason = coalesce(task.FailureReason, "划拨批次存在失败明细,整批未执行")
markAtomicFailure(task, task.FailureReason)
return 0, len(task.Details)
}
func (r *Repository) updateDetails(ctx context.Context, tx gdb.TX, task *Task, now *gtime.Time) error {
details := make([]*Detail, 0, len(task.Details))
for _, detail := range task.Details {
if detail == nil ||
(detail.DetailStatus != consts.BatchDetailStatusPending &&
detail.DetailStatus != consts.BatchDetailStatusRunning) {
continue
}
details = append(details, detail)
}
for start := 0; start < len(details); start += allocateDetailUpdateBatchSize {
end := min(start+allocateDetailUpdateBatchSize, len(details))
if err := r.updateDetailChunk(ctx, tx, task, details[start:end], now); err != nil {
return gerror.Wrapf(err, "批量更新划拨明细失败,范围:%d-%d", start+1, end)
}
}
return nil
}
func (r *Repository) updateParentDetails(ctx context.Context, tx gdb.TX, task *Task, now *gtime.Time) error {
detailsByBatch := make(map[string][]*Detail)
for _, detail := range task.Details {
if detail == nil {
continue
}
batchNo := strings.TrimSpace(detail.BatchNo)
if batchNo == "" {
return gerror.New("allocate parent detail is missing child batch number")
}
detailsByBatch[batchNo] = append(detailsByBatch[batchNo], detail)
}
batchNos := make([]string, 0, len(detailsByBatch))
for batchNo := range detailsByBatch {
batchNos = append(batchNos, batchNo)
}
sort.Strings(batchNos)
for _, batchNo := range batchNos {
details := detailsByBatch[batchNo]
sort.SliceStable(details, func(i, j int) bool {
if details[i].ID != details[j].ID {
return details[i].ID < details[j].ID
}
return details[i].RowNo < details[j].RowNo
})
batchTask := *task
batchTask.Batch.BatchNo = batchNo
for start := 0; start < len(details); start += allocateDetailUpdateBatchSize {
end := min(start+allocateDetailUpdateBatchSize, len(details))
if err := r.updateDetailChunk(ctx, tx, &batchTask, details[start:end], now); err != nil {
return gerror.Wrapf(err, "update allocate child details failed, batchNo:%s, range:%d-%d", batchNo, start+1, end)
}
}
}
return nil
}
func (r *Repository) updateDetailChunk(ctx context.Context, tx gdb.TX, task *Task, details []*Detail, now *gtime.Time) error {
if len(details) == 0 {
return nil
}
statement, args := buildDetailUpdateStatement(task, details, now)
result, err := tx.Ctx(ctx).Exec(statement, args...)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != int64(len(details)) {
// MySQL 默认返回实际修改行数。子批次已写入相同结果时可能为零,
// 必须在同一事务核对最终结果,不能据此放过缺失或越界明细。
ids := make([]int64, 0, len(details))
for _, detail := range details {
ids = append(ids, detail.ID)
}
var persisted []*entity.BatchDetail
columns := dao.BatchDetail.Columns()
if err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(columns.BatchNo, task.Batch.BatchNo).
Where(columns.BatchType, consts.BatchTypeAllocate).
Where(columns.TenantId, task.Batch.TenantID).
WhereIn(columns.Id, ids).Scan(&persisted); err != nil {
return gerror.Wrap(err, "核对划拨明细更新结果失败")
}
if err := validatePersistedDetailResults(task, details, persisted); err != nil {
return gerror.Wrapf(err, "划拨明细更新未确认,预期:%d,实际修改:%d", len(details), affected)
}
}
return nil
}
func validatePersistedDetailResults(task *Task, details []*Detail, persisted []*entity.BatchDetail) error {
if len(persisted) != len(details) {
return gerror.New("划拨明细缺失或不在当前批次租户范围内")
}
byID := make(map[int64]*entity.BatchDetail, len(persisted))
for _, row := range persisted {
if row == nil || row.BatchNo != task.Batch.BatchNo || row.BatchType != consts.BatchTypeAllocate || row.TenantId != task.Batch.TenantID {
return gerror.New("划拨明细不在当前批次租户范围内")
}
byID[row.Id] = row
}
for _, detail := range details {
row := byID[detail.ID]
status := consts.BatchDetailStatusFailed
if detail.Success() {
status = consts.BatchDetailStatusSuccess
}
if row == nil || row.DetailStatus != status || row.ErrorReason != detail.Reason || row.UpdatedBy != task.Batch.CreatedBy || row.Snapshot == nil || !sameDetailSnapshot(row.Snapshot, detail.Snapshot(task.Batch)) {
return gerror.Newf("划拨明细最终结果不一致,明细ID:%d", detail.ID)
}
delete(byID, detail.ID)
}
return nil
}
func sameDetailSnapshot(actual, expected *gjson.Json) bool {
if actual == nil || expected == nil {
return actual == expected
}
actualJSON, actualErr := json.Marshal(actual.Map())
expectedJSON, expectedErr := json.Marshal(expected.Map())
return actualErr == nil && expectedErr == nil && string(actualJSON) == string(expectedJSON)
}
func buildDetailUpdateStatement(task *Task, details []*Detail, now *gtime.Time) (string, []any) {
columns := dao.BatchDetail.Columns()
var builder strings.Builder
fmt.Fprintf(&builder, "UPDATE %s SET %s = CASE %s ", dao.BatchDetail.Table(), columns.DetailStatus, columns.Id)
args := make([]any, 0, len(details)*7+8)
for _, detail := range details {
status := consts.BatchDetailStatusFailed
if detail.Success() {
status = consts.BatchDetailStatusSuccess
}
builder.WriteString("WHEN ? THEN ? ")
args = append(args, detail.ID, status)
}
fmt.Fprintf(&builder, "ELSE %s END, %s = CASE %s ", columns.DetailStatus, columns.ErrorReason, columns.Id)
for _, detail := range details {
builder.WriteString("WHEN ? THEN ? ")
args = append(args, detail.ID, detail.Reason)
}
fmt.Fprintf(&builder, "ELSE %s END, %s = CASE %s ", columns.ErrorReason, columns.Snapshot, columns.Id)
for _, detail := range details {
builder.WriteString("WHEN ? THEN ? ")
args = append(args, detail.ID, detail.Snapshot(task.Batch).String())
}
fmt.Fprintf(&builder, "ELSE %s END, %s = ?, %s = ? ", columns.Snapshot, columns.UpdatedBy, columns.UpdatedAt)
args = append(args, task.Batch.CreatedBy, now)
fmt.Fprintf(&builder, "WHERE %s = ? AND %s = ? AND %s = ? AND %s IN (?, ?, ?, ?) AND %s IN (%s)",
columns.BatchNo,
columns.BatchType,
columns.TenantId,
columns.DetailStatus,
columns.Id,
strings.TrimSuffix(strings.Repeat("?,", len(details)), ","),
)
args = append(args,
task.Batch.BatchNo,
consts.BatchTypeAllocate,
task.Batch.TenantID,
consts.BatchDetailStatusPending,
consts.BatchDetailStatusRunning,
consts.BatchDetailStatusSuccess,
consts.BatchDetailStatusFailed,
)
for _, detail := range details {
args = append(args, detail.ID)
}
return builder.String(), args
}
func (r *Repository) updateCardOwners(ctx context.Context, tx gdb.TX, task *Task, now *gtime.Time) error {
groups := make(map[int64][]int64)
totalCards := 0
for _, detail := range task.Details {
if detail == nil || !detail.Success() || detail.Card == nil || detail.Target == nil {
continue
}
groups[detail.Target.DeptID] = append(groups[detail.Target.DeptID], detail.Card.ID)
totalCards++
}
successCount, _ := countResult(task)
if totalCards != successCount {
return gerror.Newf("划拨成功明细缺少卡或目标客户快照,预期:%d,实际:%d", successCount, totalCards)
}
for _, cardIDs := range groups {
sort.Slice(cardIDs, func(i, j int) bool { return cardIDs[i] < cardIDs[j] })
}
targetDeptIDs := make([]int64, 0, len(groups))
for targetDeptID := range groups {
targetDeptIDs = append(targetDeptIDs, targetDeptID)
}
sort.Slice(targetDeptIDs, func(i, j int) bool { return targetDeptIDs[i] < targetDeptIDs[j] })
for _, targetDeptID := range targetDeptIDs {
cardIDs := groups[targetDeptID]
for start := 0; start < len(cardIDs); start += allocateCardUpdateBatchSize {
end := min(start+allocateCardUpdateBatchSize, len(cardIDs))
ids := cardIDs[start:end]
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Safe().
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
Where(dao.TSimCardInfo.Columns().DeptId, task.Batch.CreatedDept).
WhereIn(dao.TSimCardInfo.Columns().Id, ids).
Data(do.TSimCardInfo{
DeptId: targetDeptID,
OutboundBatchNo: task.Batch.BatchNo,
AllocationBatchNo: task.Batch.BatchNo,
UpdatedBy: task.Batch.CreatedBy,
UpdatedAt: now,
}).Update()
if err != nil {
return gerror.Wrapf(err, "批量更新SIM卡划拨归属失败,目标客户:%d,范围:%d-%d", targetDeptID, start+1, end)
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != int64(len(ids)) {
return gerror.Newf("卡归属已变化,划拨批次整批回滚,目标客户:%d,预期:%d,实际:%d", targetDeptID, len(ids), affected)
}
}
}
return nil
}
func (r *Repository) commitLegacy(ctx context.Context, task *Task) error {
if task == nil {
return gerror.New("划拨任务不能为空")
}
now := gtime.Now()
if err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, detail := range task.Details {
if detail == nil {
continue
}
if detail.Success() {
if err := r.updateCardOwner(ctx, tx, task, detail, now); err != nil {
return err
}
}
if err := r.updateDetail(ctx, tx, task, detail, now); err != nil {
return err
}
}
return nil
}); err != nil {
return gerror.Wrap(err, "保存划拨明细结果失败")
}
successCount, failCount := countResult(task)
artifact, artifactErr := r.createResultFile(ctx, task)
if artifactErr != nil {
g.Log().Warningf(ctx, "allocate result file generation failed, batchNo:%s, err:%+v", task.Batch.BatchNo, artifactErr)
}
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var ossID, downloadID int64
if artifactErr == nil {
var err error
ossID, downloadID, err = r.persistArtifact(ctx, tx, task, artifact, successCount, failCount, now)
if err != nil {
batchresult.RemoveLocalFile(artifact)
return err
}
}
return r.finalizeBatch(ctx, tx, task, successCount, failCount, ossID, downloadID, now, artifactErr != nil)
})
return gerror.Wrap(err, "汇总划拨批次失败")
}
func (r *Repository) updateCardOwner(ctx context.Context, tx gdb.TX, task *Task, detail *Detail, now *gtime.Time) error {
if detail.Card == nil || detail.Target == nil {
return gerror.New("划拨明细缺少卡或目标客户档案")
}
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Safe().
Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
Where(dao.TSimCardInfo.Columns().DeptId, task.Batch.CreatedDept).
Data(do.TSimCardInfo{
DeptId: detail.Target.DeptID,
OutboundBatchNo: task.Batch.BatchNo,
AllocationBatchNo: task.Batch.BatchNo,
UpdatedBy: task.Batch.CreatedBy,
UpdatedAt: now,
}).Update()
if err != nil {
return gerror.Wrap(err, "更新SIM卡划拨归属失败")
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.New("卡归属已变化,划拨批次整批回滚")
}
return nil
}
func (r *Repository) updateDetail(ctx context.Context, tx gdb.TX, task *Task, detail *Detail, now *gtime.Time) error {
if detail == nil ||
(detail.DetailStatus != consts.BatchDetailStatusPending &&
detail.DetailStatus != consts.BatchDetailStatusRunning) {
return nil
}
status := consts.BatchDetailStatusFailed
if detail.Success() {
status = consts.BatchDetailStatusSuccess
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeAllocate).
WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{
consts.BatchDetailStatusPending,
consts.BatchDetailStatusRunning,
}).
Data(do.BatchDetail{
DetailStatus: status,
ErrorReason: detail.Reason,
Snapshot: detail.Snapshot(task.Batch),
UpdatedBy: task.Batch.CreatedBy,
UpdatedAt: now,
}).Update()
if err != nil {
return gerror.Wrap(err, "更新划拨批次明细失败")
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("划拨批次明细%d已不是运行中状态", detail.ID)
}
return nil
}
func (r *Repository) createResultFile(ctx context.Context, task *Task) (batchresult.Artifact, error) {
if r.results == nil {
return batchresult.Artifact{}, gerror.New("result generator is not configured")
}
return r.results.Create(ctx, batchresult.Request{
BatchNo: task.Batch.BatchNo,
BatchType: task.Batch.BatchType,
TenantID: task.Batch.TenantID,
Rows: task.ResultRows(),
})
}
func (r *Repository) persistArtifact(ctx context.Context, tx gdb.TX, task *Task, artifact batchresult.Artifact, successCount, failCount int, now *gtime.Time) (int64, int64, error) {
ossID, err := tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
TenantId: task.Batch.TenantID,
FileName: artifact.FileName,
OriginalName: artifact.OriginalName,
FileSuffix: artifact.FileSuffix,
Path: artifact.Path,
Url: artifact.URL,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
Service: "local",
Md5: artifact.MD5,
FileSize: artifact.Size,
}).InsertAndGetId()
if err != nil {
return 0, 0, gerror.Wrap(err, "写入划拨结果文件OSS失败")
}
downloadID, err := tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
BatchNo: task.Batch.BatchNo,
TaskName: batchresult.TaskName(task.Batch.BatchType),
ResourceType: consts.ResourceTypeExcel,
ExportScene: consts.BatchTypeAllocate,
TotalCount: len(task.Details),
SuccessCount: successCount,
FailCount: failCount,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
FileExt: artifact.FileSuffix,
FileUrl: artifact.URL,
FilePath: artifact.Path,
FileSize: artifact.Size,
FileMd5: artifact.MD5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: task.Batch.CreatedDept,
StartTime: task.Batch.StartTime,
FinishTime: now,
TenantId: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
}).InsertAndGetId()
if err != nil {
return 0, 0, gerror.Wrap(err, "写入划拨结果下载记录失败")
}
return ossID, downloadID, nil
}
func (r *Repository) finalizeBatch(ctx context.Context, tx gdb.TX, task *Task, successCount, failCount int, ossID, downloadID int64, now *gtime.Time, resultFileFailed bool) error {
batchAsyncStatus, runResult := batchResult(successCount, failCount)
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeAllocate).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: batchAsyncStatus,
RunResult: runResult,
TotalCount: len(task.Details),
ValidCount: successCount,
SuccessCount: successCount,
FailCount: failCount,
FailReason: task.FailureReason,
BatchInfo: mergeBatchInfo(task.Batch.BatchInfo, ossID, downloadID, resultFileFailed),
EndTime: now,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "更新划拨批次主表失败")
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.New("划拨批次已不是处理中状态")
}
return nil
}
func mergeBatchInfo(info *gjson.Json, ossID, downloadID int64, resultFileFailed bool) *gjson.Json {
data := make(map[string]any)
if info != nil {
for key, value := range info.Map() {
data[key] = value
}
}
if ossID > 0 {
data["resultFileOssId"] = ossID
}
if resultFileFailed {
data["resultFile"] = map[string]any{"status": "failed"}
} else if downloadID > 0 {
data["resultDownloadId"] = downloadID
data["resultFile"] = map[string]any{"status": "generated"}
} else {
data["resultFile"] = map[string]any{"status": "skipped"}
}
return gjson.New(data)
}
func batchResult(successCount, failCount int) (int, int) {
if failCount > 0 || successCount == 0 {
return consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed
}
return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}