chore: 初始开源发布

iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

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

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
MiFaZhan
2026-10-22 16:37:56 +08:00
commit 2bec257289
361 changed files with 71524 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
package unsub
import (
"context"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
)
const (
cascadeFailureReason = "same batch has invalid unsubscribe rows; this row was skipped"
validationSkipReason = "batch validation failed; unsub request was skipped"
emptyICCIDReason = "ICCID cannot be empty"
duplicateICCIDReason = "duplicate ICCID in batch"
)
type executor struct {
repository Repository
}
func NewExecutor(repository Repository) Executor {
return &executor{repository: repository}
}
func (e *executor) Execute(ctx context.Context, batchNo string) error {
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
if err != nil {
return gerror.Wrap(err, "load unsub batch failed")
}
if !validateTask(task) {
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
return gerror.Wrap(err, "save unsub validation failure failed")
}
e.saveResultFile(ctx, task)
return nil
}
if err = e.repository.CommitBusiness(ctx, task); err != nil {
return gerror.Wrap(err, "commit unsub business failed")
}
packagecustomer.PublishSyncForDeptsSafe(ctx, task.Batch.TenantID, task.Batch.CreatedBy, unsubPackageSyncDeptIDs(task), "unsub", task.Batch.BatchNo)
e.saveResultFile(ctx, task)
return nil
}
func unsubPackageSyncDeptIDs(task *Task) []int64 {
if task == nil {
return nil
}
deptIds := []int64{task.Batch.DeptID, task.Batch.CreatedDept}
for _, detail := range task.Details {
if detail == nil || strings.TrimSpace(detail.Reason) != "" {
continue
}
if detail.CardDeptID > 0 {
deptIds = append(deptIds, detail.CardDeptID)
}
}
return deptIds
}
func (e *executor) saveResultFile(ctx context.Context, task *Task) {
request := batchresult.Request{
BatchNo: task.Batch.BatchNo,
BatchType: task.Batch.BatchType,
TenantID: task.Batch.TenantID,
Rows: task.ResultRows(),
}
if err := e.repository.SaveResultFile(ctx, task, request); err != nil {
g.Log().Errorf(ctx, "save unsub result file status failed, batchNo:%s, err:%+v", task.Batch.BatchNo, err)
}
}
func validateTask(task *Task) bool {
if task == nil || len(task.Details) == 0 {
if task != nil {
task.FailureReason = "unsub batch has no valid details"
}
return false
}
hasFailure := false
iccids := make(map[string]int, len(task.Details))
for _, detail := range task.Details {
if detail == nil {
hasFailure = true
continue
}
if detail.Reason != "" {
hasFailure = true
continue
}
iccid := strings.TrimSpace(detail.ICCID)
if iccid == "" {
detail.Reason = emptyICCIDReason
hasFailure = true
continue
}
iccids[iccid]++
}
for _, detail := range task.Details {
if detail == nil {
continue
}
if detail.Reason != "" {
continue
}
if iccids[strings.TrimSpace(detail.ICCID)] > 1 {
detail.Reason = duplicateICCIDReason
hasFailure = true
}
}
if !hasFailure {
return true
}
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
detail.Reason = cascadeFailureReason
}
}
task.FailureReason = validationSkipReason
return false
}
+195
View File
@@ -0,0 +1,195 @@
package unsub
import (
"context"
"errors"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
)
func TestValidateTaskFailsWhenAnyDetailHasPreExistingErrorReason(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-1"},
Details: []*Detail{
{RowNo: 1, ICCID: "8986"},
{RowNo: 2, ICCID: "8987", Reason: "snapshot has reason"},
},
}
if validateTask(task) {
t.Fatal("expected validation to fail when any detail has a pre-existing reason")
}
if task.Details[0].Reason != cascadeFailureReason {
t.Fatalf("expected cascade reason on clean detail, got %q", task.Details[0].Reason)
}
if task.FailureReason == "" {
t.Fatal("expected task failure reason to be populated")
}
}
func TestValidateTaskFailsOnEmptyICCID(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-2"},
Details: []*Detail{
{RowNo: 1, ICCID: ""},
},
}
if validateTask(task) {
t.Fatal("expected empty ICCID to fail validation")
}
if task.Details[0].Reason != emptyICCIDReason {
t.Fatalf("expected empty ICCID reason, got %q", task.Details[0].Reason)
}
}
func TestValidateTaskFailsOnDuplicateICCID(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-3"},
Details: []*Detail{
{RowNo: 1, ICCID: "8986"},
{RowNo: 2, ICCID: "8986"},
},
}
if validateTask(task) {
t.Fatal("expected duplicate ICCID to fail validation")
}
if task.Details[0].Reason != duplicateICCIDReason || task.Details[1].Reason != duplicateICCIDReason {
t.Fatalf("expected both duplicates flagged, got %q / %q", task.Details[0].Reason, task.Details[1].Reason)
}
}
func TestValidateTaskPassesOnCleanDetails(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-4"},
Details: []*Detail{
{RowNo: 1, ICCID: "8986"},
{RowNo: 2, ICCID: "8987"},
},
}
if !validateTask(task) {
t.Fatal("expected validation to pass for clean details")
}
}
func TestExecuteHappyPathCommitsAndSavesResult(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-5", BatchType: 108, TenantID: "000000"},
Details: []*Detail{
{RowNo: 1, ICCID: "8986"},
},
}
repo := &fakeRepository{task: task}
if err := NewExecutor(repo).Execute(context.Background(), "B-5"); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !repo.committed {
t.Fatal("expected business to be committed")
}
if !repo.resultSaved {
t.Fatal("expected result file to be saved")
}
if repo.validationSaved {
t.Fatal("did not expect validation failure path on happy path")
}
}
func TestExecuteValidationFailureSkipsCommit(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-6", BatchType: 108},
Details: []*Detail{
{RowNo: 1, ICCID: "8986"},
{RowNo: 2, ICCID: "8987", Reason: "snapshot error"},
},
}
repo := &fakeRepository{task: task}
if err := NewExecutor(repo).Execute(context.Background(), "B-6"); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !repo.validationSaved {
t.Fatal("expected validation failure to be persisted")
}
if repo.committed {
t.Fatal("did not expect business to be committed when validation fails")
}
if !repo.resultSaved {
t.Fatal("expected result file to be saved even when validation fails")
}
}
func TestExecuteCommitFailurePropagatesAndSkipsResult(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-7", BatchType: 108},
Details: []*Detail{
{RowNo: 1, ICCID: "8986"},
},
}
commitErr := errors.New("transaction failed")
repo := &fakeRepository{task: task, commitErr: commitErr}
err := NewExecutor(repo).Execute(context.Background(), "B-7")
if err == nil || !errors.Is(err, commitErr) {
t.Fatalf("Execute() error = %v, want wrap of %v", err, commitErr)
}
if repo.validationSaved {
t.Fatal("validation path should not run when commit failure happens before it")
}
if repo.resultSaved {
t.Fatal("result file should not be saved when commit fails")
}
}
func TestExecuteResultFileFailureOnlyMarksBatchInfo(t *testing.T) {
task := &Task{
Batch: Batch{BatchNo: "B-8", BatchType: 108, TenantID: "000000"},
Details: []*Detail{
{RowNo: 1, ICCID: "8986"},
},
}
repo := &fakeRepository{task: task, resultErr: errors.New("disk full")}
if err := NewExecutor(repo).Execute(context.Background(), "B-8"); err != nil {
t.Fatalf("Execute() error = %v, want nil when only result file fails", err)
}
if !repo.committed {
t.Fatal("expected business to be committed before result file failure")
}
if !repo.resultSaved {
t.Fatal("expected result file save attempt to be recorded")
}
}
type fakeRepository struct {
task *Task
committed bool
commitErr error
validationSaved bool
resultSaved bool
resultErr error
loadErr error
loadCalls int
saveCalls int
resultRequest batchresult.Request
}
func (r *fakeRepository) Load(_ context.Context, _ string) (*Task, error) {
r.loadCalls++
if r.loadErr != nil {
return nil, r.loadErr
}
return r.task, nil
}
func (r *fakeRepository) SaveValidationFailure(_ context.Context, _ *Task) error {
r.validationSaved = true
return nil
}
func (r *fakeRepository) CommitBusiness(_ context.Context, _ *Task) error {
r.committed = true
return r.commitErr
}
func (r *fakeRepository) SaveResultFile(_ context.Context, _ *Task, request batchresult.Request) error {
r.saveCalls++
r.resultRequest = request
r.resultSaved = true
return r.resultErr
}
+142
View File
@@ -0,0 +1,142 @@
package unsub
import (
"context"
"strings"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
)
type Batch struct {
ID int64
BatchNo string
BatchType int
TenantID string
DeptID int64
CreatedBy int64
CreatedDept int64
StartTime *gtime.Time
BatchInfo *gjson.Json
SourceBatchNo string
Remark string
}
type Detail struct {
ID int64
RowNo int
ICCID string
MSISDN string
IMSI string
Reason string
Authorized bool
Remark string
SourceBatchNo string
SubscriptionNo string
OrderCode string
PackageCode string
PackageType int
EffectiveTime string
ExpectedExpireTime string
ActualExpireTime string
ReturnedToStock bool
StockDeptID int64
CardDeptID int64
StockBatchNo string
}
type Task struct {
Batch Batch
Details []*Detail
FailureReason string
}
type OrderRefund struct {
Amount float64
SerialNo string
Status string
Reason string
}
type Repository interface {
Load(ctx context.Context, batchNo string) (*Task, error)
SaveValidationFailure(ctx context.Context, task *Task) error
CommitBusiness(ctx context.Context, task *Task) error
SaveResultFile(ctx context.Context, task *Task, request batchresult.Request) error
}
type Executor interface {
Execute(ctx context.Context, batchNo string) error
}
func (d *Detail) Snapshot() *gjson.Json {
if d == nil {
return gjson.New(nil)
}
return gjson.New(map[string]any{
"rowNo": d.RowNo,
"ICCID": d.ICCID,
"MSISDN": d.MSISDN,
"IMSI": d.IMSI,
"authorized": d.Authorized,
"remark": d.Remark,
"sourceBatchNo": d.SourceBatchNo,
"subscriptionNo": d.SubscriptionNo,
"orderCode": d.OrderCode,
"packageCode": d.PackageCode,
"packageType": d.PackageType,
"effectiveTime": d.EffectiveTime,
"expectedExpireTime": d.ExpectedExpireTime,
"actualExpireTime": d.ActualExpireTime,
"returnedToStock": d.ReturnedToStock,
"stockDeptId": d.StockDeptID,
"cardDeptId": d.CardDeptID,
"stockBatchNo": d.StockBatchNo,
"errorReason": d.Reason,
})
}
func (t *Task) ResultRows() []batchresult.Row {
rows := make([]batchresult.Row, 0, len(t.Details))
for _, detail := range t.Details {
if detail == nil {
continue
}
rows = append(rows, batchresult.Row{
RowNo: detail.RowNo,
ICCID: detail.ICCID,
MSISDN: detail.MSISDN,
IMSI: detail.IMSI,
Success: detail.Reason == "",
Reason: detail.Reason,
})
}
return rows
}
func trimSnapshotString(value any) string {
return strings.TrimSpace(toStringValue(value))
}
func toStringValue(value any) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
return v
case *string:
if v == nil {
return ""
}
return *v
case []byte:
return string(v)
default:
return ""
}
}