commit 4e2cd43a558cbd7d1ee5e62bb62dbd0278eabc41 Author: MiFaZhan Date: Thu Oct 22 15:08:27 2026 +0800 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..53790cf --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5837541 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcdaa8b --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e0ffcf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..29ed456 --- /dev/null +++ b/README.md @@ -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=&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) diff --git a/apifox-openapi.yaml b/apifox-openapi.yaml new file mode 100644 index 0000000..1b6f4b1 --- /dev/null +++ b/apifox-openapi.yaml @@ -0,0 +1,2251 @@ +openapi: 3.0.3 +info: + title: OneLink Mock API + version: 1.0.0 + description: | + Import this OpenAPI document into APIFOX to debug the local OneLink mock service. + + The documented OneLink business paths use the `/v5/ec/...` prefix. The service + also accepts the compatible `/ec/...` prefix for the same business endpoints. + The `/sim/...` paths expose the unified channel protocol used by + `iotlcm-upstream-chan`. +servers: + - url: http://localhost:8080 + description: Local mock service +tags: + - name: Health + description: Service health check. + - name: Admin + description: Local management console APIs. + - name: Card Groups + description: SIM card group management APIs. + - name: Card Export + description: Export SIM cards to Excel for warehouse check-in. + - name: Operation Logs + description: Query and clear operation log records. + - name: Card Generation + description: Batch generate or sync SIM cards. + - name: OneLink Token + description: Token API compatible with OneLink. + - name: OneLink Query + description: Query APIs compatible with OneLink. + - name: OneLink Operate + description: Operation APIs compatible with OneLink. + - name: OneLink Diagnosis + description: Diagnosis and troubleshooting APIs compatible with OneLink. + - name: Chan API + description: Unified SIM channel APIs compatible with iotlcm-upstream-chan. + +paths: + /sim/query/sim-basic-info: + post: + tags: [Chan API] + summary: Query SIM channel data + operationId: chanQuerySimBasicInfo + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-session: + post: + tags: [Chan API] + summary: Query SIM session channel data + operationId: chanQuerySimSession + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-status: + post: + tags: [Chan API] + summary: Query SIM status channel data + operationId: chanQuerySimStatus + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-status-batch: + post: + tags: [Chan API] + summary: Query SIM status batch channel data + operationId: chanQuerySimStatusBatch + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-card-bind-status: + post: + tags: [Chan API] + summary: Query SIM card bind status channel data + operationId: chanQuerySimCardBindStatus + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-device-status: + post: + tags: [Chan API] + summary: Query SIM device status channel data + operationId: chanQuerySimDeviceStatus + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-region-limit-status: + post: + tags: [Chan API] + summary: Query SIM region limit status channel data + operationId: chanQuerySimRegionLimitStatus + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-imei: + post: + tags: [Chan API] + summary: Query SIM IMEI channel data + operationId: chanQuerySimImei + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-datausage: + post: + tags: [Chan API] + summary: Query SIM data usage channel data + operationId: chanQuerySimDataUsage + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-datausage-monthly: + post: + tags: [Chan API] + summary: Query SIM monthly data usage channel data + operationId: chanQuerySimDataUsageMonthly + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-datausage-monthly-batch: + post: + tags: [Chan API] + summary: Query SIM monthly data usage batch channel data + operationId: chanQuerySimDataUsageMonthlyBatch + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-datausage-daily: + post: + tags: [Chan API] + summary: Query SIM daily data usage channel data + operationId: chanQuerySimDataUsageDaily + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-datausage-daily-batch: + post: + tags: [Chan API] + summary: Query SIM daily data usage batch channel data + operationId: chanQuerySimDataUsageDailyBatch + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/query/sim-apn-info: + post: + tags: [Chan API] + summary: Query SIM APN info channel data + operationId: chanQuerySimApnInfo + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/change/sim-status: + post: + tags: [Chan API] + summary: Change SIM status via channel + operationId: chanChangeSimStatus + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + /sim/change/sim-apn: + post: + tags: [Chan API] + summary: Change SIM APN via channel + operationId: chanChangeSimApn + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChanRequest" } + responses: + "200": + description: Per-SIM channel responses. + content: + application/json: + schema: { $ref: "#/components/schemas/ChanResponseList" } + + /healthz: + get: + tags: + - Health + summary: Health check + operationId: getHealthz + responses: + "200": + description: Service is running. + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + examples: + ok: + value: + status: ok + + /api/state: + get: + tags: + - Admin + summary: Get full mock state + operationId: getAdminState + responses: + "200": + description: Current store snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Store" + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + + /api/appids: + post: + tags: + - Admin + summary: Create or update AppID credential + operationId: saveAdminAppId + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Credential" + examples: + default: + value: + appid: "demo_appid" + password: demo_password + name: Default test app + responses: + "200": + description: Updated store snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Store" + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + "400": + $ref: "#/components/responses/AdminBadRequest" + delete: + tags: + - Admin + summary: Delete AppID credential + operationId: deleteAdminAppId + parameters: + - name: appid + in: query + required: true + schema: + type: string + example: "demo_appid" + responses: + "200": + description: Updated store snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Store" + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + + /api/cards: + get: + tags: [Admin] + summary: Query SIM cards by any exact identifier + operationId: queryAdminCards + description: Supports filtering by keyword (MSISDN/ICCID/IMSI), groupId, and exportStatus. + parameters: + - name: keyword + in: query + required: false + description: Exact MSISDN, ICCID, or IMSI. The service detects the identifier automatically. + schema: { type: string } + example: "1440292201313" + - name: groupId + in: query + required: false + description: Filter cards by group ID. When provided, only cards in that group are returned. + schema: { type: integer } + example: 1 + - name: exportStatus + in: query + required: false + description: Filter by export status. Use 'exported' for already exported cards, 'not_exported' for non-exported cards. + schema: + type: string + enum: ["exported", "not_exported"] + example: "not_exported" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: Paginated SIM cards. + content: + application/json: + schema: { $ref: "#/components/schemas/CardPage" } + examples: + default: + value: + items: + - msisdn: "1440292201313" + iccid: "898600D6991330004146" + imsi: "460079650004176" + groupId: 1 + groupName: "批次一" + cardStatus: "2" + cardBindStatus: "1" + exported: false + total: 1 + page: 1 + pageSize: 20 + post: + tags: + - Admin + summary: Create or update SIM card + operationId: saveAdminCard + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SIMCard" + examples: + default: + value: + msisdn: "1440292201313" + iccid: "898600D6991330004146" + imsi: "460079650004176" + imei: "863725040000001" + remark: Test card A + cardStatus: "2" + onOffStatus: "1" + sessionStatus: "01" + cardBindStatus: "1" + regionLimitStatus: "0" + monthUsageKB: 18432 + dailyBaseKB: 614.4 + monthlyBaseKB: 18432 + growthPerMinuteKB: 1.5 + apns: + - name: CMIOT + status: "1" + usageBytes: 13271040 + responses: + "200": + description: Updated store snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Store" + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + "400": + $ref: "#/components/responses/AdminBadRequest" + delete: + tags: + - Admin + summary: Delete SIM card by MSISDN + operationId: deleteAdminCard + parameters: + - name: msisdn + in: query + required: true + schema: + type: string + example: "1440292201313" + responses: + "200": + description: Updated store snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Store" + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + + /api/settings: + post: + tags: + - Admin + summary: Update simulation settings + operationId: saveAdminSettings + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SimulationSettings" + examples: + default: + value: + autoGrowthEnabled: true + defaultGrowthKB: 1.5 + responses: + "200": + description: Updated store snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Store" + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + "400": + $ref: "#/components/responses/AdminBadRequest" + + /api/card-groups: + get: + tags: [Card Groups] + summary: List all card groups with card counts + operationId: listCardGroups + responses: + "200": + description: Array of card groups with current card counts. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/CardGroup" } + examples: + default: + value: + - id: 1 + name: "批次一" + createdAt: "2026-08-24 10:00:00" + updatedAt: "2026-08-24 10:00:00" + cardCount: 50 + - id: 2 + name: "批次二" + createdAt: "2026-08-24 11:00:00" + updatedAt: "2026-08-24 11:00:00" + cardCount: 30 + post: + tags: [Card Groups] + summary: Create a new card group + operationId: createCardGroup + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + examples: + default: + value: + name: "新批次" + responses: + "200": + description: Created card group. + content: + application/json: + schema: { $ref: "#/components/schemas/CardGroup" } + examples: + default: + value: + id: 3 + name: "新批次" + createdAt: "2026-08-24 12:00:00" + updatedAt: "2026-08-24 12:00:00" + cardCount: 0 + "400": + $ref: "#/components/responses/AdminBadRequest" + delete: + tags: [Card Groups] + summary: Delete a card group (cards in the group become ungrouped) + operationId: deleteCardGroup + parameters: + - name: id + in: query + required: true + description: Group ID to delete. + schema: { type: integer } + example: 1 + responses: + "200": + description: Updated store snapshot after removal. + content: + application/json: + schema: { $ref: "#/components/schemas/Store" } + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + "400": + $ref: "#/components/responses/AdminBadRequest" + + /api/cards/export: + get: + tags: [Card Export] + summary: Export SIM cards to Excel (XLSX) + operationId: exportCards + description: Downloads an Excel file containing SIM card information. Selected cards are marked as exported. + parameters: + - name: msisdns + in: query + required: false + description: Comma-separated MSISDNs to export. If omitted, all cards are exported. + schema: { type: string } + example: "1440292201313,138****8000" + responses: + "200": + description: Excel file binary. + headers: + Content-Disposition: + schema: + type: string + example: 'attachment; filename="sim_card_export.xlsx"' + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: + type: string + format: binary + + /api/operation-logs: + get: + tags: [Operation Logs] + summary: Query operation logs with optional filters + operationId: queryOperationLogs + parameters: + - name: keyword + in: query + required: false + description: Fuzzy match against target, path, or message. + schema: { type: string } + example: "1440292201313" + - name: category + in: query + required: false + description: Filter by operation category. + schema: { type: string } + example: "generate" + - name: result + in: query + required: false + description: Filter by result status. + schema: { type: string } + example: "success" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: Paginated operation logs. + content: + application/json: + schema: { $ref: "#/components/schemas/OperationLogPage" } + examples: + default: + value: + items: + - id: 1 + createdAt: "2026-08-24 10:00:00" + category: generate + action: 一键生成 SIM 卡 + target: "3" + method: POST + path: /api/generate + params: "{\"count\":3}" + result: success + statusCode: 200 + durationMs: 150 + total: 1 + page: 1 + pageSize: 20 + + /api/sync: + post: + tags: [Card Generation] + summary: Generate SIM cards without group assignment + operationId: syncCards + description: Generates new SIM cards without assigning them to a card group. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [count] + properties: + count: { type: integer, minimum: 1 } + examples: + default: + value: + count: 100 + responses: + "200": + description: Sync result with scanned and synced counts. + content: + application/json: + schema: { $ref: "#/components/schemas/SyncResult" } + examples: + default: + value: + scanned: 100 + synced: 100 + "400": + $ref: "#/components/responses/AdminBadRequest" + + /api/generate: + post: + tags: [Card Generation] + summary: Generate SIM cards with optional group assignment + operationId: generateCards + description: Batch-generate SIM cards. Specify a groupId to assign cards to a group, or omit for ungrouped cards. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [count] + properties: + count: { type: integer, minimum: 1 } + groupId: { type: integer, nullable: true } + examples: + withGroup: + value: + count: 50 + groupId: 1 + withoutGroup: + value: + count: 200 + responses: + "200": + description: Generation result with generated count and total cards. + content: + application/json: + schema: { $ref: "#/components/schemas/GenerateResult" } + examples: + default: + value: + generated: 50 + total: 51 + "400": + $ref: "#/components/responses/AdminBadRequest" + + /api/orders: + get: + tags: [Admin] + summary: List persisted operation orders + operationId: listAdminOrders + parameters: + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: Paginated orders, newest first. + content: + application/json: + schema: { $ref: "#/components/schemas/OrderPage" } + delete: + tags: + - Admin + summary: Clear operation orders + operationId: clearAdminOrders + responses: + "200": + description: Updated store snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Store" + examples: + default: + $ref: "#/components/examples/StoreSnapshot" + + /v5/ec/get/token: + get: + tags: + - OneLink Token + summary: Get token + description: Returns a token with a 3600 second TTL. Set `refresh=0` to reuse an unexpired token when available. + operationId: getOneLinkToken + parameters: + - $ref: "#/components/parameters/AppID" + - $ref: "#/components/parameters/Password" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Refresh" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - token: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + ttl: "3600" + invalidCredential: + value: + status: "12021" + message: Invalid authentication + result: [] + + /v5/ec/query/sim-basic-info: + get: + tags: + - OneLink Query + summary: Query SIM basic info + operationId: querySimBasicInfo + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + $ref: "#/components/responses/OneLinkBasicInfo" + + /v5/ec/query/sim-session: + get: + tags: + - OneLink Query + summary: Query SIM session + operationId: querySimSession + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - simSessionList: + - apnId: CMIOT + status: "01" + ip: 192.0.2.42 + ipv6Prefix: "" + ipv6: "" + createDate: "2026-06-06 14:00:00" + rat: "6" + + /v5/ec/query/sim-status: + get: + tags: + - OneLink Query + summary: Query SIM status + operationId: querySimStatus + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - cardStatus: "2" + lastChangeDate: "2026-06-06 14:00:00" + + /v5/ec/query/sim-imei: + get: + tags: + - OneLink Query + summary: Query SIM IMEI + operationId: querySimImei + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - imei: "863725040000001" + + /v5/ec/query/sim-stop-reason: + get: + tags: [OneLink Diagnosis] + summary: Query SIM stop reason + operationId: querySimStopReason + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": { $ref: "#/components/responses/OneLinkBasicInfo" } + + /v5/ec/query/sim-frozen-state-info: + get: + tags: [OneLink Diagnosis] + summary: Query SIM frozen state + operationId: querySimFrozenState + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": { $ref: "#/components/responses/OneLinkBasicInfo" } + + /v5/ec/query/sim-apn-session-info: + get: + tags: [OneLink Diagnosis] + summary: Query SIM APN sessions + operationId: querySimApnSessions + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": { $ref: "#/components/responses/OneLinkBasicInfo" } + + /v5/ec/query/sim-activated-features-info: + get: + tags: [OneLink Diagnosis] + summary: Query activated communication features + operationId: querySimActivatedFeatures + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": { $ref: "#/components/responses/OneLinkBasicInfo" } + + /v5/ec/query/sim-operating-status: + get: + tags: [OneLink Diagnosis] + summary: Query terminal operating status + operationId: querySimOperatingStatus + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": { $ref: "#/components/responses/OneLinkBasicInfo" } + + /v5/ec/query/sim-data-diagnosis: + get: + tags: [OneLink Diagnosis] + summary: Query SIM APN and PCC data diagnosis + operationId: querySimDataDiagnosis + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": { $ref: "#/components/responses/OneLinkBasicInfo" } + + /v5/ec/query/sim-data-usage-inpool: + get: + tags: + - OneLink Query + summary: Query SIM data usage in pool + operationId: querySimDataUsageInPool + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - apnList: + - apnName: CMIOT + apnUseAmount: "13271040" + - apnName: CMMTM + apnUseAmount: "5687562.24" + + /v5/ec/query/sim-data-usage: + get: + tags: + - OneLink Query + summary: Query SIM data usage + operationId: querySimDataUsage + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - dataAmount: "18432.00" + apnUseAmountList: + - apnName: CMIOT + apnUseAmount: "12902.40" + pccCodeUseAmountList: + - pccCode: default + pccCodeUseAmount: "12902.40" + + /v5/ec/query/sim-data-usage-monthly/batch: + get: + tags: + - OneLink Query + summary: Batch query monthly data usage + operationId: queryMonthlyDataUsageBatch + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdns" + - $ref: "#/components/parameters/Iccids" + - $ref: "#/components/parameters/Imsis" + - $ref: "#/components/parameters/MonthlyQueryDate" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - deadLine: "20260705" + dataAmountList: + - msisdn: "1440292201313" + dataAmount: "18432" + apnDataAmountList: + - apnName: CMIOT + apnDataAmount: "12902.40" + - apnName: CMMTM + apnDataAmount: "5529.60" + + /v5/ec/query/sim-data-usage-daily/batch: + get: + tags: + - OneLink Query + summary: Batch query daily data usage + operationId: queryDailyDataUsageBatch + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdns" + - $ref: "#/components/parameters/Iccids" + - $ref: "#/components/parameters/Imsis" + - $ref: "#/components/parameters/DailyQueryDate" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - dataAmountList: + - msisdn: "1440292201313" + dataAmount: "614.40" + apnDataAmountList: + - apnName: CMIOT + apnDataAmount: "430.08" + - apnName: CMMTM + apnDataAmount: "184.32" + + /v5/ec/query/apn-info: + get: + tags: + - OneLink Query + summary: Query APN info + operationId: queryApnInfo + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - apnList: + - apnName: CMIOT + status: "1" + - apnName: CMMTM + status: "1" + + /v5/ec/query/card-bind-status: + get: + tags: + - OneLink Query + summary: Query card bind status + operationId: queryCardBindStatus + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + - name: testType + in: query + required: false + description: Accepted for client compatibility; the mock service does not validate it. + schema: + type: string + example: "0" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - result: "1" + + /v5/ec/query/on-off-status: + get: + tags: + - OneLink Query + summary: Query on/off status + operationId: queryOnOffStatus + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - status: "1" + + /v5/ec/query/region-limit-status: + get: + tags: + - OneLink Query + summary: Query region limit status + operationId: queryRegionLimitStatus + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - regionLimitStatus: "0" + + /v5/ec/change/sim-status: + get: + tags: + - OneLink Operate + summary: Change SIM status + operationId: changeSimStatus + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + - name: operType + in: query + required: true + description: Operation type. Known mappings in this mock are 0->status 4, 1/2/5/6->status 2, 3/8->status 7, 4/9->status 1. + schema: + type: string + enum: + - "0" + - "1" + - "2" + - "3" + - "4" + - "5" + - "6" + - "8" + - "9" + example: "0" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - msisdn: "1440292201313" + orderNum: ORD2026070614000000a1b2c3 + + /v5/ec/operate/sim-apn-function: + get: + tags: + - OneLink Operate + summary: Enable or disable SIM APN function + operationId: operateSimApnFunction + parameters: + - $ref: "#/components/parameters/Token" + - $ref: "#/components/parameters/TransID" + - $ref: "#/components/parameters/Msisdn" + - $ref: "#/components/parameters/Iccid" + - $ref: "#/components/parameters/Imsi" + - name: apnName + in: query + required: true + schema: + type: string + example: CMIOT + - name: operType + in: query + required: true + description: "0 disables APN, 1 enables APN." + schema: + type: string + enum: + - "0" + - "1" + example: "1" + responses: + "200": + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - msisdn: "1440292201313" + orderNum: APN2026070614000000a1b2c3 + +components: + parameters: + Page: + name: page + in: query + required: false + schema: { type: integer, minimum: 1, default: 1 } + PageSize: + name: pageSize + in: query + required: false + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + AppID: + name: appid + in: query + required: true + schema: + type: string + example: "demo_appid" + Password: + name: password + in: query + required: true + schema: + type: string + example: demo_password + Refresh: + name: refresh + in: query + required: false + description: Use 0 to reuse an unexpired token; use 1 or omit to issue a new token. + schema: + type: string + enum: + - "0" + - "1" + example: "1" + Token: + name: token + in: query + required: true + schema: + type: string + example: "" + TransID: + name: transid + in: query + required: false + description: Client transaction id. The mock currently accepts it without validation. + schema: + type: string + example: "demo_appid2026070209300000000001" + Msisdn: + name: msisdn + in: query + required: false + description: SIM identifier. For single-card endpoints, provide exactly one of msisdn, iccid, or imsi. + schema: + type: string + example: "1440292201313" + Iccid: + name: iccid + in: query + required: false + description: SIM identifier. For single-card endpoints, provide exactly one of msisdn, iccid, or imsi. + schema: + type: string + example: "898600D6991330004146" + Imsi: + name: imsi + in: query + required: false + description: SIM identifier. For single-card endpoints, provide exactly one of msisdn, iccid, or imsi. + schema: + type: string + example: "460079650004176" + Msisdns: + name: msisdns + in: query + required: false + description: Underscore-separated SIM identifiers. For batch endpoints, provide exactly one of msisdns, iccids, or imsis. + schema: + type: string + example: "1440292201313_138****8000" + Iccids: + name: iccids + in: query + required: false + description: Underscore-separated SIM identifiers. For batch endpoints, provide exactly one of msisdns, iccids, or imsis. + schema: + type: string + example: "898600D6991330004146_898602B2221430000005" + Imsis: + name: imsis + in: query + required: false + description: Underscore-separated SIM identifiers. For batch endpoints, provide exactly one of msisdns, iccids, or imsis. + schema: + type: string + example: "460079650004176_460079650004177" + MonthlyQueryDate: + name: queryDate + in: query + required: false + description: Month to query, formatted as yyyyMM. + schema: + type: string + pattern: "^[0-9]{6}$" + example: "202607" + DailyQueryDate: + name: queryDate + in: query + required: false + description: Date to query, formatted as yyyyMMdd. + schema: + type: string + pattern: "^[0-9]{8}$" + example: "20260705" + + responses: + AdminBadRequest: + description: Invalid request body. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminError" + examples: + badRequest: + value: + error: invalid character + OneLinkBasicInfo: + description: OneLink response. + content: + application/json: + schema: + $ref: "#/components/schemas/OneLinkResponse" + examples: + success: + value: + status: "0" + message: OK + result: + - msisdn: "1440292201313" + imsi: "460079650004176" + iccid: "898600D6991330004146" + activeDate: "2025-07-09 14:00:00" + openDate: "2025-07-06 14:00:00" + remark: Test card A + missing: + value: + status: "14008" + message: Not found + result: [] + + schemas: + ChanRequest: + type: object + required: [simKeys] + properties: + simKeys: + type: array + items: + $ref: "#/components/schemas/ChanSimKey" + reqData: + type: object + additionalProperties: true + example: + simKeys: + - chanKey: channel_yd_mock + iccid: "8986040000000000001" + imsi: "460040000000000001" + msisdn: "138****8000" + reqData: {} + ChanSimKey: + type: object + properties: + chanKey: { type: string, example: channel_yd_mock } + iccid: { type: string, example: "8986040000000000001" } + imsi: { type: string, example: "460040000000000001" } + msisdn: { type: string, example: "138****8000" } + ChanResponseList: + type: array + items: + type: object + properties: + code: { type: integer, example: 0 } + message: { type: string, example: success } + simKey: { $ref: "#/components/schemas/ChanSimKey" } + resData: + type: object + additionalProperties: true + CardPage: + type: object + required: [items, total, page, pageSize] + properties: + items: { type: array, items: { $ref: "#/components/schemas/SIMCard" } } + total: { type: integer } + page: { type: integer } + pageSize: { type: integer } + OrderPage: + type: object + required: [items, total, page, pageSize] + properties: + items: { type: array, items: { $ref: "#/components/schemas/Order" } } + total: { type: integer } + page: { type: integer } + pageSize: { type: integer } + HealthResponse: + type: object + properties: + status: + type: string + example: ok + AdminError: + type: object + properties: + error: + type: string + example: appid and password are required + OneLinkResponse: + type: object + required: + - status + - message + - result + properties: + status: + type: string + description: OneLink status code. "0" means success. + example: "0" + message: + type: string + example: OK + result: + type: array + items: + type: object + additionalProperties: true + TokenResult: + type: object + properties: + token: + type: string + example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + ttl: + type: string + example: "3600" + BasicInfoResult: + type: object + properties: + msisdn: + type: string + example: "1440292201313" + imsi: + type: string + example: "460079650004176" + iccid: + type: string + example: "898600D6991330004146" + activeDate: + type: string + example: "2025-07-09 14:00:00" + openDate: + type: string + example: "2025-07-06 14:00:00" + remark: + type: string + example: Test card A + SessionResult: + type: object + properties: + simSessionList: + type: array + items: + $ref: "#/components/schemas/SessionItem" + SessionItem: + type: object + properties: + apnId: + type: string + example: CMIOT + status: + type: string + example: "01" + ip: + type: string + example: 192.0.2.42 + ipv6Prefix: + type: string + example: "" + ipv6: + type: string + example: "" + createDate: + type: string + example: "2026-06-06 14:00:00" + rat: + type: string + example: "6" + StatusResult: + type: object + properties: + cardStatus: + type: string + example: "2" + lastChangeDate: + type: string + example: "2026-06-06 14:00:00" + ImeiResult: + type: object + properties: + imei: + type: string + example: "863725040000001" + ApnUsageInPoolResult: + type: object + properties: + apnList: + type: array + items: + $ref: "#/components/schemas/ApnUsageInPoolItem" + ApnUsageInPoolItem: + type: object + properties: + apnName: + type: string + example: CMIOT + apnUseAmount: + type: string + example: "13271040" + DataUsageResult: + type: object + properties: + dataAmount: + type: string + example: "18432.00" + apnUseAmountList: + type: array + items: + $ref: "#/components/schemas/ApnUseAmountItem" + ApnUseAmountItem: + type: object + properties: + apnName: + type: string + example: CMIOT + apnUseAmount: + type: string + example: "12902.40" + pccCodeUseAmountList: + type: array + items: + $ref: "#/components/schemas/PccCodeUseAmountItem" + PccCodeUseAmountItem: + type: object + properties: + pccCode: + type: string + example: default + pccCodeUseAmount: + type: string + example: "12902.40" + BatchUsageResult: + type: object + properties: + deadLine: + type: string + example: "20260705" + dataAmountList: + type: array + items: + $ref: "#/components/schemas/BatchUsageItem" + BatchUsageItem: + type: object + properties: + msisdn: + type: string + example: "1440292201313" + iccid: + type: string + example: "898600D6991330004146" + imsi: + type: string + example: "460079650004176" + dataAmount: + type: string + example: "18432" + apnDataAmountList: + type: array + items: + $ref: "#/components/schemas/ApnDataAmountItem" + ApnDataAmountItem: + type: object + properties: + apnName: + type: string + example: CMIOT + apnDataAmount: + type: string + example: "12902.40" + ApnInfoResult: + type: object + properties: + apnList: + type: array + items: + $ref: "#/components/schemas/ApnInfoItem" + ApnInfoItem: + type: object + properties: + apnName: + type: string + example: CMIOT + status: + type: string + example: "1" + CardBindStatusResult: + type: object + properties: + result: + type: string + example: "1" + OnOffStatusResult: + type: object + properties: + status: + type: string + example: "1" + RegionLimitStatusResult: + type: object + properties: + regionLimitStatus: + type: string + example: "0" + OperationResult: + type: object + properties: + msisdn: + type: string + example: "1440292201313" + iccid: + type: string + example: "898600D6991330004146" + imsi: + type: string + example: "460079650004176" + orderNum: + type: string + example: ORD2026070614000000a1b2c3 + Store: + type: object + properties: + appids: + type: array + items: + $ref: "#/components/schemas/Credential" + groups: + type: array + items: + $ref: "#/components/schemas/CardGroup" + cards: + type: array + items: + $ref: "#/components/schemas/SIMCard" + tokens: + type: object + additionalProperties: + $ref: "#/components/schemas/Token" + orders: + type: array + items: + $ref: "#/components/schemas/Order" + settings: + $ref: "#/components/schemas/SimulationSettings" + CardGroup: + type: object + required: [id, name, createdAt, updatedAt] + properties: + id: + type: integer + example: 1 + name: + type: string + example: "批次一" + createdAt: + type: string + example: "2026-08-24 10:00:00" + updatedAt: + type: string + example: "2026-08-24 10:00:00" + cardCount: + type: integer + example: 50 + Credential: + type: object + required: + - appid + - password + properties: + appid: + type: string + example: "demo_appid" + password: + type: string + example: demo_password + name: + type: string + example: Default test app + enabled: + type: boolean + example: true + createdAt: + type: string + example: "2026-07-06 14:00:00" + lastToken: + type: string + example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + tokenExpires: + type: string + example: "2026-07-06 15:00:00" + Token: + type: object + properties: + appid: + type: string + example: "demo_appid" + value: + type: string + example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + expiresAt: + type: string + format: date-time + example: "2026-07-06T15:00:00+08:00" + APN: + type: object + properties: + name: + type: string + example: CMIOT + status: + type: string + description: "APN enable status. 0=disabled, 1=enabled." + example: "1" + diagnosisStatus: + type: string + example: "2" + usageBytes: + type: number + format: double + example: 13271040 + modifyTime: + type: string + example: "20260606140000" + serviceUsageState: + type: string + example: "1" + overUseDetails: + type: string + example: "" + pccs: + type: array + items: + $ref: "#/components/schemas/PCC" + PCC: + type: object + properties: + serviceCode: + type: string + example: default + name: + type: string + example: "APN 缺省流量" + policyStatus: + type: string + example: "2" + serviceUsageState: + type: string + example: "1" + modifyTime: + type: string + example: "20260606140000" + SIMCard: + type: object + required: + - msisdn + properties: + groupId: + type: integer + description: Group ID the card belongs to. 0 or omitted means ungrouped. + example: 1 + groupName: + type: string + example: "批次一" + msisdn: + type: string + example: "1440292201313" + iccid: + type: string + example: "898600D6991330004146" + imsi: + type: string + example: "460079650004176" + imei: + type: string + example: "863725040000001" + remark: + type: string + example: Test card A + cardStatus: + type: string + description: "SIM card status. 1=disabled, 2=active, 4=stopped, 7=in transit." + example: "2" + lastChangeDate: + type: string + example: "2026-06-06 14:00:00" + stopReason: + type: string + example: "000000000000" + frozenStatus: + type: string + example: "0" + frozenReason: + type: string + example: "" + frozenOperTime: + type: string + example: "20260606140000" + openDate: + type: string + example: "2025-07-06 14:00:00" + activeDate: + type: string + example: "2025-07-09 14:00:00" + onOffStatus: + type: string + description: "Power status. 0=off, 1=on." + example: "1" + sessionStatus: + type: string + description: "Session status. 00=disconnected, 01=connected." + example: "01" + sessionCreateDate: + type: string + example: "2026-06-06 14:00:00" + ip: + type: string + example: 192.0.2.42 + ipv6Prefix: + type: string + example: "" + ipv6: + type: string + example: "" + rat: + type: string + example: "6" + cardBindStatus: + type: string + description: "Card bind status. 1=bound (not separated), 0=unbound (separated)." + example: "1" + regionLimitStatus: + type: string + example: "0" + areaRestrictType: + type: string + example: "1" + restrictedArea: + type: string + example: "200" + monthUsageKB: + type: number + format: double + example: 18432 + poolUsageBytes: + type: number + format: double + example: 18874368 + dailyBaseKB: + type: number + format: double + example: 614.4 + monthlyBaseKB: + type: number + format: double + example: 18432 + growthPerMinuteKB: + type: number + format: double + example: 1.5 + apns: + type: array + items: + $ref: "#/components/schemas/APN" + lastUsageUpdateTime: + type: string + format: date-time + example: "2026-07-06T14:00:00+08:00" + exported: + type: boolean + description: Whether this card has been exported. + example: false + exportCheckedAt: + type: string + description: Timestamp when the card was last exported. + example: "2026-08-24 10:00:00" + Order: + type: object + properties: + orderNum: + type: string + example: ORD2026070614000000a1b2c3 + msisdn: + type: string + example: "1440292201313" + iccid: + type: string + example: "898600D6991330004146" + imsi: + type: string + example: "460079650004176" + type: + type: string + example: sim-status + operType: + type: string + example: "0" + apnName: + type: string + example: CMIOT + status: + type: string + example: completed + createdAt: + type: string + example: "2026-07-06 14:00:00" + OperationLog: + type: object + required: [id, createdAt, category, action, method, path, result, statusCode, durationMs] + properties: + id: + type: integer + example: 1 + createdAt: + type: string + example: "2026-08-24 10:00:00" + category: + type: string + description: Operation category, e.g. generate, sync, export. + example: generate + action: + type: string + example: "一键生成 SIM 卡" + target: + type: string + example: "3" + method: + type: string + example: POST + path: + type: string + example: /api/generate + params: + type: string + example: "{\"count\":3}" + result: + type: string + example: success + statusCode: + type: integer + example: 200 + message: + type: string + example: "" + durationMs: + type: integer + example: 150 + clientIp: + type: string + example: "127.0.0.1" + OperationLogPage: + type: object + required: [items, total, page, pageSize] + properties: + items: + type: array + items: { $ref: "#/components/schemas/OperationLog" } + total: + type: integer + example: 42 + page: + type: integer + example: 1 + pageSize: + type: integer + example: 20 + GenerateResult: + type: object + required: [generated, total] + properties: + generated: + type: integer + example: 50 + total: + type: integer + example: 51 + SyncResult: + type: object + required: [scanned, synced] + properties: + scanned: + type: integer + example: 100 + synced: + type: integer + example: 100 + SimulationSettings: + type: object + properties: + autoGrowthEnabled: + type: boolean + example: true + defaultGrowthKB: + type: number + format: double + example: 1.5 + + examples: + StoreSnapshot: + value: + appids: + - appid: "demo_appid" + password: demo_password + name: Default test app + enabled: true + createdAt: "2026-07-06 14:00:00" + groups: + - id: 1 + name: "批次一" + createdAt: "2026-08-24 10:00:00" + updatedAt: "2026-08-24 10:00:00" + cardCount: 1 + cards: + - groupId: 1 + groupName: "批次一" + msisdn: "1440292201313" + iccid: "898600D6991330004146" + imsi: "460079650004176" + imei: "863725040000001" + remark: Test card A + cardStatus: "2" + lastChangeDate: "2026-06-06 14:00:00" + stopReason: "000000000000" + frozenStatus: "0" + openDate: "2025-07-06 14:00:00" + activeDate: "2025-07-09 14:00:00" + onOffStatus: "1" + sessionStatus: "01" + sessionCreateDate: "2026-06-06 14:00:00" + ip: 192.0.2.42 + ipv6Prefix: "" + ipv6: "" + rat: "6" + cardBindStatus: "1" + regionLimitStatus: "0" + areaRestrictType: "1" + restrictedArea: "200" + monthUsageKB: 18432 + poolUsageBytes: 18874368 + dailyBaseKB: 614.4 + monthlyBaseKB: 18432 + growthPerMinuteKB: 1.5 + exported: false + apns: + - name: CMIOT + status: "1" + diagnosisStatus: "2" + usageBytes: 13271040 + modifyTime: "20260606140000" + serviceUsageState: "1" + pccs: + - serviceCode: default + name: "APN 缺省流量" + policyStatus: "2" + serviceUsageState: "1" + modifyTime: "20260606140000" + - name: CMMTM + status: "1" + usageBytes: 5687562.24 + lastUsageUpdateTime: "2026-07-06T14:00:00+08:00" + tokens: {} + orders: [] + settings: + autoGrowthEnabled: true + defaultGrowthKB: 1.5 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f9eaeb9 --- /dev/null +++ b/docker-compose.yaml @@ -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: diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..88c7f14 --- /dev/null +++ b/docs/deploy.md @@ -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"' +``` diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..eb76d39 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +.dockerignore +Dockerfile +*.md +.env +.env.* +.vscode +.idea \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..19ad4d4 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8c148f4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + OneLink 挡板服务 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..54366c0 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a432a84 --- /dev/null +++ b/frontend/package.json @@ -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": {} +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..8c48802 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..de28070 --- /dev/null +++ b/frontend/src/main.js @@ -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 || '' + 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: ` +
+
+
+

OneLink V25.11.1 CT Mock

挡板服务管理台

+
+ + + + + + +
+
+ +
+
{{ state.appids.length }}AppID
+
{{ cardPage.total }}SIM 卡
+
18432固定月 KB
+ + +
+ +
+

AppID 配置

支持多个 appid/password,Token 有效期 3600 秒。

+
+ + + + +
+
+
名称appid最近 token
+
+ {{ app.name }}{{ app.appid }}{{ app.lastToken || '-' }} + +
+
+
+ +
+

卡与流量数据

配置码号、状态、APN、在线状态和固定月流量。

+
+
+
MSISDNICCIDIMSI分组状态导出状态在线当月 KB
+
+ + {{ card.msisdn }}{{ card.iccid }}{{ card.imsi }}{{ card.groupName || '未分组' }}{{ statusText[card.cardStatus] || card.cardStatus }}{{ card.exported ? '已导出' : '未导出' }}{{ card.sessionStatus === '01' ? '在线' : '离线' }}{{ Math.round(card.monthUsageKB) }} + +
+
+
共 {{ cardPage.total }} 张卡第 页 / 共 {{ cardTotalPages }} 页
+
+ +
+
+

接口调试

支持移动 OneLink 与 Chan 统一接口。

+
+ +
+ + +
+ + +
+
+ +
+

最近订单

状态变更与 APN 开停会生成 orderNum。

+
暂无订单
+
+
订单号类型操作MSISDN状态创建时间
+
+ {{ order.orderNum }}{{ order.type }}{{ order.operType || '-' }}{{ order.msisdn }}{{ order.status }}{{ order.createdAt }} +
+
+
共 {{ orderPage.total }} 条订单第 页 / 共 {{ orderTotalPages }} 页
+
+ + + + +
+
+ +
+

今日操作

记录管理台、OneLink 和 Chan 接口的操作结果。

+
+
暂无操作日志
+
+
时间类别动作API 接口目标结果耗时客户端 IP
+
{{ log.createdAt }}{{ log.category }}{{ log.action }}{{ log.method }} {{ log.path }}{{ log.target || '-' }}{{ log.result === 'success' ? '成功' : '失败' }}{{ log.durationMs }} ms{{ log.clientIp || '-' }}
+
+
共 {{ operationLogPage.total }} 条日志第 {{ operationLogPage.page }} / {{ operationLogTotalPages }} 页
+
+
+ +
{{ message }}
+
+ ` +} + +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') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..4cc5a28 --- /dev/null +++ b/frontend/src/style.css @@ -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; } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..a432fdb --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..49f10fb --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70f06ba --- /dev/null +++ b/go.sum @@ -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= diff --git a/hack/codegen/main.go b/hack/codegen/main.go new file mode 100644 index 0000000..ed0f88a --- /dev/null +++ b/hack/codegen/main.go @@ -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) + } +} diff --git a/hack/config.yaml b/hack/config.yaml new file mode 100644 index 0000000..37df5e7 --- /dev/null +++ b/hack/config.yaml @@ -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" diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 0000000..24cf2c7 --- /dev/null +++ b/internal/cmd/cmd.go @@ -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 + }, +} diff --git a/internal/controller/gateway/gateway.go b/internal/controller/gateway/gateway.go new file mode 100644 index 0000000..7a2fe0b --- /dev/null +++ b/internal/controller/gateway/gateway.go @@ -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() +} diff --git a/internal/dao/internal/mock_card_group.go b/internal/dao/internal/mock_card_group.go new file mode 100644 index 0000000..d2e5b07 --- /dev/null +++ b/internal/dao/internal/mock_card_group.go @@ -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) +} diff --git a/internal/dao/internal/mock_order.go b/internal/dao/internal/mock_order.go new file mode 100644 index 0000000..043f440 --- /dev/null +++ b/internal/dao/internal/mock_order.go @@ -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) +} diff --git a/internal/dao/internal/mock_t_sim_card.go b/internal/dao/internal/mock_t_sim_card.go new file mode 100644 index 0000000..39e26be --- /dev/null +++ b/internal/dao/internal/mock_t_sim_card.go @@ -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) +} diff --git a/internal/dao/mock_card_group.go b/internal/dao/mock_card_group.go new file mode 100644 index 0000000..c994d50 --- /dev/null +++ b/internal/dao/mock_card_group.go @@ -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. diff --git a/internal/dao/mock_order.go b/internal/dao/mock_order.go new file mode 100644 index 0000000..0347919 --- /dev/null +++ b/internal/dao/mock_order.go @@ -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. diff --git a/internal/dao/mock_t_sim_card.go b/internal/dao/mock_t_sim_card.go new file mode 100644 index 0000000..c88e99f --- /dev/null +++ b/internal/dao/mock_t_sim_card.go @@ -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. diff --git a/internal/model/do/mock_card_group.go b/internal/model/do/mock_card_group.go new file mode 100644 index 0000000..23c3b07 --- /dev/null +++ b/internal/model/do/mock_card_group.go @@ -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 // +} diff --git a/internal/model/do/mock_order.go b/internal/model/do/mock_order.go new file mode 100644 index 0000000..22a6fe1 --- /dev/null +++ b/internal/model/do/mock_order.go @@ -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 // +} diff --git a/internal/model/do/mock_t_sim_card.go b/internal/model/do/mock_t_sim_card.go new file mode 100644 index 0000000..a0195be --- /dev/null +++ b/internal/model/do/mock_t_sim_card.go @@ -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 // +} diff --git a/internal/model/entity/mock_card_group.go b/internal/model/entity/mock_card_group.go new file mode 100644 index 0000000..a9083b3 --- /dev/null +++ b/internal/model/entity/mock_card_group.go @@ -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:""` // +} diff --git a/internal/model/entity/mock_order.go b/internal/model/entity/mock_order.go new file mode 100644 index 0000000..00d771b --- /dev/null +++ b/internal/model/entity/mock_order.go @@ -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:""` // +} diff --git a/internal/model/entity/mock_t_sim_card.go b/internal/model/entity/mock_t_sim_card.go new file mode 100644 index 0000000..052a8a2 --- /dev/null +++ b/internal/model/entity/mock_t_sim_card.go @@ -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:""` // +} diff --git a/internal/service/admin.go b/internal/service/admin.go new file mode 100644 index 0000000..89aa9dd --- /dev/null +++ b/internal/service/admin.go @@ -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 + } +} diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go new file mode 100644 index 0000000..ba00bb9 --- /dev/null +++ b/internal/service/admin_test.go @@ -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() +} diff --git a/internal/service/chan_api.go b/internal/service/chan_api.go new file mode 100644 index 0000000..3ac45f7 --- /dev/null +++ b/internal/service/chan_api.go @@ -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" + } +} diff --git a/internal/service/chan_api_test.go b/internal/service/chan_api_test.go new file mode 100644 index 0000000..43bd3f8 --- /dev/null +++ b/internal/service/chan_api_test.go @@ -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) + } +} diff --git a/internal/service/diagnosis_test.go b/internal/service/diagnosis_test.go new file mode 100644 index 0000000..7c1885d --- /dev/null +++ b/internal/service/diagnosis_test.go @@ -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) } diff --git a/internal/service/export_xlsx.go b/internal/service/export_xlsx.go new file mode 100644 index 0000000..eed4e2f --- /dev/null +++ b/internal/service/export_xlsx.go @@ -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(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + 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(``) + return b.String() +} + +func writeSharedStringRow(b *strings.Builder, rowNum, count int) { + b.WriteString(``) + for i := 0; i < count; i++ { + ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum) + b.WriteString(``) + b.WriteString(strconv.Itoa(i)) + b.WriteString(``) + } + b.WriteString(``) +} + +func writeSheetRow(b *strings.Builder, rowNum int, values []string) { + b.WriteString(``) + for i, value := range values { + ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum) + b.WriteString(``) + b.WriteString(xmlEscape(value)) + b.WriteString(``) + } + b.WriteString(``) +} + +func sharedStringsXML() string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + for _, header := range inboundExportHeaders { + b.WriteString(``) + b.WriteString(xmlEscape(header)) + b.WriteString(``) + } + b.WriteString(``) + 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 = ` + + + + + + + +` + +const rootRelsXML = ` + + +` + +const workbookXML = ` + + + + +` + +const workbookRelsXML = ` + + + + +` + +const stylesXML = ` + + + + + + + + +` diff --git a/internal/service/generator.go b/internal/service/generator.go new file mode 100644 index 0000000..39625d3 --- /dev/null +++ b/internal/service/generator.go @@ -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() +} diff --git a/internal/service/generator_test.go b/internal/service/generator_test.go new file mode 100644 index 0000000..80589c2 --- /dev/null +++ b/internal/service/generator_test.go @@ -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) + } +} diff --git a/internal/service/handlers.go b/internal/service/handlers.go new file mode 100644 index 0000000..4a80b5d --- /dev/null +++ b/internal/service/handlers.go @@ -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) +} diff --git a/internal/service/model.go b/internal/service/model.go new file mode 100644 index 0000000..e2b6e98 --- /dev/null +++ b/internal/service/model.go @@ -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"` +} diff --git a/internal/service/operation_log.go b/internal/service/operation_log.go new file mode 100644 index 0000000..03678ad --- /dev/null +++ b/internal/service/operation_log.go @@ -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 != "" { + 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 != "" { + 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 != "" && 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 != "" { + 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) +} diff --git a/internal/service/operation_log_test.go b/internal/service/operation_log_test.go new file mode 100644 index 0000000..0d4242a --- /dev/null +++ b/internal/service/operation_log_test.go @@ -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) + } +} diff --git a/internal/service/runtime_log.go b/internal/service/runtime_log.go new file mode 100644 index 0000000..7d6ab5d --- /dev/null +++ b/internal/service/runtime_log.go @@ -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)) +} diff --git a/internal/service/runtime_log_test.go b/internal/service/runtime_log_test.go new file mode 100644 index 0000000..2f436ff --- /dev/null +++ b/internal/service/runtime_log_test.go @@ -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) + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..505fb93 --- /dev/null +++ b/internal/service/service.go @@ -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 + } +} diff --git a/internal/service/store.go b/internal/service/store.go new file mode 100644 index 0000000..70df9ef --- /dev/null +++ b/internal/service/store.go @@ -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) +} diff --git a/internal/service/store_backend.go b/internal/service/store_backend.go new file mode 100644 index 0000000..88f29ef --- /dev/null +++ b/internal/service/store_backend.go @@ -0,0 +1,5 @@ +package service + +func newStoreBackend(path string) (storeBackend, error) { + return newSQLiteStoreBackend(path) +} diff --git a/internal/service/store_helpers.go b/internal/service/store_helpers.go new file mode 100644 index 0000000..41d9336 --- /dev/null +++ b/internal/service/store_helpers.go @@ -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 +} diff --git a/internal/service/store_sqlite.go b/internal/service/store_sqlite.go new file mode 100644 index 0000000..b35a750 --- /dev/null +++ b/internal/service/store_sqlite.go @@ -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 +} diff --git a/internal/service/store_test.go b/internal/service/store_test.go new file mode 100644 index 0000000..6664225 --- /dev/null +++ b/internal/service/store_test.go @@ -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) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bd46117 --- /dev/null +++ b/main.go @@ -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()) +} diff --git a/manifest/config/config.yaml b/manifest/config/config.yaml new file mode 100644 index 0000000..714a545 --- /dev/null +++ b/manifest/config/config.yaml @@ -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 diff --git a/manifest/schema.go b/manifest/schema.go new file mode 100644 index 0000000..72231c3 --- /dev/null +++ b/manifest/schema.go @@ -0,0 +1,6 @@ +package manifest + +import _ "embed" + +//go:embed sql/schema.sql +var SchemaSQL string diff --git a/manifest/sql/schema.sql b/manifest/sql/schema.sql new file mode 100644 index 0000000..2694efa --- /dev/null +++ b/manifest/sql/schema.sql @@ -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); \ No newline at end of file