From 5299c13c1a085b49951b7e4c3e67890240436af1 Mon Sep 17 00:00:00 2001 From: MiFaZhan Date: Thu, 29 Oct 2026 14:25:38 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20=E5=90=8C=E6=AD=A5=20OneLink=20?= =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E6=9C=8D=E5=8A=A1=20v1.0.4=20=E5=B9=B6?= =?UTF-8?q?=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 +--- apifox-openapi.yaml | 16 ++-- .../2026-07-13-sqlite-generated-cards.md | 88 +++++++++++++++++++ internal/service/chan_api_test.go | 6 +- internal/service/diagnosis_test.go | 4 +- internal/service/generator.go | 28 +----- internal/service/operation_log_test.go | 2 +- internal/service/store_test.go | 2 +- scripts/publish-images.ps1 | 64 ++++++++++++++ 9 files changed, 170 insertions(+), 58 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-13-sqlite-generated-cards.md create mode 100644 scripts/publish-images.ps1 diff --git a/README.md b/README.md index 29ed456..f8bbbc7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -# iotlcm-onelink-mock +# OneLink 挡板服务 -OneLink 挡板服务 —— 基于《中移物联网物联卡能力开放调用参考文档 V26.01.1》实现的本地测试替身。 - -用于在没有真实运营商环境时,模拟中国移动 OneLink 的鉴权与卡查询接口,支撑联调、自测与 CI。 +基于《中移物联网物联卡能力开放调用参考文档 V26.01.1》实现的本地挡板服务。 ## 功能 @@ -234,15 +232,3 @@ GET /api/orders?page=1&pageSize=20 ``` Orders are persisted in the SQLite `mock_order` table and retain all nine fields after a service restart. Existing `mock_t_sim_card` data is preserved. - -## 部署 - -本地开发用 `docker compose up -d --build` 即可(见上文「Docker 启动」)。若需要通过 Nginx 以子路径方式挂到已有站点,参考 [`docs/deploy.md`](./docs/deploy.md)。 - -## 相关项目 - -本服务是 [iotlcm](https://git.5iot.com/iotlcm-public) 物联网卡全生命周期管理平台的一部分,用于在没有真实运营商通道时提供可预期的上游替身。 - -## License - -[Apache License 2.0](./LICENSE) diff --git a/apifox-openapi.yaml b/apifox-openapi.yaml index 1b6f4b1..a08719a 100644 --- a/apifox-openapi.yaml +++ b/apifox-openapi.yaml @@ -623,7 +623,7 @@ paths: required: false description: Comma-separated MSISDNs to export. If omitted, all cards are exported. schema: { type: string } - example: "1440292201313,138****8000" + example: "1440292201313,14700004176" responses: "200": description: Excel file binary. @@ -867,7 +867,7 @@ paths: - simSessionList: - apnId: CMIOT status: "01" - ip: 192.0.2.42 + ip: 127.0.0.1 ipv6Prefix: "" ipv6: "" createDate: "2026-06-06 14:00:00" @@ -1438,7 +1438,7 @@ components: description: Underscore-separated SIM identifiers. For batch endpoints, provide exactly one of msisdns, iccids, or imsis. schema: type: string - example: "1440292201313_138****8000" + example: "1440292201313_14700004176" Iccids: name: iccids in: query @@ -1526,7 +1526,7 @@ components: - chanKey: channel_yd_mock iccid: "8986040000000000001" imsi: "460040000000000001" - msisdn: "138****8000" + msisdn: "14100000001" reqData: {} ChanSimKey: type: object @@ -1534,7 +1534,7 @@ components: chanKey: { type: string, example: channel_yd_mock } iccid: { type: string, example: "8986040000000000001" } imsi: { type: string, example: "460040000000000001" } - msisdn: { type: string, example: "138****8000" } + msisdn: { type: string, example: "14100000001" } ChanResponseList: type: array items: @@ -1641,7 +1641,7 @@ components: example: "01" ip: type: string - example: 192.0.2.42 + example: 127.0.0.1 ipv6Prefix: type: string example: "" @@ -2001,7 +2001,7 @@ components: example: "2026-06-06 14:00:00" ip: type: string - example: 192.0.2.42 + example: 127.0.0.1 ipv6Prefix: type: string example: "" @@ -2213,7 +2213,7 @@ components: onOffStatus: "1" sessionStatus: "01" sessionCreateDate: "2026-06-06 14:00:00" - ip: 192.0.2.42 + ip: 127.0.0.1 ipv6Prefix: "" ipv6: "" rat: "6" diff --git a/docs/superpowers/plans/2026-07-13-sqlite-generated-cards.md b/docs/superpowers/plans/2026-07-13-sqlite-generated-cards.md new file mode 100644 index 0000000..5349b9b --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-sqlite-generated-cards.md @@ -0,0 +1,88 @@ +# SQLite Generated Cards Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the MySQL-backed card source with a local SQLite-backed mock data generator, fixed traffic values, and Excel export. + +**Architecture:** The service will use SQLite as the runtime persistence layer and keep the existing in-memory `Store` shape for OneLink handlers. The admin sync action becomes card generation: it appends generated rows to SQLite and refreshes memory. Excel export maps stored card rows to the inbound workbook column format. + +**Tech Stack:** Go `net/http`, `database/sql`, SQLite driver, Vue 3, Vite, simple XLSX generation via Go standard library zip/XML. + +--- + +### Task 1: Lock Generated Card Rules With Tests + +**Files:** +- Test: `internal/app/generator_test.go` +- Create: `internal/app/generator.go` + +- [ ] Write tests that generated cards use timestamp-related unique identifiers, fixed monthly traffic `18432 KB`, APN `CMIOT`, and export metadata matching the confirmed sample. +- [ ] Run `go test ./internal/app -run TestGenerated -count=1` and verify it fails because the generator does not exist. +- [ ] Implement the minimal generator API and constants. +- [ ] Run the focused test and verify it passes. + +### Task 2: Replace MySQL Backend With SQLite Backend + +**Files:** +- Modify: `go.mod` +- Modify: `internal/app/store_file.go` +- Create/modify: `internal/app/store_sqlite.go` +- Modify: `internal/app/store.go` +- Test: `internal/app/store_test.go` + +- [ ] Add tests that `NewStoreManager` no longer requires `BAFFLE_DB_DSN`, loads from SQLite, and appends generated cards without deleting existing rows. +- [ ] Run focused tests and verify failure. +- [ ] Implement SQLite schema creation, load, save, delete, and append generation behavior. +- [ ] Remove runtime database reload from `Snapshot()` so normal reads do not hit external DB logic. +- [ ] Run focused tests and verify pass. + +### Task 3: Admin API And Excel Export + +**Files:** +- Modify: `internal/app/admin.go` +- Create: `internal/app/export_xlsx.go` +- Test: `internal/app/admin_test.go` or existing app tests + +- [ ] Add tests for `POST /api/generate` with `count`, and `GET /api/cards/export` returning XLSX bytes with the confirmed headers. +- [ ] Run focused tests and verify failure. +- [ ] Implement generate and export handlers. +- [ ] Run focused tests and verify pass. + +### Task 4: Remove Traffic Growth From Runtime + +**Files:** +- Modify: `internal/app/store.go` +- Modify: `internal/app/model.go` +- Modify: `internal/app/admin.go` +- Test: `internal/app/store_test.go` + +- [ ] Add or update tests to prove snapshot does not increment traffic over time. +- [ ] Remove `applyGrowthLocked()` calls and UI-facing settings behavior. +- [ ] Keep fixed traffic values in generated card records. +- [ ] Run focused tests and verify pass. + +### Task 5: Frontend Update + +**Files:** +- Modify: `frontend/src/main.js` +- Modify: `frontend/src/style.css` + +- [ ] Replace "一键同步" with "一键生成". +- [ ] Add generation count input. +- [ ] Call `POST /api/generate`. +- [ ] Add export button linking/downloading from `/api/cards/export`. +- [ ] Remove traffic growth controls and growth column. +- [ ] Run `npm run build` in `frontend`. + +### Task 6: Documentation And Verification + +**Files:** +- Modify: `README.md` +- Modify: `docker-compose.yaml` +- Modify: `docker-compose.yaml` if needed + +- [ ] Update docs to describe SQLite, generation, export, and fixed traffic. +- [ ] Remove MySQL DSN instructions. +- [ ] Run `go test ./...` with a writable `GOCACHE`. +- [ ] Run frontend build. +- [ ] Check `git diff` for unintended changes. diff --git a/internal/service/chan_api_test.go b/internal/service/chan_api_test.go index 43bd3f8..244a301 100644 --- a/internal/service/chan_api_test.go +++ b/internal/service/chan_api_test.go @@ -9,7 +9,7 @@ import ( ) func TestChanQueryEndpoints(t *testing.T) { - service, err := NewService(t.TempDir() + "/onelink_mock.db") + service, err := NewService(t.TempDir()+"/onelink_mock.db") if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func TestChanQueryEndpoints(t *testing.T) { } func TestChanBatchAndQueryStringCompatibility(t *testing.T) { - service, err := NewService(t.TempDir() + "/onelink_mock.db") + service, err := NewService(t.TempDir()+"/onelink_mock.db") if err != nil { t.Fatal(err) } @@ -85,7 +85,7 @@ func TestChanBatchAndQueryStringCompatibility(t *testing.T) { } func TestChanChangesPersistAndCreateOrders(t *testing.T) { - service, err := NewService(t.TempDir() + "/onelink_mock.db") + service, err := NewService(t.TempDir()+"/onelink_mock.db") if err != nil { t.Fatal(err) } diff --git a/internal/service/diagnosis_test.go b/internal/service/diagnosis_test.go index 7c1885d..726988f 100644 --- a/internal/service/diagnosis_test.go +++ b/internal/service/diagnosis_test.go @@ -10,7 +10,7 @@ import ( ) func TestOneLinkDataDiagnosisUsesConfiguredAPNAndPCC(t *testing.T) { - service, err := NewService(t.TempDir() + "/mock.db") + service, err := NewService(t.TempDir()+"/mock.db") if err != nil { t.Fatal(err) } @@ -56,7 +56,7 @@ func TestOneLinkDataDiagnosisUsesConfiguredAPNAndPCC(t *testing.T) { } func TestCardBindStatusRequiresMSISDNAndTestType(t *testing.T) { - service, err := NewService(t.TempDir() + "/mock.db") + service, err := NewService(t.TempDir()+"/mock.db") if err != nil { t.Fatal(err) } diff --git a/internal/service/generator.go b/internal/service/generator.go index 39625d3..562875b 100644 --- a/internal/service/generator.go +++ b/internal/service/generator.go @@ -3,7 +3,6 @@ package service import ( "fmt" "strconv" - "sync" "time" ) @@ -51,31 +50,6 @@ var inboundExportHeaders = func() []string { return headers }() -// 生成序号分配器。 -// -// 序号直接来自毫秒时间戳时,同一毫秒内的两次生成会得到完全相同的 seq, -// 进而生成重复的 ICCID/IMSI,触发 mock_t_sim_card 的唯一约束冲突 -// (表现为「一键生成」连点两次或两个分组连续生成时报 -// UNIQUE constraint failed: mock_t_sim_card.imsi)。 -// -// 这里用进程内单调递增的上界保证 seq 严格递增:时间戳只决定起点, -// 真正的唯一性由 nextGeneratedSeq 保证。 -var ( - generatedSeqMu sync.Mutex - generatedSeqLast int64 -) - -// nextGeneratedSeq 返回严格大于上一次分配值的序号。 -func nextGeneratedSeq(candidate int64) int64 { - generatedSeqMu.Lock() - defer generatedSeqMu.Unlock() - if candidate <= generatedSeqLast { - candidate = generatedSeqLast + 1 - } - generatedSeqLast = candidate - return candidate -} - func GenerateSIMCards(batchTime time.Time, count int) []SIMCard { if count <= 0 { return []SIMCard{} @@ -84,7 +58,7 @@ func GenerateSIMCards(batchTime time.Time, count int) []SIMCard { base := batchTime.UnixMilli() dbTime := batchTime.Format("2006-01-02 15:04:05") for i := 0; i < count; i++ { - seq := nextGeneratedSeq(base + int64(i+1)) + seq := base + int64(i+1) card := SIMCard{ MSISDN: fmt.Sprintf("141%08d", seq%100000000), ICCID: fmt.Sprintf("898604%014d", seq%100000000000000), diff --git a/internal/service/operation_log_test.go b/internal/service/operation_log_test.go index 0d4242a..ace82b5 100644 --- a/internal/service/operation_log_test.go +++ b/internal/service/operation_log_test.go @@ -9,7 +9,7 @@ import ( ) func TestOperationLogCapturesAdminMutationAndRedactsSecrets(t *testing.T) { - service, err := NewService(t.TempDir() + "/onelink_mock.db") + service, err := NewService(t.TempDir()+"/onelink_mock.db") if err != nil { t.Fatal(err) } diff --git a/internal/service/store_test.go b/internal/service/store_test.go index 6664225..e109239 100644 --- a/internal/service/store_test.go +++ b/internal/service/store_test.go @@ -50,7 +50,7 @@ func TestOrdersPersistAcrossStoreRestart(t *testing.T) { t.Fatal(err) } want := Order{ - OrderNum: "ORD202607270001", MSISDN: "138****8000", ICCID: "8986000000000000001", + OrderNum: "ORD202607270001", MSISDN: "14100000001", ICCID: "8986000000000000001", IMSI: "460000000000001", Type: "sim-apn-function", OperType: "1", APNName: "CMIOT", Status: "completed", CreatedAt: "2026-07-27 16:00:00", } diff --git a/scripts/publish-images.ps1 b/scripts/publish-images.ps1 new file mode 100644 index 0000000..fcbd8a2 --- /dev/null +++ b/scripts/publish-images.ps1 @@ -0,0 +1,64 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Registry, + [string]$Namespace = 'iotlcm', + [string]$Tag = 'latest', + [string]$Username = $env:REGISTRY_USERNAME +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$Password = $env:REGISTRY_PASSWORD + +if (-not [string]::IsNullOrWhiteSpace($Username) -and [string]::IsNullOrWhiteSpace($Password)) { + throw 'REGISTRY_PASSWORD is required when REGISTRY_USERNAME is set.' +} + +function Invoke-Docker { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + & docker @Arguments + if ($LASTEXITCODE -ne 0) { + throw "docker $($Arguments -join ' ') failed with exit code $LASTEXITCODE" + } +} + +$backendImage = "$Registry/$Namespace/onelink-mock-backend:$Tag" +$frontendImage = "$Registry/$Namespace/onelink-mock-frontend:$Tag" + +Push-Location $ProjectRoot +try { + if (-not [string]::IsNullOrWhiteSpace($Username)) { + Write-Host "Logging in to $Registry..." + $Password | & docker login $Registry --username $Username --password-stdin + if ($LASTEXITCODE -ne 0) { + throw "docker login failed with exit code $LASTEXITCODE" + } + } + + Write-Host "Building backend image: $backendImage" + Invoke-Docker @('build', '-t', $backendImage, '.') + + Write-Host "Pushing backend image: $backendImage" + Invoke-Docker @('push', $backendImage) + + Push-Location (Join-Path $ProjectRoot 'frontend') + try { + Write-Host "Building frontend image: $frontendImage" + Invoke-Docker @('build', '-t', $frontendImage, '.') + + Write-Host "Pushing frontend image: $frontendImage" + Invoke-Docker @('push', $frontendImage) + } finally { + Pop-Location + } + + Write-Host 'Image build and push completed.' -ForegroundColor Green + Write-Host " $backendImage" + Write-Host " $frontendImage" +} finally { + Pop-Location +}