chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
package batch
import (
"context"
"errors"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
const (
Type3 = 3
Type4 = 4
Type11 = 11
Type12 = 12
Type40 = 40
Type41 = 41
Type101 = 101
Type102 = 102
Type103 = 103
Type104 = 104
Type105 = 105
Type106 = 106
Type107 = 107
Type108 = 108
Type109 = 109
Type110 = 110
AsyncStatusPending = 1
AsyncStatusProcessing = 2
AsyncStatusSuccess = 3
AsyncStatusFailed = 4
RunResultRunning = 1
RunResultSuccess = 2
RunResultFailed = 3
DetailStatusPending = 0
DetailStatusRunning = 1
DetailStatusSuccess = 2
DetailStatusFailed = 3
)
type Message struct {
BatchNo string `json:"batchNo"`
BatchType int `json:"batchType"`
FailureReason string `json:"failureReason,omitempty"`
Retryable bool `json:"retryable,omitempty"`
}
// RetryableError marks an executor failure that may succeed after delayed redelivery.
// The batch remains in processing until retries are exhausted and finalized.
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string {
if e == nil || e.Err == nil {
return "retryable batch execution error"
}
return e.Err.Error()
}
func (e *RetryableError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func Retryable(err error) error {
if err == nil {
return nil
}
if IsRetryable(err) {
return err
}
return &RetryableError{Err: err}
}
func IsRetryable(err error) bool {
var retryableErr *RetryableError
return errors.As(err, &retryableErr)
}
// FailureMarkError 表示执行已结束,但失败终态尚未成功写入数据库。
// 消费者重投这类消息时只能补写失败终态,不能再次执行具体业务。
type FailureMarkError struct {
Reason string
Err error
}
func (e *FailureMarkError) Error() string {
return "mark batch failed after executor error: " + e.Reason + ": " + e.Err.Error()
}
func (e *FailureMarkError) Unwrap() error {
return e.Err
}
type Executor interface {
Execute(ctx context.Context, batchNo string) error
}
type ProcessingExecutor interface {
CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error)
}
// SelfClaimingProcessingExecutor marks executors that perform their own
// processing-state CAS before executing a re-entry phase.
type SelfClaimingProcessingExecutor interface {
ClaimsProcessingReentry() bool
}
// DurableFailureHandler takes ownership before any generic MarkFailed fallback.
type DurableFailureHandler interface {
ProtectExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) (bool, error)
}
type ExecutionFailureHandler interface {
HandleExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) error
}
type ProcessingClaimer interface {
ReclaimProcessing(ctx context.Context, batchNo string, batchType int, startedAt *gtime.Time) (bool, error)
}
type Dispatcher struct {
executors map[int]Executor
}
func NewDispatcher(executor Executor) *Dispatcher {
return &Dispatcher{executors: map[int]Executor{
Type103: executor,
Type104: executor,
}}
}
func (d *Dispatcher) Register(batchType int, executor Executor) {
if d == nil || executor == nil {
return
}
d.executors[batchType] = executor
}
func (d *Dispatcher) Executor(batchType int) (Executor, bool) {
if d == nil {
return nil, false
}
executor, ok := d.executors[batchType]
return executor, ok && executor != nil
}
func (d *Dispatcher) Has(batchType int) bool {
_, ok := d.Executor(batchType)
return ok
}
+574
View File
@@ -0,0 +1,574 @@
package batch
import (
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
type executorStub struct {
calls int
batchNo string
err error
}
func (s *executorStub) Execute(_ context.Context, batchNo string) error {
s.calls++
s.batchNo = batchNo
return s.err
}
type processingExecutorStub struct {
executorStub
canCalls int
main *entity.BatchMain
can bool
canErr error
selfClaim bool
}
type blockingExecutorStub struct {
calls int
}
func (s *blockingExecutorStub) Execute(ctx context.Context, _ string) error {
s.calls++
<-ctx.Done()
return ctx.Err()
}
func (s *processingExecutorStub) CanExecuteProcessing(_ context.Context, main *entity.BatchMain) (bool, error) {
s.canCalls++
s.main = main
return s.can, s.canErr
}
func (s *processingExecutorStub) ClaimsProcessingReentry() bool {
return s.selfClaim
}
type repositoryStub struct {
mains []*entity.BatchMain
findErr error
markErr error
claimed bool
findCalls int
markCalls int
markBatchNo string
markType int
failErr error
failCalls int
failBatchNo string
failType int
failReason string
successErr error
successCalls int
successBatchNo string
successType int
successCount int
successFails int
successReason string
reclaimErr error
reclaimSet bool
reclaimed bool
reclaimCalls int
reclaimBatchNo string
reclaimType int
}
func (s *repositoryStub) FindMain(context.Context, string) (*entity.BatchMain, error) {
s.findCalls++
if s.findErr != nil {
return nil, s.findErr
}
if len(s.mains) == 0 {
return nil, nil
}
index := s.findCalls - 1
if index >= len(s.mains) {
index = len(s.mains) - 1
}
return s.mains[index], nil
}
func (s *repositoryStub) MarkProcessing(_ context.Context, batchNo string, batchType int) (bool, error) {
s.markCalls++
s.markBatchNo = batchNo
s.markType = batchType
return s.claimed, s.markErr
}
func (s *repositoryStub) MarkFailed(_ context.Context, batchNo string, batchType int, reason string) (bool, error) {
s.failCalls++
s.failBatchNo = batchNo
s.failType = batchType
s.failReason = reason
return s.failErr == nil, s.failErr
}
func (s *repositoryStub) MarkSuccess(_ context.Context, batchNo string, batchType, success, fail int, reason string) (bool, error) {
s.successCalls++
s.successBatchNo = batchNo
s.successType = batchType
s.successCount = success
s.successFails = fail
s.successReason = reason
return s.successErr == nil, s.successErr
}
func (s *repositoryStub) ReclaimProcessing(_ context.Context, batchNo string, batchType int, _ *gtime.Time) (bool, error) {
s.reclaimCalls++
s.reclaimBatchNo = batchNo
s.reclaimType = batchType
if !s.reclaimSet {
return s.reclaimErr == nil, s.reclaimErr
}
return s.reclaimed, s.reclaimErr
}
type durableExecutorStub struct {
execute func(context.Context) error
protectErr error
protectCalls int
}
func (s *durableExecutorStub) Execute(ctx context.Context, _ string) error {
if s.execute != nil {
return s.execute(ctx)
}
return nil
}
func (s *durableExecutorStub) ProtectExecutionFailure(ctx context.Context, _ string, _ int, _ string) (bool, error) {
s.protectCalls++
if ctx.Err() != nil {
return true, ctx.Err()
}
return true, s.protectErr
}
func TestRuntimeDurableOwnsEveryFailurePath(t *testing.T) {
for _, kind := range []string{"error", "deadline-error", "deadline-nil", "failure-reason", "retry-exhausted", "protect-db-down"} {
t.Run(kind, func(t *testing.T) {
repo := &repositoryStub{mains: []*entity.BatchMain{{BatchNo: "DURABLE", BatchType: Type109, BatchAsyncStatus: AsyncStatusPending}}, claimed: true}
exe := &durableExecutorStub{}
msg := Message{BatchNo: "DURABLE", BatchType: Type109}
switch kind {
case "error":
exe.execute = func(context.Context) error { return errors.New("执行中断") }
case "deadline-error":
exe.execute = func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }
case "deadline-nil":
exe.execute = func(ctx context.Context) error { <-ctx.Done(); return nil }
case "failure-reason", "retry-exhausted":
msg.FailureReason = "重试耗尽或失败重投"
repo.mains[0].BatchAsyncStatus = AsyncStatusProcessing
case "protect-db-down":
exe.execute = func(context.Context) error { return errors.New("执行中断") }
exe.protectErr = errors.New("数据库不可用")
}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type109, exe)
err := NewRuntime(repo, dispatcher, WithExecutionTimeout(time.Millisecond)).Handle(context.Background(), msg)
if kind == "protect-db-down" {
var failure *FailureMarkError
if !errors.As(err, &failure) {
t.Fatalf("保护失败应可重投: %v", err)
}
} else if err != nil {
t.Fatal(err)
}
if exe.protectCalls != 1 || repo.failCalls != 0 {
t.Fatalf("通用MarkFailed越过durable保护: protect=%d failed=%d", exe.protectCalls, repo.failCalls)
}
})
}
}
func TestMessageJSONContract(t *testing.T) {
body, err := json.Marshal(Message{BatchNo: "BATCH-1", BatchType: Type103})
if err != nil {
t.Fatalf("marshal message: %v", err)
}
const expected = `{"batchNo":"BATCH-1","batchType":103}`
if string(body) != expected {
t.Fatalf("message JSON = %s, want %s", body, expected)
}
}
func TestDispatcherSupportsLifecycleTypesAndExplicitRegistration(t *testing.T) {
executor := &executorStub{}
dispatcher := NewDispatcher(executor)
for _, batchType := range []int{Type103, Type104} {
actual, ok := dispatcher.Executor(batchType)
if !ok || actual != executor {
t.Fatalf("batch type %d was not registered with shared executor", batchType)
}
}
if _, ok := dispatcher.Executor(102); ok {
t.Fatal("unexpected executor for unsupported batch type")
}
inbound := &executorStub{}
dispatcher.Register(1, inbound)
actual, ok := dispatcher.Executor(1)
if !ok || actual != inbound {
t.Fatal("registered inbound executor was not returned")
}
dataExecutor := &executorStub{}
dispatcher.Register(Type107, dataExecutor)
dispatcher.Register(Type109, dataExecutor)
dispatcher.Register(Type110, dataExecutor)
for _, batchType := range []int{Type107, Type109, Type110} {
actual, ok = dispatcher.Executor(batchType)
if !ok || actual != dataExecutor {
t.Fatalf("batch type %d was not registered with the data service executor", batchType)
}
}
}
func TestRuntimeAcknowledgesInvalidAndUnclaimableMessages(t *testing.T) {
g.Log().SetWriter(io.Discard)
tests := []struct {
name string
message Message
mains []*entity.BatchMain
claimed bool
wantFind int
wantMarked int
}{
{name: "empty batch number", message: Message{BatchType: Type103}},
{name: "blank batch number", message: Message{BatchNo: " ", BatchType: Type103}},
{name: "unsupported type", message: Message{BatchNo: "B-1", BatchType: 102}},
{name: "not found", message: Message{BatchNo: "B-1", BatchType: Type103}, wantFind: 1},
{name: "type mismatch", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}}, wantFind: 1},
{name: "processing", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 1},
{name: "terminal", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: 3}}, wantFind: 1},
{name: "CAS lost", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}, {BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 2, wantMarked: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repository := &repositoryStub{mains: tt.mains, claimed: tt.claimed}
executor := &executorStub{}
runtime := NewRuntime(repository, NewDispatcher(executor))
if err := runtime.Handle(context.Background(), tt.message); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if repository.findCalls != tt.wantFind || repository.markCalls != tt.wantMarked {
t.Fatalf("repository calls: find=%d mark=%d, want find=%d mark=%d", repository.findCalls, repository.markCalls, tt.wantFind, tt.wantMarked)
}
if executor.calls != 0 {
t.Fatalf("executor calls = %d, want 0", executor.calls)
}
})
}
}
func TestRuntimeExecutesRegisteredDataServiceBatch(t *testing.T) {
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-109", BatchType: Type109, BatchAsyncStatus: AsyncStatusPending}},
claimed: true,
}
dataExecutor := &executorStub{}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type109, dataExecutor)
dispatcher.Register(Type110, dataExecutor)
runtime := NewRuntime(repository, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-109", BatchType: Type109}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if dataExecutor.calls != 1 || dataExecutor.batchNo != "B-109" {
t.Fatalf("data executor was not invoked: %+v", dataExecutor)
}
}
func TestRuntimeExecutesTrafficCutoffConfigWithDedicatedExecutor(t *testing.T) {
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-41", BatchType: Type41, BatchAsyncStatus: AsyncStatusPending}},
claimed: true,
}
dataExecutor := &executorStub{err: errors.New("data executor must not be called")}
trafficCutoffConfigExecutor := &executorStub{}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type107, dataExecutor)
dispatcher.Register(Type41, trafficCutoffConfigExecutor)
runtime := NewRuntime(repository, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41", BatchType: Type41}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if trafficCutoffConfigExecutor.calls != 1 || trafficCutoffConfigExecutor.batchNo != "B-41" {
t.Fatalf("traffic cutoff config executor was not invoked: %+v", trafficCutoffConfigExecutor)
}
if dataExecutor.calls != 0 {
t.Fatalf("data executor calls = %d, want 0", dataExecutor.calls)
}
}
func TestRuntimeIgnoresFinalizedTrafficCutoffConfigDuplicateMessage(t *testing.T) {
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-41-DONE", BatchType: Type41, BatchAsyncStatus: AsyncStatusSuccess}},
}
trafficCutoffConfigExecutor := &executorStub{err: errors.New("traffic cutoff config executor must not be called")}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type41, trafficCutoffConfigExecutor)
runtime := NewRuntime(repository, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41-DONE", BatchType: Type41}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if trafficCutoffConfigExecutor.calls != 0 {
t.Fatalf("traffic cutoff config executor calls = %d, want 0", trafficCutoffConfigExecutor.calls)
}
}
func TestRuntimeMarksExecutorFailureAsFailed(t *testing.T) {
expectedErr := errors.New("execute failed")
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
claimed: true,
}
executor := &executorStub{err: expectedErr}
runtime := NewRuntime(repository, NewDispatcher(executor))
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); err != nil {
t.Fatalf("Handle() error = %v, want nil", err)
}
if repository.markCalls != 1 || repository.markBatchNo != "B-104" || repository.markType != Type104 {
t.Fatalf("unexpected claim call: %+v", repository)
}
if executor.calls != 1 || executor.batchNo != "B-104" {
t.Fatalf("unexpected executor call: %+v", executor)
}
if repository.failCalls != 1 || repository.failBatchNo != "B-104" || repository.failType != Type104 || repository.failReason != expectedErr.Error() {
t.Fatalf("unexpected failure mark call: %+v", repository)
}
}
func TestRuntimeReturnsRetryableExecutorFailureWithoutFinalizing(t *testing.T) {
sourceErr := errors.New("source file is temporarily unavailable")
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusPending}},
claimed: true,
}
executor := &executorStub{err: Retryable(sourceErr)}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(1, executor)
runtime := NewRuntime(repository, dispatcher)
err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1})
if !errors.Is(err, sourceErr) || !IsRetryable(err) {
t.Fatalf("Handle() error = %v, want retryable wrapper of %v", err, sourceErr)
}
if repository.failCalls != 0 {
t.Fatalf("MarkFailed calls = %d, want 0", repository.failCalls)
}
}
func TestRuntimeReclaimsRetryableProcessingBatch(t *testing.T) {
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusProcessing}},
reclaimSet: true,
reclaimed: true,
}
executor := &executorStub{}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(1, executor)
runtime := NewRuntime(repository, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1, Retryable: true}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if repository.reclaimCalls != 1 || repository.reclaimBatchNo != "INB-RETRY" || repository.reclaimType != 1 {
t.Fatalf("unexpected reclaim call: %+v", repository)
}
if executor.calls != 1 {
t.Fatalf("executor calls = %d, want 1", executor.calls)
}
}
func TestRuntimeMarksTimeoutAsFailed(t *testing.T) {
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-TIMEOUT", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
claimed: true,
}
executor := &blockingExecutorStub{}
runtime := NewRuntime(
repository,
NewDispatcher(executor),
WithExecutionTimeout(time.Millisecond),
WithFinalizationTimeout(time.Second),
)
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-TIMEOUT", BatchType: Type104}); err != nil {
t.Fatalf("Handle() error = %v, want nil", err)
}
if executor.calls != 1 {
t.Fatalf("executor calls = %d, want 1", executor.calls)
}
if repository.failCalls != 1 || !strings.Contains(repository.failReason, "timed out after 1ms") {
t.Fatalf("unexpected timeout failure mark: %+v", repository)
}
}
func TestRuntimeReturnsFailureMarkErrors(t *testing.T) {
executeErr := errors.New("execute failed")
markErr := errors.New("mark failed")
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
claimed: true,
failErr: markErr,
}
runtime := NewRuntime(repository, NewDispatcher(&executorStub{err: executeErr}))
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); !errors.Is(err, markErr) {
t.Fatalf("Handle() error = %v, want %v", err, markErr)
}
}
func TestRuntimeCompensatesFailureMarkWithoutReexecutingBatch(t *testing.T) {
markErr := errors.New("mark failed")
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-RETRY", BatchType: Type104, BatchAsyncStatus: AsyncStatusProcessing}},
failErr: markErr,
}
executor := &executorStub{err: errors.New("must not execute again")}
runtime := NewRuntime(repository, NewDispatcher(executor))
err := runtime.Handle(context.Background(), Message{
BatchNo: "B-RETRY",
BatchType: Type104,
FailureReason: "original executor failure",
})
if !errors.Is(err, markErr) {
t.Fatalf("Handle() error = %v, want %v", err, markErr)
}
if executor.calls != 0 {
t.Fatalf("executor calls = %d, want 0 for failure finalization retry", executor.calls)
}
if repository.markCalls != 0 {
t.Fatalf("MarkProcessing calls = %d, want 0 for failure finalization retry", repository.markCalls)
}
if repository.failCalls != 1 || repository.failReason != "original executor failure" {
t.Fatalf("unexpected failure finalization call: %+v", repository)
}
}
func TestRuntimeAllowsSelfClaimingProcessingReEntryWithoutRefreshingStartTime(t *testing.T) {
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
}
executor := &processingExecutorStub{can: true, selfClaim: true}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type105, executor)
runtime := NewRuntime(repository, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if executor.canCalls != 1 || executor.main == nil || executor.main.BatchNo != "RST-1" {
t.Fatalf("processing check not called with main: %+v", executor)
}
if repository.markCalls != 0 {
t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls)
}
if repository.reclaimCalls != 0 {
t.Fatalf("ReclaimProcessing calls = %d, want 0 for self-claiming executor", repository.reclaimCalls)
}
if executor.calls != 1 || executor.batchNo != "RST-1" {
t.Fatalf("executor calls = %+v", executor.executorStub)
}
}
func TestRuntimeMarksProcessingReEntryFailureAsFailed(t *testing.T) {
expectedErr := errors.New("resume failed")
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
}
executor := &processingExecutorStub{can: true}
executor.err = expectedErr
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type105, executor)
runtime := NewRuntime(repository, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil {
t.Fatalf("Handle() error = %v, want nil", err)
}
if repository.markCalls != 0 {
t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls)
}
if repository.failCalls != 1 || repository.failBatchNo != "RST-1" || repository.failType != Type105 || repository.failReason != expectedErr.Error() {
t.Fatalf("unexpected failure mark call: %+v", repository)
}
}
func TestRuntimeIgnoresProcessingWhenOptInExecutorDeclines(t *testing.T) {
repository := &repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "RST-2", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
}
executor := &processingExecutorStub{can: false}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type105, executor)
runtime := NewRuntime(repository, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-2", BatchType: Type105}); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if executor.canCalls != 1 {
t.Fatalf("processing check calls = %d, want 1", executor.canCalls)
}
if executor.calls != 0 {
t.Fatalf("executor calls = %d, want 0", executor.calls)
}
}
func TestRuntimeReturnsDatabaseErrors(t *testing.T) {
findErr := errors.New("find failed")
runtime := NewRuntime(&repositoryStub{findErr: findErr}, NewDispatcher(&executorStub{}))
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, findErr) {
t.Fatalf("find error = %v, want %v", err, findErr)
}
markErr := errors.New("mark failed")
runtime = NewRuntime(&repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}},
markErr: markErr,
}, NewDispatcher(&executorStub{}))
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, markErr) {
t.Fatalf("mark error = %v, want %v", err, markErr)
}
checkErr := errors.New("check failed")
processing := &processingExecutorStub{canErr: checkErr}
dispatcher := NewDispatcher(&executorStub{})
dispatcher.Register(Type105, processing)
runtime = NewRuntime(&repositoryStub{
mains: []*entity.BatchMain{{BatchNo: "RST-3", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
}, dispatcher)
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-3", BatchType: Type105}); !errors.Is(err, checkErr) {
t.Fatalf("processing check error = %v, want %v", err, checkErr)
}
}
func TestMessageHasOnlyContractFields(t *testing.T) {
typeOfMessage := reflect.TypeOf(Message{})
if typeOfMessage.NumField() != 4 {
t.Fatalf("Message has %d fields, want 4", typeOfMessage.NumField())
}
if field, ok := typeOfMessage.FieldByName("FailureReason"); !ok || field.Tag.Get("json") != "failureReason,omitempty" {
t.Fatalf("FailureReason JSON contract is missing or incorrect")
}
if field, ok := typeOfMessage.FieldByName("Retryable"); !ok || field.Tag.Get("json") != "retryable,omitempty" {
t.Fatalf("Retryable JSON contract is missing or incorrect")
}
}
+207
View File
@@ -0,0 +1,207 @@
package batch
import (
"context"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
type Repository interface {
FindMain(ctx context.Context, batchNo string) (*entity.BatchMain, error)
MarkProcessing(ctx context.Context, batchNo string, batchType int) (bool, error)
MarkFailed(ctx context.Context, batchNo string, batchType int, reason string) (bool, error)
MarkSuccess(ctx context.Context, batchNo string, batchType int, success, fail int, reason string) (bool, error)
}
type Runtime struct {
repository Repository
dispatcher *Dispatcher
executionTimeout time.Duration
finalizationTimeout time.Duration
}
func NewRuntime(repository Repository, dispatcher *Dispatcher, options ...RuntimeOption) *Runtime {
runtime := &Runtime{
repository: repository,
dispatcher: dispatcher,
executionTimeout: 0,
finalizationTimeout: 10 * time.Second,
}
for _, option := range options {
option(runtime)
}
return runtime
}
type RuntimeOption func(*Runtime)
func WithExecutionTimeout(timeout time.Duration) RuntimeOption {
return func(runtime *Runtime) {
if timeout >= 0 {
runtime.executionTimeout = timeout
}
}
}
func WithFinalizationTimeout(timeout time.Duration) RuntimeOption {
return func(runtime *Runtime) {
if timeout > 0 {
runtime.finalizationTimeout = timeout
}
}
}
func (r *Runtime) Handle(ctx context.Context, message Message) error {
if strings.TrimSpace(message.BatchNo) == "" {
g.Log().Warning(ctx, "batch message dropped, empty batchNo")
return nil
}
executor, ok := r.dispatcher.Executor(message.BatchType)
if !ok {
g.Log().Warningf(ctx, "batch message dropped, unsupported batchType:%d, batchNo:%s", message.BatchType, message.BatchNo)
return nil
}
main, err := r.repository.FindMain(ctx, message.BatchNo)
if err != nil {
return gerror.Wrap(err, "load batch main failed")
}
if main == nil {
g.Log().Warningf(ctx, "batch message ignored, batch not found, batchNo:%s", message.BatchNo)
return nil
}
if main.BatchType != message.BatchType {
g.Log().Warningf(ctx, "batch message ignored, type mismatch, batchNo:%s, messageType:%d, storedType:%d", message.BatchNo, message.BatchType, main.BatchType)
return nil
}
if strings.TrimSpace(message.FailureReason) != "" {
return r.markExecutionFailed(ctx, message, executor, message.FailureReason)
}
if main.BatchAsyncStatus != AsyncStatusPending {
if main.BatchAsyncStatus == AsyncStatusProcessing {
if processingExecutor, ok := executor.(ProcessingExecutor); ok {
canExecute, checkErr := processingExecutor.CanExecuteProcessing(ctx, main)
if checkErr != nil {
return gerror.Wrap(checkErr, "check processing batch re-entry failed")
}
if canExecute {
if selfClaimer, selfClaims := executor.(SelfClaimingProcessingExecutor); selfClaims && selfClaimer.ClaimsProcessingReentry() {
return r.execute(ctx, message, executor)
}
return r.reclaimAndExecute(ctx, message, executor, main)
}
} else if message.Retryable {
return r.reclaimAndExecute(ctx, message, executor, main)
}
}
logUnclaimable(ctx, main)
return nil
}
claimed, err := r.repository.MarkProcessing(ctx, message.BatchNo, message.BatchType)
if err != nil {
return gerror.Wrap(err, "mark batch processing failed")
}
if !claimed {
current, findErr := r.repository.FindMain(ctx, message.BatchNo)
if findErr != nil {
return gerror.Wrap(findErr, "reload batch main after CAS failed")
}
if current == nil {
g.Log().Warningf(ctx, "batch CAS lost, batch not found, batchNo:%s", message.BatchNo)
return nil
}
if current.BatchType != message.BatchType {
g.Log().Warningf(ctx, "batch CAS lost, type mismatch, batchNo:%s, messageType:%d, storedType:%d", message.BatchNo, message.BatchType, current.BatchType)
return nil
}
logUnclaimable(ctx, current)
return nil
}
g.Log().Infof(ctx, "batch claimed, batchNo:%s, batchType:%d", message.BatchNo, message.BatchType)
return r.execute(ctx, message, executor)
}
func (r *Runtime) reclaimAndExecute(ctx context.Context, message Message, executor Executor, main *entity.BatchMain) error {
if claimer, claimSupported := r.repository.(ProcessingClaimer); claimSupported {
reclaimed, claimErr := claimer.ReclaimProcessing(ctx, message.BatchNo, message.BatchType, main.StartTime)
if claimErr != nil {
return gerror.Wrap(claimErr, "claim stale processing batch failed")
}
if !reclaimed {
g.Log().Infof(ctx, "batch re-entry claim lost, batchNo:%s", message.BatchNo)
return nil
}
}
return r.execute(ctx, message, executor)
}
func (r *Runtime) execute(ctx context.Context, message Message, executor Executor) error {
executionCtx := ctx
var cancel context.CancelFunc = func() {}
if r.executionTimeout > 0 {
executionCtx, cancel = context.WithTimeout(ctx, r.executionTimeout)
}
defer cancel()
if err := executor.Execute(executionCtx, message.BatchNo); err != nil {
if IsRetryable(err) {
return err
}
reason := err.Error()
if r.executionTimeout > 0 && executionCtx.Err() == context.DeadlineExceeded {
reason = gerror.Newf("batch execution timed out after %s", r.executionTimeout).Error()
}
return r.markExecutionFailed(ctx, message, executor, reason)
}
if r.executionTimeout > 0 && executionCtx.Err() == context.DeadlineExceeded {
return r.markExecutionFailed(ctx, message, executor, gerror.Newf("batch execution timed out after %s", r.executionTimeout).Error())
}
return nil
}
func (r *Runtime) markExecutionFailed(ctx context.Context, message Message, executor Executor, reason string) error {
reason = batchfields.FailureReason(reason)
finalizationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.finalizationTimeout)
defer cancel()
if handler, ok := executor.(DurableFailureHandler); ok {
handled, err := handler.ProtectExecutionFailure(finalizationCtx, message.BatchNo, message.BatchType, reason)
if err != nil {
return &FailureMarkError{Reason: reason, Err: err}
}
if handled {
return nil
}
}
failed, markErr := r.repository.MarkFailed(finalizationCtx, message.BatchNo, message.BatchType, reason)
if markErr != nil {
return &FailureMarkError{Reason: reason, Err: markErr}
}
if handler, ok := executor.(ExecutionFailureHandler); ok {
if err := handler.HandleExecutionFailure(finalizationCtx, message.BatchNo, message.BatchType, reason); err != nil {
return &FailureMarkError{Reason: reason, Err: err}
}
}
if !failed {
g.Log().Warningf(ctx, "batch execution failed but status was already finalized, batchNo:%s, reason:%s", message.BatchNo, reason)
return nil
}
g.Log().Errorf(ctx, "batch execution failed and was marked failed, batchNo:%s, reason:%s", message.BatchNo, reason)
return nil
}
func logUnclaimable(ctx context.Context, main *entity.BatchMain) {
if main.BatchAsyncStatus == AsyncStatusProcessing {
g.Log().Infof(ctx, "batch message ignored, batch already processing, batchNo:%s", main.BatchNo)
return
}
g.Log().Infof(ctx, "batch message ignored, batch is terminal or not pending, batchNo:%s, status:%d", main.BatchNo, main.BatchAsyncStatus)
}
@@ -0,0 +1,154 @@
package upstream
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
// apnRequiredSchema 是异步APN提交依赖的表与关键列,对应 20261015 的四份迁移脚本。
// 缺表或缺列意味着迁移没有执行完,此时提交必然写不进去,应在发请求前就拒绝。
var apnRequiredSchema = map[string][]string{
"t_apn_operation": {"operation_id", "chan_key", "request_hash", "response_json", "revision"},
"data_operation_parent": {"batch_no", "ready", "finalize_token", "finalize_until"},
"data_operation_sub_batch": {"operation_id", "batch_no", "chan_key", "request_data", "status", "poll_count", "lease_token"},
"data_operation_sub_detail": {"detail_id", "operation_id", "batch_no", "iccid"},
"data_operation_card_claim": {"tenant_id", "iccid", "batch_id", "active_batch_id"},
}
// apnReadinessTTL 是依赖检查结果的缓存时间:迁移完成后最迟一分钟内自动放行,无需重启。
const apnReadinessTTL = time.Minute
// apnReadinessVerifyTimeout 是单次结构校验的独立超时,与调用方上下文解耦。
const apnReadinessVerifyTimeout = 5 * time.Second
type apnReadinessCache struct {
ttl time.Duration
mu sync.Mutex
checkedAt time.Time
err error
// verify 默认指向 verifyAPNSchema;独立出来是为了让缓存行为可测。
verify func(context.Context) error
}
var defaultAPNReadiness = &apnReadinessCache{ttl: apnReadinessTTL, verify: verifyAPNSchema}
// check 返回异步APN提交的依赖状态,结果按 TTL 缓存,避免每次提交都查库。
func (c *apnReadinessCache) check(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.checkedAt.IsZero() && time.Since(c.checkedAt) < c.ttl {
return c.err
}
verify := c.verify
if verify == nil {
verify = verifyAPNSchema
}
// 校验使用独立超时,不受调用方取消影响;否则一次取消就会把失败结果缓存给全进程。
verifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apnReadinessVerifyTimeout)
defer cancel()
c.err = verify(verifyCtx)
if c.err != nil && (errors.Is(c.err, context.Canceled) || errors.Is(c.err, context.DeadlineExceeded)) {
// 只是本次校验没跑完,不代表依赖缺失,不写入缓存。
c.checkedAt = time.Time{}
return c.err
}
c.checkedAt = time.Now()
return c.err
}
// verifyAPNSchema 直接查询库里的实际表结构,而不是依赖人工开开关确认「迁移已执行」。
func verifyAPNSchema(ctx context.Context) (err error) {
if !databaseConfigured(ctx) {
// 未配置数据库连接(测试桩、纯传输层用例)时跳过;生产 worker 不存在该情况。
return nil
}
// g.DB() 在缺配置时会 panic,这里统一转成可读错误,避免拖垮提交链路。
defer func() {
if recovered := recover(); recovered != nil {
err = gerror.Newf("无法获取数据库连接,不能确认异步APN依赖是否就绪:%v", recovered)
}
}()
db := g.DB()
if db == nil {
return gerror.New("无法获取数据库连接,不能确认异步APN依赖是否就绪")
}
config := db.GetConfig()
if strings.EqualFold(config.Type, "sqlite") {
// 本地/单元测试使用 SQLite,没有 information_schema,跳过结构检查。
return nil
}
names := make([]string, 0, len(apnRequiredSchema))
for name := range apnRequiredSchema {
names = append(names, name)
}
sort.Strings(names)
placeholders := make([]string, 0, len(names))
args := make([]interface{}, 0, len(names))
for _, name := range names {
placeholders = append(placeholders, "?")
args = append(args, name)
}
records, err := db.GetAll(ctx, fmt.Sprintf(
"SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM information_schema.columns WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (%s)",
strings.Join(placeholders, ",")), args...)
if err != nil {
return gerror.Wrap(err, "查询异步APN依赖表结构失败")
}
existing := make(map[string]map[string]bool, len(records))
for _, record := range records {
table := record["table_name"].String()
if existing[table] == nil {
existing[table] = make(map[string]bool)
}
existing[table][strings.ToLower(record["column_name"].String())] = true
}
return validateAPNSchema(existing)
}
// databaseConfigured 判断当前配置里是否声明了数据库,用于区分测试桩与真实运行环境。
func databaseConfigured(ctx context.Context) bool {
value := g.Cfg().MustGet(ctx, "database")
return value != nil && !value.IsNil() && !value.IsEmpty()
}
// validateAPNSchema 比对必需的表与列,返回可直接据此执行迁移的错误信息。
func validateAPNSchema(existing map[string]map[string]bool) error {
tables := make([]string, 0, len(apnRequiredSchema))
for table := range apnRequiredSchema {
tables = append(tables, table)
}
sort.Strings(tables)
var missingTables []string
var missingColumns []string
for _, table := range tables {
columns, ok := existing[table]
if !ok {
missingTables = append(missingTables, table)
continue
}
required := append([]string(nil), apnRequiredSchema[table]...)
sort.Strings(required)
for _, column := range required {
if !columns[column] {
missingColumns = append(missingColumns, table+"."+column)
}
}
}
switch {
case len(missingTables) > 0:
return gerror.Newf("异步APN依赖未就绪:缺少数据表 %s,请先执行 20261015 迁移脚本",
strings.Join(missingTables, "、"))
case len(missingColumns) > 0:
return gerror.Newf("异步APN依赖未就绪:缺少字段 %s,请先执行 20261015 迁移脚本",
strings.Join(missingColumns, "、"))
}
return nil
}
@@ -0,0 +1,138 @@
package upstream
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func fullAPNSchema() map[string]map[string]bool {
schema := make(map[string]map[string]bool, len(apnRequiredSchema))
for table, columns := range apnRequiredSchema {
schema[table] = make(map[string]bool, len(columns))
for _, column := range columns {
schema[table][column] = true
}
}
return schema
}
// TestValidateAPNSchemaRequiresAllTablesAndColumns 固化依赖判据:
// 缺表、缺列都必须给出可据此执行迁移的明确原因,而不是笼统的「未就绪」。
func TestValidateAPNSchemaRequiresAllTablesAndColumns(t *testing.T) {
if err := validateAPNSchema(fullAPNSchema()); err != nil {
t.Fatalf("完整结构不应报错:%v", err)
}
missingTable := fullAPNSchema()
delete(missingTable, "data_operation_card_claim")
err := validateAPNSchema(missingTable)
if err == nil || !strings.Contains(err.Error(), "data_operation_card_claim") || !strings.Contains(err.Error(), "缺少数据表") {
t.Fatalf("缺表未被识别:%v", err)
}
missingColumn := fullAPNSchema()
delete(missingColumn["t_apn_operation"], "revision")
err = validateAPNSchema(missingColumn)
if err == nil || !strings.Contains(err.Error(), "t_apn_operation.revision") || !strings.Contains(err.Error(), "缺少字段") {
t.Fatalf("缺列未被识别:%v", err)
}
// 表全部缺失时优先报缺表,避免一次报几十个字段干扰排查。
err = validateAPNSchema(map[string]map[string]bool{})
if err == nil || !strings.Contains(err.Error(), "缺少数据表") {
t.Fatalf("空库应优先报缺表:%v", err)
}
}
// TestAsyncApnSubmitBlockedWhenDependencyNotReady 验证依赖未就绪时在发请求前就拒绝,
// 并把错误标记为「未发送」,避免上游误判为已办理。
func TestAsyncApnSubmitBlockedWhenDependencyNotReady(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
oldReady := apnDependencyReady
defer func() { apnDependencyReady = oldReady }()
apnDependencyReady = func(ctx context.Context) error {
return errors.New("缺少数据表 data_operation_card_claim")
}
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
return
}
submits++
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
_, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}},
ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"},
})
var notSent interface{ APNRequestNotSent() bool }
if err == nil || !strings.Contains(err.Error(), "依赖未就绪") {
t.Fatalf("依赖未就绪应拒绝提交:%v", err)
}
if !errors.As(err, &notSent) || !notSent.APNRequestNotSent() {
t.Fatalf("依赖拒绝必须标记为未发送:%v", err)
}
if probes != 0 || submits != 0 {
t.Fatalf("依赖未就绪时不应发出任何请求:probes=%d submits=%d", probes, submits)
}
}
// TestReadinessCacheDoesNotCacheCanceledVerification 覆盖审查发现的场景:
// 调用方取消导致的校验失败不能进进程级缓存,否则会误挡后续所有提交。
func TestReadinessCacheDoesNotCacheCanceledVerification(t *testing.T) {
cache := &apnReadinessCache{ttl: time.Minute}
calls := 0
cache.verify = func(ctx context.Context) error {
calls++
if calls == 1 {
return context.Canceled
}
return nil
}
if err := cache.check(context.Background()); !errors.Is(err, context.Canceled) {
t.Fatalf("首次应返回取消错误:%v", err)
}
if err := cache.check(context.Background()); err != nil {
t.Fatalf("取消不应被缓存,第二次应重新校验并放行:%v", err)
}
if calls != 2 {
t.Fatalf("取消后应重新校验,实际调用%d次", calls)
}
}
// TestReadinessCacheReusesResult 保证依赖检查不会每次提交都查库,同时迁移完成后能自动恢复。
func TestReadinessCacheReusesResult(t *testing.T) {
cache := &apnReadinessCache{ttl: time.Minute}
calls := 0
cache.verify = func(ctx context.Context) error {
calls++
return nil
}
for i := 0; i < 3; i++ {
if err := cache.check(context.Background()); err != nil {
t.Fatalf("第%d次检查报错:%v", i+1, err)
}
}
if calls != 1 {
t.Fatalf("缓存期内应只查一次库,实际%d次", calls)
}
}
@@ -0,0 +1,393 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/gclient"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
const inboundBasicInfoClientNameEnv = "UPSTREAM_INBOUND_BASIC_INFO_CLIENT_NAME"
const upstreamProxyMaxTimeoutConfigKey = "upstream.proxy.maxTimeout"
// apnAsyncProtocolVersionConfigKey 是异步APN提交的兼容开关。
// 默认值改为开启:常规发布不再需要人工逐环境开开关,兼容性由提交前的协议能力探测兜底。
// 只有需要临时回退到旧协议时,才显式配为 0。
const apnAsyncProtocolVersionConfigKey = "upstream.proxy.apnAsyncProtocolVersion"
// defaultApnAsyncProtocolVersion 默认开启异步APN提交。
const defaultApnAsyncProtocolVersion = 1
// defaultUpstreamProxyMaxTimeout 是单次批量转发的兜底上限。
// 必须大于 upstream-proxy 的 gateway.batchTimeout(默认 20 分钟),
// 让超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理,造成结果未知。
const defaultUpstreamProxyMaxTimeout = 25 * time.Minute
// defaultUpstreamProxyTimeout 是未配置单卡预算时的默认值。
const defaultUpstreamProxyTimeout = 10 * time.Second
type Client struct {
currentUsageClientName string
currentUsageTimeout time.Duration
inboundBasicInfoClientName string
imeiClientName string
imeiTimeout time.Duration
}
type response[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
Data []model.SimBaseRes[T] `json:"data"`
}
func New() *Client {
return &Client{}
}
func (c *Client) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) ([]model.SimBaseRes[model.SimBasicInfoItem], error) {
address, err := c.inboundBasicInfoAddress(ctx)
if err != nil {
return nil, err
}
return requestAllWithAddress[model.SimBasicInfoItem](ctx, "/sim/query/sim-basic-info", input, len(input.SimKeys), address)
}
// NewWithCurrentUsageClient keeps the full-sync current-usage call independent
// from the configured upstream.proxy endpoint.
func NewWithCurrentUsageClient(address string, timeout ...time.Duration) *Client {
var currentUsageTimeout time.Duration
if len(timeout) > 0 {
currentUsageTimeout = timeout[0]
}
return &Client{currentUsageClientName: address, currentUsageTimeout: currentUsageTimeout}
}
func NewWithInboundBasicInfoClient(address string) *Client {
return &Client{inboundBasicInfoClientName: address}
}
// NewWithImeiClient pins the IMEI full-sync path to an explicit chan endpoint
// and timeout. An empty address falls back to the shared upstream.proxy
// configuration, which is only intended for local runs.
func NewWithImeiClient(address string, timeout ...time.Duration) *Client {
var imeiTimeout time.Duration
if len(timeout) > 0 {
imTimeout := timeout[0]
if imTimeout > 0 {
imeiTimeout = imTimeout
}
}
return &Client{imeiClientName: address, imeiTimeout: imeiTimeout}
}
func (c *Client) inboundBasicInfoAddress(ctx context.Context) (string, error) {
if strings.TrimSpace(c.inboundBasicInfoClientName) != "" {
return strings.TrimSpace(c.inboundBasicInfoClientName), nil
}
if address := strings.TrimSpace(os.Getenv(inboundBasicInfoClientNameEnv)); address != "" {
return address, nil
}
config, ok, err := optionalUpstreamConfig(ctx, "upstream.gateway")
if err != nil {
return "", err
}
if !ok || strings.TrimSpace(config.Address) == "" {
return "", gerror.Newf("upstream gateway address is empty, configure upstream.gateway.address or %s", inboundBasicInfoClientNameEnv)
}
return strings.TrimSpace(config.Address), nil
}
// QuerySimDataUsage calls Chan's current-month real-time usage endpoint. The
// response keeps one item per requested card so the caller can classify
// per-card errors without reissuing successful cards.
func (c *Client) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) ([]model.SimBaseRes[model.SimDataUsageItem], error) {
return requestAllWithAddressTimeout[model.SimDataUsageItem](ctx, "/sim/query/sim-datausage", input, len(input.SimKeys), c.currentUsageClientName, c.currentUsageTimeout)
}
// QuerySimCardStatus queries the carrier status for the supplied cards. The
// status queue currently sends one card per request, while retaining the
// response shape needed for future batch support.
func (c *Client) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) ([]model.SimBaseRes[model.SimCardStatusItem], error) {
return requestAll[model.SimCardStatusItem](ctx, "/sim/query/sim-status", input, len(input.SimKeys))
}
// QuerySimImei queries the terminal IMEI for the supplied cards. The IMEI queue
// sends one card per request.
//
// It deliberately uses requestAll rather than request: a per-item carrier
// failure (unsupported template, missing value) must reach the handler as an
// item with a non-zero Code, not be silently dropped from the slice.
func (c *Client) QuerySimImei(ctx context.Context, input *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error) {
return requestAllWithAddressTimeout[model.SimImeiItem](ctx, "/sim/query/sim-imei", input, len(input.SimKeys), c.imeiClientName, c.imeiTimeout)
}
func (c *Client) QuerySimDatausageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) {
return requestAll[model.SimDataUsageDailyOutputItem](ctx, "/sim/query/sim-datausage-daily", input, len(input.SimKeys))
}
func (c *Client) QuerySimDatausageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
return requestAll[model.SimDataUsageMonthlyOutputItem](ctx, "/sim/query/sim-datausage-monthly", input, len(input.SimKeys))
}
// ChangeSimStatus returns one result for every upstream item, including failed items.
// Lifecycle batch execution needs those failures to be persisted to the matching detail.
func (c *Client) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeOutputItem], error) {
return requestAll[model.SimCardStatusChangeOutputItem](ctx, "/sim/change/sim-status", input, len(input.SimKeys))
}
// ChangeSimApnFunction returns one result for every upstream item, including failed items.
// Data service batch execution needs those failures to be persisted to the matching detail.
func (c *Client) ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return requestAll[model.SimCardApnChangeOutputItem](ctx, "/sim/change/sim-apn", input, len(input.SimKeys))
}
// apnRequestNotSentError 仅描述任何APN写请求之前的能力拒绝。
// 提交开始后的通信错误绝不能使用此类型。
type apnRequestNotSentError struct{ cause error }
func (e *apnRequestNotSentError) Error() string { return e.cause.Error() }
func (e *apnRequestNotSentError) Unwrap() error { return e.cause }
func (e *apnRequestNotSentError) APNRequestNotSent() bool { return true }
func (c *Client) CheckSimApnAsyncCapability(ctx context.Context, input *model.SimCardApnChangeInput) (err error) {
defer func() {
if err != nil {
err = &apnRequestNotSentError{cause: err}
}
}()
if !apnAsyncSubmitEnabled(ctx) {
return gerror.New("异步APN提交已被显式关闭(upstream.proxy.apnAsyncProtocolVersion=0);清除该配置或置为1后重试")
}
if err := apnDependencyReady(ctx); err != nil {
return gerror.Wrap(err, "异步APN依赖未就绪,未发送办理请求")
}
if input == nil || strings.TrimSpace(input.ReqData.OperationID) == "" {
return gerror.New("异步APN提交能力探测缺少operationId")
}
return c.checkSimApnProtocol(ctx, input)
}
// apnDependencyReady 检查本地真实依赖(迁移表结构);测试可替换以便固定结论。
var apnDependencyReady = func(ctx context.Context) error {
return defaultAPNReadiness.check(ctx)
}
// apnAsyncSubmitEnabled 判断应急总闸是否被显式关闭。
// 默认放行:是否可提交由程序自行检查真实依赖决定——
// - 本地依赖:apn_readiness 核对迁移表结构是否就绪;
// - 远端兼容:每次提交前的协议能力探测,旧版 chan/proxy 会明确拒绝而不是按同步语义执行。
// 因此常规发布不再需要人工逐环境开开关;仅回退到旧协议时把配置显式设为 0。
func apnAsyncSubmitEnabled(ctx context.Context) bool {
return g.Cfg().MustGet(ctx, apnAsyncProtocolVersionConfigKey, defaultApnAsyncProtocolVersion).Int() == 1
}
// 仅验证远端协议:已有受理映射的只读补查不受新增提交门禁控制。
// 旧版代理/通道缺少能力接口时必须拒绝,不能把查询当作普通办理转发。
func (c *Client) checkSimApnProtocol(ctx context.Context, input *model.SimCardApnChangeInput) error {
if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 || (strings.TrimSpace(input.ReqData.OperationID) == "" && strings.TrimSpace(input.ReqData.QueryJobID) == "") {
return gerror.New("异步APN能力探测需要operationId和1至100张卡快照")
}
type capability struct {
ProtocolVersion int `json:"protocolVersion"`
}
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
items, err := requestAll[capability](probeCtx, "/sim/query/sim-apn-capability", input, len(input.SimKeys))
if err != nil {
return gerror.Wrap(err, "异步APN协议能力探测失败,未发送办理或恢复请求")
}
if len(items) != len(input.SimKeys) {
return gerror.New("异步APN协议能力响应卡数不匹配,禁止发送办理请求")
}
seen := make(map[model.SimKey]bool)
for _, item := range items {
if item.SimKey == nil || item.Code != 0 || item.ResData.ProtocolVersion != 1 || seen[*item.SimKey] {
return gerror.New("通道未就绪或不支持异步APN协议,禁止发送办理请求")
}
seen[*item.SimKey] = true
}
for _, key := range input.SimKeys {
if key == nil || !seen[*key] {
return gerror.New("异步APN协议能力响应卡关联不匹配")
}
}
return nil
}
// SubmitSimApnFunction 仅提交异步批量任务;受理状态不能按办理成功回写。
func (c *Client) SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if err := c.CheckSimApnAsyncCapability(ctx, input); err != nil {
return nil, err
}
request := *input
request.ReqData.SubmitOnly = true
request.ReqData.QueryJobID = ""
request.ReqData.RecoverOnly = false
return c.ChangeSimApnFunction(ctx, &request)
}
// QuerySimApnFunctionResult 根据已持久化的jobId只查一次,不重新提交。
func (c *Client) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if strings.TrimSpace(input.ReqData.QueryJobID) == "" && strings.TrimSpace(input.ReqData.OperationID) == "" {
return nil, gerror.New("异步APN结果查询缺少jobId和operationId")
}
request := *input
request.ReqData.QueryJobID = strings.TrimSpace(request.ReqData.QueryJobID)
request.ReqData.SubmitOnly = false
request.ReqData.RecoverOnly = request.ReqData.QueryJobID == ""
if err := c.checkSimApnProtocol(ctx, &request); err != nil {
return nil, gerror.Wrap(err, "异步APN只读查询协议未就绪,未发送查询请求")
}
return c.ChangeSimApnFunction(ctx, &request)
}
func request[T any](ctx context.Context, path string, input any, itemCount int) ([]model.SimBaseRes[T], error) {
items, err := requestAll[T](ctx, path, input, itemCount)
if err != nil {
return nil, err
}
successItems := make([]model.SimBaseRes[T], 0, len(items))
for _, item := range items {
if item.Code == 0 {
successItems = append(successItems, item)
}
}
return successItems, nil
}
func requestAll[T any](ctx context.Context, path string, input any, itemCount int) ([]model.SimBaseRes[T], error) {
return requestAllWithAddress[T](ctx, path, input, itemCount, "")
}
func requestAllWithAddress[T any](ctx context.Context, path string, input any, itemCount int, address string) ([]model.SimBaseRes[T], error) {
return requestAllWithAddressTimeout[T](ctx, path, input, itemCount, address, 0)
}
func requestAllWithAddressTimeout[T any](ctx context.Context, path string, input any, itemCount int, address string, timeoutOverride time.Duration) ([]model.SimBaseRes[T], error) {
config, err := upstreamConfig(ctx, "upstream.proxy")
if err != nil {
return nil, err
}
if address == "" {
address = config.Address
}
if address == "" {
return nil, gerror.New("upstream proxy address is empty")
}
if itemCount < 1 {
itemCount = 1
}
client := g.Client()
if path == "/sim/change/sim-apn" || path == "/sim/query/sim-apn-capability" {
client.SetContentType("application/json")
}
timeout := resolveUpstreamTimeout(path, itemCount, config.Timeout,
timeoutOverride,
configDuration(ctx, upstreamProxyMaxTimeoutConfigKey, defaultUpstreamProxyMaxTimeout),
configDuration(ctx, "upstream.proxy.dataServiceTimeout", 240*time.Second))
setClientTimeout(client, timeout)
clientResponse, err := client.Post(ctx, fmt.Sprintf("http://%s%s", address, path), input)
if err != nil {
return nil, gerror.Wrap(err, "request upstream failed")
}
defer clientResponse.Close()
return decodeResponse[T](clientResponse.ReadAll())
}
func optionalUpstreamConfig(ctx context.Context, key string) (model.UpstreamChanConfig, bool, error) {
configValue, err := g.Cfg().Get(ctx, key)
if err != nil {
return model.UpstreamChanConfig{}, false, gerror.Wrapf(err, "read upstream configuration failed: %s", key)
}
if configValue == nil || configValue.IsNil() {
return model.UpstreamChanConfig{}, false, nil
}
config := model.UpstreamChanConfig{}
if err = configValue.Scan(&config); err != nil {
return model.UpstreamChanConfig{}, false, gerror.Wrapf(err, "parse upstream configuration failed: %s", key)
}
return config, true, nil
}
func upstreamConfig(ctx context.Context, key string) (model.UpstreamChanConfig, error) {
config, ok, err := optionalUpstreamConfig(ctx, key)
if err != nil {
return model.UpstreamChanConfig{}, err
}
if !ok {
return model.UpstreamChanConfig{}, gerror.Newf("upstream configuration is missing: %s", key)
}
return config, nil
}
func decodeResponse[T any](body []byte) ([]model.SimBaseRes[T], error) {
output := response[T]{}
if err := json.Unmarshal(body, &output); err != nil {
return nil, gerror.Wrap(err, "parse upstream response failed")
}
if output.Code != 0 {
return nil, gerror.New(output.Message)
}
return output.Data, nil
}
func setClientTimeout(client *gclient.Client, timeout time.Duration) {
client.SetTimeout(timeout)
if transport, ok := client.Transport.(*http.Transport); ok {
transport.ResponseHeaderTimeout = timeout
}
}
// resolveUpstreamTimeout 计算单次转发预算:
// 普通批量按「单卡预算 × 卡数」放大,并以 maxTimeout 兜底,避免批量操作被默认 2 分钟上限提前判失败;
// 数据开停沿用独立预算,优先于按卡计时。
func resolveUpstreamTimeout(path string, itemCount int, perCard, timeoutOverride, maxTimeout, dataServiceTimeout time.Duration) time.Duration {
if itemCount < 1 {
itemCount = 1
}
timeout := timeoutOverride
if timeout <= 0 {
timeout = perCard
}
if timeout <= 0 {
timeout = defaultUpstreamProxyTimeout
}
timeout *= time.Duration(itemCount)
if path == "/sim/change/sim-apn" {
if dataServiceTimeout > 0 {
return dataServiceTimeout
}
return 240 * time.Second
}
if maxTimeout > 0 && timeout > maxTimeout {
timeout = maxTimeout
}
return timeout
}
// configDuration 读取时长配置,兼容写成 "20m" 字符串或秒数整数两种形式。
func configDuration(ctx context.Context, key string, fallback time.Duration) time.Duration {
value := g.Cfg().MustGet(ctx, key)
if value == nil || value.IsNil() {
return fallback
}
if duration := value.Duration(); duration >= time.Second {
return duration
}
if seconds := value.Int(); seconds > 0 {
return time.Duration(seconds) * time.Second
}
return fallback
}
@@ -0,0 +1,527 @@
package upstream
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func startAPNHTTPChainHelper(t *testing.T, module string, extraEnv ...string) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..", "..", "..", module))
if err != nil {
t.Fatal(err)
}
buildCtx, buildCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer buildCancel()
binary := filepath.Join(t.TempDir(), module+"-http-test.exe")
build := exec.CommandContext(buildCtx, "go", "-C", root, "test", "-work", "-c", "-o", binary, "./internal/cmd")
build.Env = append(os.Environ(), "GOTMPDIR="+t.TempDir())
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("%s模拟进程编译失败:%v\n%s", module, err, output)
}
ctx, cancel := context.WithTimeout(context.Background(), 170*time.Second)
cmd := exec.CommandContext(ctx, binary, "-test.run=^TestAPNHTTPHarness$", "-test.v", "-test.count=1")
cmd.Dir = filepath.Join(root, "internal", "cmd")
cmd.Env = append(append(os.Environ(), "APN_HTTP_CHAIN_HELPER=1"), extraEnv...)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
t.Fatal(err)
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
cancel()
t.Fatal(err)
}
ready := make(chan string, 1)
var mu sync.Mutex
var log strings.Builder
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
mu.Lock()
log.WriteString(line + "\n")
mu.Unlock()
if strings.HasPrefix(line, "APN_CHAIN_READY=") {
ready <- strings.TrimPrefix(line, "APN_CHAIN_READY=")
}
}
}()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var address string
select {
case address = <-ready:
case err := <-done:
cancel()
mu.Lock()
text := log.String()
mu.Unlock()
t.Fatalf("%s模拟进程启动即退出:%v\n%s", module, err, text)
case <-ctx.Done():
cancel()
<-done
mu.Lock()
text := log.String()
mu.Unlock()
t.Fatalf("%s模拟进程启动失败:%s", module, text)
}
t.Cleanup(func() {
requestCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
req, _ := http.NewRequestWithContext(requestCtx, http.MethodPost, address+"/__test/stop", nil)
if res, err := http.DefaultClient.Do(req); err == nil {
_ = res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("%s停止请求失败:%d", module, res.StatusCode)
}
} else {
t.Errorf("%s停止请求失败:%v", module, err)
}
if err := <-done; err != nil {
mu.Lock()
text := log.String()
mu.Unlock()
t.Errorf("%s模拟进程异常退出:%v\n%s", module, err, text)
} else {
t.Logf("%s helper正常shutdown并退出0", module)
}
cancel()
})
return address
}
func TestAPNHTTPChainWorkerClientProxyChanCarrier(t *testing.T) {
if os.Getenv("APN_HTTP_CHAIN_INTEGRATION") != "1" {
t.Skip("未启用本机跨模块HTTP及专用MySQL模拟链")
}
chanURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-chan")
proxyURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-proxy", "APN_HTTP_CHAIN_CHAN="+chanURL)
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"2s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(proxyURL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
client := New()
expectedWrites := map[string]int{}
expectedQueries := map[string]int{}
expectedSubmits := 0
for _, scenario := range []string{"drop-response", "top-error", "lost-response", "failed-task"} {
t.Run(scenario, func(t *testing.T) {
input := &model.SimCardApnChangeInput{ReqData: model.SimCardApnChangeInputItem{OperationID: fmt.Sprintf("http-chain-%s-%d", scenario, time.Now().UnixNano()), OperType: "1", ApnName: "CMIOT"}}
prefix := scenario + "-"
if scenario == "lost-response" {
prefix = "lost-"
}
if scenario == "failed-task" {
prefix = "failed-"
}
for i := 0; i < 10; i++ {
input.SimKeys = append(input.SimKeys, &model.SimKey{ChanKey: "fake-channel", ICCID: fmt.Sprintf("fake-card-%s-%d", scenario, i), MSISDN: fmt.Sprintf("%s%d", prefix, i)})
}
out, err := client.SubmitSimApnFunction(context.Background(), input)
if err != nil || len(out) != 10 {
t.Fatalf("首发结果无效:%+v %v", out, err)
}
for _, item := range out {
if scenario != "failed-task" && !item.ResData.Uncertain {
t.Fatalf("丢响应被确认:%+v", item)
}
}
res, err := http.Post(chanURL+"/__test/restart", "application/json", nil)
if err != nil {
t.Fatal(err)
}
_ = res.Body.Close()
recovered, err := client.QuerySimApnFunctionResult(context.Background(), input)
if err != nil || len(recovered) != 10 {
t.Fatalf("恢复失败:%v", err)
}
duplicate, err := client.SubmitSimApnFunction(context.Background(), input)
if err != nil || len(duplicate) != 10 {
t.Fatalf("重复恢复失败:%v", err)
}
if scenario == "lost-response" {
for i, item := range recovered {
if !item.ResData.Uncertain || item.ResData.OrderNum != "" || !duplicate[i].ResData.Uncertain {
t.Fatalf("无jobId必须未知:%+v", item)
}
}
} else {
job := recovered[0].ResData.OrderNum
for i, item := range recovered {
if job == "" || item.ResData.OrderNum != job || !item.ResData.Pending || duplicate[i].ResData.OrderNum != job {
t.Fatalf("恢复丢失已持久化jobId:%+v", item)
}
}
expectedQueries[job] = 3
input.ReqData.QueryJobID = job
for attempt := 0; attempt < 3; attempt++ {
result, e := client.QuerySimApnFunctionResult(context.Background(), input)
if e != nil || len(result) != 10 {
t.Fatalf("单次查询失败:%v", e)
}
for _, item := range result {
if attempt < 2 && (!item.ResData.Pending || item.Message == "") {
t.Fatalf("14008/处理中不得终态:%+v", item)
}
if attempt == 2 && (item.ResData.Pending || item.ResData.Uncertain || (scenario == "failed-task" && item.Code != 9999) || (scenario != "failed-task" && item.Code != 0)) {
t.Fatalf("终态语义错误:%+v", item)
}
}
}
}
statsResponse, e := http.Get(chanURL + "/__test/stats")
if e != nil {
t.Fatal(e)
}
var stats struct {
Writes map[string]int `json:"writes"`
Queries map[string]int `json:"queries"`
Submits int `json:"submits"`
Unexpected int `json:"unexpected"`
}
e = json.NewDecoder(statsResponse.Body).Decode(&stats)
_ = statsResponse.Body.Close()
expectedSubmits++
for _, key := range input.SimKeys {
expectedWrites[key.MSISDN] = 1
}
if e != nil || stats.Submits != expectedSubmits || stats.Unexpected != 0 || len(stats.Writes) != len(expectedWrites) || len(stats.Queries) != len(expectedQueries) {
t.Fatalf("额外或缺失上游请求:stats=%+v err=%v", stats, e)
}
for key, count := range expectedWrites {
if stats.Writes[key] != count {
t.Fatalf("逐卡办理次数错误:%s=%d,预期%d", key, stats.Writes[key], count)
}
}
for job, count := range expectedQueries {
if stats.Queries[job] != count {
t.Fatalf("纯查询次数错误:%s=%d,预期%d", job, stats.Queries[job], count)
}
}
counts := make([]int, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
counts = append(counts, stats.Writes[key.MSISDN])
}
t.Logf("十卡实际办理计数=%v 累计提交=%d 无额外请求;丢失jobId=%v 明确失败终态=%v", counts, stats.Submits, scenario == "lost-response", scenario == "failed-task")
})
}
}
func TestAsyncApnCapabilityGateBlocksOldChanBeforeSubmit(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
for _, tc := range []struct {
name string
enabled int
version int
wantError bool
}{{"显式关闭", 0, 1, true}, {"旧版无能力", 1, 0, true}, {"能力就绪", 1, 1, false}} {
t.Run(tc.name, func(t *testing.T) {
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
return
}
submits++
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":%d}}}`, strings.TrimPrefix(server.URL, "http://"), tc.enabled))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
_, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}})
var notSent interface{ APNRequestNotSent() bool }
if (err != nil) != tc.wantError || (tc.wantError && (submits != 0 || !errors.As(err, &notSent) || !notSent.APNRequestNotSent())) || (!tc.wantError && (submits != 1 || probes != 1)) {
t.Fatalf("能力门禁失效:probes=%d submits=%d err=%v", probes, submits, err)
}
})
}
}
// TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig 固化默认开启:
// 配置里不写 apnAsyncProtocolVersion 时,提交不应再被门禁拦下,兼容性交给协议能力探测。
func TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`))
return
}
submits++
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
if _, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}); err != nil {
t.Fatalf("未配置开关时提交应默认放行:%v", err)
}
if probes != 1 || submits != 1 {
t.Fatalf("默认放行后仍应做协议探测:probes=%d submits=%d", probes, submits)
}
}
func TestAsyncApnReadOnlyQueryBypassesSubmitGateButRequiresProtocol(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
for _, tc := range []struct {
name, jobID, operationID string
version int
missingProbe bool
wantQuery bool
}{
{name: "existing_job", jobID: "existing-job", version: 1, wantQuery: true},
{name: "existing_operation", operationID: "existing-operation", version: 1, wantQuery: true},
{name: "legacy_protocol", jobID: "existing-job", version: 0},
{name: "missing_capability", jobID: "existing-job", missingProbe: true},
} {
t.Run(tc.name, func(t *testing.T) {
probes, queries := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
if tc.missingProbe {
_ = json.NewEncoder(w).Encode(map[string]any{"code": 404, "message": "missing capability"})
return
}
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
return
}
if r.URL.Path != "/sim/change/sim-apn" {
t.Errorf("unexpected endpoint: %s", r.URL.Path)
}
queries++
var request model.SimCardApnChangeInput
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
if request.ReqData.SubmitOnly || request.ReqData.QueryJobID != tc.jobID || request.ReqData.RecoverOnly != (tc.jobID == "") {
t.Errorf("query accidentally submitted: %+v", request.ReqData)
}
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"orderNum":"existing-job","pending":true}}]}`))
}))
defer server.Close()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":0}}}`, strings.TrimPrefix(server.URL, "http://")))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
result, err := New().QuerySimApnFunctionResult(context.Background(), &model.SimCardApnChangeInput{
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}},
ReqData: model.SimCardApnChangeInputItem{OperationID: tc.operationID, QueryJobID: tc.jobID},
})
if probes != 1 || (err == nil) != tc.wantQuery || queries != map[bool]int{true: 1, false: 0}[tc.wantQuery] {
t.Fatalf("read-only protocol guard failed: probes=%d queries=%d err=%v result=%+v", probes, queries, err, result)
}
})
}
}
func TestAsyncApnSecondProbeRejectionIsTypedAndTransportIsNot(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
defer g.Cfg().SetAdapter(oldAdapter)
for _, probeFails := range []bool{true, false} {
probes, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 夹具不共享连接:并发跑包时复用空闲连接会被对端关闭,
// 使失败从"提交阶段"漂移到"只读探测阶段",造成与被测语义无关的抖动。
w.Header().Set("Connection", "close")
if r.URL.Path == "/sim/query/sim-apn-capability" {
probes++
if probes == 2 && probeFails {
http.Error(w, "模拟第二次探测失败", http.StatusNotFound)
return
}
fmt.Fprint(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`)
return
}
submits++
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
_ = conn.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)
input := &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}
client := New()
if err := client.CheckSimApnAsyncCapability(context.Background(), input); err != nil {
t.Fatal(err)
}
_, err = client.SubmitSimApnFunction(context.Background(), input)
server.Close()
var notSent interface{ APNRequestNotSent() bool }
isNotSent := errors.As(err, &notSent) && notSent.APNRequestNotSent()
if err == nil || isNotSent != probeFails || (probeFails && submits != 0) || (!probeFails && submits != 1) || probes != 2 {
t.Fatalf("零写类型边界错误:probeFails=%v probes=%d submits=%d err=%v", probeFails, probes, submits, err)
}
}
}
func TestSimCardStatusChangeProtocol(t *testing.T) {
input := model.SimCardStatusChangeInput{
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
ReqData: model.SimCardStatusChangeInputItem{OperType: "1"},
}
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"1"}}`
if got := string(body); got != want {
t.Fatalf("request body = %s, want %s", got, want)
}
}
func TestCurrentUsageConsumerProtocol(t *testing.T) {
input := model.SimDataUsageInput{
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001", IMSI: "460001", MSISDN: "13800000000"}},
}
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"460001","msisdn":"13800000000"}],"reqData":{}}`
if got := string(body); got != want {
t.Fatalf("request body = %s, want %s", got, want)
}
client := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110")
if client.currentUsageClientName != "iotlcmupstreamchan-http-svc:8110" {
t.Fatalf("current usage client = %q", client.currentUsageClientName)
}
if client.currentUsageTimeout != 0 {
t.Fatalf("default current usage timeout = %s, want unset", client.currentUsageTimeout)
}
configured := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110", 90*time.Second)
if configured.currentUsageTimeout != 90*time.Second {
t.Fatalf("configured current usage timeout = %s, want 90s", configured.currentUsageTimeout)
}
}
func TestInboundBasicInfoClientCanUseDedicatedEndpoint(t *testing.T) {
client := NewWithInboundBasicInfoClient("iotlcm.example.com:8110")
clientName, err := client.inboundBasicInfoAddress(context.Background())
if err != nil {
t.Fatal(err)
}
if clientName != "iotlcm.example.com:8110" {
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
}
}
func TestInboundBasicInfoClientCanUseEnvEndpoint(t *testing.T) {
const endpoint = "iotlcm.example.com:8110"
t.Setenv(inboundBasicInfoClientNameEnv, endpoint)
client := New()
clientName, err := client.inboundBasicInfoAddress(context.Background())
if err != nil {
t.Fatal(err)
}
if clientName != endpoint {
t.Fatalf("inbound basic info client = %q, want env endpoint", clientName)
}
}
func TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint(t *testing.T) {
if value := os.Getenv(inboundBasicInfoClientNameEnv); value != "" {
t.Setenv(inboundBasicInfoClientNameEnv, "")
}
client := New()
clientName, err := client.inboundBasicInfoAddress(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(clientName, ":8110") {
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
}
}
func TestDecodeAsyncApnResponseKeepsUnconfirmedState(t *testing.T) {
body := []byte(`{"code":0,"data":[{"code":0,"resData":{"orderNum":"job-1","pending":true}},{"code":1,"message":"响应未确认","resData":{"orderNum":"job-2","uncertain":true}}]}`)
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
if err != nil || len(items) != 2 {
t.Fatalf("解析异步响应失败:%v", err)
}
if !items[0].ResData.Pending || items[0].ResData.OrderNum != "job-1" || !items[1].ResData.Uncertain {
t.Fatalf("异步待查字段丢失:%+v", items)
}
}
func TestSimCardApnChangeProtocol(t *testing.T) {
input := model.SimCardApnChangeInput{
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
ReqData: model.SimCardApnChangeInputItem{OperType: "0", ApnName: "CMIOT"},
}
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"0","apnName":"CMIOT"}}`
if got := string(body); got != want {
t.Fatalf("request body = %s, want %s", got, want)
}
}
func TestDecodeSimCardApnChangeResponseKeepsFailedItems(t *testing.T) {
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("item count = %d, want 2", len(items))
}
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
t.Fatalf("failed item was not preserved: %+v", items[1])
}
}
func TestDecodeSimCardStatusChangeResponseKeepsFailedItems(t *testing.T) {
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
items, err := decodeResponse[model.SimCardStatusChangeOutputItem](body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("item count = %d, want 2", len(items))
}
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
t.Fatalf("failed item was not preserved: %+v", items[1])
}
}
@@ -0,0 +1,49 @@
package upstream
import (
"testing"
"time"
)
// TestResolveUpstreamTimeoutScalesWithCardCount 覆盖批量操作预算:
// worker 不能再把批量请求压回 2 分钟,否则通道仍在按卡串行办理时会被提前判失败。
func TestResolveUpstreamTimeoutScalesWithCardCount(t *testing.T) {
cases := []struct {
name string
path string
itemCount int
perCard time.Duration
override time.Duration
maxTimeout time.Duration
apnTimeout time.Duration
want time.Duration
}{
{"单卡", "/sim/change/sim-status", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second},
{"零卡按单卡处理", "/sim/change/sim-status", 0, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second},
{"50卡按单卡预算放大", "/sim/change/sim-status", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 500 * time.Second},
{"100卡恰好低于上限", "/sim/change/sim-status", 100, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 1000 * time.Second},
{"超大批量按上限截断", "/sim/query/usage", 1000, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, defaultUpstreamProxyMaxTimeout},
{"单卡预算缺失用默认值", "/sim/query/usage", 2, 0, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 20 * time.Second},
{"显式覆盖仍按卡数放大", "/sim/query/usage", 3, 10 * time.Second, 2 * time.Second, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 6 * time.Second},
{"数据开停使用独立预算", "/sim/change/sim-apn", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 240 * time.Second},
{"数据开停预算缺失回退", "/sim/change/sim-apn", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 0, 240 * time.Second},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := resolveUpstreamTimeout(c.path, c.itemCount, c.perCard, c.override, c.maxTimeout, c.apnTimeout)
if got != c.want {
t.Fatalf("resolveUpstreamTimeout=%s,期望%s", got, c.want)
}
})
}
}
// TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout 固化跨服务预算约束:
// worker 上限必须大于 proxy 的 gateway.batchTimeout(默认 20 分钟),
// 使超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理。
func TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout(t *testing.T) {
const proxyBatchTimeout = 20 * time.Minute
if defaultUpstreamProxyMaxTimeout <= proxyBatchTimeout {
t.Fatalf("worker 上限%s 必须大于 proxy 批量上限%s", defaultUpstreamProxyMaxTimeout, proxyBatchTimeout)
}
}
+64
View File
@@ -0,0 +1,64 @@
package cmd
import (
"context"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcmd"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream"
datarepository "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/data"
dataservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/worker"
)
var QueryAPNReview = gcmd.Command{
Name: "query-apn-review",
Usage: "query-apn-review --operation-id=<operationId> | --batch-no=<batchNo>",
Brief: "只读查询待核查APN子批并同步已确认结果(传 batch-no 时同步该父批下全部待核查子批)",
Arguments: []gcmd.Argument{
{Name: "operation-id", Brief: "待核查子批operationId"},
{Name: "batch-no", Brief: "待核查父批批次号,按父批同步全部待核查子批"},
},
Func: func(ctx context.Context, parser *gcmd.Parser) error {
operationID := strings.TrimSpace(parser.GetOpt("operation-id").String())
batchNo := strings.TrimSpace(parser.GetOpt("batch-no").String())
if operationID == "" && batchNo == "" {
return gerror.New("必须提供 operation-id 或 batch-no")
}
if operationID != "" && batchNo != "" {
return gerror.New("operation-id 与 batch-no 不能同时提供")
}
executor := dataservice.NewExecutor(datarepository.New(), upstream.New())
if batchNo != "" {
queried, err := executor.QueryReviewBatch(ctx, batchNo)
if err != nil {
return err
}
g.Log().Infof(ctx, "APN待核查批量同步完成 batchNo=%s 已同步=%d", batchNo, queried)
return nil
}
if err := executor.QueryReview(ctx, operationID); err != nil {
return err
}
g.Log().Infof(ctx, "APN待核查查询完成 operationId=%s", operationID)
return nil
},
}
func init() {
if err := Main.AddCommand(&QueryAPNReview); err != nil {
panic(err)
}
}
var Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start RabbitMQ worker",
Func: func(ctx context.Context, _ *gcmd.Parser) error {
return worker.Run(ctx)
},
}
@@ -0,0 +1,12 @@
package consts
// BatchSubBatchSize 是批量开停数据 / 停复机执行时,单个上游通道每次请求携带的卡数量上限。
// upstream-chan 对同一通道按卡串行调用运营商接口,单个请求耗时随卡数线性增长;
// 因此超时预算必须按「单卡预算 × 卡数」计算并配置足够上限(见 upstream.proxy.maxTimeout),
// 不能依赖固定 2 分钟上限,否则请求仍在上游办理就会被判失败。
const BatchSubBatchSize = 100
// BatchSubBatchConcurrency 是同一通道内子批次的并发度,受信号量限制:
// 即最多同时向上游发起 BatchSubBatchConcurrency 个请求,等价于最多同时发起同样数量的运营商调用,
// 既避免串行过慢,也避免无节制并发打爆运营商限流。如需更保守可调小该值。
const BatchSubBatchConcurrency = 5
+160
View File
@@ -0,0 +1,160 @@
package consts
func FormalPackageTypes() []int { return []int{PackageTypeCard, PackageTypePool} }
func IsAddPackageType(packageType int) bool {
return packageType == PackageTypeCardAddPackage || packageType == PackageTypePoolAddPackage
}
const (
BatchTypeInbound = 1
BatchTypeAllocate = 3
BatchTypeCardAddPackage = 11
BatchTypePoolAddPackage = 12
BatchTypeSimCardStart = 103
BatchTypeSimCardStop = 104
BatchTypeSimCardRestart = 105
BatchTypeSimCardCancel = 106
BatchTypeSimCardUnsub = 108
BatchTypeDataCut = 107
BatchTypeDataStart = 109
BatchTypeDataStop = 110
BatchTypeSimWarningConfig = 40
BatchTypeTrafficCutoffConfig = 41
BatchTypeTrafficCutoff = BatchTypeTrafficCutoffConfig // Deprecated: use BatchTypeTrafficCutoffConfig.
// 异步续费/变更扩展
BatchTypePrepare = 4 // 预处理(renew/change 共用 prepare)
BatchTypeRenew = 101 // 卡续费
BatchTypeChange = 102 // 卡变更套餐
// t_sim_card_bind_pkg.package_type 中,只有正式卡套餐和池套餐参与续费/变更判断。
PackageTypeCard = 101
PackageTypePool = 102
PackageTypeCardAddPackage = 103
PackageTypePoolAddPackage = 104
BatchSourceTypeBatchSelect = 0
BatchSourceTypeRange = 1
BatchSourceTypeBatchInput = 2
BatchSourceTypeFile = 3
BatchSourceTypeScheduled = 4
BatchSourceTypeExcel = BatchSourceTypeFile
BatchSourceTypeInput = BatchSourceTypeBatchInput
BatchNoPrefixCardAddPackage = "ADP"
BatchNoPrefixPoolAddPackage = "PAP"
SimCardStatusOperTypeStop = "0"
SimCardStatusOperTypeStart = "1"
RestartPhaseCreated = "created"
RestartPhaseWaitingResume = "waiting_resume"
RestartPhaseResuming = "resuming"
RestartPhaseFinished = "finished"
SimBossCardStatusActivated = 2
SimBossCardStatusStopped = 4
SimBossCardStatusCanceled = 9
SimApnOperTypeOpen = "0"
SimApnOperTypeStop = "1"
SimApnNameDefault = "CMIOT"
SimDataServiceStatusEnabled = 1
SimDataServiceStatusDisabled = 0
BatchAsyncStatusPending = 1
BatchAsyncStatusProcessing = 2
BatchAsyncStatusSuccess = 3
BatchAsyncStatusFailed = 4
BatchAsyncStatusPartialSuccess = 5
BatchAsyncStatusUnknown = 6
BatchAsyncStatusReview = 6
BatchRunResultPending = 0
BatchRunResultRunning = 1
BatchRunResultSuccess = 2
BatchRunResultFailed = 3
BatchRunResultPartialSuccess = 4
BatchRunResultUnknown = 5
BatchRunResultReview = 5
BatchDetailStatusPending = 0
BatchDetailStatusRunning = 1
BatchDetailStatusSuccess = 2
BatchDetailStatusFailed = 3
BatchDetailStatusUnknown = 4
BatchDetailStatusNotExecuted = 5
ResourceTypeExcel = 1
DownloadStatusGenerated = 1
ResourceExportSceneCardAddPackage = 11
ResourceExportScenePoolAddPackage = 12
// 卡套餐有效状态
PackageStatusNone = 0
PackageStatusPending = 1
PackageStatusEffective = 2
PackageStatusExpired = 3
// 订购记录有效状态(t_sim_card_bind_pkg.effective_status)
EffectiveStatusPending = 0
EffectiveStatusEffective = 1
EffectiveStatusExpired = 2
EffectiveStatusTerminated = 3
EffectiveStatusVoided = 4
// 退订状态(t_order.return_status)
OrderReturnStatusNone = 0
OrderReturnStatusSuccess = 4
OrderReturnStatusPartial = 3
// Renew/change order contract. These values mirror the Admin cardpackage
// domain and the production t_order/t_order_item enum contract.
OrderTypeCardSubscribe = 1
OrderTypeCardRenew = 2
OrderTypeCardChange = 3
OrderStatusCompleted = 5
CustomerTypeAgent = 1
PayTypeOffline = 3
PayTypeBalance = 4
PayStatusPaid = 2
ProductTypeCard = 1
PayModeYearMonth = 1
ShippingMethodNone = 3
PackageBusinessTypeSubscribe = 0
PackageBusinessTypeChange = 1
PackageBusinessTypeRenew = 2
ResourceBatchDownloadTaskSimCardAddPackage = "单卡加油包结果"
ResourceBatchDownloadTaskSimPoolAddPackage = "流量池加油包结果"
SimCardAddPackageMaxFileSize = 50 << 20
SimPoolAddPackageMaxFileSize = 50 << 20
SimCardAddPackageSubDirImportExcel = "sim-card-add-package/import"
SimCardAddPackageSubDirResultExcel = "sim-card-add-package/result"
SimPoolAddPackageSubDirImportExcel = "sim-pool-add-package/import"
SimPoolAddPackageSubDirResultExcel = "sim-pool-add-package/result"
// 续费/变更结果目录
SimCardRenewResultSubDir = "sim-card-renew/result"
SimCardChangeResultSubDir = "sim-card-change/result"
// 续费/变更结果任务名(沿用 Admin 约定)
ResultTaskSimCardRenew = "卡续费结果"
ResultTaskSimCardChange = "卡变更套餐结果"
ResultTaskSimCardPrepare = "卡续费/变更预处理结果"
)
// RenewPriceSource 续费/变更价格来源
const (
RenewPriceSourceSystem = "system"
RenewPriceSourceAgentDefault = "agentDefault"
RenewPriceSourceTarget = "target"
)
@@ -0,0 +1,42 @@
package consts
import "strings"
const (
BillingMethodMonth = 1
BillingMethodYear = 2
RenewDurationUnitMonth = "month"
RenewDurationUnitYear = "year"
)
func BillingMethodDurationUnit(billingMethod int) string {
if billingMethod == BillingMethodMonth {
return RenewDurationUnitMonth
}
return RenewDurationUnitYear
}
func NormalizeRenewDurationUnit(unit string) string {
if strings.EqualFold(strings.TrimSpace(unit), RenewDurationUnitMonth) {
return RenewDurationUnitMonth
}
return RenewDurationUnitYear
}
func RenewDurationUnitText(unit string) string {
if NormalizeRenewDurationUnit(unit) == RenewDurationUnitMonth {
return "个月"
}
return "年"
}
func DurationValueToMonths(unit string, value int) int {
if value <= 0 {
return 0
}
if NormalizeRenewDurationUnit(unit) == RenewDurationUnitMonth {
return value
}
return value * 12
}
+34
View File
@@ -0,0 +1,34 @@
package consts
const (
QueueTrafficUsageDailyRetry = "iotlcm.traffic_usage.daily.retry"
QueueTrafficUsageMonthlyRetry = "iotlcm.traffic_usage.monthly.retry"
QueueTrafficUsageMonthlyStart = "iotlcm.traffic_usage.monthly.start"
// QueueTrafficUsageDailyStart carries one command per full daily usage run.
// Admin publishes it; Worker owns the card scan and Redis production.
QueueTrafficUsageDailyStart = "iotlcm.traffic_usage.daily.start"
// QueueTrafficUsageMonthlyHistoryStart carries one command per full monthly
// usage history run, which writes the TDengine monthly table. It is distinct
// from QueueTrafficUsageMonthlyStart, which fills the MySQL current-month
// snapshot.
QueueTrafficUsageMonthlyHistoryStart = "iotlcm.traffic_usage.monthly_history.start"
QueueSimStatusQueueStart = "iotlcm.sim_status.queue.start"
QueueSimStatusQueueTask = "iotlcm.sim_status.queue.task"
QueueImeiQueueStart = "iotlcm.imei.queue.start"
QueueWorkOrderAutoClose = "iotlcm.work_order.auto_close"
QueueBalanceRechargeTimeout = "iotlcm.balance.recharge.timeout"
QueueBalanceRechargeDelay = "iotlcm.balance.recharge.delay"
QueueWorkerDiagnostic = "iotlcm.worker.diagnostic"
QueueBatchExecute = "iotlcm.batch.execute"
QueueBatchExecuteRetry = "iotlcm.batch.execute.retry"
QueueBatchExecuteDeadLetter = "iotlcm.batch.execute.dlq"
QueueBatchRestartResumeDelay = "iotlcm.batch.restart.resume.delay"
QueuePackageCustomerSync = "iotlcm.package_customer.sync"
QueueChannelRefresh = "iotlcm.channel.refresh"
// QueueDataReviewQuery 承载"手动同步上游结果"命令:Admin 发布父批号,
// Worker 只对既有 operationId 做只读补查并回写,绝不重发办理。
QueueDataReviewQuery = "iotlcm.data.review.query"
// QueueBalanceRechargeDeadLetter 保存重试次数耗尽后仍失败的充值超时消息,
// 供人工排查(例如支付宝凭据未配置导致的永久失败),不再自动重投。
QueueBalanceRechargeDeadLetter = "iotlcm.balance.recharge.dlq"
)
+11
View File
@@ -0,0 +1,11 @@
package consts
const (
DefaultSystemTenantCode = "000000"
SuperAdminRoleId = int64(1)
SysRoleMenuDataScopeRole = "0"
SysRoleDataScopeAll = "1"
SysRoleDataScopeCustom = "2"
SysRoleDataScopeDeptAndBelow = "4"
QueueAgentRoleSync = "iotlcm.agent.role.sync"
)
@@ -0,0 +1,103 @@
package rabbitmq
import (
"context"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
// defaultBalanceRechargeMaxRetry 是充值超时消息的默认最大重试次数。
// 支付宝凭据未配置等永久性失败会让 CheckRechargeTimeout 一直返回错误,
// 必须设置上限,否则消息会无限重投并刷屏日志。
const defaultBalanceRechargeMaxRetry = 100
// 包级变量便于测试注入发布行为,与 trafficUsagePublish 保持一致。
var balanceRechargePublish = mq.Publish
var balanceRechargePublishDelayed = mq.PublishDelayed
func (c *Consumers) initBalanceRechargeTimeout(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "balance recharge timeout consumer skipped, RabbitMQ disabled")
return nil
}
if c.balance == nil {
return gerror.New("balance recharge handler is not configured")
}
retryDelay, err := getBalanceRechargeRetryDelay(ctx)
if err != nil {
return err
}
maxRetry, err := getBalanceRechargeMaxRetry(ctx)
if err != nil {
return err
}
c.balanceRechargeRetryDelay = retryDelay
c.balanceRechargeMaxRetry = maxRetry
if err := mq.DeclareQueue(ctx, consts.QueueBalanceRechargeDeadLetter); err != nil {
return gerror.Wrap(err, "declare balance recharge dead-letter queue failed")
}
if err := mq.ConsumeJSON[model.BalanceRechargeTimeoutMessage](
ctx,
consts.QueueBalanceRechargeTimeout,
"iotlcm-balance-recharge-timeout",
c.handleBalanceRechargeTimeout,
); err != nil {
return err
}
g.Log().Infof(ctx, "balance recharge timeout consumer started, queue:%s, retryDelay:%s, maxRetry:%d", consts.QueueBalanceRechargeTimeout, retryDelay, maxRetry)
return nil
}
func (c *Consumers) handleBalanceRechargeTimeout(ctx context.Context, message model.BalanceRechargeTimeoutMessage) error {
if message.OutTradeNo == "" {
g.Log().Errorf(ctx, "balance recharge timeout message dropped, invalid message:%+v", message)
return nil
}
if c.balance == nil {
return gerror.New("balance recharge handler is not configured")
}
if err := c.balance.CheckRechargeTimeout(ctx, message.OutTradeNo); err != nil {
message.RetryCount++
if shouldDeadLetterBalanceRecharge(message.RetryCount, c.balanceRechargeMaxRetry) {
if publishErr := balanceRechargePublish(ctx, consts.QueueBalanceRechargeDeadLetter, message); publishErr != nil {
return gerror.Wrap(publishErr, "publish balance recharge dead-letter message failed")
}
g.Log().Errorf(ctx, "balance recharge timeout retries exhausted and message sent to dead-letter queue, outTradeNo:%s, retryCount:%d, maxRetry:%d, err:%+v", message.OutTradeNo, message.RetryCount, c.balanceRechargeMaxRetry, err)
return nil
}
if retryErr := balanceRechargePublishDelayed(ctx, consts.QueueBalanceRechargeDelay, consts.QueueBalanceRechargeTimeout, c.balanceRechargeRetryDelay, message); retryErr != nil {
return gerror.Wrap(retryErr, "republish balance recharge timeout message failed")
}
g.Log().Warningf(ctx, "balance recharge timeout query failed and will retry, outTradeNo:%s, retryCount:%d, err:%+v", message.OutTradeNo, message.RetryCount, err)
}
return nil
}
// shouldDeadLetterBalanceRecharge 判断本次失败后是否放弃重试。
// retryCount 是自增后的重试次数,因此 maxRetry 表示最多重投多少次。
func shouldDeadLetterBalanceRecharge(retryCount, maxRetry int) bool {
return retryCount > maxRetry
}
func getBalanceRechargeRetryDelay(ctx context.Context) (time.Duration, error) {
value := g.Cfg().MustGet(ctx, "rabbitmq.balanceRecharge.retryDelay", "1m").String()
delay, err := time.ParseDuration(value)
if err != nil || delay <= 0 {
return 0, gerror.Newf("balance recharge retry delay is invalid: %q", value)
}
return delay, nil
}
func getBalanceRechargeMaxRetry(ctx context.Context) (int, error) {
maxRetry := g.Cfg().MustGet(ctx, "rabbitmq.balanceRecharge.maxRetry", defaultBalanceRechargeMaxRetry).Int()
if maxRetry <= 0 {
return 0, gerror.Newf("balance recharge max retry is invalid: %d", maxRetry)
}
return maxRetry, nil
}
@@ -0,0 +1,153 @@
package rabbitmq
import (
"context"
"errors"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func stubBalanceRechargePublish(t *testing.T) {
t.Helper()
originalPublish, originalDelayed := balanceRechargePublish, balanceRechargePublishDelayed
t.Cleanup(func() {
balanceRechargePublish, balanceRechargePublishDelayed = originalPublish, originalDelayed
})
balanceRechargePublish = func(context.Context, string, any) error { return nil }
balanceRechargePublishDelayed = func(context.Context, string, string, time.Duration, any) error { return nil }
}
func setBalanceRechargeTestConfig(t *testing.T, content string) {
t.Helper()
oldAdapter := g.Cfg().GetAdapter()
adapter, err := gcfg.NewAdapterContent(content)
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
}
func TestHandleBalanceRechargeTimeoutRepublishesDelayedBelowMaxRetry(t *testing.T) {
stubBalanceRechargePublish(t)
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{err: errors.New("Alipay credentials are not configured")})
consumers.balanceRechargeRetryDelay = time.Minute
consumers.balanceRechargeMaxRetry = 3
delayedCalls := 0
balanceRechargePublishDelayed = func(_ context.Context, delayQueue, targetQueue string, delay time.Duration, data any) error {
delayedCalls++
if delayQueue != consts.QueueBalanceRechargeDelay || targetQueue != consts.QueueBalanceRechargeTimeout || delay != time.Minute {
t.Fatalf("unexpected delayed publish: %s -> %s, delay:%s", delayQueue, targetQueue, delay)
}
message, ok := data.(model.BalanceRechargeTimeoutMessage)
if !ok || message.OutTradeNo != "ALI-1" || message.RetryCount != 3 {
t.Fatalf("unexpected retry payload: %+v", data)
}
return nil
}
deadLetterCalls := 0
balanceRechargePublish = func(_ context.Context, queue string, _ any) error {
deadLetterCalls++
return nil
}
// 自增后达到上限仍未超过,应继续重投延迟队列。
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 2}); err != nil {
t.Fatalf("retry below cap returned error: %v", err)
}
if delayedCalls != 1 || deadLetterCalls != 0 {
t.Fatalf("delayedCalls=%d deadLetterCalls=%d, want 1 and 0", delayedCalls, deadLetterCalls)
}
}
func TestHandleBalanceRechargeTimeoutDeadLettersAboveMaxRetry(t *testing.T) {
stubBalanceRechargePublish(t)
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{err: errors.New("Alipay credentials are not configured")})
consumers.balanceRechargeRetryDelay = time.Minute
consumers.balanceRechargeMaxRetry = 3
delayedCalls := 0
balanceRechargePublishDelayed = func(context.Context, string, string, time.Duration, any) error {
delayedCalls++
return nil
}
deadLetterCalls := 0
balanceRechargePublish = func(_ context.Context, queue string, data any) error {
deadLetterCalls++
if queue != consts.QueueBalanceRechargeDeadLetter {
t.Fatalf("dead letter queue = %s, want %s", queue, consts.QueueBalanceRechargeDeadLetter)
}
message, ok := data.(model.BalanceRechargeTimeoutMessage)
if !ok || message.OutTradeNo != "ALI-1" || message.RetryCount != 4 {
t.Fatalf("unexpected dead-letter payload: %+v", data)
}
return nil
}
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 3}); err != nil {
t.Fatalf("dead-letter path returned error: %v", err)
}
if delayedCalls != 0 || deadLetterCalls != 1 {
t.Fatalf("delayedCalls=%d deadLetterCalls=%d, want 0 and 1", delayedCalls, deadLetterCalls)
}
}
func TestHandleBalanceRechargeTimeoutReturnsPublishErrors(t *testing.T) {
stubBalanceRechargePublish(t)
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{err: errors.New("Alipay credentials are not configured")})
consumers.balanceRechargeRetryDelay = time.Minute
consumers.balanceRechargeMaxRetry = 3
expected := errors.New("broker unavailable")
balanceRechargePublish = func(context.Context, string, any) error { return expected }
balanceRechargePublishDelayed = func(context.Context, string, string, time.Duration, any) error { return expected }
// 重投失败必须向上返回错误,让消费框架 Nack 重新入队,不能静默丢失消息。
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 0}); !errors.Is(err, expected) {
t.Fatalf("delayed publish failure = %v, want %v", err, expected)
}
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 3}); !errors.Is(err, expected) {
t.Fatalf("dead-letter publish failure = %v, want %v", err, expected)
}
}
func TestGetBalanceRechargeMaxRetry(t *testing.T) {
t.Run("defaults when unconfigured", func(t *testing.T) {
setBalanceRechargeTestConfig(t, "rabbitmq:\n balanceRecharge:\n retryDelay: \"1m\"\n")
maxRetry, err := getBalanceRechargeMaxRetry(context.Background())
if err != nil || maxRetry != defaultBalanceRechargeMaxRetry {
t.Fatalf("maxRetry = %d (err=%v), want %d", maxRetry, err, defaultBalanceRechargeMaxRetry)
}
})
t.Run("reads configured value", func(t *testing.T) {
setBalanceRechargeTestConfig(t, "rabbitmq:\n balanceRecharge:\n maxRetry: 7\n")
maxRetry, err := getBalanceRechargeMaxRetry(context.Background())
if err != nil || maxRetry != 7 {
t.Fatalf("maxRetry = %d (err=%v), want 7", maxRetry, err)
}
})
for _, test := range []struct {
name string
value string
}{
{name: "zero", value: "0"},
{name: "negative", value: "-1"},
{name: "non-numeric", value: "\"abc\""},
} {
t.Run("rejects "+test.name, func(t *testing.T) {
setBalanceRechargeTestConfig(t, "rabbitmq:\n balanceRecharge:\n maxRetry: "+test.value+"\n")
if _, err := getBalanceRechargeMaxRetry(context.Background()); err == nil {
t.Fatalf("maxRetry=%s was accepted, want error", test.value)
}
})
}
}
@@ -0,0 +1,178 @@
package rabbitmq
import (
"context"
"encoding/json"
"errors"
"strconv"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
amqp "github.com/rabbitmq/amqp091-go"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
)
const batchRetryCountHeader = "x-iotlcm-retry-count"
func (c *Consumers) initBatchExecute(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "batch execute consumer skipped, RabbitMQ disabled")
return nil
}
if c.batch == nil {
return gerror.New("batch execute handler is not configured")
}
retryDelay, maxRetries, err := getBatchExecuteRetryConfig(ctx)
if err != nil {
return err
}
c.batchExecuteRetryDelay = retryDelay
c.batchExecuteMaxRetries = maxRetries
if err = mq.DeclareQueue(ctx, consts.QueueBatchExecuteDeadLetter); err != nil {
return gerror.Wrap(err, "declare batch execute dead-letter queue failed")
}
if err = mq.ConsumeJSON[batch.Message](
ctx,
consts.QueueBatchExecute,
"iotlcm-batch-execute",
c.handleBatchExecute,
); err != nil {
return err
}
g.Log().Infof(ctx, "batch execute consumer started, queue:%s, retryDelay:%s, maxRetries:%d", consts.QueueBatchExecute, retryDelay, maxRetries)
return nil
}
func (c *Consumers) handleBatchExecute(ctx context.Context, message batch.Message) error {
if c.batch == nil {
return gerror.New("batch execute handler is not configured")
}
g.Log().Infof(ctx, "batch execute message received, batchNo:%s, batchType:%d", message.BatchNo, message.BatchType)
if err := c.batch.Handle(ctx, message); err != nil {
if _, delivered := mq.DeliveryMetadataFromContext(ctx); !delivered {
return err
}
if batch.IsRetryable(err) {
message.Retryable = true
}
var failureMarkErr *batch.FailureMarkError
if errors.As(err, &failureMarkErr) {
message.FailureReason = failureMarkErr.Reason
}
return c.retryOrDeadLetterBatchMessage(ctx, message, err)
}
return nil
}
func (c *Consumers) retryOrDeadLetterBatchMessage(ctx context.Context, message batch.Message, handlerErr error) error {
metadata, _ := mq.DeliveryMetadataFromContext(ctx)
retryCount := batchRetryCount(metadata.Headers)
headers := amqp.Table{batchRetryCountHeader: retryCount + 1}
if shouldDeadLetterBatch(retryCount, c.batchExecuteMaxRetries) {
if message.Retryable {
finalizationMessage := message
finalizationMessage.Retryable = false
finalizationMessage.FailureReason = handlerErr.Error()
if err := c.batch.Handle(ctx, finalizationMessage); err != nil {
return gerror.Wrap(err, "finalize retryable batch failure before dead-lettering")
}
message.FailureReason = handlerErr.Error()
}
deadLetterBody, marshalErr := json.Marshal(batchDeadLetterMessage{
Message: message,
RetryCount: retryCount,
Error: handlerErr.Error(),
FailedAt: time.Now().Format(time.RFC3339),
})
if marshalErr != nil {
return gerror.Wrap(marshalErr, "marshal batch execute dead-letter message failed")
}
if err := mq.PublishBytesWithHeaders(ctx, consts.QueueBatchExecuteDeadLetter, deadLetterBody, nil); err != nil {
return gerror.Wrap(err, "publish batch execute dead-letter message failed")
}
g.Log().Errorf(ctx, "batch execute message sent to dead-letter queue, batchNo:%s, batchType:%d, retryCount:%d, err:%+v", message.BatchNo, message.BatchType, retryCount, handlerErr)
return nil
}
body, marshalErr := json.Marshal(message)
if marshalErr != nil {
return gerror.Wrap(marshalErr, "marshal batch execute retry message failed")
}
if err := mq.PublishDelayedBytesWithHeaders(
ctx,
consts.QueueBatchExecuteRetry,
consts.QueueBatchExecute,
c.batchExecuteRetryDelay,
body,
headers,
); err != nil {
return gerror.Wrap(err, "republish batch execute retry message failed")
}
g.Log().Warningf(ctx, "batch execute message scheduled for retry, batchNo:%s, batchType:%d, retryCount:%d, delay:%s, err:%+v", message.BatchNo, message.BatchType, retryCount+1, c.batchExecuteRetryDelay, handlerErr)
return nil
}
type batchDeadLetterMessage struct {
Message batch.Message `json:"message"`
RetryCount int `json:"retryCount"`
Error string `json:"error"`
FailedAt string `json:"failedAt"`
}
func shouldDeadLetterBatch(retryCount, maxRetries int) bool {
return retryCount >= maxRetries
}
func batchRetryCount(headers map[string]any) int {
if headers == nil {
return 0
}
value, ok := headers[batchRetryCountHeader]
if !ok {
return 0
}
switch count := value.(type) {
case int:
return max(count, 0)
case int8:
return max(int(count), 0)
case int16:
return max(int(count), 0)
case int32:
return max(int(count), 0)
case int64:
return max(int(count), 0)
case uint8:
return int(count)
case uint16:
return int(count)
case uint32:
return int(count)
case uint64:
if count > uint64(^uint(0)>>1) {
return int(^uint(0) >> 1)
}
return int(count)
case string:
parsed, err := strconv.Atoi(count)
if err == nil {
return max(parsed, 0)
}
}
return 0
}
func getBatchExecuteRetryConfig(ctx context.Context) (time.Duration, int, error) {
delay := g.Cfg().MustGet(ctx, "rabbitmq.batchExecute.retryDelay", "30s").Duration()
if delay <= 0 {
return 0, 0, gerror.New("batch execute retry delay must be positive")
}
maxRetries := g.Cfg().MustGet(ctx, "rabbitmq.batchExecute.maxRetries", 3).Int()
if maxRetries < 0 {
return 0, 0, gerror.New("batch execute max retries cannot be negative")
}
return delay, maxRetries, nil
}
@@ -0,0 +1,247 @@
package rabbitmq
import (
"context"
"errors"
"path/filepath"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
batchrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/batch"
datarepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/data"
dataservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
type durableBatchRepo struct{ failed int }
func (*durableBatchRepo) FindMain(context.Context, string) (*entity.BatchMain, error) {
return &entity.BatchMain{BatchNo: "APN-RECOVER", BatchType: 109, BatchAsyncStatus: batch.AsyncStatusPending}, nil
}
func (*durableBatchRepo) MarkProcessing(context.Context, string, int) (bool, error) { return true, nil }
func (r *durableBatchRepo) MarkFailed(context.Context, string, int, string) (bool, error) {
r.failed++
return true, nil
}
func (*durableBatchRepo) MarkSuccess(context.Context, string, int, int, int, string) (bool, error) {
return true, nil
}
type durableBatchExecutor struct {
calls, protections int
retryable bool
protectionErr error
}
func (e *durableBatchExecutor) Execute(context.Context, string) error {
e.calls++
if e.retryable {
return batch.Retryable(errors.New("准备中断"))
}
return errors.New("本地执行中断")
}
func (e *durableBatchExecutor) ProtectExecutionFailure(ctx context.Context, _ string, _ int, _ string) (bool, error) {
e.protections++
if ctx.Err() != nil {
return true, ctx.Err()
}
return true, e.protectionErr
}
func TestDurableAPNConsumerNeverFallsThroughToGenericFailure(t *testing.T) {
repo := &durableBatchRepo{}
executor := &durableBatchExecutor{}
dispatcher := batch.NewDispatcher(nil)
dispatcher.Register(109, executor)
consumers := &Consumers{batch: batch.NewRuntime(repo, dispatcher)}
if err := consumers.handleBatchExecute(context.Background(), batch.Message{BatchNo: "APN-RECOVER", BatchType: 109}); err != nil {
t.Fatal(err)
}
if executor.calls != 1 || executor.protections != 1 || repo.failed != 0 {
t.Fatalf("消费者普通错误越过durable保护: calls=%d protections=%d failed=%d", executor.calls, executor.protections, repo.failed)
}
}
func TestDurableAPNConsumerRetryExhaustionProtectsBeforePublish(t *testing.T) {
repo := &durableBatchRepo{}
dbDown := errors.New("模拟保护数据库不可用")
executor := &durableBatchExecutor{retryable: true, protectionErr: dbDown}
dispatcher := batch.NewDispatcher(nil)
dispatcher.Register(109, executor)
consumers := &Consumers{batch: batch.NewRuntime(repo, dispatcher), batchExecuteMaxRetries: 0}
ctx := mq.WithDeliveryMetadata(context.Background(), mq.DeliveryMetadata{Headers: map[string]any{batchRetryCountHeader: 3}})
err := consumers.handleBatchExecute(ctx, batch.Message{BatchNo: "APN-RECOVER", BatchType: 109})
// 保护失败必须在消息发布前返回,测试不会连接真实RabbitMQ。
if !errors.Is(err, dbDown) || executor.calls != 1 || executor.protections != 1 || repo.failed != 0 {
t.Fatalf("重试耗尽错误终结或重发: err=%v calls=%d protections=%d failed=%d", err, executor.calls, executor.protections, repo.failed)
}
executor.protectionErr = nil
if err := consumers.handleBatchExecute(context.Background(), batch.Message{BatchNo: "APN-RECOVER", BatchType: 109, FailureReason: "重试耗尽"}); err != nil {
t.Fatal(err)
}
if executor.calls != 1 || executor.protections != 2 || repo.failed != 0 {
t.Fatal("失败重投重新执行或MarkFailed")
}
}
// reentryWriteCounter 同时覆盖同步写、异步提交与结果查询,重入准备期间均不得调用。
type reentryWriteCounter struct{ syncWrites, asyncWrites, queries int }
func (u *reentryWriteCounter) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
u.syncWrites++
return nil, nil
}
func (u *reentryWriteCounter) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
u.asyncWrites++
return nil, nil
}
func (u *reentryWriteCounter) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
u.queries++
return nil, nil
}
func TestAPNProcessingRabbitMQReentryRequiresDurableParent(t *testing.T) {
ctx := context.Background()
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(" + filepath.ToSlash(filepath.Join(t.TempDir(), "apn-reentry.db")) + ")"}}}); err != nil {
t.Fatal(err)
}
db := g.DB()
t.Cleanup(func() {
if err := db.Close(ctx); err != nil {
t.Error(err)
}
})
for _, sql := range []string{
"CREATE TABLE batch_main (id INTEGER PRIMARY KEY,batch_no TEXT UNIQUE,batch_type INTEGER,batch_async_status INTEGER,tenant_id TEXT,start_time DATETIME,batch_info TEXT,run_result INTEGER DEFAULT 0,valid_count INTEGER DEFAULT 0,success_count INTEGER DEFAULT 0,fail_count INTEGER DEFAULT 0,fail_reason TEXT,end_time DATETIME,updated_at DATETIME,created_by INTEGER,created_dept INTEGER,updated_by INTEGER)",
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,batch_type INTEGER,row_no INTEGER,item_id TEXT,detail_status INTEGER,error_reason TEXT,snapshot TEXT,updated_by INTEGER)",
"CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,iccid TEXT,msisdn TEXT,imsi TEXT,chan_key TEXT,data_service_status INTEGER)",
"CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0,finalize_token TEXT,finalize_until DATETIME,recovery_attempt_at DATETIME,last_reason TEXT)",
"CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,batch_type INTEGER,chan_key TEXT,operation_id TEXT UNIQUE,detail_ids TEXT,request_data TEXT,job_id TEXT,status TEXT,poll_count INTEGER DEFAULT 0,next_check_at DATETIME,submitted_at DATETIME,lease_token TEXT,lease_until DATETIME,created_at DATETIME DEFAULT CURRENT_TIMESTAMP,last_reason TEXT)",
"CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY,operation_id TEXT,batch_no TEXT,iccid TEXT,UNIQUE(batch_no,iccid))",
"CREATE TABLE data_operation_card_claim (tenant_id TEXT,iccid TEXT,batch_id INTEGER,operation_id TEXT,active_batch_id INTEGER DEFAULT 0,active_operation_id TEXT DEFAULT '',PRIMARY KEY(tenant_id,iccid))",
} {
if _, err := db.Exec(ctx, sql); err != nil {
t.Fatalf("初始化本机 SQLite fixture: %v", err)
}
}
for _, tc := range []struct {
name string
old, retryable bool
}{
{name: "legacy-ordinary", old: true},
{name: "legacy-retryable", old: true, retryable: true},
{name: "durable-ordinary"},
{name: "durable-retryable", retryable: true},
} {
t.Run(tc.name, func(t *testing.T) {
status := batch.AsyncStatusPending
if tc.old {
status = batch.AsyncStatusProcessing
}
// 使用 NULL 旧首发时间以确保 ReclaimProcessing 的 WHERE start_time IS NULL 可以命中。
if _, err := db.Exec(ctx, "INSERT INTO batch_main(batch_no,batch_type,batch_async_status,tenant_id) VALUES (?,109,?,'tenant')", tc.name, status); err != nil {
t.Fatal(err)
}
for i := 0; i < 10; i++ {
iccid := tc.name + "-card-" + string(rune('0'+i))
detailStatus := batch.DetailStatusPending
if tc.old {
detailStatus = batch.DetailStatusRunning
}
if _, err := db.Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,row_no,item_id,detail_status,snapshot) VALUES (?,109,?,?,?,?)", tc.name, i+1, iccid, detailStatus, `{"authorized":true}`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info(tenant_id,iccid,msisdn,imsi,chan_key,data_service_status) VALUES ('tenant',?,?,?,'mock-apn',0)", iccid, "130000000"+string(rune('0'+i)), "460000000"+string(rune('0'+i))); err != nil {
t.Fatal(err)
}
}
mainRepo := batchrepo.New()
if tc.old && tc.retryable {
// 先证实同一 fixture 的旧批确实可被重领,再复位空首发时间。
claimed, err := mainRepo.ReclaimProcessing(ctx, tc.name, 109, nil)
if err != nil || !claimed {
t.Fatalf("重领 CAS 必须可命中,避免假阳性: claimed=%t err=%v", claimed, err)
}
if _, err := db.Exec(ctx, "UPDATE batch_main SET start_time=NULL WHERE batch_no=?", tc.name); err != nil {
t.Fatal(err)
}
}
if !tc.old {
claimed, err := mainRepo.MarkProcessing(ctx, tc.name, 109)
if err != nil || !claimed {
t.Fatalf("新批需同事务领取 Processing 与 ready=0 父凭据: claimed=%t err=%v", claimed, err)
}
}
parent, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=? AND ready=0", tc.name)
if err != nil || parent.Int() != 1 && !tc.old || parent.Int() != 0 && tc.old {
t.Fatalf("前置凭据不符: old=%t ready0=%d err=%v", tc.old, parent.Int(), err)
}
before, err := db.GetValue(ctx, "SELECT start_time FROM batch_main WHERE batch_no=?", tc.name)
if err != nil {
t.Fatal(err)
}
upstream := &reentryWriteCounter{}
dispatcher := batch.NewDispatcher(nil)
dispatcher.Register(109, dataservice.NewExecutor(datarepo.New(), upstream))
consumer := &Consumers{batch: batch.NewRuntime(mainRepo, dispatcher)}
err = consumer.handleBatchExecute(ctx, batch.Message{BatchNo: tc.name, BatchType: 109, Retryable: tc.retryable})
if err != nil {
t.Errorf("重入应安全跳过旧批或准备新批,不能进入消息重发路径: %v", err)
}
if upstream.syncWrites != 0 || upstream.asyncWrites != 0 || upstream.queries != 0 {
t.Errorf("重入意外调用上游: sync=%d submit=%d query=%d", upstream.syncWrites, upstream.asyncWrites, upstream.queries)
}
main, err := mainRepo.FindMain(ctx, tc.name)
if err != nil || main == nil || main.BatchAsyncStatus != batch.AsyncStatusProcessing {
t.Fatalf("批状态被意外终结: main=%+v err=%v", main, err)
}
remaining, err := db.GetValue(ctx, "SELECT COUNT(*) FROM batch_detail WHERE batch_no=? AND detail_status=1", tc.name)
if err != nil || remaining.Int() != 10 {
t.Fatalf("运行中明细不能被更改: count=%d err=%v", remaining.Int(), err)
}
intent, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_batch WHERE batch_no=? AND status='prepared'", tc.name)
if err != nil {
t.Fatal(err)
}
if tc.old {
if intent.Int() != 0 {
t.Errorf("无凭据历史批不应产生新提交意图: %d", intent.Int())
}
assigned, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_detail WHERE batch_no=?", tc.name)
if err != nil || assigned.Int() != 0 {
t.Errorf("历史批不可冻结明细: count=%d err=%v", assigned.Int(), err)
}
claims, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_card_claim WHERE operation_id!='' AND iccid LIKE ?", tc.name+"-%")
if err != nil || claims.Int() != 0 {
t.Errorf("历史批不可建立卡级提交占位: count=%d err=%v", claims.Int(), err)
}
parent, err = db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=?", tc.name)
if err != nil || parent.Int() != 0 {
t.Errorf("历史批不可补造父凭据: count=%d err=%v", parent.Int(), err)
}
after, err := db.GetValue(ctx, "SELECT start_time FROM batch_main WHERE batch_no=?", tc.name)
if err != nil || after.String() != before.String() {
t.Errorf("旧批重入不可重领取: before=%s after=%s err=%v", before.String(), after.String(), err)
}
} else {
if intent.Int() != 1 {
t.Errorf("新批 ready=0 未正确恢复成 10 卡一个冻结意图: %d", intent.Int())
}
parent, err = db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=? AND ready=1", tc.name)
if err != nil || parent.Int() != 1 {
t.Errorf("新批冻结意图与 ready=1 未原子提交: count=%d err=%v", parent.Int(), err)
}
assigned, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_detail WHERE batch_no=?", tc.name)
if err != nil || assigned.Int() != 10 {
t.Errorf("新批冻结明细必须覆盖全部 10 卡: count=%d err=%v", assigned.Int(), err)
}
}
})
}
}
@@ -0,0 +1,48 @@
package rabbitmq
import "testing"
func TestShouldDeadLetterBatch(t *testing.T) {
tests := []struct {
name string
retryCount int
maxRetries int
want bool
}{
{name: "first attempt retries", retryCount: 0, maxRetries: 3, want: false},
{name: "second attempt retries", retryCount: 1, maxRetries: 3, want: false},
{name: "last configured retry retries", retryCount: 2, maxRetries: 3, want: false},
{name: "next failure dead letters", retryCount: 3, maxRetries: 3, want: true},
{name: "zero retries dead letters immediately", retryCount: 0, maxRetries: 0, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if actual := shouldDeadLetterBatch(tt.retryCount, tt.maxRetries); actual != tt.want {
t.Fatalf("shouldDeadLetterBatch(%d, %d) = %t, want %t", tt.retryCount, tt.maxRetries, actual, tt.want)
}
})
}
}
func TestBatchRetryCount(t *testing.T) {
tests := []struct {
name string
headers map[string]any
want int
}{
{name: "missing", want: 0},
{name: "int", headers: map[string]any{batchRetryCountHeader: 2}, want: 2},
{name: "int64", headers: map[string]any{batchRetryCountHeader: int64(3)}, want: 3},
{name: "uint", headers: map[string]any{batchRetryCountHeader: uint16(4)}, want: 4},
{name: "string", headers: map[string]any{batchRetryCountHeader: "5"}, want: 5},
{name: "negative", headers: map[string]any{batchRetryCountHeader: -1}, want: 0},
{name: "invalid", headers: map[string]any{batchRetryCountHeader: "not-a-number"}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if actual := batchRetryCount(tt.headers); actual != tt.want {
t.Fatalf("batchRetryCount() = %d, want %d", actual, tt.want)
}
})
}
}
@@ -0,0 +1,243 @@
package rabbitmq
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"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/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
const channelRefreshConsumer = "iotlcm-worker-channel-refresh"
type channelRefreshConfig struct {
Address string `json:"address"`
Timeout time.Duration `json:"timeout"`
}
type channelRefreshResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (c *Consumers) initChannelRefresh(ctx context.Context) error {
if !rabbitmq.Enabled(ctx) {
g.Log().Info(ctx, "channel refresh consumer skipped, RabbitMQ disabled")
return nil
}
if err := rabbitmq.ConsumeJSON[model.ChannelRefreshMessage](
ctx,
consts.QueueChannelRefresh,
channelRefreshConsumer,
c.handleChannelRefresh,
); err != nil {
return err
}
g.Log().Infof(ctx, "channel refresh consumer started, queue:%s", consts.QueueChannelRefresh)
return nil
}
func (c *Consumers) handleChannelRefresh(ctx context.Context, message model.ChannelRefreshMessage) error {
switch strings.ToLower(strings.TrimSpace(message.Type)) {
case "channel":
if strings.TrimSpace(message.ChanKey) == "" {
g.Log().Errorf(ctx, "channel refresh message dropped, chanKey is empty")
return nil
}
if err := refreshChannelWithRetry(ctx, message.ChanKey); err != nil {
g.Log().Errorf(ctx, "channel refresh failed, chanKey=%s, err=%+v", message.ChanKey, err)
return err
}
return nil
case "template":
if strings.TrimSpace(message.TempKey) == "" {
g.Log().Errorf(ctx, "channel refresh message dropped, tempKey is empty")
return nil
}
return refreshTemplateChannels(ctx, message.TempKey)
case "channel_rename":
oldChanKey := strings.TrimSpace(message.OldChanKey)
newChanKey := strings.TrimSpace(message.NewChanKey)
if oldChanKey == "" || newChanKey == "" || oldChanKey == newChanKey {
g.Log().Errorf(ctx, "channel rename message dropped, oldChanKey=%q newChanKey=%q", oldChanKey, newChanKey)
return nil
}
if err := renameChannelWithRetry(ctx, oldChanKey, newChanKey); err != nil {
// 返回 error 让消费框架 Nack 并重新入队:rename 各步骤幂等,
// 重复投递安全;静默 Ack 会导致旧通道实例永久残留,只能重启恢复。
g.Log().Errorf(ctx, "channel rename failed after retries, will requeue, oldChanKey=%s newChanKey=%s err=%+v", oldChanKey, newChanKey, err)
return err
}
return nil
default:
g.Log().Errorf(ctx, "channel refresh message dropped, unsupported type=%s", message.Type)
return nil
}
}
func renameChannelWithRetry(ctx context.Context, oldChanKey, newChanKey string) error {
var err error
for attempt := 1; attempt <= 3; attempt++ {
err = renameChannel(ctx, oldChanKey, newChanKey)
if err == nil {
return nil
}
if attempt == 3 {
break
}
delay := time.Second
if attempt == 2 {
delay = 3 * time.Second
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return gerror.Wrapf(err, "channel rename failed after %d attempts, oldChanKey=%s newChanKey=%s", 3, oldChanKey, newChanKey)
}
// renameChannel is ordered so that the old runtime remains available until
// both upstreams have built the new instance. Card data and ICCID routes are
// intentionally left untouched for operations staff to migrate manually.
func renameChannel(ctx context.Context, oldChanKey, newChanKey string) error {
if err := refreshChannel(ctx, newChanKey); err != nil {
return gerror.Wrap(err, "refresh new channel failed")
}
if err := removeChannelTarget(ctx, "upstream.gateway", oldChanKey); err != nil {
return gerror.Wrap(err, "remove old gateway channel failed")
}
if err := removeChannelTarget(ctx, "upstream.proxy", oldChanKey); err != nil {
return gerror.Wrap(err, "remove old proxy channel failed")
}
g.Log().Infof(ctx, "channel instance rebuilt after rename, oldChanKey=%s newChanKey=%s", oldChanKey, newChanKey)
return nil
}
func refreshTemplateChannels(ctx context.Context, tempKey string) error {
var rows []struct {
ChanKey string `json:"chanKey"`
}
if err := g.DB().Model("t_sim_channels").Ctx(ctx).
Fields("chan_key").
Where("temp_key", strings.TrimSpace(tempKey)).
Scan(&rows); err != nil {
return gerror.Wrap(err, "query template channels failed")
}
var firstErr error
for _, row := range rows {
if err := refreshChannelWithRetry(ctx, row.ChanKey); err != nil {
g.Log().Errorf(ctx, "template channel refresh failed, tempKey=%s, chanKey=%s, err=%+v", tempKey, row.ChanKey, err)
if firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
func refreshChannelWithRetry(ctx context.Context, chanKey string) error {
var err error
for attempt := 1; attempt <= 3; attempt++ {
err = refreshChannel(ctx, chanKey)
if err == nil {
return nil
}
if attempt == 3 {
break
}
delay := time.Second
if attempt == 2 {
delay = 3 * time.Second
}
select {
case <-ctx.Done():
return gerror.Wrapf(err, "channel refresh canceled after %d attempts, chanKey=%s", attempt, chanKey)
case <-time.After(delay):
}
}
return gerror.Wrapf(err, "channel refresh failed after %d attempts, chanKey=%s", 3, chanKey)
}
func refreshChannel(ctx context.Context, chanKey string) error {
chanKey = strings.TrimSpace(chanKey)
if chanKey == "" {
return gerror.New("chanKey is required")
}
if err := refreshChannelTarget(ctx, "upstream.gateway", chanKey); err != nil {
return gerror.Wrap(err, "refresh gateway channel failed")
}
if err := refreshChannelTarget(ctx, "upstream.proxy", chanKey); err != nil {
return gerror.Wrap(err, "refresh proxy channel failed")
}
return nil
}
func refreshChannelTarget(ctx context.Context, configKey, chanKey string) error {
return channelControlTarget(ctx, configKey, "/internal/channel/refresh", chanKey)
}
func removeChannelTarget(ctx context.Context, configKey, chanKey string) error {
return channelControlTarget(ctx, configKey, "/internal/channel/remove", chanKey)
}
func channelControlTarget(ctx context.Context, configKey, path, chanKey string) error {
configValue, err := g.Cfg().Get(ctx, configKey)
if err != nil {
return gerror.Wrapf(err, "read upstream configuration failed: %s", configKey)
}
if configValue == nil || configValue.IsNil() {
return gerror.Newf("upstream configuration is missing: %s", configKey)
}
config := channelRefreshConfig{}
if err = configValue.Scan(&config); err != nil {
return gerror.Wrapf(err, "parse upstream configuration failed: %s", configKey)
}
address := strings.TrimSpace(config.Address)
if address == "" {
return gerror.Newf("upstream address is empty: %s", configKey)
}
if config.Timeout <= 0 {
config.Timeout = 10 * time.Second
}
requestCtx, cancel := context.WithTimeout(ctx, config.Timeout)
defer cancel()
client := g.Client()
client.SetTimeout(config.Timeout)
controlURL := fmt.Sprintf("http://%s%s?chanKey=%s", address, path, url.QueryEscape(chanKey))
response, err := client.Post(requestCtx, controlURL, nil)
if err != nil {
return gerror.Wrapf(err, "request upstream channel control failed: target=%s path=%s", configKey, path)
}
defer response.Close()
output := channelRefreshResponse{}
statusCode := 0
if response.Response != nil {
statusCode = response.StatusCode
}
if err = json.Unmarshal(response.ReadAll(), &output); err != nil {
return gerror.Wrapf(err, "parse upstream channel control response failed: target=%s path=%s status=%d", configKey, path, statusCode)
}
if output.Code != 0 {
if output.Message == "" {
output.Message = "upstream channel control failed"
}
return gerror.Newf("upstream channel control failed: target=%s path=%s status=%d message=%s", configKey, path, statusCode, output.Message)
}
if statusCode < 200 || statusCode >= 300 {
return gerror.Newf("upstream channel control failed: target=%s path=%s status=%d", configKey, path, statusCode)
}
return nil
}
@@ -0,0 +1,134 @@
package rabbitmq
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func setChannelRefreshTestConfig(t *testing.T, gateway, proxy string) {
t.Helper()
oldAdapter := g.Cfg().GetAdapter()
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`
upstream:
gateway:
address: %q
timeout: 1s
proxy:
address: %q
timeout: 1s
`, gateway, proxy))
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
}
func testServerAddress(server *httptest.Server) string {
return strings.TrimPrefix(server.URL, "http://")
}
func TestRefreshChannelCallsGatewayAndProxy(t *testing.T) {
var gatewayCalls atomic.Int32
var proxyCalls atomic.Int32
newServer := func(counter *atomic.Int32) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
counter.Add(1)
if request.URL.Path != "/internal/channel/refresh" || request.URL.Query().Get("chanKey") != "channel-a" {
t.Fatalf("unexpected request: %s", request.URL.String())
}
_, _ = w.Write([]byte(`{"code":0,"message":"success"}`))
}))
}
gateway := newServer(&gatewayCalls)
defer gateway.Close()
proxy := newServer(&proxyCalls)
defer proxy.Close()
setChannelRefreshTestConfig(t, testServerAddress(gateway), testServerAddress(proxy))
if err := refreshChannel(context.Background(), "channel-a"); err != nil {
t.Fatal(err)
}
if gatewayCalls.Load() != 1 || proxyCalls.Load() != 1 {
t.Fatalf("gateway calls=%d proxy calls=%d", gatewayCalls.Load(), proxyCalls.Load())
}
}
func TestRenameChannelOnlyRebuildsRuntimeInstances(t *testing.T) {
var (
mu sync.Mutex
events []string
)
newServer := func(target string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
mu.Lock()
events = append(events, target+":"+request.URL.Path+":"+request.URL.Query().Get("chanKey"))
mu.Unlock()
_, _ = w.Write([]byte(`{"code":0,"message":"success"}`))
}))
}
gateway := newServer("gateway")
defer gateway.Close()
proxy := newServer("proxy")
defer proxy.Close()
setChannelRefreshTestConfig(t, testServerAddress(gateway), testServerAddress(proxy))
if err := renameChannel(context.Background(), "channel-old", "channel-new"); err != nil {
t.Fatal(err)
}
want := []string{
"gateway:/internal/channel/refresh:channel-new",
"proxy:/internal/channel/refresh:channel-new",
"gateway:/internal/channel/remove:channel-old",
"proxy:/internal/channel/remove:channel-old",
}
if strings.Join(events, "\n") != strings.Join(want, "\n") {
t.Fatalf("rename events = %q, want %q", events, want)
}
}
func TestChannelControlErrorIncludesTargetStatusAndMessage(t *testing.T) {
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"code":50,"message":"carrier unavailable"}`))
}))
defer gateway.Close()
setChannelRefreshTestConfig(t, testServerAddress(gateway), "127.0.0.1:1")
err := refreshChannelTarget(context.Background(), "upstream.gateway", "channel-a")
if err == nil {
t.Fatal("expected target error")
}
message := err.Error()
for _, part := range []string{"target=upstream.gateway", "status=502", "carrier unavailable"} {
if !strings.Contains(message, part) {
t.Fatalf("error %q does not contain %q", message, part)
}
}
}
func TestHandleChannelRefreshReturnsError(t *testing.T) {
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":50,"message":"refresh rejected"}`))
}))
defer gateway.Close()
setChannelRefreshTestConfig(t, testServerAddress(gateway), "127.0.0.1:1")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
consumer := &Consumers{}
if err := consumer.handleChannelRefresh(ctx, model.ChannelRefreshMessage{Type: "channel", ChanKey: "channel-a"}); err == nil {
t.Fatal("refresh failure was acknowledged as success")
}
}
@@ -0,0 +1,189 @@
package rabbitmq
import (
"context"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
type TrafficUsageHandler interface {
RetryDaily(context.Context, model.TrafficUsageDailyRetryMessage) error
RetryMonthly(context.Context, model.TrafficUsageMonthlyRetryMessage) error
}
type WorkOrderHandler interface {
AutoCloseResolved(context.Context, model.WorkOrderAutoCloseMessage) error
}
type BalanceRechargeHandler interface {
CheckRechargeTimeout(context.Context, string) error
}
type BatchHandler interface {
Handle(context.Context, batch.Message) error
}
type PackageCustomerSyncHandler interface {
SyncDeptPackages(context.Context, model.PackageCustomerSyncMessage) error
}
type TrafficUsageMonthlyStartHandler interface {
Produce(context.Context, model.TrafficUsageMonthlyStartMessage) error
}
type TrafficUsageDailyStartHandler interface {
Produce(context.Context, model.TrafficUsageDailyStartMessage) error
}
type TrafficUsageMonthlyHistoryStartHandler interface {
Produce(context.Context, model.TrafficUsageMonthlyHistoryStartMessage) error
}
type SimStatusQueueStartHandler interface {
Produce(context.Context, model.SimStatusQueueStartMessage) error
}
type SimStatusQueueTaskHandler interface {
EnqueueCard(context.Context, model.SimStatusQueueTaskMessage) error
}
type ImeiQueueStartHandler interface {
Produce(context.Context, model.ImeiQueueStartMessage) error
}
// DataReviewQueryHandler 执行"手动同步上游结果":只读补查既有 operationId。
type DataReviewQueryHandler interface {
QueryReviewBatch(ctx context.Context, batchNo string) (int, error)
}
type Consumers struct {
trafficUsage TrafficUsageHandler
workOrder WorkOrderHandler
balance BalanceRechargeHandler
balanceRechargeRetryDelay time.Duration
balanceRechargeMaxRetry int
batch BatchHandler
batchExecuteRetryDelay time.Duration
batchExecuteMaxRetries int
packageCustomerSync PackageCustomerSyncHandler
trafficUsageMonthlyStart TrafficUsageMonthlyStartHandler
trafficUsageDailyStart TrafficUsageDailyStartHandler
trafficUsageMonthlyHistoryStart TrafficUsageMonthlyHistoryStartHandler
simStatusQueueStart SimStatusQueueStartHandler
simStatusQueueTask SimStatusQueueTaskHandler
imeiQueueStart ImeiQueueStartHandler
dataReview DataReviewQueryHandler
}
// WithDataReviewQuery adds the manual "sync upstream result" consumer without
// changing any existing RabbitMQ consumer wiring.
func (c *Consumers) WithDataReviewQuery(handler DataReviewQueryHandler) *Consumers {
c.dataReview = handler
return c
}
// WithDailyUsageStart adds the previous-day usage producer without changing any
// existing RabbitMQ consumer wiring.
func (c *Consumers) WithDailyUsageStart(start TrafficUsageDailyStartHandler) *Consumers {
c.trafficUsageDailyStart = start
return c
}
// WithMonthlyHistoryStart adds the previous-month usage history producer without
// changing any existing RabbitMQ consumer wiring.
func (c *Consumers) WithMonthlyHistoryStart(start TrafficUsageMonthlyHistoryStartHandler) *Consumers {
c.trafficUsageMonthlyHistoryStart = start
return c
}
// WithSimStatusQueue adds the independent status queue handlers without
// changing any existing RabbitMQ consumer wiring.
func (c *Consumers) WithSimStatusQueue(start SimStatusQueueStartHandler, task SimStatusQueueTaskHandler) *Consumers {
c.simStatusQueueStart = start
c.simStatusQueueTask = task
return c
}
// WithImeiQueue adds the independent IMEI queue handler without changing any
// existing RabbitMQ consumer wiring.
func (c *Consumers) WithImeiQueue(start ImeiQueueStartHandler) *Consumers {
c.imeiQueueStart = start
return c
}
func New(trafficUsage TrafficUsageHandler, workOrder WorkOrderHandler, balance BalanceRechargeHandler, batchHandlers ...BatchHandler) *Consumers {
consumers := &Consumers{
trafficUsage: trafficUsage,
workOrder: workOrder,
balance: balance,
}
if len(batchHandlers) > 0 {
consumers.batch = batchHandlers[0]
}
return consumers
}
func NewWithPackageCustomerSync(trafficUsage TrafficUsageHandler, workOrder WorkOrderHandler, balance BalanceRechargeHandler, packageCustomerSync PackageCustomerSyncHandler, batchHandlers ...BatchHandler) *Consumers {
consumers := New(trafficUsage, workOrder, balance, batchHandlers...)
consumers.packageCustomerSync = packageCustomerSync
return consumers
}
func NewWithPackageCustomerSyncAndMonthlyUsage(trafficUsage TrafficUsageHandler, workOrder WorkOrderHandler, balance BalanceRechargeHandler, packageCustomerSync PackageCustomerSyncHandler, monthlyStart TrafficUsageMonthlyStartHandler, batchHandlers ...BatchHandler) *Consumers {
consumers := NewWithPackageCustomerSync(trafficUsage, workOrder, balance, packageCustomerSync, batchHandlers...)
consumers.trafficUsageMonthlyStart = monthlyStart
return consumers
}
func (c *Consumers) Start(ctx context.Context) error {
initializers := c.initializers(g.Cfg().MustGet(ctx, "workerDiagnostic.enabled", false).Bool())
for _, initialize := range initializers {
if err := initialize(ctx); err != nil {
return err
}
}
return nil
}
func (c *Consumers) initializers(workerDiagnosticEnabled bool) []func(context.Context) error {
initializers := []func(context.Context) error{
c.initTrafficUsageDailyRetry,
c.initTrafficUsageMonthlyRetry,
c.initWorkOrderAutoClose,
c.initBalanceRechargeTimeout,
c.initBatchExecute,
c.initChannelRefresh,
c.initDataReviewQuery,
}
if c.packageCustomerSync != nil {
initializers = append(initializers, c.initPackageCustomerSync)
}
if c.trafficUsageMonthlyStart != nil {
initializers = append(initializers, c.initTrafficUsageMonthlyStart)
}
if c.trafficUsageDailyStart != nil {
initializers = append(initializers, c.initTrafficUsageDailyStart)
}
if c.trafficUsageMonthlyHistoryStart != nil {
initializers = append(initializers, c.initTrafficUsageMonthlyHistoryStart)
}
if c.simStatusQueueStart != nil && c.simStatusQueueTask != nil {
initializers = append(initializers, c.initSimStatusQueueStart, c.initSimStatusQueueTask)
}
if c.imeiQueueStart != nil {
initializers = append(initializers, c.initImeiQueueStart)
}
if workerDiagnosticEnabled {
initializers = append(initializers, c.initWorkerDiagnostic)
}
return initializers
}
func (c *Consumers) Stop(ctx context.Context) error {
return mq.Close(ctx)
}
@@ -0,0 +1,527 @@
package rabbitmq
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"reflect"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
amqp "github.com/rabbitmq/amqp091-go"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
dailyusageproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/dailyusage"
monthlyhistoryproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/monthlyusagehistory"
packagecustomerservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
)
func TestMain(m *testing.M) {
g.Log().SetWriter(io.Discard)
os.Exit(m.Run())
}
type trafficUsageHandlerStub struct {
dailyCalls int
monthlyCalls int
dailyMessage model.TrafficUsageDailyRetryMessage
monthMessage model.TrafficUsageMonthlyRetryMessage
dailyErr error
monthlyErr error
}
func (s *trafficUsageHandlerStub) RetryDaily(_ context.Context, message model.TrafficUsageDailyRetryMessage) error {
s.dailyCalls++
s.dailyMessage = message
return s.dailyErr
}
func (s *trafficUsageHandlerStub) RetryMonthly(_ context.Context, message model.TrafficUsageMonthlyRetryMessage) error {
s.monthlyCalls++
s.monthMessage = message
return s.monthlyErr
}
type workOrderHandlerStub struct {
calls int
message model.WorkOrderAutoCloseMessage
err error
}
type balanceRechargeHandlerStub struct {
calls int
outTradeNo string
err error
}
func (s *balanceRechargeHandlerStub) CheckRechargeTimeout(_ context.Context, outTradeNo string) error {
s.calls++
s.outTradeNo = outTradeNo
return s.err
}
type batchHandlerStub struct {
calls int
message batch.Message
err error
}
func (s *batchHandlerStub) Handle(_ context.Context, message batch.Message) error {
s.calls++
s.message = message
return s.err
}
type packageCustomerSyncHandlerStub struct {
calls int
message model.PackageCustomerSyncMessage
err error
}
type dailyStartHandlerStub struct {
calls int
message model.TrafficUsageDailyStartMessage
err error
}
func (s *dailyStartHandlerStub) Produce(_ context.Context, message model.TrafficUsageDailyStartMessage) error {
s.calls++
s.message = message
return s.err
}
type monthlyHistoryStartHandlerStub struct {
calls int
message model.TrafficUsageMonthlyHistoryStartMessage
err error
}
func (s *monthlyHistoryStartHandlerStub) Produce(_ context.Context, message model.TrafficUsageMonthlyHistoryStartMessage) error {
s.calls++
s.message = message
return s.err
}
func (s *packageCustomerSyncHandlerStub) SyncDeptPackages(_ context.Context, message model.PackageCustomerSyncMessage) error {
s.calls++
s.message = message
return s.err
}
func (s *workOrderHandlerStub) AutoCloseResolved(_ context.Context, message model.WorkOrderAutoCloseMessage) error {
s.calls++
s.message = message
return s.err
}
func TestHandleTrafficUsageDailyRetry(t *testing.T) {
stubTrafficPublish(t)
t.Run("invalid messages are dead lettered", func(t *testing.T) {
messages := []model.TrafficUsageDailyRetryMessage{
{},
{SimKey: &model.SimKey{}, Card: model.TrafficUsageSimCard{Iccid: "card"}},
{SimKey: &model.SimKey{ICCID: "sim"}},
}
for _, message := range messages {
handler := &trafficUsageHandlerStub{}
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
if err := consumers.handleTrafficUsageDailyRetry(context.Background(), message); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
if handler.dailyCalls != 0 {
t.Fatalf("invalid message called handler %d times", handler.dailyCalls)
}
}
})
t.Run("valid message is delegated", func(t *testing.T) {
expectedErr := errors.New("daily failed")
expected := model.TrafficUsageDailyRetryMessage{
SimKey: &model.SimKey{ICCID: "sim"},
Card: model.TrafficUsageSimCard{Iccid: "sim", PackageCode: "package"},
QueryDate: "20260714",
}
handler := &trafficUsageHandlerStub{dailyErr: expectedErr}
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
if err := consumers.handleTrafficUsageDailyRetry(context.Background(), expected); err != nil {
t.Fatalf("expected delegated error, got %v", err)
}
if handler.dailyCalls != 1 || !reflect.DeepEqual(handler.dailyMessage, expected) {
t.Fatalf("daily message was not delegated exactly once: %+v", handler.dailyMessage)
}
})
}
func TestHandleTrafficUsageMonthlyRetry(t *testing.T) {
stubTrafficPublish(t)
t.Run("invalid messages are dead lettered", func(t *testing.T) {
messages := []model.TrafficUsageMonthlyRetryMessage{
{},
{SimKey: &model.SimKey{}, Card: model.TrafficUsageSimCard{Iccid: "card"}},
{SimKey: &model.SimKey{ICCID: "sim"}},
}
for _, message := range messages {
handler := &trafficUsageHandlerStub{}
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
if err := consumers.handleTrafficUsageMonthlyRetry(context.Background(), message); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
if handler.monthlyCalls != 0 {
t.Fatalf("invalid message called handler %d times", handler.monthlyCalls)
}
}
})
t.Run("valid message is delegated", func(t *testing.T) {
expectedErr := errors.New("monthly failed")
expected := model.TrafficUsageMonthlyRetryMessage{
SimKey: &model.SimKey{ICCID: "sim"},
Card: model.TrafficUsageSimCard{Iccid: "sim", PackageCode: "package"},
QueryDate: "202607",
}
handler := &trafficUsageHandlerStub{monthlyErr: expectedErr}
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
if err := consumers.handleTrafficUsageMonthlyRetry(context.Background(), expected); err != nil {
t.Fatalf("expected delegated error, got %v", err)
}
if handler.monthlyCalls != 1 || !reflect.DeepEqual(handler.monthMessage, expected) {
t.Fatalf("monthly message was not delegated exactly once: %+v", handler.monthMessage)
}
})
}
func stubTrafficPublish(t *testing.T) {
t.Helper()
original, delayed := trafficUsagePublish, trafficUsagePublishDelayed
t.Cleanup(func() { trafficUsagePublish, trafficUsagePublishDelayed = original, delayed })
trafficUsagePublish = func(context.Context, string, []byte, amqp.Table) error { return nil }
trafficUsagePublishDelayed = func(context.Context, string, string, time.Duration, []byte, amqp.Table) error { return nil }
}
func TestHandleWorkOrderAutoClose(t *testing.T) {
handler := &workOrderHandlerStub{}
consumers := New(&trafficUsageHandlerStub{}, handler, &balanceRechargeHandlerStub{})
if err := consumers.handleWorkOrderAutoClose(context.Background(), model.WorkOrderAutoCloseMessage{}); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
if handler.calls != 0 {
t.Fatalf("invalid message called handler %d times", handler.calls)
}
expectedErr := errors.New("work order failed")
expected := model.WorkOrderAutoCloseMessage{WoNo: "WO-1", ResolveTimeTs: 1}
handler.err = expectedErr
if err := consumers.handleWorkOrderAutoClose(context.Background(), expected); !errors.Is(err, expectedErr) {
t.Fatalf("expected delegated error, got %v", err)
}
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
t.Fatalf("work order message was not delegated exactly once: %+v", handler.message)
}
}
func TestHandleBalanceRechargeTimeout(t *testing.T) {
handler := &balanceRechargeHandlerStub{}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, handler)
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{}); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
if handler.calls != 0 {
t.Fatalf("invalid message called handler %d times", handler.calls)
}
message := model.BalanceRechargeTimeoutMessage{OutTradeNo: "BAL-1"}
if err := consumers.handleBalanceRechargeTimeout(context.Background(), message); err != nil {
t.Fatalf("valid message returned error: %v", err)
}
if handler.calls != 1 || handler.outTradeNo != message.OutTradeNo {
t.Fatalf("balance recharge message was not delegated exactly once: %+v", handler)
}
}
func TestHandleBatchExecute(t *testing.T) {
expectedErr := errors.New("batch failed")
expected := batch.Message{BatchNo: "BATCH-1", BatchType: batch.Type103}
handler := &batchHandlerStub{err: expectedErr}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
if err := consumers.handleBatchExecute(context.Background(), expected); !errors.Is(err, expectedErr) {
t.Fatalf("expected delegated error, got %v", err)
}
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
t.Fatalf("batch message was not delegated exactly once: %+v", handler.message)
}
}
func TestHandlePackageCustomerSync(t *testing.T) {
t.Run("invalid message is dropped", func(t *testing.T) {
handler := &packageCustomerSyncHandlerStub{}
consumers := NewWithPackageCustomerSync(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
if err := consumers.handlePackageCustomerSync(context.Background(), model.PackageCustomerSyncMessage{}); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
if handler.calls != 0 {
t.Fatalf("invalid message called handler %d times", handler.calls)
}
})
t.Run("missing dept is dropped", func(t *testing.T) {
expected := model.PackageCustomerSyncMessage{TenantId: "000000", DeptId: 164, Trigger: "package_lifecycle", SourceBatchNo: "898604017"}
handler := &packageCustomerSyncHandlerStub{err: packagecustomerservice.ErrDeptNotFound}
consumers := NewWithPackageCustomerSync(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
if err := consumers.handlePackageCustomerSync(context.Background(), expected); err != nil {
t.Fatalf("missing dept returned error: %v", err)
}
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
t.Fatalf("package customer sync message was not delegated exactly once: %+v", handler.message)
}
})
t.Run("other errors are retried", func(t *testing.T) {
expectedErr := errors.New("database failed")
expected := model.PackageCustomerSyncMessage{TenantId: "000000", DeptId: 117}
handler := &packageCustomerSyncHandlerStub{err: expectedErr}
consumers := NewWithPackageCustomerSync(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
if err := consumers.handlePackageCustomerSync(context.Background(), expected); !errors.Is(err, expectedErr) {
t.Fatalf("expected delegated error, got %v", err)
}
})
}
func TestHandleWorkerDiagnostic(t *testing.T) {
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
if err := consumers.handleWorkerDiagnostic(context.Background(), model.WorkerDiagnosticMessage{}); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
message := model.WorkerDiagnosticMessage{
TraceID: "worker-test-1",
SentAt: 1784030000000,
Message: "worker ping",
}
if err := consumers.handleWorkerDiagnostic(context.Background(), message); err != nil {
t.Fatalf("valid message returned error: %v", err)
}
}
func TestWorkerDiagnosticMessageJSONContract(t *testing.T) {
message := model.WorkerDiagnosticMessage{
TraceID: "worker-test-1",
SentAt: 1784030000000,
Message: "worker ping",
}
body, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal diagnostic message: %v", err)
}
const expected = `{"traceId":"worker-test-1","sentAt":1784030000000,"message":"worker ping"}`
if string(body) != expected {
t.Fatalf("unexpected diagnostic JSON: %s", body)
}
}
func TestChannelRefreshMessageJSONContract(t *testing.T) {
message := model.ChannelRefreshMessage{
Type: "channel_rename",
OldChanKey: "old_channel",
NewChanKey: "new_channel",
}
body, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal channel rename message: %v", err)
}
const expected = `{"type":"channel_rename","oldChanKey":"old_channel","newChanKey":"new_channel"}`
if string(body) != expected {
t.Fatalf("unexpected channel rename JSON: %s", body)
}
}
func TestHandleChannelRefreshDropsInvalidRename(t *testing.T) {
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
for _, message := range []model.ChannelRefreshMessage{
{Type: "channel_rename"},
{Type: "channel_rename", OldChanKey: "old_channel"},
{Type: "channel_rename", NewChanKey: "new_channel"},
{Type: "channel_rename", OldChanKey: "same", NewChanKey: "same"},
} {
if err := consumers.handleChannelRefresh(context.Background(), message); err != nil {
t.Fatalf("invalid channel rename returned error: %v", err)
}
}
}
func TestHandleChannelRefreshRenameFailureReturnsError(t *testing.T) {
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
ctx, cancel := context.WithCancel(context.Background())
cancel()
message := model.ChannelRefreshMessage{
Type: "channel_rename",
OldChanKey: "old_channel",
NewChanKey: "new_channel",
}
// ctx 已取消时 renameChannelWithRetry 会在首次重试等待处返回 ctx.Err(),
// 消费者必须把错误向上抛出,让框架 Nack 重新入队而不是 Ack 丢弃。
if err := consumers.handleChannelRefresh(ctx, message); err == nil {
t.Fatalf("expected failed channel rename to return error for Nack+requeue")
}
}
func TestHandleTrafficUsageDailyStart(t *testing.T) {
t.Run("invalid message is dropped", func(t *testing.T) {
for _, message := range []model.TrafficUsageDailyStartMessage{
{},
{RunID: "run-1"},
{QueryDate: "20261009"},
} {
handler := &dailyStartHandlerStub{}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
if err := consumers.handleTrafficUsageDailyStart(context.Background(), message); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
if handler.calls != 0 {
t.Fatalf("invalid message called producer %d times", handler.calls)
}
}
})
t.Run("valid message is delegated", func(t *testing.T) {
expected := model.TrafficUsageDailyStartMessage{
RunID: "daily-usage-20261009-abc",
Operation: model.OperationPreviousDayUsageFullSync,
QueryDate: "20261009",
TriggeredAt: "2026-10-10T06:00:00+08:00",
Scope: &model.SyncScope{Channels: []string{"217"}},
}
handler := &dailyStartHandlerStub{}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
if err := consumers.handleTrafficUsageDailyStart(context.Background(), expected); err != nil {
t.Fatalf("valid message returned error: %v", err)
}
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
t.Fatalf("daily start message was not delegated exactly once: %+v", handler.message)
}
})
t.Run("an already running producer is acked", func(t *testing.T) {
handler := &dailyStartHandlerStub{err: dailyusageproducer.ErrAlreadyRunning}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
if err := consumers.handleTrafficUsageDailyStart(context.Background(), model.TrafficUsageDailyStartMessage{RunID: "r", QueryDate: "20261009"}); err != nil {
t.Fatalf("ErrAlreadyRunning must be acked, got %v", err)
}
})
t.Run("a finished run is acked instead of being resurrected", func(t *testing.T) {
handler := &dailyStartHandlerStub{err: dailyusageproducer.ErrRunFinished}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
if err := consumers.handleTrafficUsageDailyStart(context.Background(), model.TrafficUsageDailyStartMessage{RunID: "r", QueryDate: "20261009"}); err != nil {
t.Fatalf("ErrRunFinished must be acked, got %v", err)
}
})
t.Run("other errors are surfaced for requeue", func(t *testing.T) {
expectedErr := errors.New("redis unavailable")
handler := &dailyStartHandlerStub{err: expectedErr}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
if err := consumers.handleTrafficUsageDailyStart(context.Background(), model.TrafficUsageDailyStartMessage{RunID: "r", QueryDate: "20261009"}); !errors.Is(err, expectedErr) {
t.Fatalf("expected the producer error to propagate, got %v", err)
}
})
}
// TestDailyUsageStartConsumerIsOptIn pins the assembly contract: without an
// explicit WithDailyUsageStart the initializer list must be unchanged, so a
// disabled daily chain cannot alter existing deployments.
func TestDailyUsageStartConsumerIsOptIn(t *testing.T) {
base := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
withDaily := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(&dailyStartHandlerStub{})
if got, want := len(withDaily.initializers(false)), len(base.initializers(false))+1; got != want {
t.Fatalf("initializers = %d, want %d", got, want)
}
}
func TestHandleTrafficUsageMonthlyHistoryStart(t *testing.T) {
t.Run("invalid message is dropped", func(t *testing.T) {
for _, message := range []model.TrafficUsageMonthlyHistoryStartMessage{
{},
{RunID: "run-1"},
{QueryMonth: "202609"},
} {
handler := &monthlyHistoryStartHandlerStub{}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), message); err != nil {
t.Fatalf("invalid message returned error: %v", err)
}
if handler.calls != 0 {
t.Fatalf("invalid message called producer %d times", handler.calls)
}
}
})
t.Run("valid message is delegated", func(t *testing.T) {
expected := model.TrafficUsageMonthlyHistoryStartMessage{
RunID: "monthly-history-202609-abc",
Operation: model.OperationPreviousMonthUsageFullSync,
QueryMonth: "202609",
TriggeredAt: "2026-10-01T06:00:00+08:00",
Scope: &model.SyncScope{Channels: []string{"217"}},
}
handler := &monthlyHistoryStartHandlerStub{}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), expected); err != nil {
t.Fatalf("valid message returned error: %v", err)
}
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
t.Fatalf("monthly history start message was not delegated exactly once: %+v", handler.message)
}
})
t.Run("an already running producer is acked", func(t *testing.T) {
handler := &monthlyHistoryStartHandlerStub{err: monthlyhistoryproducer.ErrAlreadyRunning}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), model.TrafficUsageMonthlyHistoryStartMessage{RunID: "r", QueryMonth: "202609"}); err != nil {
t.Fatalf("ErrAlreadyRunning must be acked, got %v", err)
}
})
t.Run("a finished run is acked instead of being resurrected", func(t *testing.T) {
handler := &monthlyHistoryStartHandlerStub{err: monthlyhistoryproducer.ErrRunFinished}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), model.TrafficUsageMonthlyHistoryStartMessage{RunID: "r", QueryMonth: "202609"}); err != nil {
t.Fatalf("ErrRunFinished must be acked, got %v", err)
}
})
t.Run("other errors are surfaced for requeue", func(t *testing.T) {
expectedErr := errors.New("redis unavailable")
handler := &monthlyHistoryStartHandlerStub{err: expectedErr}
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), model.TrafficUsageMonthlyHistoryStartMessage{RunID: "r", QueryMonth: "202609"}); !errors.Is(err, expectedErr) {
t.Fatalf("expected the producer error to propagate, got %v", err)
}
})
}
// TestMonthlyHistoryStartConsumerIsOptIn mirrors the daily chain: a disabled
// monthly-history chain must not add an initializer.
func TestMonthlyHistoryStartConsumerIsOptIn(t *testing.T) {
base := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
withHistory := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(&monthlyHistoryStartHandlerStub{})
if got, want := len(withHistory.initializers(false)), len(base.initializers(false))+1; got != want {
t.Fatalf("initializers = %d, want %d", got, want)
}
}
func TestConsumerInitializersRespectWorkerDiagnosticSwitch(t *testing.T) {
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
// 手动同步上游结果(initDataReviewQuery)随业务消费者常驻,无处理器时自跳过。
if got := len(consumers.initializers(false)); got != 7 {
t.Fatalf("diagnostic disabled: expected 7 business consumers, got %d", got)
}
if got := len(consumers.initializers(true)); got != 8 {
t.Fatalf("diagnostic enabled: expected 8 consumers, got %d", got)
}
}
@@ -0,0 +1,46 @@
package rabbitmq
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func (c *Consumers) initDataReviewQuery(ctx context.Context) error {
if c.dataReview == nil {
return nil
}
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "data review query consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.DataReviewQueryMessage](
ctx,
consts.QueueDataReviewQuery,
"iotlcm-data-review-query",
c.handleDataReviewQuery,
); err != nil {
return err
}
g.Log().Infof(ctx, "data review query consumer started, queue:%s", consts.QueueDataReviewQuery)
return nil
}
func (c *Consumers) handleDataReviewQuery(ctx context.Context, message model.DataReviewQueryMessage) error {
if c.dataReview == nil || message.BatchNo == "" {
g.Log().Errorf(ctx, "data review query message dropped, invalid message:%+v", message)
return nil
}
queried, err := c.dataReview.QueryReviewBatch(ctx, message.BatchNo)
if err != nil {
// 手动同步失败只记日志,不重投:重投也不会改变上游结果,且可能放大查询压力。
g.Log().Errorf(ctx, "手动同步APN上游结果失败 batchNo=%s 已同步=%d error=%v", message.BatchNo, queried, err)
return nil
}
g.Log().Infof(ctx, "手动同步APN上游结果完成 batchNo=%s 已同步=%d", message.BatchNo, queried)
return nil
}
@@ -0,0 +1,43 @@
package rabbitmq
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func (c *Consumers) initWorkerDiagnostic(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "worker diagnostic consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.WorkerDiagnosticMessage](
ctx,
consts.QueueWorkerDiagnostic,
"iotlcm-worker-diagnostic",
c.handleWorkerDiagnostic,
); err != nil {
return err
}
g.Log().Infof(ctx, "worker diagnostic consumer started, queue:%s", consts.QueueWorkerDiagnostic)
return nil
}
func (c *Consumers) handleWorkerDiagnostic(ctx context.Context, message model.WorkerDiagnosticMessage) error {
if message.TraceID == "" {
g.Log().Errorf(ctx, "worker diagnostic message dropped, invalid traceId, message:%+v", message)
return nil
}
g.Log().Infof(
ctx,
"worker diagnostic message consumed, traceId:%s, sentAt:%d, message:%s",
message.TraceID,
message.SentAt,
message.Message,
)
return nil
}
@@ -0,0 +1,44 @@
package rabbitmq
import (
"context"
"errors"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
imeiproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/imei"
)
func (c *Consumers) initImeiQueueStart(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "imei queue start consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.ImeiQueueStartMessage](ctx, consts.QueueImeiQueueStart, "iotlcm-imei-queue-start", c.handleImeiQueueStart); err != nil {
return err
}
g.Log().Infof(ctx, "imei queue start consumer started, queue:%s", consts.QueueImeiQueueStart)
return nil
}
func (c *Consumers) handleImeiQueueStart(ctx context.Context, message model.ImeiQueueStartMessage) error {
if message.RunID == "" {
g.Log().Errorf(ctx, "imei queue start message dropped, invalid message:%+v", message)
return nil
}
err := c.imeiQueueStart.Produce(ctx, message)
if errors.Is(err, imeiproducer.ErrAlreadyRunning) {
g.Log().Warningf(ctx, "imei queue start skipped because a run is active, runId:%s", message.RunID)
return nil
}
// A run that already finished must not be resurrected by a redelivered
// message. Ack it instead of retrying forever.
if errors.Is(err, imeiproducer.ErrRunFinished) {
g.Log().Warningf(ctx, "imei queue start skipped because run already finished, runId:%s", message.RunID)
return nil
}
return err
}
@@ -0,0 +1,53 @@
package rabbitmq
import (
"context"
"errors"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
packagecustomerservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
)
func (c *Consumers) initPackageCustomerSync(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "package customer sync consumer skipped, RabbitMQ disabled")
return nil
}
if c.packageCustomerSync == nil {
return gerror.New("package customer sync handler is not configured")
}
if err := mq.ConsumeJSON[model.PackageCustomerSyncMessage](
ctx,
consts.QueuePackageCustomerSync,
"iotlcm-package-customer-sync",
c.handlePackageCustomerSync,
); err != nil {
return err
}
g.Log().Infof(ctx, "package customer sync consumer started, queue:%s", consts.QueuePackageCustomerSync)
return nil
}
func (c *Consumers) handlePackageCustomerSync(ctx context.Context, message model.PackageCustomerSyncMessage) error {
if c.packageCustomerSync == nil {
return gerror.New("package customer sync handler is not configured")
}
if message.TenantId == "" || message.DeptId <= 0 {
g.Log().Errorf(ctx, "package customer sync message dropped, invalid message:%+v", message)
return nil
}
if err := c.packageCustomerSync.SyncDeptPackages(ctx, message); err != nil {
if errors.Is(err, packagecustomerservice.ErrDeptNotFound) {
g.Log().Warningf(ctx, "package customer sync message dropped, dept not found, tenantId:%s, deptId:%d, trigger:%s, sourceBatchNo:%s",
message.TenantId, message.DeptId, message.Trigger, message.SourceBatchNo)
return nil
}
return err
}
return nil
}
@@ -0,0 +1,44 @@
package rabbitmq
import (
"context"
"errors"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
simstatusproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/simstatus"
)
func (c *Consumers) initSimStatusQueueStart(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "sim status queue start consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.SimStatusQueueStartMessage](ctx, consts.QueueSimStatusQueueStart, "iotlcm-sim-status-queue-start", c.handleSimStatusQueueStart); err != nil {
return err
}
g.Log().Infof(ctx, "sim status queue start consumer started, queue:%s", consts.QueueSimStatusQueueStart)
return nil
}
func (c *Consumers) handleSimStatusQueueStart(ctx context.Context, message model.SimStatusQueueStartMessage) error {
if message.RunID == "" {
g.Log().Errorf(ctx, "sim status queue start message dropped, invalid message:%+v", message)
return nil
}
err := c.simStatusQueueStart.Produce(ctx, message)
if errors.Is(err, simstatusproducer.ErrAlreadyRunning) {
g.Log().Warningf(ctx, "sim status queue start skipped because a run is active, runId:%s", message.RunID)
return nil
}
// A run that already finished must not be resurrected by a redelivered
// message. Ack it instead of retrying forever.
if errors.Is(err, simstatusproducer.ErrRunFinished) {
g.Log().Warningf(ctx, "sim status queue start skipped because run already finished, runId:%s", message.RunID)
return nil
}
return err
}
@@ -0,0 +1,31 @@
package rabbitmq
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func (c *Consumers) initSimStatusQueueTask(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "sim status queue task consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.SimStatusQueueTaskMessage](ctx, consts.QueueSimStatusQueueTask, "iotlcm-sim-status-queue-task", c.handleSimStatusQueueTask); err != nil {
return err
}
g.Log().Infof(ctx, "sim status queue task consumer started, queue:%s", consts.QueueSimStatusQueueTask)
return nil
}
func (c *Consumers) handleSimStatusQueueTask(ctx context.Context, message model.SimStatusQueueTaskMessage) error {
if message.CardID <= 0 || message.ICCID == "" || message.ChanKey == "" || message.TempKey == "" {
g.Log().Errorf(ctx, "sim status queue task dropped, invalid message:%+v", message)
return nil
}
return c.simStatusQueueTask.EnqueueCard(ctx, message)
}
@@ -0,0 +1,42 @@
package rabbitmq
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func (c *Consumers) initTrafficUsageDailyRetry(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "traffic usage daily retry consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.TrafficUsageDailyRetryMessage](
ctx,
consts.QueueTrafficUsageDailyRetry,
"iotlcm-traffic-usage-daily",
c.handleTrafficUsageDailyRetry,
); err != nil {
return err
}
g.Log().Infof(ctx, "traffic usage daily retry consumer started, queue:%s", consts.QueueTrafficUsageDailyRetry)
return nil
}
func (c *Consumers) handleTrafficUsageDailyRetry(ctx context.Context, message model.TrafficUsageDailyRetryMessage) error {
err := validateTrafficUsageIdentity(message.SimKey, message.Card)
if err == nil {
err = c.trafficUsage.RetryDaily(ctx, message)
}
if err == nil {
return nil
}
message.Attempt++
message.LastError = err.Error()
permanent := trafficUsageFailureSnapshot(err, &message.UsageMb)
return publishTrafficUsageFailure(ctx, consts.QueueTrafficUsageDailyRetry, message, message.Attempt, permanent)
}
@@ -0,0 +1,49 @@
package rabbitmq
import (
"context"
"errors"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
dailyusageproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/dailyusage"
)
func (c *Consumers) initTrafficUsageDailyStart(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "traffic usage daily start consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.TrafficUsageDailyStartMessage](
ctx,
consts.QueueTrafficUsageDailyStart,
"iotlcm-traffic-usage-daily-start",
c.handleTrafficUsageDailyStart,
); err != nil {
return err
}
g.Log().Infof(ctx, "traffic usage daily start consumer started, queue:%s", consts.QueueTrafficUsageDailyStart)
return nil
}
func (c *Consumers) handleTrafficUsageDailyStart(ctx context.Context, message model.TrafficUsageDailyStartMessage) error {
if message.RunID == "" || message.QueryDate == "" {
g.Log().Errorf(ctx, "traffic usage daily start message dropped, invalid message:%+v", message)
return nil
}
err := c.trafficUsageDailyStart.Produce(ctx, message)
if errors.Is(err, dailyusageproducer.ErrAlreadyRunning) {
g.Log().Warningf(ctx, "traffic usage daily start skipped because previous run is active, runId:%s", message.RunID)
return nil
}
// A run that already finished must not be resurrected by a redelivered
// message. Ack it instead of retrying forever.
if errors.Is(err, dailyusageproducer.ErrRunFinished) {
g.Log().Warningf(ctx, "traffic usage daily start skipped because run already finished, runId:%s", message.RunID)
return nil
}
return err
}
@@ -0,0 +1,42 @@
package rabbitmq
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func (c *Consumers) initTrafficUsageMonthlyRetry(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "traffic usage monthly retry consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.TrafficUsageMonthlyRetryMessage](
ctx,
consts.QueueTrafficUsageMonthlyRetry,
"iotlcm-traffic-usage-monthly",
c.handleTrafficUsageMonthlyRetry,
); err != nil {
return err
}
g.Log().Infof(ctx, "traffic usage monthly retry consumer started, queue:%s", consts.QueueTrafficUsageMonthlyRetry)
return nil
}
func (c *Consumers) handleTrafficUsageMonthlyRetry(ctx context.Context, message model.TrafficUsageMonthlyRetryMessage) error {
err := validateTrafficUsageIdentity(message.SimKey, message.Card)
if err == nil {
err = c.trafficUsage.RetryMonthly(ctx, message)
}
if err == nil {
return nil
}
message.Attempt++
message.LastError = err.Error()
permanent := trafficUsageFailureSnapshot(err, &message.UsageMb)
return publishTrafficUsageFailure(ctx, consts.QueueTrafficUsageMonthlyRetry, message, message.Attempt, permanent)
}
@@ -0,0 +1,49 @@
package rabbitmq
import (
"context"
"errors"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
monthlyhistoryproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/monthlyusagehistory"
)
func (c *Consumers) initTrafficUsageMonthlyHistoryStart(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "traffic usage monthly history start consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.TrafficUsageMonthlyHistoryStartMessage](
ctx,
consts.QueueTrafficUsageMonthlyHistoryStart,
"iotlcm-traffic-usage-monthly-history-start",
c.handleTrafficUsageMonthlyHistoryStart,
); err != nil {
return err
}
g.Log().Infof(ctx, "traffic usage monthly history start consumer started, queue:%s", consts.QueueTrafficUsageMonthlyHistoryStart)
return nil
}
func (c *Consumers) handleTrafficUsageMonthlyHistoryStart(ctx context.Context, message model.TrafficUsageMonthlyHistoryStartMessage) error {
if message.RunID == "" || message.QueryMonth == "" {
g.Log().Errorf(ctx, "traffic usage monthly history start message dropped, invalid message:%+v", message)
return nil
}
err := c.trafficUsageMonthlyHistoryStart.Produce(ctx, message)
if errors.Is(err, monthlyhistoryproducer.ErrAlreadyRunning) {
g.Log().Warningf(ctx, "traffic usage monthly history start skipped because previous run is active, runId:%s", message.RunID)
return nil
}
// A run that already finished must not be resurrected by a redelivered
// message. Ack it instead of retrying forever.
if errors.Is(err, monthlyhistoryproducer.ErrRunFinished) {
g.Log().Warningf(ctx, "traffic usage monthly history start skipped because run already finished, runId:%s", message.RunID)
return nil
}
return err
}
@@ -0,0 +1,49 @@
package rabbitmq
import (
"context"
"errors"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
monthlyusageproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/monthlyusage"
)
func (c *Consumers) initTrafficUsageMonthlyStart(ctx context.Context) error {
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "traffic usage monthly start consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.TrafficUsageMonthlyStartMessage](
ctx,
consts.QueueTrafficUsageMonthlyStart,
"iotlcm-traffic-usage-monthly-start",
c.handleTrafficUsageMonthlyStart,
); err != nil {
return err
}
g.Log().Infof(ctx, "traffic usage monthly start consumer started, queue:%s", consts.QueueTrafficUsageMonthlyStart)
return nil
}
func (c *Consumers) handleTrafficUsageMonthlyStart(ctx context.Context, message model.TrafficUsageMonthlyStartMessage) error {
if message.RunID == "" || message.QueryMonth == "" {
g.Log().Errorf(ctx, "traffic usage monthly start message dropped, invalid message:%+v", message)
return nil
}
err := c.trafficUsageMonthlyStart.Produce(ctx, message)
if errors.Is(err, monthlyusageproducer.ErrAlreadyRunning) {
g.Log().Warningf(ctx, "traffic usage monthly start skipped because previous run is active, runId:%s", message.RunID)
return nil
}
// A run that already finished must not be resurrected by a redelivered
// message. Ack it instead of retrying forever.
if errors.Is(err, monthlyusageproducer.ErrRunFinished) {
g.Log().Warningf(ctx, "traffic usage monthly start skipped because run already finished, runId:%s", message.RunID)
return nil
}
return err
}
@@ -0,0 +1,68 @@
package rabbitmq
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficusage"
)
var trafficUsagePublish = mq.PublishBytesWithHeaders
var trafficUsagePublishDelayed = mq.PublishDelayedBytesWithHeaders
func validateTrafficUsageIdentity(simKey *model.SimKey, card model.TrafficUsageSimCard) error {
if simKey == nil || card.Iccid == "" || simKey.ICCID != card.Iccid {
return &trafficusage.Failure{Cause: gerror.New("invalid traffic usage card identity"), Permanent: true}
}
return nil
}
func trafficUsageFailureSnapshot(err error, usage **float64) bool {
var failure *trafficusage.Failure
if errors.As(err, &failure) {
if failure.UsageMb != nil {
*usage = failure.UsageMb
}
return failure.Permanent
}
return false
}
func publishTrafficUsageFailure(ctx context.Context, queue string, message any, attempt int, permanent bool) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
body, err := json.Marshal(message)
if err != nil {
return err
}
if permanent || attempt >= 10 {
g.Log().Errorf(ctx, "traffic usage retry exhausted or invalid, queue:%s, attempt:%d, payload:%s", queue, attempt, body)
err = trafficUsagePublish(ctx, queue+".dlq", body, nil)
return waitTrafficUsagePublishFailure(ctx, err)
}
delay := 30 * time.Second
if attempt >= 3 {
delay = 2 * time.Minute
}
if attempt >= 6 {
delay = 5 * time.Minute
}
err = trafficUsagePublishDelayed(ctx, queue+".delay."+delay.String(), queue, delay, body, nil)
return waitTrafficUsagePublishFailure(ctx, err)
}
func waitTrafficUsagePublishFailure(ctx context.Context, err error) error {
if err != nil {
select {
case <-ctx.Done():
case <-time.After(time.Second):
}
}
return err
}
@@ -0,0 +1,60 @@
package rabbitmq
import (
"context"
"encoding/json"
"errors"
amqp "github.com/rabbitmq/amqp091-go"
"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/service/trafficusage"
"testing"
"time"
)
func TestTrafficUsageRetryRetainsPayload(t *testing.T) {
stubTrafficPublish(t)
usage := 12.5
handler := &trafficUsageHandlerStub{dailyErr: &trafficusage.Failure{Cause: errors.New("database down"), UsageMb: &usage}}
consumer := New(handler, nil, nil)
message := model.TrafficUsageDailyRetryMessage{SimKey: &model.SimKey{ICCID: "card"}, Card: model.TrafficUsageSimCard{Iccid: "card"}, QueryDate: "20260831"}
calls := 0
trafficUsagePublishDelayed = func(_ context.Context, queue, target string, delay time.Duration, body []byte, _ amqp.Table) error {
calls++
var retry model.TrafficUsageDailyRetryMessage
if err := json.Unmarshal(body, &retry); err != nil {
t.Fatal(err)
}
if retry.Attempt != 1 || retry.UsageMb == nil || *retry.UsageMb != usage || retry.QueryDate != message.QueryDate || retry.LastError == "" || delay != 30*time.Second || target != consts.QueueTrafficUsageDailyRetry || queue == target {
t.Fatalf("invalid retry: %+v", retry)
}
return nil
}
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); err != nil || calls != 1 {
t.Fatalf("calls=%d err=%v", calls, err)
}
message.Attempt = 9
dlqCalls := 0
trafficUsagePublish = func(_ context.Context, queue string, body []byte, _ amqp.Table) error {
dlqCalls++
if queue != consts.QueueTrafficUsageDailyRetry+".dlq" {
t.Fatal(queue)
}
var retry model.TrafficUsageDailyRetryMessage
if err := json.Unmarshal(body, &retry); err != nil {
t.Fatal(err)
}
if retry.Attempt != 10 || retry.UsageMb == nil {
t.Fatal("DLQ lost snapshot")
}
return nil
}
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); err != nil || dlqCalls != 1 || calls != 1 {
t.Fatalf("DLQ not published: %v", err)
}
expected := errors.New("broker unavailable")
trafficUsagePublish = func(context.Context, string, []byte, amqp.Table) error { return expected }
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); !errors.Is(err, expected) {
t.Fatal("failed publication must not ack original")
}
}
@@ -0,0 +1,53 @@
package rabbitmq
import (
"context"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func (c *Consumers) initWorkOrderAutoClose(ctx context.Context) error {
if scanner, ok := c.workOrder.(interface{ CloseOverdue(context.Context) error }); ok {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
if err := scanner.CloseOverdue(ctx); err != nil {
g.Log().Errorf(ctx, "工单到期补偿失败: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
if !mq.Enabled(ctx) {
g.Log().Info(ctx, "work order auto close consumer skipped, RabbitMQ disabled")
return nil
}
if err := mq.ConsumeJSON[model.WorkOrderAutoCloseMessage](
ctx,
consts.QueueWorkOrderAutoClose,
"iotlcm-work-order-auto-close",
c.handleWorkOrderAutoClose,
); err != nil {
return err
}
g.Log().Infof(ctx, "work order auto close consumer started, queue:%s", consts.QueueWorkOrderAutoClose)
return nil
}
func (c *Consumers) handleWorkOrderAutoClose(ctx context.Context, message model.WorkOrderAutoCloseMessage) error {
if message.WoNo == "" {
g.Log().Errorf(ctx, "work order auto close message dropped, invalid message:%+v", message)
return nil
}
return c.workOrder.AutoCloseResolved(ctx, message)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,706 @@
package zset
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gogf/gf/v2/container/gvar"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
)
type redisCall struct {
command string
args []any
}
type fakeRedis struct {
mu sync.Mutex
calls []redisCall
responses []*gvar.Var
}
func (f *fakeRedis) Do(_ context.Context, command string, args ...any) (*gvar.Var, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, redisCall{command: command, args: args})
if len(f.responses) == 0 {
return gvar.New(int64(1)), nil
}
response := f.responses[0]
f.responses = f.responses[1:]
return response, nil
}
func (f *fakeRedis) Eval(_ context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
allArgs := []any{script, numKeys}
for _, key := range keys {
allArgs = append(allArgs, key)
}
allArgs = append(allArgs, args...)
return f.Do(context.Background(), "EVAL", allArgs...)
}
type testHandler struct {
query func(context.Context, queue.Task) Result
apply func(context.Context, []Result) error
}
func (h *testHandler) Query(ctx context.Context, task queue.Task) Result {
if h.query != nil {
return h.query(ctx, task)
}
return Result{Task: task, Success: true, UsageMB: float64(task.CardID)}
}
func (h *testHandler) Apply(ctx context.Context, results []Result) error {
if h.apply != nil {
return h.apply(ctx, results)
}
return nil
}
func taskMember(task queue.Task) string {
value, _ := json.Marshal(task)
return string(value)
}
func claimedTask(task queue.Task) queue.ClaimedTask {
return queue.ClaimedTask{Task: task, Member: taskMember(task), LeaseToken: "token-" + task.TaskID}
}
func newTestConsumer(t *testing.T, fake *fakeRedis, handler Handler, config Config) *Consumer {
t.Helper()
store, err := queue.New(fake, "flow:")
if err != nil {
t.Fatal(err)
}
consumer, err := New(store, handler, config)
if err != nil {
t.Fatal(err)
}
return consumer
}
func TestRoundSummaryAccumulatesAndFlushes(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New([]any{"runId", "run-1"})}}
consumer := newTestConsumer(t, fake, &testHandler{}, Config{RoundRetryEnabled: true})
started := time.Date(2026, 9, 22, 3, 0, 0, 0, time.UTC)
consumer.recordRoundBatch("run-1", 1, []queue.ClaimedTask{
claimedTask(queue.Task{RunID: "run-1", TaskID: "a"}),
claimedTask(queue.Task{RunID: "run-1", TaskID: "b"}),
}, []queue.TaskOutcome{
{Success: true},
{Retry: true},
}, started)
consumer.recordRoundBatch("run-1", 1, []queue.ClaimedTask{
claimedTask(queue.Task{RunID: "run-1", TaskID: "c"}),
}, []queue.TaskOutcome{{Success: false}}, started.Add(2*time.Second))
key := "run-1\x001"
consumer.summaryMu.Lock()
summary := *consumer.roundSummaries[key]
consumer.summaryMu.Unlock()
if summary.inputCount != 3 || summary.successCount != 1 || summary.failedCount != 2 || summary.deferredCount != 1 || summary.terminalFailedCount != 1 {
t.Fatalf("unexpected summary: %+v", summary)
}
if !summary.startedAt.Equal(started) {
t.Fatalf("startedAt = %v, want %v", summary.startedAt, started)
}
consumer.flushRoundSummary(context.Background(), "run-1", 1)
consumer.summaryMu.Lock()
_, exists := consumer.roundSummaries[key]
consumer.summaryMu.Unlock()
if exists {
t.Fatal("round summary was not removed after flush")
}
}
func TestHandleClaimsAppliesSuccessOnceAndAcknowledgesBatch(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
applyCalls := 0
var applied []Result
handler := &testHandler{apply: func(_ context.Context, results []Result) error {
applyCalls++
applied = append(applied, results...)
return nil
}}
consumer := newTestConsumer(t, fake, handler, Config{ClaimBatch: 2, MobileConcurrency: 2, TelecomConcurrency: 1})
claimed := []queue.ClaimedTask{
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
}
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
t.Fatal(err)
}
if applyCalls != 1 || len(applied) != 2 {
t.Fatalf("apply calls = %d, results = %d", applyCalls, len(applied))
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v, want one batch ack", fake.calls)
}
}
func TestDatabaseFailureLeavesEntireBatchLeased(t *testing.T) {
fake := &fakeRedis{}
handler := &testHandler{
query: func(_ context.Context, task queue.Task) Result {
if task.CardID == 2 {
return Result{Task: task, ErrorClass: "business", Code: 11011, Message: "invalid card"}
}
return Result{Task: task, Success: true, UsageMB: 1.5}
},
apply: func(context.Context, []Result) error { return errors.New("database unavailable") },
}
consumer := newTestConsumer(t, fake, handler, Config{ClaimBatch: 2, MobileConcurrency: 2, TelecomConcurrency: 1})
claimed := []queue.ClaimedTask{
claimedTask(queue.Task{TaskID: "success", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
claimedTask(queue.Task{TaskID: "failed", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
}
err := consumer.handleClaims(context.Background(), claimed)
if err == nil || err.Error() != "apply monthly usage result batch: database unavailable" {
t.Fatalf("handleClaims() error = %v", err)
}
if len(fake.calls) != 0 {
t.Fatalf("Redis calls = %+v, want no ACK when database update fails", fake.calls)
}
}
func TestBusinessFailuresAreTerminalWithoutRetry(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
failureCount := 0
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
return Result{Task: task, ErrorClass: "business", Code: 13000, Message: "busy"}
}}
consumer := newTestConsumer(t, fake, handler, Config{
MobileConcurrency: 2,
TelecomConcurrency: 1,
OnFailure: func(context.Context, Result) {
failureCount++
},
})
claimed := []queue.ClaimedTask{
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
}
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
t.Fatal(err)
}
if failureCount != 2 {
t.Fatalf("failure count = %d, want 2", failureCount)
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v; business failures must only be acknowledged", fake.calls)
}
}
func TestRetryableFailuresRemainLeasedWhileSuccessesAreAcknowledged(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(1))}}
var applied []Result
handler := &testHandler{
query: func(_ context.Context, task queue.Task) Result {
if task.CardID == 1 {
return Result{Task: task, ErrorClass: "network", Retryable: true, Message: "temporary failure"}
}
return Result{Task: task, Success: true, UsageMB: 2}
},
apply: func(_ context.Context, results []Result) error {
applied = append(applied, results...)
return nil
},
}
consumer := newTestConsumer(t, fake, handler, Config{MobileConcurrency: 2, TelecomConcurrency: 1})
claimed := []queue.ClaimedTask{
claimedTask(queue.Task{TaskID: "retry", RunID: "run", ChanKey: "mobile-a", Operator: 1, CardID: 1}),
claimedTask(queue.Task{TaskID: "success", RunID: "run", ChanKey: "mobile-a", Operator: 1, CardID: 2}),
}
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
t.Fatal(err)
}
if len(applied) != 2 {
t.Fatalf("applied results = %d, want both result rows for handler", len(applied))
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v, want one ACK for successful/terminal result", fake.calls)
}
if len(fake.calls[0].args) == 0 {
t.Fatal("ACK call did not include arguments")
}
}
func TestRunRejectsAnotherActiveConsumer(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(nil)}}
consumer := newTestConsumer(t, fake, &testHandler{}, Config{})
if err := consumer.Run(context.Background()); !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("Run error = %v, want ErrAlreadyRunning", err)
}
}
func TestProcessOnceStartsEveryRegisteredChannel(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{
gvar.New([]any{"mobile-a", "telecom-a", "mobile-b"}),
gvar.New([]any{}),
gvar.New([]any{}),
gvar.New([]any{}),
}}
consumer := newTestConsumer(t, fake, &testHandler{}, Config{})
processed, err := consumer.processOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if !processed {
t.Fatal("processOnce() did not start registered channels")
}
deadline := time.Now().Add(time.Second)
for {
fake.mu.Lock()
callCount := len(fake.calls)
fake.mu.Unlock()
if callCount == 4 {
break
}
if time.Now().After(deadline) {
t.Fatalf("Redis calls = %d, want channels + 3 claims", callCount)
}
time.Sleep(time.Millisecond)
}
consumer.channelGroup.Wait()
if len(fake.calls) != 4 {
t.Fatalf("Redis calls = %d, want channels + 3 claims", len(fake.calls))
}
}
type channelRedis struct {
mu sync.Mutex
claims map[string][]queue.Task
claimCounts map[string]int
}
func (r *channelRedis) Do(_ context.Context, command string, _ ...any) (*gvar.Var, error) {
return nil, errors.New("unexpected command " + command)
}
func (r *channelRedis) Eval(_ context.Context, script string, _ int64, keys []string, args []any) (*gvar.Var, error) {
r.mu.Lock()
defer r.mu.Unlock()
if strings.Contains(script, "ZRANGEBYSCORE") {
chanKey := strings.TrimPrefix(keys[0], "flow:")
r.claimCounts[chanKey]++
tasks := r.claims[chanKey]
if len(tasks) == 0 {
return gvar.New([]any{}), nil
}
task := tasks[0]
r.claims[chanKey] = tasks[1:]
return gvar.New([]any{taskMember(task), "token-" + task.TaskID}), nil
}
if len(keys) == 2 && len(args) == 2 {
return gvar.New(int64(1)), nil
}
return nil, errors.New("unexpected eval")
}
func TestSlowChannelDoesNotBlockAnotherChannelFromClaimingAgain(t *testing.T) {
redis := &channelRedis{
claims: map[string][]queue.Task{
"telecom-a": {{TaskID: "t1", RunID: "run", ChanKey: "telecom-a", TempKey: telecomTempKey, Operator: 2}},
"mobile-a": {
{TaskID: "m1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1},
{TaskID: "m2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1},
},
},
claimCounts: make(map[string]int),
}
telecomStarted := make(chan struct{})
releaseTelecom := make(chan struct{})
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
if task.ChanKey == "telecom-a" {
close(telecomStarted)
<-releaseTelecom
}
return Result{Task: task, Success: true}
}}
store, err := queue.New(redis, "flow:")
if err != nil {
t.Fatal(err)
}
consumer, err := New(store, handler, Config{ClaimBatch: 1, MobileConcurrency: 1, TelecomConcurrency: 1, LeaseTTL: time.Minute})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
consumer.startChannel(ctx, "telecom-a")
consumer.startChannel(ctx, "mobile-a")
select {
case <-telecomStarted:
case <-time.After(time.Second):
t.Fatal("telecom channel did not start")
}
if consumer.startChannel(ctx, "telecom-a") {
t.Fatal("channel executor started twice while first executor is running")
}
deadline := time.Now().Add(time.Second)
for {
redis.mu.Lock()
mobileClaims := redis.claimCounts["mobile-a"]
redis.mu.Unlock()
if mobileClaims >= 2 {
break
}
if time.Now().After(deadline) {
t.Fatalf("mobile claim count = %d while telecom is blocked", mobileClaims)
}
time.Sleep(time.Millisecond)
}
close(releaseTelecom)
consumer.channelGroup.Wait()
}
func TestClaimedBatchRenewsLeaseUntilAcknowledged(t *testing.T) {
fake := &fakeRedis{}
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
time.Sleep(45 * time.Millisecond)
return Result{Task: task, Success: true}
}}
consumer := newTestConsumer(t, fake, handler, Config{LeaseTTL: 30 * time.Millisecond, TaskTimeout: 20 * time.Millisecond, MobileConcurrency: 1, TelecomConcurrency: 1})
claimed := []queue.ClaimedTask{claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1})}
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
t.Fatal(err)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if len(fake.calls) < 2 {
t.Fatalf("Redis calls = %d, want lease renewal and ACK", len(fake.calls))
}
}
func TestLostBatchLeasePreventsApplyAndAck(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(0))}}
applyCalls := 0
handler := &testHandler{
query: func(ctx context.Context, task queue.Task) Result {
<-ctx.Done()
return Result{Task: task, ErrorClass: "canceled"}
},
apply: func(context.Context, []Result) error {
applyCalls++
return nil
},
}
consumer := newTestConsumer(t, fake, handler, Config{LeaseTTL: 30 * time.Millisecond, TaskTimeout: 20 * time.Millisecond, MobileConcurrency: 1, TelecomConcurrency: 1})
claimed := []queue.ClaimedTask{claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1})}
err := consumer.handleClaims(context.Background(), claimed)
if !errors.Is(err, ErrLeaseLost) {
t.Fatalf("handleClaims() error = %v, want ErrLeaseLost", err)
}
if applyCalls != 0 {
t.Fatalf("apply calls = %d, want 0 after lease loss", applyCalls)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if len(fake.calls) != 1 {
t.Fatalf("Redis calls = %d, want only failed lease renewal", len(fake.calls))
}
}
func newTrackingHandler(release chan struct{}, current, maximum *atomic.Int32) *testHandler {
return &testHandler{query: func(_ context.Context, task queue.Task) Result {
active := current.Add(1)
for {
observed := maximum.Load()
if active <= observed || maximum.CompareAndSwap(observed, active) {
break
}
}
<-release
current.Add(-1)
return Result{Task: task, Success: true}
}}
}
func TestMobileChannelsHaveIndependentConcurrencyPools(t *testing.T) {
var current atomic.Int32
var maximum atomic.Int32
release := make(chan struct{})
consumer := newTestConsumer(t, &fakeRedis{}, newTrackingHandler(release, &current, &maximum), Config{MobileConcurrency: 2, TelecomConcurrency: 1})
var group sync.WaitGroup
for index := 0; index < 3; index++ {
group.Add(1)
go func(index int) {
defer group.Done()
task := queue.Task{TaskID: string(rune('1' + index)), ChanKey: "mobile-" + string(rune('a'+index)), TempKey: mobileTempKey, CardID: int64(index + 1)}
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
}(index)
}
time.Sleep(100 * time.Millisecond)
if maximum.Load() != 3 {
t.Fatalf("maximum in-flight across channels = %d, want 3 (one pool per channel)", maximum.Load())
}
close(release)
group.Wait()
}
func TestChannelConcurrencyPoolCapsWithinChannel(t *testing.T) {
var current atomic.Int32
var maximum atomic.Int32
release := make(chan struct{})
consumer := newTestConsumer(t, &fakeRedis{}, newTrackingHandler(release, &current, &maximum), Config{MobileConcurrency: 2, TelecomConcurrency: 1})
var group sync.WaitGroup
for index := 0; index < 3; index++ {
group.Add(1)
go func(index int) {
defer group.Done()
task := queue.Task{TaskID: string(rune('1' + index)), ChanKey: "mobile-a", TempKey: mobileTempKey, CardID: int64(index + 1)}
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
}(index)
}
time.Sleep(100 * time.Millisecond)
if maximum.Load() != 2 {
t.Fatalf("maximum in-flight within one channel = %d, want 2", maximum.Load())
}
close(release)
group.Wait()
}
func TestMobileAndTelecomUseIndependentChannelPools(t *testing.T) {
started := make(chan carrier, 2)
release := make(chan struct{})
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
channelCarrier, _ := policyForTemplate(task.TempKey, Config{})
started <- channelCarrier
<-release
return Result{Task: task, Success: true}
}}
consumer := newTestConsumer(t, &fakeRedis{}, handler, Config{MobileConcurrency: 1, TelecomConcurrency: 1})
go func() {
task := queue.Task{TaskID: "mobile", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1}
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
}()
go func() {
task := queue.Task{TaskID: "telecom", ChanKey: "telecom-a", TempKey: telecomTempKey, Operator: 2}
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierTelecom, telecomQPS)
}()
seen := map[carrier]bool{}
for len(seen) < 2 {
select {
case value := <-started:
seen[value] = true
case <-time.After(time.Second):
t.Fatalf("carrier pools did not progress independently: %+v", seen)
}
}
close(release)
}
func TestUnknownTemplateUsesGenericStrategy(t *testing.T) {
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
queryCalls := 0
failures := 0
applyCalls := 0
handler := &testHandler{
query: func(_ context.Context, task queue.Task) Result {
queryCalls++
return Result{Task: task, Success: true, UsageMB: 1}
},
apply: func(_ context.Context, results []Result) error {
applyCalls++
for _, result := range results {
if !result.Success || result.BlockChannel {
t.Fatalf("unexpected result = %+v", result)
}
}
return nil
},
}
consumer := newTestConsumer(t, fake, handler, Config{OnFailure: func(context.Context, Result) { failures++ }})
claimed := []queue.ClaimedTask{
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "unknown-a", TempKey: "unknown-template", Operator: 1, CardID: 1}),
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "unknown-a", TempKey: "unknown-template", Operator: 2, CardID: 2}),
}
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
t.Fatal(err)
}
if queryCalls != 2 || failures != 0 || applyCalls != 1 {
t.Fatalf("query calls = %d, failures = %d, apply calls = %d", queryCalls, failures, applyCalls)
}
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
t.Fatalf("Redis calls = %+v, want one ack script", fake.calls)
}
if len(fake.responses) != 0 {
t.Fatalf("unexpected leftover scripted responses: %+v", fake.responses)
}
}
func TestChannelPacerLimitsEachChannelIndependently(t *testing.T) {
pacer := newChannelPacer()
now := time.Unix(100, 0)
pacedQPS := 30.0
if delay := pacer.reserve("mobile-a", pacedQPS, now); delay != 0 {
t.Fatalf("first reservation delay = %s", delay)
}
if delay := pacer.reserve("mobile-a", pacedQPS, now); delay < 33*time.Millisecond || delay > 34*time.Millisecond {
t.Fatalf("second reservation delay = %s", delay)
}
if delay := pacer.reserve("mobile-b", pacedQPS, now); delay != 0 {
t.Fatalf("independent channel delay = %s", delay)
}
}
func TestChannelPacerSkipsWhenQPSIsDisabled(t *testing.T) {
pacer := newChannelPacer()
now := time.Unix(100, 0)
if delay := pacer.reserve("mobile-a", 0, now); delay != 0 {
t.Fatalf("disabled qps delay = %s", delay)
}
if len(pacer.next) != 0 {
t.Fatalf("disabled qps should not reserve channel slots, got %+v", pacer.next)
}
}
func TestNewDefaultsTaskTimeout(t *testing.T) {
consumer := newTestConsumer(t, &fakeRedis{}, &testHandler{}, Config{})
if consumer.config.TaskTimeout != 30*time.Second {
t.Fatalf("TaskTimeout = %s, want 30s default", consumer.config.TaskTimeout)
}
if consumer.config.GenericQPS != defaultGenericQPS {
t.Fatalf("GenericQPS = %v, want %v default", consumer.config.GenericQPS, defaultGenericQPS)
}
if _, err := New(consumer.store, &testHandler{}, Config{LeaseTTL: 10 * time.Second}); err == nil {
t.Fatal("New() with LeaseTTL below default TaskTimeout should fail")
}
}
func TestPolicyForTemplateUsesTemplateValues(t *testing.T) {
tests := []struct {
name string
tempKey string
carrier carrier
qps float64
}{
{name: "mobile", tempKey: mobileTempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "mobile mock", tempKey: mobileMockKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Tengyu", tempKey: sharedTengyuTempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Xuzhou mobile", tempKey: sharedXuzhouMobileTempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Lelian", tempKey: sharedLelianTempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Qianxun", tempKey: sharedQianxunTempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Henan", tempKey: sharedHenanTempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Daihe", tempKey: sharedDaiheTempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Beixin", tempKey: sharedBeixinTempKey, carrier: carrierMobile, qps: mobileQPS},
// lanshang_8000/8003 are OneLink shared-token templates whose names
// omit the sim_onelink_ prefix. Before this case was added they fell
// through to carrierGeneric and were throttled to 1.5 QPS, which made a
// 6k-card status/traffic/IMEI run take over an hour per channel.
{name: "shared Lanshang 8000", tempKey: sharedLanshang8000TempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Lanshang 8003", tempKey: sharedLanshang8003TempKey, carrier: carrierMobile, qps: mobileQPS},
{name: "shared Lanshang case and padding", tempKey: " LANSHANG_8000 ", carrier: carrierMobile, qps: mobileQPS},
{name: "telecom", tempKey: telecomTempKey, carrier: carrierTelecom, qps: telecomQPS},
{name: "telecom v3", tempKey: " SIM_DX_V3 ", carrier: carrierTelecom, qps: telecomQPS},
{name: "unknown telecom version", tempKey: "sim_dx_v2_future", carrier: carrierGeneric, qps: defaultGenericQPS},
{name: "unknown template", tempKey: "unknown-template", carrier: carrierGeneric, qps: defaultGenericQPS},
{name: "missing template", tempKey: "", carrier: carrierGeneric, qps: defaultGenericQPS},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotCarrier, gotQPS := policyForTemplate(test.tempKey, Config{})
if gotCarrier != test.carrier || gotQPS != test.qps {
t.Fatalf("policyForTemplate(%q) = %q,%v", test.tempKey, gotCarrier, gotQPS)
}
})
}
}
func TestPolicyForTemplateConfigurationOverridesDefaults(t *testing.T) {
config := Config{
GenericQPS: 2.5,
TemplateQPS: map[string]float64{
" SIM_YD_V5 ": 12,
"unknown-template": 3,
"invalid": 0,
},
}
config.setDefaults()
if gotCarrier, gotQPS := policyForTemplate("sim_yd_v5", config); gotCarrier != carrierMobile || gotQPS != 12 {
t.Fatalf("mobile override = %q,%v, want mobile,12", gotCarrier, gotQPS)
}
if gotCarrier, gotQPS := policyForTemplate("unknown-template", config); gotCarrier != carrierGeneric || gotQPS != 3 {
t.Fatalf("generic override = %q,%v, want generic,3", gotCarrier, gotQPS)
}
if gotCarrier, gotQPS := policyForTemplate("other-template", config); gotCarrier != carrierGeneric || gotQPS != 2.5 {
t.Fatalf("generic default = %q,%v, want generic,2.5", gotCarrier, gotQPS)
}
}
// TestFailurePoolAlertThresholdIsObservabilityOnly 覆盖失败池告警:
// - 未配置阀值(0)时不发 HLEN 查询
// - 失败数低于阀值时不告警
// - 超过阀值时发一次 HLEN 并打 WARN,但绝不截断 Run
//
// 告警本身是纯观测行为,所以断言的是「有没有多打 Redis 命令」,
// 而不是「Run 有没有被改坏」——后者必须永远为「没被改坏」。
func TestFailurePoolAlertThresholdIsObservabilityOnly(t *testing.T) {
newMeta := func(failed int64) queue.RunMeta {
return queue.RunMeta{RunID: "run-1", Status: "completed_with_failures", FailedCount: failed, CurrentRound: 1}
}
cases := []struct {
name string
threshold int64
failed int64
wantHLenCalls int
}{
{"未配置阀值不查询", 0, 1_000_000, 0},
{"低于阀值不告警", 50000, 49999, 0},
{"等于阀值不告警", 50000, 50000, 0},
{"超过阀值告警一次", 50000, 50001, 1},
{"远超阀值也只告警一次", 50000, 500_000, 1},
}
for _, tc := range cases {
fake := &fakeRedis{}
consumer := newTestConsumer(t, fake, &testHandler{}, Config{
FailurePoolAlertThreshold: tc.threshold,
})
consumer.reportFailurePoolSize(context.Background(), "run-1", newMeta(tc.failed))
hlen := 0
for _, call := range fake.calls {
if call.command == "HLEN" {
hlen++
}
}
if hlen != tc.wantHLenCalls {
t.Fatalf("%s: HLEN 调用 %d 次, want %d", tc.name, hlen, tc.wantHLenCalls)
}
}
}
// TestFailurePoolAlertReadsCorrectKey 告警必须查该 Run 自己的失败池键。
func TestFailurePoolAlertReadsCorrectKey(t *testing.T) {
fake := &fakeRedis{}
consumer := newTestConsumer(t, fake, &testHandler{}, Config{FailurePoolAlertThreshold: 10})
consumer.reportFailurePoolSize(context.Background(), "run-abc", queue.RunMeta{
RunID: "run-abc", Status: "completed_with_failures", FailedCount: 11, CurrentRound: 1,
})
var key string
for _, call := range fake.calls {
if call.command == "HLEN" {
if len(call.args) > 0 {
key, _ = call.args[0].(string)
}
}
}
want, _ := consumer.store.FailedKey("run-abc")
if key != want {
t.Fatalf("HLEN key = %q, want %q", key, want)
}
}
@@ -0,0 +1,480 @@
package zset
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"time"
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
)
type runHandler struct {
query func(queue.Task) Result
apply func([]Result) error
}
func (h runHandler) Query(_ context.Context, task queue.Task) Result { return h.query(task) }
func (h runHandler) Apply(_ context.Context, results []Result) error {
if h.apply != nil {
return h.apply(results)
}
return nil
}
func runTasks(t *testing.T, store *queue.Store, run string, count int) []queue.Task {
t.Helper()
ctx := context.Background()
if err := store.CreateRun(ctx, queue.RunMeta{RunID: run}); err != nil {
t.Fatal(err)
}
tasks := make([]queue.Task, count)
for i := range tasks {
tasks[i] = queue.Task{RunID: run, TaskID: fmt.Sprintf("%s:%d", run, i), CardID: int64(i + 1), ChanKey: "channel", TempKey: mobileTempKey, ICCID: fmt.Sprintf("card-%d", i)}
}
if n, err := store.EnqueueRunBatch(ctx, run, "channel", tasks, time.Now()); err != nil || n != count {
t.Fatalf("enqueue %d: %v", n, err)
}
return tasks
}
func runMeta(t *testing.T, store *queue.Store, run string) queue.RunMeta {
t.Helper()
meta, err := store.GetRunMeta(context.Background(), run)
if err != nil {
t.Fatal(err)
}
return meta
}
func runConsumer(t *testing.T, store *queue.Store, handler Handler, rounds bool) *Consumer {
t.Helper()
c, err := New(store, handler, Config{RunQueueEnabled: true, RoundRetryEnabled: rounds, BlockedTasksAsFailures: !rounds, EventPrefix: "sim_status_queue", TemplateQPS: map[string]float64{mobileTempKey: 1000000}})
if err != nil {
t.Fatal(err)
}
return c
}
func claimRun(t *testing.T, store *queue.Store, run string, now time.Time, count int) []queue.ClaimedTask {
t.Helper()
claims, err := store.ClaimRun(context.Background(), run, "channel", now, now.Add(time.Minute), count)
if err != nil || len(claims) != count {
t.Fatalf("claim got %d want %d: %v", len(claims), count, err)
}
return claims
}
func finishProducer(t *testing.T, store *queue.Store, run string) {
t.Helper()
if err := store.MarkProducerDone(context.Background(), run); err != nil {
t.Fatal(err)
}
}
func checkTerminal(t *testing.T, store *queue.Store, run string, success, failed int64) {
t.Helper()
meta := runMeta(t, store, run)
want := "completed"
if failed > 0 {
want = "completed_with_failures"
}
if meta.Status != want || meta.CompletedAt.IsZero() || meta.SuccessCount != success || meta.FailedCount != failed || meta.ActiveCount != 0 || meta.InflightCount != 0 || meta.NextRoundCount != 0 {
t.Fatalf("unexpected terminal metadata: %+v", meta)
}
}
func TestRunConsumerCountsAndCompletes(t *testing.T) {
redis := testutil.Redis(t)
ctx := context.Background()
for _, failure := range []bool{false, true} {
t.Run(fmt.Sprint(failure), func(t *testing.T) {
store, _ := queue.New(redis, "sim-status:")
run := fmt.Sprint("counts-", failure)
tasks := runTasks(t, store, run, 3)
if n, err := store.EnqueueRunBatch(ctx, run, "channel", tasks, time.Now()); err != nil || n != 0 {
t.Fatalf("duplicate enqueue: %d %v", n, err)
}
finishProducer(t, store, run)
handler := runHandler{query: func(task queue.Task) Result {
return Result{Task: task, Success: !failure || task.CardID != 2, ErrorClass: "business"}
}, apply: func(_ []Result) error {
if meta := runMeta(t, store, run); meta.SuccessCount != 0 || meta.FailedCount != 0 {
return errors.New("counted before DB apply")
}
return nil
}}
c := runConsumer(t, store, handler, false)
claims := claimRun(t, store, run, time.Now(), 3)
if err := c.handleRunClaims(ctx, claims); err != nil {
t.Fatal(err)
}
var failures int64
if failure {
failures = 1
}
checkTerminal(t, store, run, 3-failures, failures)
outcomes := []queue.TaskOutcome{{Success: true}, {Success: true}, {Success: true}}
if n, err := store.FinalizeRunTasks(ctx, claims, outcomes); err != nil || n != 0 {
t.Fatalf("duplicate finalize: %d %v", n, err)
}
checkTerminal(t, store, run, 3-failures, failures)
key, _ := store.RunMetaKey(run)
ttl, err := redis.Do(ctx, "TTL", key)
if err != nil || ttl.Int64() < 29*24*3600 {
t.Fatalf("retention=%v err=%v", ttl, err)
}
})
}
}
func TestRunConsumerLateProducerEmptyAndHistoricalRunCompletion(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "sim-status:")
ctx := context.Background()
if err := store.CreateRun(ctx, queue.RunMeta{RunID: "historical", Status: "running", ProducerDone: true}); err != nil {
t.Fatal(err)
}
runTasks(t, store, "empty", 0)
runTasks(t, store, "late", 1)
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }}, false)
claims := claimRun(t, store, "late", time.Now(), 1)
if err := c.handleRunClaims(ctx, claims); err != nil {
t.Fatal(err)
}
if meta := runMeta(t, store, "late"); !meta.CompletedAt.IsZero() || meta.SuccessCount != 1 {
t.Fatalf("premature completion: %+v", meta)
}
finishProducer(t, store, "late")
finishProducer(t, store, "empty")
if _, err := c.processOnce(ctx); err != nil {
t.Fatal(err)
}
c.channelGroup.Wait()
checkTerminal(t, store, "late", 1, 0)
checkTerminal(t, store, "empty", 0, 0)
checkTerminal(t, store, "historical", 0, 0)
}
func TestRunConsumerRecoveryAndFencing(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "sim-status:")
ctx := context.Background()
for _, mode := range []string{"database", "network", "stale"} {
t.Run(mode, func(t *testing.T) {
runTasks(t, store, mode, 1)
finishProducer(t, store, mode)
now := time.Now()
claims := claimRun(t, store, mode, now, 1)
good := runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }}
handler := good
if mode == "database" {
handler.apply = func([]Result) error { return errors.New("database unavailable") }
}
if mode == "network" {
handler.query = func(task queue.Task) Result { return Result{Task: task, Retryable: true, ErrorClass: "network"} }
}
c := runConsumer(t, store, handler, false)
if mode != "stale" {
err := c.handleRunClaims(ctx, claims)
if (err != nil) != (mode == "database") {
t.Fatalf("handle: %v", err)
}
if meta := runMeta(t, store, mode); meta.SuccessCount != 0 || meta.FailedCount != 0 || meta.ActiveCount != 1 || meta.InflightCount != 1 || meta.Status != "running" {
t.Fatalf("lost pending task: %+v", meta)
}
}
fresh := claimRun(t, store, mode, now.Add(2*time.Minute), 1)
if n, err := store.RenewRunBatch(ctx, claims, now.Add(3*time.Minute)); err != nil || n != 0 {
t.Fatalf("stale renewal: %d %v", n, err)
}
if n, err := store.FinalizeRunTasks(ctx, claims, []queue.TaskOutcome{{Success: true}}); err != nil || n != 0 {
t.Fatalf("stale ack: %d %v", n, err)
}
if n, err := store.BlockRunScopedAsFailed(ctx, claims[0], queue.TaskOutcome{}); err != nil || n != -1 {
t.Fatalf("stale block: %d %v", n, err)
}
c.handler = good
if err := c.handleRunClaims(ctx, fresh); err != nil {
t.Fatal(err)
}
checkTerminal(t, store, mode, 1, 0)
})
}
}
func TestRunConsumerBlockedChannelCountsOnlyUnfinishedTasks(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "sim-status:")
ctx := context.Background()
runTasks(t, store, "blocked", 5)
runTasks(t, store, "other", 1)
// Production continues while the channel is blocked.
claims := claimRun(t, store, "blocked", time.Now(), 2)
var applied int
successQueried := make(chan struct{})
handler := runHandler{query: func(task queue.Task) Result {
if task.CardID == 1 {
close(successQueried)
return Result{Task: task, Success: true}
}
<-successQueried
return Result{Task: task, BlockChannel: true, ErrorClass: "channel_limited", Code: 797, ErrCode: "S00004", Message: "limited"}
}, apply: func(results []Result) error { applied = countSuccessful(results); return nil }}
c := runConsumer(t, store, handler, false)
// Ensure the success response arrives before the channel-limit response.
if err := c.handleRunClaims(ctx, claims); err != nil {
t.Fatal(err)
}
meta := runMeta(t, store, "blocked")
if applied != 1 || meta.SuccessCount != 1 || meta.FailedCount != 4 || meta.ActiveCount != 0 || meta.InflightCount != 0 {
t.Fatalf("applied=%d meta=%+v", applied, meta)
}
if n, err := store.BlockRunScopedAsFailed(ctx, claims[1], queue.TaskOutcome{}); err != nil || n != -1 {
t.Fatalf("duplicate block: %d %v", n, err)
}
late := queue.Task{TaskID: "late", RunID: "blocked", ChanKey: "channel", CardID: 99}
if n, err := store.EnqueueRunBatch(ctx, "blocked", "channel", []queue.Task{late}, time.Now()); err != nil || n != 0 {
t.Fatalf("blocked enqueue: %d %v", n, err)
}
key, _ := store.FailedKey("blocked")
failures, err := redis.Do(ctx, "HLEN", key)
if err != nil || failures.Int() != 4 {
t.Fatalf("failure records=%v err=%v", failures, err)
}
stored, err := redis.Do(ctx, "HGET", key, "blocked:4")
if err != nil {
t.Fatal(err)
}
var record queue.FailureRecord
if err := json.Unmarshal([]byte(stored.String()), &record); err != nil {
t.Fatal(err)
}
if record.TaskID != "blocked:4" || record.CardID != 5 || record.ICCID != "card-4" || record.Attempts != 0 || record.FailureType != "channel_blocked" || record.ErrCode != "S00004" {
t.Fatalf("failure identity/reason: %+v", record)
}
stateKey, _ := store.TaskStateKey("blocked")
state, err := redis.Do(ctx, "HGET", stateKey, "blocked:4")
if err != nil || state.MapStrAny()["status"] != "channel_blocked" {
t.Fatalf("blocked state=%v err=%v", state, err)
}
if meta := runMeta(t, store, "other"); meta.ActiveCount != 1 || meta.FailedCount != 0 {
t.Fatalf("other run affected: %+v", meta)
}
finishProducer(t, store, "blocked")
if err := c.completeRun(ctx, "blocked"); err != nil {
t.Fatal(err)
}
checkTerminal(t, store, "blocked", 1, 4)
}
func TestRunConsumerMonthlyRoundsRemainEnabled(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "flow:")
ctx := context.Background()
runTasks(t, store, "monthly", 1)
finishProducer(t, store, "monthly")
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, true)
for round := 1; round <= 5; round++ {
if err := c.handleRunClaims(ctx, claimRun(t, store, "monthly", time.Now(), 1)); err != nil {
t.Fatal(err)
}
if round < 5 {
if meta := runMeta(t, store, "monthly"); meta.NextRoundCount != 1 || meta.FailedCount != 0 || meta.Status != "running" {
t.Fatalf("round %d: %+v", round, meta)
}
if ok, err := store.AdvanceRunRound(ctx, "monthly", 5); err != nil || !ok {
t.Fatalf("advance %v %v", ok, err)
}
}
}
checkTerminal(t, store, "monthly", 0, 1)
}
func TestRunConsumerStatusRetriesRetryableResultsForThreeRounds(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "sim-status:")
ctx := context.Background()
runTasks(t, store, "status", 1)
finishProducer(t, store, "status")
c, err := New(store, runHandler{query: func(task queue.Task) Result {
return Result{Task: task, ErrorClass: "carrier_retry", Retryable: true, Code: 9999, ErrCode: "S00004"}
}}, Config{
RunQueueEnabled: true,
RoundRetryEnabled: true,
MaxRounds: 3,
EventPrefix: "sim_status_queue",
ShouldRetryNextRound: func(result Result) bool {
return result.Retryable
},
TemplateQPS: map[string]float64{mobileTempKey: 1000000},
})
if err != nil {
t.Fatal(err)
}
for round := 1; round <= 3; round++ {
if err := c.handleRunClaims(ctx, claimRun(t, store, "status", time.Now(), 1)); err != nil {
t.Fatal(err)
}
if round < 3 {
if meta := runMeta(t, store, "status"); meta.NextRoundCount != 1 || meta.FailedCount != 0 || meta.Status != "running" {
t.Fatalf("round %d: %+v", round, meta)
}
if ok, err := store.AdvanceRunRound(ctx, "status", 3); err != nil || !ok {
t.Fatalf("advance %v %v", ok, err)
}
}
}
checkTerminal(t, store, "status", 0, 1)
}
func TestReplayMaxRoundOverridesConsumerDefault(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "sim-status:")
ctx := context.Background()
if err := store.CreateRun(ctx, queue.RunMeta{RunID: "replay-one", Operation: "status", SyncType: "replay", MaxRound: 1}); err != nil {
t.Fatal(err)
}
task := queue.Task{RunID: "replay-one", TaskID: "task", CardID: 1, ICCID: "card", ChanKey: "channel", TempKey: mobileTempKey}
if _, err := store.EnqueueRunBatch(ctx, task.RunID, task.ChanKey, []queue.Task{task}, time.Now()); err != nil {
t.Fatal(err)
}
finishProducer(t, store, "replay-one")
c, err := New(store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, Config{RunQueueEnabled: true, RoundRetryEnabled: true, MaxRounds: 3, TemplateQPS: map[string]float64{mobileTempKey: 1000000}})
if err != nil {
t.Fatal(err)
}
if err := c.handleRunClaims(ctx, claimRun(t, store, "replay-one", time.Now(), 1)); err != nil {
t.Fatal(err)
}
checkTerminal(t, store, "replay-one", 0, 1)
meta := runMeta(t, store, "replay-one")
if meta.CurrentRound != 1 || meta.NextRoundCount != 0 {
t.Fatalf("replay advanced rounds: %+v", meta)
}
}
func TestRunQueueModeDoesNotEnableRoundRetry(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "sim-status:")
runTasks(t, store, "single-round", 1)
finishProducer(t, store, "single-round")
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, false)
if err := c.handleRunClaims(context.Background(), claimRun(t, store, "single-round", time.Now(), 1)); err != nil {
t.Fatal(err)
}
checkTerminal(t, store, "single-round", 0, 1)
if meta := runMeta(t, store, "single-round"); meta.CurrentRound != 1 {
t.Fatalf("unexpected retry: %+v", meta)
}
}
func TestMonthlyChannelBlockKeepsExistingCountingPolicy(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "flow:")
ctx := context.Background()
runTasks(t, store, "monthly-block", 2)
claimRun(t, store, "monthly-block", time.Now(), 1)
for i := 0; i < 2; i++ {
want := int64(2)
if i == 1 {
want = 0
}
if n, err := store.BlockRunScoped(ctx, "monthly-block", "channel"); err != nil || n != want {
t.Fatalf("monthly block=%d err=%v", n, err)
}
}
if meta := runMeta(t, store, "monthly-block"); meta.FailedCount != 0 || meta.ActiveCount != 0 || meta.InflightCount != 0 {
t.Fatalf("monthly counting changed: %+v", meta)
}
}
func TestRunConsumerConcurrentRuns(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "sim-status:")
ctx := context.Background()
var mu sync.Mutex
applied := map[string]int{}
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }, apply: func(results []Result) error {
mu.Lock()
defer mu.Unlock()
for _, result := range results {
applied[result.Task.RunID]++
}
return nil
}}, false)
for _, run := range []string{"a", "b"} {
runTasks(t, store, run, 3)
finishProducer(t, store, run)
}
if started, err := c.processOnce(ctx); err != nil || !started {
t.Fatalf("process: %v %v", started, err)
}
c.channelGroup.Wait()
for _, run := range []string{"a", "b"} {
checkTerminal(t, store, run, 3, 0)
if applied[run] != 3 {
t.Fatalf("applied %s: %d", run, applied[run])
}
}
}
// TestRunConsumerCoveredResultsCompleteWithoutFailures drives the full
// consumer path for a covered result: the round must close, the run must end
// as "completed" rather than "completed_with_failures", and the failure pool
// must stay empty. This is the IMEI 14000 "carrier has no record" case — on
// 2026-10-20 it accounted for every failure recorded in production while the
// run success rate read 21%.
func TestRunConsumerCoveredResultsCompleteWithoutFailures(t *testing.T) {
redis := testutil.Redis(t)
store, _ := queue.New(redis, "imei:")
ctx := context.Background()
runTasks(t, store, "covered", 4)
finishProducer(t, store, "covered")
queried := 0
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result {
queried++
switch task.CardID {
case 1:
return Result{Task: task, Success: true, Imei: "865466070817587", ImeiSynced: true}
case 2:
return Result{Task: task, ErrorClass: "no_record", Code: 14000, ImeiSynced: true, Covered: true, Message: "系统无记录,请检查输入参数是否正确"}
case 3:
return Result{Task: task, ErrorClass: "data_invalid", ImeiSynced: true, Covered: true, Message: "carrier returned no imei value"}
default:
return Result{Task: task, ErrorClass: "business", Code: 11011, Message: "invalid card"}
}
}}, false)
if err := c.handleRunClaims(ctx, claimRun(t, store, "covered", time.Now(), 4)); err != nil {
t.Fatal(err)
}
if queried != 4 {
t.Fatalf("queried = %d, want 4", queried)
}
meta := runMeta(t, store, "covered")
// Three cards produced a usable or definitive answer (success + 2 covered),
// and only the business failure is counted. Without the covered channel the
// two 14000-style answers would have made failedCount=3.
if meta.Status != "completed_with_failures" || meta.SuccessCount != 3 || meta.FailedCount != 1 || meta.NextRoundCount != 0 {
t.Fatalf("unexpected run accounting: %+v", meta)
}
failureKey, _ := store.FailedKey("covered")
size, err := redis.Do(ctx, "HLEN", failureKey)
if err != nil || size.Int64() != 1 {
t.Fatalf("failure pool entries = %d err=%v, want 1 (only the business failure)", size.Int64(), err)
}
for _, taskID := range []string{"covered:1", "covered:2"} {
exists, err := redis.Do(ctx, "HEXISTS", failureKey, taskID)
if err != nil || exists.Int() != 0 {
t.Fatalf("covered card %s leaked into the failure pool: %v %v", taskID, exists, err)
}
}
exists, err := redis.Do(ctx, "HEXISTS", failureKey, "covered:3")
if err != nil || exists.Int() != 1 {
t.Fatalf("the business failure is missing from the pool: %v %v", exists, err)
}
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// batchDetailDao is the data access object for the table batch_detail.
// You can define custom methods on it to extend its functionality as needed.
type batchDetailDao struct {
*internal.BatchDetailDao
}
var (
// BatchDetail is a globally accessible object for table batch_detail operations.
BatchDetail = batchDetailDao{internal.NewBatchDetailDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// batchMainDao is the data access object for the table batch_main.
// You can define custom methods on it to extend its functionality as needed.
type batchMainDao struct {
*internal.BatchMainDao
}
var (
// BatchMain is a globally accessible object for table batch_main operations.
BatchMain = batchMainDao{internal.NewBatchMainDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,109 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// BatchDetailDao is the data access object for the table batch_detail.
type BatchDetailDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns BatchDetailColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// BatchDetailColumns defines and stores column names for the table batch_detail.
type BatchDetailColumns struct {
Id string // 主键ID
BatchNo string // 批次编号
BatchType string // 批次类型 枚举(与主表一致)
DetailStatus string // 明细异步处理状态 0-待处理 1-处理中 2-成功 3-失败
BlankOutStatus string // 作废状态 0-是 1-否
RowNo string // 行号
ItemId string // 项ID:卡ICCID/设备编码
ErrorCode string // 异常编码
ErrorReason string // 异常原因
Snapshot string // 信息快照
TenantId string // 租户ID
CreatedDept string // 创建部门ID
CreatedBy string // 创建者ID
CreatedAt string // 创建时间
UpdatedBy string // 更新者ID
UpdatedAt string // 更新时间
}
// batchDetailColumns holds the columns for the table batch_detail.
var batchDetailColumns = BatchDetailColumns{
Id: "id",
BatchNo: "batch_no",
BatchType: "batch_type",
DetailStatus: "detail_status",
BlankOutStatus: "blank_out_status",
RowNo: "row_no",
ItemId: "item_id",
ErrorCode: "error_code",
ErrorReason: "error_reason",
Snapshot: "snapshot",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewBatchDetailDao creates and returns a new DAO object for table data access.
func NewBatchDetailDao(handlers ...gdb.ModelHandler) *BatchDetailDao {
return &BatchDetailDao{
group: "default",
table: "batch_detail",
columns: batchDetailColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *BatchDetailDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *BatchDetailDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *BatchDetailDao) Columns() BatchDetailColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *BatchDetailDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *BatchDetailDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *BatchDetailDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,139 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// BatchMainDao is the data access object for the table batch_main.
type BatchMainDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns BatchMainColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// BatchMainColumns defines and stores column names for the table batch_main.
type BatchMainColumns struct {
Id string // 主键ID
BatchNo string // 批次编号
OrderSn string // 订单编号
BatchType string // 批次类型 枚举
BatchStatus string // 批次状态 枚举
BatchAsyncStatus string // 异步处理状态
SourceType string // 来源方式 枚举
SourceBatchNo string // 来源批次编号
SourceOssId string // 源文件OSS ID
ResultOssId string // 结果文件OSS ID
IsCrossDept string // 是否跨级操作机构
TargetDeptId string // 目标机构编号
DeptId string // 机构编号
TotalCount string // 总数量
ValidCount string // 有效数量
SuccessCount string // 成功数量
FailCount string // 失败数量
RunResult string // 运行结果
FailReason string // 失败原因
BatchInfo string // 批次信息:入库/出库展示字段
Remark string // 备注扩展字段
StartTime string // 开始时间
EndTime string // 结束时间
TenantId string // 租户ID
CreatedDept string // 创建部门ID
CreatedBy string // 创建者ID
CreatedAt string // 创建时间
UpdatedBy string // 更新者ID
UpdatedAt string // 更新时间
SourceChannel string // 发起渠道:console/mini_program/api/system/legacy
ExecutionMode string // 操作规模:0未知1单卡2批量
}
// batchMainColumns holds the columns for the table batch_main.
var batchMainColumns = BatchMainColumns{
Id: "id",
BatchNo: "batch_no",
OrderSn: "order_sn",
BatchType: "batch_type",
BatchStatus: "batch_status",
BatchAsyncStatus: "batch_async_status",
SourceType: "source_type",
SourceBatchNo: "source_batch_no",
SourceOssId: "source_oss_id",
ResultOssId: "result_oss_id",
IsCrossDept: "is_cross_dept",
TargetDeptId: "target_dept_id",
DeptId: "dept_id",
TotalCount: "total_count",
ValidCount: "valid_count",
SuccessCount: "success_count",
FailCount: "fail_count",
RunResult: "run_result",
FailReason: "fail_reason",
BatchInfo: "batch_info",
Remark: "remark",
StartTime: "start_time",
EndTime: "end_time",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
SourceChannel: "source_channel",
ExecutionMode: "execution_mode",
}
// NewBatchMainDao creates and returns a new DAO object for table data access.
func NewBatchMainDao(handlers ...gdb.ModelHandler) *BatchMainDao {
return &BatchMainDao{
group: "default",
table: "batch_main",
columns: batchMainColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *BatchMainDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *BatchMainDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *BatchMainDao) Columns() BatchMainColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *BatchMainDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *BatchMainDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *BatchMainDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,101 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysConfigDao is the data access object for the table sys_config.
type SysConfigDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysConfigColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysConfigColumns defines and stores column names for the table sys_config.
type SysConfigColumns struct {
ConfigId string //
TenantId string //
ConfigName string //
ConfigKey string //
ConfigValue string //
ConfigType string //
CreatedDept string //
CreatedBy string //
CreatedAt string //
UpdatedBy string //
UpdatedAt string //
Remark string //
}
// sysConfigColumns holds the columns for the table sys_config.
var sysConfigColumns = SysConfigColumns{
ConfigId: "config_id",
TenantId: "tenant_id",
ConfigName: "config_name",
ConfigKey: "config_key",
ConfigValue: "config_value",
ConfigType: "config_type",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
Remark: "remark",
}
// NewSysConfigDao creates and returns a new DAO object for table data access.
func NewSysConfigDao(handlers ...gdb.ModelHandler) *SysConfigDao {
return &SysConfigDao{
group: "default",
table: "sys_config",
columns: sysConfigColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysConfigDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysConfigDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysConfigDao) Columns() SysConfigColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysConfigDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysConfigDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysConfigDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,125 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysDeptDao is the data access object for the table sys_dept.
type SysDeptDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysDeptColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysDeptColumns defines and stores column names for the table sys_dept.
type SysDeptColumns struct {
DeptId string // 部门id
TenantId string // 租户编号
ParentId string // 父部门id
Ancestors string // 祖级列表
DeptKey string // 机构编号
DeptName string // 部门名称
DeptType string // 部门类型(0:部门 1:公司)
DeptCategory string // 部门类别编码
OrderNum string // 显示顺序
Leader string // 负责人
Phone string // 联系电话
Email string // 邮箱
Status string // 部门状态(0正常 1停用)
CreditCode string // 统一社会信用代码
AreaCode string // 行政区域代码
Address string // 地址
DeptRole string // 机构默认角色ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
DeletedBy string // 删除人
DeletedAt string // 删除时间
}
// sysDeptColumns holds the columns for the table sys_dept.
var sysDeptColumns = SysDeptColumns{
DeptId: "dept_id",
TenantId: "tenant_id",
ParentId: "parent_id",
Ancestors: "ancestors",
DeptKey: "dept_key",
DeptName: "dept_name",
DeptType: "dept_type",
DeptCategory: "dept_category",
OrderNum: "order_num",
Leader: "leader",
Phone: "phone",
Email: "email",
Status: "status",
CreditCode: "credit_code",
AreaCode: "area_code",
Address: "address",
DeptRole: "dept_role",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
DeletedBy: "deleted_by",
DeletedAt: "deleted_at",
}
// NewSysDeptDao creates and returns a new DAO object for table data access.
func NewSysDeptDao(handlers ...gdb.ModelHandler) *SysDeptDao {
return &SysDeptDao{
group: "default",
table: "sys_dept",
columns: sysDeptColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysDeptDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysDeptDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysDeptDao) Columns() SysDeptColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysDeptDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysDeptDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysDeptDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+111
View File
@@ -0,0 +1,111 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysOssDao is the data access object for the table sys_oss.
type SysOssDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysOssColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysOssColumns defines and stores column names for the table sys_oss.
type SysOssColumns struct {
OssId string // 对象存储主键
TenantId string // 租户编号
FileName string // 文件名
OriginalName string // 原名
FileSuffix string // 文件后缀名
Path string // 存储路径
Url string // URL地址
CreatedDept string // 创建部门
CreatedAt string // 创建时间
CreatedBy string // 创建者
UpdatedAt string // 更新时间
UpdatedBy string // 更新者
Service string // 服务商
Md5 string // 文件MD5
FileSize string // 文件大小
FileCrc16 string // 文件Crc16
FileSum string // 文件校验和
}
// sysOssColumns holds the columns for the table sys_oss.
var sysOssColumns = SysOssColumns{
OssId: "oss_id",
TenantId: "tenant_id",
FileName: "file_name",
OriginalName: "original_name",
FileSuffix: "file_suffix",
Path: "path",
Url: "url",
CreatedDept: "created_dept",
CreatedAt: "created_at",
CreatedBy: "created_by",
UpdatedAt: "updated_at",
UpdatedBy: "updated_by",
Service: "service",
Md5: "md5",
FileSize: "file_size",
FileCrc16: "file_crc16",
FileSum: "file_sum",
}
// NewSysOssDao creates and returns a new DAO object for table data access.
func NewSysOssDao(handlers ...gdb.ModelHandler) *SysOssDao {
return &SysOssDao{
group: "default",
table: "sys_oss",
columns: sysOssColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysOssDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysOssDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysOssDao) Columns() SysOssColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysOssDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysOssDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysOssDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,83 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysPermissionRevisionDao is the data access object for the table sys_permission_revision.
type SysPermissionRevisionDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysPermissionRevisionColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysPermissionRevisionColumns defines and stores column names for the table sys_permission_revision.
type SysPermissionRevisionColumns struct {
Id string //
Revision string //
UpdatedAt string //
}
// sysPermissionRevisionColumns holds the columns for the table sys_permission_revision.
var sysPermissionRevisionColumns = SysPermissionRevisionColumns{
Id: "id",
Revision: "revision",
UpdatedAt: "updated_at",
}
// NewSysPermissionRevisionDao creates and returns a new DAO object for table data access.
func NewSysPermissionRevisionDao(handlers ...gdb.ModelHandler) *SysPermissionRevisionDao {
return &SysPermissionRevisionDao{
group: "default",
table: "sys_permission_revision",
columns: sysPermissionRevisionColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysPermissionRevisionDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysPermissionRevisionDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysPermissionRevisionDao) Columns() SysPermissionRevisionColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysPermissionRevisionDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysPermissionRevisionDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysPermissionRevisionDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,119 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysRoleDao is the data access object for the table sys_role.
type SysRoleDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysRoleColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysRoleColumns defines and stores column names for the table sys_role.
type SysRoleColumns struct {
RoleId string //
TenantId string //
DeptId string //
RoleName string //
RoleKey string //
RoleSort string //
DataScope string //
MenuCheckStrictly string //
DeptCheckStrictly string //
Status string //
IsPass string //
SourceRoleId string //
SourceDeptId string //
CreatedDept string //
CreatedBy string //
CreatedAt string //
UpdatedBy string //
UpdatedAt string //
DeletedBy string //
DeletedAt string //
Remark string //
}
// sysRoleColumns holds the columns for the table sys_role.
var sysRoleColumns = SysRoleColumns{
RoleId: "role_id",
TenantId: "tenant_id",
DeptId: "dept_id",
RoleName: "role_name",
RoleKey: "role_key",
RoleSort: "role_sort",
DataScope: "data_scope",
MenuCheckStrictly: "menu_check_strictly",
DeptCheckStrictly: "dept_check_strictly",
Status: "status",
IsPass: "is_pass",
SourceRoleId: "source_role_id",
SourceDeptId: "source_dept_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
DeletedBy: "deleted_by",
DeletedAt: "deleted_at",
Remark: "remark",
}
// NewSysRoleDao creates and returns a new DAO object for table data access.
func NewSysRoleDao(handlers ...gdb.ModelHandler) *SysRoleDao {
return &SysRoleDao{
group: "default",
table: "sys_role",
columns: sysRoleColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysRoleDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysRoleDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysRoleDao) Columns() SysRoleColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysRoleDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysRoleDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysRoleDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,83 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysRoleDeptDao is the data access object for the table sys_role_dept.
type SysRoleDeptDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysRoleDeptColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysRoleDeptColumns defines and stores column names for the table sys_role_dept.
type SysRoleDeptColumns struct {
TenantId string //
RoleId string //
DeptId string //
}
// sysRoleDeptColumns holds the columns for the table sys_role_dept.
var sysRoleDeptColumns = SysRoleDeptColumns{
TenantId: "tenant_id",
RoleId: "role_id",
DeptId: "dept_id",
}
// NewSysRoleDeptDao creates and returns a new DAO object for table data access.
func NewSysRoleDeptDao(handlers ...gdb.ModelHandler) *SysRoleDeptDao {
return &SysRoleDeptDao{
group: "default",
table: "sys_role_dept",
columns: sysRoleDeptColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysRoleDeptDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysRoleDeptDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysRoleDeptDao) Columns() SysRoleDeptColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysRoleDeptDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysRoleDeptDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysRoleDeptDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,85 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysRoleMenuDao is the data access object for the table sys_role_menu.
type SysRoleMenuDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysRoleMenuColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysRoleMenuColumns defines and stores column names for the table sys_role_menu.
type SysRoleMenuColumns struct {
TenantId string //
RoleId string //
MenuId string //
DataScope string //
}
// sysRoleMenuColumns holds the columns for the table sys_role_menu.
var sysRoleMenuColumns = SysRoleMenuColumns{
TenantId: "tenant_id",
RoleId: "role_id",
MenuId: "menu_id",
DataScope: "data_scope",
}
// NewSysRoleMenuDao creates and returns a new DAO object for table data access.
func NewSysRoleMenuDao(handlers ...gdb.ModelHandler) *SysRoleMenuDao {
return &SysRoleMenuDao{
group: "default",
table: "sys_role_menu",
columns: sysRoleMenuColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysRoleMenuDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysRoleMenuDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysRoleMenuDao) Columns() SysRoleMenuColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysRoleMenuDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysRoleMenuDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysRoleMenuDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,111 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysRoleSyncTaskDao is the data access object for the table sys_role_sync_task.
type SysRoleSyncTaskDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysRoleSyncTaskColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysRoleSyncTaskColumns defines and stores column names for the table sys_role_sync_task.
type SysRoleSyncTaskColumns struct {
TaskId string //
TenantId string //
SourceRoleId string //
SourceDeptId string //
ActorId string //
Status string //
Attempts string //
NextAttemptAt string //
LeaseUntil string //
LeaseToken string //
Payload string //
LastError string //
TotalCount string //
SuccessCount string //
FailedCount string //
CreatedAt string //
UpdatedAt string //
}
// sysRoleSyncTaskColumns holds the columns for the table sys_role_sync_task.
var sysRoleSyncTaskColumns = SysRoleSyncTaskColumns{
TaskId: "task_id",
TenantId: "tenant_id",
SourceRoleId: "source_role_id",
SourceDeptId: "source_dept_id",
ActorId: "actor_id",
Status: "status",
Attempts: "attempts",
NextAttemptAt: "next_attempt_at",
LeaseUntil: "lease_until",
LeaseToken: "lease_token",
Payload: "payload",
LastError: "last_error",
TotalCount: "total_count",
SuccessCount: "success_count",
FailedCount: "failed_count",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewSysRoleSyncTaskDao creates and returns a new DAO object for table data access.
func NewSysRoleSyncTaskDao(handlers ...gdb.ModelHandler) *SysRoleSyncTaskDao {
return &SysRoleSyncTaskDao{
group: "default",
table: "sys_role_sync_task",
columns: sysRoleSyncTaskColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysRoleSyncTaskDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysRoleSyncTaskDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysRoleSyncTaskDao) Columns() SysRoleSyncTaskColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysRoleSyncTaskDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysRoleSyncTaskDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysRoleSyncTaskDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,83 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysUserRoleDao is the data access object for the table sys_user_role.
type SysUserRoleDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysUserRoleColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysUserRoleColumns defines and stores column names for the table sys_user_role.
type SysUserRoleColumns struct {
TenantId string //
UserId string //
RoleId string //
}
// sysUserRoleColumns holds the columns for the table sys_user_role.
var sysUserRoleColumns = SysUserRoleColumns{
TenantId: "tenant_id",
UserId: "user_id",
RoleId: "role_id",
}
// NewSysUserRoleDao creates and returns a new DAO object for table data access.
func NewSysUserRoleDao(handlers ...gdb.ModelHandler) *SysUserRoleDao {
return &SysUserRoleDao{
group: "default",
table: "sys_user_role",
columns: sysUserRoleColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysUserRoleDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysUserRoleDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysUserRoleDao) Columns() SysUserRoleColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysUserRoleDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysUserRoleDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysUserRoleDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,107 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TMessageLogDao is the data access object for the table t_message_log.
type TMessageLogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TMessageLogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TMessageLogColumns defines and stores column names for the table t_message_log.
type TMessageLogColumns struct {
MessageLogId string // 主键ID
MessageType string // 消息类型
SendUserType string // 发送用户类型
UserIds string // 接收用户ID集合(JSON数组)
MessageTitle string // 消息标题
MessageContent string // 消息内容
MessageLink string // 跳转链接地址
SendTime string // 发送时间
IsRecall string // 是否撤回
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tMessageLogColumns holds the columns for the table t_message_log.
var tMessageLogColumns = TMessageLogColumns{
MessageLogId: "message_log_id",
MessageType: "message_type",
SendUserType: "send_user_type",
UserIds: "user_ids",
MessageTitle: "message_title",
MessageContent: "message_content",
MessageLink: "message_link",
SendTime: "send_time",
IsRecall: "is_recall",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTMessageLogDao creates and returns a new DAO object for table data access.
func NewTMessageLogDao(handlers ...gdb.ModelHandler) *TMessageLogDao {
return &TMessageLogDao{
group: "default",
table: "t_message_log",
columns: tMessageLogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TMessageLogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TMessageLogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TMessageLogDao) Columns() TMessageLogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TMessageLogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TMessageLogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TMessageLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,141 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TResourceBatchDownloadDao is the data access object for the table t_resource_batch_download.
type TResourceBatchDownloadDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TResourceBatchDownloadColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TResourceBatchDownloadColumns defines and stores column names for the table t_resource_batch_download.
type TResourceBatchDownloadColumns struct {
Id string // 主键ID
BatchNo string // 批次编号(全局唯一)
TaskName string // 任务名称
ResourceType string // 资源类型 枚举
ExportScene string // 导出场景 枚举
QueryParams string // 导出查询条件快照
TotalCount string // 预计导出总条数
SuccessCount string // 已成功导出条数
FailCount string // 失败条数
DownloadStatus string // 下载状态 枚举
OssId string // OSS文件ID
FileName string // 导出文件名
FileExt string // 文件扩展名
FileUrl string // 文件访问地址
FilePath string // 服务器本地路径
FileSize string // 文件大小(字节)
FileMd5 string // 文件MD5
DownloadCount string // 下载次数
LastDownloadTime string // 最后下载时间
MaxDownloadCount string // 最大下载次数限制
ErrorMessage string // 失败原因
DeptId string // 所属机构ID
StartTime string // 任务开始时间
FinishTime string // 任务完成时间
ExpireTime string // 文件过期时间
Remark string // 备注
TenantId string // 租户ID
CreatedDept string // 创建部门ID
CreatedBy string // 创建者ID
CreatedAt string // 创建时间
UpdatedBy string // 更新者ID
UpdatedAt string // 更新时间
}
// tResourceBatchDownloadColumns holds the columns for the table t_resource_batch_download.
var tResourceBatchDownloadColumns = TResourceBatchDownloadColumns{
Id: "id",
BatchNo: "batch_no",
TaskName: "task_name",
ResourceType: "resource_type",
ExportScene: "export_scene",
QueryParams: "query_params",
TotalCount: "total_count",
SuccessCount: "success_count",
FailCount: "fail_count",
DownloadStatus: "download_status",
OssId: "oss_id",
FileName: "file_name",
FileExt: "file_ext",
FileUrl: "file_url",
FilePath: "file_path",
FileSize: "file_size",
FileMd5: "file_md5",
DownloadCount: "download_count",
LastDownloadTime: "last_download_time",
MaxDownloadCount: "max_download_count",
ErrorMessage: "error_message",
DeptId: "dept_id",
StartTime: "start_time",
FinishTime: "finish_time",
ExpireTime: "expire_time",
Remark: "remark",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTResourceBatchDownloadDao creates and returns a new DAO object for table data access.
func NewTResourceBatchDownloadDao(handlers ...gdb.ModelHandler) *TResourceBatchDownloadDao {
return &TResourceBatchDownloadDao{
group: "default",
table: "t_resource_batch_download",
columns: tResourceBatchDownloadColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TResourceBatchDownloadDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TResourceBatchDownloadDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TResourceBatchDownloadDao) Columns() TResourceBatchDownloadColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TResourceBatchDownloadDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TResourceBatchDownloadDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TResourceBatchDownloadDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,191 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimCardCancellationDao is the data access object for the table t_sim_card_cancellation.
type TSimCardCancellationDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimCardCancellationColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimCardCancellationColumns defines and stores column names for the table t_sim_card_cancellation.
type TSimCardCancellationColumns struct {
Id string // 主键ID
Iccid string // ICCID
Msisdn string // MSISDN/卡号
Imsi string // IMSI
Imei string // IMEI
RegionCode string // 归属地
DeptId string // 渠道客户ID
CurrentPackageCode string // 当前套餐编号
PackageStatus string // 套餐状态:0 无套餐,1待生效,2生效中,3已过期
AccessType string // 接入方式:ipv4、ipv6、ipv4_ipv6
BossCardStatus string // 运营商卡状态 枚举
CancelBatchNo string // 系统销户批次号
CancelledAt string // 系统销户完成时间
CardType string // 卡类型 枚举
BossOpenAt string // 运营商开户日期
BossActivateAt string // 运营商激活日期
IsBossActivateEnabled string // 是否按运营商激活减去赠送时长方:1是,0否
InboundBatchNo string // 入库批次
OutboundBatchNo string // 出库批次
AllocationBatchNo string // 划拨批次
IsFlowPool string // 是否为池卡:1是,0否
FlowPoolCode string // 流量池编号
FlowPkgBeginAt string // 当前套餐生效时间
FlowPkgPresentAt string // 套餐赠送结束时间
FlowPkgRealPresentAt string // 实际赠送结束时间
FlowPkgEndAt string // 当前套餐预计到期时间
DataServiceStatus string // 数据服务状态:0启用,1停用
PackageTotalFlowMb string // 套餐总流量MB
UsedFlowMb string // 已用流量MB
TrafficCutoffStatus string // 达量断网配置状态:0未配置,1已开启,2已关闭
TrafficCutoffThreshold string // 达量断网阈值
SmsServiceStatus string // 短信服务状态:0启用,1停用
PackageTotalSms string // 套餐短信总条数
UsedSms string // 已用短信条数
VoiceServiceStatus string // 语音服务状态:0启用,1停用
PackageTotalVoice string // 套餐语音总分钟数
UsedVoice string // 已用语音分钟数
RoamServiceStatus string // 国漫服务状态:0启用,1停用
PackageTotalRoamFlowMb string // 套餐国际流量MB
UsedRoamFlowMb string // 已用国际流量MB
StatusSyncTime string // 状态最后一次同步时间
FlowSyncTime string // 流量最后一次同步时间
SmsSyncTime string // 短信最后一次同步时间
VoiceSyncTime string // 语音最后一次同步时间
RoamSyncTime string // 国漫最后一次同步时间
ExtraFeeEnabled string // 是否收取附加费:0是,1否
ExtraFeeMultiple string // 附加费用倍数
ExtraFee string // 附加金额
ChanKey string // 通道编号
NetModel string // 网络类型 枚举
Remark string // 备注
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimCardCancellationColumns holds the columns for the table t_sim_card_cancellation.
var tSimCardCancellationColumns = TSimCardCancellationColumns{
Id: "id",
Iccid: "iccid",
Msisdn: "msisdn",
Imsi: "imsi",
Imei: "imei",
RegionCode: "region_code",
DeptId: "dept_id",
CurrentPackageCode: "current_package_code",
PackageStatus: "package_status",
AccessType: "access_type",
BossCardStatus: "boss_card_status",
CancelBatchNo: "cancel_batch_no",
CancelledAt: "cancelled_at",
CardType: "card_type",
BossOpenAt: "boss_open_at",
BossActivateAt: "boss_activate_at",
IsBossActivateEnabled: "is_boss_activate_enabled",
InboundBatchNo: "inbound_batch_no",
OutboundBatchNo: "outbound_batch_no",
AllocationBatchNo: "allocation_batch_no",
IsFlowPool: "is_flow_pool",
FlowPoolCode: "flow_pool_code",
FlowPkgBeginAt: "flow_pkg_begin_at",
FlowPkgPresentAt: "flow_pkg_present_at",
FlowPkgRealPresentAt: "flow_pkg_real_present_at",
FlowPkgEndAt: "flow_pkg_end_at",
DataServiceStatus: "data_service_status",
PackageTotalFlowMb: "package_total_flow_mb",
UsedFlowMb: "used_flow_mb",
TrafficCutoffStatus: "traffic_cutoff_status",
TrafficCutoffThreshold: "traffic_cutoff_threshold",
SmsServiceStatus: "sms_service_status",
PackageTotalSms: "package_total_sms",
UsedSms: "used_sms",
VoiceServiceStatus: "voice_service_status",
PackageTotalVoice: "package_total_voice",
UsedVoice: "used_voice",
RoamServiceStatus: "roam_service_status",
PackageTotalRoamFlowMb: "package_total_roam_flow_mb",
UsedRoamFlowMb: "used_roam_flow_mb",
StatusSyncTime: "status_sync_time",
FlowSyncTime: "flow_sync_time",
SmsSyncTime: "sms_sync_time",
VoiceSyncTime: "voice_sync_time",
RoamSyncTime: "roam_sync_time",
ExtraFeeEnabled: "extra_fee_enabled",
ExtraFeeMultiple: "extra_fee_multiple",
ExtraFee: "extra_fee",
ChanKey: "chan_key",
NetModel: "net_model",
Remark: "remark",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimCardCancellationDao creates and returns a new DAO object for table data access.
func NewTSimCardCancellationDao(handlers ...gdb.ModelHandler) *TSimCardCancellationDao {
return &TSimCardCancellationDao{
group: "default",
table: "t_sim_card_cancellation",
columns: tSimCardCancellationColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimCardCancellationDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimCardCancellationDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimCardCancellationDao) Columns() TSimCardCancellationColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimCardCancellationDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimCardCancellationDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimCardCancellationDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,193 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimCardInfoDao is the data access object for the table t_sim_card_info.
type TSimCardInfoDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimCardInfoColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimCardInfoColumns defines and stores column names for the table t_sim_card_info.
type TSimCardInfoColumns struct {
Id string // 主键ID
Iccid string // ICCID
Msisdn string // MSISDN/卡号
Imsi string // IMSI
Imei string // IMEI
RegionCode string // 归属地
DeptId string // 渠道客户ID
CurrentPackageCode string // 当前套餐编号
PackageStatus string // 套餐状态:0 无套餐,1待生效,2生效中,3已过期
AccessType string // 接入方式:ipv4、ipv6、ipv4_ipv6
BossCardStatus string // 运营商卡状态 枚举
CardType string // 卡类型 枚举
BossOpenAt string // 运营商开户日期
BossActivateAt string // 运营商激活日期
IsBossActivateEnabled string // 是否按运营商激活减去赠送时长方:1是,0否
InboundBatchNo string // 入库批次
OutboundBatchNo string // 出库批次
AllocationBatchNo string // 划拨批次
IsFlowPool string // 是否为池卡:1是,0否
FlowPoolCode string // 流量池编号
OutStockTime string // 出库时间
FlowPkgBeginAt string // 当前套餐生效时间
FlowPkgPresentAt string // 套餐赠送结束时间
FlowPkgRealPresentAt string // 实际赠送结束时间
FlowPkgEndAt string // 当前套餐预计到期时间
DataServiceStatus string // 数据服务状态:1启用、0禁用
PackageTotalFlowMb string // 套餐总流量MB
UsedFlowMb string // 已用流量MB
TrafficCutoffStatus string // 达量断网配置状态:0未配置,1已开启,2已关闭
TrafficCutoffThreshold string // 达量断网阈值
SmsServiceStatus string // 短信服务状态:1启用、0禁用
PackageTotalSms string // 套餐短信总条数
UsedSms string // 已用短信条数
VoiceServiceStatus string // 语音服务状态:1启用、0禁用
PackageTotalVoice string // 套餐语音总分钟数
UsedVoice string // 已用语音分钟数
RoamServiceStatus string // 国漫服务状态:1启用、0禁用
PackageTotalRoamFlowMb string // 套餐国际流量MB
UsedRoamFlowMb string // 已用国际流量MB
StatusSyncTime string // 状态最后一次同步时间
FlowSyncTime string // 流量最后一次同步时间
SmsSyncTime string // 短信最后一次同步时间
VoiceSyncTime string // 语音最后一次同步时间
RoamSyncTime string // 国漫最后一次同步时间
ImeiSyncTime string // IMEI 最后一次轮询同步时间
ImeiUpdateTime string // IMEI 值最后一次实际变更时间
ExtraFeeEnabled string // 是否收取附加费:1是,0否
ExtraFeeMultiple string // 附加费用倍数
ExtraFee string // 附加金额
ChanKey string // 通道编号
NetModel string // 网络类型:4G、5G、NB-IoT
Remark string // 备注
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimCardInfoColumns holds the columns for the table t_sim_card_info.
var tSimCardInfoColumns = TSimCardInfoColumns{
Id: "id",
Iccid: "iccid",
Msisdn: "msisdn",
Imsi: "imsi",
Imei: "imei",
RegionCode: "region_code",
DeptId: "dept_id",
CurrentPackageCode: "current_package_code",
PackageStatus: "package_status",
AccessType: "access_type",
BossCardStatus: "boss_card_status",
CardType: "card_type",
BossOpenAt: "boss_open_at",
BossActivateAt: "boss_activate_at",
IsBossActivateEnabled: "is_boss_activate_enabled",
InboundBatchNo: "inbound_batch_no",
OutboundBatchNo: "outbound_batch_no",
AllocationBatchNo: "allocation_batch_no",
IsFlowPool: "is_flow_pool",
FlowPoolCode: "flow_pool_code",
OutStockTime: "out_stock_time",
FlowPkgBeginAt: "flow_pkg_begin_at",
FlowPkgPresentAt: "flow_pkg_present_at",
FlowPkgRealPresentAt: "flow_pkg_real_present_at",
FlowPkgEndAt: "flow_pkg_end_at",
DataServiceStatus: "data_service_status",
PackageTotalFlowMb: "package_total_flow_mb",
UsedFlowMb: "used_flow_mb",
TrafficCutoffStatus: "traffic_cutoff_status",
TrafficCutoffThreshold: "traffic_cutoff_threshold",
SmsServiceStatus: "sms_service_status",
PackageTotalSms: "package_total_sms",
UsedSms: "used_sms",
VoiceServiceStatus: "voice_service_status",
PackageTotalVoice: "package_total_voice",
UsedVoice: "used_voice",
RoamServiceStatus: "roam_service_status",
PackageTotalRoamFlowMb: "package_total_roam_flow_mb",
UsedRoamFlowMb: "used_roam_flow_mb",
StatusSyncTime: "status_sync_time",
FlowSyncTime: "flow_sync_time",
SmsSyncTime: "sms_sync_time",
VoiceSyncTime: "voice_sync_time",
RoamSyncTime: "roam_sync_time",
ImeiSyncTime: "imei_sync_time",
ImeiUpdateTime: "imei_update_time",
ExtraFeeEnabled: "extra_fee_enabled",
ExtraFeeMultiple: "extra_fee_multiple",
ExtraFee: "extra_fee",
ChanKey: "chan_key",
NetModel: "net_model",
Remark: "remark",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimCardInfoDao creates and returns a new DAO object for table data access.
func NewTSimCardInfoDao(handlers ...gdb.ModelHandler) *TSimCardInfoDao {
return &TSimCardInfoDao{
group: "default",
table: "t_sim_card_info",
columns: tSimCardInfoColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimCardInfoDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimCardInfoDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimCardInfoDao) Columns() TSimCardInfoColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimCardInfoDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimCardInfoDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimCardInfoDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,119 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimFlowPoolDao is the data access object for the table t_sim_flow_pool.
type TSimFlowPoolDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimFlowPoolColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimFlowPoolColumns defines and stores column names for the table t_sim_flow_pool.
type TSimFlowPoolColumns struct {
Id string // 主键ID
FlowPoolCode string // 流量池编号
DeptId string // 客户编号
PackageCodes string // 套餐编号列表(冗余)
PoolPackageCode string // 池套餐编码
CardCount string // 关联卡数量
CardFlowMb string // 套餐卡流量MB
PoolPackageFlowMb string // 套内总流量MB
AddOnFlowMb string // 加油包总流量MB
TotalFlowMb string // 总流量MB(套内+加油包)
UsedFlowMb string // 已使用流量MB
RemainFlowMb string // 剩余流量MB
LastStatTime string // 最后流量统计时间
PoolStatus string // 状态:0 正常、1 停用、2 锁定
Remark string // 备注
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimFlowPoolColumns holds the columns for the table t_sim_flow_pool.
var tSimFlowPoolColumns = TSimFlowPoolColumns{
Id: "id",
FlowPoolCode: "flow_pool_code",
DeptId: "dept_id",
PackageCodes: "package_codes",
PoolPackageCode: "pool_package_code",
CardCount: "card_count",
CardFlowMb: "card_flow_mb",
PoolPackageFlowMb: "pool_package_flow_mb",
AddOnFlowMb: "add_on_flow_mb",
TotalFlowMb: "total_flow_mb",
UsedFlowMb: "used_flow_mb",
RemainFlowMb: "remain_flow_mb",
LastStatTime: "last_stat_time",
PoolStatus: "pool_status",
Remark: "remark",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimFlowPoolDao creates and returns a new DAO object for table data access.
func NewTSimFlowPoolDao(handlers ...gdb.ModelHandler) *TSimFlowPoolDao {
return &TSimFlowPoolDao{
group: "default",
table: "t_sim_flow_pool",
columns: tSimFlowPoolColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimFlowPoolDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimFlowPoolDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimFlowPoolDao) Columns() TSimFlowPoolColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimFlowPoolDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimFlowPoolDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimFlowPoolDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,97 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TUserMessageDao is the data access object for the table t_user_message.
type TUserMessageDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TUserMessageColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TUserMessageColumns defines and stores column names for the table t_user_message.
type TUserMessageColumns struct {
MessageId string // 主键ID
MessageLogId string // 消息日志ID
UserId string // 用户ID
IsRead string // 是否已读
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tUserMessageColumns holds the columns for the table t_user_message.
var tUserMessageColumns = TUserMessageColumns{
MessageId: "message_id",
MessageLogId: "message_log_id",
UserId: "user_id",
IsRead: "is_read",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTUserMessageDao creates and returns a new DAO object for table data access.
func NewTUserMessageDao(handlers ...gdb.ModelHandler) *TUserMessageDao {
return &TUserMessageDao{
group: "default",
table: "t_user_message",
columns: tUserMessageColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TUserMessageDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TUserMessageDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TUserMessageDao) Columns() TUserMessageColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TUserMessageDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TUserMessageDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TUserMessageDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,123 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TWarnConfigDao is the data access object for the table t_warn_config.
type TWarnConfigDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TWarnConfigColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TWarnConfigColumns defines and stores column names for the table t_warn_config.
type TWarnConfigColumns struct {
Id string // 主键ID
TenantId string // 租户ID
BatchNo string // 批次编号
ItemType string // 对象类型:1卡,2流量池
ItemId string // 对象ID:单卡为t_sim_card_info.id,流量池为t_sim_flow_pool.id
ItemCode string // 对象编码快照:ICCID/MSISDN/流量池编码
WarnUnit string // 预警单位:1MB,2百分比,3天数,4月数
FirstWarn string // 第一档预警阈值,单位由warn_unit决定
SecondWarn string // 第二档预警阈值,单位由warn_unit决定
ThirdWarn string // 第三档预警阈值,单位由warn_unit决定
WarnMethods string // 预警方式:0短信,1邮箱,2电话,3平台消息;多选逗号分隔
WarnPhones string // 预警手机号,多个逗号分隔
WarnEmails string // 预警邮箱,多个逗号分隔
AutoAction string // 自动动作:0无,2断网;第一期只保存配置不自动执行
IsEnabled string // 是否启用:1启用,0停用
WxTemplateId string // 微信订阅消息模板ID
Remark string // 备注
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
AppNotifyEnabled string // 是否开启小程序预警通知:0关闭,1开启
}
// tWarnConfigColumns holds the columns for the table t_warn_config.
var tWarnConfigColumns = TWarnConfigColumns{
Id: "id",
TenantId: "tenant_id",
BatchNo: "batch_no",
ItemType: "item_type",
ItemId: "item_id",
ItemCode: "item_code",
WarnUnit: "warn_unit",
FirstWarn: "first_warn",
SecondWarn: "second_warn",
ThirdWarn: "third_warn",
WarnMethods: "warn_methods",
WarnPhones: "warn_phones",
WarnEmails: "warn_emails",
AutoAction: "auto_action",
IsEnabled: "is_enabled",
WxTemplateId: "wx_template_id",
Remark: "remark",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
AppNotifyEnabled: "app_notify_enabled",
}
// NewTWarnConfigDao creates and returns a new DAO object for table data access.
func NewTWarnConfigDao(handlers ...gdb.ModelHandler) *TWarnConfigDao {
return &TWarnConfigDao{
group: "default",
table: "t_warn_config",
columns: tWarnConfigColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TWarnConfigDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TWarnConfigDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TWarnConfigDao) Columns() TWarnConfigColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TWarnConfigDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TWarnConfigDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TWarnConfigDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,125 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TWarnLogDao is the data access object for the table t_warn_log.
type TWarnLogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TWarnLogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TWarnLogColumns defines and stores column names for the table t_warn_log.
type TWarnLogColumns struct {
Id string // 主键ID
TenantId string // 租户ID
ConfigId string // 预警配置ID
ItemType string // 对象类型:1单卡,2流量池
ItemId string // 对象ID:单卡为t_sim_card_info.id,流量池为t_sim_flow_pool.id
ItemCode string // 对象编码快照:ICCID/MSISDN/流量池编码
WarnStage string // 触发档位:1第一档,2第二档,3第三档
WarnLevel string // 预警级别:1提示,2注意,3警告,4危险,5紧急
WarnUnit string // 预警单位/维度:1MB,2百分比,3天数,4月数
ThresholdValue string // 触发阈值,单位由warn_unit决定
ActualValue string // 触发时实际值,单位由warn_unit决定
TotalFlowMb string // 触发时总流量MB;流量类预警使用
RemainFlowMb string // 触发时剩余流量MB;流量类预警使用
WarnMethods string // 实际预警方式快照
WarnPhones string // 实际预警手机号快照
WarnEmails string // 实际预警邮箱快照
MessageLogId string // 平台消息ID;聚合通知时可能为空
WarnTime string // 预警时间
Remark string // 备注
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tWarnLogColumns holds the columns for the table t_warn_log.
var tWarnLogColumns = TWarnLogColumns{
Id: "id",
TenantId: "tenant_id",
ConfigId: "config_id",
ItemType: "item_type",
ItemId: "item_id",
ItemCode: "item_code",
WarnStage: "warn_stage",
WarnLevel: "warn_level",
WarnUnit: "warn_unit",
ThresholdValue: "threshold_value",
ActualValue: "actual_value",
TotalFlowMb: "total_flow_mb",
RemainFlowMb: "remain_flow_mb",
WarnMethods: "warn_methods",
WarnPhones: "warn_phones",
WarnEmails: "warn_emails",
MessageLogId: "message_log_id",
WarnTime: "warn_time",
Remark: "remark",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTWarnLogDao creates and returns a new DAO object for table data access.
func NewTWarnLogDao(handlers ...gdb.ModelHandler) *TWarnLogDao {
return &TWarnLogDao{
group: "default",
table: "t_warn_log",
columns: tWarnLogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TWarnLogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TWarnLogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TWarnLogDao) Columns() TWarnLogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TWarnLogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TWarnLogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TWarnLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,137 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TWoMainDao is the data access object for the table t_wo_main.
type TWoMainDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TWoMainColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TWoMainColumns defines and stores column names for the table t_wo_main.
type TWoMainColumns struct {
Id string // 主键ID
WoNo string // 工单编号
WoType string // 工单类型 0卡,1模组,2设备,3订单,4余额,5提现
RelatedId string // 关联业务ID
Title string // 工单标题
Content string // 问题描述
Attachment string // 附件
CreatorType string // 创建人类型:1终端用户,2代理商
CreatorDeptId string // 创建机构ID
CreatorUserId string // 创建用户ID
CreatorName string // 创建人姓名
CreatorPhone string // 创建人手机号
AssignDeptId string // 分派方机构ID
AssignUserId string // 分派人用户ID
CurrentStatus string // 当前处理状态:1已分派,2处理中,3已解决,4已完结_关单,5已取消
HandlerDeptId string // 处理方机构ID
HandlerUserId string // 处理人用户ID
FirstResponseTime string // 首次响应时间
ResolveTime string // 确认时间
CloseTime string // 关闭时间
SatisfactionLevel string // 满意度:1-5分
EvalContent string // 评价内容
EvalTime string // 评价时间
Remark string // 备注
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tWoMainColumns holds the columns for the table t_wo_main.
var tWoMainColumns = TWoMainColumns{
Id: "id",
WoNo: "wo_no",
WoType: "wo_type",
RelatedId: "related_id",
Title: "title",
Content: "content",
Attachment: "attachment",
CreatorType: "creator_type",
CreatorDeptId: "creator_dept_id",
CreatorUserId: "creator_user_id",
CreatorName: "creator_name",
CreatorPhone: "creator_phone",
AssignDeptId: "assign_dept_id",
AssignUserId: "assign_user_id",
CurrentStatus: "current_status",
HandlerDeptId: "handler_dept_id",
HandlerUserId: "handler_user_id",
FirstResponseTime: "first_response_time",
ResolveTime: "resolve_time",
CloseTime: "close_time",
SatisfactionLevel: "satisfaction_level",
EvalContent: "eval_content",
EvalTime: "eval_time",
Remark: "remark",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTWoMainDao creates and returns a new DAO object for table data access.
func NewTWoMainDao(handlers ...gdb.ModelHandler) *TWoMainDao {
return &TWoMainDao{
group: "default",
table: "t_wo_main",
columns: tWoMainColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TWoMainDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TWoMainDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TWoMainDao) Columns() TWoMainColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TWoMainDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TWoMainDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TWoMainDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,107 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TWoRecordDao is the data access object for the table t_wo_record.
type TWoRecordDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TWoRecordColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TWoRecordColumns defines and stores column names for the table t_wo_record.
type TWoRecordColumns struct {
Id string // 主键ID
WoNo string // 工单编号
OperationType string // 操作类型:0创建,1分派,2处理,3完成,4关单,5取消
OperationDeptId string // 操作机构ID
OperationUserId string // 操作用户ID
TargetDeptId string // 目标部门ID
TargetUserId string // 目标用户ID
BeforeStatus string // 变更前状态
AfterStatus string // 变更后状态
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tWoRecordColumns holds the columns for the table t_wo_record.
var tWoRecordColumns = TWoRecordColumns{
Id: "id",
WoNo: "wo_no",
OperationType: "operation_type",
OperationDeptId: "operation_dept_id",
OperationUserId: "operation_user_id",
TargetDeptId: "target_dept_id",
TargetUserId: "target_user_id",
BeforeStatus: "before_status",
AfterStatus: "after_status",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTWoRecordDao creates and returns a new DAO object for table data access.
func NewTWoRecordDao(handlers ...gdb.ModelHandler) *TWoRecordDao {
return &TWoRecordDao{
group: "default",
table: "t_wo_record",
columns: tWoRecordColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TWoRecordDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TWoRecordDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TWoRecordDao) Columns() TWoRecordColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TWoRecordDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TWoRecordDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TWoRecordDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysConfigDao is the data access object for the table sys_config.
// You can define custom methods on it to extend its functionality as needed.
type sysConfigDao struct {
*internal.SysConfigDao
}
var (
// SysConfig is a globally accessible object for table sys_config operations.
SysConfig = sysConfigDao{internal.NewSysConfigDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysDeptDao is the data access object for the table sys_dept.
// You can define custom methods on it to extend its functionality as needed.
type sysDeptDao struct {
*internal.SysDeptDao
}
var (
// SysDept is a globally accessible object for table sys_dept operations.
SysDept = sysDeptDao{internal.NewSysDeptDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysOssDao is the data access object for the table sys_oss.
// You can define custom methods on it to extend its functionality as needed.
type sysOssDao struct {
*internal.SysOssDao
}
var (
// SysOss is a globally accessible object for table sys_oss operations.
SysOss = sysOssDao{internal.NewSysOssDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysPermissionRevisionDao is the data access object for the table sys_permission_revision.
// You can define custom methods on it to extend its functionality as needed.
type sysPermissionRevisionDao struct {
*internal.SysPermissionRevisionDao
}
var (
// SysPermissionRevision is a globally accessible object for table sys_permission_revision operations.
SysPermissionRevision = sysPermissionRevisionDao{internal.NewSysPermissionRevisionDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysRoleDao is the data access object for the table sys_role.
// You can define custom methods on it to extend its functionality as needed.
type sysRoleDao struct {
*internal.SysRoleDao
}
var (
// SysRole is a globally accessible object for table sys_role operations.
SysRole = sysRoleDao{internal.NewSysRoleDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysRoleDeptDao is the data access object for the table sys_role_dept.
// You can define custom methods on it to extend its functionality as needed.
type sysRoleDeptDao struct {
*internal.SysRoleDeptDao
}
var (
// SysRoleDept is a globally accessible object for table sys_role_dept operations.
SysRoleDept = sysRoleDeptDao{internal.NewSysRoleDeptDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysRoleMenuDao is the data access object for the table sys_role_menu.
// You can define custom methods on it to extend its functionality as needed.
type sysRoleMenuDao struct {
*internal.SysRoleMenuDao
}
var (
// SysRoleMenu is a globally accessible object for table sys_role_menu operations.
SysRoleMenu = sysRoleMenuDao{internal.NewSysRoleMenuDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysRoleSyncTaskDao is the data access object for the table sys_role_sync_task.
// You can define custom methods on it to extend its functionality as needed.
type sysRoleSyncTaskDao struct {
*internal.SysRoleSyncTaskDao
}
var (
// SysRoleSyncTask is a globally accessible object for table sys_role_sync_task operations.
SysRoleSyncTask = sysRoleSyncTaskDao{internal.NewSysRoleSyncTaskDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// sysUserRoleDao is the data access object for the table sys_user_role.
// You can define custom methods on it to extend its functionality as needed.
type sysUserRoleDao struct {
*internal.SysUserRoleDao
}
var (
// SysUserRole is a globally accessible object for table sys_user_role operations.
SysUserRole = sysUserRoleDao{internal.NewSysUserRoleDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tMessageLogDao is the data access object for the table t_message_log.
// You can define custom methods on it to extend its functionality as needed.
type tMessageLogDao struct {
*internal.TMessageLogDao
}
var (
// TMessageLog is a globally accessible object for table t_message_log operations.
TMessageLog = tMessageLogDao{internal.NewTMessageLogDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tResourceBatchDownloadDao is the data access object for the table t_resource_batch_download.
// You can define custom methods on it to extend its functionality as needed.
type tResourceBatchDownloadDao struct {
*internal.TResourceBatchDownloadDao
}
var (
// TResourceBatchDownload is a globally accessible object for table t_resource_batch_download operations.
TResourceBatchDownload = tResourceBatchDownloadDao{internal.NewTResourceBatchDownloadDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tSimCardCancellationDao is the data access object for the table t_sim_card_cancellation.
// You can define custom methods on it to extend its functionality as needed.
type tSimCardCancellationDao struct {
*internal.TSimCardCancellationDao
}
var (
// TSimCardCancellation is a globally accessible object for table t_sim_card_cancellation operations.
TSimCardCancellation = tSimCardCancellationDao{internal.NewTSimCardCancellationDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tSimCardInfoDao is the data access object for the table t_sim_card_info.
// You can define custom methods on it to extend its functionality as needed.
type tSimCardInfoDao struct {
*internal.TSimCardInfoDao
}
var (
// TSimCardInfo is a globally accessible object for table t_sim_card_info operations.
TSimCardInfo = tSimCardInfoDao{internal.NewTSimCardInfoDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tSimFlowPoolDao is the data access object for the table t_sim_flow_pool.
// You can define custom methods on it to extend its functionality as needed.
type tSimFlowPoolDao struct {
*internal.TSimFlowPoolDao
}
var (
// TSimFlowPool is a globally accessible object for table t_sim_flow_pool operations.
TSimFlowPool = tSimFlowPoolDao{internal.NewTSimFlowPoolDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tUserMessageDao is the data access object for the table t_user_message.
// You can define custom methods on it to extend its functionality as needed.
type tUserMessageDao struct {
*internal.TUserMessageDao
}
var (
// TUserMessage is a globally accessible object for table t_user_message operations.
TUserMessage = tUserMessageDao{internal.NewTUserMessageDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tWarnConfigDao is the data access object for the table t_warn_config.
// You can define custom methods on it to extend its functionality as needed.
type tWarnConfigDao struct {
*internal.TWarnConfigDao
}
var (
// TWarnConfig is a globally accessible object for table t_warn_config operations.
TWarnConfig = tWarnConfigDao{internal.NewTWarnConfigDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tWarnLogDao is the data access object for the table t_warn_log.
// You can define custom methods on it to extend its functionality as needed.
type tWarnLogDao struct {
*internal.TWarnLogDao
}
var (
// TWarnLog is a globally accessible object for table t_warn_log operations.
TWarnLog = tWarnLogDao{internal.NewTWarnLogDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tWoMainDao is the data access object for the table t_wo_main.
// You can define custom methods on it to extend its functionality as needed.
type tWoMainDao struct {
*internal.TWoMainDao
}
var (
// TWoMain is a globally accessible object for table t_wo_main operations.
TWoMain = tWoMainDao{internal.NewTWoMainDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
)
// tWoRecordDao is the data access object for the table t_wo_record.
// You can define custom methods on it to extend its functionality as needed.
type tWoRecordDao struct {
*internal.TWoRecordDao
}
var (
// TWoRecord is a globally accessible object for table t_wo_record operations.
TWoRecord = tWoRecordDao{internal.NewTWoRecordDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,802 @@
// Package archive durably stores summary-only receipts for completed full-sync
// runs and replay actions. It depends only on the shared full-sync contracts;
// repository implementations and service wiring live elsewhere.
package archive
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
)
const (
defaultInterval = time.Minute
defaultBackoff = time.Second
defaultMaxBackoff = time.Minute
defaultMaxAttempts = 4
defaultReplayLeaseTTL = time.Minute
defaultReplayLeaseName = "fullsync-archive"
defaultReleaseTimeout = 5 * time.Second
maxRounds = 10000
)
var errChecksumMismatch = gerror.New("archive checksum verification failed")
// Config controls the recovery scanner. Root is the archive directory. Zero
// timing values select conservative defaults.
type Config struct {
Root string
Interval time.Duration
InitialBackoff time.Duration
MaxBackoff time.Duration
MaxAttempts int
ReplayLeaseOwner string
ReplayLeaseTTL time.Duration
Now func() time.Time
}
// ReplayRepositories are the contract boundaries used to recover replay
// actions left in receipt_pending after a process restart.
type ReplayRepositories struct {
Discovery contract.ReplayWorkDiscovery
Leases contract.ReplayLeaseStore
Progress contract.ReplayProgressStore
}
// Service publishes archive files and scans durable repository indexes at
// startup and periodically. A Service may be configured for runs, actions, or
// both.
type Service struct {
root string
runDiscovery contract.ArchiveWorkDiscovery
runLoader contract.ArchiveWorkLoader
runRegistrar contract.ArchiveRegistrar
replay ReplayRepositories
interval time.Duration
initialBackoff time.Duration
maxBackoff time.Duration
maxAttempts int
leaseOwner string
leaseTTL time.Duration
now func() time.Time
// Publications are serialized so repeat writes in this process observe the
// same final file timestamp and therefore produce the same receipt.
publishMu sync.Mutex
mu sync.Mutex
running bool
cancel context.CancelFunc
done chan struct{}
// These hooks keep failure-path tests deterministic without weakening the
// production filesystem path.
replaceFile func(string, string) error
afterReplace func(string) error
}
// New constructs a run-archive service with default scanner settings.
func New(root string, discovery contract.ArchiveWorkDiscovery, loader contract.ArchiveWorkLoader, registrar contract.ArchiveRegistrar) *Service {
return NewService(Config{Root: root}, discovery, loader, registrar)
}
// NewService constructs a run-archive service. Use NewServiceWithReplay when
// replay receipt recovery is also required.
func NewService(cfg Config, discovery contract.ArchiveWorkDiscovery, loader contract.ArchiveWorkLoader, registrar contract.ArchiveRegistrar) *Service {
return NewServiceWithReplay(cfg, discovery, loader, registrar, ReplayRepositories{})
}
// NewServiceWithReplay constructs a lifecycle-enabled run and replay archive
// service using only shared contract interfaces.
func NewServiceWithReplay(
cfg Config,
discovery contract.ArchiveWorkDiscovery,
loader contract.ArchiveWorkLoader,
registrar contract.ArchiveRegistrar,
replay ReplayRepositories,
) *Service {
if cfg.Interval <= 0 {
cfg.Interval = defaultInterval
}
if cfg.InitialBackoff <= 0 {
cfg.InitialBackoff = defaultBackoff
}
if cfg.MaxBackoff <= 0 {
cfg.MaxBackoff = defaultMaxBackoff
}
if cfg.MaxBackoff < cfg.InitialBackoff {
cfg.MaxBackoff = cfg.InitialBackoff
}
if cfg.MaxAttempts <= 0 {
cfg.MaxAttempts = defaultMaxAttempts
}
if strings.TrimSpace(cfg.ReplayLeaseOwner) == "" {
cfg.ReplayLeaseOwner = defaultReplayLeaseName
}
if cfg.ReplayLeaseTTL <= 0 {
cfg.ReplayLeaseTTL = defaultReplayLeaseTTL
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &Service{
root: cfg.Root,
runDiscovery: discovery,
runLoader: loader,
runRegistrar: registrar,
replay: replay,
interval: cfg.Interval,
initialBackoff: cfg.InitialBackoff,
maxBackoff: cfg.MaxBackoff,
maxAttempts: cfg.MaxAttempts,
leaseOwner: cfg.ReplayLeaseOwner,
leaseTTL: cfg.ReplayLeaseTTL,
now: cfg.Now,
replaceFile: atomicReplace,
afterReplace: func(string) error { return nil },
}
}
// Start launches an immediate recovery scan followed by periodic scans. It is
// idempotent while the service is running.
func (s *Service) Start(parent context.Context) error {
if parent == nil {
return gerror.New("archive parent context is nil")
}
if err := s.validateConfiguration(); err != nil {
return err
}
s.mu.Lock()
if s.running {
s.mu.Unlock()
return nil
}
ctx, cancel := context.WithCancel(parent)
done := make(chan struct{})
s.cancel, s.done, s.running = cancel, done, true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
if s.done == done {
s.running = false
s.cancel = nil
}
s.mu.Unlock()
close(done)
}()
s.scanLoop(ctx)
}()
return nil
}
// Stop cancels the scanner and waits for it to finish. It is safe to call more
// than once.
func (s *Service) Stop() error {
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return nil
}
cancel, done := s.cancel, s.done
s.mu.Unlock()
cancel()
<-done
return nil
}
// Close implements the common service lifecycle convention.
func (s *Service) Close() error { return s.Stop() }
// Done is closed when the current scanner invocation exits. It returns nil
// before the service has been started.
func (s *Service) Done() <-chan struct{} {
s.mu.Lock()
defer s.mu.Unlock()
return s.done
}
// RunOnce performs one recovery pass. It is useful for explicit lifecycle
// wiring and deterministic integration tests.
func (s *Service) RunOnce(ctx context.Context) error {
if ctx == nil {
return gerror.New("archive scan context is nil")
}
if err := s.validateConfiguration(); err != nil {
return err
}
var scanErrors []error
if s.runDiscovery != nil {
if err := s.scanRuns(ctx); err != nil {
scanErrors = append(scanErrors, gerror.Wrap(err, "scan run archives"))
}
}
if s.replay.Discovery != nil && ctx.Err() == nil {
if err := s.scanActions(ctx); err != nil {
scanErrors = append(scanErrors, gerror.Wrap(err, "scan replay action archives"))
}
}
return errors.Join(scanErrors...)
}
// PublishRun atomically writes a run summary and then conditionally registers
// the durable receipt against the work version.
func (s *Service) PublishRun(ctx context.Context, work contract.ArchiveWork, snapshot contract.RunArchive) (contract.ArchiveReceipt, error) {
if err := validateWork(work); err != nil {
return contract.ArchiveReceipt{}, err
}
normalized, err := normalizeRunArchive(work, snapshot)
if err != nil {
return contract.ArchiveReceipt{}, err
}
pathTime := normalized.Run.CompletedAt
if pathTime.IsZero() {
pathTime = normalized.Run.CreatedAt
}
receipt, err := s.publishJSON(ctx, "runs", work.RunID, pathTime, normalized)
if err != nil {
return contract.ArchiveReceipt{}, err
}
if s.runRegistrar != nil {
if err := s.runRegistrar.RegisterArchive(ctx, work, receipt); err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "register run archive")
}
}
return receipt, nil
}
// PublishAction atomically writes a receipt_pending replay action summary. The
// repository update is performed by ArchiveAction because it requires a fenced
// replay lease.
func (s *Service) PublishAction(ctx context.Context, action contract.ReplayAction) (contract.ArchiveReceipt, error) {
normalized, err := normalizeAction(action)
if err != nil {
return contract.ArchiveReceipt{}, err
}
return s.publishJSON(ctx, "actions", normalized.ActionID, normalized.CreatedAt, normalized)
}
// ArchiveRun loads, publishes, and registers one durable run work item.
func (s *Service) ArchiveRun(ctx context.Context, work contract.ArchiveWork) error {
if s.runLoader == nil {
return gerror.New("archive run loader is nil")
}
if s.runRegistrar == nil {
return gerror.New("archive run registrar is nil")
}
snapshot, err := s.runLoader.LoadArchiveWork(ctx, work)
if err != nil {
s.recordRunFailure(ctx, work, err)
return gerror.Wrap(err, "load run archive work")
}
if _, err = s.PublishRun(ctx, work, snapshot); err != nil {
s.recordRunFailure(ctx, work, err)
return err
}
return nil
}
// ArchiveAction acquires a replay lease, atomically publishes the action
// summary, and fences the completed receipt update through ReplayProgressStore.
func (s *Service) ArchiveAction(ctx context.Context, action contract.ReplayAction) error {
if s.replay.Leases == nil || s.replay.Progress == nil {
return gerror.New("replay archive repositories are incomplete")
}
if action.Status != contract.ActionReceiptPending {
return gerror.Wrap(contract.ErrInvalidArgument, "replay action is not receipt_pending")
}
if _, err := normalizeAction(action); err != nil {
return err
}
lease, err := s.replay.Leases.AcquireReplayLease(ctx, action.ActionID, s.leaseOwner, s.leaseTTL)
if err != nil {
return gerror.Wrap(err, "acquire replay archive lease")
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), defaultReleaseTimeout)
defer cancel()
_ = s.replay.Leases.ReleaseReplayLease(releaseCtx, lease)
}()
receipt, err := s.PublishAction(ctx, action)
if err != nil {
return err
}
lease, err = s.replay.Leases.RenewReplayLease(ctx, lease, s.leaseTTL)
if err != nil {
return gerror.Wrap(err, "renew replay archive lease")
}
completedAt := receipt.ArchivedAt
_, err = s.replay.Progress.UpdateReplayAction(ctx, contract.ReplayActionUpdate{
Lease: lease,
ExpectedRevision: action.Revision,
Status: contract.ActionCompleted,
Progress: action.Progress,
Receipt: &receipt,
CompletedAt: &completedAt,
})
if err != nil {
return gerror.Wrap(err, "register replay action archive")
}
return nil
}
func (s *Service) scanLoop(ctx context.Context) {
_ = s.RunOnce(ctx)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = s.RunOnce(ctx)
}
}
}
func (s *Service) scanRuns(ctx context.Context) error {
page := contract.PageRequest{Limit: contract.DefaultPageLimit}
seenCursors := map[contract.Cursor]struct{}{page.Cursor: {}}
var itemErrors []error
for {
if err := ctx.Err(); err != nil {
return err
}
result, err := s.runDiscovery.ListArchiveWork(ctx, page)
if err != nil {
return err
}
for _, work := range result.Items {
err := s.retry(ctx, func() error { return s.ArchiveRun(ctx, work) })
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
itemErrors = append(itemErrors, gerror.Wrapf(err, "archive run %q", work.RunID))
}
}
if result.NextCursor == "" {
return errors.Join(itemErrors...)
}
if _, repeated := seenCursors[result.NextCursor]; repeated {
return errors.Join(append(itemErrors, gerror.New("run archive pagination cursor did not advance"))...)
}
seenCursors[result.NextCursor] = struct{}{}
page.Cursor = result.NextCursor
}
}
func (s *Service) scanActions(ctx context.Context) error {
page := contract.PageRequest{Limit: contract.DefaultPageLimit}
seenCursors := map[contract.Cursor]struct{}{page.Cursor: {}}
var itemErrors []error
for {
if err := ctx.Err(); err != nil {
return err
}
result, err := s.replay.Discovery.ListReplayWork(ctx, page)
if err != nil {
return err
}
for _, action := range result.Items {
if action.Status != contract.ActionReceiptPending {
continue
}
err := s.retry(ctx, func() error { return s.ArchiveAction(ctx, action) })
if errors.Is(err, contract.ErrBusy) {
continue
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
itemErrors = append(itemErrors, gerror.Wrapf(err, "archive replay action %q", action.ActionID))
}
}
if result.NextCursor == "" {
return errors.Join(itemErrors...)
}
if _, repeated := seenCursors[result.NextCursor]; repeated {
return errors.Join(append(itemErrors, gerror.New("replay archive pagination cursor did not advance"))...)
}
seenCursors[result.NextCursor] = struct{}{}
page.Cursor = result.NextCursor
}
}
func (s *Service) retry(ctx context.Context, fn func() error) error {
delay := s.initialBackoff
var lastErr error
for attempt := 0; attempt < s.maxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
lastErr = fn()
if lastErr == nil || errors.Is(lastErr, contract.ErrBusy) {
return lastErr
}
if attempt+1 == s.maxAttempts {
break
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ctx.Err()
case <-timer.C:
}
if delay <= s.maxBackoff/2 {
delay *= 2
} else {
delay = s.maxBackoff
}
}
return lastErr
}
func (s *Service) recordRunFailure(ctx context.Context, work contract.ArchiveWork, archiveErr error) {
if s.runRegistrar == nil || ctx.Err() != nil {
return
}
_ = s.runRegistrar.RegisterArchiveFailure(ctx, work, contract.BoundedErrorMessage(archiveErr.Error()))
}
func (s *Service) publishJSON(ctx context.Context, kind, id string, pathTime time.Time, value any) (contract.ArchiveReceipt, error) {
if ctx == nil {
return contract.ArchiveReceipt{}, gerror.New("archive publication context is nil")
}
if err := ctx.Err(); err != nil {
return contract.ArchiveReceipt{}, err
}
dir, final, relative, err := s.storagePath(kind, id, pathTime)
if err != nil {
return contract.ArchiveReceipt{}, err
}
s.publishMu.Lock()
defer s.publishMu.Unlock()
if err := os.MkdirAll(dir, 0o755); err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "create archive directory")
}
if err := ctx.Err(); err != nil {
return contract.ArchiveReceipt{}, err
}
temp, err := os.CreateTemp(dir, "."+id+"-*.tmp")
if err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "create archive temporary file")
}
tempName := temp.Name()
defer func() { _ = os.Remove(tempName) }()
hasher := sha256.New()
buffer := bufio.NewWriter(io.MultiWriter(contextWriter{ctx: ctx, writer: temp}, hasher))
encoder := json.NewEncoder(buffer)
if err = encoder.Encode(value); err == nil {
err = buffer.Flush()
}
if err == nil {
err = temp.Chmod(0o644)
}
if err == nil {
err = temp.Sync()
}
closeErr := temp.Close()
if err == nil {
err = closeErr
}
if err != nil {
_ = temp.Close()
return contract.ArchiveReceipt{}, gerror.Wrap(err, "encode archive temporary file")
}
expectedChecksum := hex.EncodeToString(hasher.Sum(nil))
if receipt, ok, existingErr := s.existingReceipt(final, relative, kind, id, expectedChecksum); existingErr != nil {
return contract.ArchiveReceipt{}, existingErr
} else if ok {
if err := syncDirectory(dir); err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "sync archive directory")
}
return receipt, nil
}
if err := ctx.Err(); err != nil {
return contract.ArchiveReceipt{}, err
}
if err := s.replaceFile(tempName, final); err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "publish archive file")
}
if err := syncDirectory(dir); err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "sync archive directory")
}
if err := s.afterReplace(final); err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "archive post-publish verification hook")
}
stored, err := os.ReadFile(final)
if err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "read published archive file")
}
storedSum := sha256.Sum256(stored)
if hex.EncodeToString(storedSum[:]) != expectedChecksum {
return contract.ArchiveReceipt{}, errChecksumMismatch
}
info, err := os.Stat(final)
if err != nil {
return contract.ArchiveReceipt{}, gerror.Wrap(err, "stat published archive file")
}
receipt := contract.ArchiveReceipt{
Version: contract.ArchiveSchemaVersion,
Path: relative,
Checksum: expectedChecksum,
ArchivedAt: archiveModTime(info, s.now),
}
if err := receipt.Validate(); err != nil {
return contract.ArchiveReceipt{}, err
}
return receipt, nil
}
func (s *Service) existingReceipt(final, relative, kind, id, expectedChecksum string) (contract.ArchiveReceipt, bool, error) {
info, err := os.Lstat(final)
if errors.Is(err, os.ErrNotExist) {
return contract.ArchiveReceipt{}, false, nil
}
if err != nil {
return contract.ArchiveReceipt{}, false, gerror.Wrap(err, "inspect existing archive file")
}
if !info.Mode().IsRegular() {
return contract.ArchiveReceipt{}, false, nil
}
stored, err := os.ReadFile(final)
if err != nil {
return contract.ArchiveReceipt{}, false, gerror.Wrap(err, "read existing archive file")
}
storedSum := sha256.Sum256(stored)
if hex.EncodeToString(storedSum[:]) != expectedChecksum {
if validExistingArchive(kind, id, stored) {
return contract.ArchiveReceipt{}, false, gerror.Wrap(contract.ErrConflict, "archive path already contains different valid content")
}
return contract.ArchiveReceipt{}, false, nil
}
receipt := contract.ArchiveReceipt{
Version: contract.ArchiveSchemaVersion,
Path: relative,
Checksum: expectedChecksum,
ArchivedAt: archiveModTime(info, s.now),
}
if err := receipt.Validate(); err != nil {
return contract.ArchiveReceipt{}, false, err
}
return receipt, true, nil
}
func validExistingArchive(kind, id string, data []byte) bool {
switch kind {
case "runs":
var snapshot contract.RunArchive
if json.Unmarshal(data, &snapshot) != nil {
return false
}
_, err := normalizeRunArchive(contract.ArchiveWork{RunID: id, Version: "existing"}, snapshot)
return err == nil
case "actions":
var action contract.ReplayAction
if json.Unmarshal(data, &action) != nil || action.ActionID != id {
return false
}
_, err := normalizeAction(action)
return err == nil
default:
return false
}
}
func archiveModTime(info os.FileInfo, now func() time.Time) time.Time {
archivedAt := info.ModTime().UTC()
if archivedAt.IsZero() {
archivedAt = now().UTC()
}
return archivedAt
}
func (s *Service) storagePath(kind, id string, pathTime time.Time) (string, string, string, error) {
if err := validateStorageID(id); err != nil {
return "", "", "", err
}
if kind != "runs" && kind != "actions" {
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "unknown archive kind")
}
if strings.TrimSpace(s.root) == "" {
return "", "", "", gerror.New("archive root is empty")
}
if pathTime.IsZero() {
pathTime = s.now().UTC()
} else {
pathTime = pathTime.UTC()
}
if pathTime.Year() < 1 || pathTime.Year() > 9999 {
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "archive timestamp is outside the supported year range")
}
root := filepath.Clean(s.root)
prefix := "run-"
if kind == "actions" {
prefix = "action-"
}
relativeOS := prefix + id + ".json"
final := filepath.Join(root, relativeOS)
contained, err := filepath.Rel(root, final)
if err != nil || filepath.IsAbs(contained) || contained == ".." || strings.HasPrefix(contained, ".."+string(filepath.Separator)) {
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "archive path escapes root")
}
return filepath.Dir(final), final, filepath.ToSlash(relativeOS), nil
}
func (s *Service) validateConfiguration() error {
if strings.TrimSpace(s.root) == "" {
return gerror.New("archive root is empty")
}
if s.runDiscovery != nil && (s.runLoader == nil || s.runRegistrar == nil) {
return gerror.New("run archive repositories are incomplete")
}
if s.replay.Discovery != nil && (s.replay.Leases == nil || s.replay.Progress == nil) {
return gerror.New("replay archive repositories are incomplete")
}
return nil
}
func validateWork(work contract.ArchiveWork) error {
if err := validateStorageID(work.RunID); err != nil {
return err
}
if strings.TrimSpace(work.Version) == "" {
return gerror.Wrap(contract.ErrInvalidArgument, "archive work version is required")
}
return nil
}
func validateStorageID(id string) error {
if err := contract.ValidateID(id); err != nil {
return err
}
if id == "." || id == ".." || filepath.VolumeName(id) != "" || strings.ContainsRune(id, ':') || filepath.Base(id) != id {
return gerror.Wrap(contract.ErrInvalidArgument, "resource identifier is not safe for archive storage")
}
name := strings.ToUpper(strings.TrimSuffix(id, filepath.Ext(id)))
switch name {
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return gerror.Wrap(contract.ErrInvalidArgument, "resource identifier is reserved by the filesystem")
}
return nil
}
func normalizeRunArchive(work contract.ArchiveWork, snapshot contract.RunArchive) (contract.RunArchive, error) {
if snapshot.Version == 0 {
snapshot.Version = contract.ArchiveSchemaVersion
}
if snapshot.Version != contract.ArchiveSchemaVersion {
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "invalid archive schema version")
}
if snapshot.Run.RunID != work.RunID {
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "archive run ID does not match work")
}
if !snapshot.Run.Status.Terminal() {
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "only terminal runs can be archived")
}
if err := snapshot.Run.Validate(); err != nil {
return contract.RunArchive{}, err
}
if len(snapshot.Rounds) > maxRounds {
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "too many archived rounds")
}
snapshot.Run = normalizeRunMeta(snapshot.Run)
snapshot.Rounds = append([]contract.RoundStats(nil), snapshot.Rounds...)
if snapshot.Rounds == nil {
snapshot.Rounds = make([]contract.RoundStats, 0)
}
seen := make(map[int]struct{}, len(snapshot.Rounds))
for i := range snapshot.Rounds {
round := &snapshot.Rounds[i]
if round.RunID != work.RunID {
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "archived round belongs to another run")
}
if _, duplicate := seen[round.Round]; duplicate {
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "duplicate archived round")
}
seen[round.Round] = struct{}{}
if err := round.Validate(); err != nil {
return contract.RunArchive{}, err
}
normalizedCounts, err := round.ErrorCounts.Normalized()
if err != nil {
return contract.RunArchive{}, err
}
round.ErrorCounts = normalizedCounts
round.StartedAt = utcTimePtr(round.StartedAt)
round.CompletedAt = utcTimePtr(round.CompletedAt)
}
sort.Slice(snapshot.Rounds, func(i, j int) bool { return snapshot.Rounds[i].Round < snapshot.Rounds[j].Round })
return snapshot, nil
}
func normalizeRunMeta(meta contract.RunMeta) contract.RunMeta {
meta.CreatedAt = meta.CreatedAt.UTC()
meta.ProducerCompletedAt = meta.ProducerCompletedAt.UTC()
meta.CompletedAt = meta.CompletedAt.UTC()
meta.StartedAt = utcTimePtr(meta.StartedAt)
meta.ScanCompletedAt = utcTimePtr(meta.ScanCompletedAt)
// Archive state describes the repository registration and is intentionally
// excluded from the file whose receipt populates that state.
meta.ArchiveStatus = ""
meta.ArchiveVersion = 0
meta.ArchivePath = ""
meta.ArchiveChecksum = ""
meta.ArchivedAt = nil
return meta
}
func normalizeAction(action contract.ReplayAction) (contract.ReplayAction, error) {
if err := validateStorageID(action.ActionID); err != nil {
return contract.ReplayAction{}, err
}
if action.Status != contract.ActionReceiptPending {
return contract.ReplayAction{}, gerror.Wrap(contract.ErrInvalidArgument, "only receipt_pending replay actions can be archived")
}
action.Error = contract.BoundedErrorMessage(action.Error)
action.CreatedAt = action.CreatedAt.UTC()
action.UpdatedAt = action.UpdatedAt.UTC()
action.CompletedAt = nil
action.Receipt = nil
if err := action.Validate(); err != nil {
return contract.ReplayAction{}, err
}
return action, nil
}
func utcTimePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
utc := value.UTC()
return &utc
}
type contextWriter struct {
ctx context.Context
writer io.Writer
}
func (w contextWriter) Write(p []byte) (int, error) {
if err := w.ctx.Err(); err != nil {
return 0, err
}
return w.writer.Write(p)
}
@@ -0,0 +1,588 @@
package archive
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
)
func TestPublishRunIsDurableDeterministicAndSummaryOnly(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-1"))
service := New(root, nil, nil, repository)
work := contract.ArchiveWork{RunID: "run-1", Version: "version-1"}
staleDir := root
if err := os.MkdirAll(staleDir, 0o755); err != nil {
t.Fatal(err)
}
stalePath := filepath.Join(staleDir, ".run-1-stale.tmp")
if err := os.WriteFile(stalePath, []byte("incomplete"), 0o600); err != nil {
t.Fatal(err)
}
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
if err != nil {
t.Fatalf("PublishRun() error = %v", err)
}
if receipt.Path != "run-run-1.json" {
t.Fatalf("receipt path = %q", receipt.Path)
}
if err := receipt.Validate(); err != nil {
t.Fatalf("receipt validation failed: %v", err)
}
archivePath := filepath.Join(root, filepath.FromSlash(receipt.Path))
info, err := os.Stat(archivePath)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
if got := info.Mode().Perm(); got != 0o644 {
t.Fatalf("archive mode = %o, want 644", got)
}
}
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(data)
if got := hex.EncodeToString(sum[:]); got != receipt.Checksum {
t.Fatalf("checksum = %q, want %q", receipt.Checksum, got)
}
for _, forbidden := range [][]byte{[]byte(`"archiveStatus"`), []byte(`"archivePath"`), []byte(`"taskId"`), []byte(`"card"`), []byte(`"message"`)} {
if bytes.Contains(data, forbidden) {
t.Fatalf("archive contains forbidden detail %s: %s", forbidden, data)
}
}
var decoded contract.RunArchive
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.Rounds) != 2 || decoded.Rounds[0].Round != 1 || decoded.Rounds[1].Round != 2 {
t.Fatalf("rounds were not deterministically sorted: %+v", decoded.Rounds)
}
if decoded.Rounds[1].ErrorCounts[contract.ErrorRateLimited] != 1 {
t.Fatalf("error categories were not normalized: %+v", decoded.Rounds[1].ErrorCounts)
}
repeated, err := service.PublishRun(context.Background(), work, repository.snapshot)
if err != nil {
t.Fatalf("repeated PublishRun() error = %v", err)
}
if !reflect.DeepEqual(repeated, receipt) {
t.Fatalf("repeated receipt changed:\nfirst: %+v\nsecond: %+v", receipt, repeated)
}
if _, err := os.Stat(stalePath); err != nil {
t.Fatalf("stale temp residue should not block publication: %v", err)
}
repository.mu.Lock()
defer repository.mu.Unlock()
if len(repository.receipts) != 2 || !reflect.DeepEqual(repository.receipts[0], repository.receipts[1]) {
t.Fatalf("registered receipts = %+v", repository.receipts)
}
}
func TestArchiveActionPublishesAndRegistersFencedReceipt(t *testing.T) {
root := t.TempDir()
action := testReplayAction("action-1")
action.Error = strings.Repeat("x", contract.MaxErrorMessageBytes+50)
repository := newFakeReplayRepository(action)
service := NewServiceWithReplay(
Config{Root: root},
nil,
nil,
nil,
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
)
if err := service.ArchiveAction(context.Background(), action); err != nil {
t.Fatalf("ArchiveAction() error = %v", err)
}
repository.mu.Lock()
update := repository.update
released := repository.released
repository.mu.Unlock()
if update == nil || update.Status != contract.ActionCompleted || update.Receipt == nil || update.CompletedAt == nil {
t.Fatalf("registered update = %+v", update)
}
if update.ExpectedRevision != action.Revision || update.Receipt.ArchivedAt != *update.CompletedAt {
t.Fatalf("receipt registration lost fencing/time: %+v", update)
}
if update.Lease.Token != "lease-token-2" {
t.Fatalf("receipt registration used the unrenewed lease: %+v", update.Lease)
}
if released.Token != "lease-token-2" {
t.Fatalf("archive released the stale lease: %+v", released)
}
if update.Receipt.Path != "action-action-1.json" {
t.Fatalf("action receipt path = %q", update.Receipt.Path)
}
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(update.Receipt.Path)))
if err != nil {
t.Fatal(err)
}
for _, forbidden := range [][]byte{[]byte("idempotencyKey"), []byte("fingerprint"), []byte("leaseOwner"), []byte("leaseToken"), []byte("revision"), []byte(`"receipt"`), []byte("completedAt")} {
if bytes.Contains(data, forbidden) {
t.Fatalf("action archive contains internal field %q: %s", forbidden, data)
}
}
var decoded contract.ReplayAction
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Status != contract.ActionReceiptPending || len(decoded.Error) != contract.MaxErrorMessageBytes {
t.Fatalf("archived action was not bounded receipt_pending summary: %+v", decoded)
}
repeated, err := service.PublishAction(context.Background(), action)
if err != nil {
t.Fatalf("repeated PublishAction() error = %v", err)
}
if !reflect.DeepEqual(repeated, *update.Receipt) {
t.Fatalf("repeated action receipt changed: %+v != %+v", repeated, *update.Receipt)
}
}
func TestPublishRunCleansTempAfterRenameFailure(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-rename"))
service := New(root, nil, nil, repository)
renameErr := gerror.New("injected rename failure")
service.replaceFile = func(string, string) error { return renameErr }
_, err := service.PublishRun(context.Background(), contract.ArchiveWork{RunID: "run-rename", Version: "v1"}, repository.snapshot)
if !errors.Is(err, renameErr) {
t.Fatalf("PublishRun() error = %v, want rename failure", err)
}
if matches, globErr := filepath.Glob(filepath.Join(root, ".run-rename-*.tmp")); globErr != nil || len(matches) != 0 {
t.Fatalf("temporary files after failure = %v, error = %v", matches, globErr)
}
if _, statErr := os.Stat(filepath.Join(root, "run-run-rename.json")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("final file exists after failed rename: %v", statErr)
}
if len(repository.receipts) != 0 {
t.Fatalf("registered receipt after failed rename: %+v", repository.receipts)
}
}
func TestChecksumFailureIsRecoveredByExactPathReplacement(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-checksum"))
service := New(root, nil, nil, repository)
service.afterReplace = func(path string) error {
return os.WriteFile(path, []byte("corrupt"), 0o600)
}
work := contract.ArchiveWork{RunID: "run-checksum", Version: "v1"}
if _, err := service.PublishRun(context.Background(), work, repository.snapshot); !errors.Is(err, errChecksumMismatch) {
t.Fatalf("PublishRun() error = %v, want checksum mismatch", err)
}
if len(repository.receipts) != 0 {
t.Fatalf("registered receipt after checksum failure: %+v", repository.receipts)
}
service.afterReplace = func(string) error { return nil }
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
if err != nil {
t.Fatalf("recovery PublishRun() error = %v", err)
}
if receipt.Path != "run-run-checksum.json" {
t.Fatalf("replacement used different path: %q", receipt.Path)
}
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(receipt.Path)))
if err != nil {
t.Fatal(err)
}
if string(data) == "corrupt" {
t.Fatal("corrupt final file was not replaced")
}
}
func TestRegistrationRetryReusesIdenticalReceipt(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-register"))
repository.registerErr = gerror.New("registration result was uncertain")
service := New(root, nil, nil, repository)
work := contract.ArchiveWork{RunID: "run-register", Version: "v1"}
if _, err := service.PublishRun(context.Background(), work, repository.snapshot); !errors.Is(err, repository.registerErr) {
t.Fatalf("first PublishRun() error = %v", err)
}
repository.mu.Lock()
firstReceipt := repository.receipts[0]
repository.registerErr = nil
repository.mu.Unlock()
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
if err != nil {
t.Fatalf("retry PublishRun() error = %v", err)
}
if !reflect.DeepEqual(receipt, firstReceipt) {
t.Fatalf("retry changed uncertain receipt:\nfirst: %+v\nretry: %+v", firstReceipt, receipt)
}
}
func TestPublishRunRejectsDifferentValidContentAtSamePath(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-conflict"))
service := New(root, nil, nil, repository)
work := contract.ArchiveWork{RunID: "run-conflict", Version: "v1"}
first, err := service.PublishRun(context.Background(), work, repository.snapshot)
if err != nil {
t.Fatal(err)
}
changed := repository.snapshot
changed.Run.TriggerSource = "different-valid-summary"
if _, err = service.PublishRun(context.Background(), work, changed); !errors.Is(err, contract.ErrConflict) {
t.Fatalf("PublishRun() error = %v, want conflict", err)
}
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(first.Path)))
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(data)
if got := hex.EncodeToString(sum[:]); got != first.Checksum {
t.Fatalf("conflicting publication replaced the original file: %q != %q", got, first.Checksum)
}
}
func TestScannerRecoversFailedRunAfterRestart(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-restart"))
repository.load = func(context.Context, contract.ArchiveWork) (contract.RunArchive, error) {
return contract.RunArchive{}, gerror.New("source temporarily unavailable")
}
first := NewService(Config{Root: root, Interval: time.Hour, MaxAttempts: 1}, repository, repository, repository)
if err := first.Start(context.Background()); err != nil {
t.Fatal(err)
}
waitSignal(t, repository.failed, "initial archive failure")
if err := first.Stop(); err != nil {
t.Fatal(err)
}
repository.mu.Lock()
repository.load = nil
repository.mu.Unlock()
second := NewService(Config{Root: root, Interval: time.Hour, MaxAttempts: 1}, repository, repository, repository)
if err := second.Start(context.Background()); err != nil {
t.Fatal(err)
}
waitSignal(t, repository.succeeded, "restart archive success")
if err := second.Stop(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "run-run-restart.json")); err != nil {
t.Fatalf("recovered archive file missing: %v", err)
}
}
func TestPeriodicScannerRetriesPendingRun(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-periodic"))
var calls atomic.Int32
repository.load = func(context.Context, contract.ArchiveWork) (contract.RunArchive, error) {
if calls.Add(1) == 1 {
return contract.RunArchive{}, gerror.New("first scan fails")
}
return repository.snapshot, nil
}
service := NewService(Config{Root: root, Interval: 10 * time.Millisecond, MaxAttempts: 1}, repository, repository, repository)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
waitSignal(t, repository.succeeded, "periodic archive success")
if err := service.Stop(); err != nil {
t.Fatal(err)
}
if calls.Load() < 2 {
t.Fatalf("loader calls = %d, want at least 2", calls.Load())
}
}
func TestStartupScannerArchivesReceiptPendingAction(t *testing.T) {
action := testReplayAction("action-startup")
repository := newFakeReplayRepository(action)
service := NewServiceWithReplay(
Config{Root: t.TempDir(), Interval: time.Hour, MaxAttempts: 1},
nil,
nil,
nil,
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
waitSignal(t, repository.completed, "startup replay archive success")
if err := service.Stop(); err != nil {
t.Fatal(err)
}
}
func TestCancellationStopsBlockedScannerAndPublication(t *testing.T) {
root := t.TempDir()
repository := newFakeRunRepository(testRunArchive("run-cancel"))
started := make(chan struct{})
repository.load = func(ctx context.Context, _ contract.ArchiveWork) (contract.RunArchive, error) {
close(started)
<-ctx.Done()
return contract.RunArchive{}, ctx.Err()
}
service := NewService(Config{Root: root, Interval: time.Hour, InitialBackoff: time.Hour}, repository, repository, repository)
if err := service.Start(context.Background()); err != nil {
t.Fatal(err)
}
waitSignal(t, started, "blocked loader start")
stopped := make(chan struct{})
go func() {
_ = service.Stop()
close(stopped)
}()
waitSignal(t, stopped, "scanner cancellation")
canceled, cancel := context.WithCancel(context.Background())
cancel()
_, err := service.PublishRun(canceled, contract.ArchiveWork{RunID: "run-cancel", Version: "v1"}, repository.snapshot)
if !errors.Is(err, context.Canceled) {
t.Fatalf("canceled PublishRun() error = %v", err)
}
if _, err := os.Stat(filepath.Join(root, "run-run-cancel.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("canceled publication wrote final file: %v", err)
}
}
func TestCancellationAfterRenameStillVerifiesPublicationAndReleasesLease(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
action := testReplayAction("action-cancel-after-rename")
repository := newFakeReplayRepository(action)
service := NewServiceWithReplay(
Config{Root: t.TempDir()},
nil,
nil,
nil,
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
)
service.afterReplace = func(string) error {
cancel()
return nil
}
if err := service.ArchiveAction(ctx, action); err != nil {
t.Fatalf("ArchiveAction() error = %v", err)
}
repository.mu.Lock()
releaseCtxErr := repository.releaseCtxErr
repository.mu.Unlock()
if releaseCtxErr != nil {
t.Fatalf("lease release inherited canceled context: %v", releaseCtxErr)
}
}
func TestArchiveRejectsTraversalAndUnsafeFilesystemIDs(t *testing.T) {
root := t.TempDir()
service := New(root, nil, nil, newFakeRunRepository(contract.RunArchive{}))
for _, id := range []string{"../escape", `..\escape`, "/absolute", `C:\escape`, "drive:name", ".", "..", "{hash}", "CON"} {
t.Run(strings.ReplaceAll(id, "/", "_"), func(t *testing.T) {
snapshot := testRunArchive(id)
_, err := service.PublishRun(context.Background(), contract.ArchiveWork{RunID: id, Version: "v1"}, snapshot)
if !errors.Is(err, contract.ErrInvalidArgument) {
t.Fatalf("PublishRun(%q) error = %v, want invalid argument", id, err)
}
})
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("unsafe IDs created archive entries: %+v", entries)
}
}
func testRunArchive(runID string) contract.RunArchive {
created := time.Date(2026, time.August, 31, 23, 0, 0, 0, time.FixedZone("test", 8*60*60))
completed := time.Date(2026, time.September, 20, 16, 0, 0, 0, time.FixedZone("test", 8*60*60))
archived := completed.Add(time.Minute)
return contract.RunArchive{
Version: contract.ArchiveSchemaVersion,
Run: contract.RunMeta{
RunID: runID, Status: contract.RunCompletedWithFailures, ProducerDone: true,
CurrentRound: 2, MaxRound: 2, EnqueuedCount: 3, SuccessCount: 2, FailedCount: 1, PendingFailureCount: 1,
CreatedAt: created, ProducerCompletedAt: completed, CompletedAt: completed,
ArchiveStatus: contract.ArchiveFailed, ArchiveVersion: 99, ArchivePath: "old.json",
ArchiveChecksum: strings.Repeat("a", 64), ArchivedAt: &archived,
},
Rounds: []contract.RoundStats{
{RunID: runID, Round: 2, EnqueuedCount: 1, FailedCount: 1, ErrorCounts: contract.ErrorCounts{"mobile_busy": 1}},
{RunID: runID, Round: 1, EnqueuedCount: 2, SuccessCount: 2, ErrorCounts: nil},
},
}
}
func testReplayAction(actionID string) contract.ReplayAction {
created := time.Date(2026, time.September, 20, 12, 0, 0, 0, time.FixedZone("test", 8*60*60))
return contract.ReplayAction{
ActionID: actionID, SourceRunID: "source-1", TargetRunID: "target-1", RequestedBy: "operator",
IdempotencyKey: "secret-key", Fingerprint: "secret-fingerprint", Status: contract.ActionReceiptPending,
ReplayCycle: 1, Progress: contract.ReplayProgress{DiscoveredCount: 2, EnqueuedCount: 2, CleanedCount: 2},
LeaseOwner: "engine", LeaseToken: "secret-token", LeaseUntil: created.Add(time.Hour), Revision: 7,
CreatedAt: created, UpdatedAt: created.Add(time.Minute),
}
}
type fakeRunRepository struct {
mu sync.Mutex
work contract.ArchiveWork
snapshot contract.RunArchive
load func(context.Context, contract.ArchiveWork) (contract.RunArchive, error)
registered bool
receipts []contract.ArchiveReceipt
failures []string
registerErr error
succeeded chan struct{}
failed chan struct{}
}
func newFakeRunRepository(snapshot contract.RunArchive) *fakeRunRepository {
return &fakeRunRepository{
work: contract.ArchiveWork{RunID: snapshot.Run.RunID, Version: "repository-version"},
snapshot: snapshot,
succeeded: make(chan struct{}, 8),
failed: make(chan struct{}, 8),
}
}
func (r *fakeRunRepository) ListArchiveWork(ctx context.Context, _ contract.PageRequest) (contract.ArchiveWorkPage, error) {
if err := ctx.Err(); err != nil {
return contract.ArchiveWorkPage{}, err
}
r.mu.Lock()
defer r.mu.Unlock()
if r.registered {
return contract.ArchiveWorkPage{Items: []contract.ArchiveWork{}}, nil
}
return contract.ArchiveWorkPage{Items: []contract.ArchiveWork{r.work}}, nil
}
func (r *fakeRunRepository) LoadArchiveWork(ctx context.Context, work contract.ArchiveWork) (contract.RunArchive, error) {
r.mu.Lock()
load := r.load
snapshot := r.snapshot
r.mu.Unlock()
if load != nil {
return load(ctx, work)
}
return snapshot, nil
}
func (r *fakeRunRepository) RegisterArchive(_ context.Context, _ contract.ArchiveWork, receipt contract.ArchiveReceipt) error {
r.mu.Lock()
r.receipts = append(r.receipts, receipt)
err := r.registerErr
if err == nil {
r.registered = true
}
r.mu.Unlock()
if err != nil {
return err
}
notify(r.succeeded)
return nil
}
func (r *fakeRunRepository) RegisterArchiveFailure(_ context.Context, _ contract.ArchiveWork, message string) error {
r.mu.Lock()
r.failures = append(r.failures, message)
r.mu.Unlock()
notify(r.failed)
return nil
}
type fakeReplayRepository struct {
mu sync.Mutex
action contract.ReplayAction
update *contract.ReplayActionUpdate
released contract.ReplayLease
releaseCtxErr error
completed chan struct{}
}
func newFakeReplayRepository(action contract.ReplayAction) *fakeReplayRepository {
return &fakeReplayRepository{action: action, completed: make(chan struct{}, 4)}
}
func (r *fakeReplayRepository) ListReplayWork(ctx context.Context, _ contract.PageRequest) (contract.ReplayActionPage, error) {
if err := ctx.Err(); err != nil {
return contract.ReplayActionPage{}, err
}
r.mu.Lock()
defer r.mu.Unlock()
if r.update != nil {
return contract.ReplayActionPage{Items: []contract.ReplayAction{}}, nil
}
return contract.ReplayActionPage{Items: []contract.ReplayAction{r.action}}, nil
}
func (r *fakeReplayRepository) AcquireReplayLease(_ context.Context, actionID, owner string, ttl time.Duration) (contract.ReplayLease, error) {
return contract.ReplayLease{ActionID: actionID, Owner: owner, Token: "lease-token", Until: time.Now().Add(ttl)}, nil
}
func (r *fakeReplayRepository) RenewReplayLease(_ context.Context, lease contract.ReplayLease, ttl time.Duration) (contract.ReplayLease, error) {
lease.Token = "lease-token-2"
lease.Until = time.Now().Add(ttl)
return lease, nil
}
func (r *fakeReplayRepository) ReleaseReplayLease(ctx context.Context, lease contract.ReplayLease) error {
r.mu.Lock()
r.released = lease
r.releaseCtxErr = ctx.Err()
r.mu.Unlock()
return nil
}
func (r *fakeReplayRepository) UpdateReplayAction(_ context.Context, update contract.ReplayActionUpdate) (contract.ReplayAction, error) {
r.mu.Lock()
copyOfUpdate := update
r.update = &copyOfUpdate
action := r.action
action.Status = update.Status
action.Receipt = update.Receipt
action.CompletedAt = update.CompletedAt
r.mu.Unlock()
notify(r.completed)
return action, nil
}
func notify(ch chan struct{}) {
select {
case ch <- struct{}{}:
default:
}
}
func waitSignal(t *testing.T, ch <-chan struct{}, operation string) {
t.Helper()
select {
case <-ch:
case <-time.After(3 * time.Second):
t.Fatalf("timed out waiting for %s", operation)
}
}
@@ -0,0 +1,21 @@
//go:build !windows
package archive
import "os"
func atomicReplace(from, to string) error {
return os.Rename(from, to)
}
func syncDirectory(path string) error {
dir, err := os.Open(path)
if err != nil {
return err
}
if err := dir.Sync(); err != nil {
_ = dir.Close()
return err
}
return dir.Close()
}
@@ -0,0 +1,21 @@
//go:build windows
package archive
import "golang.org/x/sys/windows"
func atomicReplace(from, to string) error {
fromUTF16, err := windows.UTF16PtrFromString(from)
if err != nil {
return err
}
toUTF16, err := windows.UTF16PtrFromString(to)
if err != nil {
return err
}
return windows.MoveFileEx(fromUTF16, toUTF16, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
}
// Windows has no portable directory fsync operation. MoveFileEx with
// MOVEFILE_WRITE_THROUGH provides the strongest supported replacement flush.
func syncDirectory(string) error { return nil }
@@ -0,0 +1,124 @@
# Full-sync shared contracts
This package establishes the boundary for later queue accounting, archive,
replay, and HTTP work. It does not change running service behavior. Production
code in this package does not import the queue package; compatibility tests
compare its models and keys with the existing queue implementation.
## Compatibility and counters
- `RunMeta` retains all existing JSON fields and adds optional fields. Existing
`zset.RunMeta`, `zset.Task`, and `zset.FailureRecord` remain unchanged; adapters
are a later integration step. The new `RunStatus` is a named string type.
- `Validate` accepts missing extension fields on old records.
`ValidateAccounting` is opt-in for runs with known extended counters and
enforces `enqueuedCount = successCount + failedCount + activeCount + nextRoundCount`.
`inflightCount` is a subset of `activeCount`.
- Source cleanup changes `pendingFailureCount`, never historical `failedCount`.
- Round statistics have no task/card/channel dimensions. `ErrorCounts`
normalizes both JSON input and output to eight canonical categories, merging
unknown names into `other` and rejecting negative/overflowing totals.
- Existing failure payloads retain their free-form classification and message.
The HTTP `Failure` projection normalizes categories and bounds messages to
2,048 UTF-8 bytes. Historical blocked tasks may have round zero.
## Status domains
| Domain | Wire values |
| --- | --- |
| Run | `producing`, `running`, `completed`, `completed_with_failures` |
| Sync type | `normal`, `replay` |
| Archive | `disabled`, `pending`, `processing`, `succeeded`, `failed` |
| Source replay disposition | `none`, `pending`, `processing`, `completed`, `needs_attention` |
| Replay action | `accepted`, `processing`, `cleanup_pending`, `receipt_pending`, `completed`, `needs_attention` |
| Error category | `timeout`, `rate_limited`, `authentication`, `validation`, `upstream`, `transport`, `internal`, `other` |
The source field is `replayDisposition`, distinct from the replay action's
`status`. Lease tokens, record versions, action revision, idempotency key, and
request fingerprint are internal and excluded from HTTP JSON.
## Interfaces and persistence obligations
HTTP consumers depend on `RunReader`, `FailurePager`, `ReplayRequester`, and
`ActionReader`. Separate live/archive readers support fallback only after a live
`ErrNotFound`; storage failures must remain visible. An archived summary cannot
provide per-card details: use `ErrFailureDetailsUnavailable` when live details
are gone. Repositories and HTTP both apply the default page limit 100 and
maximum 500. Cursors remain opaque and are bound to the original query. An empty
scan page with a next cursor does not signal completion.
Archive discovery, scheduling, snapshot loading, and result registration use
separate narrow interfaces. Registration is conditional on an opaque work
version. Archives contain run and round aggregates only. Archive receipts use
a schema version, storage-owned path, lowercase SHA-256 checksum of stored
bytes, and publication time. Archive schema version starts at 1.
Replay acceptance atomically reserves `(sourceRunId, idempotencyKey)` with a
SHA-256 fingerprint of the source and exact requestedBy body value. Repeating
the request returns its original action/target; changing the request returns
`ErrConflict`. The action fixes its target ID before target creation/enqueue.
Replay work discovery, lease management, progress updates, source pagination,
target creation/enqueue, and source cleanup are separate interfaces. Progress
updates are fenced and use a revision check. Target enqueue distinguishes
durably present duplicates from rejected writes; a zero insert count by itself
does not authorize source cleanup. Cleanup checks exact source record versions
and target acceptance evidence. Implementations must persist enough evidence
to recover partial batches; a scan cursor by itself is insufficient. Source
pagination must remain correct while already-enqueued entries are deleted.
`ErrNotFound`, `ErrConflict`, `ErrInvalidArgument`,
`ErrFailureDetailsUnavailable`, `ErrLeaseLost`, and `ErrBusy` support
`errors.Is` through wrapping. HTTP error envelopes and status mappings remain
the HTTP implementation's responsibility.
## Redis naming
All names are prefixed with the configured namespace (`flow:` by default;
`sim-status:` for status sync). Existing run/round/task-state/failure names are
preserved. New keys include:
| Suffix after namespace | Scope |
| --- | --- |
| `{runId}:round:N:stats` | Run slot |
| `{runId}:archive` | Run slot |
| `runs:all` | Global index |
| `archive:pending` | Global index |
| `{replay}:action:actionId:meta` | Replay slot |
| `{replay}:action:actionId:lease` | Replay slot |
| `{replay}:idempotency:SHA256(sourceRunId,key)` | Replay slot; tuple encoded as a JSON array before hashing |
| `{replay}:pending` | Replay slot |
Replay action creation, idempotency, and discovery can be atomic within their
shared slot. Source/target runs and global indexes can occupy different slots;
their coordination requires recoverable registrations/receipts rather than a
single cross-slot Lua transaction. New identifier validation rejects hash-tag
or path delimiters without rewriting legacy JSON on decode.
## HTTP surface
| Method | Route | Response |
| --- | --- | --- |
| GET | `/internal/v1/full-sync-runs?syncKind=&limit=` | `RunList` merged from active Redis Runs and `run-<runId>.json` archives |
| GET | `/internal/v1/full-sync-runs/{runId}` | `RunView` with `source`, `run`, optional `rounds` |
| GET | `/internal/v1/full-sync-runs/{runId}/failures?cursor=&limit=` | `FailurePage` with `items`, `nextCursor` |
| POST | `/internal/v1/full-sync-runs/{runId}/replay` | HTTP 202 `ReplayAccepted` |
| GET | `/internal/v1/full-sync-actions/{actionId}` | `ReplayAction` |
POST requires `Idempotency-Key` and JSON `{"requestedBy":"..."}`. Authentication,
HTTP handlers, storage, replay execution, archive files, and service wiring are
intentionally left to subsequent implementations.
## Verification
```text
gofmt -l internal/fullsync/contract
go test ./internal/fullsync/contract
go test ./internal/queue/zset ./internal/consumer/zset ./internal/producer/simstatus ./internal/producer/monthlyusage ./internal/service/simstatus ./internal/service/monthlyusage
```
Tests cover legacy schema and JSON compatibility, existing key compatibility,
cluster key boundaries, bounded categories/messages, overflow, run accounting,
pagination limits, status domains, replay identity, internal field exclusion,
and receipt/progress validation. Regression tests use the repository's isolated
Redis helper and require `redis-server` or `REDIS_SERVER_BINARY`.
@@ -0,0 +1,46 @@
package contract
import "time"
const ArchiveSchemaVersion = 1
// ArchiveReceipt identifies a durably published archive object. Path is a
// storage-owned reference, never a client-supplied destination. Checksum is
// lowercase hex SHA-256 of the final stored bytes; version is the schema.
type ArchiveReceipt struct {
Version int `json:"version"`
Path string `json:"path"`
Checksum string `json:"checksum"`
ArchivedAt time.Time `json:"archivedAt"`
}
func (r ArchiveReceipt) Validate() error {
if r.Version < 1 || r.Path == "" || r.ArchivedAt.IsZero() || len(r.Checksum) != 64 {
return invalid("incomplete archive receipt")
}
for _, c := range r.Checksum {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return invalid("archive checksum must be lowercase SHA-256 hex")
}
}
return nil
}
// RunArchive is summary-only: never embed per-card task/failure records.
type RunArchive struct {
Version int `json:"version"`
Run RunMeta `json:"run"`
Rounds []RoundStats `json:"rounds"`
}
// ArchiveWork.Version is an opaque concurrency token for registration; it is
// distinct from the archive schema version. Work discovery must survive restarts.
type ArchiveWork struct {
RunID string `json:"runId"`
Version string `json:"-"`
}
type ArchiveWorkPage struct {
Items []ArchiveWork `json:"items"`
NextCursor Cursor `json:"nextCursor"`
}
@@ -0,0 +1,107 @@
package contract_test
import (
"encoding/json"
"reflect"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
)
// Check every old field, including zero/omitempty behavior, so future adapters
// can adopt the contract without silently changing the persisted wire format.
func TestLegacyFieldSchemas(t *testing.T) {
for _, pair := range []struct{ old, next any }{
{zset.RunMeta{}, contract.RunMeta{}},
{zset.FailureRecord{}, contract.FailureRecord{}},
{zset.Task{}, contract.Task{}},
} {
old, next := reflect.TypeOf(pair.old), reflect.TypeOf(pair.next)
for i := 0; i < old.NumField(); i++ {
field := old.Field(i)
replacement, ok := next.FieldByName(field.Name)
if !ok || replacement.Tag.Get("json") != field.Tag.Get("json") || replacement.Type.Kind() != field.Type.Kind() {
t.Errorf("%s.%s lost its legacy schema: old=%+v new=%+v", old.Name(), field.Name, field, replacement)
}
}
}
}
func TestLegacyRunJSONRoundTrip(t *testing.T) {
now := time.Date(2026, 9, 20, 1, 2, 3, 456, time.UTC)
legacy := zset.RunMeta{
RunID: "legacy", Operation: "current_month_usage_full_sync", QueryMonth: "202609",
Status: "completed_with_failures", ProducerDone: true, CurrentRound: 5,
SuccessCount: 9, FailedCount: 1, CreatedAt: now, ProducerCompletedAt: now, CompletedAt: now,
}
data := mustJSON(t, legacy)
var decoded contract.RunMeta
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if err := decoded.Validate(); err != nil {
t.Fatalf("legacy run rejected: %v", err)
}
if got := string(mustJSON(t, decoded)); got != string(data) {
t.Fatalf("legacy wire format changed:\n%s\n%s", data, got)
}
// New fields do not prevent old readers from consuming the same metadata.
decoded.SyncType = contract.SyncReplay
decoded.SourceRunID = "source"
decoded.ReplayCycle = 1
decoded.EnqueuedCount = 10
decoded.PendingFailureCount = 1
var oldReader zset.RunMeta
if err := json.Unmarshal(mustJSON(t, decoded), &oldReader); err != nil {
t.Fatal(err)
}
oldReader.SyncType = ""
oldReader.SourceRunID = ""
oldReader.ReplayCycle = 0
oldReader.EnqueuedCount = 0
oldReader.PendingFailureCount = 0
if !reflect.DeepEqual(oldReader, legacy) {
t.Fatalf("old reader lost fields: %+v", oldReader)
}
}
func TestLegacyFailureAndTaskJSON(t *testing.T) {
legacy := zset.FailureRecord{
Version: 1, TaskID: "source:op:42", OriginalRunID: "source", LatestRunID: "latest",
QueryMonth: "202609", Operation: "op", CardID: 42, ICCID: "iccid", MSISDN: "msisdn", IMSI: "imsi",
ChanKey: "chan", TempKey: "template", Carrier: "mobile", FailureType: "mobile_busy_exhausted",
Round: 5, Attempts: 7, Code: 13000, ErrCode: "BUSY", Message: "try again",
ReviewStatus: "pending", ReviewRemark: "remark", ReviewedBy: "operator", ReviewedAt: "2026-09-20",
RetryCycle: 2, ReplayRunID: "replayed", FirstFailedAt: time.Unix(1, 0).UTC(), LastFailedAt: time.Unix(2, 0).UTC(),
}
var record contract.FailureRecord
if err := json.Unmarshal(mustJSON(t, legacy), &record); err != nil {
t.Fatal(err)
}
if string(mustJSON(t, record)) != string(mustJSON(t, legacy)) {
t.Fatal("failure record wire format changed")
}
if summary := record.Summary(); summary.ErrorCategory != contract.ErrorRateLimited || summary.TaskID != legacy.TaskID || summary.FailedAt != legacy.LastFailedAt {
t.Fatalf("incorrect failure projection: %+v", summary)
}
task := zset.Task{TaskID: "r:op:42", RunID: "r", Operation: "op", QueryMonth: "202609", ChanKey: "c", TempKey: "t", Operator: 2, CardID: 42, ICCID: "i", MSISDN: "m", IMSI: "s", NextExecuteAt: 1234}
var newTask contract.Task
if err := json.Unmarshal(mustJSON(t, task), &newTask); err != nil {
t.Fatal(err)
}
newTask.NextExecuteAt = 9999
if string(mustJSON(t, newTask)) != string(mustJSON(t, task)) {
t.Fatal("task wire format or scheduling exclusion changed")
}
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return data
}
@@ -0,0 +1,14 @@
// Package contract defines the shared full-sync domain and service boundaries.
// It contains no Redis, filesystem, replay execution, or HTTP handler code.
//
// Legacy queue models remain in internal/queue/zset until adapters adopt this
// package. Their existing JSON fields are retained here; new run fields are
// optional so historical records still decode. Missing extended counters mean
// unknown, not a reconstructed total. FailedCount is the historical terminal
// count for a run; cleaning source failures changes PendingFailureCount only.
//
// Implementations must preserve these boundaries: run-local Redis keys share
// one hash tag, global indexes do not; archive summaries never contain card
// details; cursors are opaque and scoped to the original query; replay writes
// are idempotent and fenced by a lease before source cleanup is allowed.
package contract
@@ -0,0 +1,112 @@
package contract
import (
"encoding/json"
"math"
"strings"
"unicode/utf8"
)
type ErrorCategory string
const (
ErrorTimeout ErrorCategory = "timeout"
ErrorRateLimited ErrorCategory = "rate_limited"
ErrorAuthentication ErrorCategory = "authentication"
ErrorValidation ErrorCategory = "validation"
ErrorUpstream ErrorCategory = "upstream"
ErrorTransport ErrorCategory = "transport"
ErrorInternal ErrorCategory = "internal"
ErrorOther ErrorCategory = "other"
MaxErrorCategories = 8
MaxErrorMessageBytes = 2048
)
// NormalizeErrorCategory maps the existing consumer classifications into the
// approved, bounded vocabulary. Never use an upstream message/code as a key.
func NormalizeErrorCategory(value string) ErrorCategory {
switch strings.ToLower(strings.TrimSpace(value)) {
case "timeout":
return ErrorTimeout
case "rate_limited", "mobile_busy", "mobile_busy_exhausted", "channel_limited", "channel_blocked":
return ErrorRateLimited
case "authentication":
return ErrorAuthentication
case "validation":
return ErrorValidation
case "upstream", "business", "carrier_retry":
return ErrorUpstream
case "transport", "network":
return ErrorTransport
case "internal", "canceled":
return ErrorInternal
default:
return ErrorOther
}
}
func (c ErrorCategory) MarshalJSON() ([]byte, error) {
return json.Marshal(string(NormalizeErrorCategory(string(c))))
}
func (c *ErrorCategory) UnmarshalJSON(data []byte) error {
var value string
if err := json.Unmarshal(data, &value); err != nil {
return invalid("error category must be a string: %v", err)
}
*c = NormalizeErrorCategory(value)
return nil
}
// ErrorCounts is normalized on both JSON input and output, including map keys
// (encoding/json does not invoke ErrorCategory.MarshalJSON for map keys).
// Counts are per category, not card IDs or individual error messages.
type ErrorCounts map[ErrorCategory]int64
func (counts ErrorCounts) Normalized() (ErrorCounts, error) {
normalized := make(ErrorCounts)
for category, count := range counts {
key := NormalizeErrorCategory(string(category))
if count < 0 || count > math.MaxInt64-normalized[key] {
return nil, invalid("error counts must be nonnegative and must not overflow")
}
normalized[key] += count
}
return normalized, nil
}
func (counts ErrorCounts) MarshalJSON() ([]byte, error) {
normalized, err := counts.Normalized()
if err != nil {
return nil, err
}
type wireCounts map[ErrorCategory]int64
return json.Marshal(wireCounts(normalized))
}
func (counts *ErrorCounts) UnmarshalJSON(data []byte) error {
var decoded map[ErrorCategory]int64
if err := json.Unmarshal(data, &decoded); err != nil {
return invalid("invalid error counts: %v", err)
}
normalized, err := ErrorCounts(decoded).Normalized()
if err != nil {
return err
}
*counts = normalized
return nil
}
// BoundedErrorMessage makes new summary/action error messages valid UTF-8 and
// caps their byte length. It does not modify legacy FailureRecord.Message.
func BoundedErrorMessage(message string) string {
message = strings.ToValidUTF8(message, "\uFFFD")
if len(message) <= MaxErrorMessageBytes {
return message
}
end := MaxErrorMessageBytes
for !utf8.RuneStart(message[end]) {
end--
}
return message[:end]
}
@@ -0,0 +1,91 @@
package contract_test
import (
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"testing"
"unicode/utf8"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
)
func TestBoundedErrorCategories(t *testing.T) {
for raw, want := range map[string]contract.ErrorCategory{
"timeout": contract.ErrorTimeout, " RATE_LIMITED ": contract.ErrorRateLimited,
"authentication": contract.ErrorAuthentication, "validation": contract.ErrorValidation,
"upstream": contract.ErrorUpstream, "transport": contract.ErrorTransport,
"internal": contract.ErrorInternal, "other": contract.ErrorOther,
"network": contract.ErrorTransport, "mobile_busy": contract.ErrorRateLimited,
"mobile_busy_exhausted": contract.ErrorRateLimited, "channel_blocked": contract.ErrorRateLimited,
"channel_limited": contract.ErrorRateLimited, "business": contract.ErrorUpstream,
"carrier_retry": contract.ErrorUpstream, "canceled": contract.ErrorInternal,
"": contract.ErrorOther, "card-898600-unique-error": contract.ErrorOther,
} {
if got := contract.NormalizeErrorCategory(raw); got != want {
t.Errorf("normalize %q = %q, want %q", raw, got, want)
}
var decoded contract.ErrorCategory
if err := json.Unmarshal(mustJSON(t, raw), &decoded); err != nil || decoded != want {
t.Errorf("decode %q = %q, err %v", raw, decoded, err)
}
if got := string(mustJSON(t, contract.ErrorCategory(raw))); got != string(mustJSON(t, string(want))) {
t.Errorf("encode %q = %s", raw, got)
}
}
}
func TestErrorCountsCannotGrowWithCardinality(t *testing.T) {
counts := contract.ErrorCounts{contract.ErrorTimeout: 2, contract.ErrorOther: 3, "network": 4}
for i := 0; i < 1000; i++ {
counts[contract.ErrorCategory(fmt.Sprintf("card-error-%d", i))] = 1
}
var decoded contract.ErrorCounts
if err := json.Unmarshal(mustJSON(t, counts), &decoded); err != nil {
t.Fatal(err)
}
if len(decoded) != 3 || len(decoded) > contract.MaxErrorCategories || decoded[contract.ErrorOther] != 1003 || decoded[contract.ErrorTransport] != 4 {
t.Fatalf("unbounded or lost counts: %+v", decoded)
}
if err := json.Unmarshal([]byte(`{"unknown":2,"different":3,"other":4,"network":5}`), &decoded); err != nil {
t.Fatal(err)
}
if len(decoded) != 2 || decoded[contract.ErrorOther] != 9 || decoded[contract.ErrorTransport] != 5 {
t.Fatalf("input normalization failed: %+v", decoded)
}
}
func TestErrorCountsRejectInvalidNumbers(t *testing.T) {
for _, raw := range []string{`{"timeout":-1}`, `{"one":9223372036854775807,"two":1}`, `{"other":1.5}`, `{"other":"1"}`} {
var counts contract.ErrorCounts
if err := json.Unmarshal([]byte(raw), &counts); !errors.Is(err, contract.ErrInvalidArgument) {
t.Errorf("invalid input %s returned %v", raw, err)
}
}
for _, counts := range []contract.ErrorCounts{{"timeout": -1}, {"one": math.MaxInt64, "two": 1}} {
if _, err := json.Marshal(counts); !errors.Is(err, contract.ErrInvalidArgument) {
t.Errorf("invalid output counts returned %v", err)
}
}
}
func TestBoundedFailureMessage(t *testing.T) {
for _, message := range []string{"short", strings.Repeat("x", 3000), strings.Repeat("卡", 1000), strings.Repeat("x", 2047) + "卡", "invalid\xfftext"} {
got := contract.BoundedErrorMessage(message)
if len(got) > contract.MaxErrorMessageBytes || !utf8.ValidString(got) {
t.Fatalf("invalid bounded output: bytes=%d", len(got))
}
if utf8.ValidString(message) && len(message) <= contract.MaxErrorMessageBytes && got != message {
t.Fatal("short message changed")
}
}
record := contract.FailureRecord{TaskID: "task", Message: strings.Repeat("卡", 1000)}
if err := record.Summary().Validate(); err != nil {
t.Fatal(err)
}
if len(record.Message) != 3000 {
t.Fatal("summary mutated legacy failure message")
}
}
@@ -0,0 +1,129 @@
package contract
import (
"time"
"unicode/utf8"
)
// Cursor is an opaque repository-issued continuation token. Clients must not
// parse, increment, or reuse it for another run/filter. Empty starts a scan;
// an empty nextCursor ends it. An empty page with a cursor is not end-of-scan.
// Repositories validate token contents and query binding with ErrInvalidArgument.
type Cursor string
const (
DefaultPageLimit = 100
MaxPageLimit = 500
)
type PageRequest struct {
Cursor Cursor `json:"cursor,omitempty"`
Limit int `json:"limit,omitempty"`
}
// Normalized must be called at both HTTP and repository boundaries. Limits
// outside the bounds are rejected, not silently truncated.
func (p PageRequest) Normalized() (PageRequest, error) {
if p.Limit == 0 {
p.Limit = DefaultPageLimit
}
if p.Limit < 1 || p.Limit > MaxPageLimit {
return PageRequest{}, invalid("limit must be between 1 and %d", MaxPageLimit)
}
return p, nil
}
// Failure is the bounded public projection, not the replay payload. Legacy
// free-form failureType/message fields remain available in FailureRecord.
type Failure struct {
TaskID string `json:"taskId"`
CardID int64 `json:"cardId"`
ErrorCategory ErrorCategory `json:"errorCategory"`
ErrorMessage string `json:"errorMessage"`
FailedAt time.Time `json:"failedAt"`
Round int `json:"round"`
}
func (f Failure) Validate() error {
if f.TaskID == "" || f.Round < 0 || len(f.ErrorMessage) > MaxErrorMessageBytes || !utf8.ValidString(f.ErrorMessage) {
return invalid("invalid failure summary")
}
return nil
}
type FailurePage struct {
Items []Failure `json:"items"`
NextCursor Cursor `json:"nextCursor"`
}
// FailureRecord preserves the existing persisted payload without changing
// review semantics or discarding fields needed to reconstruct replay tasks.
type FailureRecord struct {
Version int `json:"version"`
TaskID string `json:"taskId"`
OriginalRunID string `json:"originalRunId"`
LatestRunID string `json:"latestRunId"`
QueryMonth string `json:"queryMonth"`
Operation string `json:"operation"`
CardID int64 `json:"cardId"`
ICCID string `json:"iccid"`
MSISDN string `json:"msisdn"`
IMSI string `json:"imsi"`
ChanKey string `json:"chanKey"`
TempKey string `json:"tempKey"`
// PackageCode is additive; older failure records decode it as empty.
PackageCode string `json:"packageCode,omitempty"`
Carrier string `json:"carrier"`
FailureType string `json:"failureType"`
Round int `json:"round"`
Attempts int `json:"attempts"`
Code int `json:"code"`
ErrCode string `json:"errCode"`
Message string `json:"message"`
ReviewStatus string `json:"reviewStatus"`
ReviewRemark string `json:"reviewRemark"`
ReviewedBy string `json:"reviewedBy"`
ReviewedAt string `json:"reviewedAt"`
RetryCycle int `json:"retryCycle"`
ReplayRunID string `json:"replayRunId"`
FirstFailedAt time.Time `json:"firstFailedAt"`
LastFailedAt time.Time `json:"lastFailedAt"`
}
func (f FailureRecord) Summary() Failure {
return Failure{
TaskID: f.TaskID, CardID: f.CardID, ErrorCategory: NormalizeErrorCategory(f.FailureType),
ErrorMessage: BoundedErrorMessage(f.Message), FailedAt: f.LastFailedAt, Round: f.Round,
}
}
// FailureEntry carries an opaque version for conditional source cleanup. It
// must identify the precise record read, not just its reusable task ID.
type FailureEntry struct {
Record FailureRecord `json:"record"`
Version string `json:"-"`
}
type FailureRecordPage struct {
Items []FailureEntry `json:"items"`
NextCursor Cursor `json:"nextCursor"`
}
// Task keeps the existing queue member serialization. The scheduling score
// must never be part of the JSON member or producer retries lose idempotency.
type Task struct {
TaskID string `json:"taskId"`
RunID string `json:"runId"`
Operation string `json:"operation"`
QueryMonth string `json:"queryMonth"`
ChanKey string `json:"chanKey"`
TempKey string `json:"tempKey"`
Operator int `json:"operator,omitempty"`
CardID int64 `json:"cardId"`
ICCID string `json:"iccid"`
MSISDN string `json:"msisdn"`
IMSI string `json:"imsi"`
// PackageCode is additive; older members decode it as empty.
PackageCode string `json:"packageCode,omitempty"`
NextExecuteAt int64 `json:"-"`
}
@@ -0,0 +1,28 @@
package contract
import "net/url"
const (
ListRunsRoute = "/internal/v1/full-sync-runs"
GetRunRoute = "/internal/v1/full-sync-runs/{runId}"
GetFailuresRoute = "/internal/v1/full-sync-runs/{runId}/failures"
PostReplayRoute = "/internal/v1/full-sync-runs/{runId}/replay"
GetActionRoute = "/internal/v1/full-sync-actions/{actionId}"
IdempotencyHeader = "Idempotency-Key"
)
// ReplayAccepted is the 202 response from a successful ReplayRequester call.
// A repeated request returns the original action and its current status.
type ReplayAccepted struct {
ActionID string `json:"actionId"`
TargetRunID string `json:"targetRunId"`
Status ActionStatus `json:"status"`
StatusURL string `json:"statusUrl"`
}
func ActionStatusURL(actionID string) (string, error) {
if err := ValidateID(actionID); err != nil {
return "", err
}
return "/internal/v1/full-sync-actions/" + url.PathEscape(actionID), nil
}
@@ -0,0 +1,145 @@
package contract
import (
"context"
"time"
)
// RunReader is the HTTP lookup boundary. Fall back to archive only when the
// live reader returns ErrNotFound, not on transient live storage failures.
type RunReader interface {
GetRun(ctx context.Context, runID string) (RunView, error)
}
type RunListFilter struct {
SyncKind SyncKind
Limit int
}
type RunList struct {
Items []RunView `json:"items"`
Total int `json:"total"`
}
type RunLister interface {
ListRuns(ctx context.Context, filter RunListFilter) (RunList, error)
}
type LiveRunReader interface {
GetRunMeta(ctx context.Context, runID string) (RunMeta, error)
}
type ArchivedRunReader interface {
GetArchivedRun(ctx context.Context, runID string) (RunArchive, error)
}
type RoundStatsReader interface {
GetRoundStats(ctx context.Context, runID string) ([]RoundStats, error)
}
// FailurePager returns live details only. An archived summary without live
// details returns ErrFailureDetailsUnavailable, never a misleading empty page.
// Items must serialize as [] on empty pages; NextCursor alone signals EOF.
type FailurePager interface {
ListFailures(ctx context.Context, runID string, page PageRequest) (FailurePage, error)
}
type ReplayRequester interface {
RequestReplay(ctx context.Context, request ReplayRequest) (ReplayAccepted, error)
}
type ActionReader interface {
GetReplayAction(ctx context.Context, actionID string) (ReplayAction, error)
}
// ArchiveWorkDiscovery returns pending/retryable work from a durable index.
// Terminal run registration must not depend on an in-memory notification.
type ArchiveWorkDiscovery interface {
ListArchiveWork(ctx context.Context, page PageRequest) (ArchiveWorkPage, error)
}
// RegisterArchiveWork idempotently schedules a terminal run/version. Since the
// index can occupy another Redis slot, callers must reconcile registration
// after a crash between completing the run and updating the index.
type ArchiveWorkRegistrar interface {
RegisterArchiveWork(ctx context.Context, work ArchiveWork) error
}
type ArchiveWorkLoader interface {
LoadArchiveWork(ctx context.Context, work ArchiveWork) (RunArchive, error)
}
// ArchiveRegistrar conditionally records a durable publication against the
// work version. Repeating the same receipt is a no-op; a different version or
// conflicting receipt returns ErrConflict. Failure retains discoverable work.
type ArchiveRegistrar interface {
RegisterArchive(ctx context.Context, work ArchiveWork, receipt ArchiveReceipt) error
RegisterArchiveFailure(ctx context.Context, work ArchiveWork, message string) error
}
// ReplayActionCreator atomically reserves (sourceRunId, idempotencyKey) with
// its fingerprint and action. Same key/request returns the original action
// regardless of candidate IDs; different request returns ErrConflict. The
// candidate starts accepted and fixes the target ID before any target writes.
type ReplayActionCreator interface {
GetOrCreateReplayAction(ctx context.Context, request ReplayRequest, candidate ReplayAction) (ReplayActionCreation, error)
}
// Discovery includes accepted, processing, cleanup_pending and receipt_pending
// actions after crashes. Completed/needs_attention actions require no automatic
// work. Lease acquisition, not listing, chooses an exclusive owner.
type ReplayWorkDiscovery interface {
ListReplayWork(ctx context.Context, page PageRequest) (ReplayActionPage, error)
}
type ReplayLeaseStore interface {
AcquireReplayLease(ctx context.Context, actionID, owner string, ttl time.Duration) (ReplayLease, error)
RenewReplayLease(ctx context.Context, lease ReplayLease, ttl time.Duration) (ReplayLease, error)
ReleaseReplayLease(ctx context.Context, lease ReplayLease) error
}
// All updates are fenced and compare ExpectedRevision. Expired/replaced leases
// return ErrLeaseLost; revision conflicts return ErrConflict. Implementations
// reject progress regression and preserve immutable action identity.
type ReplayProgressStore interface {
UpdateReplayAction(ctx context.Context, update ReplayActionUpdate) (ReplayAction, error)
}
// The implementation must preserve scan continuity while source entries are
// cleaned. This can use a durable selection/snapshot; deleting records must not
// make a later cursor skip an unprocessed failure. Tokens are bound to actionID.
type ReplayFailurePager interface {
ListReplayFailures(ctx context.Context, actionID string, page PageRequest) (FailureRecordPage, error)
}
// TargetRunCreator is idempotent: retrying the same target preserves counters;
// incompatible source/operation/month/cycle returns ErrConflict. Replay targets
// are distinct runs and must not mutate the source's historical FailedCount.
type TargetRunCreator interface {
EnsureTargetRun(ctx context.Context, meta RunMeta) (RunMeta, error)
}
// TargetEnqueueResult distinguishes a durable duplicate from a rejected write.
// A zero NewlyEnqueued count alone is never sufficient evidence for cleanup.
// Every input TaskID must be durably present on nil error; otherwise return an
// error and let the caller safely retry the identical batch. Both counts are
// unique task counts, and their sum equals the number of distinct input IDs.
type TargetEnqueueResult struct {
NewlyEnqueued int64
AlreadyPresent int64
}
type TargetRunEnqueuer interface {
// Tasks must match the target run and channel and use stable logical IDs.
EnqueueTargetBatch(ctx context.Context, lease ReplayLease, runID, chanKey string, tasks []Task, nextExecuteAt time.Time) (TargetEnqueueResult, error)
MarkTargetProducerDone(ctx context.Context, lease ReplayLease, runID string) error
}
// Cleanup is idempotent and conditional on the lease, exact source versions,
// and durable evidence of target acceptance. It changes pending failures only,
// never historical failedCount. Conflicts must remain visible for attention.
// Source keys and action/target keys may occupy different Redis Cluster slots;
// implementations need recoverable receipts, not cross-slot Lua assumptions.
type ReplayFailureCleaner interface {
CleanupReplayFailures(ctx context.Context, cleanup ReplayCleanup) (ReplayCleanupResult, error)
}
@@ -0,0 +1,116 @@
package contract
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
)
const (
DefaultFlowPrefix = "flow:"
DefaultSimStatusPrefix = "sim-status:"
)
// Keys is a naming contract only. It does not create indexes or storage.
// Existing run names are byte-for-byte compatible with zset.Store. Global
// run/archive indexes are outside run hash slots and cannot join run-local Lua.
type Keys struct{ prefix string }
func NewKeys(prefix string) (Keys, error) {
if strings.TrimSpace(prefix) == "" {
prefix = DefaultFlowPrefix
}
if strings.ContainsAny(prefix, "{}") {
return Keys{}, invalid("prefix must not contain Redis hash tags")
}
return Keys{prefix: prefix}, nil
}
// The zero value uses the legacy flow prefix, just like NewKeys("").
func (k Keys) Prefix() string {
if k.prefix == "" {
return DefaultFlowPrefix
}
return k.prefix
}
func (k Keys) runKey(runID, suffix string) (string, error) {
if err := ValidateID(runID); err != nil {
return "", err
}
return k.Prefix() + "{" + runID + "}:" + suffix, nil
}
func (k Keys) RunMetaKey(runID string) (string, error) { return k.runKey(runID, "meta") }
func (k Keys) RunChannelsKey(runID string) (string, error) { return k.runKey(runID, "channels") }
func (k Keys) TaskStateKey(runID string) (string, error) { return k.runKey(runID, "task-state") }
func (k Keys) FailedKey(runID string) (string, error) { return k.runKey(runID, "failed") }
func (k Keys) ArchiveStateKey(runID string) (string, error) { return k.runKey(runID, "archive") }
func (k Keys) roundKey(runID string, round int, suffix string) (string, error) {
if round < 1 {
return "", invalid("round must be positive")
}
return k.runKey(runID, fmt.Sprintf("round:%d:%s", round, suffix))
}
func (k Keys) RoundStatsKey(runID string, round int) (string, error) {
return k.roundKey(runID, round, "stats")
}
func (k Keys) roundChannelKey(runID string, round int, kind, chanKey string) (string, error) {
if strings.TrimSpace(chanKey) == "" {
return "", invalid("chanKey is empty")
}
return k.roundKey(runID, round, kind+":"+chanKey)
}
func (k Keys) RoundActiveKey(runID string, round int, chanKey string) (string, error) {
return k.roundChannelKey(runID, round, "active", chanKey)
}
func (k Keys) RoundWaitingKey(runID string, round int, chanKey string) (string, error) {
return k.roundChannelKey(runID, round, "waiting", chanKey)
}
func (k Keys) RoundLeaseKey(runID string, round int, chanKey string) (string, error) {
return k.roundChannelKey(runID, round, "lease", chanKey)
}
func (k Keys) FailedRunsKey() string { return k.Prefix() + "failed:runs" }
func (k Keys) ActiveRunsKey() string { return k.Prefix() + "runs:active" }
func (k Keys) AllRunsKey() string { return k.Prefix() + "runs:all" }
func (k Keys) PendingArchivesKey() string { return k.Prefix() + "archive:pending" }
// Replay action state, leases, idempotency reservations and pending index share
// a dedicated slot, allowing atomic acceptance/discovery. Source/target run
// writes remain in their own slots and require a recoverable replay protocol.
func (k Keys) replayKey(actionID, suffix string) (string, error) {
if err := ValidateID(actionID); err != nil {
return "", err
}
return k.Prefix() + "{replay}:action:" + actionID + ":" + suffix, nil
}
func (k Keys) ReplayActionKey(actionID string) (string, error) { return k.replayKey(actionID, "meta") }
func (k Keys) ReplayLeaseKey(actionID string) (string, error) { return k.replayKey(actionID, "lease") }
func (k Keys) PendingReplayActionsKey() string { return k.Prefix() + "{replay}:pending" }
// Hash the complete tuple so neither delimiter collisions nor arbitrary
// Idempotency-Key characters can change the Redis key or hash slot.
func (k Keys) ReplayIdempotencyKey(sourceRunID, idempotencyKey string) (string, error) {
if err := ValidateID(sourceRunID); err != nil {
return "", err
}
if err := ValidateIdempotencyKey(idempotencyKey); err != nil {
return "", err
}
data, err := json.Marshal([2]string{sourceRunID, idempotencyKey})
if err != nil {
return "", invalid("cannot encode idempotency scope: %v", err)
}
digest := sha256.Sum256(data)
return k.Prefix() + "{replay}:idempotency:" + hex.EncodeToString(digest[:]), nil
}
@@ -0,0 +1,116 @@
package contract_test
import (
"context"
"errors"
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
"github.com/gogf/gf/v2/container/gvar"
)
type unusedRedis struct{}
func (unusedRedis) Do(context.Context, string, ...any) (*gvar.Var, error) {
panic("naming contracts must not access Redis")
}
func (unusedRedis) Eval(context.Context, string, int64, []string, []any) (*gvar.Var, error) {
panic("naming contracts must not access Redis")
}
func TestKeysMatchExistingQueue(t *testing.T) {
for _, prefix := range []string{"", "flow:", "sim-status:", "custom-prefix"} {
keys, err := contract.NewKeys(prefix)
if err != nil {
t.Fatal(err)
}
store, err := zset.New(unusedRedis{}, prefix)
if err != nil {
t.Fatal(err)
}
for _, pair := range []struct {
old, next func(string) (string, error)
}{
{store.RunMetaKey, keys.RunMetaKey}, {store.RunChannelsKey, keys.RunChannelsKey},
{store.TaskStateKey, keys.TaskStateKey}, {store.FailedKey, keys.FailedKey},
} {
old, oldErr := pair.old("run:1")
next, nextErr := pair.next("run:1")
if oldErr != nil || nextErr != nil || old != next || !strings.Contains(next, "{run:1}") {
t.Errorf("run naming changed: old=%q new=%q errors=%v/%v", old, next, oldErr, nextErr)
}
}
for _, pair := range []struct {
old, next func(string, int, string) (string, error)
}{
{store.RoundActiveKey, keys.RoundActiveKey}, {store.RoundWaitingKey, keys.RoundWaitingKey},
{store.RoundLeaseKey, keys.RoundLeaseKey},
} {
old, oldErr := pair.old("run:1", 3, "channel")
next, nextErr := pair.next("run:1", 3, "channel")
if oldErr != nil || nextErr != nil || old != next {
t.Errorf("round naming changed: old=%q new=%q errors=%v/%v", old, next, oldErr, nextErr)
}
}
if keys.ActiveRunsKey() != store.ActiveRunsKey() || keys.FailedRunsKey() != store.FailedRunsKey() {
t.Fatal("existing global index names changed")
}
}
}
func TestNewKeysAndClusterBoundaries(t *testing.T) {
keys, _ := contract.NewKeys("flow:")
stats, _ := keys.RoundStatsKey("run", 2)
archive, _ := keys.ArchiveStateKey("run")
if stats != "flow:{run}:round:2:stats" || archive != "flow:{run}:archive" {
t.Fatalf("new run keys changed: %s, %s", stats, archive)
}
action, _ := keys.ReplayActionKey("a")
lease, _ := keys.ReplayLeaseKey("a")
idempotency, _ := keys.ReplayIdempotencyKey("source", "a{different-slot}:key")
for _, key := range []string{action, lease, idempotency, keys.PendingReplayActionsKey()} {
if !strings.HasPrefix(key, "flow:{replay}:") || strings.Count(key, "{") != 1 {
t.Fatalf("replay key escaped action slot: %s", key)
}
}
if action != "flow:{replay}:action:a:meta" || lease != "flow:{replay}:action:a:lease" {
t.Fatal("action naming changed")
}
for _, key := range []string{keys.ActiveRunsKey(), keys.FailedRunsKey(), keys.AllRunsKey(), keys.PendingArchivesKey()} {
if strings.ContainsAny(key, "{}") {
t.Fatalf("global index unexpectedly shares run slot: %s", key)
}
}
first, _ := keys.ReplayIdempotencyKey("a:b", "c")
second, _ := keys.ReplayIdempotencyKey("a", "b:c")
repeat, _ := keys.ReplayIdempotencyKey("a:b", "c")
otherSource, _ := keys.ReplayIdempotencyKey("other", "c")
if first == second || first == otherSource || first != repeat {
t.Fatal("idempotency scope is ambiguous or unstable")
}
}
func TestInvalidKeyParts(t *testing.T) {
keys := contract.Keys{}
for _, id := range []string{"", " ", "trim ", "{run}", "run}", "run/path", "run\\path", "run\n", "invalid\xff"} {
if _, err := keys.RunMetaKey(id); !errors.Is(err, contract.ErrInvalidArgument) {
t.Errorf("invalid ID %q returned %v", id, err)
}
}
if _, err := contract.NewKeys("prefix:{wrong}:"); !errors.Is(err, contract.ErrInvalidArgument) {
t.Fatalf("invalid prefix returned %v", err)
}
if _, err := keys.RoundStatsKey("run", 0); !errors.Is(err, contract.ErrInvalidArgument) {
t.Fatalf("invalid round returned %v", err)
}
if _, err := keys.RoundActiveKey("run", 1, " "); !errors.Is(err, contract.ErrInvalidArgument) {
t.Fatalf("invalid channel returned %v", err)
}
if key, err := keys.RunMetaKey("run"); err != nil || key != "flow:{run}:meta" {
t.Fatalf("zero-value keys differ from legacy default: %s %v", key, err)
}
}
@@ -0,0 +1,172 @@
package contract
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const MaxIdempotencyKeyBytes = 256
// ReplayRequest combines the path, header and body. Only requestedBy is in the
// HTTP body. Idempotency keys are scoped to the source run; fingerprints cover
// the source and exact request body, not the key or a generated target run ID.
type ReplayRequest struct {
SourceRunID string `json:"-"`
IdempotencyKey string `json:"-"`
RequestedBy string `json:"requestedBy"`
}
func ValidateIdempotencyKey(key string) error {
if strings.TrimSpace(key) == "" || len(key) > MaxIdempotencyKeyBytes || !utf8.ValidString(key) || strings.ContainsFunc(key, unicode.IsControl) {
return invalid("invalid Idempotency-Key")
}
return nil
}
func (r ReplayRequest) Validate() error {
if err := ValidateID(r.SourceRunID); err != nil {
return err
}
if err := ValidateIdempotencyKey(r.IdempotencyKey); err != nil {
return err
}
if strings.TrimSpace(r.RequestedBy) == "" || !utf8.ValidString(r.RequestedBy) {
return invalid("requestedBy is required")
}
return nil
}
func (r ReplayRequest) Fingerprint() (string, error) {
if err := r.Validate(); err != nil {
return "", err
}
// JSON avoids ambiguous delimiter concatenation and fixes field ordering.
data, err := json.Marshal(struct {
SourceRunID string `json:"sourceRunId"`
RequestedBy string `json:"requestedBy"`
}{r.SourceRunID, r.RequestedBy})
if err != nil {
return "", invalid("cannot encode replay request: %v", err)
}
digest := sha256.Sum256(data)
return hex.EncodeToString(digest[:]), nil
}
// Counters describe distinct source failures, not scan visits or retry calls.
// ScanCursor alone is not an enqueue/cleanup checkpoint: repositories must
// retain sufficient durable per-batch evidence to resume partial writes.
type ReplayProgress struct {
ScanCursor Cursor `json:"scanCursor"`
DiscoveredCount int64 `json:"discoveredCount"`
EnqueuedCount int64 `json:"enqueuedCount"`
CleanedCount int64 `json:"cleanedCount"`
FailureCount int64 `json:"failureCount"`
}
func (p ReplayProgress) Validate() error {
if err := nonnegative(p.DiscoveredCount, p.EnqueuedCount, p.CleanedCount, p.FailureCount); err != nil {
return err
}
if p.EnqueuedCount > p.DiscoveredCount || p.CleanedCount > p.EnqueuedCount {
return invalid("replay progress must satisfy cleaned <= enqueued <= discovered")
}
return nil
}
type ReplayAction struct {
ActionID string `json:"actionId"`
SourceRunID string `json:"sourceRunId"`
TargetRunID string `json:"targetRunId"`
RequestedBy string `json:"requestedBy"`
IdempotencyKey string `json:"-"`
Fingerprint string `json:"-"`
Status ActionStatus `json:"status"`
ReplayCycle int `json:"replayCycle"`
Progress ReplayProgress `json:"progress"`
LeaseOwner string `json:"-"`
LeaseToken string `json:"-"`
LeaseUntil time.Time `json:"-"`
Revision uint64 `json:"-"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Receipt *ArchiveReceipt `json:"receipt,omitempty"`
}
func (a ReplayAction) Validate() error {
for _, id := range []string{a.ActionID, a.SourceRunID, a.TargetRunID} {
if err := ValidateID(id); err != nil {
return err
}
}
if a.SourceRunID == a.TargetRunID || a.ReplayCycle < 1 || !a.Status.Valid() || strings.TrimSpace(a.RequestedBy) == "" {
return invalid("invalid replay action identity or status")
}
if len(a.Error) > MaxErrorMessageBytes || !utf8.ValidString(a.Error) {
return invalid("action error exceeds message bounds")
}
if err := a.Progress.Validate(); err != nil {
return err
}
if a.Receipt != nil {
if err := a.Receipt.Validate(); err != nil {
return err
}
}
if a.Status == ActionCompleted && (a.Receipt == nil || a.CompletedAt == nil || a.CompletedAt.IsZero()) {
return invalid("completed action requires a durable receipt and completedAt")
}
return nil
}
type ReplayActionPage struct {
Items []ReplayAction `json:"items"`
NextCursor Cursor `json:"nextCursor"`
}
type ReplayActionCreation struct {
Action ReplayAction
Created bool
}
// Lease token fences a particular acquisition, even if the same owner name is
// reused. Repository methods check expiry against their authoritative clock.
type ReplayLease struct {
ActionID string
Owner string
Token string
Until time.Time
}
// ReplayActionUpdate is a compare-and-swap of mutable action state. Identity,
// provenance, request fingerprint, and target run must never be changed.
type ReplayActionUpdate struct {
Lease ReplayLease
ExpectedRevision uint64
Status ActionStatus
Progress ReplayProgress
Error string
Receipt *ArchiveReceipt
CompletedAt *time.Time
}
// Cleanup contains the exact source versions durably accepted by the target.
// The engine must not request cleanup until target enqueue is confirmed.
type ReplayCleanup struct {
Lease ReplayLease
SourceRunID string
TargetRunID string
Entries []FailureEntry
}
type ReplayCleanupResult struct {
CleanedCount int64
AlreadyCleanedCount int64
ConflictCount int64
}
@@ -0,0 +1,103 @@
package contract_test
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
)
func TestReplayRequestIdentity(t *testing.T) {
request := contract.ReplayRequest{SourceRunID: "source", IdempotencyKey: "key", RequestedBy: "operator"}
fingerprint, err := request.Fingerprint()
if err != nil || len(fingerprint) != 64 {
t.Fatalf("fingerprint=%q, err=%v", fingerprint, err)
}
for name, mutate := range map[string]func(*contract.ReplayRequest){
"same": func(*contract.ReplayRequest) {},
"key": func(r *contract.ReplayRequest) { r.IdempotencyKey = "another-key" },
"source": func(r *contract.ReplayRequest) { r.SourceRunID = "another-source" },
"body": func(r *contract.ReplayRequest) { r.RequestedBy = "another-operator" },
} {
copy := request
mutate(&copy)
got, err := copy.Fingerprint()
if err != nil || (got == fingerprint) != (name == "same" || name == "key") {
t.Errorf("%s fingerprint=%s err=%v", name, got, err)
}
}
if got := string(mustJSON(t, request)); got != `{"requestedBy":"operator"}` {
t.Fatalf("path/header leaked into body: %s", got)
}
for _, bad := range []contract.ReplayRequest{
{SourceRunID: "source", RequestedBy: "operator"},
{SourceRunID: "source", IdempotencyKey: "key", RequestedBy: " "},
{SourceRunID: "{source}", IdempotencyKey: "key", RequestedBy: "operator"},
{SourceRunID: "source", IdempotencyKey: "key\r\ninjected", RequestedBy: "operator"},
{SourceRunID: "source", IdempotencyKey: strings.Repeat("x", 257), RequestedBy: "operator"},
} {
if _, err := bad.Fingerprint(); !errors.Is(err, contract.ErrInvalidArgument) {
t.Errorf("bad request returned %v", err)
}
}
}
func TestActionProgressAndReceiptValidation(t *testing.T) {
for _, progress := range []contract.ReplayProgress{
{DiscoveredCount: -1}, {DiscoveredCount: 1, EnqueuedCount: 2},
{DiscoveredCount: 2, EnqueuedCount: 1, CleanedCount: 2}, {FailureCount: -1},
} {
if err := progress.Validate(); !errors.Is(err, contract.ErrInvalidArgument) {
t.Fatalf("bad progress %+v returned %v", progress, err)
}
}
now := time.Now().UTC()
action := contract.ReplayAction{ActionID: "action", SourceRunID: "source", TargetRunID: "target", RequestedBy: "operator", ReplayCycle: 1, Status: contract.ActionAccepted}
if err := action.Validate(); err != nil {
t.Fatal(err)
}
action.Status = contract.ActionCompleted
if err := action.Validate(); !errors.Is(err, contract.ErrInvalidArgument) {
t.Fatalf("completed action without receipt returned %v", err)
}
action.Receipt = &contract.ArchiveReceipt{Version: 1, Path: "receipt.json", Checksum: strings.Repeat("a", 64), ArchivedAt: now}
action.CompletedAt = &now
if err := action.Validate(); err != nil {
t.Fatal(err)
}
action.Receipt.Checksum = "not-a-checksum"
if err := action.Validate(); !errors.Is(err, contract.ErrInvalidArgument) {
t.Fatalf("bad receipt returned %v", err)
}
}
func TestActionWireFormatHidesInternalState(t *testing.T) {
action := contract.ReplayAction{
ActionID: "action", SourceRunID: "source", TargetRunID: "target", RequestedBy: "operator", ReplayCycle: 1,
Status: contract.ActionCleanupPending, IdempotencyKey: "secret", Fingerprint: "internal-fingerprint",
LeaseOwner: "worker", LeaseToken: "token", LeaseUntil: time.Now(), Revision: 9,
Progress: contract.ReplayProgress{ScanCursor: "opaque", DiscoveredCount: 10, EnqueuedCount: 8, CleanedCount: 7, FailureCount: 2},
}
data := mustJSON(t, action)
for _, hidden := range []string{"idempotency", "fingerprint", "lease", "revision", "secret", "token"} {
if strings.Contains(strings.ToLower(string(data)), hidden) {
t.Errorf("internal %s leaked: %s", hidden, data)
}
}
var decoded contract.ReplayAction
if err := json.Unmarshal(data, &decoded); err != nil || !reflect.DeepEqual(decoded.Progress, action.Progress) || decoded.Status != contract.ActionCleanupPending {
t.Fatalf("action roundtrip lost state: %+v, %v", decoded, err)
}
statusURL, err := contract.ActionStatusURL("action%1")
if err != nil || statusURL != "/internal/v1/full-sync-actions/action%251" {
t.Fatalf("status URL=%s, %v", statusURL, err)
}
accepted := contract.ReplayAccepted{ActionID: "action", TargetRunID: "target", Status: contract.ActionAccepted, StatusURL: "/internal/v1/full-sync-actions/action"}
if got := string(mustJSON(t, accepted)); got != `{"actionId":"action","targetRunId":"target","status":"accepted","statusUrl":"/internal/v1/full-sync-actions/action"}` {
t.Fatalf("202 contract changed: %s", got)
}
}
@@ -0,0 +1,146 @@
package contract
import (
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
"time"
)
// RunMeta retains every existing queue metadata field and JSON tag. The
// optional fields are additive; zero extension fields are legal on old runs.
type RunMeta struct {
SupplyObservation *supply.Snapshot `json:"supplyObservation,omitempty"`
RunID string `json:"runId"`
Operation string `json:"operation"`
QueryMonth string `json:"queryMonth"`
Status RunStatus `json:"status"`
ProducerDone bool `json:"producerDone"`
CurrentRound int `json:"currentRound"`
ActiveCount int64 `json:"activeCount"`
InflightCount int64 `json:"inflightCount"`
NextRoundCount int64 `json:"nextRoundCount"`
SuccessCount int64 `json:"successCount"`
FailedCount int64 `json:"failedCount"`
CreatedAt time.Time `json:"createdAt"`
ProducerCompletedAt time.Time `json:"producerCompletedAt"`
CompletedAt time.Time `json:"completedAt"`
SyncType SyncType `json:"syncType,omitempty"`
SourceRunID string `json:"sourceRunId,omitempty"`
ReplayCycle int `json:"replayCycle,omitempty"`
TriggerSource string `json:"triggerSource,omitempty"`
ScannedCount int64 `json:"scannedCount,omitempty"`
EnqueuedCount int64 `json:"enqueuedCount,omitempty"`
UpstreamAttemptCount int64 `json:"upstreamAttemptCount,omitempty"`
BatchCount int64 `json:"batchCount,omitempty"`
MaxRound int `json:"maxRound,omitempty"`
PendingFailureCount int64 `json:"pendingFailureCount,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
ScanCompletedAt *time.Time `json:"scanCompletedAt,omitempty"`
ArchiveStatus ArchiveStatus `json:"archiveStatus,omitempty"`
ArchiveVersion int `json:"archiveVersion,omitempty"`
ArchivePath string `json:"archivePath,omitempty"`
ArchiveChecksum string `json:"archiveChecksum,omitempty"`
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
ReplayDisposition ReplayDisposition `json:"replayDisposition,omitempty"`
// Scope fields mirror zset.RunMeta. ScopeChannels == 0 means a full sync.
ScopeChannels int `json:"scopeChannels,omitempty"`
ScopeDigest string `json:"scopeDigest,omitempty"`
ScopeChannelsSample string `json:"scopeChannelsSample,omitempty"`
RoundStats []RoundStats `json:"rounds,omitempty"`
}
func (m RunMeta) Validate() error {
if err := ValidateID(m.RunID); err != nil {
return err
}
if m.Status != "" && !m.Status.Valid() {
return invalid("unknown run status")
}
if m.SyncType != "" && !m.SyncType.Valid() {
return invalid("unknown sync type")
}
if m.ArchiveStatus != "" && !m.ArchiveStatus.Valid() {
return invalid("unknown archive status")
}
if m.ReplayDisposition != "" && !m.ReplayDisposition.Valid() {
return invalid("unknown replay disposition")
}
if m.SourceRunID != "" {
if err := ValidateID(m.SourceRunID); err != nil {
return err
}
if m.SourceRunID == m.RunID {
return invalid("a run cannot replay itself")
}
}
if m.SyncType == SyncReplay && (m.SourceRunID == "" || m.ReplayCycle < 1) {
return invalid("replay requires sourceRunId and a positive replayCycle")
}
if m.SyncType == SyncNormal && (m.SourceRunID != "" || m.ReplayCycle != 0) {
return invalid("normal runs cannot have replay provenance")
}
if m.MaxRound > 0 && m.CurrentRound > m.MaxRound {
return invalid("currentRound exceeds maxRound")
}
return nonnegative(int64(m.CurrentRound), int64(m.MaxRound), int64(m.ReplayCycle), int64(m.ArchiveVersion), m.ActiveCount, m.InflightCount, m.NextRoundCount, m.SuccessCount, m.FailedCount, m.ScannedCount, m.EnqueuedCount, m.UpstreamAttemptCount, m.BatchCount, m.PendingFailureCount, int64(m.ScopeChannels))
}
// ValidateAccounting is opt-in for runs whose extended counters are known.
// Do not apply it to legacy metadata with a missing enqueuedCount. Inflight is
// a subset of active and is never added to the completion equation.
func (m RunMeta) ValidateAccounting() error {
if err := m.Validate(); err != nil {
return err
}
remaining := m.EnqueuedCount
for _, count := range []int64{m.SuccessCount, m.FailedCount, m.ActiveCount, m.NextRoundCount} {
if count > remaining {
return invalid("enqueuedCount does not balance run outcomes")
}
remaining -= count
}
if remaining != 0 || m.InflightCount > m.ActiveCount || m.PendingFailureCount > m.FailedCount {
return invalid("inconsistent run counters")
}
if m.Status.Terminal() && (!m.ProducerDone || m.ActiveCount != 0 || m.NextRoundCount != 0) {
return invalid("terminal run still has producer or queue work")
}
return nil
}
// RoundStats contains only bounded aggregate fields. It must never grow with
// the number of cards, channels, task IDs, upstream codes, or error messages.
type RoundStats struct {
RunID string `json:"runId"`
Round int `json:"round"`
EnqueuedCount int64 `json:"enqueuedCount"`
ActiveCount int64 `json:"activeCount"`
NextRoundCount int64 `json:"nextRoundCount"`
SuccessCount int64 `json:"successCount"`
FailedCount int64 `json:"failedCount"`
UpstreamAttemptCount int64 `json:"upstreamAttemptCount"`
BatchCount int64 `json:"batchCount"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
ErrorCounts ErrorCounts `json:"errorCounts"`
}
func (s RoundStats) Validate() error {
if err := ValidateID(s.RunID); err != nil {
return err
}
if s.Round < 1 {
return invalid("round must be positive")
}
if err := nonnegative(s.EnqueuedCount, s.ActiveCount, s.NextRoundCount, s.SuccessCount, s.FailedCount, s.UpstreamAttemptCount, s.BatchCount); err != nil {
return err
}
_, err := s.ErrorCounts.Normalized()
return err
}
type RunView struct {
Source RunSource `json:"source"`
Run RunMeta `json:"run"`
Rounds []RoundStats `json:"rounds,omitempty"`
}

Some files were not shown because too many files have changed in this diff Show More