1012 lines
36 KiB
Go
1012 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)
|
|
if failCount > 0 {
|
|
failCount = countValidationFailures(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: len(task.Details) - failCount, 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, countValidationFailures(task)
|
|
}
|
|
|
|
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 := persistedAllocationDetailStatus(task, detail)
|
|
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+9)
|
|
for _, detail := range details {
|
|
status := persistedAllocationDetailStatus(task, detail)
|
|
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,
|
|
consts.BatchDetailStatusNotExecuted,
|
|
)
|
|
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 := persistedAllocationDetailStatus(task, detail)
|
|
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: len(task.Details) - failCount,
|
|
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
|
|
}
|