chore: 初始开源发布
iotlcm-onelink-mock:OneLink 挡板服务(本地测试替身)。 - Go module 迁移至 git.5iot.com/iotlcm-public/iotlcm-onelink-mock - 修复同毫秒内多次生成卡导致 ICCID/IMSI 唯一约束冲突 - 补 Apache-2.0 LICENSE,README 重写并新增 docs/deploy.md - 移除内部部署资产(CI 触发器 / 镜像推送脚本 / 指定环境部署文档) - 测试凭据改为 demo_appid / demo_password,真实手机号与内网 IP 脱敏 - 统一 LF 行尾并加 .gitattributes
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 统一使用 LF,避免跨平台协作时产生整文件 diff
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# 二进制文件不做行尾转换
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.pdf binary
|
||||||
|
*.exe binary
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
onelink-baffle-linux
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
*~
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.sqlite-shm
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/.vite/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
.run/
|
||||||
|
.workbuddy/
|
||||||
|
.idea/
|
||||||
|
tmp/
|
||||||
+25
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# iotlcm-onelink-mock
|
||||||
|
|
||||||
|
OneLink 挡板服务 —— 基于《中移物联网物联卡能力开放调用参考文档 V26.01.1》实现的本地测试替身。
|
||||||
|
|
||||||
|
用于在没有真实运营商环境时,模拟中国移动 OneLink 的鉴权与卡查询接口,支撑联调、自测与 CI。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- Go 后端,兼容 `/v5/ec/...` 和 `/ec/...` 两种路径。
|
||||||
|
- Vue3 管理台,管理多个 `appid/password`、SIM 卡基础信息、状态和固定流量参数。
|
||||||
|
- Token 有效期 3600 秒,支持 `refresh=0/1`。
|
||||||
|
- 不再连接 MySQL,也不再查询 `t_sim_card_info`。
|
||||||
|
- SIM 卡数据由后端一键生成,持久化到本地 SQLite 表 `mock_t_sim_card`。
|
||||||
|
- 月流量固定为 `18432 KB`,不再按时间自动增长。
|
||||||
|
- 支持按入库模板导出 Excel。
|
||||||
|
|
||||||
|
## 数据源
|
||||||
|
|
||||||
|
服务使用 SQLite 数据库作为唯一持久化数据源。默认数据库路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/onelink_mock.db
|
||||||
|
```
|
||||||
|
|
||||||
|
可以通过环境变量覆盖:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BAFFLE_SQLITE_PATH=C:\sqlite\onelink_mock.db
|
||||||
|
```
|
||||||
|
|
||||||
|
后端通过 GoFrame SQLite 驱动直接访问数据库,不依赖本机安装 `sqlite3` 命令行工具。
|
||||||
|
|
||||||
|
Docker 镜像内会安装 `sqlite` 命令行工具。
|
||||||
|
|
||||||
|
## 一键生成
|
||||||
|
|
||||||
|
管理台顶部可输入生成数量,点击“一键生成”后追加新卡数据。
|
||||||
|
|
||||||
|
生成逻辑:
|
||||||
|
|
||||||
|
- 不删除旧卡。
|
||||||
|
- 不查询旧卡做重复对比。
|
||||||
|
- ICCID/MSISDN/IMSI/IMEI 与生成时间戳和序号相关,降低重复概率。
|
||||||
|
- 删除卡只在管理台删卡或业务删除逻辑触发时发生。
|
||||||
|
|
||||||
|
固定生成值:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chanKey=channel_yd_mock
|
||||||
|
cardType=1
|
||||||
|
upPkgCode=CMIOT-SD-500M-24M-D01
|
||||||
|
suppliersCode=SUP_CMCC
|
||||||
|
insertToPlugEnabled=true
|
||||||
|
carrierTrafficCutoffEnabled=false
|
||||||
|
hasTestPeriod=true
|
||||||
|
testPeriodDays=30
|
||||||
|
hasSilentPeriod=false
|
||||||
|
silentPeriodDays=
|
||||||
|
silentPeriodLatestExpiryDate=
|
||||||
|
```
|
||||||
|
|
||||||
|
流量固定值:
|
||||||
|
|
||||||
|
```text
|
||||||
|
current_month_usage_kb=18432
|
||||||
|
monthly_usage_kb=18432
|
||||||
|
daily_usage_kb=614.4
|
||||||
|
pool_usage_bytes=18874368
|
||||||
|
growth_per_minute_kb=0
|
||||||
|
auto_growth_enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Excel 导出
|
||||||
|
|
||||||
|
管理台点击“导出 Excel”会下载当前卡数据,表头顺序为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ICCID
|
||||||
|
MSISDN
|
||||||
|
IMSI
|
||||||
|
IMEI
|
||||||
|
通道编号
|
||||||
|
卡类型
|
||||||
|
上游套餐编码
|
||||||
|
供应商编码
|
||||||
|
上游激活时间
|
||||||
|
是否贴片转插拔
|
||||||
|
是否运营商达量断网
|
||||||
|
协议到期时间
|
||||||
|
是否有测试期
|
||||||
|
测试期天数
|
||||||
|
测试期最晚过期日期
|
||||||
|
是否有沉默期
|
||||||
|
沉默期天数
|
||||||
|
沉默期最晚过期日期
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `协议到期时间` 为激活日期后 1 年。
|
||||||
|
- `测试期最晚过期日期` 为激活日期后 30 天。
|
||||||
|
- `是否有沉默期=false` 时,沉默期天数和截止日期导出为空。
|
||||||
|
|
||||||
|
## 已实现接口
|
||||||
|
|
||||||
|
- `ec/get/token`
|
||||||
|
- `ec/query/sim-basic-info`
|
||||||
|
- `ec/query/sim-session`
|
||||||
|
- `ec/query/sim-status`
|
||||||
|
- `ec/query/sim-stop-reason`
|
||||||
|
- `ec/query/sim-frozen-state-info`
|
||||||
|
- `ec/query/sim-apn-session-info`
|
||||||
|
- `ec/query/sim-activated-features-info`
|
||||||
|
- `ec/query/sim-operating-status`
|
||||||
|
- `ec/query/sim-data-diagnosis`
|
||||||
|
- `ec/query/sim-imei`
|
||||||
|
- `ec/query/sim-data-usage-inpool`
|
||||||
|
- `ec/query/sim-data-usage`
|
||||||
|
- `ec/query/sim-data-usage-monthly/batch`
|
||||||
|
- `ec/query/sim-data-usage-daily/batch`
|
||||||
|
- `ec/query/apn-info`
|
||||||
|
- `ec/query/card-bind-status`
|
||||||
|
- `ec/query/on-off-status`
|
||||||
|
- `ec/query/region-limit-status`
|
||||||
|
- `ec/change/sim-status`
|
||||||
|
- `ec/operate/sim-apn-function`
|
||||||
|
|
||||||
|
Chan-compatible unified APIs (GET and POST):
|
||||||
|
- `GET|POST /sim/query/sim-basic-info`
|
||||||
|
- `GET|POST /sim/query/sim-session`
|
||||||
|
- `GET|POST /sim/query/sim-status`
|
||||||
|
- `GET|POST /sim/query/sim-status-batch`
|
||||||
|
- `GET|POST /sim/query/sim-card-bind-status`
|
||||||
|
- `GET|POST /sim/query/sim-device-status`
|
||||||
|
- `GET|POST /sim/query/sim-region-limit-status`
|
||||||
|
- `GET|POST /sim/query/sim-imei`
|
||||||
|
- `GET|POST /sim/query/sim-datausage`
|
||||||
|
- `GET|POST /sim/query/sim-datausage-monthly`
|
||||||
|
- `GET|POST /sim/query/sim-datausage-monthly-batch`
|
||||||
|
- `GET|POST /sim/query/sim-datausage-daily`
|
||||||
|
- `GET|POST /sim/query/sim-datausage-daily-batch`
|
||||||
|
- `GET|POST /sim/query/sim-apn-info`
|
||||||
|
- `GET|POST /sim/change/sim-status`
|
||||||
|
- `GET|POST /sim/change/sim-apn`
|
||||||
|
|
||||||
|
Chan requests use `simKeys` and `reqData`; responses are per-SIM arrays with
|
||||||
|
`code`, `message`, `simKey`, and `resData`. The chan endpoints reuse the same
|
||||||
|
SQLite SIM cards as the OneLink endpoints.
|
||||||
|
|
||||||
|
管理接口:
|
||||||
|
|
||||||
|
- `GET /api/state`
|
||||||
|
- `POST /api/generate`
|
||||||
|
- `GET /api/cards/export`
|
||||||
|
- `POST /api/appids`
|
||||||
|
- `POST /api/cards`
|
||||||
|
- `DELETE /api/cards`
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BAFFLE_ADDR` | 否 | 监听地址,默认 `:8080` |
|
||||||
|
| `BAFFLE_SQLITE_PATH` | 否 | SQLite 数据库路径,默认 `data/onelink_mock.db` |
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
### 1. 启动后端
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:BAFFLE_ADDR=":8080"
|
||||||
|
$env:BAFFLE_SQLITE_PATH="C:\sqlite\onelink_mock.db"
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
后端默认监听:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动前端
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端 dev server 默认监听:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5173/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 启动
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
访问地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:18080/onelink-mock/
|
||||||
|
```
|
||||||
|
|
||||||
|
停止:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 默认测试认证
|
||||||
|
|
||||||
|
```text
|
||||||
|
appid=demo_appid
|
||||||
|
password=demo_password
|
||||||
|
```
|
||||||
|
|
||||||
|
获取 Token:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080/v5/ec/get/token?appid=demo_appid&password=demo_password&transid=demo_appid2026070209300000000001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin query APIs
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/cards?keyword=<msisdn-or-iccid-or-imsi>&page=1&pageSize=20
|
||||||
|
GET /api/orders?page=1&pageSize=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Orders are persisted in the SQLite `mock_order` table and retain all nine fields after a service restart. Existing `mock_t_sim_card` data is preserved.
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
本地开发用 `docker compose up -d --build` 即可(见上文「Docker 启动」)。若需要通过 Nginx 以子路径方式挂到已有站点,参考 [`docs/deploy.md`](./docs/deploy.md)。
|
||||||
|
|
||||||
|
## 相关项目
|
||||||
|
|
||||||
|
本服务是 [iotlcm](https://git.5iot.com/iotlcm-public) 物联网卡全生命周期管理平台的一部分,用于在没有真实运营商通道时提供可预期的上游替身。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Apache License 2.0](./LICENSE)
|
||||||
+2251
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||||
@@ -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"'
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>OneLink 挡板服务</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
Generated
+856
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import { createApp, computed, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js'
|
||||||
|
import { ArrowLeft, BadgePlus, ClipboardList, Copy, CreditCard, Database, Download, Eye, FolderPlus, KeyRound, Plus, RefreshCw, Save, Search, Server, Trash2, X } from 'lucide-vue-next'
|
||||||
|
import { onBeforeUnmount } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const appBase = normalizeBase(import.meta.env.BASE_URL)
|
||||||
|
|
||||||
|
const statusText = {
|
||||||
|
'1': '待激活',
|
||||||
|
'2': '已激活',
|
||||||
|
'4': '停机',
|
||||||
|
'6': '可测试',
|
||||||
|
'7': '库存',
|
||||||
|
'8': '预销户'
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailLabels = {
|
||||||
|
msisdn: 'MSISDN', iccid: 'ICCID', imsi: 'IMSI', imei: 'IMEI', remark: '备注',
|
||||||
|
cardStatus: '卡状态', lastChangeDate: '最后变更时间', stopReason: '停机原因码',
|
||||||
|
frozenStatus: '管理冻结', frozenReason: '冻结原因', frozenOperTime: '冻结操作时间',
|
||||||
|
openDate: '开卡时间', activeDate: '首次激活时间', onOffStatus: '终端开关机',
|
||||||
|
sessionStatus: '会话状态', sessionCreateDate: '会话建立时间', ip: 'IPv4', ipv6Prefix: 'IPv6 前缀',
|
||||||
|
ipv6: 'IPv6 接口 ID', rat: '接入制式', cardBindStatus: '机卡绑定', regionLimitStatus: '区域限制',
|
||||||
|
areaRestrictType: '区域限制方式', restrictedArea: '限制区域', monthUsageKB: '当月流量 KB',
|
||||||
|
dailyBaseKB: '日基础流量 KB', monthlyBaseKB: '月基础流量 KB', lastUsageUpdateTime: '流量更新时间'
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailLabel(key) { return detailLabels[key] || key }
|
||||||
|
function cardStatusLabel(value) { return statusText[value] || value || '-' }
|
||||||
|
function boolLabel(value) { return value === '1' || value === 1 || value === true ? '是' : '否' }
|
||||||
|
function apnDiagnosisStatusLabel(value) { return ({ '1': '未激活', '2': '激活', '4': '暂停' })[value] || value || '-' }
|
||||||
|
function apnServiceUsageLabel(value) { return value === '1' || !value ? '正常速率' : `限速档位 ${value}` }
|
||||||
|
|
||||||
|
const endpointGroups = [
|
||||||
|
{
|
||||||
|
key: 'mobile-core', label: '移动基础接口', items: [
|
||||||
|
['ec/query/sim-basic-info', '单卡基础信息'], ['ec/query/sim-session', '单卡会话信息'], ['ec/query/sim-imei', '查询 IMEI'],
|
||||||
|
['ec/query/sim-data-usage-inpool', '查询流量池用量'], ['ec/query/sim-data-usage', '查询流量用量'],
|
||||||
|
['ec/query/sim-data-usage-monthly/batch', '批量查询月流量'], ['ec/query/sim-data-usage-daily/batch', '批量查询日流量'],
|
||||||
|
['ec/query/apn-info', '查询 APN'], ['ec/change/sim-status', '变更 SIM 状态'], ['ec/operate/sim-apn-function', '操作 APN']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mobile-diagnosis', label: '移动智能诊断', items: [
|
||||||
|
['ec/query/sim-status', 'SIM 卡状态'], ['ec/query/sim-stop-reason', '停机原因'], ['ec/query/sim-frozen-state-info', '管理冻结状态'],
|
||||||
|
['ec/query/sim-apn-session-info', 'APN 在线会话'], ['ec/query/sim-activated-features-info', '通信功能状态'],
|
||||||
|
['ec/query/sim-operating-status', '开关机状态'], ['ec/query/sim-data-diagnosis', '达量限速档位'],
|
||||||
|
['ec/query/region-limit-status', '区域限制状态'], ['ec/query/card-bind-status', '机卡绑定状态'], ['ec/query/on-off-status', '设备开关机状态']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chan', label: 'Chan 统一接口', items: [
|
||||||
|
['sim/query/sim-basic-info', '单卡基础信息'], ['sim/query/sim-session', '单卡会话信息'], ['sim/query/sim-status', 'SIM 卡状态'],
|
||||||
|
['sim/query/sim-status-batch', '批量 SIM 卡状态'], ['sim/query/sim-card-bind-status', '机卡绑定状态'], ['sim/query/sim-device-status', '设备状态'],
|
||||||
|
['sim/query/sim-region-limit-status', '区域限制状态'], ['sim/query/sim-imei', '查询 IMEI'], ['sim/query/sim-datausage', '查询流量'],
|
||||||
|
['sim/query/sim-datausage-monthly', '查询月流量'], ['sim/query/sim-datausage-monthly-batch', '批量查询月流量'],
|
||||||
|
['sim/query/sim-datausage-daily', '查询日流量'], ['sim/query/sim-datausage-daily-batch', '批量查询日流量'],
|
||||||
|
['sim/query/sim-apn-info', '查询 APN'], ['sim/change/sim-status', '变更 SIM 状态'], ['sim/change/sim-apn', '变更 APN']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const endpointCatalog = endpointGroups.flatMap(group => group.items.map(([path, label]) => ({ path, label, group: group.key })))
|
||||||
|
|
||||||
|
const App = {
|
||||||
|
components: { ArrowLeft, BadgePlus, ClipboardList, Copy, CreditCard, Database, Download, Eye, FolderPlus, KeyRound, Plus, RefreshCw, Save, Search, Server, Trash2, X },
|
||||||
|
setup() {
|
||||||
|
const state = reactive({ appids: [], groups: [], cards: [], orders: [], settings: {} })
|
||||||
|
const message = ref('')
|
||||||
|
const appForm = reactive({ appid: '', password: '', name: '' })
|
||||||
|
const cardForm = reactive(emptyCard())
|
||||||
|
const selectedCategory = ref('mobile-core')
|
||||||
|
const selectedEndpoint = ref('ec/query/sim-basic-info')
|
||||||
|
const generateCount = ref(100)
|
||||||
|
const selectedGroupId = ref(0)
|
||||||
|
const groupForm = reactive({ name: '' })
|
||||||
|
const isGroupEditorOpen = ref(false)
|
||||||
|
const groupSaving = ref(false)
|
||||||
|
const selectedCards = reactive(new Set())
|
||||||
|
const cardKeyword = ref('')
|
||||||
|
const cardGroupFilter = ref('')
|
||||||
|
const cardExportStatusFilter = ref('')
|
||||||
|
const cardPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||||
|
const orderPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||||
|
const cardJumpPage = ref(1)
|
||||||
|
const orderJumpPage = ref(1)
|
||||||
|
const cardTotalPages = computed(() => Math.max(1, Math.ceil(cardPage.total / cardPage.pageSize)))
|
||||||
|
const orderTotalPages = computed(() => Math.max(1, Math.ceil(orderPage.total / orderPage.pageSize)))
|
||||||
|
const selectedCardDetail = ref(null)
|
||||||
|
const selectedOrderDetail = ref(null)
|
||||||
|
const isCardEditorOpen = ref(false)
|
||||||
|
const cardEditorMode = ref('create')
|
||||||
|
const cardSaving = ref(false)
|
||||||
|
const currentView = ref(viewFromPath())
|
||||||
|
const operationLogs = ref([])
|
||||||
|
const operationLogPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||||
|
const operationLogKeyword = ref('')
|
||||||
|
const operationLogCategory = ref('')
|
||||||
|
const operationLogResult = ref('')
|
||||||
|
const selectedOperationLog = ref(null)
|
||||||
|
const operationLogTotalPages = computed(() => Math.max(1, Math.ceil(operationLogPage.total / operationLogPage.pageSize)))
|
||||||
|
const visibleEndpoints = computed(() => endpointCatalog.filter(item => item.group === selectedCategory.value))
|
||||||
|
const currentEndpoint = computed(() => endpointCatalog.find(item => item.path === selectedEndpoint.value) || endpointCatalog[0])
|
||||||
|
const isChanEndpoint = computed(() => selectedEndpoint.value.startsWith('sim/'))
|
||||||
|
const endpointMethod = computed(() => isChanEndpoint.value ? 'POST' : 'GET')
|
||||||
|
|
||||||
|
const baseUrl = computed(() => `${location.origin}${appBase}/v5/`)
|
||||||
|
const sampleCard = computed(() => state.cards[0] || { msisdn: '1440292201313' })
|
||||||
|
const selectedCount = computed(() => selectedCards.size)
|
||||||
|
const allCardsSelected = computed(() => state.cards.length > 0 && state.cards.every(card => selectedCards.has(card.msisdn)))
|
||||||
|
const firstTokenUrl = computed(() => {
|
||||||
|
const app = state.appids[0] || { appid: 'demo_appid', password: 'demo_password' }
|
||||||
|
return `${baseUrl.value}ec/get/token?appid=${app.appid}&password=${app.password}&transid=${transid(app.appid)}`
|
||||||
|
})
|
||||||
|
const endpointUrl = computed(() => {
|
||||||
|
if (isChanEndpoint.value) {
|
||||||
|
return `${location.origin}${appBase}/${selectedEndpoint.value}`
|
||||||
|
}
|
||||||
|
const token = state.appids[0]?.lastToken || '<token>'
|
||||||
|
const params = new URLSearchParams({ transid: transid(state.appids[0]?.appid || 'appid'), token })
|
||||||
|
if (selectedEndpoint.value.includes('/batch')) {
|
||||||
|
params.set('msisdns', state.cards.map(c => c.msisdn).slice(0, 3).join('_') || sampleCard.value.msisdn)
|
||||||
|
params.set('queryDate', selectedEndpoint.value.includes('monthly') ? monthText() : yesterday())
|
||||||
|
} else if (selectedEndpoint.value.includes('card-bind-status')) {
|
||||||
|
params.set('msisdn', sampleCard.value.msisdn)
|
||||||
|
params.set('testType', '0')
|
||||||
|
} else if (selectedEndpoint.value.includes('change/sim-status')) {
|
||||||
|
params.set('msisdn', sampleCard.value.msisdn)
|
||||||
|
params.set('operType', '0')
|
||||||
|
} else if (selectedEndpoint.value.includes('operate/sim-apn-function')) {
|
||||||
|
params.set('msisdn', sampleCard.value.msisdn)
|
||||||
|
params.set('apnName', 'CMIOT')
|
||||||
|
params.set('operType', '1')
|
||||||
|
} else {
|
||||||
|
params.set('msisdn', sampleCard.value.msisdn)
|
||||||
|
}
|
||||||
|
return `${baseUrl.value}${selectedEndpoint.value}?${params.toString()}`
|
||||||
|
})
|
||||||
|
const endpointBody = computed(() => {
|
||||||
|
if (!isChanEndpoint.value) return ''
|
||||||
|
const cards = state.cards.slice(0, 3)
|
||||||
|
const simKeys = (cards.length ? cards : [sampleCard.value]).map(card => ({
|
||||||
|
chanKey: 'channel_yd_mock',
|
||||||
|
iccid: card.iccid || '',
|
||||||
|
imsi: card.imsi || '',
|
||||||
|
msisdn: card.msisdn || ''
|
||||||
|
}))
|
||||||
|
const reqData = {}
|
||||||
|
if (selectedEndpoint.value.includes('monthly')) reqData.queryDate = monthText()
|
||||||
|
if (selectedEndpoint.value.includes('daily')) reqData.queryDate = yesterday()
|
||||||
|
if (selectedEndpoint.value === 'sim/change/sim-status') reqData.operType = '0'
|
||||||
|
if (selectedEndpoint.value === 'sim/change/sim-apn') Object.assign(reqData, { apnName: 'CMIOT', operType: '0' })
|
||||||
|
return JSON.stringify({ simKeys, reqData }, null, 2)
|
||||||
|
})
|
||||||
|
const endpointHeaders = computed(() => '')
|
||||||
|
|
||||||
|
function selectCategory() {
|
||||||
|
const first = visibleEndpoints.value[0]
|
||||||
|
if (first) selectedEndpoint.value = first.path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [snapshot, groups, cards, orders, logs] = await Promise.all([request('/api/state'), request('/api/card-groups'), loadCards(), loadOrders(), loadOperationLogs(1, 1)])
|
||||||
|
Object.assign(state, snapshot, { groups, cards: cards.items, orders: orders.items })
|
||||||
|
Object.assign(cardPage, { page: cards.page, pageSize: cards.pageSize, total: cards.total })
|
||||||
|
Object.assign(orderPage, { page: orders.page, pageSize: orders.pageSize, total: orders.total })
|
||||||
|
cardJumpPage.value = cards.page
|
||||||
|
orderJumpPage.value = orders.page
|
||||||
|
operationLogPage.total = logs.total
|
||||||
|
for (const msisdn of Array.from(selectedCards)) {
|
||||||
|
if (!state.cards.some(card => card.msisdn === msisdn)) selectedCards.delete(msisdn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function loadOperationLogs(page = operationLogPage.page, pageSize = operationLogPage.pageSize) {
|
||||||
|
const params = new URLSearchParams({ page, pageSize })
|
||||||
|
if (operationLogKeyword.value.trim()) params.set('keyword', operationLogKeyword.value.trim())
|
||||||
|
if (operationLogCategory.value) params.set('category', operationLogCategory.value)
|
||||||
|
if (operationLogResult.value) params.set('result', operationLogResult.value)
|
||||||
|
return request(`/api/operation-logs?${params.toString()}`)
|
||||||
|
}
|
||||||
|
async function refreshOperationLogs() {
|
||||||
|
const result = await loadOperationLogs()
|
||||||
|
operationLogs.value = result.items
|
||||||
|
Object.assign(operationLogPage, { page: result.page, pageSize: result.pageSize, total: result.total })
|
||||||
|
}
|
||||||
|
async function searchOperationLogs() {
|
||||||
|
operationLogPage.page = 1
|
||||||
|
await refreshOperationLogs()
|
||||||
|
}
|
||||||
|
async function clearOperationLogSearch() {
|
||||||
|
operationLogKeyword.value = ''
|
||||||
|
operationLogCategory.value = ''
|
||||||
|
operationLogResult.value = ''
|
||||||
|
await searchOperationLogs()
|
||||||
|
}
|
||||||
|
async function changeOperationLogPage(page) {
|
||||||
|
operationLogPage.page = page
|
||||||
|
await refreshOperationLogs()
|
||||||
|
}
|
||||||
|
async function changeOperationLogPageSize(pageSize) {
|
||||||
|
operationLogPage.pageSize = Number(pageSize)
|
||||||
|
operationLogPage.page = 1
|
||||||
|
await refreshOperationLogs()
|
||||||
|
}
|
||||||
|
async function navigate(view) {
|
||||||
|
const path = view === 'operation-logs' ? withBase('/operation-logs') : (appBase || '/')
|
||||||
|
history.pushState({}, '', path)
|
||||||
|
currentView.value = view
|
||||||
|
if (view === 'operation-logs') await refreshOperationLogs()
|
||||||
|
}
|
||||||
|
function handlePopState() {
|
||||||
|
currentView.value = viewFromPath()
|
||||||
|
if (currentView.value === 'operation-logs') refreshOperationLogs()
|
||||||
|
}
|
||||||
|
function scrollToOrders() {
|
||||||
|
document.getElementById('recent-orders')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
function loadCards() {
|
||||||
|
const params = new URLSearchParams({ page: cardPage.page, pageSize: cardPage.pageSize })
|
||||||
|
appendCardFilters(params)
|
||||||
|
return request(`/api/cards?${params.toString()}`)
|
||||||
|
}
|
||||||
|
function appendCardFilters(params) {
|
||||||
|
if (cardKeyword.value.trim()) params.set('keyword', cardKeyword.value.trim())
|
||||||
|
if (cardGroupFilter.value !== '') params.set('groupId', cardGroupFilter.value)
|
||||||
|
if (cardExportStatusFilter.value) params.set('exportStatus', cardExportStatusFilter.value)
|
||||||
|
}
|
||||||
|
function loadOrders() { return request(`/api/orders?page=${orderPage.page}&pageSize=${orderPage.pageSize}`) }
|
||||||
|
async function searchCards() { cardPage.page = 1; await searchCardsPage() }
|
||||||
|
async function clearCardSearch() { cardKeyword.value = ''; cardGroupFilter.value = ''; cardExportStatusFilter.value = ''; await searchCards() }
|
||||||
|
async function filterCardsByGroup() { cardPage.page = 1; await searchCardsPage() }
|
||||||
|
async function changeCardPage(nextPage) { cardPage.page = nextPage; await searchCardsPage() }
|
||||||
|
async function changeCardPageSize(pageSize) { cardPage.pageSize = Number(pageSize); cardPage.page = 1; await searchCardsPage() }
|
||||||
|
async function searchCardsPage() {
|
||||||
|
const cards = await loadCards()
|
||||||
|
state.cards = cards.items
|
||||||
|
Object.assign(cardPage, { page: cards.page, pageSize: cards.pageSize, total: cards.total })
|
||||||
|
cardJumpPage.value = cards.page
|
||||||
|
}
|
||||||
|
async function changeOrderPage(nextPage) { orderPage.page = nextPage; await refreshOrders() }
|
||||||
|
async function changeOrderPageSize(pageSize) { orderPage.pageSize = Number(pageSize); orderPage.page = 1; await refreshOrders() }
|
||||||
|
async function refreshOrders() {
|
||||||
|
const orders = await loadOrders()
|
||||||
|
state.orders = orders.items
|
||||||
|
Object.assign(orderPage, { page: orders.page, pageSize: orders.pageSize, total: orders.total })
|
||||||
|
orderJumpPage.value = orders.page
|
||||||
|
}
|
||||||
|
async function goToCardPage() {
|
||||||
|
let p = Number(cardJumpPage.value)
|
||||||
|
if (!p || p < 1) p = 1
|
||||||
|
if (p > cardTotalPages.value) p = cardTotalPages.value
|
||||||
|
cardJumpPage.value = p
|
||||||
|
if (p !== cardPage.page) {
|
||||||
|
cardPage.page = p
|
||||||
|
await searchCardsPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function goToOrderPage() {
|
||||||
|
let p = Number(orderJumpPage.value)
|
||||||
|
if (!p || p < 1) p = 1
|
||||||
|
if (p > orderTotalPages.value) p = orderTotalPages.value
|
||||||
|
orderJumpPage.value = p
|
||||||
|
if (p !== orderPage.page) {
|
||||||
|
orderPage.page = p
|
||||||
|
await refreshOrders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function goCardFirst() { changeCardPage(1) }
|
||||||
|
function goCardLast() { changeCardPage(cardTotalPages.value) }
|
||||||
|
function goOrderFirst() { changeOrderPage(1) }
|
||||||
|
function goOrderLast() { changeOrderPage(orderTotalPages.value) }
|
||||||
|
async function generateCards() {
|
||||||
|
const result = await request('/api/generate', { method: 'POST', body: { count: generateCount.value, groupId: selectedGroupId.value || null } })
|
||||||
|
await load()
|
||||||
|
const group = state.groups.find(item => item.id === selectedGroupId.value)
|
||||||
|
toast(`已生成 ${result.generated} 张卡${group ? `,已放入“${group.name}”` : '(未分组)'},共 ${result.total} 张`)
|
||||||
|
}
|
||||||
|
function openGroupEditor() { groupForm.name = ''; isGroupEditorOpen.value = true }
|
||||||
|
async function createGroup() {
|
||||||
|
groupSaving.value = true
|
||||||
|
try {
|
||||||
|
const group = await request('/api/card-groups', { method: 'POST', body: groupForm })
|
||||||
|
state.groups.push(group)
|
||||||
|
selectedGroupId.value = group.id
|
||||||
|
isGroupEditorOpen.value = false
|
||||||
|
} finally { groupSaving.value = false }
|
||||||
|
}
|
||||||
|
async function exportCards() {
|
||||||
|
if (!selectedCards.size) {
|
||||||
|
toast('请选择要导出的卡')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({ msisdns: Array.from(selectedCards).join(',') })
|
||||||
|
await downloadCardExport(params, selectedCards.size)
|
||||||
|
}
|
||||||
|
async function exportFilteredCards() {
|
||||||
|
if (!cardPage.total) {
|
||||||
|
toast('当前筛选条件下没有可导出的卡')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exportCount = cardPage.total
|
||||||
|
const params = new URLSearchParams({ selection: 'filtered' })
|
||||||
|
appendCardFilters(params)
|
||||||
|
await downloadCardExport(params, exportCount)
|
||||||
|
}
|
||||||
|
async function downloadCardExport(params, exportCount) {
|
||||||
|
const response = await fetch(withBase(`/api/cards/export?${params.toString()}`))
|
||||||
|
if (!response.ok) throw new Error(await response.text())
|
||||||
|
const downloadURL = URL.createObjectURL(await response.blob())
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = downloadURL
|
||||||
|
link.download = 'sim_card_export.xlsx'
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(downloadURL)
|
||||||
|
await searchCardsPage()
|
||||||
|
toast(`已导出 ${exportCount} 张卡`)
|
||||||
|
}
|
||||||
|
function toggleCard(msisdn) {
|
||||||
|
if (selectedCards.has(msisdn)) selectedCards.delete(msisdn)
|
||||||
|
else selectedCards.add(msisdn)
|
||||||
|
}
|
||||||
|
function toggleAllCards() {
|
||||||
|
if (allCardsSelected.value) {
|
||||||
|
selectedCards.clear()
|
||||||
|
} else {
|
||||||
|
state.cards.forEach(card => selectedCards.add(card.msisdn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveApp() {
|
||||||
|
await request('/api/appids', { method: 'POST', body: appForm })
|
||||||
|
Object.assign(appForm, { appid: '', password: '', name: '' })
|
||||||
|
await load()
|
||||||
|
toast('AppID 已保存')
|
||||||
|
}
|
||||||
|
async function deleteApp(appid) {
|
||||||
|
await request(`/api/appids?appid=${encodeURIComponent(appid)}`, { method: 'DELETE' })
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
function openCardEditor(card = null) {
|
||||||
|
cardEditorMode.value = card ? 'edit' : 'create'
|
||||||
|
Object.assign(cardForm, JSON.parse(JSON.stringify(card || emptyCard())))
|
||||||
|
isCardEditorOpen.value = true
|
||||||
|
}
|
||||||
|
async function saveCard() {
|
||||||
|
cardSaving.value = true
|
||||||
|
try {
|
||||||
|
await request('/api/cards', { method: 'POST', body: cardForm })
|
||||||
|
isCardEditorOpen.value = false
|
||||||
|
Object.assign(cardForm, emptyCard())
|
||||||
|
await load()
|
||||||
|
toast(cardEditorMode.value === 'edit' ? '卡数据已更新' : '卡数据已保存')
|
||||||
|
} finally { cardSaving.value = false }
|
||||||
|
}
|
||||||
|
async function deleteCard(msisdn) {
|
||||||
|
await request(`/api/cards?msisdn=${encodeURIComponent(msisdn)}`, { method: 'DELETE' })
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
async function copyText(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
toast('复制内容为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = String(value)
|
||||||
|
if (!text) {
|
||||||
|
toast('复制内容为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast('已复制到剪贴板')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', 'readonly')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length)
|
||||||
|
const ok = document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
toast(ok ? '已复制到剪贴板' : '复制失败,请手动复制')
|
||||||
|
} catch (_) {
|
||||||
|
toast('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const init = { ...options, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
if (options.body) init.body = JSON.stringify(options.body)
|
||||||
|
const res = await fetch(withBase(path), init)
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
function toast(text) {
|
||||||
|
message.value = text
|
||||||
|
setTimeout(() => { if (message.value === text) message.value = '' }, 2200)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
if (currentView.value === 'operation-logs') await refreshOperationLogs()
|
||||||
|
else await load()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('popstate', handlePopState))
|
||||||
|
return { state, message, appForm, cardForm, selectedCategory, selectedEndpoint, endpointGroups, visibleEndpoints, currentEndpoint, firstTokenUrl, endpointUrl, endpointBody, endpointHeaders, endpointMethod, isChanEndpoint, statusText, detailLabel, cardStatusLabel, boolLabel, apnDiagnosisStatusLabel, apnServiceUsageLabel, generateCount, selectedGroupId, groupForm, isGroupEditorOpen, groupSaving, selectedCards, selectedCount, allCardsSelected, cardKeyword, cardGroupFilter, cardExportStatusFilter, cardPage, orderPage, cardJumpPage, orderJumpPage, cardTotalPages, orderTotalPages, selectedCardDetail, selectedOrderDetail, isCardEditorOpen, cardEditorMode, cardSaving, currentView, operationLogs, operationLogPage, operationLogKeyword, operationLogCategory, operationLogResult, operationLogTotalPages, selectedOperationLog, load, selectCategory, searchCards, clearCardSearch, filterCardsByGroup, changeCardPage, changeCardPageSize, changeOrderPage, changeOrderPageSize, goToCardPage, goToOrderPage, goCardFirst, goCardLast, goOrderFirst, goOrderLast, generateCards, openGroupEditor, createGroup, exportCards, exportFilteredCards, toggleCard, toggleAllCards, saveApp, deleteApp, openCardEditor, saveCard, deleteCard, copyText, navigate, scrollToOrders, searchOperationLogs, clearOperationLogSearch, changeOperationLogPage, changeOperationLogPageSize }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<main class="shell">
|
||||||
|
<div v-if="currentView === 'dashboard'">
|
||||||
|
<header class="topbar">
|
||||||
|
<div><p class="eyebrow">OneLink V25.11.1 CT Mock</p><h1>挡板服务管理台</h1></div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<input v-model.number="generateCount" class="count-input" type="number" min="1" />
|
||||||
|
<select v-model.number="selectedGroupId" class="group-select"><option :value="0">未分组</option><option v-for="group in state.groups" :key="group.id" :value="group.id">{{ group.name }}</option></select>
|
||||||
|
<button class="ghost icon-only" @click="openGroupEditor" title="新建分组"><FolderPlus :size="16" /></button>
|
||||||
|
<button @click="generateCards"><RefreshCw :size="16" />一键生成</button>
|
||||||
|
<button class="ghost" @click="exportCards"><Download :size="16" />导出选中 {{ selectedCount }}</button>
|
||||||
|
<button class="ghost" :disabled="!cardPage.total" @click="exportFilteredCards"><Download :size="16" />导出筛选结果 {{ cardPage.total }}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="metrics">
|
||||||
|
<div><KeyRound :size="19" /><strong>{{ state.appids.length }}</strong><span>AppID</span></div>
|
||||||
|
<div><CreditCard :size="19" /><strong>{{ cardPage.total }}</strong><span>SIM 卡</span></div>
|
||||||
|
<div><Database :size="19" /><strong>18432</strong><span>固定月 KB</span></div>
|
||||||
|
<div class="metric-link" role="link" tabindex="0" @click="scrollToOrders" @keyup.enter="scrollToOrders"><Database :size="19" /><strong>{{ orderPage.total }}</strong><span>办理订单</span></div>
|
||||||
|
<div class="metric-link" role="link" tabindex="0" @click="navigate('operation-logs')" @keyup.enter="navigate('operation-logs')"><ClipboardList :size="19" /><strong>{{ operationLogPage.total }}</strong><span>操作日志</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="band">
|
||||||
|
<div class="section-head"><div><h2>AppID 配置</h2><p>支持多个 appid/password,Token 有效期 3600 秒。</p></div><KeyRound :size="22" /></div>
|
||||||
|
<div class="form-grid app-grid">
|
||||||
|
<input v-model="appForm.appid" placeholder="appid" />
|
||||||
|
<input v-model="appForm.password" placeholder="password" />
|
||||||
|
<input v-model="appForm.name" placeholder="名称" />
|
||||||
|
<button @click="saveApp"><Plus :size="16" />保存</button>
|
||||||
|
</div>
|
||||||
|
<div class="table">
|
||||||
|
<div class="tr th"><span>名称</span><span>appid</span><span>最近 token</span><span></span></div>
|
||||||
|
<div class="tr" v-for="app in state.appids" :key="app.appid">
|
||||||
|
<span>{{ app.name }}</span><span>{{ app.appid }}</span><span class="mono">{{ app.lastToken || '-' }}</span>
|
||||||
|
<button class="ghost" @click="deleteApp(app.appid)" title="删除"><Trash2 :size="15" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="band">
|
||||||
|
<div class="section-head"><div><h2>卡与流量数据</h2><p>配置码号、状态、APN、在线状态和固定月流量。</p></div><div class="section-head-actions"><button @click="openCardEditor()"><Plus :size="16" />新增卡</button><CreditCard :size="22" /></div></div>
|
||||||
|
<div class="search-row"><input v-model="cardKeyword" @keyup.enter="searchCards" placeholder="输入 MSISDN / ICCID / IMSI" /><select v-model="cardGroupFilter" @change="filterCardsByGroup"><option value="">全部分组</option><option value="0">未分组</option><option v-for="group in state.groups" :key="group.id" :value="String(group.id)">{{ group.name }}</option></select><select v-model="cardExportStatusFilter" @change="filterCardsByGroup"><option value="">全部导出状态</option><option value="exported">已导出</option><option value="not_exported">未导出</option></select><button @click="searchCards"><Search :size="16" />查询</button><button class="ghost icon-only" @click="clearCardSearch" title="清空查询"><X :size="16" /></button></div>
|
||||||
|
<div class="table cards">
|
||||||
|
<div class="tr th"><span><input class="select-box" type="checkbox" :checked="allCardsSelected" @change="toggleAllCards" /></span><span>MSISDN</span><span>ICCID</span><span>IMSI</span><span>分组</span><span>状态</span><span>导出状态</span><span>在线</span><span>当月 KB</span><span></span></div>
|
||||||
|
<div class="tr" v-for="card in state.cards" :key="card.msisdn">
|
||||||
|
<span><input class="select-box" type="checkbox" :checked="selectedCards.has(card.msisdn)" @change="toggleCard(card.msisdn)" /></span>
|
||||||
|
<span class="mono">{{ card.msisdn }}</span><span class="mono">{{ card.iccid }}</span><span class="mono">{{ card.imsi }}</span><span>{{ card.groupName || '未分组' }}</span><span>{{ statusText[card.cardStatus] || card.cardStatus }}</span><span><em class="export-status" :class="card.exported ? 'exported' : 'not-exported'">{{ card.exported ? '已导出' : '未导出' }}</em></span><span>{{ card.sessionStatus === '01' ? '在线' : '离线' }}</span><span>{{ Math.round(card.monthUsageKB) }}</span>
|
||||||
|
<span class="row-actions"><button class="ghost icon-only" @click="selectedCardDetail = card" title="详情"><Eye :size="15" /></button><button class="ghost" @click="openCardEditor(card)">编辑</button><button class="ghost icon-only" @click="deleteCard(card.msisdn)" title="删除"><Trash2 :size="15" /></button></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager"><span>共 {{ cardPage.total }} 张卡</span><label class="page-size"><span>每页</span><select :value="cardPage.pageSize" @change="changeCardPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="cardPage.page <= 1" @click="goCardFirst">首页</button><button class="ghost" :disabled="cardPage.page <= 1" @click="changeCardPage(cardPage.page - 1)">上一页</button><span class="page-jump">第 <input class="page-input" type="number" min="1" :max="cardTotalPages" v-model.number="cardJumpPage" @keyup.enter="goToCardPage" /> 页 / 共 {{ cardTotalPages }} 页</span><button class="ghost" :disabled="cardPage.page >= cardTotalPages" @click="changeCardPage(cardPage.page + 1)">下一页</button><button class="ghost" :disabled="cardPage.page >= cardTotalPages" @click="goCardLast">尾页</button><button class="ghost" @click="goToCardPage">跳转</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="split single">
|
||||||
|
<div class="band">
|
||||||
|
<div class="section-head"><div><h2>接口调试</h2><p>支持移动 OneLink 与 Chan 统一接口。</p></div><Server :size="22" /></div>
|
||||||
|
<div class="endpoint-categories" role="tablist" aria-label="接口分类">
|
||||||
|
<button v-for="group in endpointGroups" :key="group.key" class="endpoint-category" :class="{ active: selectedCategory === group.key }" @click="selectedCategory = group.key; selectCategory()">{{ group.label }}<span>{{ group.items.length }}</span></button>
|
||||||
|
</div>
|
||||||
|
<label class="field" v-if="selectedCategory.startsWith('mobile-')"><span>Token 获取</span><div class="copy-field"><input class="mono" :value="firstTokenUrl" readonly /><button class="ghost icon-only" @click="copyText(firstTokenUrl)" title="复制 Token 地址"><Copy :size="16" /></button></div></label>
|
||||||
|
<label class="field"><span>业务接口({{ endpointMethod }})</span><select class="endpoint-select" v-model="selectedEndpoint"><option v-for="item in visibleEndpoints" :key="item.path" :value="item.path">{{ item.label }} · {{ item.path }}</option></select></label>
|
||||||
|
<div class="copy-field"><input class="mono" :value="endpointUrl" readonly /><button class="ghost icon-only" @click="copyText(endpointUrl)" title="复制业务接口地址"><Copy :size="16" /></button></div>
|
||||||
|
<label class="field endpoint-detail" v-if="endpointHeaders"><span>请求 Header</span><div class="copy-field"><textarea class="mono endpoint-body compact" :value="endpointHeaders" readonly></textarea><button class="ghost icon-only" @click="copyText(endpointHeaders)" title="复制请求 Header"><Copy :size="16" /></button></div></label>
|
||||||
|
<label class="field endpoint-detail" v-if="endpointBody"><span>JSON 请求体</span><div class="copy-field"><textarea class="mono endpoint-body" :value="endpointBody" readonly></textarea><button class="ghost icon-only" @click="copyText(endpointBody)" title="复制 JSON 请求体"><Copy :size="16" /></button></div></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="band" id="recent-orders">
|
||||||
|
<div class="section-head"><div><h2>最近订单</h2><p>状态变更与 APN 开停会生成 orderNum。</p></div><BadgePlus :size="22" /></div>
|
||||||
|
<div class="empty" v-if="!state.orders.length">暂无订单</div>
|
||||||
|
<div class="table orders" v-else>
|
||||||
|
<div class="tr th"><span>订单号</span><span>类型</span><span>操作</span><span>MSISDN</span><span>状态</span><span>创建时间</span><span></span></div>
|
||||||
|
<div class="tr order-row" v-for="order in state.orders" :key="order.orderNum">
|
||||||
|
<span class="mono">{{ order.orderNum }}</span><span>{{ order.type }}</span><span>{{ order.operType || '-' }}</span><span class="mono">{{ order.msisdn }}</span><span>{{ order.status }}</span><span>{{ order.createdAt }}</span><button class="ghost icon-only" @click="selectedOrderDetail = order" title="详情"><Eye :size="15" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" v-if="state.orders.length"><span>共 {{ orderPage.total }} 条订单</span><label class="page-size"><span>每页</span><select :value="orderPage.pageSize" @change="changeOrderPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="orderPage.page <= 1" @click="goOrderFirst">首页</button><button class="ghost" :disabled="orderPage.page <= 1" @click="changeOrderPage(orderPage.page - 1)">上一页</button><span class="page-jump">第 <input class="page-input" type="number" min="1" :max="orderTotalPages" v-model.number="orderJumpPage" @keyup.enter="goToOrderPage" /> 页 / 共 {{ orderTotalPages }} 页</span><button class="ghost" :disabled="orderPage.page >= orderTotalPages" @click="changeOrderPage(orderPage.page + 1)">下一页</button><button class="ghost" :disabled="orderPage.page >= orderTotalPages" @click="goOrderLast">尾页</button><button class="ghost" @click="goToOrderPage">跳转</button></div>
|
||||||
|
</section>
|
||||||
|
<div class="modal-backdrop" v-if="selectedCardDetail" @click.self="selectedCardDetail = null"><section class="detail-modal card-detail-modal"><div class="detail-head"><div><h2>卡信息详情</h2><p class="mono">{{ selectedCardDetail.msisdn }}</p></div><button class="ghost icon-only" @click="selectedCardDetail = null" title="关闭"><X :size="18" /></button></div><div class="detail-section"><h3>基础信息</h3><div class="detail-grid"><div class="detail-item" v-for="key in ['msisdn','iccid','imsi','imei','remark','openDate','activeDate']" :key="key"><span>{{ detailLabel(key) }}</span><strong :class="['mono', { 'wrap-value': key === 'remark' }]">{{ selectedCardDetail[key] || '-' }}</strong></div></div></div><div class="detail-section"><h3>状态与连接</h3><div class="detail-grid"><div class="detail-item"><span>卡状态</span><strong>{{ cardStatusLabel(selectedCardDetail.cardStatus) }}</strong></div><div class="detail-item"><span>终端开关机</span><strong>{{ boolLabel(selectedCardDetail.onOffStatus) }}</strong></div><div class="detail-item"><span>会话状态</span><strong>{{ selectedCardDetail.sessionStatus === '01' ? '在线' : '离线' }}</strong></div><div class="detail-item"><span>机卡绑定</span><strong>{{ selectedCardDetail.cardBindStatus === '1' ? '未分离' : '已分离' }}</strong></div><div class="detail-item"><span>区域限制</span><strong>{{ selectedCardDetail.regionLimitStatus === '1' ? '已触发' : '未触发' }}</strong></div><div class="detail-item"><span>接入制式</span><strong>{{ selectedCardDetail.rat || '-' }}</strong></div><div class="detail-item"><span>IPv4</span><strong class="mono">{{ selectedCardDetail.ip || '-' }}</strong></div><div class="detail-item"><span>会话建立时间</span><strong class="mono">{{ selectedCardDetail.sessionCreateDate || selectedCardDetail.lastChangeDate || '-' }}</strong></div></div></div><div class="detail-section"><h3>停机与冻结诊断</h3><div class="detail-grid"><div class="detail-item"><span>停机原因码</span><strong class="mono">{{ selectedCardDetail.stopReason || '000000000000' }}</strong></div><div class="detail-item"><span>管理冻结</span><strong>{{ selectedCardDetail.frozenStatus === '1' ? '是' : '否' }}</strong></div><div class="detail-item"><span>冻结原因</span><strong>{{ selectedCardDetail.frozenReason || '-' }}</strong></div><div class="detail-item"><span>冻结操作时间</span><strong class="mono">{{ selectedCardDetail.frozenOperTime || '-' }}</strong></div></div></div><div class="detail-section"><h3>APN 与 PCC 智能诊断</h3><div class="diagnosis-list"><div class="diagnosis-row" v-for="apn in (selectedCardDetail.apns || [])" :key="apn.name"><div class="diagnosis-row-head"><strong>{{ apn.name }}</strong><span>{{ apnDiagnosisStatusLabel(apn.diagnosisStatus || (apn.status === '1' ? '2' : apn.status)) }}</span><span>{{ apnServiceUsageLabel(apn.serviceUsageState) }}</span></div><div class="diagnosis-meta">变更时间 <span class="mono">{{ apn.modifyTime || '-' }}</span><template v-if="apn.overUseDetails"> · 超停原因 <span>{{ apn.overUseDetails }}</span></template></div><div class="pcc-list"><div class="pcc-item" v-for="pcc in (apn.pccs || [])" :key="pcc.serviceCode"><span class="mono">{{ pcc.serviceCode }}</span><span>{{ pcc.name }}</span><span>策略 {{ pcc.policyStatus }}</span><span>{{ apnServiceUsageLabel(pcc.serviceUsageState) }}</span></div><div class="empty-inline" v-if="!(apn.pccs || []).length">暂无 PCC 策略</div></div></div><div class="empty-inline" v-if="!(selectedCardDetail.apns || []).length">暂无 APN 诊断数据</div></div></div></section></div>
|
||||||
|
<div class="modal-backdrop" v-if="selectedOrderDetail" @click.self="selectedOrderDetail = null"><section class="detail-modal compact"><div class="detail-head"><h2>订单完整信息</h2><button class="ghost icon-only" @click="selectedOrderDetail = null" title="关闭"><X :size="18" /></button></div><div class="detail-grid"><div class="detail-item" v-for="key in ['orderNum','msisdn','iccid','imsi','type','operType','apnName','status','createdAt']" :key="key"><span>{{ key }}</span><strong class="mono">{{ selectedOrderDetail[key] || '-' }}</strong></div></div></section></div>
|
||||||
|
<div class="modal-backdrop" v-if="isCardEditorOpen" @click.self="isCardEditorOpen = false"><section class="detail-modal card-editor-modal">
|
||||||
|
<div class="detail-head"><div><h2>{{ cardEditorMode === 'edit' ? '编辑卡' : '新增卡' }}</h2><p>保存后将刷新当前卡列表。</p></div><button class="ghost icon-only" @click="isCardEditorOpen = false" title="关闭"><X :size="18" /></button></div>
|
||||||
|
<div class="editor-section"><h3>基础信息</h3><div class="editor-grid"><label><span>MSISDN</span><input v-model="cardForm.msisdn" /></label><label><span>ICCID</span><input v-model="cardForm.iccid" /></label><label><span>IMSI</span><input v-model="cardForm.imsi" /></label><label><span>IMEI</span><input v-model="cardForm.imei" /></label><label class="wide"><span>备注</span><input v-model="cardForm.remark" /></label></div></div>
|
||||||
|
<div class="editor-section"><h3>状态与流量</h3><div class="editor-grid"><label><span>卡状态</span><select v-model="cardForm.cardStatus"><option value="1">待激活</option><option value="2">已激活</option><option value="4">停机</option><option value="6">可测试</option><option value="7">库存</option><option value="8">预销户</option></select></label><label><span>终端开关机</span><select v-model="cardForm.onOffStatus"><option value="0">关机</option><option value="1">开机</option></select></label><label><span>会话状态</span><select v-model="cardForm.sessionStatus"><option value="00">离线</option><option value="01">在线</option></select></label><label><span>机卡绑定</span><select v-model="cardForm.cardBindStatus"><option value="0">已分离</option><option value="1">未分离</option></select></label><label><span>区域限制</span><select v-model="cardForm.regionLimitStatus"><option value="0">未触发</option><option value="1">已触发</option></select></label><label><span>当月流量 KB</span><input v-model.number="cardForm.monthUsageKB" type="number" /></label><label><span>日基础流量 KB</span><input v-model.number="cardForm.dailyBaseKB" type="number" /></label><label><span>月基础流量 KB</span><input v-model.number="cardForm.monthlyBaseKB" type="number" /></label></div></div>
|
||||||
|
<div class="editor-section"><h3>诊断模拟</h3><div class="editor-grid"><label><span>停机原因码</span><input v-model="cardForm.stopReason" maxlength="12" /></label><label><span>管理冻结</span><select v-model="cardForm.frozenStatus"><option value="0">否</option><option value="1">是</option></select></label><label class="wide"><span>冻结原因</span><input v-model="cardForm.frozenReason" /></label><label><span>APN 诊断状态</span><select v-model="cardForm.apns[0].diagnosisStatus"><option value="1">未激活</option><option value="2">激活</option><option value="4">暂停</option></select></label><label><span>APN 限速档位</span><input v-model="cardForm.apns[0].serviceUsageState" /></label><label class="wide"><span>超停原因</span><input v-model="cardForm.apns[0].overUseDetails" placeholder="例如 16|17" /></label><label><span>PCC 编码</span><input v-model="cardForm.apns[0].pccs[0].serviceCode" /></label><label><span>PCC 名称</span><input v-model="cardForm.apns[0].pccs[0].name" /></label><label><span>PCC 策略状态</span><select v-model="cardForm.apns[0].pccs[0].policyStatus"><option value="2">激活</option><option value="4">暂停</option></select></label></div></div>
|
||||||
|
<div class="editor-actions"><button class="ghost" @click="isCardEditorOpen = false">取消</button><button :disabled="cardSaving" @click="saveCard"><Save :size="16" />{{ cardSaving ? '保存中...' : '保存卡' }}</button></div>
|
||||||
|
</section></div>
|
||||||
|
<div class="modal-backdrop" v-if="isGroupEditorOpen" @click.self="isGroupEditorOpen = false"><section class="detail-modal compact"><div class="detail-head"><h2>新建分组</h2><button class="ghost icon-only" @click="isGroupEditorOpen = false" title="关闭"><X :size="18" /></button></div><label class="field"><span>分组名称</span><input v-model="groupForm.name" @keyup.enter="createGroup" /></label><div class="editor-actions"><button class="ghost" @click="isGroupEditorOpen = false">取消</button><button :disabled="groupSaving || !groupForm.name.trim()" @click="createGroup"><Save :size="16" />{{ groupSaving ? '保存中...' : '创建分组' }}</button></div></section></div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<header class="page-header"><div><button class="ghost icon-only" @click="navigate('dashboard')" title="返回管理台"><ArrowLeft :size="18" /></button><div><p class="eyebrow">OneLink V25.11.1 CT Mock</p><h1>操作日志</h1></div></div><span>每日 00:00 自动清空</span></header>
|
||||||
|
<section class="band log-page">
|
||||||
|
<div class="section-head"><div><h2>今日操作</h2><p>记录管理台、OneLink 和 Chan 接口的操作结果。</p></div><ClipboardList :size="22" /></div>
|
||||||
|
<div class="log-search-row"><input v-model="operationLogKeyword" @keyup.enter="searchOperationLogs" placeholder="搜索动作、目标、接口或 IP" /><select v-model="operationLogCategory" @change="searchOperationLogs"><option value="">全部类别</option><option value="管理台">管理台</option><option value="OneLink">OneLink</option><option value="Chan">Chan</option></select><select v-model="operationLogResult" @change="searchOperationLogs"><option value="">全部结果</option><option value="success">成功</option><option value="failed">失败</option></select><button @click="searchOperationLogs"><Search :size="16" />查询</button><button class="ghost icon-only" @click="clearOperationLogSearch" title="清空查询"><X :size="16" /></button></div>
|
||||||
|
<div class="empty" v-if="!operationLogs.length">暂无操作日志</div>
|
||||||
|
<div class="table log-table" v-else>
|
||||||
|
<div class="tr th"><span>时间</span><span>类别</span><span>动作</span><span>API 接口</span><span>目标</span><span>结果</span><span>耗时</span><span>客户端 IP</span><span></span></div>
|
||||||
|
<div class="tr" v-for="log in operationLogs" :key="log.id"><span>{{ log.createdAt }}</span><span>{{ log.category }}</span><span>{{ log.action }}</span><span class="mono api-path">{{ log.method }} {{ log.path }}</span><span class="mono">{{ log.target || '-' }}</span><span><em class="result-tag" :class="log.result">{{ log.result === 'success' ? '成功' : '失败' }}</em></span><span>{{ log.durationMs }} ms</span><span class="mono">{{ log.clientIp || '-' }}</span><button class="ghost icon-only" @click="selectedOperationLog = log" title="详情"><Eye :size="15" /></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" v-if="operationLogPage.total"><span>共 {{ operationLogPage.total }} 条日志</span><label class="page-size"><span>每页</span><select :value="operationLogPage.pageSize" @change="changeOperationLogPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="operationLogPage.page <= 1" @click="changeOperationLogPage(operationLogPage.page - 1)">上一页</button><span>第 {{ operationLogPage.page }} / {{ operationLogTotalPages }} 页</span><button class="ghost" :disabled="operationLogPage.page >= operationLogTotalPages" @click="changeOperationLogPage(operationLogPage.page + 1)">下一页</button></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" v-if="selectedOperationLog" @click.self="selectedOperationLog = null"><section class="detail-modal compact"><div class="detail-head"><h2>操作日志详情</h2><button class="ghost icon-only" @click="selectedOperationLog = null" title="关闭"><X :size="18" /></button></div><div class="detail-grid"><div class="detail-item" v-for="key in ['createdAt','category','action','target','method','path','params','result','statusCode','message','durationMs','clientIp']" :key="key"><span>{{ key }}</span><strong class="mono">{{ selectedOperationLog[key] ?? '-' }}</strong></div></div></section></div>
|
||||||
|
<div class="toast" v-if="message">{{ message }}</div>
|
||||||
|
</main>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCard() {
|
||||||
|
return {
|
||||||
|
msisdn: '', iccid: '', imsi: '', imei: '', remark: '',
|
||||||
|
stopReason: '000000000000', frozenStatus: '0', frozenReason: '', frozenOperTime: '', sessionCreateDate: '',
|
||||||
|
cardStatus: '2', onOffStatus: '1', sessionStatus: '01', cardBindStatus: '1', regionLimitStatus: '0',
|
||||||
|
monthUsageKB: 18432, dailyBaseKB: 614.4, monthlyBaseKB: 18432, growthPerMinuteKB: 0,
|
||||||
|
apns: [{ name: 'CMIOT', status: '1', diagnosisStatus: '2', serviceUsageState: '1', overUseDetails: '', usageBytes: 0, modifyTime: '', pccs: [{ serviceCode: 'default', name: 'APN 缺省流量', policyStatus: '2', serviceUsageState: '1', modifyTime: '' }] }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function transid(appid) { return `${appid}${new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)}00000001` }
|
||||||
|
function yesterday() { return new Date(Date.now() - 86400000).toISOString().slice(0, 10).replaceAll('-', '') }
|
||||||
|
function monthText() { return new Date().toISOString().slice(0, 7).replace('-', '') }
|
||||||
|
function normalizeBase(base) {
|
||||||
|
const clean = (base || '/').replace(/\/+$/, '')
|
||||||
|
return clean === '' ? '' : clean
|
||||||
|
}
|
||||||
|
function withBase(path) {
|
||||||
|
const cleanPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${appBase}${cleanPath}` || '/'
|
||||||
|
}
|
||||||
|
function viewFromPath() {
|
||||||
|
const path = location.pathname.replace(appBase, '') || '/'
|
||||||
|
return path === '/operation-logs' || path === '/operation-logs/' ? 'operation-logs' : 'dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 //
|
||||||
|
}
|
||||||
@@ -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 //
|
||||||
|
}
|
||||||
@@ -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 //
|
||||||
|
}
|
||||||
@@ -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:""` //
|
||||||
|
}
|
||||||
@@ -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:""` //
|
||||||
|
}
|
||||||
@@ -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:""` //
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func exportInboundCardsXLSX(cards []SIMCard) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
files := map[string]string{
|
||||||
|
"[Content_Types].xml": contentTypesXML,
|
||||||
|
"_rels/.rels": rootRelsXML,
|
||||||
|
"xl/workbook.xml": workbookXML,
|
||||||
|
"xl/_rels/workbook.xml.rels": workbookRelsXML,
|
||||||
|
"xl/sharedStrings.xml": sharedStringsXML(),
|
||||||
|
"xl/styles.xml": stylesXML,
|
||||||
|
"xl/worksheets/sheet1.xml": sheetXML(cards),
|
||||||
|
}
|
||||||
|
for _, name := range []string{"[Content_Types].xml", "_rels/.rels", "xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/sharedStrings.xml", "xl/styles.xml", "xl/worksheets/sheet1.xml"} {
|
||||||
|
w, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(files[name])); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sheetXML(cards []SIMCard) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||||
|
b.WriteString(`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">`)
|
||||||
|
b.WriteString(`<dimension ref="A1:R`)
|
||||||
|
b.WriteString(strconv.Itoa(len(cards) + 1))
|
||||||
|
b.WriteString(`"/><sheetViews><sheetView tabSelected="true" workbookViewId="0"/></sheetViews>`)
|
||||||
|
b.WriteString(`<sheetFormatPr defaultRowHeight="15"/><cols><col min="1" max="18" width="9.140625" style="1"/></cols><sheetData>`)
|
||||||
|
writeSharedStringRow(&b, 1, len(inboundExportHeaders))
|
||||||
|
for i, card := range cards {
|
||||||
|
row := inboundExportRow(card)
|
||||||
|
values := make([]string, 0, len(inboundExportColumns))
|
||||||
|
for _, column := range inboundExportColumns {
|
||||||
|
values = append(values, row[column.Key])
|
||||||
|
}
|
||||||
|
writeSheetRow(&b, i+2, values)
|
||||||
|
}
|
||||||
|
b.WriteString(`</sheetData></worksheet>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSharedStringRow(b *strings.Builder, rowNum, count int) {
|
||||||
|
b.WriteString(`<row r="`)
|
||||||
|
b.WriteString(strconv.Itoa(rowNum))
|
||||||
|
b.WriteString(`">`)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
|
||||||
|
b.WriteString(`<c r="`)
|
||||||
|
b.WriteString(ref)
|
||||||
|
b.WriteString(`" s="1" t="s"><v>`)
|
||||||
|
b.WriteString(strconv.Itoa(i))
|
||||||
|
b.WriteString(`</v></c>`)
|
||||||
|
}
|
||||||
|
b.WriteString(`</row>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSheetRow(b *strings.Builder, rowNum int, values []string) {
|
||||||
|
b.WriteString(`<row r="`)
|
||||||
|
b.WriteString(strconv.Itoa(rowNum))
|
||||||
|
b.WriteString(`">`)
|
||||||
|
for i, value := range values {
|
||||||
|
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
|
||||||
|
b.WriteString(`<c r="`)
|
||||||
|
b.WriteString(ref)
|
||||||
|
b.WriteString(`" s="1" t="inlineStr"><is><t>`)
|
||||||
|
b.WriteString(xmlEscape(value))
|
||||||
|
b.WriteString(`</t></is></c>`)
|
||||||
|
}
|
||||||
|
b.WriteString(`</row>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharedStringsXML() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||||
|
b.WriteString(`<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="`)
|
||||||
|
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
|
||||||
|
b.WriteString(`" uniqueCount="`)
|
||||||
|
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
|
||||||
|
b.WriteString(`">`)
|
||||||
|
for _, header := range inboundExportHeaders {
|
||||||
|
b.WriteString(`<si><t>`)
|
||||||
|
b.WriteString(xmlEscape(header))
|
||||||
|
b.WriteString(`</t></si>`)
|
||||||
|
}
|
||||||
|
b.WriteString(`</sst>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnName(index int) string {
|
||||||
|
var out []byte
|
||||||
|
for index > 0 {
|
||||||
|
index--
|
||||||
|
out = append([]byte{byte('A' + index%26)}, out...)
|
||||||
|
index /= 26
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlEscape(value string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
_ = xml.EscapeText(&b, []byte(value))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||||
|
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||||
|
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
||||||
|
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||||
|
</Types>`
|
||||||
|
|
||||||
|
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||||
|
</Relationships>`
|
||||||
|
|
||||||
|
const workbookXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<sheets>
|
||||||
|
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
|
||||||
|
</sheets>
|
||||||
|
</workbook>`
|
||||||
|
|
||||||
|
const workbookRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
||||||
|
</Relationships>`
|
||||||
|
|
||||||
|
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font></fonts>
|
||||||
|
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
||||||
|
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||||
|
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||||
|
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>
|
||||||
|
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||||
|
<dxfs count="0"/><tableStyles count="0" defaultPivotStyle="PivotStyleLight16" defaultTableStyle="TableStyleMedium2"/>
|
||||||
|
</styleSheet>`
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxOperationDetailBytes = 2048
|
||||||
|
maxLoggedRequestBytes = 2 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type OperationLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Params string `json:"params,omitempty"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
DurationMS int64 `json:"durationMs"`
|
||||||
|
ClientIP string `json:"clientIp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationLogPage struct {
|
||||||
|
Items []OperationLog `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationLogStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
logs []OperationLog
|
||||||
|
nextID int64
|
||||||
|
now func() time.Time
|
||||||
|
day string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOperationLogStore() *OperationLogStore {
|
||||||
|
store := newOperationLogStore(time.Now)
|
||||||
|
store.scheduleMidnightClear()
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOperationLogStore(now func() time.Time) *OperationLogStore {
|
||||||
|
return &OperationLogStore{logs: make([]OperationLog, 0), now: now, day: logDay(now())}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OperationLogStore) Add(log OperationLog) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.clearExpiredLocked()
|
||||||
|
s.nextID++
|
||||||
|
log.ID = s.nextID
|
||||||
|
log.CreatedAt = s.now().Format("2006-01-02 15:04:05")
|
||||||
|
s.logs = append(s.logs, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OperationLogStore) Query(keyword, category, result string, page, pageSize int) OperationLogPage {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.clearExpiredLocked()
|
||||||
|
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||||
|
matched := make([]OperationLog, 0, len(s.logs))
|
||||||
|
for i := len(s.logs) - 1; i >= 0; i-- {
|
||||||
|
item := s.logs[i]
|
||||||
|
if category != "" && item.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result != "" && item.Result != result {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
searchable := strings.ToLower(item.Action + " " + item.Target + " " + item.Path + " " + item.Params + " " + item.Message + " " + item.ClientIP)
|
||||||
|
if keyword != "" && !strings.Contains(searchable, keyword) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matched = append(matched, item)
|
||||||
|
}
|
||||||
|
total := len(matched)
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
items := append([]OperationLog(nil), matched[start:end]...)
|
||||||
|
return OperationLogPage{Items: items, Total: total, Page: page, PageSize: pageSize}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OperationLogStore) Clear() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.logs = s.logs[:0]
|
||||||
|
s.day = logDay(s.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OperationLogStore) clearExpiredLocked() {
|
||||||
|
today := logDay(s.now())
|
||||||
|
if s.day == today {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logs = s.logs[:0]
|
||||||
|
s.day = today
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OperationLogStore) scheduleMidnightClear() {
|
||||||
|
now := s.now()
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||||
|
time.AfterFunc(time.Until(next), func() {
|
||||||
|
s.Clear()
|
||||||
|
s.scheduleMidnightClear()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func logDay(value time.Time) string {
|
||||||
|
return value.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
body bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *operationResponseWriter) WriteHeader(status int) {
|
||||||
|
if w.status == 0 {
|
||||||
|
w.status = status
|
||||||
|
}
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *operationResponseWriter) Write(p []byte) (int, error) {
|
||||||
|
if w.status == 0 {
|
||||||
|
w.status = http.StatusOK
|
||||||
|
}
|
||||||
|
if w.body.Len() < maxOperationDetailBytes {
|
||||||
|
remaining := maxOperationDetailBytes - w.body.Len()
|
||||||
|
if len(p) > remaining {
|
||||||
|
_, _ = w.body.Write(p[:remaining])
|
||||||
|
} else {
|
||||||
|
_, _ = w.body.Write(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *operationResponseWriter) Flush() {
|
||||||
|
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayReadCloser struct {
|
||||||
|
io.Reader
|
||||||
|
closer io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *replayReadCloser) Close() error {
|
||||||
|
return r.closer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) withOperationLogging(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !shouldLogOperation(r.URL.Path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := captureRequestBody(r)
|
||||||
|
category, action := s.describeOperation(r, body)
|
||||||
|
started := time.Now()
|
||||||
|
wrapped := &operationResponseWriter{ResponseWriter: w}
|
||||||
|
next.ServeHTTP(wrapped, r)
|
||||||
|
if wrapped.status == 0 {
|
||||||
|
wrapped.status = http.StatusOK
|
||||||
|
}
|
||||||
|
result, message := operationResult(wrapped.status, wrapped.body.Bytes())
|
||||||
|
s.operationLogs.Add(OperationLog{
|
||||||
|
Category: category, Action: action, Target: operationTarget(r.URL.Query(), body),
|
||||||
|
Method: r.Method, Path: r.URL.Path, Params: operationParams(r.URL.Query(), body),
|
||||||
|
Result: result, StatusCode: wrapped.status, Message: message,
|
||||||
|
DurationMS: time.Since(started).Milliseconds(), ClientIP: clientIP(r),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldLogOperation(path string) bool {
|
||||||
|
if path == "/api/operation-logs" || path == "/api/runtime-logs" || path == "/healthz" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v5/") || strings.HasPrefix(path, "/ec/") || strings.HasPrefix(path, "/sim/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureRequestBody(r *http.Request) []byte {
|
||||||
|
if r.Body == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
original := r.Body
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(original, maxLoggedRequestBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.Body = &replayReadCloser{Reader: io.MultiReader(bytes.NewReader(raw), original), closer: original}
|
||||||
|
if len(raw) > maxLoggedRequestBytes {
|
||||||
|
return raw[:maxLoggedRequestBytes]
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) describeOperation(r *http.Request, body []byte) (string, string) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if strings.HasPrefix(path, "/api/") {
|
||||||
|
return "管理台", s.describeAdminOperation(r, body)
|
||||||
|
}
|
||||||
|
category := "OneLink"
|
||||||
|
endpoint := strings.TrimPrefix(strings.TrimPrefix(path, "/v5/"), "/")
|
||||||
|
if strings.HasPrefix(path, "/sim/") {
|
||||||
|
category = "Chan"
|
||||||
|
endpoint = strings.TrimPrefix(path, "/sim/")
|
||||||
|
}
|
||||||
|
actions := map[string]string{
|
||||||
|
"ec/get/token": "获取 Token", "ec/query/sim-basic-info": "查询卡基础信息",
|
||||||
|
"ec/query/sim-session": "查询在线状态", "ec/query/sim-status": "查询卡状态",
|
||||||
|
"ec/query/sim-imei": "查询 IMEI", "ec/query/sim-data-usage-inpool": "查询流量池用量",
|
||||||
|
"ec/query/sim-data-usage": "查询当月流量", "ec/query/sim-data-usage-monthly/batch": "批量查询月流量",
|
||||||
|
"ec/query/sim-data-usage-daily/batch": "批量查询日流量", "ec/query/apn-info": "查询 APN",
|
||||||
|
"ec/query/card-bind-status": "查询绑定状态", "ec/query/on-off-status": "查询开关机状态",
|
||||||
|
"ec/query/region-limit-status": "查询区域限制", "ec/change/sim-status": "变更 SIM 状态",
|
||||||
|
"ec/operate/sim-apn-function": "操作 APN",
|
||||||
|
"query/sim-basic-info": "查询卡基础信息", "query/sim-session": "查询在线状态",
|
||||||
|
"query/sim-status": "查询卡状态", "query/sim-status-batch": "批量查询卡状态",
|
||||||
|
"query/sim-card-bind-status": "查询绑定状态", "query/sim-device-status": "查询设备状态",
|
||||||
|
"query/sim-region-limit-status": "查询区域限制", "query/sim-imei": "查询 IMEI",
|
||||||
|
"query/sim-datausage": "查询当月流量", "query/sim-datausage-monthly": "查询月流量",
|
||||||
|
"query/sim-datausage-monthly-batch": "批量查询月流量", "query/sim-datausage-daily": "查询日流量",
|
||||||
|
"query/sim-datausage-daily-batch": "批量查询日流量", "query/sim-apn-info": "查询 APN",
|
||||||
|
"change/sim-status": "变更 SIM 状态", "change/sim-apn": "操作 APN",
|
||||||
|
}
|
||||||
|
if action := actions[endpoint]; action != "" {
|
||||||
|
return category, action
|
||||||
|
}
|
||||||
|
return category, "访问 " + endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) describeAdminOperation(r *http.Request, body []byte) string {
|
||||||
|
key := r.Method + " " + r.URL.Path
|
||||||
|
actions := map[string]string{
|
||||||
|
"GET /api/state": "查看管理台", "GET /api/card-groups": "查询卡分组",
|
||||||
|
"POST /api/card-groups": "新增卡分组", "DELETE /api/card-groups": "删除卡分组",
|
||||||
|
"GET /api/cards": "查询 SIM 卡", "DELETE /api/cards": "删除 SIM 卡",
|
||||||
|
"GET /api/cards/export": "导出 SIM 卡", "POST /api/settings": "更新模拟设置",
|
||||||
|
"GET /api/orders": "查询订单", "DELETE /api/orders": "清空订单",
|
||||||
|
"POST /api/generate": "一键生成 SIM 卡", "POST /api/sync": "同步生成 SIM 卡",
|
||||||
|
"DELETE /api/appids": "删除 AppID",
|
||||||
|
}
|
||||||
|
if key == "POST /api/appids" {
|
||||||
|
appid := jsonStringField(body, "appid")
|
||||||
|
for _, item := range s.store.Snapshot().AppIDs {
|
||||||
|
if item.AppID == appid {
|
||||||
|
return "更新 AppID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "新增 AppID"
|
||||||
|
}
|
||||||
|
if key == "POST /api/cards" {
|
||||||
|
msisdn := jsonStringField(body, "msisdn")
|
||||||
|
for _, item := range s.store.Snapshot().Cards {
|
||||||
|
if item.MSISDN == msisdn {
|
||||||
|
return "编辑 SIM 卡"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "新增 SIM 卡"
|
||||||
|
}
|
||||||
|
if action := actions[key]; action != "" {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
return "访问 " + r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonStringField(body []byte, key string) string {
|
||||||
|
var value map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &value) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(value[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationTarget(query url.Values, body []byte) string {
|
||||||
|
keys := []string{"appid", "msisdn", "iccid", "imsi", "id"}
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(query.Get(key)); value != "" {
|
||||||
|
return key + ": " + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var value map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &value) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if item := strings.TrimSpace(fmt.Sprint(value[key])); item != "" && item != "<nil>" {
|
||||||
|
return key + ": " + item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if simKeys, ok := value["simKeys"].([]interface{}); ok && len(simKeys) > 0 {
|
||||||
|
if first, ok := simKeys[0].(map[string]interface{}); ok {
|
||||||
|
for _, key := range []string{"msisdn", "iccid", "imsi"} {
|
||||||
|
if item := strings.TrimSpace(fmt.Sprint(first[key])); item != "" && item != "<nil>" {
|
||||||
|
return key + ": " + item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationParams(query url.Values, body []byte) string {
|
||||||
|
parts := make([]string, 0, 2)
|
||||||
|
if len(query) > 0 {
|
||||||
|
clean := make(url.Values, len(query))
|
||||||
|
keys := make([]string, 0, len(query))
|
||||||
|
for key := range query {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if sensitiveOperationKey(key) {
|
||||||
|
clean.Set(key, "***")
|
||||||
|
} else {
|
||||||
|
clean[key] = append([]string(nil), query[key]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, clean.Encode())
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(body)) > 0 {
|
||||||
|
var value interface{}
|
||||||
|
if json.Unmarshal(body, &value) == nil {
|
||||||
|
if raw, err := json.Marshal(redactOperationValue(value)); err == nil {
|
||||||
|
parts = append(parts, string(raw))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "[non-JSON request body]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := strings.Join(parts, " ")
|
||||||
|
if len(result) > maxOperationDetailBytes {
|
||||||
|
return result[:maxOperationDetailBytes] + "..."
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactOperationValue(value interface{}) interface{} {
|
||||||
|
switch item := value.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
for key, child := range item {
|
||||||
|
if sensitiveOperationKey(key) {
|
||||||
|
item[key] = "***"
|
||||||
|
} else {
|
||||||
|
item[key] = redactOperationValue(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
for i := range item {
|
||||||
|
item[i] = redactOperationValue(item[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sensitiveOperationKey(key string) bool {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
return strings.Contains(key, "password") || strings.Contains(key, "token") || strings.Contains(key, "authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationResult(status int, body []byte) (string, string) {
|
||||||
|
if status >= http.StatusBadRequest {
|
||||||
|
return "failed", responseMessage(body, http.StatusText(status))
|
||||||
|
}
|
||||||
|
var value interface{}
|
||||||
|
if json.Unmarshal(body, &value) != nil {
|
||||||
|
return "success", ""
|
||||||
|
}
|
||||||
|
if item, ok := value.(map[string]interface{}); ok {
|
||||||
|
if message, ok := item["error"].(string); ok && message != "" {
|
||||||
|
return "failed", message
|
||||||
|
}
|
||||||
|
if code := fmt.Sprint(item["status"]); code != "<nil>" && code != "" && code != "0" {
|
||||||
|
return "failed", fmt.Sprint(item["message"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items, ok := value.([]interface{}); ok {
|
||||||
|
for _, raw := range items {
|
||||||
|
item, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code, ok := item["code"].(float64); ok && code != chanSuccessCode {
|
||||||
|
return "failed", fmt.Sprint(item["message"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "success", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseMessage(body []byte, fallback string) string {
|
||||||
|
var value map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &value) == nil {
|
||||||
|
for _, key := range []string{"error", "message"} {
|
||||||
|
if message := strings.TrimSpace(fmt.Sprint(value[key])); message != "" && message != "<nil>" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
|
||||||
|
return forwarded
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) adminOperationLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, pageSize := pagination(r)
|
||||||
|
writeJSON(w, http.StatusOK, s.operationLogs.Query(
|
||||||
|
r.URL.Query().Get("keyword"), r.URL.Query().Get("category"), r.URL.Query().Get("result"), page, pageSize,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationLogID(value int64) string {
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
func newStoreBackend(path string) (storeBackend, error) {
|
||||||
|
return newSQLiteStoreBackend(path)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed sql/schema.sql
|
||||||
|
var SchemaSQL string
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user