sync: 同步 v1.0.4 开源代码并脱敏

This commit is contained in:
MiFaZhan
2026-10-29 14:52:43 +08:00
parent 32e4316ee4
commit 6893e21f07
136 changed files with 9851 additions and 436 deletions
+431
View File
@@ -0,0 +1,431 @@
package data
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"sort"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
// 首查 +3min,此后每 3min 重试 3 次(+3/+6/+9/+12min)。
// 2026-10-15 潍坊、濮阳实测:10/30/100 卡共12笔首次可见终态约 65~225s;
// 15s 采样只能给出完成区间,不能据此承诺所有高峰时段的完成上限。
// 偶发超长任务不增加自动重试;超窗记 review,保留受理号和原因供手动查询。
// 未确认不得记办理失败,也不得重新提交办理请求。
const (
subBatchLease = 5 * time.Minute
firstResultDelay = 3 * time.Minute
furtherResultDelay = 3 * time.Minute
maxResultQueries = 4
)
func newSubBatch(task *Task, details []*Detail) SubBatch {
keys := make([]*model.SimKey, 0, len(details))
ids := make([]int64, 0, len(details))
for _, detail := range details {
ids = append(ids, detail.ID)
keys = append(keys, &model.SimKey{ChanKey: detail.ChanKey, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI})
}
raw, _ := json.Marshal(struct {
BatchNo string
BatchType int
IDs []int64
}{task.Batch.BatchNo, task.Batch.BatchType, ids})
digest := sha256.Sum256(raw)
id := hex.EncodeToString(digest[:])
input := model.SimCardApnChangeInput{SimKeys: keys, ReqData: model.SimCardApnChangeInputItem{OperType: task.Operation.ApnOperType, ApnName: task.ApnName, OperationID: id, SubmitOnly: true}}
return SubBatch{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, ChanKey: details[0].ChanKey, OperationID: id, DetailIDs: ids, Request: input, Status: "prepared"}
}
func splitAsyncDetails(details []*Detail) [][]*Detail {
withMSISDN := make([]*Detail, 0, len(details))
batches := make([][]*Detail, 0)
for _, detail := range details {
if strings.TrimSpace(detail.MSISDN) == "" {
batches = append(batches, []*Detail{detail})
} else {
withMSISDN = append(withMSISDN, detail)
}
}
for _, chunk := range splitDetails(withMSISDN, consts.BatchSubBatchSize) {
if len(chunk) < 10 {
for _, detail := range chunk {
batches = append(batches, []*Detail{detail})
}
} else {
batches = append(batches, chunk)
}
}
return batches
}
// prepareAndSubmit persists every intent before a single write leaves the process.
func (e *executor) prepareAndSubmit(ctx context.Context, task *Task, repo SubBatchRepository, upstream AsyncUpstream) error {
groups := make(map[string][]*Detail)
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" && (detail.DetailStatus == 0 || detail.DetailStatus == consts.BatchDetailStatusRunning) {
groups[detail.ChanKey] = append(groups[detail.ChanKey], detail)
}
}
keys := make([]string, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, slice := range splitAsyncDetails(groups[key]) {
sub := newSubBatch(task, slice)
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
return fmt.Errorf("保存APN提交意图失败(未提交运营商): %w", err)
}
}
}
return nil
}
// processSubBatch only submits while prepared. Any interrupted submission is recovered by operationId, never repeated.
func (e *executor) processSubBatch(ctx context.Context, repo SubBatchRepository, upstream AsyncUpstream, operationID string) error {
channel, err := repo.SubBatchChannel(ctx, operationID)
if err != nil {
return err
}
value, _ := e.channelSlots.LoadOrStore(channel, make(chan struct{}, consts.BatchSubBatchConcurrency))
slots := value.(chan struct{})
select {
case slots <- struct{}{}:
defer func() { <-slots }()
case <-ctx.Done():
return ctx.Err()
}
token := uuid.NewString()
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
if checker, ok := upstream.(interface {
CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error
}); ok {
snapshot, err := repo.GetSubBatch(callCtx, operationID)
if err != nil || snapshot == nil {
return err
}
if snapshot.Status == "done" || snapshot.Status == "review" {
return nil
}
if snapshot.Status == "prepared" {
if err := checker.CheckSimApnAsyncCapability(callCtx, &snapshot.Request); err != nil {
persistCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer stop()
if delayErr := repo.DelaySubBatchPrecheck(persistCtx, *snapshot, time.Now().Add(30*time.Second), err.Error()); delayErr != nil {
return fmt.Errorf("保存APN预检调度退避失败: %w", delayErr)
}
return fmt.Errorf("APN提交预检未就绪,未领取提交意图: %w", err)
}
}
}
sub, err := repo.ClaimSubBatch(ctx, operationID, token, time.Now())
if err != nil || sub == nil {
return err
}
if sub.Status == "prepared" {
response, submitErr := upstream.SubmitSimApnFunction(callCtx, &sub.Request)
if submitErr != nil {
if isAPNRequestNotSent(submitErr) {
return releaseUnsent(ctx, repo, *sub, submitErr)
}
return e.rememberUncertain(ctx, repo, *sub, "提交结果未确认(网络超时或连接中断): "+submitErr.Error())
}
return e.handleResponse(ctx, repo, *sub, response, false)
}
if sub.Status == "submitting" || sub.Status == "uncertain" || (sub.Status == "accepted" && sub.JobID == "") {
sub.Request.ReqData.RecoverOnly = true
sub.Request.ReqData.QueryJobID = ""
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
if queryErr != nil {
if isAPNRequestNotSent(queryErr) {
return releaseUnsent(ctx, repo, *sub, queryErr)
}
return e.rememberUncertain(ctx, repo, *sub, "受理映射恢复失败(不重发): "+queryErr.Error())
}
return e.handleResponse(ctx, repo, *sub, response, false)
}
sub.Request.ReqData.QueryJobID = sub.JobID
sub.Request.ReqData.RecoverOnly = false
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
if queryErr != nil {
if isAPNRequestNotSent(queryErr) {
return releaseUnsent(ctx, repo, *sub, queryErr)
}
return e.queryUnavailable(ctx, repo, *sub, "查询请求超时或不可用: "+queryErr.Error())
}
return e.handleResponse(ctx, repo, *sub, response, true)
}
// QueryReview仅执行一次只读补查,不重新开启自动轮询或提交办理。
func (e *executor) QueryReview(ctx context.Context, operationID string) error {
operationID = strings.TrimSpace(operationID)
if operationID == "" {
return fmt.Errorf("operationId不能为空")
}
repo, ok := e.repository.(SubBatchRepository)
if !ok {
return fmt.Errorf("未配置APN持久化仓储")
}
reviewRepo, ok := e.repository.(ReviewRepository)
if !ok {
return fmt.Errorf("未配置APN待核查查询仓储")
}
upstream, ok := e.upstream.(AsyncUpstream)
if !ok {
return fmt.Errorf("未配置APN只读补查客户端")
}
sub, err := reviewRepo.ClaimReviewSubBatch(ctx, operationID, uuid.NewString(), time.Now())
if err != nil {
return err
}
if sub == nil {
snapshot, err := repo.GetSubBatch(ctx, operationID)
if err != nil {
return err
}
if snapshot != nil && snapshot.Status == "done" {
return repo.FinalizeReadyBatch(ctx, snapshot.BatchNo)
}
return fmt.Errorf("子批不处于可查询的待核查状态,或已有查询/汇总正在运行")
}
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
sub.Request.ReqData.SubmitOnly = false
sub.Request.ReqData.QueryJobID = sub.JobID
sub.Request.ReqData.RecoverOnly = sub.JobID == ""
items, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
if queryErr != nil {
if err := e.deferResult(ctx, repo, *sub, "review", "手动补查未确认:"+queryErr.Error()); err != nil {
return err
}
return queryErr
}
return e.handleResponse(ctx, repo, *sub, items, true)
}
// QueryReviewBatch 是"手动同步上游结果"入口:只对父批下已冻结的待核查子批
// 做一次只读补查,然后尝试汇总父批。
// 不重发办理请求、不清零自动补查计数、不重新开启轮询调度。
func (e *executor) QueryReviewBatch(ctx context.Context, batchNo string) (int, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return 0, fmt.Errorf("批次编号不能为空")
}
repo, ok := e.repository.(ReviewBatchRepository)
if !ok {
return 0, fmt.Errorf("未配置APN待核查批量同步仓储")
}
if _, ok := e.upstream.(AsyncUpstream); !ok {
return 0, fmt.Errorf("未配置APN只读补查客户端")
}
ids, err := repo.ListReviewSubBatches(ctx, batchNo)
if err != nil {
return 0, err
}
queried := 0
var firstErr error
for _, id := range ids {
if err := e.QueryReview(ctx, id); err != nil {
g.Log().Warningf(ctx, "手动同步待核查子批未完成 batchNo=%s operationId=%s error=%v", batchNo, id, err)
if firstErr == nil {
firstErr = fmt.Errorf("operationId=%s: %w", id, err)
}
continue
}
queried++
}
// 已确认终态的子批会在这里收敛父批;仍为 review 的子批继续留在待核查。
if err := repo.FinalizeReadyBatch(ctx, batchNo); err != nil && firstErr == nil {
firstErr = err
}
g.Log().Infof(ctx, "手动同步APN上游结果 batchNo=%s 待核查子批=%d 本次已同步=%d", batchNo, len(ids), queried)
if firstErr != nil {
return queried, firstErr
}
return queried, nil
}
func isAPNRequestNotSent(err error) bool {
var notSent interface{ APNRequestNotSent() bool }
return errors.As(err, &notSent) && notSent.APNRequestNotSent()
}
func releaseUnsent(ctx context.Context, repo SubBatchRepository, sub SubBatch, cause error) error {
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
return repo.ReleaseUnsentSubBatch(persistCtx, sub, cause.Error())
}
func nextResultCheck(sub SubBatch) time.Time {
base := sub.SubmittedAt
if base.IsZero() {
base = time.Now()
}
return base.Add(firstResultDelay + time.Duration(sub.PollCount)*furtherResultDelay)
}
func (e *executor) rememberUncertain(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
return e.deferResult(ctx, repo, sub, "uncertain", reason)
}
func (e *executor) queryUnavailable(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
return e.deferResult(ctx, repo, sub, "accepted", reason)
}
func (e *executor) deferResult(ctx context.Context, repo SubBatchRepository, sub SubBatch, state, reason string) error {
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
g.Log().Warningf(persistCtx, "APN补查 operationId=%s jobId=%s pollCount=%d reason=%s", sub.OperationID, sub.JobID, sub.PollCount, reason)
if sub.Status == "review_query" || sub.PollCount >= maxResultQueries {
if err := repo.UpdateSubBatch(persistCtx, sub, "review", sub.JobID, "补查窗口结束,办理结果未知:"+reason, time.Time{}, sub.PollCount); err != nil {
return err
}
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
}
return repo.UpdateSubBatch(persistCtx, sub, state, sub.JobID, reason, nextResultCheck(sub), sub.PollCount)
}
func (e *executor) handleResponse(ctx context.Context, repo SubBatchRepository, sub SubBatch, items []model.SimBaseRes[model.SimCardApnChangeOutputItem], queried bool) error {
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
outcomes := make([]Outcome, len(sub.Request.SimKeys))
used := make([]bool, len(items))
pending := false
reason := "未返回该卡结果,办理未确认"
for i, key := range sub.Request.SimKeys {
matched := -1
for j, item := range items {
if !used[j] && sameSim(&Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI}, item.SimKey) {
matched = j
break
}
}
if matched < 0 {
outcomes[i] = Outcome{Pending: true, Message: reason}
pending = true
continue
}
used[matched] = true
item := items[matched]
if item.ResData.Pending || item.ResData.Uncertain {
pending = true
outcomes[i] = Outcome{Pending: true, Code: item.Code, Message: item.Message}
if strings.TrimSpace(item.Message) != "" {
reason = item.Message
}
// Only Pending identifies a batch job: a synchronous per-card order is never queried as a job.
if item.ResData.Pending && strings.TrimSpace(item.ResData.OrderNum) != "" {
sub.JobID = strings.TrimSpace(item.ResData.OrderNum)
}
continue
}
outcomes[i] = Outcome{Success: item.Code == 0, Code: item.Code, Message: item.Message, OrderNum: item.ResData.OrderNum}
}
if pending {
// Confirmed cards are committed now. Later missing/pending responses cannot overwrite their terminal rows.
if err := repo.SaveSubBatchProgress(persistCtx, sub, outcomes); err != nil {
return err
}
state := "accepted"
if sub.JobID == "" {
state = "uncertain"
reason += "(缺少jobId,仅按operationId恢复,不重发)"
}
return e.deferResult(persistCtx, repo, sub, state, reason)
}
if err := repo.CompleteSubBatch(persistCtx, sub, outcomes); err != nil {
return err
}
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
}
func mapOutcomesFromKeys(keys []*model.SimKey, response []model.SimBaseRes[model.SimCardApnChangeOutputItem]) []Outcome {
details := make([]*Detail, 0, len(keys))
for _, key := range keys {
details = append(details, &Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI})
}
return mapOutcomes(details, response)
}
// RunReconciler uses a fixed global worker bound; waiting for a job never retains a worker slot.
func (e *executor) RunReconciler(ctx context.Context) error {
repo, ok := e.repository.(SubBatchRepository)
if !ok {
return nil
}
upstream, ok := e.upstream.(AsyncUpstream)
if !ok {
return nil
}
jobs := make(chan string, 64)
var inFlight sync.Map
var workers sync.WaitGroup
for i := 0; i < 20; i++ {
workers.Add(1)
go func() {
defer workers.Done()
for {
select {
case <-ctx.Done():
return
case id := <-jobs:
if err := e.processSubBatch(ctx, repo, upstream, id); err != nil {
g.Log().Errorf(ctx, "APN恢复失败 operationId=%s error=%v", id, err)
}
inFlight.Delete(id)
}
}
}()
}
defer workers.Wait()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
batches, err := repo.ListRecoverableBatches(ctx, 32)
if err != nil {
g.Log().Errorf(ctx, "读取APN恢复父批失败: %v", err)
} else {
for _, batchNo := range batches {
if err := e.Execute(ctx, batchNo); err != nil {
g.Log().Errorf(ctx, "恢复APN父批失败 batchNo=%s error=%v", batchNo, err)
}
}
}
ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128)
if err != nil {
g.Log().Errorf(ctx, "读取APN补查子批失败: %v", err)
} else {
for _, id := range ids {
if _, loaded := inFlight.LoadOrStore(id, true); loaded {
continue
}
select {
case jobs <- id:
case <-ctx.Done():
inFlight.Delete(id)
return nil
default:
inFlight.Delete(id)
}
}
}
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
}
@@ -0,0 +1,126 @@
package data
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
type fairnessRepository struct{ *durableFake }
func (f *fairnessRepository) Load(context.Context, string) (*Task, error) {
return nil, errors.New("公平性测试不应准备父批")
}
func (f *fairnessRepository) SaveValidationFailure(context.Context, *Task) error { return nil }
func (f *fairnessRepository) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
return Outcome{}, nil
}
func (f *fairnessRepository) Finalize(context.Context, *Task) error { return nil }
type fairnessUpstream struct {
asyncFake
rejected atomic.Int32
}
func (f *fairnessUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, errors.New("调度不得走同步办理")
}
func (f *fairnessUpstream) CheckSimApnAsyncCapability(_ context.Context, in *model.SimCardApnChangeInput) error {
if in.SimKeys[0].ChanKey == "removed" {
f.rejected.Add(1)
return errors.New("通道已移除,无protocolVersion")
}
return nil
}
func TestDurableAPNPrecheckBackoffAllowsHealthy129thTask(t *testing.T) {
repo := newDurableFake()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for i := 0; i < 129; i++ {
sub := fixtureSub()
sub.OperationID = fmt.Sprintf("fair-%03d", i)
sub.Request.ReqData.OperationID = sub.OperationID
sub.ChanKey = "removed"
if i == 128 {
sub.ChanKey = "healthy"
}
for _, key := range sub.Request.SimKeys {
key.ChanKey = sub.ChanKey
}
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
t.Fatal(err)
}
}
completed := make(chan struct{}, 1)
upstream := &fairnessUpstream{}
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.SimKeys[0].ChanKey != "healthy" {
t.Error("拒绝通道发出办理")
}
out := pendingResponse(in, 0)
for i := range out {
out[i].ResData.Pending = false
}
completed <- struct{}{}
return out, nil
}
e := &executor{repository: &fairnessRepository{repo}, upstream: upstream}
done := make(chan error, 1)
go func() { done <- e.RunReconciler(ctx) }()
select {
case <-completed:
case <-time.After(15 * time.Second):
cancel()
<-done
t.Fatal("前128个预检拒绝使第129健康任务饥饿")
}
// 健康提交可能早于已入队的拒绝任务完成;等待退避持久化后再取消调度器。
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
timeout := time.NewTimer(15 * time.Second)
defer timeout.Stop()
for {
repo.mu.Lock()
delayed := 0
for id, sub := range repo.batches {
if sub.ChanKey == "removed" && !repo.next[id].IsZero() {
delayed++
}
}
repo.mu.Unlock()
if delayed == 128 {
break
}
select {
case <-ticker.C:
case <-timeout.C:
cancel()
<-done
t.Fatalf("拒绝任务退避未全部持久化: delayed=%d rejected=%d", delayed, upstream.rejected.Load())
}
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
if upstream.submitCalls != 1 || upstream.rejected.Load() != 128 {
t.Fatalf("submit=%d rejected=%d", upstream.submitCalls, upstream.rejected.Load())
}
repo.mu.Lock()
defer repo.mu.Unlock()
for id, sub := range repo.batches {
if sub.ChanKey == "healthy" {
continue
}
if sub.Status != "prepared" || sub.PollCount != 0 || !sub.SubmittedAt.IsZero() || sub.LeaseToken != "" || repo.next[id].IsZero() {
t.Fatalf("只读退避改变办理意图: %+v", sub)
}
}
}
@@ -0,0 +1,201 @@
package data
import (
"context"
"errors"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
// reviewBatchFake 复用 durableFake 的持久化语义,只补手动同步需要的仓储能力。
type reviewBatchFake struct {
*durableFake
candidates []string
finalized []string
}
func newReviewBatchFake(candidates ...string) *reviewBatchFake {
return &reviewBatchFake{durableFake: newDurableFake(), candidates: candidates}
}
func (f *reviewBatchFake) ListReviewSubBatches(_ context.Context, _ string) ([]string, error) {
return append([]string(nil), f.candidates...), nil
}
// 手动同步不触碰校验/终态写入路径,这里只补齐 Repository 契约。
func (f *reviewBatchFake) Load(context.Context, string) (*Task, error) { return &Task{}, nil }
func (f *reviewBatchFake) SaveValidationFailure(context.Context, *Task) error { return nil }
func (f *reviewBatchFake) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
return Outcome{}, nil
}
func (f *reviewBatchFake) Finalize(context.Context, *Task) error { return nil }
// manualSyncUpstream 补上同步办理入口,确保手动同步一旦误走同步路径立即暴露。
type manualSyncUpstream struct {
*asyncFake
}
func (u *manualSyncUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, errors.New("手动同步不得调用同步办理入口")
}
func (f *reviewBatchFake) FinalizeReadyBatch(_ context.Context, batchNo string) error {
f.finalized = append(f.finalized, batchNo)
return nil
}
func (f *reviewBatchFake) ClaimReviewSubBatch(_ context.Context, operationID, token string, now time.Time) (*SubBatch, error) {
f.mu.Lock()
defer f.mu.Unlock()
sub, ok := f.batches[operationID]
if !ok || (sub.Status != "review" && sub.Status != "review_query") {
return nil, nil
}
sub.Status = "review_query"
sub.LeaseToken = token
sub.LeaseUntil = now.Add(subBatchLease)
f.batches[operationID] = sub
out := sub
return &out, nil
}
// 手动同步只用既有受理号只读补查:不重发办理、不清零自动补查计数。
func TestManualSyncQueriesFrozenIntentsWithoutResubmitting(t *testing.T) {
ctx := context.Background()
sub := fixtureSub()
repo := newReviewBatchFake(sub.OperationID)
sub.Status = "review"
sub.JobID = "job-1"
sub.PollCount = 4
repo.batches[sub.OperationID] = sub
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
t.Fatal("手动同步不得重发办理")
return nil, nil
},
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.ReqData.QueryJobID != "job-1" {
t.Fatalf("手动同步必须按既有 jobId 查询: %+v", in.ReqData)
}
out := make([]model.SimBaseRes[model.SimCardApnChangeOutputItem], 0, len(in.SimKeys))
for _, key := range in.SimKeys {
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 0, ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1"}})
}
return out, nil
},
}}
executor := &executor{repository: repo, upstream: upstream}
queried, err := executor.QueryReviewBatch(ctx, " P-1 ")
if err != nil {
t.Fatalf("QueryReviewBatch() error = %v", err)
}
if queried != 1 {
t.Fatalf("queried = %d, want 1", queried)
}
if upstream.submitCalls != 0 {
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
}
got := repo.batches[sub.OperationID]
if got.Status != "done" {
t.Fatalf("状态 = %s, want done", got.Status)
}
if got.PollCount != 4 {
t.Fatalf("pollCount = %d, want 4(手动同步不得清零自动补查计数)", got.PollCount)
}
// 子批收敛与批量入口都会尝试汇总父批,重复汇总是幂等的。
if len(repo.finalized) == 0 || repo.finalized[0] != "P-1" {
t.Fatalf("父批未汇总: %+v", repo.finalized)
}
if len(repo.outcomes[sub.OperationID]) != len(sub.DetailIDs) {
t.Fatalf("逐卡结果未写回: %+v", repo.outcomes[sub.OperationID])
}
}
// 上游仍无结论时明细保持待核查,且可以再次手动同步。
func TestManualSyncKeepsUnknownWhenUpstreamStillPending(t *testing.T) {
ctx := context.Background()
sub := fixtureSub()
repo := newReviewBatchFake(sub.OperationID)
sub.Status = "review"
repo.batches[sub.OperationID] = sub
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
t.Fatal("手动同步不得重发办理")
return nil, nil
},
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return pendingResponse(in, 14008), nil
},
}}
executor := &executor{repository: repo, upstream: upstream}
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
t.Fatalf("QueryReviewBatch() error = %v", err)
}
got := repo.batches[sub.OperationID]
if got.Status != "review" {
t.Fatalf("状态 = %s, want review(未确认不得记失败)", got.Status)
}
if got.JobID != "job-1" {
t.Fatalf("jobId = %s, want job-1", got.JobID)
}
if got.PollCount != 0 {
t.Fatalf("pollCount = %d, want 0(手动同步不得占用自动补查次数)", got.PollCount)
}
// 再次同步仍可用,说明没有被自动窗口锁死。
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
t.Fatalf("第二次 QueryReviewBatch() error = %v", err)
}
}
// 单个子批补查失败不影响其余子批,并把失败如实返回给调用方。
func TestManualSyncReportsPerIntentFailureAndContinues(t *testing.T) {
ctx := context.Background()
first := fixtureSub()
first.Status = "review"
first.JobID = "job-1"
second := fixtureSub()
second.OperationID = "intent-2"
second.Status = "review"
repo := newReviewBatchFake(first.OperationID, second.OperationID)
repo.batches[first.OperationID] = first
repo.batches[second.OperationID] = second
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
t.Fatal("手动同步不得重发办理")
return nil, nil
},
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.ReqData.RecoverOnly {
return nil, errors.New("上游查询不可用")
}
return pendingResponse(in, 0), nil
},
}}
executor := &executor{repository: repo, upstream: upstream}
queried, err := executor.QueryReviewBatch(ctx, "P-1")
if err == nil {
t.Fatal("QueryReviewBatch() error = nil, want error")
}
if queried != 1 {
t.Fatalf("queried = %d, want 1(成功一个,失败一个)", queried)
}
if upstream.submitCalls != 0 {
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
}
}
// 空批次号直接拒绝,避免误触发全量扫描。
func TestManualSyncRejectsBlankBatchNo(t *testing.T) {
executor := &executor{repository: newReviewBatchFake(), upstream: &manualSyncUpstream{asyncFake: &asyncFake{}}}
if _, err := executor.QueryReviewBatch(context.Background(), " "); err == nil {
t.Fatal("QueryReviewBatch() blank batchNo error = nil, want error")
}
}
+650
View File
@@ -0,0 +1,650 @@
package data
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
upstreamclient "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func TestDurableAPNResultScheduleIsThreeMinutesAndThreeRetries(t *testing.T) {
if maxResultQueries != 4 {
t.Fatalf("首次加三次重试应为四次查询: got=%d", maxResultQueries)
}
base := time.Date(2026, 10, 15, 8, 0, 0, 0, time.UTC)
for i, minute := range []int{3, 6, 9, 12} {
got := nextResultCheck(SubBatch{SubmittedAt: base, PollCount: i})
want := base.Add(time.Duration(minute) * time.Minute)
if !got.Equal(want) {
t.Fatalf("第%d次查询: got=%v want=%v", i+1, got, want)
}
}
}
type durableFake struct {
mu sync.Mutex
batches map[string]SubBatch
next map[string]time.Time
outcomes map[string][]Outcome
preparedBeforeSubmit int
}
func newDurableFake() *durableFake {
return &durableFake{batches: map[string]SubBatch{}, next: map[string]time.Time{}, outcomes: map[string][]Outcome{}}
}
func (f *durableFake) HasDurableParent(context.Context, string) (bool, error) { return true, nil }
func (f *durableFake) PrepareSubBatch(_ context.Context, b SubBatch) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.batches[b.OperationID]; !ok {
b.ID = int64(len(f.batches) + 1)
f.batches[b.OperationID] = b
}
return nil
}
func (f *durableFake) ResumeOrPrepare(ctx context.Context, _ string, p func(context.Context) error) error {
return p(ctx)
}
func (f *durableFake) GetSubBatch(_ context.Context, id string) (*SubBatch, error) {
f.mu.Lock()
defer f.mu.Unlock()
b := f.batches[id]
b.NextCheckAt = f.next[id]
return &b, nil
}
func (f *durableFake) DelaySubBatchPrecheck(_ context.Context, snapshot SubBatch, next time.Time, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
b := f.batches[snapshot.OperationID]
if b.Status == snapshot.Status && b.LeaseToken == snapshot.LeaseToken && f.next[snapshot.OperationID].Equal(snapshot.NextCheckAt) {
f.next[snapshot.OperationID] = next
}
return nil
}
func (f *durableFake) ReleaseUnsentSubBatch(_ context.Context, b SubBatch, _ string) error {
old := f.batches[b.OperationID]
old.Status = b.Status
old.LeaseToken = ""
if b.Status == "prepared" {
old.SubmittedAt = time.Time{}
} else {
old.PollCount--
}
f.batches[b.OperationID] = old
return nil
}
func (f *durableFake) SubBatchChannel(_ context.Context, id string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.batches[id].ChanKey, nil
}
func (f *durableFake) ProtectExecutionFailure(context.Context, string, string) error { return nil }
func (f *durableFake) SaveSubBatchProgress(_ context.Context, b SubBatch, results []Outcome) error {
f.mu.Lock()
defer f.mu.Unlock()
old := f.outcomes[b.OperationID]
if old == nil {
old = make([]Outcome, len(results))
for i := range old {
old[i].Pending = true
}
}
for i, result := range results {
if old[i].Pending && !result.Pending {
old[i] = result
}
}
confirmed := false
for _, result := range old {
confirmed = confirmed || !result.Pending
}
if confirmed {
f.outcomes[b.OperationID] = old
}
return nil
}
func (f *durableFake) ClaimSubBatch(_ context.Context, id, token string, now time.Time) (*SubBatch, error) {
f.mu.Lock()
defer f.mu.Unlock()
b, ok := f.batches[id]
if !ok || b.LeaseToken != "" || b.Status == "done" || b.Status == "review" || f.next[id].After(now) {
return nil, nil
}
out := b
b.LeaseToken = token
out.LeaseToken = token
if b.Status == "prepared" {
b.Status = "submitting"
b.SubmittedAt = now
out.SubmittedAt = now
} else {
b.PollCount++
out.PollCount = b.PollCount
}
f.batches[id] = b
return &out, nil
}
func (f *durableFake) ListDueSubBatches(_ context.Context, now time.Time, limit int) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var ids []string
for id, b := range f.batches {
if b.Status != "done" && b.Status != "review" && !f.next[id].After(now) {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool {
a, b := f.next[ids[i]], f.next[ids[j]]
if a.Equal(b) {
return ids[i] < ids[j]
}
return a.Before(b)
})
if len(ids) > limit {
ids = ids[:limit]
}
return ids, nil
}
func (f *durableFake) UpdateSubBatch(_ context.Context, b SubBatch, status, job, reason string, next time.Time, count int) error {
f.mu.Lock()
defer f.mu.Unlock()
old := f.batches[b.OperationID]
if old.LeaseToken != b.LeaseToken {
return errors.New("lease lost")
}
old.Status = status
old.JobID = job
old.PollCount = count
old.LeaseToken = ""
f.batches[b.OperationID] = old
f.next[b.OperationID] = next
return nil
}
func (f *durableFake) CompleteSubBatch(_ context.Context, b SubBatch, results []Outcome) error {
f.mu.Lock()
defer f.mu.Unlock()
old := f.batches[b.OperationID]
if old.LeaseToken != b.LeaseToken {
return errors.New("lease lost")
}
old.Status = "done"
old.LeaseToken = ""
f.batches[b.OperationID] = old
f.outcomes[b.OperationID] = results
return nil
}
func (f *durableFake) FinalizeReadyBatch(context.Context, string) error { return nil }
func (f *durableFake) ListRecoverableBatches(context.Context, int) ([]string, error) { return nil, nil }
type asyncFake struct {
submitCalls int
queryCalls int
submit func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
query func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
}
func (f *asyncFake) SubmitSimApnFunction(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
f.submitCalls++
return f.submit(in)
}
func (f *asyncFake) QuerySimApnFunctionResult(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
f.queryCalls++
return f.query(in)
}
func fixtureSub() SubBatch {
op, _ := OperationByBatchType(consts.BatchTypeDataStop)
return newSubBatch(&Task{Batch: Batch{BatchNo: "P-1", BatchType: consts.BatchTypeDataStop}, Operation: op, ApnName: "CMIOT"}, []*Detail{{ID: 1, ICCID: "iccid-1", MSISDN: "1001", ChanKey: "a"}, {ID: 2, ICCID: "iccid-2", MSISDN: "1002", ChanKey: "a"}})
}
func pendingResponse(in *model.SimCardApnChangeInput, code int) []model.SimBaseRes[model.SimCardApnChangeOutputItem] {
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
for _, key := range in.SimKeys {
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: code, Message: "结果暂不可查", ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1", Pending: true}})
}
return out
}
func TestDurableAPNRestartDoesNotSubmitAcceptedAgain(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if repo.batches[sub.OperationID].Status != "submitting" {
t.Fatal("提交前未保存意图")
}
return pendingResponse(in, 0), nil
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.ReqData.QueryJobID != "job-1" {
t.Fatalf("查询丢失jobId: %+v", in.ReqData)
}
out := pendingResponse(in, 0)
out[0].ResData.Pending = false
out[1].ResData.Pending = false
out[1].Code = 12047
out[1].Message = "明确失败"
return out, nil
}}
e := &executor{}
before := time.Now()
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if repo.next[sub.OperationID].Before(before.Add(firstResultDelay)) || repo.next[sub.OperationID].After(time.Now().Add(firstResultDelay)) {
t.Fatalf("首查未按firstResultDelay排程: %v", repo.next[sub.OperationID])
}
// A second process must respect next_check_at and never issue a second write.
restarted := &executor{}
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if upstream.queryCalls != 0 {
t.Fatal("过早查询")
}
repo.next[sub.OperationID] = time.Time{}
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if upstream.submitCalls != 1 || upstream.queryCalls != 1 {
t.Fatalf("submit=%d query=%d", upstream.submitCalls, upstream.queryCalls)
}
outcomes := repo.outcomes[sub.OperationID]
if len(outcomes) != 2 || !outcomes[0].Success || outcomes[1].Success {
t.Fatalf("部分失败未逐卡保留: %+v", outcomes)
}
}
func TestDurableAPNTimeoutRecoversMappingWithoutResubmission(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, context.DeadlineExceeded
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if !in.ReqData.RecoverOnly || in.ReqData.QueryJobID != "" || in.ReqData.OperationID != sub.OperationID {
t.Fatalf("不是按operationId恢复: %+v", in.ReqData)
}
return pendingResponse(in, 0), nil
}}
e := &executor{}
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if len(repo.outcomes) != 0 {
t.Fatal("超时不能标记办理失败")
}
repo.next[sub.OperationID] = time.Time{}
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if upstream.submitCalls != 1 || upstream.queryCalls != 1 || repo.batches[sub.OperationID].JobID != "job-1" {
t.Fatalf("受理恢复错误: %+v", repo.batches[sub.OperationID])
}
}
func TestDurableAPNPendingNonzeroStopsAfterQueryWindowWithoutFailure(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
sub.Status = "accepted"
sub.JobID = "job-1"
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return pendingResponse(in, 500), nil
}}
for i := 1; i <= maxResultQueries; i++ {
repo.next[sub.OperationID] = time.Time{}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
b := repo.batches[sub.OperationID]
if b.PollCount != i {
t.Fatalf("poll count=%d want %d", b.PollCount, i)
}
if i < maxResultQueries && b.Status != "accepted" {
t.Fatalf("pending误终态: %+v", b)
}
}
if repo.batches[sub.OperationID].Status != "review" || len(repo.outcomes) != 0 {
t.Fatal("补查窗口耗尽后未确认不得写办理失败")
}
}
func TestDurableAPNMixedResultsPersistAcrossMissingAndReview(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
out := pendingResponse(in, 0)
out[0].ResData.Pending = false
out[0].ResData.OrderNum = "single-order"
return out, nil
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, nil
}}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if got := repo.outcomes[sub.OperationID]; len(got) != 2 || !got[0].Success || !got[1].Pending {
t.Fatalf("混合确认结果未立即保存: %+v", got)
}
base := repo.batches[sub.OperationID].SubmittedAt
for i := 1; i <= maxResultQueries; i++ {
repo.next[sub.OperationID] = time.Time{}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
b := repo.batches[sub.OperationID]
if b.PollCount != i || !b.SubmittedAt.Equal(base) {
t.Fatalf("重启丢失持久时点/次数: %+v", b)
}
if i < maxResultQueries && !repo.next[sub.OperationID].Equal(base.Add(firstResultDelay+time.Duration(i)*furtherResultDelay)) {
t.Fatalf("排程未按submitted_at: %v", repo.next[sub.OperationID])
}
}
if !repo.outcomes[sub.OperationID][0].Success || repo.batches[sub.OperationID].Status != "review" || upstream.submitCalls != 1 {
t.Fatal("后续缺卡覆盖终态或重发")
}
}
func TestDurableAPNMappingRecoveryKeepsConsumedPollCount(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
sub.Status = "uncertain"
sub.PollCount = 2
sub.SubmittedAt = time.Now().Add(-8 * time.Minute)
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if !in.ReqData.RecoverOnly {
t.Fatal("恢复应为只读")
}
return pendingResponse(in, 0), nil
}}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
b := repo.batches[sub.OperationID]
if b.JobID != "job-1" || b.PollCount != 3 || !repo.next[sub.OperationID].Equal(sub.SubmittedAt.Add(firstResultDelay+time.Duration(b.PollCount)*furtherResultDelay)) {
t.Fatalf("恢复jobId清零或重排: %+v next=%v", b, repo.next[sub.OperationID])
}
}
func TestDurableAPNExplicitUncertainAndMissingNeverFailCard(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return []model.SimBaseRes[model.SimCardApnChangeOutputItem]{{SimKey: in.SimKeys[0], Code: 9999, Message: "代理未确认", ResData: model.SimCardApnChangeOutputItem{Uncertain: true}}}, nil
}}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if len(repo.outcomes) != 0 || repo.batches[sub.OperationID].Status != "uncertain" {
t.Fatal("显式未知或缺卡不能转明确失败")
}
}
type capabilityFake struct {
*asyncFake
checkErr error
}
func (f *capabilityFake) CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error {
return f.checkErr
}
type requestNotSent struct{}
func (requestNotSent) Error() string { return "能力预检明确拒绝" }
func (requestNotSent) APNRequestNotSent() bool { return true }
func TestDurableAPNCapabilityRefusalDoesNotClaimOrSpendQueries(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &capabilityFake{asyncFake: &asyncFake{}, checkErr: errors.New("门禁关闭")}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err == nil {
t.Fatal("应拒绝预检")
}
b := repo.batches[sub.OperationID]
if b.Status != "prepared" || b.LeaseToken != "" || !b.SubmittedAt.IsZero() || b.PollCount != 0 || upstream.submitCalls != 0 {
t.Fatalf("预检拒绝却已领取或提交: %+v", b)
}
upstream.checkErr = nil
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return pendingResponse(in, 0), nil
}
if upstream.submitCalls != 0 || !repo.next[sub.OperationID].After(time.Now()) {
t.Fatal("只读预检拒绝未持久退避")
}
repo.next[sub.OperationID] = time.Time{}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if upstream.submitCalls != 1 || repo.batches[sub.OperationID].Status != "accepted" {
t.Fatal("门禁打开且退避到期后不能恢复首发")
}
}
func TestDurableAPNCapabilityRefusalDoesNotBlockReadOnlyQueries(t *testing.T) {
for _, tt := range []struct {
name string
status string
jobID string
recoverOnly bool
}{
{name: "accepted_with_job_id", status: "accepted", jobID: "job-1"},
{name: "submitting", status: "submitting", recoverOnly: true},
{name: "uncertain", status: "uncertain", recoverOnly: true},
{name: "accepted_without_job_id", status: "accepted", recoverOnly: true},
} {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
sub.Status = tt.status
sub.JobID = tt.jobID
sub.SubmittedAt = time.Now().Add(-firstResultDelay)
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
t.Fatal(err)
}
upstream := &capabilityFake{
checkErr: errors.New("门禁关闭"),
asyncFake: &asyncFake{
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
t.Fatal("只读补查不得重新提交")
return nil, nil
},
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.ReqData.OperationID != sub.OperationID || in.ReqData.QueryJobID != tt.jobID || in.ReqData.RecoverOnly != tt.recoverOnly {
t.Fatalf("只读查询参数错误: %+v", in.ReqData)
}
return pendingResponse(in, 0), nil
},
},
}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatalf("只读查询被提交门禁阻断: %v", err)
}
if upstream.queryCalls != 1 || upstream.submitCalls != 0 {
t.Fatalf("应仅查询一次: query=%d submit=%d", upstream.queryCalls, upstream.submitCalls)
}
b := repo.batches[sub.OperationID]
if b.Status != "accepted" || b.JobID != "job-1" || b.PollCount != 1 || b.LeaseToken != "" {
t.Fatalf("只读查询结果未持久化: %+v", b)
}
})
}
}
func TestDurableAPNOnlyTypedUnsentCanReturnPrepared(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, fmt.Errorf("预检竞态: %w", requestNotSent{})
}}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
b := repo.batches[sub.OperationID]
if b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.PollCount != 0 {
t.Fatalf("确定未发送应安全回退: %+v", b)
}
upstream.submit = func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, context.DeadlineExceeded
}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if repo.batches[sub.OperationID].Status != "uncertain" {
t.Fatal("普通网络错误被误作确定未发送")
}
}
func TestDurableAPNFinishedFailure9999IsTerminal(t *testing.T) {
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
sub.Status = "accepted"
sub.JobID = "failed-job"
sub.SubmittedAt = time.Now().Add(-time.Minute)
_ = repo.PrepareSubBatch(ctx, sub)
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
for _, key := range in.SimKeys {
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 9999, Message: "任务已完成且明确失败"})
}
return out, nil
}}
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
t.Fatal(err)
}
if repo.batches[sub.OperationID].Status != "done" || len(repo.outcomes[sub.OperationID]) != 2 {
t.Fatal("明确9999被误判待查")
}
for _, out := range repo.outcomes[sub.OperationID] {
if out.Pending || out.Success || out.Code != 9999 {
t.Fatalf("明确失败终态错误: %+v", out)
}
}
}
func TestDurableAPNRealClientSecondProbeRejectsThenSubmitsOnce(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
ctx := context.Background()
repo := newDurableFake()
sub := fixtureSub()
_ = repo.PrepareSubBatch(ctx, sub)
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in model.SimCardApnChangeInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
t.Error(err)
http.Error(w, "bad", 400)
return
}
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
if probes == 2 {
http.Error(w, "第二次探测拒绝", 404)
return
}
type capability struct {
ProtocolVersion int `json:"protocolVersion"`
}
out := make([]model.SimBaseRes[capability], 0, len(in.SimKeys))
for _, key := range in.SimKeys {
out = append(out, model.SimBaseRes[capability]{SimKey: key, ResData: capability{ProtocolVersion: 1}})
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
return
}
submits++
out := pendingResponse(&in, 0)
for i := range out {
out[i].ResData.Pending = false
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
client := upstreamclient.New()
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
t.Fatal(err)
}
b := repo.batches[sub.OperationID]
if probes != 2 || submits != 0 || b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.LeaseToken != "" {
t.Fatalf("双probe竞态烧掉意图: probes=%d submits=%d sub=%+v", probes, submits, b)
}
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
t.Fatal(err)
}
if submits != 1 || repo.batches[sub.OperationID].Status != "done" {
t.Fatalf("门禁恢复未唯一首发: submits=%d sub=%+v", submits, repo.batches[sub.OperationID])
}
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
t.Fatal(err)
}
if submits != 1 {
t.Fatal("完成后重复首发")
}
}
func TestAsyncSubBatchStableAndChannelIsolated(t *testing.T) {
op, _ := OperationByBatchType(consts.BatchTypeDataStart)
task := &Task{Batch: Batch{BatchNo: "P-2", BatchType: consts.BatchTypeDataStart}, Operation: op}
groups := map[string][]*Detail{"a": {}, "b": {}}
for i := 0; i < 203; i++ {
key := "a"
if i >= 201 {
key = "b"
}
groups[key] = append(groups[key], &Detail{ID: int64(i + 1), ICCID: fmt.Sprint(i), MSISDN: fmt.Sprint(i), ChanKey: key})
}
total := 0
for key, details := range groups {
for _, chunk := range splitAsyncDetails(details) {
if len(chunk) > 100 {
t.Fatal("超过100卡")
}
first := newSubBatch(task, chunk)
second := newSubBatch(task, chunk)
if first.OperationID != second.OperationID {
t.Fatal("operationId不稳定")
}
for _, sim := range first.Request.SimKeys {
if sim.ChanKey != key {
t.Fatal("跨通道子批")
}
}
total++
}
}
if total != 5 {
t.Fatalf("sub batches=%d want5", total)
}
}
+93 -19
View File
@@ -9,13 +9,34 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
type executor struct {
repository Repository
upstream Upstream
repository Repository
upstream Upstream
channelSlots sync.Map
}
func (e *executor) ClaimsProcessingReentry() bool { return true }
func (e *executor) CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error) {
repo, durable := e.repository.(SubBatchRepository)
_, async := e.upstream.(AsyncUpstream)
if !durable || !async {
return false, nil
}
return repo.HasDurableParent(ctx, strings.TrimSpace(main.BatchNo))
}
func (e *executor) ProtectExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) (bool, error) {
repo, durable := e.repository.(SubBatchRepository)
_, async := e.upstream.(AsyncUpstream)
if !durable || !async {
return false, nil
}
return true, repo.ProtectExecutionFailure(ctx, batchNo, reason)
}
func NewExecutor(repository Repository, upstream Upstream) Executor {
@@ -23,6 +44,18 @@ func NewExecutor(repository Repository, upstream Upstream) Executor {
}
func (e *executor) Execute(ctx context.Context, batchNo string) error {
if durable, ok := e.repository.(SubBatchRepository); ok {
if _, ok := e.upstream.(AsyncUpstream); ok {
// Earlier child intents were durably frozen: a re-delivered parent cannot re-split running rows.
return batch.Retryable(durable.ResumeOrPrepare(ctx, strings.TrimSpace(batchNo), func(prepareCtx context.Context) error {
return e.executeFresh(prepareCtx, batchNo)
}))
}
}
return e.executeFresh(ctx, batchNo)
}
func (e *executor) executeFresh(ctx context.Context, batchNo string) error {
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
if err != nil {
return gerror.Wrap(err, "load data service batch")
@@ -34,6 +67,11 @@ func (e *executor) Execute(ctx context.Context, batchNo string) error {
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
return gerror.Wrap(err, "save data service validation failure")
}
if _, durable := e.repository.(SubBatchRepository); durable {
if _, async := e.upstream.(AsyncUpstream); async {
return nil
}
}
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize data service validation failure")
}
@@ -45,6 +83,11 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error {
if err := e.repository.SaveValidationFailure(ctx, task); err != nil {
return gerror.Wrap(err, "save traffic cutoff validation failure")
}
if _, durable := e.repository.(SubBatchRepository); durable {
if _, async := e.upstream.(AsyncUpstream); async {
return nil
}
}
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize traffic cutoff validation failure")
}
if err := e.refreshDataServiceState(ctx, task); err != nil {
@@ -53,6 +96,12 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error {
if err := e.saveInvalidTrafficCutoffRows(ctx, task); err != nil {
return err
}
if _, durable := e.repository.(SubBatchRepository); durable {
if _, async := e.upstream.(AsyncUpstream); async {
// 本地disabled不能证明前序开通已终结;持久化路径统一经过卡级提交屏障。
return e.executeValidated(ctx, task)
}
}
if err := e.saveIdempotentCutoffSkips(ctx, task); err != nil {
return err
}
@@ -60,6 +109,26 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error {
}
func (e *executor) executeValidated(ctx context.Context, task *Task) error {
if durable, ok := e.repository.(SubBatchRepository); ok {
if async, ok := e.upstream.(AsyncUpstream); ok {
seen := map[string]bool{}
for _, detail := range task.Details {
if detail == nil || detail.Reason != "" || (detail.DetailStatus != consts.BatchDetailStatusPending && detail.DetailStatus != consts.BatchDetailStatusRunning) {
continue
}
if seen[detail.ICCID] {
resolved, err := e.repository.SaveResult(ctx, task, detail, Outcome{Message: "重复ICCID,未提交上游"})
if err != nil {
return err
}
detail.ApplyOutcome(resolved)
continue
}
seen[detail.ICCID] = true
}
return e.prepareAndSubmit(ctx, task, durable, async)
}
}
groups := groupByChanKey(task.Operation, task.Details)
keys := make([]string, 0, len(groups))
for chanKey := range groups {
@@ -96,7 +165,7 @@ func (e *executor) saveIdempotentCutoffSkips(ctx context.Context, task *Task) er
}
resolved, saveErr := e.repository.SaveResult(ctx, task, detail, Outcome{
Success: true,
Message: "data service is already disabled; upstream request was skipped",
Message: "数据服务已关闭,未发起上游请求",
})
if saveErr != nil {
return gerror.Wrapf(saveErr, "save data service detail %d idempotent skip result", detail.ID)
@@ -108,6 +177,9 @@ func (e *executor) saveIdempotentCutoffSkips(ctx context.Context, task *Task) er
func validateTask(task *Task) bool {
if task == nil || len(task.Details) == 0 {
if task != nil && task.FailureReason == "" {
task.FailureReason = "数据开停批次没有有效明细"
}
return false
}
hasFailure := false
@@ -132,17 +204,17 @@ func validateTask(task *Task) bool {
}
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
detail.Reason = "same batch contains validation failure; upstream request was skipped"
detail.Reason = "同批次存在校验失败卡,本卡未请求上游"
}
}
task.FailureReason = "batch validation failed; upstream request was skipped"
task.FailureReason = "批次校验未通过,未发起上游请求"
return false
}
func validateTrafficCutoffTask(task *Task) bool {
if task == nil || len(task.Details) == 0 {
if task != nil {
task.FailureReason = "traffic cutoff batch has no valid details"
task.FailureReason = "流量断网批次无有效明细"
}
return false
}
@@ -160,13 +232,13 @@ func validateTrafficCutoffTask(task *Task) bool {
if hasSuccess {
task.ValidationOK = true
if failed := countFailedDetails(task); failed > 0 {
task.FailureReason = strconv.Itoa(failed) + " SIM cards failed validation"
task.FailureReason = strconv.Itoa(failed) + " 张卡未通过校验"
} else {
task.FailureReason = ""
}
return true
}
task.FailureReason = "traffic cutoff batch validation failed; upstream request was skipped"
task.FailureReason = "流量断网批次校验未通过,未发起上游请求"
return false
}
@@ -188,7 +260,7 @@ func validateDetail(_ Operation, detail *Detail) {
return
}
if detail.Card == nil {
detail.Reason = "ICCID does not exist in SIM card records"
detail.Reason = "ICCID不存在或不属于当前租户"
return
}
detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN)
@@ -198,26 +270,28 @@ func validateDetail(_ Operation, detail *Detail) {
detail.StatusAfter = detail.Card.DataServiceStatus
if !detail.Authorized {
detail.Reason = "ICCID is outside the current operator authorization scope"
detail.Reason = "ICCID不在当前操作员授权范围内"
return
}
var reasons []string
if detail.ChanKey == "" {
detail.Reason = "ICCID is missing chanKey"
return
reasons = append(reasons, "ICCID缺少通道标识")
}
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
detail.Reason = "MSISDN does not match the SIM card record"
return
reasons = append(reasons, "MSISDN与卡档案不一致")
}
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
detail.Reason = "IMSI does not match the SIM card record"
return
reasons = append(reasons, "IMSI与卡档案不一致")
}
detail.Reason = strings.Join(reasons, ";")
}
func groupByChanKey(operation Operation, details []*Detail) map[string][]*Detail {
groups := make(map[string][]*Detail)
for _, detail := range details {
if detail == nil || detail.Reason != "" {
continue
}
if operation.BatchType == consts.BatchTypeDataCut && (detail == nil || !detail.ValidationPassed || detail.Reason != "") {
continue
}
@@ -316,7 +390,7 @@ func (e *executor) changeSubBatch(ctx context.Context, task *Task, details []*De
if err != nil {
outcomes := make([]Outcome, len(details))
for index := range outcomes {
outcomes[index].Message = "upstream request failed: " + err.Error()
outcomes[index].Message = "请求上游失败:" + err.Error()
}
return outcomes
}
@@ -354,14 +428,14 @@ func mapOutcomes(details []*Detail, response []model.SimBaseRes[model.SimCardApn
matched = detailIndex
}
if matched < 0 {
outcomes[detailIndex] = Outcome{Message: "upstream did not return a result for this SIM card"}
outcomes[detailIndex] = Outcome{Message: "上游未返回该卡处理结果"}
continue
}
used[matched] = true
item := response[matched]
message := strings.TrimSpace(item.Message)
if item.Code != 0 && message == "" {
message = "upstream processing failed"
message = "上游处理失败"
}
outcomes[detailIndex] = Outcome{Success: item.Code == 0, Code: item.Code, Message: message, OrderNum: strings.TrimSpace(item.ResData.OrderNum)}
}
@@ -0,0 +1,253 @@
package data
import (
"context"
"errors"
"fmt"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
func TestAccuracyInvalidDuplicateKeepsValidTrafficCutoffRow(t *testing.T) {
for _, invalidFirst := range []bool{true, false} {
for _, invalidInput := range []string{"MSISDN", "IMSI", "authorization", "existing"} {
t.Run(fmt.Sprintf("%s/invalidFirst=%v", invalidInput, invalidFirst), func(t *testing.T) {
operation, err := OperationByBatchType(107)
if err != nil {
t.Fatal(err)
}
card := &Card{ID: 10, ICCID: "same-card", MSISDN: "1001", IMSI: "2001", ChanKey: "channel", DataServiceStatus: 1}
invalid := &Detail{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card}
var wantReason string
switch invalidInput {
case "MSISDN":
invalid.InputMSISDN = "wrong"
wantReason = "MSISDN与卡档案不一致"
case "IMSI":
invalid.InputIMSI = "wrong"
wantReason = "IMSI与卡档案不一致"
case "authorization":
invalid.Authorized = false
wantReason = "ICCID不在当前操作员授权范围内"
case "existing":
wantReason = "导入行格式校验失败"
invalid.Reason = wantReason
}
valid := &Detail{ID: 2, ICCID: card.ICCID, Authorized: true, Card: card}
otherInvalid := &Detail{ID: 3, ICCID: card.ICCID, Reason: "该行独立校验失败"}
duplicate := &Detail{ID: 4, ICCID: card.ICCID, Authorized: true, Card: card}
details := []*Detail{invalid, valid, otherInvalid, duplicate}
if !invalidFirst {
details[0], details[1] = details[1], details[0]
}
task := &Task{Batch: Batch{BatchNo: "ACCURACY-CUT", BatchType: 107}, Operation: operation, ApnName: "CMIOT", Details: details}
base := &accuracyRepository{task: task, saved: map[int64]Outcome{}}
repo := &accuracyDurableRepository{accuracyRepository: base}
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
t.Fatal(err)
}
if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != valid.ID {
t.Fatalf("有效行未独立冻结: %+v", repo.prepared)
}
if valid.Reason != "" || valid.DetailStatus == consts.BatchDetailStatusFailed {
t.Fatalf("有效行被无效重复行阻断: %+v", valid)
}
for _, check := range []struct {
detail *Detail
reason string
}{{invalid, wantReason}, {otherInvalid, "该行独立校验失败"}, {duplicate, "重复ICCID,未提交上游"}} {
outcome, ok := base.saved[check.detail.ID]
if !ok || outcome.Success || outcome.Message != check.reason || check.detail.Reason != check.reason {
t.Fatalf("逐行原因被改写: id=%d detail=%+v outcome=%+v", check.detail.ID, check.detail, outcome)
}
}
if repo.prepared[0].Request.ReqData.OperType != "1" || operation.TargetStatus != 0 {
t.Fatalf("107开停方向倒置: %+v", repo.prepared[0])
}
})
}
}
}
func TestAccuracyFrozenRequestsKeepAPNDirection(t *testing.T) {
for _, tc := range []struct {
batchType int
operType string
target int
}{{107, "1", 0}, {109, "0", 1}, {110, "1", 0}} {
t.Run(fmt.Sprint(tc.batchType), func(t *testing.T) {
operation, err := OperationByBatchType(tc.batchType)
if err != nil {
t.Fatal(err)
}
card := &Card{ID: 10, ICCID: "card", MSISDN: "1001", ChanKey: "channel", DataServiceStatus: 1 - tc.target}
task := &Task{
Batch: Batch{BatchNo: "ACCURACY-DIRECTION", BatchType: tc.batchType}, Operation: operation, ApnName: "CMIOT",
Details: []*Detail{{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card}, {ID: 2, ICCID: card.ICCID, Authorized: true, Card: card}},
}
repo := &accuracyDurableRepository{accuracyRepository: &accuracyRepository{task: task, saved: map[int64]Outcome{}}}
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
t.Fatal(err)
}
if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != 1 {
t.Fatalf("同ICCID未只保留首个有效行: %+v", repo.prepared)
}
if operation.TargetStatus != tc.target || repo.prepared[0].Request.ReqData.OperType != tc.operType {
t.Fatalf("批次%d开停方向错误: operation=%+v request=%+v", tc.batchType, operation, repo.prepared[0].Request)
}
})
}
}
func TestAccuracyEmptyBatchKeepsValidationReason(t *testing.T) {
for _, batchType := range []int{107, 109, 110} {
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
operation, err := OperationByBatchType(batchType)
if err != nil {
t.Fatal(err)
}
task := &Task{Batch: Batch{BatchNo: "ACCURACY-EMPTY", BatchType: batchType}, Operation: operation}
repo := &accuracyRepository{task: task}
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
t.Fatal(err)
}
if !repo.validationSaved || !repo.finalized || task.ValidationOK || task.FailureReason == "" {
t.Fatalf("空明细校验失败原因缺失: task=%+v saved=%v finalized=%v", task, repo.validationSaved, repo.finalized)
}
})
}
}
type accuracyRepository struct {
task *Task
saved map[int64]Outcome
validationSaved bool
finalized bool
}
func (r *accuracyRepository) Load(context.Context, string) (*Task, error) { return r.task, nil }
func (r *accuracyRepository) SaveValidationFailure(context.Context, *Task) error {
r.validationSaved = true
return nil
}
func (r *accuracyRepository) SaveResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome) (Outcome, error) {
r.saved[detail.ID] = outcome
return outcome, nil
}
func (r *accuracyRepository) Finalize(context.Context, *Task) error {
r.finalized = true
return nil
}
type accuracyDurableRepository struct {
*accuracyRepository
SubBatchRepository
prepared []SubBatch
}
func (r *accuracyDurableRepository) ResumeOrPrepare(ctx context.Context, _ string, prepare func(context.Context) error) error {
return prepare(ctx)
}
func (r *accuracyDurableRepository) PrepareSubBatch(_ context.Context, sub SubBatch) error {
r.prepared = append(r.prepared, sub)
return nil
}
func TestAccuracyCanExecuteProcessingRequiresExistingParent(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
parent bool
readErr error
wantOK bool
wantErr bool
}{
{name: "旧Processing无凭据不可接管"},
{name: "新批次ready0已持久化凭据", parent: true, wantOK: true},
{name: "缺少迁移查询失败安全拒绝", readErr: errors.New("no such table: data_operation_parent"), wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: &accuracyRepository{}}, present: tc.parent, readErr: tc.readErr}
e := NewExecutor(repo, &accuracyNoUpstream{t: t}).(*executor)
ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: " APN-EXISTING "})
if ok != tc.wantOK || (err != nil) != tc.wantErr || repo.lookups != 1 || repo.batchNo != "APN-EXISTING" {
t.Fatalf("parent gate ok=%v err=%v lookups=%d batchNo=%q", ok, err, repo.lookups, repo.batchNo)
}
})
}
base := &accuracyRepository{}
for _, tc := range []struct {
name string
repo Repository
upstream Upstream
}{
{name: "缺少持久仓储", repo: base, upstream: &accuracyNoUpstream{t: t}},
{name: "仅同步上游", repo: &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: base}}, upstream: &accuracySyncOnlyUpstream{}},
} {
t.Run(tc.name, func(t *testing.T) {
e := NewExecutor(tc.repo, tc.upstream).(*executor)
ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: "APN-EXISTING"})
if ok || err != nil {
t.Fatalf("不支持持久异步不得接管Processing: ok=%v err=%v", ok, err)
}
})
}
}
func TestAccuracyDurableRepositoryWithSyncUpstreamFinalizesValidationFailure(t *testing.T) {
for _, batchType := range []int{107, 109, 110} {
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
operation, err := OperationByBatchType(batchType)
if err != nil {
t.Fatal(err)
}
task := &Task{Batch: Batch{BatchNo: "SYNC-FAILED", BatchType: batchType}, Operation: operation}
base := &accuracyRepository{task: task}
repo := &accuracyDurableRepository{accuracyRepository: base}
if err := NewExecutor(repo, &accuracySyncOnlyUpstream{}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
t.Fatal(err)
}
if !base.validationSaved || !base.finalized || task.FailureReason == "" {
t.Fatalf("同步兜底遗漏校验失败父批: saved=%v finalized=%v task=%+v", base.validationSaved, base.finalized, task)
}
})
}
}
type accuracyParentRepository struct {
*accuracyDurableRepository
present bool
readErr error
lookups int
batchNo string
}
func (r *accuracyParentRepository) HasDurableParent(_ context.Context, batchNo string) (bool, error) {
r.lookups++
r.batchNo = batchNo
return r.present, r.readErr
}
type accuracySyncOnlyUpstream struct{}
func (*accuracySyncOnlyUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
panic("校验失败不得发运营商请求")
}
type accuracyNoUpstream struct{ t *testing.T }
func (u *accuracyNoUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
u.t.Fatal("该回归不得调用同步上游")
return nil, nil
}
func (u *accuracyNoUpstream) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
u.t.Fatal("该回归只冻结意图,不得提交上游")
return nil, nil
}
func (u *accuracyNoUpstream) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
u.t.Fatal("该回归不得查询上游")
return nil, nil
}
+20 -1
View File
@@ -211,7 +211,7 @@ func TestExecuteTrafficCutoffValidationFailureAllowsValidRows(t *testing.T) {
if len(repo.saved) != 2 {
t.Fatalf("saved count = %d, want 2; saved = %+v", len(repo.saved), repo.saved)
}
if repo.saved[0].Success || repo.saved[0].Message != "ICCID is outside the current operator authorization scope" {
if repo.saved[0].Success || repo.saved[0].Message != "ICCID不在当前操作员授权范围内" {
t.Fatalf("invalid row result = %+v", repo.saved[0])
}
if !repo.saved[1].Success {
@@ -522,3 +522,22 @@ func (u *fakeUpstream) ChangeSimApnFunction(_ context.Context, input *model.SimC
u.input = input
return u.response, u.responseError
}
func TestValidateDetailCollectsIndependentDiagnostics(t *testing.T) {
detail := &Detail{ICCID: "card", Authorized: true, InputMSISDN: "wrong-phone", InputIMSI: "wrong-imsi", Card: &Card{MSISDN: "phone", IMSI: "imsi"}}
validateDetail(Operation{}, detail)
want := "ICCID缺少通道标识;MSISDN与卡档案不一致;IMSI与卡档案不一致"
if detail.Reason != want {
t.Fatalf("独立校验原因丢失: got=%q want=%q", detail.Reason, want)
}
unauthorized := &Detail{ICCID: "card", InputMSISDN: "wrong", Card: &Card{MSISDN: "phone"}}
validateDetail(Operation{}, unauthorized)
if unauthorized.Reason != "ICCID不在当前操作员授权范围内" {
t.Fatalf("越权卡不应返回其他数据差异: %q", unauthorized.Reason)
}
existing := &Detail{Reason: "原始输入格式错误", Authorized: true, Card: &Card{}}
validateDetail(Operation{}, existing)
if existing.Reason != "原始输入格式错误" {
t.Fatal("原始原因被覆盖")
}
}
+65 -1
View File
@@ -3,6 +3,7 @@ package data
import (
"context"
"strings"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
@@ -39,6 +40,7 @@ type Card struct {
type Detail struct {
ID int64
DetailStatus int
RowNo int
ICCID string
InputMSISDN string
@@ -59,6 +61,7 @@ type Detail struct {
}
type Outcome struct {
Pending bool
Success bool
Code int
Message string
@@ -76,6 +79,11 @@ type Task struct {
func (d *Detail) ApplyOutcome(outcome Outcome) {
d.OrderNum = strings.TrimSpace(outcome.OrderNum)
if outcome.Success {
d.DetailStatus = 2
} else {
d.DetailStatus = 3
}
d.UpstreamCode = outcome.Code
d.UpstreamReason = strings.TrimSpace(outcome.Message)
if outcome.Success {
@@ -83,7 +91,7 @@ func (d *Detail) ApplyOutcome(outcome Outcome) {
return
}
if d.UpstreamReason == "" {
d.UpstreamReason = "upstream processing failed"
d.UpstreamReason = "上游处理失败"
}
d.Reason = d.UpstreamReason
}
@@ -138,10 +146,66 @@ type Repository interface {
Finalize(ctx context.Context, task *Task) error
}
// SubBatch is a durable, channel-isolated intent; Request is frozen before submission.
type SubBatch struct {
ID int64
BatchNo string
BatchType int
ChanKey string
OperationID string
DetailIDs []int64
Request model.SimCardApnChangeInput
JobID string
Status string
PollCount int
SubmittedAt time.Time
LeaseToken string
LeaseUntil time.Time
NextCheckAt time.Time
}
// SubBatchRepository keeps the synchronous legacy tests separate from durable scheduling.
type SubBatchRepository interface {
HasDurableParent(ctx context.Context, batchNo string) (bool, error)
PrepareSubBatch(ctx context.Context, batch SubBatch) error
ResumeOrPrepare(ctx context.Context, batchNo string, prepare func(context.Context) error) error
ClaimSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error)
ListDueSubBatches(ctx context.Context, now time.Time, limit int) ([]string, error)
GetSubBatch(ctx context.Context, operationID string) (*SubBatch, error)
DelaySubBatchPrecheck(ctx context.Context, snapshot SubBatch, next time.Time, reason string) error
ReleaseUnsentSubBatch(ctx context.Context, batch SubBatch, reason string) error
SubBatchChannel(ctx context.Context, operationID string) (string, error)
UpdateSubBatch(ctx context.Context, batch SubBatch, status, jobID, reason string, next time.Time, pollCount int) error
CompleteSubBatch(ctx context.Context, batch SubBatch, outcomes []Outcome) error
SaveSubBatchProgress(ctx context.Context, batch SubBatch, outcomes []Outcome) error
ProtectExecutionFailure(ctx context.Context, batchNo, reason string) error
FinalizeReadyBatch(ctx context.Context, batchNo string) error
ListRecoverableBatches(ctx context.Context, limit int) ([]string, error)
}
type Upstream interface {
ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
}
// AsyncUpstream provides an explicit submit/recovery/query protocol: never resend a write request.
type AsyncUpstream interface {
SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
}
type ReviewRepository interface {
ClaimReviewSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error)
}
// ReviewBatchRepository 支撑"手动同步上游结果":按父批找出待核查子批并只读补查,
// 不重发办理、不重置自动补查计数。
type ReviewBatchRepository interface {
ListReviewSubBatches(ctx context.Context, batchNo string) ([]string, error)
FinalizeReadyBatch(ctx context.Context, batchNo string) error
}
type Executor interface {
Execute(ctx context.Context, batchNo string) error
QueryReview(ctx context.Context, operationID string) error
QueryReviewBatch(ctx context.Context, batchNo string) (int, error)
}