sync: 同步 OneLink 模拟服务 v1.0.4 并脱敏
This commit is contained in:
@@ -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)
|
||||
|
||||
+8
-8
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user