chore: 初始开源发布

iotlcm-onelink-mock:OneLink 挡板服务(本地测试替身)。

- Go module 迁移至 git.5iot.com/iotlcm-public/iotlcm-onelink-mock
- 修复同毫秒内多次生成卡导致 ICCID/IMSI 唯一约束冲突
- 补 Apache-2.0 LICENSE,README 重写并新增 docs/deploy.md
- 移除内部部署资产(CI 触发器 / 镜像推送脚本 / 指定环境部署文档)
- 测试凭据改为 demo_appid / demo_password,真实手机号与内网 IP 脱敏
- 统一 LF 行尾并加 .gitattributes
This commit is contained in:
MiFaZhan
2026-10-22 15:08:27 +08:00
commit 4e2cd43a55
61 changed files with 10417 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
.git
.idea
.run
.workbuddy
cmd
data
deploy
docs
frontend
hack
logs
scripts
tmp
build
onelink-baffle.exe
onelink-baffle-linux
*.db
*.log
.env
.env.*
apifox-openapi.yaml
docker-compose.yaml
README.md
+13
View File
@@ -0,0 +1,13 @@
# 统一使用 LF,避免跨平台协作时产生整文件 diff
* text=auto eol=lf
# 二进制文件不做行尾转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.pdf binary
*.exe binary
+34
View File
@@ -0,0 +1,34 @@
*.exe
*.exe~
onelink-baffle-linux
build/
*.log
*~
*.swp
*.swo
data/
logs/
*.db-wal
*.db-shm
*.sqlite-wal
*.sqlite-shm
.env
.env.*
!.env.example
coverage.out
coverage.html
frontend/node_modules/
frontend/.vite/
frontend/dist/
.DS_Store
Thumbs.db
.run/
.workbuddy/
.idea/
tmp/
+25
View File
@@ -0,0 +1,25 @@
FROM golang:1.24-alpine
WORKDIR /app
ENV GOPROXY=https://goproxy.cn,direct
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
COPY internal ./internal
COPY manifest ./manifest
RUN sed -i 's|https://dl-cdn.alpinelinux.org|https://mirrors.aliyun.com|g' /etc/apk/repositories && \
apk add --no-cache sqlite
RUN go build -o server .
RUN mkdir -p /app/data /app/logs
ENV GF_GCFG_FILE=/app/manifest/config/config.yaml
ENV BAFFLE_ADDR=:8080
ENV BAFFLE_SQLITE_PATH=/app/data/onelink_mock.db
EXPOSE 8080
CMD ["./server"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+248
View File
@@ -0,0 +1,248 @@
# iotlcm-onelink-mock
OneLink 挡板服务 —— 基于《中移物联网物联卡能力开放调用参考文档 V26.01.1》实现的本地测试替身。
用于在没有真实运营商环境时,模拟中国移动 OneLink 的鉴权与卡查询接口,支撑联调、自测与 CI。
## 功能
- Go 后端,兼容 `/v5/ec/...` 和 `/ec/...` 两种路径。
- Vue3 管理台,管理多个 `appid/password`、SIM 卡基础信息、状态和固定流量参数。
- Token 有效期 3600 秒,支持 `refresh=0/1`。
- 不再连接 MySQL,也不再查询 `t_sim_card_info`。
- SIM 卡数据由后端一键生成,持久化到本地 SQLite 表 `mock_t_sim_card`。
- 月流量固定为 `18432 KB`,不再按时间自动增长。
- 支持按入库模板导出 Excel。
## 数据源
服务使用 SQLite 数据库作为唯一持久化数据源。默认数据库路径:
```text
data/onelink_mock.db
```
可以通过环境变量覆盖:
```text
BAFFLE_SQLITE_PATH=C:\sqlite\onelink_mock.db
```
后端通过 GoFrame SQLite 驱动直接访问数据库,不依赖本机安装 `sqlite3` 命令行工具。
Docker 镜像内会安装 `sqlite` 命令行工具。
## 一键生成
管理台顶部可输入生成数量,点击“一键生成”后追加新卡数据。
生成逻辑:
- 不删除旧卡。
- 不查询旧卡做重复对比。
- ICCID/MSISDN/IMSI/IMEI 与生成时间戳和序号相关,降低重复概率。
- 删除卡只在管理台删卡或业务删除逻辑触发时发生。
固定生成值:
```text
chanKey=channel_yd_mock
cardType=1
upPkgCode=CMIOT-SD-500M-24M-D01
suppliersCode=SUP_CMCC
insertToPlugEnabled=true
carrierTrafficCutoffEnabled=false
hasTestPeriod=true
testPeriodDays=30
hasSilentPeriod=false
silentPeriodDays=
silentPeriodLatestExpiryDate=
```
流量固定值:
```text
current_month_usage_kb=18432
monthly_usage_kb=18432
daily_usage_kb=614.4
pool_usage_bytes=18874368
growth_per_minute_kb=0
auto_growth_enabled=false
```
## Excel 导出
管理台点击“导出 Excel”会下载当前卡数据,表头顺序为:
```text
ICCID
MSISDN
IMSI
IMEI
通道编号
卡类型
上游套餐编码
供应商编码
上游激活时间
是否贴片转插拔
是否运营商达量断网
协议到期时间
是否有测试期
测试期天数
测试期最晚过期日期
是否有沉默期
沉默期天数
沉默期最晚过期日期
```
其中:
- `协议到期时间` 为激活日期后 1 年。
- `测试期最晚过期日期` 为激活日期后 30 天。
- `是否有沉默期=false` 时,沉默期天数和截止日期导出为空。
## 已实现接口
- `ec/get/token`
- `ec/query/sim-basic-info`
- `ec/query/sim-session`
- `ec/query/sim-status`
- `ec/query/sim-stop-reason`
- `ec/query/sim-frozen-state-info`
- `ec/query/sim-apn-session-info`
- `ec/query/sim-activated-features-info`
- `ec/query/sim-operating-status`
- `ec/query/sim-data-diagnosis`
- `ec/query/sim-imei`
- `ec/query/sim-data-usage-inpool`
- `ec/query/sim-data-usage`
- `ec/query/sim-data-usage-monthly/batch`
- `ec/query/sim-data-usage-daily/batch`
- `ec/query/apn-info`
- `ec/query/card-bind-status`
- `ec/query/on-off-status`
- `ec/query/region-limit-status`
- `ec/change/sim-status`
- `ec/operate/sim-apn-function`
Chan-compatible unified APIs (GET and POST):
- `GET|POST /sim/query/sim-basic-info`
- `GET|POST /sim/query/sim-session`
- `GET|POST /sim/query/sim-status`
- `GET|POST /sim/query/sim-status-batch`
- `GET|POST /sim/query/sim-card-bind-status`
- `GET|POST /sim/query/sim-device-status`
- `GET|POST /sim/query/sim-region-limit-status`
- `GET|POST /sim/query/sim-imei`
- `GET|POST /sim/query/sim-datausage`
- `GET|POST /sim/query/sim-datausage-monthly`
- `GET|POST /sim/query/sim-datausage-monthly-batch`
- `GET|POST /sim/query/sim-datausage-daily`
- `GET|POST /sim/query/sim-datausage-daily-batch`
- `GET|POST /sim/query/sim-apn-info`
- `GET|POST /sim/change/sim-status`
- `GET|POST /sim/change/sim-apn`
Chan requests use `simKeys` and `reqData`; responses are per-SIM arrays with
`code`, `message`, `simKey`, and `resData`. The chan endpoints reuse the same
SQLite SIM cards as the OneLink endpoints.
管理接口:
- `GET /api/state`
- `POST /api/generate`
- `GET /api/cards/export`
- `POST /api/appids`
- `POST /api/cards`
- `DELETE /api/cards`
## 环境变量
| 变量 | 必填 | 说明 |
| --- | --- | --- |
| `BAFFLE_ADDR` | 否 | 监听地址,默认 `:8080` |
| `BAFFLE_SQLITE_PATH` | 否 | SQLite 数据库路径,默认 `data/onelink_mock.db` |
## 本地启动
### 1. 启动后端
PowerShell:
```powershell
$env:BAFFLE_ADDR=":8080"
$env:BAFFLE_SQLITE_PATH="C:\sqlite\onelink_mock.db"
go run .
```
后端默认监听:
```text
http://127.0.0.1:8080
```
### 2. 启动前端
```powershell
cd frontend
pnpm install --frozen-lockfile
pnpm run dev
```
前端 dev server 默认监听:
```text
http://127.0.0.1:5173/
```
## Docker 启动
```powershell
docker compose up -d --build
```
访问地址:
```text
http://127.0.0.1:18080/onelink-mock/
```
停止:
```powershell
docker compose down
```
## 默认测试认证
```text
appid=demo_appid
password=demo_password
```
获取 Token:
```text
http://localhost:8080/v5/ec/get/token?appid=demo_appid&password=demo_password&transid=demo_appid2026070209300000000001
```
## Admin query APIs
```text
GET /api/cards?keyword=<msisdn-or-iccid-or-imsi>&page=1&pageSize=20
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)
+2251
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
services:
backend:
build: .
restart: unless-stopped
environment:
BAFFLE_ADDR: ":8080"
BAFFLE_SQLITE_PATH: /app/data/onelink_mock.db
volumes:
- backend-data:/app/data
- backend-logs:/app/logs
networks:
- onelink-mock
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "127.0.0.1:18080:80"
depends_on:
- backend
networks:
- onelink-mock
volumes:
backend-data:
backend-logs:
networks:
onelink-mock:
+66
View File
@@ -0,0 +1,66 @@
# 部署说明
## Docker Compose(推荐)
```bash
docker compose up -d --build
```
- 后端:容器内 `:8080`
- 前端:`http://127.0.0.1:18080/onelink-mock/`
- 数据:命名卷 `backend-data`(SQLite)与 `backend-logs`
停止与清理:
```bash
docker compose down # 停容器,保留数据
docker compose down -v # 连数据卷一起删除
```
## 镜像
镜像未预置到公共仓库时,用本地构建:
```bash
docker build -t iotlcm-onelink-mock-backend .
docker build -t iotlcm-onelink-mock-frontend ./frontend
```
推到自己的镜像仓库后,在 compose 里用 `image:` 覆盖 `build:` 即可。**建议使用不可变标签**(commit sha)而不是 `latest`。
## 用 Nginx 以子路径挂载
服务前端默认以 `/onelink-mock/` 为前缀访问。挂到已有 Nginx 站点下时,用一个 `location` 转发到前端容器:
```nginx
location = /onelink-mock {
return 301 /onelink-mock/;
}
location /onelink-mock/ {
proxy_pass http://127.0.0.1:18080;
proxy_http_version 1.1;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## 环境变量
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `BAFFLE_ADDR` | `:8080` | 后端监听地址 |
| `BAFFLE_SQLITE_PATH` | `data/onelink_mock.db` | SQLite 数据文件路径 |
| `GF_GCFG_FILE` | `manifest/config/config.yaml` | 配置文件路径(容器内为 `/app/manifest/config/config.yaml`) |
## 数据持久化
卡数据、appid 凭据、订单与操作日志全部落在 SQLite 单文件里。备份直接拷这个文件即可:
```bash
docker compose exec backend sh -c 'sqlite3 /app/data/onelink_mock.db ".backup /app/data/backup.db"'
```
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
.dockerignore
Dockerfile
*.md
.env
.env.*
.vscode
.idea
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine AS builder
WORKDIR /app
ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
RUN corepack enable \
&& corepack prepare pnpm@11.0.9 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm config set registry https://registry.npmmirror.com \
&& pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build -- --base=/onelink-mock/
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OneLink 挡板服务</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /onelink-mock {
return 301 /onelink-mock/;
}
location /onelink-mock/assets/ {
alias /usr/share/nginx/html/assets/;
}
location /onelink-mock/api/ {
rewrite ^/onelink-mock/(.*)$ /$1 break;
proxy_pass http://backend:8080;
}
location /onelink-mock/v5/ {
rewrite ^/onelink-mock/(.*)$ /$1 break;
proxy_pass http://backend:8080;
}
location /onelink-mock/ec/ {
rewrite ^/onelink-mock/(.*)$ /$1 break;
proxy_pass http://backend:8080;
}
location /onelink-mock/healthz {
rewrite ^/onelink-mock/(.*)$ /$1 break;
proxy_pass http://backend:8080;
}
location /onelink-mock/ {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8080;
}
location /v5/ {
proxy_pass http://backend:8080;
}
location /ec/ {
proxy_pass http://backend:8080;
}
location /healthz {
proxy_pass http://backend:8080;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "onelink-baffle-frontend",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@11.0.9",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"vite": "^7.2.7",
"vue": "^3.5.25",
"lucide-vue-next": "^0.561.0"
},
"devDependencies": {}
}
+856
View File
@@ -0,0 +1,856 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@vitejs/plugin-vue':
specifier: ^6.0.2
version: 6.0.8(vite@7.3.6)(vue@3.5.41)
lucide-vue-next:
specifier: ^0.561.0
version: 0.561.0(vue@3.5.41)
vite:
specifier: ^7.2.7
version: 7.3.6
vue:
specifier: ^3.5.25
version: 3.5.41
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rollup/rollup-android-arm-eabi@4.63.0':
resolution: {integrity: sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.63.0':
resolution: {integrity: sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.63.0':
resolution: {integrity: sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.63.0':
resolution: {integrity: sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.63.0':
resolution: {integrity: sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.63.0':
resolution: {integrity: sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.63.0':
resolution: {integrity: sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.63.0':
resolution: {integrity: sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.63.0':
resolution: {integrity: sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.63.0':
resolution: {integrity: sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.63.0':
resolution: {integrity: sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.63.0':
resolution: {integrity: sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.63.0':
resolution: {integrity: sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.63.0':
resolution: {integrity: sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.63.0':
resolution: {integrity: sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.63.0':
resolution: {integrity: sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.63.0':
resolution: {integrity: sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.63.0':
resolution: {integrity: sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.63.0':
resolution: {integrity: sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.63.0':
resolution: {integrity: sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.63.0':
resolution: {integrity: sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.63.0':
resolution: {integrity: sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.63.0':
resolution: {integrity: sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.63.0':
resolution: {integrity: sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.63.0':
resolution: {integrity: sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@vitejs/plugin-vue@6.0.8':
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@vue/compiler-core@3.5.41':
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
'@vue/compiler-dom@3.5.41':
resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==}
'@vue/compiler-sfc@3.5.41':
resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==}
'@vue/compiler-ssr@3.5.41':
resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==}
'@vue/reactivity@3.5.41':
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
'@vue/runtime-core@3.5.41':
resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==}
'@vue/runtime-dom@3.5.41':
resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==}
'@vue/server-renderer@3.5.41':
resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==}
'@vue/shared@3.5.41':
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
lucide-vue-next@0.561.0:
resolution: {integrity: sha512-c5HUckO0qHklVSOf/0vaSR3pEb8fYImRDCRDLde56uqS9js0D/e3RAvq0/YFWjkmyOBKCb0/IdskdoHZQEkT5g==}
peerDependencies:
vue: '>=3.0.1'
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.63.0:
resolution: {integrity: sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vue@3.5.41:
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@rollup/rollup-android-arm-eabi@4.63.0':
optional: true
'@rollup/rollup-android-arm64@4.63.0':
optional: true
'@rollup/rollup-darwin-arm64@4.63.0':
optional: true
'@rollup/rollup-darwin-x64@4.63.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.63.0':
optional: true
'@rollup/rollup-freebsd-x64@4.63.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.63.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.63.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.63.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.63.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.63.0':
optional: true
'@rollup/rollup-linux-loong64-musl@4.63.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.63.0':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.63.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.63.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.63.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.63.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.63.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.63.0':
optional: true
'@rollup/rollup-openbsd-x64@4.63.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.63.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.63.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.63.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.63.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.63.0':
optional: true
'@types/estree@1.0.9': {}
'@vitejs/plugin-vue@6.0.8(vite@7.3.6)(vue@3.5.41)':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 7.3.6
vue: 3.5.41
'@vue/compiler-core@3.5.41':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.41
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.41':
dependencies:
'@vue/compiler-core': 3.5.41
'@vue/shared': 3.5.41
'@vue/compiler-sfc@3.5.41':
dependencies:
'@babel/parser': 7.29.8
'@vue/compiler-core': 3.5.41
'@vue/compiler-dom': 3.5.41
'@vue/compiler-ssr': 3.5.41
'@vue/shared': 3.5.41
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.26
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.41':
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/reactivity@3.5.41':
dependencies:
'@vue/shared': 3.5.41
'@vue/runtime-core@3.5.41':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/shared': 3.5.41
'@vue/runtime-dom@3.5.41':
dependencies:
'@vue/reactivity': 3.5.41
'@vue/runtime-core': 3.5.41
'@vue/shared': 3.5.41
csstype: 3.2.3
'@vue/server-renderer@3.5.41':
dependencies:
'@vue/compiler-ssr': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/shared': 3.5.41
'@vue/shared@3.5.41': {}
csstype@3.2.3: {}
entities@7.0.1: {}
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fsevents@2.3.3:
optional: true
lucide-vue-next@0.561.0(vue@3.5.41):
dependencies:
vue: 3.5.41
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.18: {}
picocolors@1.1.1: {}
picomatch@4.0.7: {}
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.63.0:
dependencies:
'@types/estree': 1.0.9
optionalDependencies:
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
'@rollup/rollup-android-arm-eabi': 4.63.0
'@rollup/rollup-android-arm64': 4.63.0
'@rollup/rollup-darwin-arm64': 4.63.0
'@rollup/rollup-darwin-x64': 4.63.0
'@rollup/rollup-freebsd-arm64': 4.63.0
'@rollup/rollup-freebsd-x64': 4.63.0
'@rollup/rollup-linux-arm-gnueabihf': 4.63.0
'@rollup/rollup-linux-arm-musleabihf': 4.63.0
'@rollup/rollup-linux-arm64-gnu': 4.63.0
'@rollup/rollup-linux-arm64-musl': 4.63.0
'@rollup/rollup-linux-loong64-gnu': 4.63.0
'@rollup/rollup-linux-loong64-musl': 4.63.0
'@rollup/rollup-linux-ppc64-gnu': 4.63.0
'@rollup/rollup-linux-ppc64-musl': 4.63.0
'@rollup/rollup-linux-riscv64-gnu': 4.63.0
'@rollup/rollup-linux-riscv64-musl': 4.63.0
'@rollup/rollup-linux-s390x-gnu': 4.63.0
'@rollup/rollup-linux-x64-gnu': 4.63.0
'@rollup/rollup-linux-x64-musl': 4.63.0
'@rollup/rollup-openbsd-x64': 4.63.0
'@rollup/rollup-openharmony-arm64': 4.63.0
'@rollup/rollup-win32-arm64-msvc': 4.63.0
'@rollup/rollup-win32-ia32-msvc': 4.63.0
'@rollup/rollup-win32-x64-gnu': 4.63.0
'@rollup/rollup-win32-x64-msvc': 4.63.0
fsevents: 2.3.3
source-map-js@1.2.1: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
vite@7.3.6:
dependencies:
esbuild: 0.28.2
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
postcss: 8.5.26
rollup: 4.63.0
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
vue@3.5.41:
dependencies:
'@vue/compiler-dom': 3.5.41
'@vue/compiler-sfc': 3.5.41
'@vue/runtime-dom': 3.5.41
'@vue/server-renderer': 3.5.41
'@vue/shared': 3.5.41
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+548
View File
@@ -0,0 +1,548 @@
import { createApp, computed, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js'
import { ArrowLeft, BadgePlus, ClipboardList, Copy, CreditCard, Database, Download, Eye, FolderPlus, KeyRound, Plus, RefreshCw, Save, Search, Server, Trash2, X } from 'lucide-vue-next'
import { onBeforeUnmount } from 'vue'
import './style.css'
const appBase = normalizeBase(import.meta.env.BASE_URL)
const statusText = {
'1': '待激活',
'2': '已激活',
'4': '停机',
'6': '可测试',
'7': '库存',
'8': '预销户'
}
const detailLabels = {
msisdn: 'MSISDN', iccid: 'ICCID', imsi: 'IMSI', imei: 'IMEI', remark: '备注',
cardStatus: '卡状态', lastChangeDate: '最后变更时间', stopReason: '停机原因码',
frozenStatus: '管理冻结', frozenReason: '冻结原因', frozenOperTime: '冻结操作时间',
openDate: '开卡时间', activeDate: '首次激活时间', onOffStatus: '终端开关机',
sessionStatus: '会话状态', sessionCreateDate: '会话建立时间', ip: 'IPv4', ipv6Prefix: 'IPv6 前缀',
ipv6: 'IPv6 接口 ID', rat: '接入制式', cardBindStatus: '机卡绑定', regionLimitStatus: '区域限制',
areaRestrictType: '区域限制方式', restrictedArea: '限制区域', monthUsageKB: '当月流量 KB',
dailyBaseKB: '日基础流量 KB', monthlyBaseKB: '月基础流量 KB', lastUsageUpdateTime: '流量更新时间'
}
function detailLabel(key) { return detailLabels[key] || key }
function cardStatusLabel(value) { return statusText[value] || value || '-' }
function boolLabel(value) { return value === '1' || value === 1 || value === true ? '是' : '否' }
function apnDiagnosisStatusLabel(value) { return ({ '1': '未激活', '2': '激活', '4': '暂停' })[value] || value || '-' }
function apnServiceUsageLabel(value) { return value === '1' || !value ? '正常速率' : `限速档位 ${value}` }
const endpointGroups = [
{
key: 'mobile-core', label: '移动基础接口', items: [
['ec/query/sim-basic-info', '单卡基础信息'], ['ec/query/sim-session', '单卡会话信息'], ['ec/query/sim-imei', '查询 IMEI'],
['ec/query/sim-data-usage-inpool', '查询流量池用量'], ['ec/query/sim-data-usage', '查询流量用量'],
['ec/query/sim-data-usage-monthly/batch', '批量查询月流量'], ['ec/query/sim-data-usage-daily/batch', '批量查询日流量'],
['ec/query/apn-info', '查询 APN'], ['ec/change/sim-status', '变更 SIM 状态'], ['ec/operate/sim-apn-function', '操作 APN']
]
},
{
key: 'mobile-diagnosis', label: '移动智能诊断', items: [
['ec/query/sim-status', 'SIM 卡状态'], ['ec/query/sim-stop-reason', '停机原因'], ['ec/query/sim-frozen-state-info', '管理冻结状态'],
['ec/query/sim-apn-session-info', 'APN 在线会话'], ['ec/query/sim-activated-features-info', '通信功能状态'],
['ec/query/sim-operating-status', '开关机状态'], ['ec/query/sim-data-diagnosis', '达量限速档位'],
['ec/query/region-limit-status', '区域限制状态'], ['ec/query/card-bind-status', '机卡绑定状态'], ['ec/query/on-off-status', '设备开关机状态']
]
},
{
key: 'chan', label: 'Chan 统一接口', items: [
['sim/query/sim-basic-info', '单卡基础信息'], ['sim/query/sim-session', '单卡会话信息'], ['sim/query/sim-status', 'SIM 卡状态'],
['sim/query/sim-status-batch', '批量 SIM 卡状态'], ['sim/query/sim-card-bind-status', '机卡绑定状态'], ['sim/query/sim-device-status', '设备状态'],
['sim/query/sim-region-limit-status', '区域限制状态'], ['sim/query/sim-imei', '查询 IMEI'], ['sim/query/sim-datausage', '查询流量'],
['sim/query/sim-datausage-monthly', '查询月流量'], ['sim/query/sim-datausage-monthly-batch', '批量查询月流量'],
['sim/query/sim-datausage-daily', '查询日流量'], ['sim/query/sim-datausage-daily-batch', '批量查询日流量'],
['sim/query/sim-apn-info', '查询 APN'], ['sim/change/sim-status', '变更 SIM 状态'], ['sim/change/sim-apn', '变更 APN']
]
}
]
const endpointCatalog = endpointGroups.flatMap(group => group.items.map(([path, label]) => ({ path, label, group: group.key })))
const App = {
components: { ArrowLeft, BadgePlus, ClipboardList, Copy, CreditCard, Database, Download, Eye, FolderPlus, KeyRound, Plus, RefreshCw, Save, Search, Server, Trash2, X },
setup() {
const state = reactive({ appids: [], groups: [], cards: [], orders: [], settings: {} })
const message = ref('')
const appForm = reactive({ appid: '', password: '', name: '' })
const cardForm = reactive(emptyCard())
const selectedCategory = ref('mobile-core')
const selectedEndpoint = ref('ec/query/sim-basic-info')
const generateCount = ref(100)
const selectedGroupId = ref(0)
const groupForm = reactive({ name: '' })
const isGroupEditorOpen = ref(false)
const groupSaving = ref(false)
const selectedCards = reactive(new Set())
const cardKeyword = ref('')
const cardGroupFilter = ref('')
const cardExportStatusFilter = ref('')
const cardPage = reactive({ page: 1, pageSize: 20, total: 0 })
const orderPage = reactive({ page: 1, pageSize: 20, total: 0 })
const cardJumpPage = ref(1)
const orderJumpPage = ref(1)
const cardTotalPages = computed(() => Math.max(1, Math.ceil(cardPage.total / cardPage.pageSize)))
const orderTotalPages = computed(() => Math.max(1, Math.ceil(orderPage.total / orderPage.pageSize)))
const selectedCardDetail = ref(null)
const selectedOrderDetail = ref(null)
const isCardEditorOpen = ref(false)
const cardEditorMode = ref('create')
const cardSaving = ref(false)
const currentView = ref(viewFromPath())
const operationLogs = ref([])
const operationLogPage = reactive({ page: 1, pageSize: 20, total: 0 })
const operationLogKeyword = ref('')
const operationLogCategory = ref('')
const operationLogResult = ref('')
const selectedOperationLog = ref(null)
const operationLogTotalPages = computed(() => Math.max(1, Math.ceil(operationLogPage.total / operationLogPage.pageSize)))
const visibleEndpoints = computed(() => endpointCatalog.filter(item => item.group === selectedCategory.value))
const currentEndpoint = computed(() => endpointCatalog.find(item => item.path === selectedEndpoint.value) || endpointCatalog[0])
const isChanEndpoint = computed(() => selectedEndpoint.value.startsWith('sim/'))
const endpointMethod = computed(() => isChanEndpoint.value ? 'POST' : 'GET')
const baseUrl = computed(() => `${location.origin}${appBase}/v5/`)
const sampleCard = computed(() => state.cards[0] || { msisdn: '1440292201313' })
const selectedCount = computed(() => selectedCards.size)
const allCardsSelected = computed(() => state.cards.length > 0 && state.cards.every(card => selectedCards.has(card.msisdn)))
const firstTokenUrl = computed(() => {
const app = state.appids[0] || { appid: 'demo_appid', password: 'demo_password' }
return `${baseUrl.value}ec/get/token?appid=${app.appid}&password=${app.password}&transid=${transid(app.appid)}`
})
const endpointUrl = computed(() => {
if (isChanEndpoint.value) {
return `${location.origin}${appBase}/${selectedEndpoint.value}`
}
const token = state.appids[0]?.lastToken || '<token>'
const params = new URLSearchParams({ transid: transid(state.appids[0]?.appid || 'appid'), token })
if (selectedEndpoint.value.includes('/batch')) {
params.set('msisdns', state.cards.map(c => c.msisdn).slice(0, 3).join('_') || sampleCard.value.msisdn)
params.set('queryDate', selectedEndpoint.value.includes('monthly') ? monthText() : yesterday())
} else if (selectedEndpoint.value.includes('card-bind-status')) {
params.set('msisdn', sampleCard.value.msisdn)
params.set('testType', '0')
} else if (selectedEndpoint.value.includes('change/sim-status')) {
params.set('msisdn', sampleCard.value.msisdn)
params.set('operType', '0')
} else if (selectedEndpoint.value.includes('operate/sim-apn-function')) {
params.set('msisdn', sampleCard.value.msisdn)
params.set('apnName', 'CMIOT')
params.set('operType', '1')
} else {
params.set('msisdn', sampleCard.value.msisdn)
}
return `${baseUrl.value}${selectedEndpoint.value}?${params.toString()}`
})
const endpointBody = computed(() => {
if (!isChanEndpoint.value) return ''
const cards = state.cards.slice(0, 3)
const simKeys = (cards.length ? cards : [sampleCard.value]).map(card => ({
chanKey: 'channel_yd_mock',
iccid: card.iccid || '',
imsi: card.imsi || '',
msisdn: card.msisdn || ''
}))
const reqData = {}
if (selectedEndpoint.value.includes('monthly')) reqData.queryDate = monthText()
if (selectedEndpoint.value.includes('daily')) reqData.queryDate = yesterday()
if (selectedEndpoint.value === 'sim/change/sim-status') reqData.operType = '0'
if (selectedEndpoint.value === 'sim/change/sim-apn') Object.assign(reqData, { apnName: 'CMIOT', operType: '0' })
return JSON.stringify({ simKeys, reqData }, null, 2)
})
const endpointHeaders = computed(() => '')
function selectCategory() {
const first = visibleEndpoints.value[0]
if (first) selectedEndpoint.value = first.path
}
async function load() {
const [snapshot, groups, cards, orders, logs] = await Promise.all([request('/api/state'), request('/api/card-groups'), loadCards(), loadOrders(), loadOperationLogs(1, 1)])
Object.assign(state, snapshot, { groups, cards: cards.items, orders: orders.items })
Object.assign(cardPage, { page: cards.page, pageSize: cards.pageSize, total: cards.total })
Object.assign(orderPage, { page: orders.page, pageSize: orders.pageSize, total: orders.total })
cardJumpPage.value = cards.page
orderJumpPage.value = orders.page
operationLogPage.total = logs.total
for (const msisdn of Array.from(selectedCards)) {
if (!state.cards.some(card => card.msisdn === msisdn)) selectedCards.delete(msisdn)
}
}
function loadOperationLogs(page = operationLogPage.page, pageSize = operationLogPage.pageSize) {
const params = new URLSearchParams({ page, pageSize })
if (operationLogKeyword.value.trim()) params.set('keyword', operationLogKeyword.value.trim())
if (operationLogCategory.value) params.set('category', operationLogCategory.value)
if (operationLogResult.value) params.set('result', operationLogResult.value)
return request(`/api/operation-logs?${params.toString()}`)
}
async function refreshOperationLogs() {
const result = await loadOperationLogs()
operationLogs.value = result.items
Object.assign(operationLogPage, { page: result.page, pageSize: result.pageSize, total: result.total })
}
async function searchOperationLogs() {
operationLogPage.page = 1
await refreshOperationLogs()
}
async function clearOperationLogSearch() {
operationLogKeyword.value = ''
operationLogCategory.value = ''
operationLogResult.value = ''
await searchOperationLogs()
}
async function changeOperationLogPage(page) {
operationLogPage.page = page
await refreshOperationLogs()
}
async function changeOperationLogPageSize(pageSize) {
operationLogPage.pageSize = Number(pageSize)
operationLogPage.page = 1
await refreshOperationLogs()
}
async function navigate(view) {
const path = view === 'operation-logs' ? withBase('/operation-logs') : (appBase || '/')
history.pushState({}, '', path)
currentView.value = view
if (view === 'operation-logs') await refreshOperationLogs()
}
function handlePopState() {
currentView.value = viewFromPath()
if (currentView.value === 'operation-logs') refreshOperationLogs()
}
function scrollToOrders() {
document.getElementById('recent-orders')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function loadCards() {
const params = new URLSearchParams({ page: cardPage.page, pageSize: cardPage.pageSize })
appendCardFilters(params)
return request(`/api/cards?${params.toString()}`)
}
function appendCardFilters(params) {
if (cardKeyword.value.trim()) params.set('keyword', cardKeyword.value.trim())
if (cardGroupFilter.value !== '') params.set('groupId', cardGroupFilter.value)
if (cardExportStatusFilter.value) params.set('exportStatus', cardExportStatusFilter.value)
}
function loadOrders() { return request(`/api/orders?page=${orderPage.page}&pageSize=${orderPage.pageSize}`) }
async function searchCards() { cardPage.page = 1; await searchCardsPage() }
async function clearCardSearch() { cardKeyword.value = ''; cardGroupFilter.value = ''; cardExportStatusFilter.value = ''; await searchCards() }
async function filterCardsByGroup() { cardPage.page = 1; await searchCardsPage() }
async function changeCardPage(nextPage) { cardPage.page = nextPage; await searchCardsPage() }
async function changeCardPageSize(pageSize) { cardPage.pageSize = Number(pageSize); cardPage.page = 1; await searchCardsPage() }
async function searchCardsPage() {
const cards = await loadCards()
state.cards = cards.items
Object.assign(cardPage, { page: cards.page, pageSize: cards.pageSize, total: cards.total })
cardJumpPage.value = cards.page
}
async function changeOrderPage(nextPage) { orderPage.page = nextPage; await refreshOrders() }
async function changeOrderPageSize(pageSize) { orderPage.pageSize = Number(pageSize); orderPage.page = 1; await refreshOrders() }
async function refreshOrders() {
const orders = await loadOrders()
state.orders = orders.items
Object.assign(orderPage, { page: orders.page, pageSize: orders.pageSize, total: orders.total })
orderJumpPage.value = orders.page
}
async function goToCardPage() {
let p = Number(cardJumpPage.value)
if (!p || p < 1) p = 1
if (p > cardTotalPages.value) p = cardTotalPages.value
cardJumpPage.value = p
if (p !== cardPage.page) {
cardPage.page = p
await searchCardsPage()
}
}
async function goToOrderPage() {
let p = Number(orderJumpPage.value)
if (!p || p < 1) p = 1
if (p > orderTotalPages.value) p = orderTotalPages.value
orderJumpPage.value = p
if (p !== orderPage.page) {
orderPage.page = p
await refreshOrders()
}
}
function goCardFirst() { changeCardPage(1) }
function goCardLast() { changeCardPage(cardTotalPages.value) }
function goOrderFirst() { changeOrderPage(1) }
function goOrderLast() { changeOrderPage(orderTotalPages.value) }
async function generateCards() {
const result = await request('/api/generate', { method: 'POST', body: { count: generateCount.value, groupId: selectedGroupId.value || null } })
await load()
const group = state.groups.find(item => item.id === selectedGroupId.value)
toast(`已生成 ${result.generated} 张卡${group ? `,已放入“${group.name}”` : '(未分组)'},共 ${result.total} 张`)
}
function openGroupEditor() { groupForm.name = ''; isGroupEditorOpen.value = true }
async function createGroup() {
groupSaving.value = true
try {
const group = await request('/api/card-groups', { method: 'POST', body: groupForm })
state.groups.push(group)
selectedGroupId.value = group.id
isGroupEditorOpen.value = false
} finally { groupSaving.value = false }
}
async function exportCards() {
if (!selectedCards.size) {
toast('请选择要导出的卡')
return
}
const params = new URLSearchParams({ msisdns: Array.from(selectedCards).join(',') })
await downloadCardExport(params, selectedCards.size)
}
async function exportFilteredCards() {
if (!cardPage.total) {
toast('当前筛选条件下没有可导出的卡')
return
}
const exportCount = cardPage.total
const params = new URLSearchParams({ selection: 'filtered' })
appendCardFilters(params)
await downloadCardExport(params, exportCount)
}
async function downloadCardExport(params, exportCount) {
const response = await fetch(withBase(`/api/cards/export?${params.toString()}`))
if (!response.ok) throw new Error(await response.text())
const downloadURL = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = downloadURL
link.download = 'sim_card_export.xlsx'
link.click()
URL.revokeObjectURL(downloadURL)
await searchCardsPage()
toast(`已导出 ${exportCount} 张卡`)
}
function toggleCard(msisdn) {
if (selectedCards.has(msisdn)) selectedCards.delete(msisdn)
else selectedCards.add(msisdn)
}
function toggleAllCards() {
if (allCardsSelected.value) {
selectedCards.clear()
} else {
state.cards.forEach(card => selectedCards.add(card.msisdn))
}
}
async function saveApp() {
await request('/api/appids', { method: 'POST', body: appForm })
Object.assign(appForm, { appid: '', password: '', name: '' })
await load()
toast('AppID 已保存')
}
async function deleteApp(appid) {
await request(`/api/appids?appid=${encodeURIComponent(appid)}`, { method: 'DELETE' })
await load()
}
function openCardEditor(card = null) {
cardEditorMode.value = card ? 'edit' : 'create'
Object.assign(cardForm, JSON.parse(JSON.stringify(card || emptyCard())))
isCardEditorOpen.value = true
}
async function saveCard() {
cardSaving.value = true
try {
await request('/api/cards', { method: 'POST', body: cardForm })
isCardEditorOpen.value = false
Object.assign(cardForm, emptyCard())
await load()
toast(cardEditorMode.value === 'edit' ? '卡数据已更新' : '卡数据已保存')
} finally { cardSaving.value = false }
}
async function deleteCard(msisdn) {
await request(`/api/cards?msisdn=${encodeURIComponent(msisdn)}`, { method: 'DELETE' })
await load()
}
async function copyText(value) {
if (value === null || value === undefined) {
toast('复制内容为空')
return
}
const text = String(value)
if (!text) {
toast('复制内容为空')
return
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
toast('已复制到剪贴板')
return
}
} catch (_) {}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'readonly')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
toast(ok ? '已复制到剪贴板' : '复制失败,请手动复制')
} catch (_) {
toast('复制失败,请手动复制')
}
}
async function request(path, options = {}) {
const init = { ...options, headers: { 'Content-Type': 'application/json' } }
if (options.body) init.body = JSON.stringify(options.body)
const res = await fetch(withBase(path), init)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
function toast(text) {
message.value = text
setTimeout(() => { if (message.value === text) message.value = '' }, 2200)
}
onMounted(async () => {
window.addEventListener('popstate', handlePopState)
if (currentView.value === 'operation-logs') await refreshOperationLogs()
else await load()
})
onBeforeUnmount(() => window.removeEventListener('popstate', handlePopState))
return { state, message, appForm, cardForm, selectedCategory, selectedEndpoint, endpointGroups, visibleEndpoints, currentEndpoint, firstTokenUrl, endpointUrl, endpointBody, endpointHeaders, endpointMethod, isChanEndpoint, statusText, detailLabel, cardStatusLabel, boolLabel, apnDiagnosisStatusLabel, apnServiceUsageLabel, generateCount, selectedGroupId, groupForm, isGroupEditorOpen, groupSaving, selectedCards, selectedCount, allCardsSelected, cardKeyword, cardGroupFilter, cardExportStatusFilter, cardPage, orderPage, cardJumpPage, orderJumpPage, cardTotalPages, orderTotalPages, selectedCardDetail, selectedOrderDetail, isCardEditorOpen, cardEditorMode, cardSaving, currentView, operationLogs, operationLogPage, operationLogKeyword, operationLogCategory, operationLogResult, operationLogTotalPages, selectedOperationLog, load, selectCategory, searchCards, clearCardSearch, filterCardsByGroup, changeCardPage, changeCardPageSize, changeOrderPage, changeOrderPageSize, goToCardPage, goToOrderPage, goCardFirst, goCardLast, goOrderFirst, goOrderLast, generateCards, openGroupEditor, createGroup, exportCards, exportFilteredCards, toggleCard, toggleAllCards, saveApp, deleteApp, openCardEditor, saveCard, deleteCard, copyText, navigate, scrollToOrders, searchOperationLogs, clearOperationLogSearch, changeOperationLogPage, changeOperationLogPageSize }
},
template: `
<main class="shell">
<div v-if="currentView === 'dashboard'">
<header class="topbar">
<div><p class="eyebrow">OneLink V25.11.1 CT Mock</p><h1>挡板服务管理台</h1></div>
<div class="top-actions">
<input v-model.number="generateCount" class="count-input" type="number" min="1" />
<select v-model.number="selectedGroupId" class="group-select"><option :value="0">未分组</option><option v-for="group in state.groups" :key="group.id" :value="group.id">{{ group.name }}</option></select>
<button class="ghost icon-only" @click="openGroupEditor" title="新建分组"><FolderPlus :size="16" /></button>
<button @click="generateCards"><RefreshCw :size="16" />一键生成</button>
<button class="ghost" @click="exportCards"><Download :size="16" />导出选中 {{ selectedCount }}</button>
<button class="ghost" :disabled="!cardPage.total" @click="exportFilteredCards"><Download :size="16" />导出筛选结果 {{ cardPage.total }}</button>
</div>
</header>
<section class="metrics">
<div><KeyRound :size="19" /><strong>{{ state.appids.length }}</strong><span>AppID</span></div>
<div><CreditCard :size="19" /><strong>{{ cardPage.total }}</strong><span>SIM 卡</span></div>
<div><Database :size="19" /><strong>18432</strong><span>固定月 KB</span></div>
<div class="metric-link" role="link" tabindex="0" @click="scrollToOrders" @keyup.enter="scrollToOrders"><Database :size="19" /><strong>{{ orderPage.total }}</strong><span>办理订单</span></div>
<div class="metric-link" role="link" tabindex="0" @click="navigate('operation-logs')" @keyup.enter="navigate('operation-logs')"><ClipboardList :size="19" /><strong>{{ operationLogPage.total }}</strong><span>操作日志</span></div>
</section>
<section class="band">
<div class="section-head"><div><h2>AppID 配置</h2><p>支持多个 appid/password,Token 有效期 3600 秒。</p></div><KeyRound :size="22" /></div>
<div class="form-grid app-grid">
<input v-model="appForm.appid" placeholder="appid" />
<input v-model="appForm.password" placeholder="password" />
<input v-model="appForm.name" placeholder="名称" />
<button @click="saveApp"><Plus :size="16" />保存</button>
</div>
<div class="table">
<div class="tr th"><span>名称</span><span>appid</span><span>最近 token</span><span></span></div>
<div class="tr" v-for="app in state.appids" :key="app.appid">
<span>{{ app.name }}</span><span>{{ app.appid }}</span><span class="mono">{{ app.lastToken || '-' }}</span>
<button class="ghost" @click="deleteApp(app.appid)" title="删除"><Trash2 :size="15" /></button>
</div>
</div>
</section>
<section class="band">
<div class="section-head"><div><h2>卡与流量数据</h2><p>配置码号、状态、APN、在线状态和固定月流量。</p></div><div class="section-head-actions"><button @click="openCardEditor()"><Plus :size="16" />新增卡</button><CreditCard :size="22" /></div></div>
<div class="search-row"><input v-model="cardKeyword" @keyup.enter="searchCards" placeholder="输入 MSISDN / ICCID / IMSI" /><select v-model="cardGroupFilter" @change="filterCardsByGroup"><option value="">全部分组</option><option value="0">未分组</option><option v-for="group in state.groups" :key="group.id" :value="String(group.id)">{{ group.name }}</option></select><select v-model="cardExportStatusFilter" @change="filterCardsByGroup"><option value="">全部导出状态</option><option value="exported">已导出</option><option value="not_exported">未导出</option></select><button @click="searchCards"><Search :size="16" />查询</button><button class="ghost icon-only" @click="clearCardSearch" title="清空查询"><X :size="16" /></button></div>
<div class="table cards">
<div class="tr th"><span><input class="select-box" type="checkbox" :checked="allCardsSelected" @change="toggleAllCards" /></span><span>MSISDN</span><span>ICCID</span><span>IMSI</span><span>分组</span><span>状态</span><span>导出状态</span><span>在线</span><span>当月 KB</span><span></span></div>
<div class="tr" v-for="card in state.cards" :key="card.msisdn">
<span><input class="select-box" type="checkbox" :checked="selectedCards.has(card.msisdn)" @change="toggleCard(card.msisdn)" /></span>
<span class="mono">{{ card.msisdn }}</span><span class="mono">{{ card.iccid }}</span><span class="mono">{{ card.imsi }}</span><span>{{ card.groupName || '未分组' }}</span><span>{{ statusText[card.cardStatus] || card.cardStatus }}</span><span><em class="export-status" :class="card.exported ? 'exported' : 'not-exported'">{{ card.exported ? '已导出' : '未导出' }}</em></span><span>{{ card.sessionStatus === '01' ? '在线' : '离线' }}</span><span>{{ Math.round(card.monthUsageKB) }}</span>
<span class="row-actions"><button class="ghost icon-only" @click="selectedCardDetail = card" title="详情"><Eye :size="15" /></button><button class="ghost" @click="openCardEditor(card)">编辑</button><button class="ghost icon-only" @click="deleteCard(card.msisdn)" title="删除"><Trash2 :size="15" /></button></span>
</div>
</div>
<div class="pager"><span>共 {{ cardPage.total }} 张卡</span><label class="page-size"><span>每页</span><select :value="cardPage.pageSize" @change="changeCardPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="cardPage.page <= 1" @click="goCardFirst">首页</button><button class="ghost" :disabled="cardPage.page <= 1" @click="changeCardPage(cardPage.page - 1)">上一页</button><span class="page-jump">第 <input class="page-input" type="number" min="1" :max="cardTotalPages" v-model.number="cardJumpPage" @keyup.enter="goToCardPage" /> 页 / 共 {{ cardTotalPages }} 页</span><button class="ghost" :disabled="cardPage.page >= cardTotalPages" @click="changeCardPage(cardPage.page + 1)">下一页</button><button class="ghost" :disabled="cardPage.page >= cardTotalPages" @click="goCardLast">尾页</button><button class="ghost" @click="goToCardPage">跳转</button></div>
</section>
<section class="split single">
<div class="band">
<div class="section-head"><div><h2>接口调试</h2><p>支持移动 OneLink 与 Chan 统一接口。</p></div><Server :size="22" /></div>
<div class="endpoint-categories" role="tablist" aria-label="接口分类">
<button v-for="group in endpointGroups" :key="group.key" class="endpoint-category" :class="{ active: selectedCategory === group.key }" @click="selectedCategory = group.key; selectCategory()">{{ group.label }}<span>{{ group.items.length }}</span></button>
</div>
<label class="field" v-if="selectedCategory.startsWith('mobile-')"><span>Token 获取</span><div class="copy-field"><input class="mono" :value="firstTokenUrl" readonly /><button class="ghost icon-only" @click="copyText(firstTokenUrl)" title="复制 Token 地址"><Copy :size="16" /></button></div></label>
<label class="field"><span>业务接口({{ endpointMethod }})</span><select class="endpoint-select" v-model="selectedEndpoint"><option v-for="item in visibleEndpoints" :key="item.path" :value="item.path">{{ item.label }} · {{ item.path }}</option></select></label>
<div class="copy-field"><input class="mono" :value="endpointUrl" readonly /><button class="ghost icon-only" @click="copyText(endpointUrl)" title="复制业务接口地址"><Copy :size="16" /></button></div>
<label class="field endpoint-detail" v-if="endpointHeaders"><span>请求 Header</span><div class="copy-field"><textarea class="mono endpoint-body compact" :value="endpointHeaders" readonly></textarea><button class="ghost icon-only" @click="copyText(endpointHeaders)" title="复制请求 Header"><Copy :size="16" /></button></div></label>
<label class="field endpoint-detail" v-if="endpointBody"><span>JSON 请求体</span><div class="copy-field"><textarea class="mono endpoint-body" :value="endpointBody" readonly></textarea><button class="ghost icon-only" @click="copyText(endpointBody)" title="复制 JSON 请求体"><Copy :size="16" /></button></div></label>
</div>
</section>
<section class="band" id="recent-orders">
<div class="section-head"><div><h2>最近订单</h2><p>状态变更与 APN 开停会生成 orderNum。</p></div><BadgePlus :size="22" /></div>
<div class="empty" v-if="!state.orders.length">暂无订单</div>
<div class="table orders" v-else>
<div class="tr th"><span>订单号</span><span>类型</span><span>操作</span><span>MSISDN</span><span>状态</span><span>创建时间</span><span></span></div>
<div class="tr order-row" v-for="order in state.orders" :key="order.orderNum">
<span class="mono">{{ order.orderNum }}</span><span>{{ order.type }}</span><span>{{ order.operType || '-' }}</span><span class="mono">{{ order.msisdn }}</span><span>{{ order.status }}</span><span>{{ order.createdAt }}</span><button class="ghost icon-only" @click="selectedOrderDetail = order" title="详情"><Eye :size="15" /></button>
</div>
</div>
<div class="pager" v-if="state.orders.length"><span>共 {{ orderPage.total }} 条订单</span><label class="page-size"><span>每页</span><select :value="orderPage.pageSize" @change="changeOrderPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="orderPage.page <= 1" @click="goOrderFirst">首页</button><button class="ghost" :disabled="orderPage.page <= 1" @click="changeOrderPage(orderPage.page - 1)">上一页</button><span class="page-jump">第 <input class="page-input" type="number" min="1" :max="orderTotalPages" v-model.number="orderJumpPage" @keyup.enter="goToOrderPage" /> 页 / 共 {{ orderTotalPages }} 页</span><button class="ghost" :disabled="orderPage.page >= orderTotalPages" @click="changeOrderPage(orderPage.page + 1)">下一页</button><button class="ghost" :disabled="orderPage.page >= orderTotalPages" @click="goOrderLast">尾页</button><button class="ghost" @click="goToOrderPage">跳转</button></div>
</section>
<div class="modal-backdrop" v-if="selectedCardDetail" @click.self="selectedCardDetail = null"><section class="detail-modal card-detail-modal"><div class="detail-head"><div><h2>卡信息详情</h2><p class="mono">{{ selectedCardDetail.msisdn }}</p></div><button class="ghost icon-only" @click="selectedCardDetail = null" title="关闭"><X :size="18" /></button></div><div class="detail-section"><h3>基础信息</h3><div class="detail-grid"><div class="detail-item" v-for="key in ['msisdn','iccid','imsi','imei','remark','openDate','activeDate']" :key="key"><span>{{ detailLabel(key) }}</span><strong :class="['mono', { 'wrap-value': key === 'remark' }]">{{ selectedCardDetail[key] || '-' }}</strong></div></div></div><div class="detail-section"><h3>状态与连接</h3><div class="detail-grid"><div class="detail-item"><span>卡状态</span><strong>{{ cardStatusLabel(selectedCardDetail.cardStatus) }}</strong></div><div class="detail-item"><span>终端开关机</span><strong>{{ boolLabel(selectedCardDetail.onOffStatus) }}</strong></div><div class="detail-item"><span>会话状态</span><strong>{{ selectedCardDetail.sessionStatus === '01' ? '在线' : '离线' }}</strong></div><div class="detail-item"><span>机卡绑定</span><strong>{{ selectedCardDetail.cardBindStatus === '1' ? '未分离' : '已分离' }}</strong></div><div class="detail-item"><span>区域限制</span><strong>{{ selectedCardDetail.regionLimitStatus === '1' ? '已触发' : '未触发' }}</strong></div><div class="detail-item"><span>接入制式</span><strong>{{ selectedCardDetail.rat || '-' }}</strong></div><div class="detail-item"><span>IPv4</span><strong class="mono">{{ selectedCardDetail.ip || '-' }}</strong></div><div class="detail-item"><span>会话建立时间</span><strong class="mono">{{ selectedCardDetail.sessionCreateDate || selectedCardDetail.lastChangeDate || '-' }}</strong></div></div></div><div class="detail-section"><h3>停机与冻结诊断</h3><div class="detail-grid"><div class="detail-item"><span>停机原因码</span><strong class="mono">{{ selectedCardDetail.stopReason || '000000000000' }}</strong></div><div class="detail-item"><span>管理冻结</span><strong>{{ selectedCardDetail.frozenStatus === '1' ? '是' : '否' }}</strong></div><div class="detail-item"><span>冻结原因</span><strong>{{ selectedCardDetail.frozenReason || '-' }}</strong></div><div class="detail-item"><span>冻结操作时间</span><strong class="mono">{{ selectedCardDetail.frozenOperTime || '-' }}</strong></div></div></div><div class="detail-section"><h3>APN 与 PCC 智能诊断</h3><div class="diagnosis-list"><div class="diagnosis-row" v-for="apn in (selectedCardDetail.apns || [])" :key="apn.name"><div class="diagnosis-row-head"><strong>{{ apn.name }}</strong><span>{{ apnDiagnosisStatusLabel(apn.diagnosisStatus || (apn.status === '1' ? '2' : apn.status)) }}</span><span>{{ apnServiceUsageLabel(apn.serviceUsageState) }}</span></div><div class="diagnosis-meta">变更时间 <span class="mono">{{ apn.modifyTime || '-' }}</span><template v-if="apn.overUseDetails"> · 超停原因 <span>{{ apn.overUseDetails }}</span></template></div><div class="pcc-list"><div class="pcc-item" v-for="pcc in (apn.pccs || [])" :key="pcc.serviceCode"><span class="mono">{{ pcc.serviceCode }}</span><span>{{ pcc.name }}</span><span>策略 {{ pcc.policyStatus }}</span><span>{{ apnServiceUsageLabel(pcc.serviceUsageState) }}</span></div><div class="empty-inline" v-if="!(apn.pccs || []).length">暂无 PCC 策略</div></div></div><div class="empty-inline" v-if="!(selectedCardDetail.apns || []).length">暂无 APN 诊断数据</div></div></div></section></div>
<div class="modal-backdrop" v-if="selectedOrderDetail" @click.self="selectedOrderDetail = null"><section class="detail-modal compact"><div class="detail-head"><h2>订单完整信息</h2><button class="ghost icon-only" @click="selectedOrderDetail = null" title="关闭"><X :size="18" /></button></div><div class="detail-grid"><div class="detail-item" v-for="key in ['orderNum','msisdn','iccid','imsi','type','operType','apnName','status','createdAt']" :key="key"><span>{{ key }}</span><strong class="mono">{{ selectedOrderDetail[key] || '-' }}</strong></div></div></section></div>
<div class="modal-backdrop" v-if="isCardEditorOpen" @click.self="isCardEditorOpen = false"><section class="detail-modal card-editor-modal">
<div class="detail-head"><div><h2>{{ cardEditorMode === 'edit' ? '编辑卡' : '新增卡' }}</h2><p>保存后将刷新当前卡列表。</p></div><button class="ghost icon-only" @click="isCardEditorOpen = false" title="关闭"><X :size="18" /></button></div>
<div class="editor-section"><h3>基础信息</h3><div class="editor-grid"><label><span>MSISDN</span><input v-model="cardForm.msisdn" /></label><label><span>ICCID</span><input v-model="cardForm.iccid" /></label><label><span>IMSI</span><input v-model="cardForm.imsi" /></label><label><span>IMEI</span><input v-model="cardForm.imei" /></label><label class="wide"><span>备注</span><input v-model="cardForm.remark" /></label></div></div>
<div class="editor-section"><h3>状态与流量</h3><div class="editor-grid"><label><span>卡状态</span><select v-model="cardForm.cardStatus"><option value="1">待激活</option><option value="2">已激活</option><option value="4">停机</option><option value="6">可测试</option><option value="7">库存</option><option value="8">预销户</option></select></label><label><span>终端开关机</span><select v-model="cardForm.onOffStatus"><option value="0">关机</option><option value="1">开机</option></select></label><label><span>会话状态</span><select v-model="cardForm.sessionStatus"><option value="00">离线</option><option value="01">在线</option></select></label><label><span>机卡绑定</span><select v-model="cardForm.cardBindStatus"><option value="0">已分离</option><option value="1">未分离</option></select></label><label><span>区域限制</span><select v-model="cardForm.regionLimitStatus"><option value="0">未触发</option><option value="1">已触发</option></select></label><label><span>当月流量 KB</span><input v-model.number="cardForm.monthUsageKB" type="number" /></label><label><span>日基础流量 KB</span><input v-model.number="cardForm.dailyBaseKB" type="number" /></label><label><span>月基础流量 KB</span><input v-model.number="cardForm.monthlyBaseKB" type="number" /></label></div></div>
<div class="editor-section"><h3>诊断模拟</h3><div class="editor-grid"><label><span>停机原因码</span><input v-model="cardForm.stopReason" maxlength="12" /></label><label><span>管理冻结</span><select v-model="cardForm.frozenStatus"><option value="0">否</option><option value="1">是</option></select></label><label class="wide"><span>冻结原因</span><input v-model="cardForm.frozenReason" /></label><label><span>APN 诊断状态</span><select v-model="cardForm.apns[0].diagnosisStatus"><option value="1">未激活</option><option value="2">激活</option><option value="4">暂停</option></select></label><label><span>APN 限速档位</span><input v-model="cardForm.apns[0].serviceUsageState" /></label><label class="wide"><span>超停原因</span><input v-model="cardForm.apns[0].overUseDetails" placeholder="例如 16|17" /></label><label><span>PCC 编码</span><input v-model="cardForm.apns[0].pccs[0].serviceCode" /></label><label><span>PCC 名称</span><input v-model="cardForm.apns[0].pccs[0].name" /></label><label><span>PCC 策略状态</span><select v-model="cardForm.apns[0].pccs[0].policyStatus"><option value="2">激活</option><option value="4">暂停</option></select></label></div></div>
<div class="editor-actions"><button class="ghost" @click="isCardEditorOpen = false">取消</button><button :disabled="cardSaving" @click="saveCard"><Save :size="16" />{{ cardSaving ? '保存中...' : '保存卡' }}</button></div>
</section></div>
<div class="modal-backdrop" v-if="isGroupEditorOpen" @click.self="isGroupEditorOpen = false"><section class="detail-modal compact"><div class="detail-head"><h2>新建分组</h2><button class="ghost icon-only" @click="isGroupEditorOpen = false" title="关闭"><X :size="18" /></button></div><label class="field"><span>分组名称</span><input v-model="groupForm.name" @keyup.enter="createGroup" /></label><div class="editor-actions"><button class="ghost" @click="isGroupEditorOpen = false">取消</button><button :disabled="groupSaving || !groupForm.name.trim()" @click="createGroup"><Save :size="16" />{{ groupSaving ? '保存中...' : '创建分组' }}</button></div></section></div>
</div>
<div v-else>
<header class="page-header"><div><button class="ghost icon-only" @click="navigate('dashboard')" title="返回管理台"><ArrowLeft :size="18" /></button><div><p class="eyebrow">OneLink V25.11.1 CT Mock</p><h1>操作日志</h1></div></div><span>每日 00:00 自动清空</span></header>
<section class="band log-page">
<div class="section-head"><div><h2>今日操作</h2><p>记录管理台、OneLink 和 Chan 接口的操作结果。</p></div><ClipboardList :size="22" /></div>
<div class="log-search-row"><input v-model="operationLogKeyword" @keyup.enter="searchOperationLogs" placeholder="搜索动作、目标、接口或 IP" /><select v-model="operationLogCategory" @change="searchOperationLogs"><option value="">全部类别</option><option value="管理台">管理台</option><option value="OneLink">OneLink</option><option value="Chan">Chan</option></select><select v-model="operationLogResult" @change="searchOperationLogs"><option value="">全部结果</option><option value="success">成功</option><option value="failed">失败</option></select><button @click="searchOperationLogs"><Search :size="16" />查询</button><button class="ghost icon-only" @click="clearOperationLogSearch" title="清空查询"><X :size="16" /></button></div>
<div class="empty" v-if="!operationLogs.length">暂无操作日志</div>
<div class="table log-table" v-else>
<div class="tr th"><span>时间</span><span>类别</span><span>动作</span><span>API 接口</span><span>目标</span><span>结果</span><span>耗时</span><span>客户端 IP</span><span></span></div>
<div class="tr" v-for="log in operationLogs" :key="log.id"><span>{{ log.createdAt }}</span><span>{{ log.category }}</span><span>{{ log.action }}</span><span class="mono api-path">{{ log.method }} {{ log.path }}</span><span class="mono">{{ log.target || '-' }}</span><span><em class="result-tag" :class="log.result">{{ log.result === 'success' ? '成功' : '失败' }}</em></span><span>{{ log.durationMs }} ms</span><span class="mono">{{ log.clientIp || '-' }}</span><button class="ghost icon-only" @click="selectedOperationLog = log" title="详情"><Eye :size="15" /></button></div>
</div>
<div class="pager" v-if="operationLogPage.total"><span>共 {{ operationLogPage.total }} 条日志</span><label class="page-size"><span>每页</span><select :value="operationLogPage.pageSize" @change="changeOperationLogPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="operationLogPage.page <= 1" @click="changeOperationLogPage(operationLogPage.page - 1)">上一页</button><span>第 {{ operationLogPage.page }} / {{ operationLogTotalPages }} 页</span><button class="ghost" :disabled="operationLogPage.page >= operationLogTotalPages" @click="changeOperationLogPage(operationLogPage.page + 1)">下一页</button></div>
</section>
</div>
<div class="modal-backdrop" v-if="selectedOperationLog" @click.self="selectedOperationLog = null"><section class="detail-modal compact"><div class="detail-head"><h2>操作日志详情</h2><button class="ghost icon-only" @click="selectedOperationLog = null" title="关闭"><X :size="18" /></button></div><div class="detail-grid"><div class="detail-item" v-for="key in ['createdAt','category','action','target','method','path','params','result','statusCode','message','durationMs','clientIp']" :key="key"><span>{{ key }}</span><strong class="mono">{{ selectedOperationLog[key] ?? '-' }}</strong></div></div></section></div>
<div class="toast" v-if="message">{{ message }}</div>
</main>
`
}
function emptyCard() {
return {
msisdn: '', iccid: '', imsi: '', imei: '', remark: '',
stopReason: '000000000000', frozenStatus: '0', frozenReason: '', frozenOperTime: '', sessionCreateDate: '',
cardStatus: '2', onOffStatus: '1', sessionStatus: '01', cardBindStatus: '1', regionLimitStatus: '0',
monthUsageKB: 18432, dailyBaseKB: 614.4, monthlyBaseKB: 18432, growthPerMinuteKB: 0,
apns: [{ name: 'CMIOT', status: '1', diagnosisStatus: '2', serviceUsageState: '1', overUseDetails: '', usageBytes: 0, modifyTime: '', pccs: [{ serviceCode: 'default', name: 'APN 缺省流量', policyStatus: '2', serviceUsageState: '1', modifyTime: '' }] }]
}
}
function transid(appid) { return `${appid}${new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)}00000001` }
function yesterday() { return new Date(Date.now() - 86400000).toISOString().slice(0, 10).replaceAll('-', '') }
function monthText() { return new Date().toISOString().slice(0, 7).replace('-', '') }
function normalizeBase(base) {
const clean = (base || '/').replace(/\/+$/, '')
return clean === '' ? '' : clean
}
function withBase(path) {
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${appBase}${cleanPath}` || '/'
}
function viewFromPath() {
const path = location.pathname.replace(appBase, '') || '/'
return path === '/operation-logs' || path === '/operation-logs/' ? 'operation-logs' : 'dashboard'
}
createApp(App).mount('#app')
+308
View File
@@ -0,0 +1,308 @@
:root {
font-family: Inter, "Microsoft YaHei", system-ui, sans-serif;
color: #1d252b;
background: #f5f7f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
.shell {
width: min(1280px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 44px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.top-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.count-input {
width: 110px;
}
.group-select {
width: 150px;
}
.eyebrow {
margin: 0 0 6px;
color: #57717f;
font-size: 13px;
font-weight: 700;
}
h1, h2, p { margin: 0; }
h1 { font-size: clamp(28px, 4vw, 44px); line-height: 1.08; color: #14242d; }
h2 { font-size: 20px; }
p { color: #63717a; }
.icon-btn, button {
border: 0;
border-radius: 8px;
min-height: 38px;
padding: 0 14px;
background: #116a6d;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
cursor: pointer;
}
.icon-btn { width: 42px; padding: 0; }
.ghost {
min-height: 30px;
color: #116a6d;
background: #e5f0ef;
}
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.metrics div {
min-height: 76px;
display: flex;
align-items: center;
gap: 10px;
background: white;
border: 1px solid #dce4e6;
border-radius: 8px;
padding: 16px;
}
.metric-link { cursor: pointer; }
.metric-link:hover, .metric-link:focus-visible { border-color: #116a6d; background: #f0f8f7; outline: none; }
.metrics strong { font-size: 26px; color: #0f4348; }
.metrics span { color: #68777f; }
.band {
background: white;
border: 1px solid #dce4e6;
border-radius: 8px;
padding: 18px;
margin-top: 14px;
}
.section-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.section-head-actions { display: inline-flex; align-items: center; gap: 14px; }
.form-grid {
display: grid;
gap: 10px;
margin-bottom: 14px;
}
.search-row { display: grid; grid-template-columns: minmax(260px, 1fr) 150px 150px auto 42px; gap: 10px; margin-bottom: 14px; align-items: center; }
.search-row > button { white-space: nowrap; }
.app-grid { grid-template-columns: 1.4fr 1fr 1fr auto; }
.card-grid { grid-template-columns: repeat(4, minmax(0, 1fr)) auto; }
input, select {
width: 100%;
min-height: 38px;
border: 1px solid #ccd7da;
border-radius: 7px;
padding: 8px 10px;
color: #1d252b;
background: #fbfcfc;
}
.mono {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
}
.table {
border: 1px solid #e0e7e9;
border-radius: 8px;
overflow: hidden;
}
.tr {
display: grid;
grid-template-columns: 1fr 1.4fr 2fr 56px;
gap: 10px;
align-items: center;
min-height: 42px;
padding: 8px 12px;
border-top: 1px solid #e8eeee;
}
.tr:first-child { border-top: 0; }
.th {
background: #f1f5f5;
color: #52646d;
font-weight: 700;
}
.cards .tr {
grid-template-columns: 42px 1fr 1.5fr 1.25fr 0.85fr 0.7fr 0.8fr 0.6fr 0.7fr 150px;
}
.cards { max-height: 492px; overflow-y: auto; }
.cards .tr.th { position: sticky; top: 0; z-index: 1; }
.orders { max-height: 492px; overflow-y: auto; }
.orders .tr.th { position: sticky; top: 0; z-index: 1; }
.row-actions { display: inline-flex; gap: 8px; justify-content: flex-end; }
.export-status { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 12px; font-style: normal; }
.export-status.exported { color: #166534; background: #dcfce7; }
.export-status.not-exported { color: #9a3412; background: #ffedd5; }
.icon-only { width: 34px; padding: 0; }
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 10px; color: #63717a; font-size: 13px; }
.pager button:disabled { cursor: not-allowed; opacity: 0.45; }
.page-size { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.page-size select { width: auto; min-height: 30px; padding: 4px 28px 4px 8px; }
.page-jump { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.page-input { width: 56px; min-height: 30px; padding: 4px 8px; text-align: center; }
.page-input::-webkit-outer-spin-button,
.page-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.page-input[type=number] { -moz-appearance: textfield; }
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: rgb(16 28 34 / 55%); }
.detail-modal { width: min(920px, 100%); max-height: min(760px, calc(100vh - 40px)); overflow: auto; padding: 20px; border-radius: 8px; background: white; box-shadow: 0 18px 50px rgb(0 0 0 / 20%); }
.detail-modal.compact { width: min(720px, 100%); }
.card-editor-modal { width: min(860px, 100%); }
.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid #dce4e6; border-radius: 8px; background: #dce4e6; }
.detail-item { min-width: 0; display: grid; gap: 6px; padding: 12px; background: white; }
.detail-item span { color: #63717a; font-size: 12px; }
.detail-item strong { overflow-wrap: anywhere; font-weight: 600; }
.card-detail-modal { width: min(980px, 100%); }
.detail-section { margin-top: 16px; }
.detail-section:first-of-type { margin-top: 0; }
.detail-section h3 { margin: 0 0 9px; color: #31404a; font-size: 14px; }
.wrap-value { white-space: pre-wrap; }
.diagnosis-list { display: grid; gap: 10px; }
.diagnosis-row { padding: 12px; border: 1px solid #dce4e6; border-radius: 7px; background: #fbfcfc; }
.diagnosis-row-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.diagnosis-row-head span { padding: 3px 7px; border-radius: 4px; color: #116a6d; background: #e5f0ef; font-size: 12px; }
.diagnosis-meta { margin-top: 7px; color: #63717a; font-size: 12px; }
.pcc-list { display: grid; gap: 6px; margin-top: 10px; }
.pcc-item { display: grid; grid-template-columns: 1fr 1.5fr 0.8fr 1fr; gap: 8px; padding: 7px 9px; border-left: 3px solid #9fc7c6; color: #40515a; background: #f1f5f5; font-size: 12px; }
.empty-inline { color: #71818a; font-size: 12px; }
.editor-section { padding: 14px 0; border-top: 1px solid #e1e8e9; }
.editor-section:first-of-type { border-top: 0; }
.editor-section h3 { margin: 0 0 10px; font-size: 14px; color: #31404a; }
.editor-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.editor-grid label { display: grid; gap: 6px; min-width: 0; }
.editor-grid label span { color: #63717a; font-size: 12px; }
.editor-grid .wide { grid-column: span 2; }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 16px; }
.editor-actions button:disabled { cursor: wait; opacity: 0.55; }
.order-row, .table .th:has(+ .order-row) { grid-template-columns: 1.5fr 1fr 0.6fr 1fr 0.7fr 1.2fr 42px; }
.select-box {
width: 18px;
min-height: 18px;
padding: 0;
}
.split {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 14px;
}
.split.single {
grid-template-columns: 1fr;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
color: #31404a;
}
.toggle input {
width: 20px;
min-height: 20px;
}
.field {
display: grid;
gap: 6px;
color: #52646d;
margin-bottom: 12px;
}
.copy-field { display: grid; grid-template-columns: minmax(0, 1fr) 42px; gap: 8px; align-items: center; }
.copy-field input { min-width: 0; }
.endpoint-select { max-height: 220px; overflow-y: auto; }
.endpoint-body { width: 100%; min-height: 150px; resize: vertical; border: 1px solid #ccd7da; border-radius: 7px; padding: 10px; color: #1d252b; background: #fbfcfc; }
.endpoint-body.compact { min-height: 112px; }
.endpoint-detail { margin-top: 12px; }
.endpoint-categories { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.endpoint-category { min-height: 38px; padding: 8px 12px; color: #40515a; background: #f4f7f7; border: 1px solid #ccd7da; }
.endpoint-category span { min-width: 22px; height: 20px; display: inline-grid; place-items: center; margin-left: 7px; border-radius: 10px; color: #63717a; background: #e4e9ea; font-size: 12px; }
.endpoint-category.active { color: #fff; background: #116a6d; border-color: #116a6d; }
.endpoint-category.active span { color: #116a6d; background: #fff; }
.empty {
min-height: 64px;
display: grid;
place-items: center;
color: #71818a;
border: 1px dashed #cbd7da;
border-radius: 8px;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
background: #14242d;
color: white;
border-radius: 8px;
padding: 12px 16px;
}
.page-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 18px; }
.page-header > div { display: flex; align-items: center; gap: 12px; }
.page-header > span { color: #63717a; font-size: 13px; }
.log-search-row { display: grid; grid-template-columns: minmax(260px, 1fr) 140px 140px auto 42px; gap: 10px; margin-bottom: 14px; align-items: center; }
.log-table { overflow-x: auto; }
.log-table .tr { min-width: 1240px; grid-template-columns: 1.15fr 0.65fr 1.25fr 1.7fr 1.2fr 0.65fr 0.6fr 1fr 42px; }
.api-path { overflow-wrap: anywhere; }
.result-tag { font-style: normal; font-size: 12px; color: #116a6d; background: #e5f0ef; padding: 4px 8px; border-radius: 6px; }
.result-tag.failed { color: #a23b32; background: #fae9e7; }
@media (max-width: 900px) {
.metrics, .split, .app-grid, .card-grid, .search-row, .log-search-row, .detail-grid, .editor-grid { grid-template-columns: 1fr; }
.editor-grid .wide { grid-column: auto; }
.topbar { align-items: flex-start; flex-direction: column; }
.top-actions { justify-content: flex-start; }
.tr, .cards .tr { grid-template-columns: 1fr; align-items: start; }
.th { display: none; }
.endpoint-categories { display: grid; grid-template-columns: 1fr 1fr; }
.pcc-item { grid-template-columns: 1fr 1fr; }
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
host: '127.0.0.1',
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
},
'/v5': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
},
'/ec': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
},
'/sim': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
},
'/ct': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
}
}
}
})
+45
View File
@@ -0,0 +1,45 @@
module git.5iot.com/iotlcm-public/iotlcm-onelink-mock
go 1.24.0
require (
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.9.4
github.com/gogf/gf/v2 v2.9.4
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+99
View File
@@ -0,0 +1,99 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.9.4 h1:dZb+oMxxg2EaqDV7w+7H6a/1uKflq5+SgVlH6G0ToKM=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.9.4/go.mod h1:pFgoWuCNW/J8vDaHD0EzRouBNSwV+Uyqms1B4sxQJ6U=
github.com/gogf/gf/v2 v2.9.4 h1:6vleEWypot9WBPncP2GjbpgAUeG6Mzb1YESb9nPMkjY=
github.com/gogf/gf/v2 v2.9.4/go.mod h1:Ukl+5HUH9S7puBmNLR4L1zUqeRwi0nrW4OigOknEztU=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"context"
"os"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
)
func main() {
schema, err := os.ReadFile("manifest/sql/schema.sql")
if err != nil {
panic(err)
}
if err = gdb.AddConfigNode(gdb.DefaultGroupName, gdb.ConfigNode{
Type: "sqlite",
Link: "sqlite::@file(./data/codegen.db)",
}); err != nil {
panic(err)
}
db, err := gdb.NewByGroup()
if err != nil {
panic(err)
}
if _, err = db.Exec(context.Background(), string(schema)); err != nil {
panic(err)
}
}
+7
View File
@@ -0,0 +1,7 @@
gfcli:
gen:
dao:
- link: "sqlite::@file(./data/codegen.db)"
tables: "mock_card_group,mock_t_sim_card,mock_order"
descriptionTag: true
jsonCase: "CamelLower"
+45
View File
@@ -0,0 +1,45 @@
package cmd
import (
"context"
"os"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/controller/gateway"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/service"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcmd"
)
var Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start OneLink mock service",
Func: func(ctx context.Context, parser *gcmd.Parser) error {
var (
server = g.Server()
runtimeLogCapacity = g.Cfg().MustGet(ctx, "runtimeLog.capacity", 500).Int()
runtimeLogs = service.NewRuntimeLogStore(runtimeLogCapacity)
databaseLink = g.Cfg().MustGet(ctx, "database.default.link", "sqlite::@file(data/onelink_mock.db)").String()
)
if value := os.Getenv("BAFFLE_ADDR"); value != "" {
server.SetAddr(value)
}
if value := os.Getenv("BAFFLE_SQLITE_PATH"); value != "" {
databaseLink = value
}
g.Log().SetWriter(runtimeLogs)
g.Log().SetWriterColorEnable(false)
server.Logger().SetWriter(runtimeLogs)
server.Logger().SetWriterColorEnable(false)
mockService, err := service.NewService(databaseLink, runtimeLogs)
if err != nil {
return gerror.Wrap(err, "initialize mock service")
}
gateway.New(mockService).Register(server)
g.Log().Infof(ctx, "OneLink mock service listening on %s", server.GetListenedAddress())
server.Run()
return nil
},
}
+59
View File
@@ -0,0 +1,59 @@
package gateway
import (
"net/http"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/service"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct {
service *service.Service
}
func New(mockService *service.Service) *Controller {
return &Controller{service: mockService}
}
func (c *Controller) Register(server *ghttp.Server) {
server.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(c.cors)
group.ALL("/healthz", c.forward(c.service.Health, false))
group.ALL("/api/state", c.forward(c.service.AdminState, true))
group.ALL("/api/appids", c.forward(c.service.AdminAppIDs, true))
group.ALL("/api/cards", c.forward(c.service.AdminCards, true))
group.ALL("/api/card-groups", c.forward(c.service.AdminCardGroups, true))
group.ALL("/api/cards/export", c.forward(c.service.AdminCardsExport, true))
group.ALL("/api/settings", c.forward(c.service.AdminSettings, true))
group.ALL("/api/orders", c.forward(c.service.AdminOrders, true))
group.ALL("/api/operation-logs", c.forward(c.service.AdminOperationLogs, false))
group.ALL("/api/runtime-logs", c.forward(c.service.AdminRuntimeLogs, false))
group.ALL("/api/sync", c.forward(c.service.AdminSync, true))
group.ALL("/api/generate", c.forward(c.service.AdminGenerate, true))
group.ALL("/v5/*endpoint", c.forward(c.service.OneLink, true))
group.ALL("/ec/*endpoint", c.forward(c.service.OneLink, true))
group.ALL("/sim/*endpoint", c.forward(c.service.Chan, true))
})
}
func (c *Controller) forward(handler http.HandlerFunc, operationLogging bool) ghttp.HandlerFunc {
var next http.Handler = handler
if operationLogging {
next = c.service.WithOperationLogging(next)
}
return func(r *ghttp.Request) {
next.ServeHTTP(r.Response.Writer, r.Request)
}
}
func (c *Controller) cors(r *ghttp.Request) {
r.Response.Header().Set("Access-Control-Allow-Origin", "*")
r.Response.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
r.Response.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
r.Response.WriteStatus(http.StatusNoContent)
r.ExitAll()
return
}
r.Middleware.Next()
}
+85
View File
@@ -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"
)
// MockCardGroupDao is the data access object for the table mock_card_group.
type MockCardGroupDao 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 MockCardGroupColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// MockCardGroupColumns defines and stores column names for the table mock_card_group.
type MockCardGroupColumns struct {
Id string //
Name string //
CreatedAt string //
UpdatedAt string //
}
// mockCardGroupColumns holds the columns for the table mock_card_group.
var mockCardGroupColumns = MockCardGroupColumns{
Id: "id",
Name: "name",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewMockCardGroupDao creates and returns a new DAO object for table data access.
func NewMockCardGroupDao(handlers ...gdb.ModelHandler) *MockCardGroupDao {
return &MockCardGroupDao{
group: "default",
table: "mock_card_group",
columns: mockCardGroupColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *MockCardGroupDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *MockCardGroupDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *MockCardGroupDao) Columns() MockCardGroupColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *MockCardGroupDao) 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 *MockCardGroupDao) 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 *MockCardGroupDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+97
View File
@@ -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"
)
// MockOrderDao is the data access object for the table mock_order.
type MockOrderDao 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 MockOrderColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// MockOrderColumns defines and stores column names for the table mock_order.
type MockOrderColumns struct {
Id string //
OrderNum string //
Msisdn string //
Iccid string //
Imsi string //
Type string //
OperType string //
ApnName string //
Status string //
CreatedAt string //
}
// mockOrderColumns holds the columns for the table mock_order.
var mockOrderColumns = MockOrderColumns{
Id: "id",
OrderNum: "order_num",
Msisdn: "msisdn",
Iccid: "iccid",
Imsi: "imsi",
Type: "type",
OperType: "oper_type",
ApnName: "apn_name",
Status: "status",
CreatedAt: "created_at",
}
// NewMockOrderDao creates and returns a new DAO object for table data access.
func NewMockOrderDao(handlers ...gdb.ModelHandler) *MockOrderDao {
return &MockOrderDao{
group: "default",
table: "mock_order",
columns: mockOrderColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *MockOrderDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *MockOrderDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *MockOrderDao) Columns() MockOrderColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *MockOrderDao) 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 *MockOrderDao) 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 *MockOrderDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+187
View File
@@ -0,0 +1,187 @@
// ==========================================================================
// 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"
)
// MockTSimCardDao is the data access object for the table mock_t_sim_card.
type MockTSimCardDao 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 MockTSimCardColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// MockTSimCardColumns defines and stores column names for the table mock_t_sim_card.
type MockTSimCardColumns struct {
Id string //
GroupId string //
Msisdn string //
Iccid string //
Imsi string //
Imei string //
Remark string //
Carrier string //
PackageCode string //
PackageName string //
PackageStatus string //
PackageTotalFlowMb string //
IsFlowPool string //
CardStatus string //
LastChangeDate string //
StopReason string //
FrozenStatus string //
FrozenReason string //
FrozenOperTime string //
OpenDate string //
ActiveDate string //
OnOffStatus string //
SessionStatus string //
ApnId string //
SessionIpv4 string //
SessionIpv6Prefix string //
SessionIpv6 string //
SessionCreateDate string //
Rat string //
ApnListJson string //
ApnUsageJson string //
PrimaryApnName string //
PrimaryApnStatus string //
PrimaryApnUsageKb string //
PrimaryApnUsageBytes string //
PccCode string //
PccUsageKb string //
CurrentMonthUsageKb string //
PoolUsageBytes string //
DailyUsageKb string //
MonthlyUsageKb string //
DailyQueryDate string //
MonthlyQueryMonth string //
MonthlyDeadline string //
GrowthPerMinuteKb string //
AutoGrowthEnabled string //
LastUsageUpdateTime string //
CardBindStatus string //
RegionLimitStatus string //
AreaRestrictType string //
RestrictedArea string //
Exported string //
ExportCheckedAt string //
CreatedAt string //
UpdatedAt string //
}
// mockTSimCardColumns holds the columns for the table mock_t_sim_card.
var mockTSimCardColumns = MockTSimCardColumns{
Id: "id",
GroupId: "group_id",
Msisdn: "msisdn",
Iccid: "iccid",
Imsi: "imsi",
Imei: "imei",
Remark: "remark",
Carrier: "carrier",
PackageCode: "package_code",
PackageName: "package_name",
PackageStatus: "package_status",
PackageTotalFlowMb: "package_total_flow_mb",
IsFlowPool: "is_flow_pool",
CardStatus: "card_status",
LastChangeDate: "last_change_date",
StopReason: "stop_reason",
FrozenStatus: "frozen_status",
FrozenReason: "frozen_reason",
FrozenOperTime: "frozen_oper_time",
OpenDate: "open_date",
ActiveDate: "active_date",
OnOffStatus: "on_off_status",
SessionStatus: "session_status",
ApnId: "apn_id",
SessionIpv4: "session_ipv4",
SessionIpv6Prefix: "session_ipv6_prefix",
SessionIpv6: "session_ipv6",
SessionCreateDate: "session_create_date",
Rat: "rat",
ApnListJson: "apn_list_json",
ApnUsageJson: "apn_usage_json",
PrimaryApnName: "primary_apn_name",
PrimaryApnStatus: "primary_apn_status",
PrimaryApnUsageKb: "primary_apn_usage_kb",
PrimaryApnUsageBytes: "primary_apn_usage_bytes",
PccCode: "pcc_code",
PccUsageKb: "pcc_usage_kb",
CurrentMonthUsageKb: "current_month_usage_kb",
PoolUsageBytes: "pool_usage_bytes",
DailyUsageKb: "daily_usage_kb",
MonthlyUsageKb: "monthly_usage_kb",
DailyQueryDate: "daily_query_date",
MonthlyQueryMonth: "monthly_query_month",
MonthlyDeadline: "monthly_deadline",
GrowthPerMinuteKb: "growth_per_minute_kb",
AutoGrowthEnabled: "auto_growth_enabled",
LastUsageUpdateTime: "last_usage_update_time",
CardBindStatus: "card_bind_status",
RegionLimitStatus: "region_limit_status",
AreaRestrictType: "area_restrict_type",
RestrictedArea: "restricted_area",
Exported: "exported",
ExportCheckedAt: "export_checked_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewMockTSimCardDao creates and returns a new DAO object for table data access.
func NewMockTSimCardDao(handlers ...gdb.ModelHandler) *MockTSimCardDao {
return &MockTSimCardDao{
group: "default",
table: "mock_t_sim_card",
columns: mockTSimCardColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *MockTSimCardDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *MockTSimCardDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *MockTSimCardDao) Columns() MockTSimCardColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *MockTSimCardDao) 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 *MockTSimCardDao) 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 *MockTSimCardDao) 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-onelink-mock/internal/dao/internal"
)
// mockCardGroupDao is the data access object for the table mock_card_group.
// You can define custom methods on it to extend its functionality as needed.
type mockCardGroupDao struct {
*internal.MockCardGroupDao
}
var (
// MockCardGroup is a globally accessible object for table mock_card_group operations.
MockCardGroup = mockCardGroupDao{internal.NewMockCardGroupDao()}
)
// 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-onelink-mock/internal/dao/internal"
)
// mockOrderDao is the data access object for the table mock_order.
// You can define custom methods on it to extend its functionality as needed.
type mockOrderDao struct {
*internal.MockOrderDao
}
var (
// MockOrder is a globally accessible object for table mock_order operations.
MockOrder = mockOrderDao{internal.NewMockOrderDao()}
)
// 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-onelink-mock/internal/dao/internal"
)
// mockTSimCardDao is the data access object for the table mock_t_sim_card.
// You can define custom methods on it to extend its functionality as needed.
type mockTSimCardDao struct {
*internal.MockTSimCardDao
}
var (
// MockTSimCard is a globally accessible object for table mock_t_sim_card operations.
MockTSimCard = mockTSimCardDao{internal.NewMockTSimCardDao()}
)
// Add your custom methods and functionality below.
+18
View File
@@ -0,0 +1,18 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// MockCardGroup is the golang structure of table mock_card_group for DAO operations like Where/Data.
type MockCardGroup struct {
g.Meta `orm:"table:mock_card_group, do:true"`
Id any //
Name any //
CreatedAt any //
UpdatedAt any //
}
+24
View File
@@ -0,0 +1,24 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// MockOrder is the golang structure of table mock_order for DAO operations like Where/Data.
type MockOrder struct {
g.Meta `orm:"table:mock_order, do:true"`
Id any //
OrderNum any //
Msisdn any //
Iccid any //
Imsi any //
Type any //
OperType any //
ApnName any //
Status any //
CreatedAt any //
}
+69
View File
@@ -0,0 +1,69 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// MockTSimCard is the golang structure of table mock_t_sim_card for DAO operations like Where/Data.
type MockTSimCard struct {
g.Meta `orm:"table:mock_t_sim_card, do:true"`
Id any //
GroupId any //
Msisdn any //
Iccid any //
Imsi any //
Imei any //
Remark any //
Carrier any //
PackageCode any //
PackageName any //
PackageStatus any //
PackageTotalFlowMb any //
IsFlowPool any //
CardStatus any //
LastChangeDate any //
StopReason any //
FrozenStatus any //
FrozenReason any //
FrozenOperTime any //
OpenDate any //
ActiveDate any //
OnOffStatus any //
SessionStatus any //
ApnId any //
SessionIpv4 any //
SessionIpv6Prefix any //
SessionIpv6 any //
SessionCreateDate any //
Rat any //
ApnListJson any //
ApnUsageJson any //
PrimaryApnName any //
PrimaryApnStatus any //
PrimaryApnUsageKb any //
PrimaryApnUsageBytes any //
PccCode any //
PccUsageKb any //
CurrentMonthUsageKb any //
PoolUsageBytes any //
DailyUsageKb any //
MonthlyUsageKb any //
DailyQueryDate any //
MonthlyQueryMonth any //
MonthlyDeadline any //
GrowthPerMinuteKb any //
AutoGrowthEnabled any //
LastUsageUpdateTime any //
CardBindStatus any //
RegionLimitStatus any //
AreaRestrictType any //
RestrictedArea any //
Exported any //
ExportCheckedAt any //
CreatedAt any //
UpdatedAt any //
}
+13
View File
@@ -0,0 +1,13 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// MockCardGroup is the golang structure for table mock_card_group.
type MockCardGroup struct {
Id int `json:"id" orm:"id" description:""` //
Name string `json:"name" orm:"name" description:""` //
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:""` //
}
+19
View File
@@ -0,0 +1,19 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// MockOrder is the golang structure for table mock_order.
type MockOrder struct {
Id int `json:"id" orm:"id" description:""` //
OrderNum string `json:"orderNum" orm:"order_num" description:""` //
Msisdn string `json:"msisdn" orm:"msisdn" description:""` //
Iccid string `json:"iccid" orm:"iccid" description:""` //
Imsi string `json:"imsi" orm:"imsi" description:""` //
Type string `json:"type" orm:"type" description:""` //
OperType string `json:"operType" orm:"oper_type" description:""` //
ApnName string `json:"apnName" orm:"apn_name" description:""` //
Status string `json:"status" orm:"status" description:""` //
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
}
+64
View File
@@ -0,0 +1,64 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// MockTSimCard is the golang structure for table mock_t_sim_card.
type MockTSimCard struct {
Id int `json:"id" orm:"id" description:""` //
GroupId int `json:"groupId" orm:"group_id" description:""` //
Msisdn string `json:"msisdn" orm:"msisdn" description:""` //
Iccid string `json:"iccid" orm:"iccid" description:""` //
Imsi string `json:"imsi" orm:"imsi" description:""` //
Imei string `json:"imei" orm:"imei" description:""` //
Remark string `json:"remark" orm:"remark" description:""` //
Carrier string `json:"carrier" orm:"carrier" description:""` //
PackageCode string `json:"packageCode" orm:"package_code" description:""` //
PackageName string `json:"packageName" orm:"package_name" description:""` //
PackageStatus int `json:"packageStatus" orm:"package_status" description:""` //
PackageTotalFlowMb float32 `json:"packageTotalFlowMb" orm:"package_total_flow_mb" description:""` //
IsFlowPool int `json:"isFlowPool" orm:"is_flow_pool" description:""` //
CardStatus string `json:"cardStatus" orm:"card_status" description:""` //
LastChangeDate string `json:"lastChangeDate" orm:"last_change_date" description:""` //
StopReason string `json:"stopReason" orm:"stop_reason" description:""` //
FrozenStatus string `json:"frozenStatus" orm:"frozen_status" description:""` //
FrozenReason string `json:"frozenReason" orm:"frozen_reason" description:""` //
FrozenOperTime string `json:"frozenOperTime" orm:"frozen_oper_time" description:""` //
OpenDate string `json:"openDate" orm:"open_date" description:""` //
ActiveDate string `json:"activeDate" orm:"active_date" description:""` //
OnOffStatus string `json:"onOffStatus" orm:"on_off_status" description:""` //
SessionStatus string `json:"sessionStatus" orm:"session_status" description:""` //
ApnId string `json:"apnId" orm:"apn_id" description:""` //
SessionIpv4 string `json:"sessionIpv4" orm:"session_ipv4" description:""` //
SessionIpv6Prefix string `json:"sessionIpv6Prefix" orm:"session_ipv6_prefix" description:""` //
SessionIpv6 string `json:"sessionIpv6" orm:"session_ipv6" description:""` //
SessionCreateDate string `json:"sessionCreateDate" orm:"session_create_date" description:""` //
Rat string `json:"rat" orm:"rat" description:""` //
ApnListJson string `json:"apnListJson" orm:"apn_list_json" description:""` //
ApnUsageJson string `json:"apnUsageJson" orm:"apn_usage_json" description:""` //
PrimaryApnName string `json:"primaryApnName" orm:"primary_apn_name" description:""` //
PrimaryApnStatus string `json:"primaryApnStatus" orm:"primary_apn_status" description:""` //
PrimaryApnUsageKb float32 `json:"primaryApnUsageKb" orm:"primary_apn_usage_kb" description:""` //
PrimaryApnUsageBytes int `json:"primaryApnUsageBytes" orm:"primary_apn_usage_bytes" description:""` //
PccCode string `json:"pccCode" orm:"pcc_code" description:""` //
PccUsageKb float32 `json:"pccUsageKb" orm:"pcc_usage_kb" description:""` //
CurrentMonthUsageKb float32 `json:"currentMonthUsageKb" orm:"current_month_usage_kb" description:""` //
PoolUsageBytes int `json:"poolUsageBytes" orm:"pool_usage_bytes" description:""` //
DailyUsageKb float32 `json:"dailyUsageKb" orm:"daily_usage_kb" description:""` //
MonthlyUsageKb float32 `json:"monthlyUsageKb" orm:"monthly_usage_kb" description:""` //
DailyQueryDate string `json:"dailyQueryDate" orm:"daily_query_date" description:""` //
MonthlyQueryMonth string `json:"monthlyQueryMonth" orm:"monthly_query_month" description:""` //
MonthlyDeadline string `json:"monthlyDeadline" orm:"monthly_deadline" description:""` //
GrowthPerMinuteKb float32 `json:"growthPerMinuteKb" orm:"growth_per_minute_kb" description:""` //
AutoGrowthEnabled int `json:"autoGrowthEnabled" orm:"auto_growth_enabled" description:""` //
LastUsageUpdateTime string `json:"lastUsageUpdateTime" orm:"last_usage_update_time" description:""` //
CardBindStatus string `json:"cardBindStatus" orm:"card_bind_status" description:""` //
RegionLimitStatus string `json:"regionLimitStatus" orm:"region_limit_status" description:""` //
AreaRestrictType string `json:"areaRestrictType" orm:"area_restrict_type" description:""` //
RestrictedArea string `json:"restrictedArea" orm:"restricted_area" description:""` //
Exported int `json:"exported" orm:"exported" description:""` //
ExportCheckedAt string `json:"exportCheckedAt" orm:"export_checked_at" description:""` //
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:""` //
}
+561
View File
@@ -0,0 +1,561 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func (s *Service) adminState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
}
func (s *Service) adminAppIDs(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var cred Credential
if err := json.NewDecoder(r.Body).Decode(&cred); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if cred.AppID == "" || cred.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "appid and password are required"})
return
}
if cred.Name == "" {
cred.Name = cred.AppID
}
cred.Enabled = true
cred.CreatedAt = time.Now().Format("2006-01-02 15:04:05")
err := s.store.SaveMutation(func(st *Store) error {
for i := range st.AppIDs {
if st.AppIDs[i].AppID == cred.AppID {
st.AppIDs[i].Password = cred.Password
st.AppIDs[i].Name = cred.Name
st.AppIDs[i].Enabled = cred.Enabled
st.AppIDs[i].LastToken = ""
st.AppIDs[i].TokenExpires = ""
delete(st.Tokens, cred.AppID)
return nil
}
}
st.AppIDs = append(st.AppIDs, cred)
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
case http.MethodDelete:
appid := r.URL.Query().Get("appid")
_ = s.store.SaveMutation(func(st *Store) error {
next := st.AppIDs[:0]
for _, c := range st.AppIDs {
if c.AppID != appid {
next = append(next, c)
}
}
st.AppIDs = next
delete(st.Tokens, appid)
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Service) adminCards(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
page, pageSize := pagination(r)
filter, err := cardFilterFromRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.QueryCardsByGroup(filter.keyword, filter.exportStatus, filter.groupID, filter.filterGroup, page, pageSize))
case http.MethodPost:
var card SIMCard
if err := json.NewDecoder(r.Body).Decode(&card); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
normalizeCard(&card)
err := s.store.SaveMutation(func(st *Store) error {
for i := range st.Cards {
if st.Cards[i].MSISDN == card.MSISDN {
st.Cards[i] = card
return nil
}
}
st.Cards = append(st.Cards, card)
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
case http.MethodDelete:
msisdn := r.URL.Query().Get("msisdn")
_ = s.store.SaveMutation(func(st *Store) error {
next := st.Cards[:0]
for _, c := range st.Cards {
if c.MSISDN != msisdn {
next = append(next, c)
}
}
st.Cards = next
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Service) adminCardGroups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
st := s.store.Snapshot()
counts := map[int64]int{}
for _, card := range st.Cards {
counts[card.GroupID]++
}
for i := range st.Groups {
st.Groups[i].CardCount = counts[st.Groups[i].ID]
}
writeJSON(w, http.StatusOK, st.Groups)
case http.MethodPost:
var input struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group name is required"})
return
}
var created CardGroup
err := s.store.SaveMutation(func(st *Store) error {
for _, group := range st.Groups {
if strings.EqualFold(group.Name, input.Name) {
return fmt.Errorf("group name already exists")
}
}
id := int64(1)
for _, group := range st.Groups {
if group.ID >= id {
id = group.ID + 1
}
}
now := time.Now().Format("2006-01-02 15:04:05")
created = CardGroup{ID: id, Name: input.Name, CreatedAt: now, UpdatedAt: now}
st.Groups = append(st.Groups, created)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, created)
case http.MethodDelete:
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
if err != nil || id <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid group id"})
return
}
err = s.store.SaveMutation(func(st *Store) error {
found := false
next := st.Groups[:0]
for _, group := range st.Groups {
if group.ID == id {
found = true
} else {
next = append(next, group)
}
}
if !found {
return fmt.Errorf("card group not found")
}
st.Groups = next
for i := range st.Cards {
if st.Cards[i].GroupID == id {
st.Cards[i].GroupID = 0
st.Cards[i].GroupName = ""
}
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Service) adminSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var settings SimulationSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.store.SaveMutation(func(st *Store) error {
st.Settings = settings
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
}
func (s *Service) adminOrders(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
page, pageSize := pagination(r)
writeJSON(w, http.StatusOK, s.store.QueryOrders(page, pageSize))
return
}
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
_ = s.store.SaveMutation(func(st *Store) error {
st.Orders = []Order{}
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
}
func pagination(r *http.Request) (int, int) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
type cardFilter struct {
keyword string
exportStatus string
groupID int64
filterGroup bool
}
func cardFilterFromRequest(r *http.Request) (cardFilter, error) {
filter := cardFilter{
keyword: r.URL.Query().Get("keyword"),
exportStatus: strings.TrimSpace(r.URL.Query().Get("exportStatus")),
}
if filter.exportStatus != "" && filter.exportStatus != "exported" && filter.exportStatus != "not_exported" {
return cardFilter{}, fmt.Errorf("invalid export status")
}
groupRaw := strings.TrimSpace(r.URL.Query().Get("groupId"))
if groupRaw == "" {
return filter, nil
}
groupID, err := strconv.ParseInt(groupRaw, 10, 64)
if err != nil || groupID < 0 {
return cardFilter{}, fmt.Errorf("invalid group id")
}
filter.groupID = groupID
filter.filterGroup = true
return filter, nil
}
func (s *Service) adminGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
count, groupID, err := generationRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
result, err := s.store.GenerateCardsInGroup(count, groupID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Service) adminSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
count, _, err := generationRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
result, err := s.store.GenerateCards(count)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Service) adminCardsExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var cards []SIMCard
if r.URL.Query().Get("selection") == "filtered" {
filter, err := cardFilterFromRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
cards = s.store.QueryAllCardsByGroup(filter.keyword, filter.exportStatus, filter.groupID, filter.filterGroup)
} else {
cards = exportCardsByMSISDN(s.store.Snapshot().Cards, r.URL.Query().Get("msisdns"))
}
raw, err := exportInboundCardsXLSX(cards)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
exportedAt := time.Now().Format("2006-01-02 15:04:05")
exportedMSISDNs := make(map[string]struct{}, len(cards))
for _, card := range cards {
exportedMSISDNs[card.MSISDN] = struct{}{}
}
if err := s.store.SaveMutation(func(st *Store) error {
for i := range st.Cards {
if _, ok := exportedMSISDNs[st.Cards[i].MSISDN]; ok {
st.Cards[i].Exported = true
st.Cards[i].ExportCheckedAt = exportedAt
}
}
return nil
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", `attachment; filename="sim_card_export.xlsx"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func exportCardsByMSISDN(cards []SIMCard, rawMSISDNs string) []SIMCard {
rawMSISDNs = strings.TrimSpace(rawMSISDNs)
if rawMSISDNs == "" {
return cards
}
selected := map[string]bool{}
for _, item := range strings.FieldsFunc(rawMSISDNs, func(r rune) bool {
return r == ',' || r == '_' || r == ' '
}) {
item = strings.TrimSpace(item)
if item != "" {
selected[item] = true
}
}
if len(selected) == 0 {
return cards
}
out := make([]SIMCard, 0, len(selected))
for _, card := range cards {
if selected[card.MSISDN] {
out = append(out, card)
}
}
return out
}
func generationCount(r *http.Request) (int, error) {
count := 100
if r.Body != nil {
var payload struct {
Count int `json:"count"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil && payload.Count > 0 {
count = payload.Count
}
}
if value := r.URL.Query().Get("count"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
count = parsed
}
if count <= 0 {
return 0, strconv.ErrSyntax
}
return count, nil
}
func generationRequest(r *http.Request) (int, int64, error) {
var input struct {
Count int `json:"count"`
GroupID *int64 `json:"groupId"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
if err == io.EOF {
count, countErr := generationCount(r)
return count, 0, countErr
}
return 0, 0, err
}
if input.Count <= 0 {
return 0, 0, fmt.Errorf("count must be greater than 0")
}
if input.GroupID == nil || *input.GroupID < 0 {
return input.Count, 0, nil
}
return input.Count, *input.GroupID, nil
}
func normalizeCard(card *SIMCard) {
now := time.Now()
card.MSISDN = strings.TrimSpace(card.MSISDN)
card.ICCID = strings.TrimSpace(card.ICCID)
card.IMSI = strings.TrimSpace(card.IMSI)
if card.CardStatus == "" {
card.CardStatus = "2"
}
if card.StopReason == "" {
card.StopReason = defaultStopReason(card.CardStatus)
}
if card.FrozenStatus == "" {
card.FrozenStatus = "0"
}
if card.FrozenOperTime == "" {
card.FrozenOperTime = diagnosticTime(now)
}
if card.LastChangeDate == "" {
card.LastChangeDate = now.Format("2006-01-02 15:04:05")
}
if card.OpenDate == "" {
card.OpenDate = now.Format("2006-01-02 15:04:05")
}
if card.ActiveDate == "" {
card.ActiveDate = now.Format("2006-01-02 15:04:05")
}
if card.OnOffStatus == "" {
card.OnOffStatus = "1"
}
if card.SessionStatus == "" {
card.SessionStatus = "01"
}
if card.SessionStatus != "00" && card.SessionStatus != "01" {
card.SessionStatus = "00"
}
if card.SessionCreateDate == "" {
card.SessionCreateDate = card.LastChangeDate
}
if card.SessionCreateDate == "" {
card.SessionCreateDate = card.LastChangeDate
}
if card.RAT == "" {
card.RAT = "6"
}
if card.CardBindStatus == "" {
card.CardBindStatus = "1"
}
if card.RegionLimitStatus == "" {
card.RegionLimitStatus = "0"
}
if card.AreaRestrictType == "" {
card.AreaRestrictType = "1"
}
if card.RestrictedArea == "" {
card.RestrictedArea = "200"
}
if card.LastUsageUpdateTime == "" {
card.LastUsageUpdateTime = now.Format(time.RFC3339)
}
card.GrowthPerMinuteKB = 0
if len(card.APNs) == 0 {
card.APNs = []APN{{Name: "CMIOT", Status: "1", UsageBytes: card.MonthUsageKB * 1024}}
}
for i := range card.APNs {
apn := &card.APNs[i]
if apn.Name == "" {
apn.Name = "CMIOT"
}
if apn.Status == "" {
apn.Status = "1"
}
if apn.DiagnosisStatus == "" {
apn.DiagnosisStatus = diagnosisStatus(apn.Status)
}
if apn.ModifyTime == "" {
apn.ModifyTime = diagnosticTime(now)
}
if apn.ServiceUsageState == "" {
apn.ServiceUsageState = "1"
}
if len(apn.PCCs) == 0 {
apn.PCCs = []PCC{{ServiceCode: "default", Name: "APN 缺省流量", PolicyStatus: "2", ServiceUsageState: "1", ModifyTime: apn.ModifyTime}}
}
for j := range apn.PCCs {
if apn.PCCs[j].ServiceCode == "" {
apn.PCCs[j].ServiceCode = "default"
}
if apn.PCCs[j].Name == "" {
apn.PCCs[j].Name = "APN 缺省流量"
}
if apn.PCCs[j].PolicyStatus == "" {
apn.PCCs[j].PolicyStatus = "2"
}
if apn.PCCs[j].ServiceUsageState == "" {
apn.PCCs[j].ServiceUsageState = "1"
}
if apn.PCCs[j].ModifyTime == "" {
apn.PCCs[j].ModifyTime = apn.ModifyTime
}
}
}
}
func diagnosticTime(value time.Time) string { return value.Format("20060102150405") }
func defaultStopReason(cardStatus string) string {
if cardStatus == "4" {
return "000020000000"
}
return "000000000000"
}
func diagnosisStatus(status string) string {
switch status {
case "0":
return "4"
case "1":
return "2"
default:
return status
}
}
+475
View File
@@ -0,0 +1,475 @@
package service
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestAdminAppIDUpdateClearsCachedToken(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
appid := service.store.Snapshot().AppIDs[0].AppID
if err := service.store.SaveMutation(func(st *Store) error {
st.Tokens[appid] = Token{AppID: appid, Value: "old-token", ExpiresAt: time.Now().Add(time.Hour)}
st.AppIDs[0].LastToken = "old-token"
st.AppIDs[0].TokenExpires = time.Now().Add(time.Hour).Format("2006-01-02 15:04:05")
return nil
}); err != nil {
t.Fatal(err)
}
body := strings.NewReader(`{"appid":"` + appid + `","password":"demo_password","name":"default"}`)
req := httptest.NewRequest(http.MethodPost, "/api/appids", body)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
st := service.store.Snapshot()
if _, ok := st.Tokens[appid]; ok {
t.Fatalf("cached token was not cleared: %#v", st.Tokens[appid])
}
if st.AppIDs[0].LastToken != "" || st.AppIDs[0].TokenExpires != "" {
t.Fatalf("appid token display was not cleared: %#v", st.AppIDs[0])
}
}
func TestAdminCardsExportMarksExportedCardsAndPersists(t *testing.T) {
dbPath := t.TempDir() + "/onelink_mock.db"
service, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+cards[0].MSISDN, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
exportedPage := service.store.QueryCards("", "exported", 1, 20)
if exportedPage.Total != 1 || exportedPage.Items[0].MSISDN != cards[0].MSISDN {
t.Fatalf("exported cards = %#v", exportedPage)
}
if all := service.store.QueryCards("", "", 1, 20); len(all.Items) != 2 || all.Items[0].MSISDN != cards[1].MSISDN {
t.Fatalf("cards are not newest-first: %#v", all.Items)
}
reloaded, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
persisted := reloaded.store.QueryCards("", "exported", 1, 20)
if persisted.Total != 1 || persisted.Items[0].MSISDN != cards[0].MSISDN || persisted.Items[0].ExportCheckedAt == "" {
t.Fatalf("persisted exported cards = %#v", persisted)
}
}
func TestAdminCardRegionLimitStatusPersistsAndQueries(t *testing.T) {
dbPath := t.TempDir() + "/onelink_mock.db"
service, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(1); err != nil {
t.Fatal(err)
}
card := service.store.Snapshot().Cards[0]
card.RegionLimitStatus = "1"
body, err := json.Marshal(card)
if err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/cards", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("save status = %d body=%s", res.Code, res.Body.String())
}
reloaded, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
if got := reloaded.store.Snapshot().Cards[0].RegionLimitStatus; got != "1" {
t.Fatalf("persisted region limit status = %q, want 1", got)
}
token := "region-limit-test-token"
if err := reloaded.store.SaveMutation(func(st *Store) error {
st.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: time.Now().Add(time.Hour)}
return nil
}); err != nil {
t.Fatal(err)
}
query := func(path string, target any) {
method := http.MethodGet
var requestBody *bytes.Reader
if strings.HasPrefix(path, "/sim/") {
method = http.MethodPost
requestBody = bytes.NewReader([]byte(`{"simKeys":[{"msisdn":"` + card.MSISDN + `"}],"reqData":{}}`))
} else {
requestBody = bytes.NewReader(nil)
}
queryString := ""
if method == http.MethodGet {
queryString = "?token=" + token + "&msisdn=" + card.MSISDN
}
req := httptest.NewRequest(method, path+queryString, requestBody)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
reloaded.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, res.Code, res.Body.String())
}
if err := json.Unmarshal(res.Body.Bytes(), target); err != nil {
t.Fatal(err)
}
}
var oneLink Response
query("/v5/ec/query/region-limit-status", &oneLink)
result, ok := oneLink.Result[0].(map[string]any)
if !ok || len(oneLink.Result) != 1 || result["regionLimitStatus"] != "1" {
t.Fatalf("one link region response = %#v", oneLink)
}
var chanResponses []chanResponse[chanRegionLimitStatus]
query("/sim/query/sim-region-limit-status", &chanResponses)
if len(chanResponses) != 1 || chanResponses[0].ResData.RegionLimitStatus != "1" {
t.Fatalf("chan region response = %#v", chanResponses)
}
}
func TestAdminGenerateAppendsRequestedCount(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
body := strings.NewReader(`{"count":2}`)
req := httptest.NewRequest(http.MethodPost, "/api/generate", body)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var result GenerateResult
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Generated != 2 || result.Total != 2 {
t.Fatalf("result = %#v, want generated 2 total 2", result)
}
if got := service.store.Snapshot(); len(got.Cards) != 2 {
t.Fatalf("store cards = %d, want 2", len(got.Cards))
}
}
func TestAdminGenerateAssignsSelectedGroup(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
create := httptest.NewRequest(http.MethodPost, "/api/card-groups", strings.NewReader(`{"name":"批次一"}`))
create.Header.Set("Content-Type", "application/json")
createdRes := httptest.NewRecorder()
service.Router().ServeHTTP(createdRes, create)
if createdRes.Code != http.StatusOK {
t.Fatalf("create group status = %d body=%s", createdRes.Code, createdRes.Body.String())
}
var group CardGroup
if err := json.Unmarshal(createdRes.Body.Bytes(), &group); err != nil {
t.Fatal(err)
}
generate := httptest.NewRequest(http.MethodPost, "/api/generate", strings.NewReader(fmt.Sprintf(`{"count":2,"groupId":%d}`, group.ID)))
generate.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, generate)
if res.Code != http.StatusOK {
t.Fatalf("generate status = %d body=%s", res.Code, res.Body.String())
}
for _, card := range service.store.Snapshot().Cards {
if card.GroupID != group.ID || card.GroupName != group.Name {
t.Fatalf("card group = %#v, want %d/%s", card, group.ID, group.Name)
}
}
}
func TestAdminCardsQueriesByAnyExactIdentifier(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
for _, keyword := range []string{cards[0].MSISDN, cards[0].ICCID, cards[0].IMSI} {
t.Run(keyword, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword="+keyword, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var result struct {
Items []SIMCard `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Total != 1 || len(result.Items) != 1 || result.Items[0].MSISDN != cards[0].MSISDN {
t.Fatalf("result = %#v, want the exact matching card", result)
}
})
}
}
func TestAdminCardsQueryReturnsEmptyPageWhenNotFound(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword=not-found", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var result struct {
Items []SIMCard `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Items == nil || result.Total != 0 || len(result.Items) != 0 {
t.Fatalf("result = %#v, want an empty page", result)
}
}
func TestAdminCardsExportUsesInboundExcelHeaders(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(1); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards/export", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
if contentType := res.Header().Get("Content-Type"); contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
t.Fatalf("content type = %q", contentType)
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
for _, header := range inboundExportHeaders {
if !strings.Contains(workbookText, header) {
t.Fatalf("export workbook missing header %q in %s", header, workbookText)
}
}
if !strings.Contains(workbookText, "channel_yd_mock") || !strings.Contains(workbookText, "CMIOT-SD-500M-24M-D01") {
t.Fatalf("export workbook missing expected generated values: %s", workbookText)
}
for _, marker := range []string{"sharedStrings.xml", "styles.xml"} {
if !zipContainsFile(t, res.Body.Bytes(), "xl/"+marker) {
t.Fatalf("export workbook missing xl/%s", marker)
}
}
}
func zipContainsFile(t *testing.T, raw []byte, name string) bool {
t.Helper()
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
for _, file := range reader.File {
if file.Name == name {
return true
}
}
return false
}
func TestAdminCardsExportFiltersSelectedMSISDNs(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(3); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
selected := cards[0].MSISDN + "," + cards[2].MSISDN
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+selected, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
if !strings.Contains(workbookText, cards[0].MSISDN) || !strings.Contains(workbookText, cards[2].MSISDN) {
t.Fatalf("export workbook missing selected cards: %s", workbookText)
}
if strings.Contains(workbookText, cards[1].MSISDN) {
t.Fatalf("export workbook included unselected card %s: %s", cards[1].MSISDN, workbookText)
}
}
func TestAdminCardsExportFilteredResultsAcrossPages(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
createdAt := time.Now().Format("2006-01-02 15:04:05")
if err = service.store.SaveMutation(func(st *Store) error {
st.Groups = append(st.Groups,
CardGroup{ID: 7, Name: "group-seven", CreatedAt: createdAt, UpdatedAt: createdAt},
CardGroup{ID: 8, Name: "group-eight", CreatedAt: createdAt, UpdatedAt: createdAt},
)
return nil
}); err != nil {
t.Fatal(err)
}
if _, err = service.store.GenerateCardsInGroup(25, 7); err != nil {
t.Fatal(err)
}
if _, err = service.store.GenerateCardsInGroup(5, 8); err != nil {
t.Fatal(err)
}
groupSevenCards := service.store.QueryAllCardsByGroup("", "", 7, true)
if len(groupSevenCards) != 25 {
t.Fatalf("group 7 cards = %d, want 25", len(groupSevenCards))
}
alreadyExported := groupSevenCards[0].MSISDN
if err = service.store.SaveMutation(func(st *Store) error {
for i := range st.Cards {
if st.Cards[i].MSISDN == alreadyExported {
st.Cards[i].Exported = true
st.Cards[i].ExportCheckedAt = "2026-08-27 00:00:00"
break
}
}
return nil
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&groupId=7&exportStatus=not_exported", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
for _, card := range groupSevenCards[1:] {
if !strings.Contains(workbookText, card.MSISDN) {
t.Fatalf("export workbook missing filtered card %s", card.MSISDN)
}
}
if strings.Contains(workbookText, alreadyExported) {
t.Fatalf("export workbook included already exported card %s", alreadyExported)
}
for _, card := range service.store.QueryAllCardsByGroup("", "", 8, true) {
if strings.Contains(workbookText, card.MSISDN) {
t.Fatalf("export workbook included card from another group %s", card.MSISDN)
}
}
if exported := service.store.QueryAllCardsByGroup("", "exported", 7, true); len(exported) != 25 {
t.Fatalf("exported group 7 cards = %d, want 25", len(exported))
}
if exported := service.store.QueryAllCardsByGroup("", "exported", 8, true); len(exported) != 0 {
t.Fatalf("exported group 8 cards = %d, want 0", len(exported))
}
}
func TestAdminCardsExportFilteredResultsByKeyword(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err = service.store.GenerateCards(3); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&keyword="+cards[1].ICCID, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
if !strings.Contains(workbookText, cards[1].MSISDN) {
t.Fatalf("export workbook missing keyword match %s", cards[1].MSISDN)
}
for _, card := range []SIMCard{cards[0], cards[2]} {
if strings.Contains(workbookText, card.MSISDN) {
t.Fatalf("export workbook included non-matching card %s", card.MSISDN)
}
}
}
func TestAdminCardsExportFilteredResultsRejectsInvalidFilter(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&exportStatus=invalid", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
}
func unzipWorkbookText(t *testing.T, raw []byte) string {
t.Helper()
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
var out strings.Builder
for _, file := range reader.File {
if !strings.HasSuffix(file.Name, ".xml") {
continue
}
rc, err := file.Open()
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(&out, rc)
_ = rc.Close()
}
return out.String()
}
+612
View File
@@ -0,0 +1,612 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
chanSuccessCode = 0
chanErrorCode = 9999
)
type chanSimKey struct {
ChanKey string `json:"chanKey"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
MSISDN string `json:"msisdn"`
}
type chanRequest struct {
SimKeys []chanSimKey `json:"simKeys"`
ReqData json.RawMessage `json:"reqData"`
}
type chanResponse[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
SimKey chanSimKey `json:"simKey"`
ResData T `json:"resData"`
}
type chanBasicInfo struct {
ActiveDate string `json:"activeDate"`
OpenDate string `json:"openDate"`
Remark string `json:"remark"`
}
type chanSession struct {
ApnID string `json:"apnId"`
Rat string `json:"rat"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
IPv6Prefix string `json:"ipv6Prefix"`
Status string `json:"status"`
CreateTime string `json:"createTime"`
}
type chanCardStatus struct {
CardStatus int `json:"cardStatus"`
LastChangeDate string `json:"lastChangeDate"`
}
type chanIMEI struct {
IMEI string `json:"imei"`
}
type chanUsage struct {
DataUsage float64 `json:"dataUsage"`
}
type chanMonthlyUsage struct {
DeadLine string `json:"deadLine"`
DataAmount float64 `json:"dataAmount"`
}
type chanDailyUsage struct {
QueryDate string `json:"queryDate"`
DataAmount float64 `json:"dataAmount"`
}
type chanAPN struct {
APNName string `json:"apnName"`
Status string `json:"status"`
}
type chanAPNList struct {
APNList []chanAPN `json:"apnList"`
}
type chanOrder struct {
OrderNum string `json:"orderNum"`
}
type chanBindStatus struct {
BindStatus string `json:"bindStatus"`
}
type chanDeviceStatus struct {
DeviceStatus string `json:"deviceStatus"`
}
type chanRegionLimitStatus struct {
RegionLimitStatus string `json:"regionLimitStatus"`
}
type chanReqData struct {
QueryDate string `json:"queryDate"`
OperType string `json:"operType"`
APNName string `json:"apnName"`
TestType *int `json:"testType"`
}
func (s *Service) chanAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/sim/")
switch path {
case "query/sim-basic-info":
s.chanQueryBasicInfo(w, r)
case "query/sim-session":
s.chanQuerySession(w, r)
case "query/sim-status", "query/sim-status-batch":
s.chanQueryStatus(w, r)
case "query/sim-card-bind-status":
s.chanQueryBindStatus(w, r)
case "query/sim-device-status":
s.chanQueryDeviceStatus(w, r)
case "query/sim-region-limit-status":
s.chanQueryRegionLimitStatus(w, r)
case "query/sim-imei":
s.chanQueryIMEI(w, r)
case "query/sim-datausage":
s.chanQueryDataUsage(w, r)
case "query/sim-datausage-monthly", "query/sim-datausage-monthly-batch":
s.chanQueryMonthly(w, r)
case "query/sim-datausage-daily", "query/sim-datausage-daily-batch":
s.chanQueryDaily(w, r)
case "query/sim-apn-info":
s.chanQueryAPN(w, r)
case "change/sim-status":
s.chanChangeStatus(w, r)
case "change/sim-apn":
s.chanChangeAPN(w, r)
default:
writeJSON(w, http.StatusNotFound, map[string]string{"message": "unknown sim endpoint"})
}
}
func parseChanRequest(r *http.Request) (chanRequest, error) {
var req chanRequest
if r.Method != http.MethodGet && r.Body != nil {
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
return req, err
}
if len(strings.TrimSpace(string(body))) > 0 {
if err := json.Unmarshal(body, &req); err != nil {
return req, fmt.Errorf("invalid JSON body: %w", err)
}
}
}
if len(req.SimKeys) == 0 {
req.SimKeys = simKeysFromQuery(r.URL.Query())
}
if len(req.ReqData) == 0 {
data := chanReqData{
QueryDate: r.URL.Query().Get("queryDate"),
OperType: r.URL.Query().Get("operType"),
APNName: r.URL.Query().Get("apnName"),
}
if value := r.URL.Query().Get("testType"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
data.TestType = &parsed
}
}
if data.QueryDate != "" || data.OperType != "" || data.APNName != "" || data.TestType != nil {
req.ReqData, _ = json.Marshal(data)
}
}
return req, nil
}
func simKeysFromQuery(q url.Values) []chanSimKey {
if raw := q.Get("simKeys"); raw != "" {
var keys []chanSimKey
if json.Unmarshal([]byte(raw), &keys) == nil {
return keys
}
}
var values []chanSimKey
for _, item := range []struct {
key string
name string
}{
{"iccids", "iccid"}, {"imsis", "imsi"}, {"msisdns", "msisdn"},
} {
for _, value := range splitIDs(q.Get(item.key)) {
key := chanSimKey{}
switch item.name {
case "iccid":
key.ICCID = value
case "imsi":
key.IMSI = value
case "msisdn":
key.MSISDN = value
}
values = append(values, key)
}
}
if len(values) == 0 {
key := chanSimKey{ICCID: q.Get("iccid"), IMSI: q.Get("imsi"), MSISDN: q.Get("msisdn")}
if key.ICCID != "" || key.IMSI != "" || key.MSISDN != "" {
values = append(values, key)
}
}
return values
}
func splitIDs(raw string) []string {
if raw == "" {
return nil
}
raw = strings.ReplaceAll(raw, ",", "_")
parts := strings.Split(raw, "_")
values := make([]string, 0, len(parts))
for _, part := range parts {
if value := strings.TrimSpace(part); value != "" {
values = append(values, value)
}
}
return values
}
func decodeChanReqData(req chanRequest) chanReqData {
var data chanReqData
_ = json.Unmarshal(req.ReqData, &data)
return data
}
func chanSuccess[T any](key chanSimKey, data T) chanResponse[T] {
return chanResponse[T]{Code: chanSuccessCode, Message: "success", SimKey: key, ResData: data}
}
func chanFailure[T any](key chanSimKey, message string) chanResponse[T] {
return chanResponse[T]{Code: chanErrorCode, Message: message, SimKey: key, ResData: *new(T)}
}
func writeChanResponses[T any](w http.ResponseWriter, responses []chanResponse[T]) {
writeJSON(w, http.StatusOK, responses)
}
func (s *Service) chanCards(r *http.Request) (chanRequest, Store, error) {
req, err := parseChanRequest(r)
if err != nil {
return req, Store{}, err
}
return req, s.store.Snapshot(), nil
}
func findChanCard(st Store, key chanSimKey) (SIMCard, bool) {
if key.ICCID != "" {
for _, card := range st.Cards {
if card.ICCID == key.ICCID {
return card, true
}
}
}
if key.IMSI != "" {
for _, card := range st.Cards {
if card.IMSI == key.IMSI {
return card, true
}
}
}
if key.MSISDN != "" {
for _, card := range st.Cards {
if card.MSISDN == key.MSISDN {
return card, true
}
}
}
return SIMCard{}, false
}
func chanRequestError[T any](w http.ResponseWriter, err error) {
writeJSON(w, http.StatusBadRequest, []chanResponse[T]{{Code: chanErrorCode, Message: err.Error()}})
}
func (s *Service) chanQueryBasicInfo(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanBasicInfo](w, err)
return
}
responses := make([]chanResponse[chanBasicInfo], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanBasicInfo](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanBasicInfo{ActiveDate: card.ActiveDate, OpenDate: card.OpenDate, Remark: card.Remark}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQuerySession(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanSession](w, err)
return
}
responses := make([]chanResponse[chanSession], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanSession](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanSession{ApnID: primaryAPN(card).Name, Rat: card.RAT, IPv4: card.IP, IPv6: card.IPv6, IPv6Prefix: card.IPv6Prefix, Status: chanSessionStatus(card.SessionStatus), CreateTime: card.LastChangeDate}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanCardStatus](w, err)
return
}
responses := make([]chanResponse[chanCardStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanCardStatus](key, "card not found"))
continue
}
status, _ := strconv.Atoi(card.CardStatus)
responses = append(responses, chanSuccess(key, chanCardStatus{CardStatus: status, LastChangeDate: card.LastChangeDate}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryBindStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanBindStatus](w, err)
return
}
responses := make([]chanResponse[chanBindStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanBindStatus](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanBindStatus{BindStatus: card.CardBindStatus}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryDeviceStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanDeviceStatus](w, err)
return
}
responses := make([]chanResponse[chanDeviceStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanDeviceStatus](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanDeviceStatus{DeviceStatus: chanSessionStatus(card.OnOffStatus)}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryRegionLimitStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanRegionLimitStatus](w, err)
return
}
responses := make([]chanResponse[chanRegionLimitStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanRegionLimitStatus](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanRegionLimitStatus{RegionLimitStatus: card.RegionLimitStatus}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryIMEI(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanIMEI](w, err)
return
}
responses := make([]chanResponse[chanIMEI], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanIMEI](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanIMEI{IMEI: card.IMEI}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryDataUsage(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanUsage](w, err)
return
}
responses := make([]chanResponse[chanUsage], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanUsage](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanUsage{DataUsage: card.MonthUsageKB / 1024}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryMonthly(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanMonthlyUsage](w, err)
return
}
data := decodeChanReqData(req)
responses := make([]chanResponse[chanMonthlyUsage], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanMonthlyUsage](key, "card not found"))
continue
}
amount := card.MonthUsageKB
if data.QueryDate != "" && len(data.QueryDate) == 6 && data.QueryDate != time.Now().Format("200601") {
amount = card.MonthlyBaseKB
}
responses = append(responses, chanSuccess(key, chanMonthlyUsage{DeadLine: time.Now().AddDate(0, 0, -1).Format("20060102"), DataAmount: amount / 1024}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryDaily(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanDailyUsage](w, err)
return
}
data := decodeChanReqData(req)
queryDate := data.QueryDate
if queryDate == "" {
queryDate = r.URL.Query().Get("queryDate")
}
if queryDate == "" {
queryDate = time.Now().Format("20060102")
}
responses := make([]chanResponse[chanDailyUsage], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanDailyUsage](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanDailyUsage{QueryDate: queryDate, DataAmount: card.DailyBaseKB / 1024}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryAPN(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanAPNList](w, err)
return
}
responses := make([]chanResponse[chanAPNList], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanAPNList](key, "card not found"))
continue
}
apns := make([]chanAPN, 0, len(card.APNs))
for _, apn := range card.APNs {
apns = append(apns, chanAPN{APNName: apn.Name, Status: apn.Status})
}
responses = append(responses, chanSuccess(key, chanAPNList{APNList: apns}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanChangeStatus(w http.ResponseWriter, r *http.Request) {
req, err := parseChanRequest(r)
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
data := decodeChanReqData(req)
if data.OperType != "0" && data.OperType != "1" {
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
responses = append(responses, chanFailure[chanOrder](key, "unsupported operType, expected 0(stop) or 1(resume)"))
}
writeChanResponses(w, responses)
return
}
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
err = s.store.SaveMutation(func(st *Store) error {
for _, key := range req.SimKeys {
idx := findChanCardIndex(*st, key)
if idx < 0 {
responses = append(responses, chanFailure[chanOrder](key, "card not found"))
continue
}
card := &st.Cards[idx]
if data.OperType == "0" {
card.CardStatus = "4"
} else {
card.CardStatus = "2"
}
card.LastChangeDate = time.Now().Format("2006-01-02 15:04:05")
orderNum := "ORD" + time.Now().Format("20060102150405") + randomHex(3)
st.Orders = append(st.Orders, Order{OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI, Type: "sim-status", OperType: data.OperType, Status: "completed", CreatedAt: card.LastChangeDate})
responses = append(responses, chanSuccess(key, chanOrder{OrderNum: orderNum}))
}
return nil
})
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
writeChanResponses(w, responses)
}
func (s *Service) chanChangeAPN(w http.ResponseWriter, r *http.Request) {
req, err := parseChanRequest(r)
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
data := decodeChanReqData(req)
if data.APNName == "" || (data.OperType != "0" && data.OperType != "1") {
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
responses = append(responses, chanFailure[chanOrder](key, "apnName and operType are required"))
}
writeChanResponses(w, responses)
return
}
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
err = s.store.SaveMutation(func(st *Store) error {
for _, key := range req.SimKeys {
idx := findChanCardIndex(*st, key)
if idx < 0 {
responses = append(responses, chanFailure[chanOrder](key, "card not found"))
continue
}
card := &st.Cards[idx]
status := "0"
if data.OperType == "0" {
status = "1"
}
found := false
for i := range card.APNs {
if strings.EqualFold(card.APNs[i].Name, data.APNName) {
card.APNs[i].Status = status
found = true
}
}
if !found {
card.APNs = append(card.APNs, APN{Name: data.APNName, Status: status})
}
orderNum := "APN" + time.Now().Format("20060102150405") + randomHex(3)
now := time.Now().Format("2006-01-02 15:04:05")
st.Orders = append(st.Orders, Order{OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI, Type: "sim-apn-function", OperType: data.OperType, APNName: data.APNName, Status: "completed", CreatedAt: now})
responses = append(responses, chanSuccess(key, chanOrder{OrderNum: orderNum}))
}
return nil
})
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
writeChanResponses(w, responses)
}
func findChanCardIndex(st Store, key chanSimKey) int {
for i, card := range st.Cards {
if (key.ICCID != "" && card.ICCID == key.ICCID) || (key.IMSI != "" && card.IMSI == key.IMSI) || (key.MSISDN != "" && card.MSISDN == key.MSISDN) {
return i
}
}
return -1
}
func chanSessionStatus(status string) string {
switch strings.ToUpper(strings.TrimSpace(status)) {
case "01", "1", "0", "ONLINE":
return "0"
case "00", "-1", "OFFLINE":
return "-1"
default:
return "-2"
}
}
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestChanQueryEndpoints(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
body, _ := json.Marshal(map[string]any{
"simKeys": []map[string]string{{"chanKey": "channel_yd_mock", "iccid": cards[0].ICCID, "imsi": cards[0].IMSI, "msisdn": cards[0].MSISDN}},
"reqData": map[string]string{"queryDate": "20260101"},
})
paths := []string{
"/sim/query/sim-basic-info",
"/sim/query/sim-session",
"/sim/query/sim-status",
"/sim/query/sim-status-batch",
"/sim/query/sim-card-bind-status",
"/sim/query/sim-device-status",
"/sim/query/sim-region-limit-status",
"/sim/query/sim-imei",
"/sim/query/sim-datausage",
"/sim/query/sim-datausage-monthly",
"/sim/query/sim-datausage-monthly-batch",
"/sim/query/sim-datausage-daily",
"/sim/query/sim-datausage-daily-batch",
"/sim/query/sim-apn-info",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var responses []struct {
Code int `json:"code"`
SimKey chanSimKey `json:"simKey"`
}
if err := json.Unmarshal(res.Body.Bytes(), &responses); err != nil {
t.Fatal(err)
}
if len(responses) != 1 || responses[0].Code != chanSuccessCode || responses[0].SimKey.ICCID != cards[0].ICCID {
t.Fatalf("responses = %#v", responses)
}
})
}
}
func TestChanBatchAndQueryStringCompatibility(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
req := httptest.NewRequest(http.MethodGet, "/sim/query/sim-status-batch?iccids="+cards[0].ICCID+"_"+cards[1].ICCID, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var responses []chanResponse[chanCardStatus]
if err := json.Unmarshal(res.Body.Bytes(), &responses); err != nil {
t.Fatal(err)
}
if len(responses) != 2 || responses[0].Code != chanSuccessCode || responses[1].Code != chanSuccessCode {
t.Fatalf("responses = %#v", responses)
}
}
func TestChanChangesPersistAndCreateOrders(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(1); err != nil {
t.Fatal(err)
}
card := service.store.Snapshot().Cards[0]
statusBody := []byte(`{"simKeys":[{"iccid":"` + card.ICCID + `"}],"reqData":{"operType":"0"}}`)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/sim/change/sim-status", bytes.NewReader(statusBody))
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status change status = %d body=%s", res.Code, res.Body.String())
}
if got := service.store.Snapshot().Cards[0].CardStatus; got != "4" {
t.Fatalf("card status = %s, want 4", got)
}
apnBody := []byte(`{"simKeys":[{"iccid":"` + card.ICCID + `"}],"reqData":{"apnName":"TEST_APN","operType":"0"}}`)
res = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/sim/change/sim-apn", bytes.NewReader(apnBody))
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("apn change status = %d body=%s", res.Code, res.Body.String())
}
updated := service.store.Snapshot()
if len(updated.Orders) != 2 {
t.Fatalf("orders = %d, want 2", len(updated.Orders))
}
found := false
for _, apn := range updated.Cards[0].APNs {
if apn.Name == "TEST_APN" && apn.Status == "1" {
found = true
}
}
if !found {
t.Fatalf("updated APNs = %#v", updated.Cards[0].APNs)
}
}
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOneLinkDataDiagnosisUsesConfiguredAPNAndPCC(t *testing.T) {
service, err := NewService(t.TempDir() + "/mock.db")
if err != nil {
t.Fatal(err)
}
st := service.store.Snapshot()
st.Cards = GenerateSIMCards(time.Now(), 1)
card := &st.Cards[0]
card.APNs[0].DiagnosisStatus = "4"
card.APNs[0].ServiceUsageState = "96"
card.APNs[0].OverUseDetails = "16|17"
card.APNs[0].PCCs = []PCC{{ServiceCode: "2323232", Name: "APN 缺省流量", PolicyStatus: "4", ServiceUsageState: "1", ModifyTime: "20220812154700"}}
if err := service.store.SaveMutation(func(next *Store) error { *next = st; return nil }); err != nil {
t.Fatal(err)
}
st = service.store.Snapshot()
token := "diagnosis-test-token"
if err := service.store.SaveMutation(func(next *Store) error {
next.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: nowPlusHour()}
return nil
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/v5/ec/query/sim-data-diagnosis?token="+token+"&msisdn="+st.Cards[0].MSISDN, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body.String())
}
var body Response
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status != "0" {
t.Fatalf("response=%s", res.Body.String())
}
encoded, _ := json.Marshal(body.Result)
text := string(encoded)
for _, want := range []string{"\"apnStatus\":\"4\"", "\"apnServiceUsageState\":\"96\"", "\"overUseDetails\":\"16|17\"", "\"pccServiceCode\":\"2323232\"", "\"pccPolicyStatus\":\"4\""} {
if !contains(text, want) {
t.Fatalf("missing %s in %s", want, text)
}
}
}
func TestCardBindStatusRequiresMSISDNAndTestType(t *testing.T) {
service, err := NewService(t.TempDir() + "/mock.db")
if err != nil {
t.Fatal(err)
}
st := service.store.Snapshot()
st.Cards = GenerateSIMCards(time.Now(), 1)
token := "bind-test-token"
if err := service.store.SaveMutation(func(next *Store) error {
next.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: nowPlusHour()}
return nil
}); err != nil {
t.Fatal(err)
}
for _, query := range []string{"iccid=" + st.Cards[0].ICCID + "&testType=0", "msisdn=" + st.Cards[0].MSISDN} {
req := httptest.NewRequest(http.MethodGet, "/v5/ec/query/card-bind-status?token="+token+"&"+query, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
var body Response
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status == "0" {
t.Fatalf("expected validation failure for %s", query)
}
}
}
func nowPlusHour() (value time.Time) { return time.Now().Add(time.Hour) }
func contains(value, want string) bool { return strings.Contains(value, want) }
+161
View File
@@ -0,0 +1,161 @@
package service
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
)
func exportInboundCardsXLSX(cards []SIMCard) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
files := map[string]string{
"[Content_Types].xml": contentTypesXML,
"_rels/.rels": rootRelsXML,
"xl/workbook.xml": workbookXML,
"xl/_rels/workbook.xml.rels": workbookRelsXML,
"xl/sharedStrings.xml": sharedStringsXML(),
"xl/styles.xml": stylesXML,
"xl/worksheets/sheet1.xml": sheetXML(cards),
}
for _, name := range []string{"[Content_Types].xml", "_rels/.rels", "xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/sharedStrings.xml", "xl/styles.xml", "xl/worksheets/sheet1.xml"} {
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write([]byte(files[name])); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sheetXML(cards []SIMCard) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">`)
b.WriteString(`<dimension ref="A1:R`)
b.WriteString(strconv.Itoa(len(cards) + 1))
b.WriteString(`"/><sheetViews><sheetView tabSelected="true" workbookViewId="0"/></sheetViews>`)
b.WriteString(`<sheetFormatPr defaultRowHeight="15"/><cols><col min="1" max="18" width="9.140625" style="1"/></cols><sheetData>`)
writeSharedStringRow(&b, 1, len(inboundExportHeaders))
for i, card := range cards {
row := inboundExportRow(card)
values := make([]string, 0, len(inboundExportColumns))
for _, column := range inboundExportColumns {
values = append(values, row[column.Key])
}
writeSheetRow(&b, i+2, values)
}
b.WriteString(`</sheetData></worksheet>`)
return b.String()
}
func writeSharedStringRow(b *strings.Builder, rowNum, count int) {
b.WriteString(`<row r="`)
b.WriteString(strconv.Itoa(rowNum))
b.WriteString(`">`)
for i := 0; i < count; i++ {
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
b.WriteString(`<c r="`)
b.WriteString(ref)
b.WriteString(`" s="1" t="s"><v>`)
b.WriteString(strconv.Itoa(i))
b.WriteString(`</v></c>`)
}
b.WriteString(`</row>`)
}
func writeSheetRow(b *strings.Builder, rowNum int, values []string) {
b.WriteString(`<row r="`)
b.WriteString(strconv.Itoa(rowNum))
b.WriteString(`">`)
for i, value := range values {
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
b.WriteString(`<c r="`)
b.WriteString(ref)
b.WriteString(`" s="1" t="inlineStr"><is><t>`)
b.WriteString(xmlEscape(value))
b.WriteString(`</t></is></c>`)
}
b.WriteString(`</row>`)
}
func sharedStringsXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="`)
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
b.WriteString(`" uniqueCount="`)
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
b.WriteString(`">`)
for _, header := range inboundExportHeaders {
b.WriteString(`<si><t>`)
b.WriteString(xmlEscape(header))
b.WriteString(`</t></si>`)
}
b.WriteString(`</sst>`)
return b.String()
}
func columnName(index int) string {
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
}
return string(out)
}
func xmlEscape(value string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(value))
return b.String()
}
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>`
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`
const workbookXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>`
const workbookRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
</Relationships>`
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
<dxfs count="0"/><tableStyles count="0" defaultPivotStyle="PivotStyleLight16" defaultTableStyle="TableStyleMedium2"/>
</styleSheet>`
+160
View File
@@ -0,0 +1,160 @@
package service
import (
"fmt"
"strconv"
"sync"
"time"
)
const (
generatedChanKey = "channel_yd_mock"
generatedCardType = "1"
generatedPackageCode = "CMIOT-SD-500M-24M-D01"
generatedSupplierCode = "SUP_CMCC"
generatedMonthlyUsage = 18432.0
generatedDailyUsage = 614.4
generatedPoolUsageByte = 18874368.0
)
type inboundExportColumn struct {
Header string
Key string
}
var inboundExportColumns = []inboundExportColumn{
{Header: "ICCID*", Key: "ICCID"},
{Header: "MSISDN*", Key: "MSISDN"},
{Header: "IMSI*", Key: "IMSI"},
{Header: "IMEI", Key: "IMEI"},
{Header: "通道编号*", Key: "chanKey"},
{Header: "卡类型", Key: "cardType"},
{Header: "上游套餐编码", Key: "upPkgCode"},
{Header: "供应商编码", Key: "suppliersCode"},
{Header: "上游激活时间", Key: "upstreamActivationTime"},
{Header: "是否贴片转插拔", Key: "insertToPlugEnabled"},
{Header: "是否运营商达量断网", Key: "carrierTrafficCutoffEnabled"},
{Header: "协议到期时间", Key: "agreementExpiryDate"},
{Header: "是否有测试期", Key: "hasTestPeriod"},
{Header: "测试期天数", Key: "testPeriodDays"},
{Header: "测试期最晚过期日期", Key: "testPeriodLatestExpiryDate"},
{Header: "是否有沉默期", Key: "hasSilentPeriod"},
{Header: "沉默期天数", Key: "silentPeriodDays"},
{Header: "沉默期最晚过期日期", Key: "silentPeriodLatestExpiryDate"},
}
var inboundExportHeaders = func() []string {
headers := make([]string, 0, len(inboundExportColumns))
for _, column := range inboundExportColumns {
headers = append(headers, column.Header)
}
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{}
}
cards := make([]SIMCard, 0, count)
base := batchTime.UnixMilli()
dbTime := batchTime.Format("2006-01-02 15:04:05")
for i := 0; i < count; i++ {
seq := nextGeneratedSeq(base + int64(i+1))
card := SIMCard{
MSISDN: fmt.Sprintf("141%08d", seq%100000000),
ICCID: fmt.Sprintf("898604%014d", seq%100000000000000),
IMSI: fmt.Sprintf("46004%010d", seq%10000000000),
IMEI: fmt.Sprintf("86%013d", seq%10000000000000),
Remark: sourceRemark("CMCC", generatedPackageCode, generatedPackageCode),
CardStatus: "2",
LastChangeDate: dbTime,
StopReason: "000000000000",
FrozenStatus: "0",
FrozenOperTime: batchTime.Format("20060102150405"),
OpenDate: dbTime,
ActiveDate: dbTime,
OnOffStatus: "1",
SessionStatus: "01",
IP: fmt.Sprintf("10.%d.%d.%d", (seq/10000)%255, (seq/100)%255, seq%255),
IPv6Prefix: "",
IPv6: "",
RAT: "6",
CardBindStatus: "1",
RegionLimitStatus: "0",
AreaRestrictType: "1",
RestrictedArea: "110000,310000,440000",
MonthUsageKB: generatedMonthlyUsage,
PoolUsageBytes: generatedPoolUsageByte,
DailyBaseKB: generatedDailyUsage,
MonthlyBaseKB: generatedMonthlyUsage,
GrowthPerMinuteKB: 0,
LastUsageUpdateTime: batchTime.Format(time.RFC3339),
APNs: []APN{
{Name: "CMIOT", Status: "1", UsageBytes: generatedPoolUsageByte, ModifyTime: batchTime.Format("20060102150405"), ServiceUsageState: "1", PCCs: []PCC{{ServiceCode: "default", Name: "APN 缺省流量", PolicyStatus: "2", ServiceUsageState: "1", ModifyTime: batchTime.Format("20060102150405")}}},
},
}
cards = append(cards, card)
}
return cards
}
func inboundExportRow(card SIMCard) map[string]string {
activation := parseCardTime(card.ActiveDate)
return map[string]string{
"ICCID": card.ICCID,
"MSISDN": card.MSISDN,
"IMSI": card.IMSI,
"IMEI": card.IMEI,
"chanKey": generatedChanKey,
"cardType": generatedCardType,
"upPkgCode": generatedPackageCode,
"suppliersCode": generatedSupplierCode,
"upstreamActivationTime": activation.Format("2006-01-02 15:04:05"),
"insertToPlugEnabled": "true",
"carrierTrafficCutoffEnabled": "false",
"agreementExpiryDate": activation.AddDate(1, 0, 0).Format("2006-01-02"),
"hasTestPeriod": "true",
"testPeriodDays": "30",
"testPeriodLatestExpiryDate": activation.AddDate(0, 0, 30).Format("2006-01-02"),
"hasSilentPeriod": "false",
"silentPeriodDays": "",
"silentPeriodLatestExpiryDate": "",
}
}
func parseCardTime(value string) time.Time {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, time.RFC3339Nano} {
if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return t
}
}
if unixMilli, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.UnixMilli(unixMilli)
}
return time.Now()
}
+86
View File
@@ -0,0 +1,86 @@
package service
import (
"reflect"
"testing"
"time"
)
func TestInboundExportHeadersMatchLatestTemplate(t *testing.T) {
expected := []string{
"ICCID*", "MSISDN*", "IMSI*", "IMEI", "通道编号*", "卡类型", "上游套餐编码", "供应商编码",
"上游激活时间", "是否贴片转插拔", "是否运营商达量断网", "协议到期时间", "是否有测试期",
"测试期天数", "测试期最晚过期日期", "是否有沉默期", "沉默期天数", "沉默期最晚过期日期",
}
if !reflect.DeepEqual(inboundExportHeaders, expected) {
t.Fatalf("export headers = %#v, want %#v", inboundExportHeaders, expected)
}
}
func TestGeneratedCardsUseTimestampIdentifiersAndFixedTraffic(t *testing.T) {
batchTime := time.Date(2026, 7, 10, 10, 0, 0, 123000000, time.UTC)
cards := GenerateSIMCards(batchTime, 3)
if len(cards) != 3 {
t.Fatalf("generated card count = %d, want 3", len(cards))
}
first := cards[0]
second := cards[1]
if first.ICCID == second.ICCID || first.MSISDN == second.MSISDN || first.IMSI == second.IMSI || first.IMEI == second.IMEI {
t.Fatalf("generated identifiers should differ within a batch: first=%#v second=%#v", first, second)
}
if len(first.ICCID) != 20 || first.ICCID[:6] != "898604" {
t.Fatalf("iccid = %q, want 20 digits with 898604 prefix", first.ICCID)
}
if len(first.MSISDN) != 11 || first.MSISDN[:3] != "141" {
t.Fatalf("msisdn = %q, want 11 digits with 141 prefix", first.MSISDN)
}
if len(first.IMSI) != 15 || first.IMSI[:5] != "46004" {
t.Fatalf("imsi = %q, want 15 digits with 46004 prefix", first.IMSI)
}
if len(first.IMEI) != 15 || first.IMEI[:2] != "86" {
t.Fatalf("imei = %q, want 15 digits with 86 prefix", first.IMEI)
}
if first.MonthUsageKB != 18432 || first.MonthlyBaseKB != 18432 || first.DailyBaseKB != 614.4 {
t.Fatalf("fixed traffic not applied: month=%v monthly=%v daily=%v", first.MonthUsageKB, first.MonthlyBaseKB, first.DailyBaseKB)
}
if first.PoolUsageBytes != 18874368 {
t.Fatalf("pool usage bytes = %v, want 18874368", first.PoolUsageBytes)
}
if first.GrowthPerMinuteKB != 0 {
t.Fatalf("growth per minute = %v, want 0", first.GrowthPerMinuteKB)
}
if len(first.APNs) != 1 || first.APNs[0].Name != "CMIOT" || first.APNs[0].Status != "1" || first.APNs[0].UsageBytes != 18874368 {
t.Fatalf("apn not fixed to CMIOT usage: %#v", first.APNs)
}
}
func TestGeneratedExportRowMatchesInboundTemplateDefaults(t *testing.T) {
batchTime := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
card := GenerateSIMCards(batchTime, 1)[0]
row := inboundExportRow(card)
if row["chanKey"] != "channel_yd_mock" {
t.Fatalf("chanKey = %q", row["chanKey"])
}
if row["cardType"] != "1" || row["upPkgCode"] != "CMIOT-SD-500M-24M-D01" || row["suppliersCode"] != "SUP_CMCC" {
t.Fatalf("package defaults not applied: %#v", row)
}
if row["upstreamActivationTime"] != "2026-07-10 10:00:00" {
t.Fatalf("activation time = %q", row["upstreamActivationTime"])
}
if row["insertToPlugEnabled"] != "true" || row["carrierTrafficCutoffEnabled"] != "false" {
t.Fatalf("boolean defaults not applied: %#v", row)
}
if row["agreementExpiryDate"] != "2027-07-10" {
t.Fatalf("agreement expiry = %q", row["agreementExpiryDate"])
}
if row["hasTestPeriod"] != "true" || row["testPeriodDays"] != "30" || row["testPeriodLatestExpiryDate"] != "2026-08-09" {
t.Fatalf("test period defaults not applied: %#v", row)
}
if row["hasSilentPeriod"] != "false" || row["silentPeriodDays"] != "" || row["silentPeriodLatestExpiryDate"] != "" {
t.Fatalf("silent period defaults not applied: %#v", row)
}
}
+26
View File
@@ -0,0 +1,26 @@
package service
import "net/http"
func (s *Service) Health(w http.ResponseWriter, r *http.Request) { s.health(w, r) }
func (s *Service) AdminState(w http.ResponseWriter, r *http.Request) { s.adminState(w, r) }
func (s *Service) AdminAppIDs(w http.ResponseWriter, r *http.Request) { s.adminAppIDs(w, r) }
func (s *Service) AdminCards(w http.ResponseWriter, r *http.Request) { s.adminCards(w, r) }
func (s *Service) AdminCardGroups(w http.ResponseWriter, r *http.Request) { s.adminCardGroups(w, r) }
func (s *Service) AdminCardsExport(w http.ResponseWriter, r *http.Request) { s.adminCardsExport(w, r) }
func (s *Service) AdminSettings(w http.ResponseWriter, r *http.Request) { s.adminSettings(w, r) }
func (s *Service) AdminOrders(w http.ResponseWriter, r *http.Request) { s.adminOrders(w, r) }
func (s *Service) AdminOperationLogs(w http.ResponseWriter, r *http.Request) {
s.adminOperationLogs(w, r)
}
func (s *Service) AdminRuntimeLogs(w http.ResponseWriter, r *http.Request) {
s.adminRuntimeLogs(w, r)
}
func (s *Service) AdminSync(w http.ResponseWriter, r *http.Request) { s.adminSync(w, r) }
func (s *Service) AdminGenerate(w http.ResponseWriter, r *http.Request) { s.adminGenerate(w, r) }
func (s *Service) OneLink(w http.ResponseWriter, r *http.Request) { s.oneLink(w, r) }
func (s *Service) Chan(w http.ResponseWriter, r *http.Request) { s.chanAPI(w, r) }
func (s *Service) WithOperationLogging(next http.Handler) http.Handler {
return s.withOperationLogging(next)
}
+140
View File
@@ -0,0 +1,140 @@
package service
import "time"
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Result []interface{} `json:"result"`
}
type Store struct {
AppIDs []Credential `json:"appids"`
Groups []CardGroup `json:"groups"`
Cards []SIMCard `json:"cards"`
Tokens map[string]Token `json:"tokens"`
Orders []Order `json:"orders"`
Settings SimulationSettings `json:"settings"`
}
type CardGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CardCount int `json:"cardCount"`
}
type Credential struct {
AppID string `json:"appid"`
Password string `json:"password"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"createdAt"`
LastToken string `json:"lastToken,omitempty"`
TokenExpires string `json:"tokenExpires,omitempty"`
}
type Token struct {
AppID string `json:"appid"`
Value string `json:"value"`
ExpiresAt time.Time `json:"expiresAt"`
}
type APN struct {
Name string `json:"name"`
Status string `json:"status"`
DiagnosisStatus string `json:"diagnosisStatus,omitempty"`
UsageBytes float64 `json:"usageBytes"`
ModifyTime string `json:"modifyTime,omitempty"`
OverUseDetails string `json:"overUseDetails,omitempty"`
ServiceUsageState string `json:"serviceUsageState,omitempty"`
PCCs []PCC `json:"pccs,omitempty"`
}
type PCC struct {
ServiceCode string `json:"serviceCode"`
Name string `json:"name"`
PolicyStatus string `json:"policyStatus"`
ServiceUsageState string `json:"serviceUsageState"`
ModifyTime string `json:"modifyTime"`
}
type SIMCard struct {
GroupID int64 `json:"groupId,omitempty"`
GroupName string `json:"groupName,omitempty"`
MSISDN string `json:"msisdn"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
IMEI string `json:"imei"`
Remark string `json:"remark"`
CardStatus string `json:"cardStatus"`
LastChangeDate string `json:"lastChangeDate"`
StopReason string `json:"stopReason,omitempty"`
FrozenStatus string `json:"frozenStatus,omitempty"`
FrozenReason string `json:"frozenReason,omitempty"`
FrozenOperTime string `json:"frozenOperTime,omitempty"`
OpenDate string `json:"openDate"`
ActiveDate string `json:"activeDate"`
OnOffStatus string `json:"onOffStatus"`
SessionStatus string `json:"sessionStatus"`
SessionCreateDate string `json:"sessionCreateDate,omitempty"`
IP string `json:"ip"`
IPv6Prefix string `json:"ipv6Prefix"`
IPv6 string `json:"ipv6"`
RAT string `json:"rat"`
CardBindStatus string `json:"cardBindStatus"`
RegionLimitStatus string `json:"regionLimitStatus"`
AreaRestrictType string `json:"areaRestrictType"`
RestrictedArea string `json:"restrictedArea"`
MonthUsageKB float64 `json:"monthUsageKB"`
PoolUsageBytes float64 `json:"poolUsageBytes"`
DailyBaseKB float64 `json:"dailyBaseKB"`
MonthlyBaseKB float64 `json:"monthlyBaseKB"`
GrowthPerMinuteKB float64 `json:"growthPerMinuteKB"`
APNs []APN `json:"apns"`
LastUsageUpdateTime string `json:"lastUsageUpdateTime"`
Exported bool `json:"exported"`
ExportCheckedAt string `json:"exportCheckedAt"`
}
type Order struct {
OrderNum string `json:"orderNum"`
MSISDN string `json:"msisdn,omitempty"`
ICCID string `json:"iccid,omitempty"`
IMSI string `json:"imsi,omitempty"`
Type string `json:"type"`
OperType string `json:"operType,omitempty"`
APNName string `json:"apnName,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}
type SimulationSettings struct {
AutoGrowthEnabled bool `json:"autoGrowthEnabled"`
DefaultGrowthKB float64 `json:"defaultGrowthKB"`
}
type SyncResult struct {
Scanned int `json:"scanned"`
Synced int `json:"synced"`
}
type GenerateResult struct {
Generated int `json:"generated"`
Total int `json:"total"`
}
type CardPage struct {
Items []SIMCard `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type OrderPage struct {
Items []Order `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
+456
View File
@@ -0,0 +1,456 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
maxOperationDetailBytes = 2048
maxLoggedRequestBytes = 2 << 20
)
type OperationLog struct {
ID int64 `json:"id"`
CreatedAt string `json:"createdAt"`
Category string `json:"category"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Params string `json:"params,omitempty"`
Result string `json:"result"`
StatusCode int `json:"statusCode"`
Message string `json:"message,omitempty"`
DurationMS int64 `json:"durationMs"`
ClientIP string `json:"clientIp,omitempty"`
}
type OperationLogPage struct {
Items []OperationLog `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type OperationLogStore struct {
mu sync.RWMutex
logs []OperationLog
nextID int64
now func() time.Time
day string
}
func NewOperationLogStore() *OperationLogStore {
store := newOperationLogStore(time.Now)
store.scheduleMidnightClear()
return store
}
func newOperationLogStore(now func() time.Time) *OperationLogStore {
return &OperationLogStore{logs: make([]OperationLog, 0), now: now, day: logDay(now())}
}
func (s *OperationLogStore) Add(log OperationLog) {
s.mu.Lock()
defer s.mu.Unlock()
s.clearExpiredLocked()
s.nextID++
log.ID = s.nextID
log.CreatedAt = s.now().Format("2006-01-02 15:04:05")
s.logs = append(s.logs, log)
}
func (s *OperationLogStore) Query(keyword, category, result string, page, pageSize int) OperationLogPage {
s.mu.Lock()
defer s.mu.Unlock()
s.clearExpiredLocked()
keyword = strings.ToLower(strings.TrimSpace(keyword))
matched := make([]OperationLog, 0, len(s.logs))
for i := len(s.logs) - 1; i >= 0; i-- {
item := s.logs[i]
if category != "" && item.Category != category {
continue
}
if result != "" && item.Result != result {
continue
}
searchable := strings.ToLower(item.Action + " " + item.Target + " " + item.Path + " " + item.Params + " " + item.Message + " " + item.ClientIP)
if keyword != "" && !strings.Contains(searchable, keyword) {
continue
}
matched = append(matched, item)
}
total := len(matched)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := append([]OperationLog(nil), matched[start:end]...)
return OperationLogPage{Items: items, Total: total, Page: page, PageSize: pageSize}
}
func (s *OperationLogStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.logs = s.logs[:0]
s.day = logDay(s.now())
}
func (s *OperationLogStore) clearExpiredLocked() {
today := logDay(s.now())
if s.day == today {
return
}
s.logs = s.logs[:0]
s.day = today
}
func (s *OperationLogStore) scheduleMidnightClear() {
now := s.now()
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
time.AfterFunc(time.Until(next), func() {
s.Clear()
s.scheduleMidnightClear()
})
}
func logDay(value time.Time) string {
return value.Format("2006-01-02")
}
type operationResponseWriter struct {
http.ResponseWriter
status int
body bytes.Buffer
}
func (w *operationResponseWriter) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
w.ResponseWriter.WriteHeader(status)
}
func (w *operationResponseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
if w.body.Len() < maxOperationDetailBytes {
remaining := maxOperationDetailBytes - w.body.Len()
if len(p) > remaining {
_, _ = w.body.Write(p[:remaining])
} else {
_, _ = w.body.Write(p)
}
}
return w.ResponseWriter.Write(p)
}
func (w *operationResponseWriter) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
type replayReadCloser struct {
io.Reader
closer io.Closer
}
func (r *replayReadCloser) Close() error {
return r.closer.Close()
}
func (s *Service) withOperationLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !shouldLogOperation(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
body := captureRequestBody(r)
category, action := s.describeOperation(r, body)
started := time.Now()
wrapped := &operationResponseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
if wrapped.status == 0 {
wrapped.status = http.StatusOK
}
result, message := operationResult(wrapped.status, wrapped.body.Bytes())
s.operationLogs.Add(OperationLog{
Category: category, Action: action, Target: operationTarget(r.URL.Query(), body),
Method: r.Method, Path: r.URL.Path, Params: operationParams(r.URL.Query(), body),
Result: result, StatusCode: wrapped.status, Message: message,
DurationMS: time.Since(started).Milliseconds(), ClientIP: clientIP(r),
})
})
}
func shouldLogOperation(path string) bool {
if path == "/api/operation-logs" || path == "/api/runtime-logs" || path == "/healthz" {
return false
}
return strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v5/") || strings.HasPrefix(path, "/ec/") || strings.HasPrefix(path, "/sim/")
}
func captureRequestBody(r *http.Request) []byte {
if r.Body == nil {
return nil
}
original := r.Body
raw, err := io.ReadAll(io.LimitReader(original, maxLoggedRequestBytes+1))
if err != nil {
return nil
}
r.Body = &replayReadCloser{Reader: io.MultiReader(bytes.NewReader(raw), original), closer: original}
if len(raw) > maxLoggedRequestBytes {
return raw[:maxLoggedRequestBytes]
}
return raw
}
func (s *Service) describeOperation(r *http.Request, body []byte) (string, string) {
path := r.URL.Path
if strings.HasPrefix(path, "/api/") {
return "管理台", s.describeAdminOperation(r, body)
}
category := "OneLink"
endpoint := strings.TrimPrefix(strings.TrimPrefix(path, "/v5/"), "/")
if strings.HasPrefix(path, "/sim/") {
category = "Chan"
endpoint = strings.TrimPrefix(path, "/sim/")
}
actions := map[string]string{
"ec/get/token": "获取 Token", "ec/query/sim-basic-info": "查询卡基础信息",
"ec/query/sim-session": "查询在线状态", "ec/query/sim-status": "查询卡状态",
"ec/query/sim-imei": "查询 IMEI", "ec/query/sim-data-usage-inpool": "查询流量池用量",
"ec/query/sim-data-usage": "查询当月流量", "ec/query/sim-data-usage-monthly/batch": "批量查询月流量",
"ec/query/sim-data-usage-daily/batch": "批量查询日流量", "ec/query/apn-info": "查询 APN",
"ec/query/card-bind-status": "查询绑定状态", "ec/query/on-off-status": "查询开关机状态",
"ec/query/region-limit-status": "查询区域限制", "ec/change/sim-status": "变更 SIM 状态",
"ec/operate/sim-apn-function": "操作 APN",
"query/sim-basic-info": "查询卡基础信息", "query/sim-session": "查询在线状态",
"query/sim-status": "查询卡状态", "query/sim-status-batch": "批量查询卡状态",
"query/sim-card-bind-status": "查询绑定状态", "query/sim-device-status": "查询设备状态",
"query/sim-region-limit-status": "查询区域限制", "query/sim-imei": "查询 IMEI",
"query/sim-datausage": "查询当月流量", "query/sim-datausage-monthly": "查询月流量",
"query/sim-datausage-monthly-batch": "批量查询月流量", "query/sim-datausage-daily": "查询日流量",
"query/sim-datausage-daily-batch": "批量查询日流量", "query/sim-apn-info": "查询 APN",
"change/sim-status": "变更 SIM 状态", "change/sim-apn": "操作 APN",
}
if action := actions[endpoint]; action != "" {
return category, action
}
return category, "访问 " + endpoint
}
func (s *Service) describeAdminOperation(r *http.Request, body []byte) string {
key := r.Method + " " + r.URL.Path
actions := map[string]string{
"GET /api/state": "查看管理台", "GET /api/card-groups": "查询卡分组",
"POST /api/card-groups": "新增卡分组", "DELETE /api/card-groups": "删除卡分组",
"GET /api/cards": "查询 SIM 卡", "DELETE /api/cards": "删除 SIM 卡",
"GET /api/cards/export": "导出 SIM 卡", "POST /api/settings": "更新模拟设置",
"GET /api/orders": "查询订单", "DELETE /api/orders": "清空订单",
"POST /api/generate": "一键生成 SIM 卡", "POST /api/sync": "同步生成 SIM 卡",
"DELETE /api/appids": "删除 AppID",
}
if key == "POST /api/appids" {
appid := jsonStringField(body, "appid")
for _, item := range s.store.Snapshot().AppIDs {
if item.AppID == appid {
return "更新 AppID"
}
}
return "新增 AppID"
}
if key == "POST /api/cards" {
msisdn := jsonStringField(body, "msisdn")
for _, item := range s.store.Snapshot().Cards {
if item.MSISDN == msisdn {
return "编辑 SIM 卡"
}
}
return "新增 SIM 卡"
}
if action := actions[key]; action != "" {
return action
}
return "访问 " + r.URL.Path
}
func jsonStringField(body []byte, key string) string {
var value map[string]interface{}
if json.Unmarshal(body, &value) != nil {
return ""
}
return fmt.Sprint(value[key])
}
func operationTarget(query url.Values, body []byte) string {
keys := []string{"appid", "msisdn", "iccid", "imsi", "id"}
for _, key := range keys {
if value := strings.TrimSpace(query.Get(key)); value != "" {
return key + ": " + value
}
}
var value map[string]interface{}
if json.Unmarshal(body, &value) != nil {
return ""
}
for _, key := range keys {
if item := strings.TrimSpace(fmt.Sprint(value[key])); item != "" && item != "<nil>" {
return key + ": " + item
}
}
if simKeys, ok := value["simKeys"].([]interface{}); ok && len(simKeys) > 0 {
if first, ok := simKeys[0].(map[string]interface{}); ok {
for _, key := range []string{"msisdn", "iccid", "imsi"} {
if item := strings.TrimSpace(fmt.Sprint(first[key])); item != "" && item != "<nil>" {
return key + ": " + item
}
}
}
}
return ""
}
func operationParams(query url.Values, body []byte) string {
parts := make([]string, 0, 2)
if len(query) > 0 {
clean := make(url.Values, len(query))
keys := make([]string, 0, len(query))
for key := range query {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if sensitiveOperationKey(key) {
clean.Set(key, "***")
} else {
clean[key] = append([]string(nil), query[key]...)
}
}
parts = append(parts, clean.Encode())
}
if len(bytes.TrimSpace(body)) > 0 {
var value interface{}
if json.Unmarshal(body, &value) == nil {
if raw, err := json.Marshal(redactOperationValue(value)); err == nil {
parts = append(parts, string(raw))
}
} else {
parts = append(parts, "[non-JSON request body]")
}
}
result := strings.Join(parts, " ")
if len(result) > maxOperationDetailBytes {
return result[:maxOperationDetailBytes] + "..."
}
return result
}
func redactOperationValue(value interface{}) interface{} {
switch item := value.(type) {
case map[string]interface{}:
for key, child := range item {
if sensitiveOperationKey(key) {
item[key] = "***"
} else {
item[key] = redactOperationValue(child)
}
}
case []interface{}:
for i := range item {
item[i] = redactOperationValue(item[i])
}
}
return value
}
func sensitiveOperationKey(key string) bool {
key = strings.ToLower(key)
return strings.Contains(key, "password") || strings.Contains(key, "token") || strings.Contains(key, "authorization")
}
func operationResult(status int, body []byte) (string, string) {
if status >= http.StatusBadRequest {
return "failed", responseMessage(body, http.StatusText(status))
}
var value interface{}
if json.Unmarshal(body, &value) != nil {
return "success", ""
}
if item, ok := value.(map[string]interface{}); ok {
if message, ok := item["error"].(string); ok && message != "" {
return "failed", message
}
if code := fmt.Sprint(item["status"]); code != "<nil>" && code != "" && code != "0" {
return "failed", fmt.Sprint(item["message"])
}
}
if items, ok := value.([]interface{}); ok {
for _, raw := range items {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
if code, ok := item["code"].(float64); ok && code != chanSuccessCode {
return "failed", fmt.Sprint(item["message"])
}
}
}
return "success", ""
}
func responseMessage(body []byte, fallback string) string {
var value map[string]interface{}
if json.Unmarshal(body, &value) == nil {
for _, key := range []string{"error", "message"} {
if message := strings.TrimSpace(fmt.Sprint(value[key])); message != "" && message != "<nil>" {
return message
}
}
}
return fallback
}
func clientIP(r *http.Request) string {
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
return forwarded
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func (s *Service) adminOperationLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
page, pageSize := pagination(r)
writeJSON(w, http.StatusOK, s.operationLogs.Query(
r.URL.Query().Get("keyword"), r.URL.Query().Get("category"), r.URL.Query().Get("result"), page, pageSize,
))
}
func operationLogID(value int64) string {
return strconv.FormatInt(value, 10)
}
+41
View File
@@ -0,0 +1,41 @@
package service
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOperationLogCapturesAdminMutationAndRedactsSecrets(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/appids", strings.NewReader(`{"appid":"demo","password":"plain-secret","name":"演示"}`))
req.RemoteAddr = "192.0.2.10:1234"
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", res.Code, res.Body.String())
}
page := service.operationLogs.Query("", "管理台", "success", 1, 20)
if page.Total != 1 || page.Items[0].Action != "新增 AppID" {
t.Fatalf("logs = %#v", page.Items)
}
if strings.Contains(page.Items[0].Params, "plain-secret") || !strings.Contains(page.Items[0].Params, "***") {
t.Fatalf("secret was not redacted: %s", page.Items[0].Params)
}
}
func TestOperationLogQueryReturnsNewestFirst(t *testing.T) {
store := newOperationLogStore(func() time.Time { return time.Date(2026, 8, 13, 10, 0, 0, 0, time.Local) })
store.Add(OperationLog{Category: "管理台", Action: "第一条", Result: "success"})
store.Add(OperationLog{Category: "管理台", Action: "第二条", Result: "success"})
page := store.Query("", "", "", 1, 1)
if page.Total != 2 || len(page.Items) != 1 || page.Items[0].Action != "第二条" {
t.Fatalf("page = %#v", page)
}
}
+129
View File
@@ -0,0 +1,129 @@
package service
import (
"bytes"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const defaultRuntimeLogCapacity = 500
type RuntimeLog struct {
ID int64 `json:"id"`
CreatedAt string `json:"createdAt"`
Level string `json:"level"`
Message string `json:"message"`
}
type RuntimeLogResult struct {
Items []RuntimeLog `json:"items"`
Total int `json:"total"`
}
type RuntimeLogStore struct {
mu sync.Mutex
logs []RuntimeLog
partial []byte
nextID int64
capacity int
now func() time.Time
}
func NewRuntimeLogStore(capacities ...int) *RuntimeLogStore {
capacity := defaultRuntimeLogCapacity
if len(capacities) > 0 {
capacity = capacities[0]
}
return newRuntimeLogStore(capacity, time.Now)
}
func newRuntimeLogStore(capacity int, now func() time.Time) *RuntimeLogStore {
if capacity < 1 {
capacity = 1
}
return &RuntimeLogStore{
logs: make([]RuntimeLog, 0, capacity),
capacity: capacity,
now: now,
}
}
func (s *RuntimeLogStore) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
written := len(p)
s.partial = append(s.partial, p...)
for {
index := bytes.IndexByte(s.partial, '\n')
if index < 0 {
break
}
s.addLineLocked(string(bytes.TrimSuffix(s.partial[:index], []byte{'\r'})))
s.partial = append(s.partial[:0], s.partial[index+1:]...)
}
return written, nil
}
func (s *RuntimeLogStore) Latest(limit int) RuntimeLogResult {
s.mu.Lock()
defer s.mu.Unlock()
if limit < 1 || limit > s.capacity {
limit = s.capacity
}
start := len(s.logs) - limit
if start < 0 {
start = 0
}
items := make([]RuntimeLog, len(s.logs)-start)
for i := range items {
items[i] = s.logs[len(s.logs)-1-i]
}
return RuntimeLogResult{Items: items, Total: len(s.logs)}
}
func (s *RuntimeLogStore) addLineLocked(message string) {
message = strings.TrimSpace(message)
if message == "" {
return
}
s.nextID++
item := RuntimeLog{
ID: s.nextID,
CreatedAt: s.now().Format("2006-01-02 15:04:05"),
Level: runtimeLogLevel(message),
Message: message,
}
if len(s.logs) == s.capacity {
copy(s.logs, s.logs[1:])
s.logs[len(s.logs)-1] = item
return
}
s.logs = append(s.logs, item)
}
func runtimeLogLevel(message string) string {
upper := strings.ToUpper(message)
switch {
case strings.Contains(upper, "FATAL"), strings.Contains(upper, "PANIC"), strings.Contains(upper, "ERROR"), strings.Contains(upper, "[ERRO]"):
return "ERROR"
case strings.Contains(upper, "WARN"), strings.Contains(upper, "[WARN]"):
return "WARN"
case strings.Contains(upper, "DEBUG"), strings.Contains(upper, "[DEBU]"):
return "DEBUG"
default:
return "INFO"
}
}
func (s *Service) adminRuntimeLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
writeJSON(w, http.StatusOK, s.runtimeLogs.Latest(limit))
}
+56
View File
@@ -0,0 +1,56 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestRuntimeLogStoreRetainsLatestLines(t *testing.T) {
now := func() time.Time { return time.Date(2026, 8, 13, 12, 30, 0, 0, time.Local) }
store := newRuntimeLogStore(2, now)
if _, err := store.Write([]byte("first\nsecond")); err != nil {
t.Fatal(err)
}
if _, err := store.Write([]byte(" line\nERROR third\n")); err != nil {
t.Fatal(err)
}
result := store.Latest(10)
if result.Total != 2 || len(result.Items) != 2 {
t.Fatalf("result = %#v", result)
}
if result.Items[0].Message != "ERROR third" || result.Items[0].Level != "ERROR" {
t.Fatalf("newest = %#v", result.Items[0])
}
if result.Items[1].Message != "second line" {
t.Fatalf("second = %#v", result.Items[1])
}
}
func TestRuntimeLogsEndpointIsNotRecordedAsOperation(t *testing.T) {
runtimeLogs := NewRuntimeLogStore()
service, err := NewService("sqlite::@file(:memory:)", runtimeLogs)
if err != nil {
t.Fatal(err)
}
_, _ = runtimeLogs.Write([]byte("service ready\n"))
req := httptest.NewRequest(http.MethodGet, "/api/runtime-logs?limit=10", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", res.Code, res.Body.String())
}
var result RuntimeLogResult
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if len(result.Items) != 1 || result.Items[0].Message != "service ready" {
t.Fatalf("result = %#v", result)
}
if page := service.operationLogs.Query("", "", "", 1, 20); page.Total != 0 {
t.Fatalf("runtime log polling was recorded: %#v", page)
}
}
+746
View File
@@ -0,0 +1,746 @@
package service
import (
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
type Service struct {
store *StoreManager
operationLogs *OperationLogStore
runtimeLogs *RuntimeLogStore
}
func NewService(dataPath string, runtimeLogStores ...*RuntimeLogStore) (*Service, error) {
store, err := NewStoreManager(dataPath)
if err != nil {
return nil, err
}
runtimeLogs := NewRuntimeLogStore()
if len(runtimeLogStores) > 0 && runtimeLogStores[0] != nil {
runtimeLogs = runtimeLogStores[0]
}
return &Service{store: store, operationLogs: NewOperationLogStore(), runtimeLogs: runtimeLogs}, nil
}
func (s *Service) Router() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/api/state", s.adminState)
mux.HandleFunc("/api/appids", s.adminAppIDs)
mux.HandleFunc("/api/cards", s.adminCards)
mux.HandleFunc("/api/card-groups", s.adminCardGroups)
mux.HandleFunc("/api/cards/export", s.adminCardsExport)
mux.HandleFunc("/api/settings", s.adminSettings)
mux.HandleFunc("/api/orders", s.adminOrders)
mux.HandleFunc("/api/operation-logs", s.adminOperationLogs)
mux.HandleFunc("/api/runtime-logs", s.adminRuntimeLogs)
mux.HandleFunc("/api/sync", s.adminSync)
mux.HandleFunc("/api/generate", s.adminGenerate)
mux.HandleFunc("/sim/", s.chanAPI)
mux.HandleFunc("/v5/", s.oneLink)
mux.HandleFunc("/ec/", s.oneLink)
return withCORS(s.withOperationLogging(mux))
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Service) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Service) oneLink(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/v5/")
path = strings.TrimPrefix(path, "/")
switch path {
case "ec/get/token":
s.getToken(w, r)
case "ec/query/sim-basic-info":
s.withToken(w, r, s.queryBasicInfo)
case "ec/query/sim-session":
s.withToken(w, r, s.querySession)
case "ec/query/sim-status":
s.withToken(w, r, s.queryStatus)
case "ec/query/sim-stop-reason":
s.withToken(w, r, s.queryStopReason)
case "ec/query/sim-frozen-state-info":
s.withToken(w, r, s.queryFrozenState)
case "ec/query/sim-apn-session-info":
s.withToken(w, r, s.queryAPNSession)
case "ec/query/sim-activated-features-info":
s.withToken(w, r, s.queryActivatedFeatures)
case "ec/query/sim-operating-status":
s.withToken(w, r, s.queryOperatingStatus)
case "ec/query/sim-data-diagnosis":
s.withToken(w, r, s.queryDataDiagnosis)
case "ec/query/sim-imei":
s.withToken(w, r, s.queryIMEI)
case "ec/query/sim-data-usage-inpool":
s.withToken(w, r, s.queryDataUsageInPool)
case "ec/query/sim-data-usage":
s.withToken(w, r, s.queryDataUsage)
case "ec/query/sim-data-usage-monthly/batch":
s.withToken(w, r, s.queryDataUsageMonthlyBatch)
case "ec/query/sim-data-usage-daily/batch":
s.withToken(w, r, s.queryDataUsageDailyBatch)
case "ec/query/apn-info":
s.withToken(w, r, s.queryAPNInfo)
case "ec/query/card-bind-status":
s.withToken(w, r, s.queryCardBindStatus)
case "ec/query/on-off-status":
s.withToken(w, r, s.queryOnOffStatus)
case "ec/query/region-limit-status":
s.withToken(w, r, s.queryRegionLimitStatus)
case "ec/change/sim-status":
s.withToken(w, r, s.changeSIMStatus)
case "ec/operate/sim-apn-function":
s.withToken(w, r, s.operateSIMAPNFunction)
default:
writeOneLink(w, Response{Status: "14008", Message: "查询不到信息", Result: []interface{}{}})
}
}
func (s *Service) getToken(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
appid := q.Get("appid")
password := q.Get("password")
refresh := q.Get("refresh")
var result Response
err := s.store.SaveMutation(func(st *Store) error {
for i := range st.AppIDs {
cred := &st.AppIDs[i]
if cred.AppID != appid || !cred.Enabled {
continue
}
if cred.Password != password {
result = Response{Status: "12022", Message: "PASSWORD 鉴权不通过", Result: []interface{}{}}
return nil
}
now := time.Now()
if refresh == "0" {
if token, ok := st.Tokens[appid]; ok && token.ExpiresAt.After(now) {
ttl := int(time.Until(token.ExpiresAt).Seconds())
result = okResp(map[string]string{"token": token.Value, "ttl": strconv.Itoa(ttl)})
return nil
}
}
token := Token{
AppID: appid,
Value: randomHex(32),
ExpiresAt: now.Add(time.Hour),
}
st.Tokens[appid] = token
cred.LastToken = token.Value
cred.TokenExpires = token.ExpiresAt.Format("2006-01-02 15:04:05")
result = okResp(map[string]string{"token": token.Value, "ttl": "3600"})
return nil
}
result = Response{Status: "12021", Message: "认证无效", Result: []interface{}{}}
return nil
})
if err != nil {
writeOneLink(w, Response{Status: "19000", Message: err.Error(), Result: []interface{}{}})
return
}
writeOneLink(w, result)
}
func (s *Service) withToken(w http.ResponseWriter, r *http.Request, next func(http.ResponseWriter, *http.Request, Store)) {
token := r.URL.Query().Get("token")
st := s.store.Snapshot()
for _, t := range st.Tokens {
if t.Value == token && t.ExpiresAt.After(time.Now()) {
next(w, r, st)
return
}
}
writeOneLink(w, Response{Status: "12021", Message: "TOKEN 不存在或已过期,请重新获取", Result: []interface{}{}})
}
func (s *Service) queryBasicInfo(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{
"msisdn": card.MSISDN, "imsi": card.IMSI, "iccid": card.ICCID,
"activeDate": card.ActiveDate, "openDate": card.OpenDate, "remark": card.Remark,
}))
}
func (s *Service) querySession(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]interface{}{
"simSessionList": []map[string]string{{
"apnId": "CMIOT", "status": card.SessionStatus, "ip": card.IP,
"ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6,
"createDate": card.LastChangeDate, "rat": card.RAT,
}},
}))
}
func (s *Service) queryStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"cardStatus": card.CardStatus, "lastChangeDate": card.LastChangeDate}))
}
func (s *Service) queryStopReason(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
stopReason := card.StopReason
if stopReason == "" {
stopReason = defaultStopReason(card.CardStatus)
}
writeOneLink(w, okResp(map[string]string{"platformType": "OneLink-CT", "stopReason": stopReason}))
}
func (s *Service) queryFrozenState(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
frozen := card.FrozenStatus
if frozen == "" {
frozen = "0"
}
operTime := card.FrozenOperTime
if operTime == "" {
operTime = diagnosticTime(parseCardTime(card.LastChangeDate))
}
data := map[string]string{"frozenStatus": frozen, "operTime": operTime}
if frozen == "1" && card.FrozenReason != "" {
data["frozenReason"] = card.FrozenReason
}
writeOneLink(w, okResp(data))
}
func (s *Service) queryAPNSession(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
status := card.SessionStatus
if status != "00" && status != "01" {
status = "00"
}
createTime := card.SessionCreateDate
if createTime == "" {
createTime = card.LastChangeDate
}
items = append(items, map[string]string{"apn": apn.Name, "sessionStatus": status, "ipv4": card.IP, "ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6, "createTime": createTime, "accessType": accessType(card.RAT)})
}
if len(items) == 0 {
status := card.SessionStatus
if status != "00" && status != "01" {
status = "00"
}
createTime := card.SessionCreateDate
if createTime == "" {
createTime = card.LastChangeDate
}
items = append(items, map[string]string{"apn": "CMIOT", "sessionStatus": status, "ipv4": card.IP, "ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6, "createTime": createTime, "accessType": accessType(card.RAT)})
}
writeOneLink(w, okResp(map[string]interface{}{"infoList": items}))
}
func (s *Service) queryActivatedFeatures(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
openStatus := "0"
if apn.Status == "1" {
openStatus = "1"
}
changeTime := apn.ModifyTime
if changeTime == "" {
changeTime = diagnosticTime(parseCardTime(card.LastChangeDate))
}
items = append(items, map[string]string{"functionType": "11", "openStatus": openStatus, "apn": apn.Name, "changeTime": changeTime})
}
if len(items) == 0 {
items = append(items, map[string]string{"functionType": "11", "openStatus": "1", "apn": "CMIOT", "changeTime": diagnosticTime(parseCardTime(card.LastChangeDate))})
}
writeOneLink(w, okResp(map[string]interface{}{"communicationFunctionList": items}))
}
func (s *Service) queryOperatingStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"operatingStatus": card.OnOffStatus}))
}
func (s *Service) queryDataDiagnosis(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apnInfo := make([]map[string]interface{}, 0, len(card.APNs))
for _, apn := range card.APNs {
status := apn.DiagnosisStatus
if status == "" {
status = "2"
}
modifyTime := apn.ModifyTime
if modifyTime == "" {
modifyTime = diagnosticTime(parseCardTime(card.LastChangeDate))
}
usageState := apn.ServiceUsageState
if usageState == "" {
usageState = "1"
}
item := map[string]interface{}{"apnName": apn.Name, "apnStatus": status, "modifyTime": modifyTime, "apnServiceUsageState": usageState}
if status == "4" && apn.OverUseDetails != "" {
item["overUseDetails"] = apn.OverUseDetails
}
pccInfo := make([]map[string]string, 0, len(apn.PCCs))
for _, pcc := range apn.PCCs {
pccTime := pcc.ModifyTime
if pccTime == "" {
pccTime = modifyTime
}
pccInfo = append(pccInfo, map[string]string{"pccServiceCode": pcc.ServiceCode, "pccName": pcc.Name, "pccPolicyStatus": pcc.PolicyStatus, "pccServiceUsageState": pcc.ServiceUsageState, "modifyTime": pccTime})
}
item["pccList"] = map[string]interface{}{"pccInfo": pccInfo}
apnInfo = append(apnInfo, item)
}
if len(apnInfo) == 0 {
apnInfo = append(apnInfo, map[string]interface{}{"apnName": "CMIOT", "apnStatus": "2", "modifyTime": diagnosticTime(parseCardTime(card.LastChangeDate)), "apnServiceUsageState": "1", "pccList": map[string]interface{}{"pccInfo": []map[string]string{}}})
}
writeOneLink(w, okResp(map[string]interface{}{"apnList": map[string]interface{}{"apnInfo": apnInfo}}))
}
func accessType(rat string) string {
switch strings.ToUpper(rat) {
case "5G", "51", "SA":
return "51"
case "NSA", "10":
return "10"
case "REDCAP", "58":
return "58"
case "NB", "8":
return "8"
case "2G", "2":
return "2"
case "3G", "3":
return "1"
default:
return "6"
}
}
func (s *Service) queryIMEI(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"imei": card.IMEI}))
}
func (s *Service) queryDataUsageInPool(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apns := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
apns = append(apns, map[string]string{"apnName": apn.Name, "apnUseAmount": fmtAmount(apn.UsageBytes)})
}
writeOneLink(w, okResp(map[string]interface{}{"apnList": apns}))
}
func (s *Service) queryDataUsage(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]interface{}{
"dataAmount": fmt.Sprintf("%.2f", card.MonthUsageKB),
"apnUseAmountList": apnUseAmountList(card),
}))
}
func (s *Service) queryDataUsageMonthlyBatch(w http.ResponseWriter, r *http.Request, st Store) {
cards, key, err := findBatchCards(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]interface{}, 0, len(cards))
for _, card := range cards {
amount := card.MonthUsageKB
if q := r.URL.Query().Get("queryDate"); len(q) == 6 && q != time.Now().Format("200601") {
amount = card.MonthlyBaseKB
}
items = append(items, batchUsageItem(card, key, amount))
}
writeOneLink(w, okResp(map[string]interface{}{
"deadLine": time.Now().AddDate(0, 0, -1).Format("20060102"),
"dataAmountList": items,
}))
}
func (s *Service) queryDataUsageDailyBatch(w http.ResponseWriter, r *http.Request, st Store) {
cards, key, err := findBatchCards(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]interface{}, 0, len(cards))
for _, card := range cards {
items = append(items, batchUsageItem(card, key, card.DailyBaseKB))
}
writeOneLink(w, okResp(map[string]interface{}{"dataAmountList": items}))
}
func (s *Service) queryAPNInfo(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apns := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
apns = append(apns, map[string]string{"apnName": apn.Name, "status": apn.Status})
}
writeOneLink(w, okResp(map[string]interface{}{"apnList": apns}))
}
func (s *Service) queryCardBindStatus(w http.ResponseWriter, r *http.Request, st Store) {
if r.URL.Query().Get("msisdn") == "" || r.URL.Query().Get("iccid") != "" || r.URL.Query().Get("imsi") != "" {
writeOneLink(w, paramErr("card-bind-status requires msisdn only"))
return
}
testType := r.URL.Query().Get("testType")
if testType != "0" && testType != "1" {
writeOneLink(w, paramErr("testType must be 0 or 1"))
return
}
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
result := card.CardBindStatus
if result != "0" && result != "1" && result != "2" {
result = "1"
}
data := map[string]string{"result": result}
if result == "2" {
data["errorCode"] = "MOCK_BIND_CHECK_FAILED"
data["errorDes"] = "mock bind check failed"
}
writeOneLink(w, okResp(data))
}
func (s *Service) queryOnOffStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"status": card.OnOffStatus}))
}
func (s *Service) queryRegionLimitStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"regionLimitStatus": card.RegionLimitStatus}))
}
func (s *Service) changeSIMStatus(w http.ResponseWriter, r *http.Request, st Store) {
q := r.URL.Query()
idKey, idVal, err := singleIdentifier(q)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
operType := q.Get("operType")
orderNum := "ORD" + time.Now().Format("20060102150405") + randomHex(3)
var result map[string]string
err = s.store.SaveMutation(func(store *Store) error {
idx := findCardIndex(*store, idKey, idVal)
if idx < 0 {
return fmt.Errorf("查询不到信息")
}
card := &store.Cards[idx]
card.CardStatus = statusByOperType(operType, card.CardStatus)
card.LastChangeDate = time.Now().Format("2006-01-02 15:04:05")
store.Orders = append(store.Orders, Order{
OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI,
Type: "sim-status", OperType: operType, Status: "completed", CreatedAt: card.LastChangeDate,
})
result = map[string]string{idKey: idVal, "orderNum": orderNum}
return nil
})
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(result))
}
func (s *Service) operateSIMAPNFunction(w http.ResponseWriter, r *http.Request, st Store) {
q := r.URL.Query()
idKey, idVal, err := singleIdentifier(q)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apnName := q.Get("apnName")
operType := q.Get("operType")
if apnName == "" || (operType != "0" && operType != "1") {
writeOneLink(w, paramErr("apnName 与 operType 必填"))
return
}
orderNum := "APN" + time.Now().Format("20060102150405") + randomHex(3)
var result map[string]string
err = s.store.SaveMutation(func(store *Store) error {
idx := findCardIndex(*store, idKey, idVal)
if idx < 0 {
return fmt.Errorf("查询不到信息")
}
card := &store.Cards[idx]
status := "0"
if operType == "0" {
status = "1"
}
found := false
for i := range card.APNs {
if strings.EqualFold(card.APNs[i].Name, apnName) {
card.APNs[i].Status = status
found = true
}
}
if !found {
card.APNs = append(card.APNs, APN{Name: apnName, Status: status})
}
now := time.Now().Format("2006-01-02 15:04:05")
store.Orders = append(store.Orders, Order{
OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI,
Type: "sim-apn-function", OperType: operType, APNName: apnName, Status: "completed", CreatedAt: now,
})
result = map[string]string{idKey: idVal, "orderNum": orderNum}
return nil
})
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(result))
}
func okResp(item interface{}) Response {
return Response{Status: "0", Message: "正确", Result: []interface{}{item}}
}
func paramErr(message string) Response {
if strings.Contains(message, "查询不到") {
return Response{Status: "14008", Message: "查询不到信息", Result: []interface{}{}}
}
return Response{Status: "12001", Message: message, Result: []interface{}{}}
}
func writeOneLink(w http.ResponseWriter, resp Response) {
writeJSON(w, http.StatusOK, resp)
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func singleIdentifier(q mapLike) (string, string, error) {
found := [][2]string{}
for _, key := range []string{"msisdn", "iccid", "imsi"} {
if v := q.Get(key); v != "" {
found = append(found, [2]string{key, v})
}
}
if len(found) != 1 {
return "", "", fmt.Errorf("msisdn、iccid、imsi 必须有且只有一项")
}
return found[0][0], found[0][1], nil
}
type mapLike interface {
Get(string) string
}
func findSingleCard(r *http.Request, st Store) (SIMCard, error) {
key, value, err := singleIdentifier(r.URL.Query())
if err != nil {
return SIMCard{}, err
}
for _, card := range st.Cards {
if matches(card, key, value) {
return card, nil
}
}
return SIMCard{}, fmt.Errorf("查询不到信息")
}
func findBatchCards(r *http.Request, st Store) ([]SIMCard, string, error) {
q := r.URL.Query()
keys := []string{"msisdns", "iccids", "imsis"}
foundKey := ""
foundValue := ""
for _, key := range keys {
if v := q.Get(key); v != "" {
if foundKey != "" {
return nil, "", fmt.Errorf("msisdns、iccids、imsis 必须有且只有一项")
}
foundKey = key
foundValue = v
}
}
if foundKey == "" {
return nil, "", fmt.Errorf("msisdns、iccids、imsis 必须有且只有一项")
}
singular := strings.TrimSuffix(foundKey, "s")
ids := strings.Split(foundValue, "_")
var cards []SIMCard
for _, id := range ids {
id = strings.TrimSpace(id)
for _, card := range st.Cards {
if matches(card, singular, id) {
cards = append(cards, card)
break
}
}
}
if len(cards) == 0 {
return nil, "", fmt.Errorf("查询不到信息")
}
return cards, singular, nil
}
func findCardIndex(st Store, key, value string) int {
for i, card := range st.Cards {
if matches(card, key, value) {
return i
}
}
return -1
}
func matches(card SIMCard, key, value string) bool {
switch key {
case "msisdn":
return card.MSISDN == value
case "iccid":
return card.ICCID == value
case "imsi":
return card.IMSI == value
}
return false
}
func apnUseAmountList(card SIMCard) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(card.APNs))
for _, apn := range card.APNs {
out = append(out, map[string]interface{}{
"apnName": apn.Name,
"apnUseAmount": fmtAmount(apn.UsageBytes / 1024),
"pccCodeUseAmountList": []map[string]string{{
"pccCode": "default",
"pccCodeUseAmount": fmtAmount(apn.UsageBytes / 1024),
}},
})
}
return out
}
func batchUsageItem(card SIMCard, key string, amount float64) map[string]interface{} {
item := map[string]interface{}{
key: identifierValue(card, key),
"dataAmount": fmtAmount(amount),
}
if amount > 0 {
item["apnDataAmountList"] = []map[string]string{
{"apnName": "CMIOT", "apnDataAmount": fmtAmount(amount * 0.7)},
{"apnName": "CMMTM", "apnDataAmount": fmtAmount(amount * 0.3)},
}
}
return item
}
func identifierValue(card SIMCard, key string) string {
switch key {
case "iccid":
return card.ICCID
case "imsi":
return card.IMSI
default:
return card.MSISDN
}
}
func fmtAmount(v float64) string {
if math.Abs(v-math.Round(v)) < 0.001 {
return strconv.FormatInt(int64(math.Round(v)), 10)
}
return fmt.Sprintf("%.2f", v)
}
func statusByOperType(operType, current string) string {
switch operType {
case "0":
return "4"
case "1", "2", "5", "6":
return "2"
case "3", "8":
return "7"
case "4", "9":
return "1"
default:
return current
}
}
+285
View File
@@ -0,0 +1,285 @@
package service
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
type StoreManager struct {
mu sync.Mutex
backend storeBackend
data Store
initialized bool
}
type storeBackend interface {
Load() (Store, error)
Save(Store) error
}
type generatedCardBackend interface {
AppendCards([]SIMCard) error
}
func NewStoreManager(path string) (*StoreManager, error) {
backend, err := newStoreBackend(path)
if err != nil {
return nil, err
}
sm := &StoreManager{backend: backend}
if err := sm.load(); err != nil {
return nil, err
}
return sm, nil
}
func (s *StoreManager) Snapshot() Store {
s.mu.Lock()
defer s.mu.Unlock()
return cloneStore(s.data)
}
func (s *StoreManager) SaveMutation(fn func(*Store) error) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := fn(&s.data); err != nil {
return err
}
return s.saveLocked()
}
func (s *StoreManager) WithStore(fn func(*Store) error) error {
s.mu.Lock()
defer s.mu.Unlock()
return fn(&s.data)
}
func (s *StoreManager) Persist() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.saveLocked()
}
func (s *StoreManager) GenerateCards(count int) (GenerateResult, error) {
return s.GenerateCardsInGroup(count, 0)
}
func (s *StoreManager) GenerateCardsInGroup(count int, groupID int64) (GenerateResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if count <= 0 {
return GenerateResult{Total: len(s.data.Cards)}, nil
}
generator, ok := s.backend.(generatedCardBackend)
if !ok {
return GenerateResult{}, fmt.Errorf("current store backend does not support card generation")
}
if groupID != 0 {
found := false
for _, group := range s.data.Groups {
if group.ID == groupID {
found = true
break
}
}
if !found {
return GenerateResult{}, fmt.Errorf("card group not found")
}
}
cards := GenerateSIMCards(time.Now(), count)
for i := range cards {
cards[i].GroupID = groupID
for _, group := range s.data.Groups {
if group.ID == groupID {
cards[i].GroupName = group.Name
break
}
}
}
if err := generator.AppendCards(cards); err != nil {
return GenerateResult{}, err
}
s.data.Cards = append(s.data.Cards, cards...)
return GenerateResult{Generated: len(cards), Total: len(s.data.Cards)}, nil
}
func (s *StoreManager) QueryCards(keyword, exportStatus string, page, pageSize int) CardPage {
return s.QueryCardsByGroup(keyword, exportStatus, 0, false, page, pageSize)
}
func (s *StoreManager) QueryCardsByGroup(keyword, exportStatus string, groupID int64, filterGroup bool, page, pageSize int) CardPage {
s.mu.Lock()
defer s.mu.Unlock()
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
matched := filterCards(s.data.Cards, keyword, exportStatus, groupID, filterGroup)
start := (page - 1) * pageSize
if start > len(matched) {
start = len(matched)
}
end := start + pageSize
if end > len(matched) {
end = len(matched)
}
return CardPage{Items: append([]SIMCard{}, matched[start:end]...), Total: len(matched), Page: page, PageSize: pageSize}
}
func (s *StoreManager) QueryAllCardsByGroup(keyword, exportStatus string, groupID int64, filterGroup bool) []SIMCard {
s.mu.Lock()
defer s.mu.Unlock()
return append([]SIMCard{}, filterCards(s.data.Cards, keyword, exportStatus, groupID, filterGroup)...)
}
func filterCards(cards []SIMCard, keyword, exportStatus string, groupID int64, filterGroup bool) []SIMCard {
keyword = strings.TrimSpace(keyword)
matched := make([]SIMCard, 0)
for i := len(cards) - 1; i >= 0; i-- {
card := cards[i]
groupMatch := !filterGroup || card.GroupID == groupID
exportMatch := exportStatus == "" || (exportStatus == "exported" && card.Exported) || (exportStatus == "not_exported" && !card.Exported)
if groupMatch && exportMatch && (keyword == "" || card.MSISDN == keyword || card.ICCID == keyword || card.IMSI == keyword) {
matched = append(matched, card)
}
}
return matched
}
func (s *StoreManager) QueryOrders(page, pageSize int) OrderPage {
s.mu.Lock()
defer s.mu.Unlock()
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
total := len(s.data.Orders)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := make([]Order, 0, end-start)
for i := total - 1 - start; i >= total-end; i-- {
items = append(items, s.data.Orders[i])
}
return OrderPage{Items: items, Total: total, Page: page, PageSize: pageSize}
}
func (s *StoreManager) load() error {
return s.reloadLocked()
}
func (s *StoreManager) reloadLocked() error {
data, err := s.backend.Load()
if err != nil {
return err
}
if s.initialized {
data.AppIDs = s.data.AppIDs
data.Tokens = s.data.Tokens
data.Orders = s.data.Orders
data.Settings = s.data.Settings
}
s.data = data
if s.data.Tokens == nil {
s.data.Tokens = map[string]Token{}
}
s.initialized = true
return nil
}
func (s *StoreManager) saveLocked() error {
return s.backend.Save(s.data)
}
func defaultStore() Store {
now := time.Now()
return Store{
AppIDs: []Credential{
{
AppID: "demo_appid",
Password: "demo_password",
Name: "默认测试应用",
Enabled: true,
CreatedAt: now.Format("2006-01-02 15:04:05"),
},
},
Cards: []SIMCard{},
Groups: []CardGroup{},
Tokens: map[string]Token{},
Orders: []Order{},
Settings: SimulationSettings{
AutoGrowthEnabled: false,
DefaultGrowthKB: 0,
},
}
}
func sampleCard(msisdn, iccid, imsi, imei, remark string, monthKB float64) SIMCard {
now := time.Now()
return SIMCard{
MSISDN: msisdn,
ICCID: iccid,
IMSI: imsi,
IMEI: imei,
Remark: remark,
CardStatus: "2",
LastChangeDate: now.AddDate(0, -1, 0).Format("2006-01-02 15:04:05"),
OpenDate: now.AddDate(-1, 0, 0).Format("2006-01-02 15:04:05"),
ActiveDate: now.AddDate(-1, 0, 3).Format("2006-01-02 15:04:05"),
OnOffStatus: "1",
SessionStatus: "01",
IP: fmt.Sprintf("10.8.%d.%d", len(msisdn), now.Second()+20),
IPv6Prefix: "",
IPv6: "",
RAT: "6",
CardBindStatus: "1",
RegionLimitStatus: "0",
AreaRestrictType: "1",
RestrictedArea: "200",
MonthUsageKB: monthKB,
PoolUsageBytes: monthKB * 1024,
DailyBaseKB: monthKB / 30,
MonthlyBaseKB: monthKB,
GrowthPerMinuteKB: 1.5,
LastUsageUpdateTime: now.Format(time.RFC3339),
APNs: []APN{
{Name: "CMIOT", Status: "1", UsageBytes: monthKB * 1024 * 0.7},
{Name: "CMMTM", Status: "1", UsageBytes: monthKB * 1024 * 0.3},
},
}
}
func cloneStore(src Store) Store {
b, _ := json.Marshal(src)
var out Store
_ = json.Unmarshal(b, &out)
return out
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
+5
View File
@@ -0,0 +1,5 @@
package service
func newStoreBackend(path string) (storeBackend, error) {
return newSQLiteStoreBackend(path)
}
+143
View File
@@ -0,0 +1,143 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
type dbAPN struct {
APNName string `json:"apnName"`
Status string `json:"status"`
DiagnosisStatus string `json:"diagnosisStatus"`
UsageBytes json.Number `json:"usageBytes"`
ModifyTime string `json:"modifyTime"`
OverUseDetails string `json:"overUseDetails"`
ServiceUsageState string `json:"serviceUsageState"`
PCCs []dbPCC `json:"pccs"`
}
type dbPCC struct {
ServiceCode string `json:"serviceCode"`
Name string `json:"name"`
PolicyStatus string `json:"policyStatus"`
ServiceUsageState string `json:"serviceUsageState"`
ModifyTime string `json:"modifyTime"`
}
func parseAPNs(raw, fallbackName, fallbackStatus string, fallbackUsageKB float64, fallbackUsageBytes int64) []APN {
var rows []dbAPN
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
if raw != "" && decoder.Decode(&rows) == nil {
apns := make([]APN, 0, len(rows))
for _, row := range rows {
usageBytes, _ := strconv.ParseFloat(row.UsageBytes.String(), 64)
pccs := make([]PCC, 0, len(row.PCCs))
for _, pcc := range row.PCCs {
pccs = append(pccs, PCC{ServiceCode: pcc.ServiceCode, Name: pcc.Name, PolicyStatus: pcc.PolicyStatus, ServiceUsageState: pcc.ServiceUsageState, ModifyTime: pcc.ModifyTime})
}
apns = append(apns, APN{Name: row.APNName, Status: row.Status, DiagnosisStatus: row.DiagnosisStatus, UsageBytes: usageBytes, ModifyTime: row.ModifyTime, OverUseDetails: row.OverUseDetails, ServiceUsageState: row.ServiceUsageState, PCCs: pccs})
}
return apns
}
usageBytes := float64(fallbackUsageBytes)
if usageBytes == 0 && fallbackUsageKB > 0 {
usageBytes = fallbackUsageKB * 1024
}
if fallbackName == "" {
return nil
}
return []APN{{Name: fallbackName, Status: fallbackStatus, UsageBytes: usageBytes}}
}
func apnsForDB(apns []APN) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(apns))
for _, apn := range apns {
out = append(out, map[string]interface{}{
"apnName": apn.Name,
"status": apn.Status,
"diagnosisStatus": apn.DiagnosisStatus,
"usageBytes": int64(apn.UsageBytes),
"modifyTime": apn.ModifyTime,
"overUseDetails": apn.OverUseDetails,
"serviceUsageState": apn.ServiceUsageState,
"pccs": pccsForDB(apn.PCCs),
})
}
return out
}
func pccsForDB(pccs []PCC) []map[string]string {
out := make([]map[string]string, 0, len(pccs))
for _, pcc := range pccs {
out = append(out, map[string]string{
"serviceCode": pcc.ServiceCode, "name": pcc.Name, "policyStatus": pcc.PolicyStatus,
"serviceUsageState": pcc.ServiceUsageState, "modifyTime": pcc.ModifyTime,
})
}
return out
}
func primaryAPN(card SIMCard) APN {
for _, apn := range card.APNs {
if apn.Status == "1" {
return apn
}
}
if len(card.APNs) > 0 {
return card.APNs[0]
}
return APN{Name: "CMIOT", Status: "1", UsageBytes: card.MonthUsageKB * 1024}
}
func sourceRemark(carrier, packageCode, packageName string) string {
return fmt.Sprintf("carrier=%s; package=%s; name=%s; channel=%s; supplier=%s", carrier, packageCode, packageName, generatedChanKey, generatedSupplierCode)
}
func mockMetadataFromRemark(remark string) (string, string, string) {
values := map[string]string{}
for _, part := range strings.Split(remark, ";") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if ok {
values[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
}
carrier := values["carrier"]
if carrier == "" {
carrier = "CMCC"
}
packageCode := values["package"]
if packageCode == "" {
packageCode = generatedPackageCode
}
packageName := values["name"]
if packageName == "" {
packageName = generatedPackageCode
}
return carrier, packageCode, packageName
}
func dbTimeToRFC3339(value string) string {
if value == "" {
return time.Now().Format(time.RFC3339)
}
if t, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local); err == nil {
return t.Format(time.RFC3339)
}
return value
}
func rfc3339ToDBTime(value string) string {
if value == "" {
return ""
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return t.Format("2006-01-02 15:04:05")
}
}
return value
}
+245
View File
@@ -0,0 +1,245 @@
package service
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/manifest"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
var sqliteStoreSequence atomic.Uint64
type sqliteStoreBackend struct {
db gdb.DB
ctx context.Context
keepOpen bool
}
func newSQLiteStoreBackend(databaseLink string) (storeBackend, error) {
if !strings.HasPrefix(databaseLink, "sqlite:") {
if strings.TrimSpace(databaseLink) == "" {
databaseLink = filepath.Join("data", "onelink_mock.db")
}
if databaseLink != ":memory:" {
if err := os.MkdirAll(filepath.Dir(databaseLink), 0o755); err != nil {
return nil, gerror.Wrap(err, "create sqlite directory")
}
}
databaseLink = fmt.Sprintf("sqlite::@file(%s)", filepath.ToSlash(databaseLink))
}
group := fmt.Sprintf("onelink_sqlite_%d", sqliteStoreSequence.Add(1))
if err := gdb.AddConfigNode(group, gdb.ConfigNode{
Type: "sqlite", Link: databaseLink,
MaxIdleConnCount: 1, MaxOpenConnCount: 1, MaxConnLifeTime: 24 * time.Hour,
}); err != nil {
return nil, gerror.Wrap(err, "configure sqlite")
}
db, err := gdb.NewByGroup(group)
if err != nil {
return nil, gerror.Wrap(err, "open sqlite")
}
backend := &sqliteStoreBackend{db: db, ctx: context.Background(), keepOpen: strings.Contains(databaseLink, ":memory:")}
if err = backend.ensureSchema(); err != nil {
return nil, err
}
if !backend.keepOpen {
if err = db.Close(backend.ctx); err != nil {
return nil, gerror.Wrap(err, "close sqlite after initialization")
}
}
return backend, nil
}
func (b *sqliteStoreBackend) close() error {
if b.keepOpen {
return nil
}
if err := b.db.Close(b.ctx); err != nil {
return gerror.Wrap(err, "close sqlite connection")
}
return nil
}
func (b *sqliteStoreBackend) ensureSchema() error {
if _, err := b.db.Exec(b.ctx, manifest.SchemaSQL); err != nil {
return gerror.Wrap(err, "initialize sqlite schema")
}
rows, err := b.db.GetAll(b.ctx, "PRAGMA table_info(mock_t_sim_card)")
if err != nil {
return gerror.Wrap(err, "inspect sqlite schema")
}
columns := make(map[string]struct{}, len(rows))
for _, row := range rows {
columns[row["name"].String()] = struct{}{}
}
migrations := map[string]string{
"group_id": "ALTER TABLE mock_t_sim_card ADD COLUMN group_id INTEGER",
"stop_reason": "ALTER TABLE mock_t_sim_card ADD COLUMN stop_reason TEXT",
"frozen_status": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_status TEXT",
"frozen_reason": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_reason TEXT",
"frozen_oper_time": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_oper_time TEXT",
"exported": "ALTER TABLE mock_t_sim_card ADD COLUMN exported INTEGER NOT NULL DEFAULT 0",
"export_checked_at": "ALTER TABLE mock_t_sim_card ADD COLUMN export_checked_at TEXT",
}
for column, statement := range migrations {
if _, ok := columns[column]; ok {
continue
}
if _, err = b.db.Exec(b.ctx, statement); err != nil {
return gerror.Wrapf(err, "add sqlite column %s", column)
}
}
return nil
}
func (b *sqliteStoreBackend) Load() (Store, error) {
defer b.close()
data := defaultStore()
var groups []entity.MockCardGroup
if err := b.db.Model(dao.MockCardGroup.Table()).Ctx(b.ctx).OrderAsc(dao.MockCardGroup.Columns().Id).Scan(&groups); err != nil {
return Store{}, gerror.Wrap(err, "load card groups")
}
data.Groups = make([]CardGroup, 0, len(groups))
groupNames := make(map[int64]string, len(groups))
for _, row := range groups {
group := CardGroup{ID: int64(row.Id), Name: row.Name, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
data.Groups = append(data.Groups, group)
groupNames[group.ID] = group.Name
}
var rows []entity.MockTSimCard
if err := b.db.Model(dao.MockTSimCard.Table()).Ctx(b.ctx).OrderAsc(dao.MockTSimCard.Columns().Id).Scan(&rows); err != nil {
return Store{}, gerror.Wrap(err, "load sim cards")
}
data.Cards = make([]SIMCard, 0, len(rows))
for _, row := range rows {
card := SIMCard{
GroupID: int64(row.GroupId), GroupName: groupNames[int64(row.GroupId)],
MSISDN: row.Msisdn, ICCID: row.Iccid, IMSI: row.Imsi, IMEI: row.Imei, Remark: row.Remark,
CardStatus: row.CardStatus, LastChangeDate: row.LastChangeDate, StopReason: row.StopReason,
FrozenStatus: row.FrozenStatus, FrozenReason: row.FrozenReason, FrozenOperTime: row.FrozenOperTime,
OpenDate: row.OpenDate, ActiveDate: row.ActiveDate, OnOffStatus: row.OnOffStatus,
SessionStatus: row.SessionStatus, SessionCreateDate: row.SessionCreateDate,
IP: row.SessionIpv4, IPv6Prefix: row.SessionIpv6Prefix, IPv6: row.SessionIpv6, RAT: row.Rat,
CardBindStatus: row.CardBindStatus, RegionLimitStatus: row.RegionLimitStatus,
AreaRestrictType: row.AreaRestrictType, RestrictedArea: row.RestrictedArea,
MonthUsageKB: float64(row.CurrentMonthUsageKb), PoolUsageBytes: float64(row.PoolUsageBytes),
DailyBaseKB: float64(row.DailyUsageKb), MonthlyBaseKB: float64(row.MonthlyUsageKb),
GrowthPerMinuteKB: float64(row.GrowthPerMinuteKb), LastUsageUpdateTime: dbTimeToRFC3339(row.LastUsageUpdateTime),
Exported: row.Exported != 0, ExportCheckedAt: row.ExportCheckedAt,
}
card.APNs = parseAPNs(row.ApnListJson, row.PrimaryApnName, row.PrimaryApnStatus, float64(row.PrimaryApnUsageKb), int64(row.PrimaryApnUsageBytes))
data.Cards = append(data.Cards, card)
}
var orders []entity.MockOrder
if err := b.db.Model(dao.MockOrder.Table()).Ctx(b.ctx).OrderAsc(dao.MockOrder.Columns().Id).Scan(&orders); err != nil {
return Store{}, gerror.Wrap(err, "load orders")
}
data.Orders = make([]Order, 0, len(orders))
for _, row := range orders {
data.Orders = append(data.Orders, Order{OrderNum: row.OrderNum, MSISDN: row.Msisdn, ICCID: row.Iccid, IMSI: row.Imsi, Type: row.Type, OperType: row.OperType, APNName: row.ApnName, Status: row.Status, CreatedAt: row.CreatedAt})
}
return data, nil
}
func (b *sqliteStoreBackend) Save(data Store) error {
defer b.close()
return b.db.Transaction(b.ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := tx.Exec(manifest.SchemaSQL); err != nil {
return gerror.Wrap(err, "initialize sqlite schema")
}
for _, table := range []string{dao.MockOrder.Table(), dao.MockTSimCard.Table(), dao.MockCardGroup.Table()} {
if _, err := tx.Model(table).Ctx(ctx).Where("1=1").Delete(); err != nil {
return gerror.Wrap(err, "clear sqlite table")
}
}
if err := insertGroups(ctx, tx, data.Groups); err != nil {
return err
}
if err := insertCards(ctx, tx, data.Cards); err != nil {
return err
}
return insertOrders(ctx, tx, data.Orders)
})
}
func (b *sqliteStoreBackend) AppendCards(cards []SIMCard) error {
defer b.close()
return b.db.Transaction(b.ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := tx.Exec(manifest.SchemaSQL); err != nil {
return gerror.Wrap(err, "initialize sqlite schema")
}
return insertCards(ctx, tx, cards)
})
}
func insertGroups(ctx context.Context, tx gdb.TX, groups []CardGroup) error {
for _, item := range groups {
if _, err := tx.Model(dao.MockCardGroup.Table()).Ctx(ctx).Data(do.MockCardGroup{Id: item.ID, Name: item.Name, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}).Insert(); err != nil {
return gerror.Wrap(err, "insert card group")
}
}
return nil
}
func insertCards(ctx context.Context, tx gdb.TX, cards []SIMCard) error {
for _, item := range cards {
normalizeCard(&item)
primary := primaryAPN(item)
apnList, err := json.Marshal(apnsForDB(item.APNs))
if err != nil {
return gerror.Wrap(err, "encode apn list")
}
carrier, packageCode, packageName := mockMetadataFromRemark(item.Remark)
data := do.MockTSimCard{
GroupId: item.GroupID, Msisdn: item.MSISDN, Iccid: item.ICCID, Imsi: item.IMSI, Imei: item.IMEI,
Remark: item.Remark, Carrier: carrier, PackageCode: packageCode, PackageName: packageName,
PackageStatus: 1, PackageTotalFlowMb: item.MonthlyBaseKB / 1024, IsFlowPool: 1,
CardStatus: item.CardStatus, LastChangeDate: item.LastChangeDate, StopReason: item.StopReason,
FrozenStatus: item.FrozenStatus, FrozenReason: item.FrozenReason, FrozenOperTime: item.FrozenOperTime,
OpenDate: item.OpenDate, ActiveDate: item.ActiveDate, OnOffStatus: item.OnOffStatus, SessionStatus: item.SessionStatus,
ApnId: primary.Name, SessionIpv4: item.IP, SessionIpv6Prefix: item.IPv6Prefix, SessionIpv6: item.IPv6,
SessionCreateDate: item.SessionCreateDate, Rat: item.RAT, ApnListJson: string(apnList), ApnUsageJson: string(apnList),
PrimaryApnName: primary.Name, PrimaryApnStatus: primary.Status, PrimaryApnUsageKb: primary.UsageBytes / 1024,
PrimaryApnUsageBytes: int64(primary.UsageBytes), PccCode: "default", PccUsageKb: primary.UsageBytes / 1024,
CurrentMonthUsageKb: item.MonthUsageKB, PoolUsageBytes: int64(item.PoolUsageBytes), DailyUsageKb: item.DailyBaseKB,
MonthlyUsageKb: item.MonthlyBaseKB, DailyQueryDate: parseCardTime(item.ActiveDate).Format("20060102"),
MonthlyQueryMonth: parseCardTime(item.ActiveDate).Format("200601"), MonthlyDeadline: parseCardTime(item.ActiveDate).AddDate(0, 0, -1).Format("20060102"),
GrowthPerMinuteKb: item.GrowthPerMinuteKB, AutoGrowthEnabled: 0, LastUsageUpdateTime: rfc3339ToDBTime(item.LastUsageUpdateTime),
CardBindStatus: item.CardBindStatus, RegionLimitStatus: item.RegionLimitStatus, AreaRestrictType: item.AreaRestrictType,
RestrictedArea: item.RestrictedArea, Exported: boolInt(item.Exported), ExportCheckedAt: item.ExportCheckedAt,
CreatedAt: item.OpenDate, UpdatedAt: item.LastChangeDate,
}
if _, err = tx.Model(dao.MockTSimCard.Table()).Ctx(ctx).Data(data).Insert(); err != nil {
return gerror.Wrap(err, "insert sim card")
}
}
return nil
}
func insertOrders(ctx context.Context, tx gdb.TX, orders []Order) error {
for _, item := range orders {
if _, err := tx.Model(dao.MockOrder.Table()).Ctx(ctx).Data(do.MockOrder{OrderNum: item.OrderNum, Msisdn: item.MSISDN, Iccid: item.ICCID, Imsi: item.IMSI, Type: item.Type, OperType: item.OperType, ApnName: item.APNName, Status: item.Status, CreatedAt: item.CreatedAt}).Insert(); err != nil {
return gerror.Wrap(err, "insert order")
}
}
return nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
+71
View File
@@ -0,0 +1,71 @@
package service
import "testing"
func TestStoreManagerUsesSQLiteWithoutExternalDatabase(t *testing.T) {
manager, err := NewStoreManager(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
got := manager.Snapshot()
if len(got.Cards) != 0 {
t.Fatalf("new sqlite store should not contain sample cards, got %#v", got.Cards)
}
if len(got.AppIDs) == 0 || got.AppIDs[0].AppID != "demo_appid" {
t.Fatalf("default app id not preserved: %#v", got.AppIDs)
}
}
func TestStoreManagerGenerateCardsAppendsWithoutDeletingOldCards(t *testing.T) {
manager, err := NewStoreManager(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
first, err := manager.GenerateCards(2)
if err != nil {
t.Fatal(err)
}
if first.Generated != 2 {
t.Fatalf("first generated = %d, want 2", first.Generated)
}
second, err := manager.GenerateCards(1)
if err != nil {
t.Fatal(err)
}
if second.Generated != 1 || second.Total != 3 {
t.Fatalf("second result = %#v, want generated 1 total 3", second)
}
got := manager.Snapshot()
if len(got.Cards) != 3 {
t.Fatalf("generated cards should append, got %d cards: %#v", len(got.Cards), got.Cards)
}
}
func TestOrdersPersistAcrossStoreRestart(t *testing.T) {
dbPath := t.TempDir() + "/onelink_mock.db"
manager, err := NewStoreManager(dbPath)
if err != nil {
t.Fatal(err)
}
want := Order{
OrderNum: "ORD202607270001", MSISDN: "138****8000", ICCID: "8986000000000000001",
IMSI: "460000000000001", Type: "sim-apn-function", OperType: "1",
APNName: "CMIOT", Status: "completed", CreatedAt: "2026-07-27 16:00:00",
}
if err := manager.SaveMutation(func(st *Store) error {
st.Orders = append(st.Orders, want)
return nil
}); err != nil {
t.Fatal(err)
}
reopened, err := NewStoreManager(dbPath)
if err != nil {
t.Fatal(err)
}
got := reopened.Snapshot().Orders
if len(got) != 1 || got[0] != want {
t.Fatalf("orders after restart = %#v, want %#v", got, want)
}
}
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/cmd"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
cmd.Main.Run(gctx.GetInitCtx())
}
+26
View File
@@ -0,0 +1,26 @@
server:
address: ":8080"
openapiPath: "/api.json"
swaggerPath: "/swagger"
logPath: "logs"
logLevel: "all"
logStdout: true
accessLogEnabled: true
logger:
path: "logs"
file: "{Y-m-d}.log"
level: "all"
stdout: true
stdoutColorDisabled: true
rotateSize: "100M"
rotateBackupLimit: 10
rotateBackupExpire: "30d"
database:
default:
type: "sqlite"
link: "sqlite::@file(data/onelink_mock.db)"
runtimeLog:
capacity: 500
+6
View File
@@ -0,0 +1,6 @@
package manifest
import _ "embed"
//go:embed sql/schema.sql
var SchemaSQL string
+80
View File
@@ -0,0 +1,80 @@
CREATE TABLE IF NOT EXISTS mock_card_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mock_t_sim_card (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER,
msisdn TEXT NOT NULL UNIQUE,
iccid TEXT NOT NULL UNIQUE,
imsi TEXT NOT NULL UNIQUE,
imei TEXT,
remark TEXT,
carrier TEXT,
package_code TEXT,
package_name TEXT,
package_status INTEGER,
package_total_flow_mb REAL,
is_flow_pool INTEGER,
card_status TEXT,
last_change_date TEXT,
stop_reason TEXT,
frozen_status TEXT,
frozen_reason TEXT,
frozen_oper_time TEXT,
open_date TEXT,
active_date TEXT,
on_off_status TEXT,
session_status TEXT,
apn_id TEXT,
session_ipv4 TEXT,
session_ipv6_prefix TEXT,
session_ipv6 TEXT,
session_create_date TEXT,
rat TEXT,
apn_list_json TEXT,
apn_usage_json TEXT,
primary_apn_name TEXT,
primary_apn_status TEXT,
primary_apn_usage_kb REAL,
primary_apn_usage_bytes INTEGER,
pcc_code TEXT,
pcc_usage_kb REAL,
current_month_usage_kb REAL,
pool_usage_bytes INTEGER,
daily_usage_kb REAL,
monthly_usage_kb REAL,
daily_query_date TEXT,
monthly_query_month TEXT,
monthly_deadline TEXT,
growth_per_minute_kb REAL,
auto_growth_enabled INTEGER,
last_usage_update_time TEXT,
card_bind_status TEXT,
region_limit_status TEXT,
area_restrict_type TEXT,
restricted_area TEXT,
exported INTEGER NOT NULL DEFAULT 0,
export_checked_at TEXT,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS mock_order (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_num TEXT NOT NULL UNIQUE,
msisdn TEXT,
iccid TEXT,
imsi TEXT,
type TEXT NOT NULL,
oper_type TEXT,
apn_name TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mock_card_created_at ON mock_t_sim_card(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_mock_order_created_at ON mock_order(created_at DESC);