105 Commits

Author SHA1 Message Date
MiFaZhan f7cab5ad5c chore: 整合为单仓 iotlcm(12 个仓 → 1 个仓)
mirror-to-gitee / mirror (push) Successful in 14s
把原来 12 个独立仓合并为单仓多服务结构,各仓完整提交历史通过
read-tree 并入保留(137 个提交,blame 可追溯)。

目录:
  admin-svr/ admin-ui/ app/{server,app}/ openapi/ worker-svr/
  upstream-chan/ upstream-proxy/ sms/ bot/ docs/ onelink-mock/ deploy/

同时:
- 新增根 README(目录说明、快速开始、源码构建、镜像发布、许可)
- 新增根 Makefile(make build / test / images / docs)
- 新增根 .gitattributes / .gitignore,删掉 12 份重复的 Apache LICENSE
  (admin-ui/ 与 app/app/ 的 MIT 上游许可保留)
- 各服务的 .gitea/workflows 收敛到仓根:trigger-jenkins.yml(tag 构建全部
  服务)+ mirror-to-gitee.yml(自动建 Gitee 仓后镜像)
- 文档内跨仓绝对链接改为仓内相对路径;部署/构建文档改为单仓路径
2026-11-02 13:35:58 +08:00
MiFaZhan 2923c78755 chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e) 2026-11-02 13:28:03 +08:00
MiFaZhan 6de2e503cc chore: 并入 iotlcm-upstream-proxy 历史到 upstream-proxy/(原提交 44209ec) 2026-11-02 13:28:02 +08:00
MiFaZhan 9c9f6c2a29 chore: 并入 iotlcm-upstream-chan 历史到 upstream-chan/(原提交 1e74497) 2026-11-02 13:28:02 +08:00
MiFaZhan 691b792391 chore: 并入 iotlcm-sms 历史到 sms/(原提交 1d38bc4) 2026-11-02 13:28:02 +08:00
MiFaZhan b10b8cfc51 chore: 并入 iotlcm-openapi 历史到 openapi/(原提交 3b05d5a) 2026-11-02 13:28:02 +08:00
MiFaZhan c3c1f09ebd chore: 并入 iotlcm-onelink-mock 历史到 onelink-mock/(原提交 f398595) 2026-11-02 13:28:02 +08:00
MiFaZhan 029f81fb1c chore: 并入 iotlcm-docs 历史到 docs/(原提交 947e22e) 2026-11-02 13:28:01 +08:00
MiFaZhan fa1f96e942 chore: 并入 iotlcm-deploy 历史到 deploy/(原提交 eb97c53) 2026-11-02 13:28:00 +08:00
MiFaZhan 05014446c3 chore: 并入 iotlcm-bot 历史到 bot/(原提交 31dbc1d) 2026-11-02 13:28:00 +08:00
MiFaZhan eb97c5300a fix: 修复代理转发端口缺失与 mock 通道未实例化
测试反馈两个问题:

1. 代理转发地址只写了服务名 upstream-chan,未带端口 → 默认连 80 端口被拒。
   改为 upstream-chan:8110。已核对其它服务(admin-svr / openapi / app-server /
   worker-svr)的上游地址均带端口,只有 upstream-proxy 这一处遗漏。

2. 直连 8110 查询报「通道未实例化」。根因在种子数据的 is_normal:
   该字段语义是 0=正常、1=异常(chan 侧 CanCreateInstance 只在 *IsNormal==0 时
   创建实例;NULL 也不合格)。mock 通道原来写的 1,因此实例从未创建。
   改为 0。注意内网演示环境种子同样是 1,属开源版修正。
   种子的 ON DUPLICATE KEY UPDATE 已包含 is_normal,老库重跑该段即可修复。

已验证(清空 data 目录从零启动):
- t_sim_channels 中 channel_yd_mock 的 is_normal=0
- chan 日志出现 Create channel instance success, chanKey: channel_yd_mock
- 日志无 abnormal / 未实例化 相关记录
2026-10-30 17:44:02 +08:00
MiFaZhan 01d1e39c34 fix: docs 镜像改回 v1.0.4(修复已并入该版本,不新增版本号)
版本号与功能一一对应,这是开源发布版本,修复不能自造新版本号。

- iotlcm-docs 的 git tag v1.0.4 前移到含 nginx 修复的提交(947e22e)
- 镜像 iotlcm/iotlcm:iotlcm-docs-v1.0.4 用修复版覆盖(现 digest 8ae5b646)
- 删除临时产生的 git tag v1.0.5 与镜像 tag iotlcm-docs-v1.0.5
- 本仓 docs 镜像 tag 从 v1.0.5 改回 v1.0.4

实测重新拉取 v1.0.4 后:/ 302、/docs/ 200、/docs/manual/web/ 200、核心 CSS 200。
2026-10-30 13:39:20 +08:00
MiFaZhan a8a5537c69 ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:20:32 +08:00
MiFaZhan f3985950cc ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:20:31 +08:00
MiFaZhan 947e22e3b9 ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:20:30 +08:00
MiFaZhan 31dbc1d910 ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:20:00 +08:00
MiFaZhan 1d38bc4226 ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:19:59 +08:00
MiFaZhan 44209ecaaf ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:19:57 +08:00
MiFaZhan 1e74497728 ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:19:56 +08:00
MiFaZhan 96f0f4eacd ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:19:55 +08:00
MiFaZhan 3b05d5a73a ci: 镜像到 Gitee 增加退避重试与每日兜底
Gitee 在推送密集时返回 429 Pending queue overflowed / 408 Authorization server
timeout,属服务端限流,原实现一次失败即终止。
- 推送改为最多 5 次退避重试(30s/60s/90s/120s)
- 增加每日定时兜底(18:30 UTC = 次日 02:30 北京时间),
  避免某次推送失败后长期不同步
2026-10-30 13:19:53 +08:00
MiFaZhan 6b5fb707c2 fix: docs 镜像升级到 v1.0.5,修复文档站白屏
测试反馈:访问文档站白屏、点击无反应。

根因:VitePress 配置 base: '/docs/',构建产物里资源引用是绝对路径 /docs/assets/xxx,
而 dist 目录是平铺的;镜像里的 nginx.conf 从根路径服务,/docs/assets/* 全部 404。
(此前修过同一问题,v1.0.4 同步代码时被覆盖回旧配置。)

上游已在 iotlcm-docs 修 nginx.conf(alias 剥前缀 + / -> /docs/ 重定向)并打 v1.0.5,
镜像 iotlcm-docs-v1.0.5 已由 tag 推送自动构建入库。

本仓把 docs 镜像 tag 从 v1.0.4 升到 v1.0.5。
实测:/ 302、/docs/ 200、/docs/manual/web/ 200、核心 CSS 200。
2026-10-30 13:19:18 +08:00
MiFaZhan eeeb6572ba fix: nginx 在 /docs/ 下服务产物,修复白屏
VitePress 配置 base: '/docs/',构建出的 HTML 里资源引用是绝对路径 /docs/assets/xxx;
而 dist 目录是平铺的(index.html、assets/ 直接在根)。原 nginx.conf 从根路径服务,
/docs/assets/* 全部 404 → 页面能打开但 JS/CSS 加载失败,白屏、点击无反应。

改为用 alias 把 /docs/ 前缀剥掉再落到产物目录,并加 / -> /docs/ 的 302,
避免直接访问根路径白屏。

实测(本地构建镜像后):/docs/ 200、/docs/manual/web/ 200、/docs/openapi/ 200、
核心 CSS 资源 200;/ 返回 302。
2026-10-30 13:11:58 +08:00
MiFaZhan 56e930c7d0 docs: 初始化脚本注释去掉内部环境措辞(仅注释,不影响执行)
schema/init.sql 两处「照预演」的注释改为直述授权范围,避免出现内部环境字样。
SQL 语句本身未改动。
2026-10-30 11:50:19 +08:00
MiFaZhan dfeb5e8d13 docs: 删除 fullsync/supply 的内部记录,修复 README 悬空链接
- 删除 internal/fullsync/supply/README.md:后半段「预演门禁与回退记录」含内网
  Compose 目录(/home/xjzl/...)、两个镜像 sha256、内部挂载路径与 origin/main 引用,
  属内网部署记录,不应公开;前半段为内部实现说明,一并移除(代码包注释已足够)
- README:移除指向已删文件 SIM_STATUS_RUN_ACCOUNTING.md 的链接,
  改为直述 simStatusQueueSync.redisKeyPrefix 的用途
- README:移除指向已删文件 docs/角色权限任务切换.md 的链接,部署顺序改为直述要求
- go build ./... 通过
2026-10-30 11:50:17 +08:00
MiFaZhan ce0db55e36 docs: 移除开发过程文档,开源版文档统一见文档站
- docs/superpowers/plans/2026-07-13-sqlite-generated-cards.md(SQLite 挡板改造实施计划,含任务分解与勾选清单)

保留 docs/deploy.md(面向使用者的部署说明)。

文档站:https://git.5iot.com/iotlcm-public/iotlcm-docs
2026-10-30 11:43:10 +08:00
MiFaZhan 6ed0691134 docs: 移除开发过程文档,开源版文档统一见文档站
- docs/apika-shared-token-integration.md(Apika 共享 Token 通道对接说明)
- docs/mingqian-integration.md(明千 v3 三方接入操作手册,含本地工作区路径与部署步骤)
- docs/mobile-public-token-integration.md(移动公共 Token 通道配置与签名说明)
- docs/telecom-v3-apn-integration.md(电信 V3 APN 状态与开停适配,含内部回退记录)
- docs/plans/channel-template-model-design.md(通道模板模型设计方案)
- docs/plans/telecom-v3-integration-and-switch.md(电信 v1/v3 切换与回退方案,含开发库现状)
- docs/scripts/query_sim_card_status.sh、docs/scripts/query_sim_session.sh(内部联调脚本)
- docs/sql/*.sql(共享模板迁移与种子 SQL)

保留 docs/config/*.json 通道模板样例(已脱敏,对使用者有参考价值)。

文档站:https://git.5iot.com/iotlcm-public/iotlcm-docs
2026-10-30 11:42:38 +08:00
MiFaZhan e6ea61380a docs: 移除开发过程文档,开源版文档统一见文档站
- docs/角色权限任务切换.md(管理后端与 Worker 的角色权限任务切换上线顺序、回滚与验收)
- SIM_STATUS_RUN_ACCOUNTING.md(卡状态同步 Run 计数收尾语义与上线步骤)

文档站:https://git.5iot.com/iotlcm-public/iotlcm-docs
2026-10-30 11:42:28 +08:00
MiFaZhan 623af9e3c7 docs: 移除开发过程文档,开源版文档统一见文档站
- docs/batch-number.md(内部批次编号约定,含预演/生产 Redis 隔离说明)
- docs/openapi-permission-rollout.md(v1.0.4 权限修复上线说明,含路由下线决定与授权放量流程)

文档站:https://git.5iot.com/iotlcm-public/iotlcm-docs
2026-10-30 11:40:50 +08:00
MiFaZhan 69a136b05d ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml
2026-10-30 11:27:41 +08:00
MiFaZhan 27ce9df7a5 ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
2026-10-30 11:27:32 +08:00
MiFaZhan 3b9b1bc045 ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
2026-10-30 11:27:32 +08:00
MiFaZhan 468796d843 ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
2026-10-30 11:27:31 +08:00
MiFaZhan 9710fc22eb ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
2026-10-30 11:27:30 +08:00
MiFaZhan 6d71fa79b1 ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
2026-10-30 11:27:29 +08:00
MiFaZhan cca4c5df9f ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
2026-10-30 11:27:29 +08:00
MiFaZhan 0f963ac085 ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
2026-10-30 11:27:28 +08:00
MiFaZhan e4298ef64c ci: main/tag 推送时自动镜像到 Gitee
新增 .gitea/workflows/mirror-to-gitee.yml:
- 触发:push 到 main、push v* tag、手动 dispatch
- 动作:git clone --mirror 源仓 → git push --mirror 到 gitee.com/snowgoat/<同名仓库>
- 凭据:组织密钥 SRC_GIT_TOKEN(源仓只读)、GITEE_TOKEN / GITEE_OWNER / GITEE_USER(目标)
- 同一 ref 连续推送时取消旧任务
2026-10-30 11:26:19 +08:00
MiFaZhan a30c286b03 ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:50 +08:00
MiFaZhan 200846aee3 ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:49 +08:00
MiFaZhan 1e3e2c4bb9 ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:48 +08:00
MiFaZhan 10df6d65aa ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:48 +08:00
MiFaZhan 2f9e952df6 ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:46 +08:00
MiFaZhan 452f7c6549 ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:45 +08:00
MiFaZhan 767148a8a4 ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:45 +08:00
MiFaZhan 9fb667cc6a ci: Jenkins job 改由组织密钥指定,适配 folder 化
Jenkins 的 job 已移入文件夹 iotlcm-opensrc,访问路径变为
/job/iotlcm-opensrc/job/iotlcm-oss-build/,原硬编码的 /job/iotlcm-oss-build/ 会 404。

改为读取组织密钥 JENKINS_JOB_PATH,默认回退到旧路径。
以后 Jenkins 里再移动 job 只需改该密钥,不必改 11 个仓。
2026-10-30 11:10:44 +08:00
MiFaZhan d740123174 chore: run.sh 加可执行位,校正首次启动耗时说明
实测从空目录执行 docker compose up -d:33 秒全部服务启动,
MySQL 初始化出 95 表、TDengine 建表校验通过。
2026-10-30 11:00:14 +08:00
MiFaZhan 6d3b8dab43 docs: 部署手册补充时序库已内置
部署编排已加入 TDengine(tdengine 3.3.6.13)与 tdengine-init 建库建表服务,
文档同步:
- prerequisites:外部依赖章节把 TDengine 从「可选/需自行部署」改为「已内置」,
  列出两个库的分片/保留/超级表,并说明与生产的版本差异
- troubleshooting:历史流量无数据一节改为常见原因表(init 未成功、库表缺失、地址错误),
  给出对应的排查命令与处理方式
2026-10-30 10:03:12 +08:00
MiFaZhan d84f93ce0a feat: 内置 TDengine 时序库,自动建库建表
此前编排不含时序库,首页流量/用量图表、单卡流量历史等依赖时序数据的功能
不可用。现补上:

- 新增 tdengine 服务(镜像 iotlcm/iotlcm:tdengine-3.3.6.13)
  端口 6041(taosAdapter/WebSocket,应用用这个)+ 6030,数据卷 ./data/tdengine
- 新增 tdengine-init 一次性服务:等 tdengine 健康后建库建表,并校验超级表存在,
  缺表则以非零码退出
- 建库建表 SQL 取自生产环境 SHOW CREATE STABLE(仅结构无数据):
  iotlcm_daily   DURATION 7d  KEEP 1095d  ms  -> t_sim_card_usage_daily
  iotlcm_monthly DURATION 30d KEEP 1095d  ms  -> t_sim_card_usage_monthly
- 三个服务的 tsdb.tdengine 配置指向 tdengine:6041(admin-svr / app-server / worker-svr)

踩坑记录:taos -f 是按行执行语句的,多行 CREATE STABLE 会报
Incomplete SQL statement,因此 SQL 必须单行;且 taos 出错时退出码仍为 0,
故 run.sh 额外做超级表校验。

已验证:初始化 4 条语句全部 Create OK、0 error;按 worker 的 INSERT 形式
(USING ... TAGS)写入成功、按 admin 的查询形式(SELECT ts,usage_mb,package_code,iccid)
可读回;整栈 15 个服务全 Up,admin-svr/openapi/sms/docs 均 200,worker 无时序库报错。
2026-10-30 10:02:32 +08:00
MiFaZhan c49aee102a ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:39:26 +08:00
MiFaZhan 98293f1813 ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:39:25 +08:00
MiFaZhan 1f07186679 ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:39:19 +08:00
MiFaZhan a93b7de14d ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:39:17 +08:00
MiFaZhan 5c54d32752 ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:39:16 +08:00
MiFaZhan 3ebe0adf3e ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:39:15 +08:00
MiFaZhan 1eed26991f ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:39:14 +08:00
MiFaZhan b1b83d806a ci: 打 tag 时自动触发 Jenkins 构建镜像
新增 .gitea/workflows/trigger-jenkins.yml:
- 触发时机:推送 v* tag(main 日常提交不触发),也可手动 dispatch
- 动作:调用 Jenkins 任务 iotlcm-oss-build,参数 SERVICES=<仓库名>、VERSION=<tag>
- 凭据来自组织密钥 JENKINS_URL / JENKINS_USER / JENKINS_API_TOKEN
- 同一 ref 连续推送时取消旧任务
2026-10-30 09:36:31 +08:00
MiFaZhan b4944d7c21 docs: 部署手册镜像地址改为单仓库格式 iotlcm/iotlcm:<服务名>-<版本>
官方仓库把全部镜像放在同一个镜像仓库 iotlcm/iotlcm 下,靠 tag 区分服务:

  .../iotlcm/iotlcm:mysql-8.0
  .../iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4
  .../iotlcm/iotlcm:iotlcm-app-server-v1.0.2

改动页面:
- prerequisites:镜像仓库章节重写,补充命名空间/镜像仓库/tag 三层结构说明,
  不再提 library/ 命名空间
- configuration:镜像地址示例与 tag 规则表
- build-image:构建命令、命名规则表、基础组件同步脚本、验证命令同步改
- offline:离线镜像清单
- split:分离部署示例

已构建通过,6 个相关页面均可访问。
2026-10-30 09:15:42 +08:00
MiFaZhan 247e5b2a60 update: 镜像改为单仓库 iotlcm/iotlcm,tag 为「服务名-版本」
阿里云 ACR 的仓库地址结构是 <Registry>/<命名空间>/<镜像仓库>:<tag>,
此前每个服务单独建了一个镜像仓库(iotlcm/mysql、iotlcm/iotlcm-admin-svr …),
现按要求统一到同一个镜像仓库 iotlcm/iotlcm,服务名放进 tag。

镜像地址变化:
  crpi-.../iotlcm/iotlcm-admin-svr:v1.0.4
  → crpi-.../iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4

17 个镜像(12 业务 + 5 基础组件)已重新打 tag 推送到单仓库并验证。
已验证 docker compose pull 全部拉取成功、14 个默认服务正常启动、
admin-svr/openapi/sms 健康检查 200、数据库 95 表。

Jenkins 流水线同步改为推送到 iotlcm/iotlcm,tag 自动拼接服务名。
2026-10-30 09:12:45 +08:00
MiFaZhan 8543038dad docs: 修正配置文件头部的旧版复制说明,明确 bot 为可选运维工具
- 7 个配置文件的头部仍写着「cp config.example.yaml config.yaml」,
  但该文件已不存在(现为直接可用的 config.yaml),改为准确说明
- config/bot 补充说明:飞书通知是可选运维工具,非平台功能,
  默认不启动;使用需自行在飞书开放平台创建应用,不提供企业凭据
- README 同步:去掉「开源未提供企业凭据」的措辞,改为使用者自备
2026-10-30 08:47:21 +08:00
MiFaZhan bf2656e95f docs: 说明时序库未内置的功能限制 2026-10-29 18:20:05 +08:00
MiFaZhan b60695b0e5 fix: 补齐异步办理恢复表并校正初始化文档 2026-10-29 18:07:50 +08:00
MiFaZhan 57e03233f8 fix: 按管理服务实际加密算法重置种子账号口令 2026-10-29 17:56:46 +08:00
MiFaZhan 94e09fd013 fix: 恢复管理服务缺失的数据库连接配置 2026-10-29 17:32:46 +08:00
MiFaZhan 314e213a5a update: 固定 12 个业务镜像版本并修正部署健康检查 2026-10-29 17:21:59 +08:00
MiFaZhan e6abbf65dd docs: 校正初始化脚本的表和种子数据统计 2026-10-29 17:14:58 +08:00
MiFaZhan 08af8dfdbe fix: 修复通道镜像复制不存在配置文件的构建错误 2026-10-29 17:10:40 +08:00
MiFaZhan 616451b29d update: 数据库初始化为单文件,换用演示环境种子库(v1.0.4 对齐)
原 13 个分散脚本整合为 schema/init.sql 一个文件,来源换成演示环境
正在使用的种子库(结构已与生产核对,89 表基线)。

内容变化(相比旧的 12 文件版本):
- 新增 v1.0.4 前置段:t_export_task(导出中心)、sys_notice_user、
  card_channel_assoc.channel_id 扩宽为 varchar(64)、9 条菜单权限
- 修正 3 个真实缺陷:cron 表达式 L 改为 28-31(调度器可解析)、
  通道模板 created_dept 103 改为 1(悬空机构致列表恒为空)、
  菜单 444 的 path 修正
- 数据库共 91 表(旧版 89,v1.0.4 新增 2 张)

脱敏:
- 18 个演示账号保留(出库/划拨依赖账号与部门树关联),
  昵称/邮箱/手机号/登录IP/头像改为占位或编造值
- 归属部门、租户的联系电话与邮箱一并处理
- 公司名、组织名 → 示例企业
- 内网/生产 IP、内部域名 → 删除或改为服务名
- Mock 通道地址指向 onelink-mock 服务
- 通道模板真实上游密钥置空
- sys_social(微信绑定)不随分发

口令处理:改为纯 SQL,不再用 .sh + 环境变量。
  业务账号:CREATE USER 语句内字面口令
  种子账号:UPDATE sys_user SET password = MD5(CONCAT(MD5('<口令>'), MD5(salt)))
  与 docker-compose.yml、config/*/config.yaml 保持同一口令

已验证:本地 MySQL 8.0 容器按 docker-entrypoint-initdb.d 实际导入,
无报错;表 91、菜单 456、账号 18、字典 86/436、通道模板 3、Mock 通道 1、
导出中心与通知表各 1、定时任务 20;18 条口令哈希自洽;
应用账号 u_iotlcm 可连库查询。
2026-10-29 16:32:27 +08:00
MiFaZhan 3ab3682d14 sync: 同步文档 v1.0.4 并脱敏 2026-10-29 15:45:46 +08:00
MiFaZhan 2d045591b7 sync: 同步生产版 v1.0.4 并脱敏 2026-10-29 15:03:39 +08:00
MiFaZhan 3e5a7453b8 sync: 同步 v1.0.4 开源代码并脱敏 2026-10-29 14:59:32 +08:00
MiFaZhan 6893e21f07 sync: 同步 v1.0.4 开源代码并脱敏 2026-10-29 14:52:43 +08:00
MiFaZhan a8874ca6f2 sync: 同步 v1.0.4 开源代码并脱敏 2026-10-29 14:49:18 +08:00
MiFaZhan 4d43238c63 update: 基础组件镜像改用 iotlcm 命名空间
阿里云企业版 ACR 的命名空间须在控制台预先创建,library 不存在。
统一放到已存在的 iotlcm 命名空间,与业务镜像同处一处:

  <REGISTRY>/iotlcm/mysql:8.0
  <REGISTRY>/iotlcm/redis:7.2
  <REGISTRY>/iotlcm/rabbitmq:4.3.5-management
  <REGISTRY>/iotlcm/consul:1.22
  <REGISTRY>/iotlcm/nginx:1.30

已实测推送成功。
2026-10-29 14:44:56 +08:00
MiFaZhan 5299c13c1a sync: 同步 OneLink 模拟服务 v1.0.4 并脱敏 2026-10-29 14:25:38 +08:00
MiFaZhan 24ae53bc0b sync: 同步飞书机器人 v1.0.4 并脱敏 2026-10-29 14:19:44 +08:00
MiFaZhan e6997f51ed sync: 同步短信服务 v1.0.4 并脱敏 2026-10-29 14:17:14 +08:00
MiFaZhan fed9459244 fix: 删除从 builder 拷贝 zoneinfo 的指令
原 Dockerfile 依赖私有基础镜像预装的 tzdata;换成公共 golang:alpine 后
builder 阶段不存在 /usr/share/zoneinfo/Asia/Shanghai,构建报
'failed to compute cache key: not found'。

runtime 阶段已 apk add tzdata + ENV TZ=Asia/Shanghai,该 COPY 多余,直接删除。
2026-10-29 13:42:19 +08:00
MiFaZhan 14f4bb64e1 fix: 删除从 builder 拷贝 zoneinfo 的指令
原 Dockerfile 依赖私有基础镜像预装的 tzdata;换成公共 golang:alpine 后
builder 阶段不存在 /usr/share/zoneinfo/Asia/Shanghai,构建报
'failed to compute cache key: not found'。

runtime 阶段已 apk add tzdata + ENV TZ=Asia/Shanghai,该 COPY 多余,直接删除。
2026-10-29 13:42:18 +08:00
MiFaZhan 32e4316ee4 fix: 删除从 builder 拷贝 zoneinfo 的指令
原 Dockerfile 依赖私有基础镜像预装的 tzdata;换成公共 golang:alpine 后
builder 阶段不存在 /usr/share/zoneinfo/Asia/Shanghai,构建报
'failed to compute cache key: not found'。

runtime 阶段已 apk add tzdata + ENV TZ=Asia/Shanghai,该 COPY 多余,直接删除。
2026-10-29 13:42:18 +08:00
MiFaZhan 6c26f7a0e1 fix: 删除从 builder 拷贝 zoneinfo 的指令
原 Dockerfile 依赖私有基础镜像预装的 tzdata;换成公共 golang:alpine 后
builder 阶段不存在 /usr/share/zoneinfo/Asia/Shanghai,构建报
'failed to compute cache key: not found'。

runtime 阶段已 apk add tzdata + ENV TZ=Asia/Shanghai,该 COPY 多余,直接删除。
2026-10-29 13:42:17 +08:00
MiFaZhan f42c9b29f6 fix: Dockerfile 基础镜像改为公共仓库,移除私有仓库依赖
- FROM 改为 ${BASE_REGISTRY}/<镜像>:<版本>,默认 docker.io/library
  内网可传 --build-arg BASE_REGISTRY=<自有仓库>/library 覆盖
- iotlcm-app: 删除 ENV GOPRIVATE=git.xiujie.cn
- iotlcm-app: VITE_MP_SERVER_BASEURL 默认值由内网域名改为 /app/api/v1
- Alpine 基础镜像固定到 3.23(原来未锁版本)
2026-10-29 13:33:24 +08:00
MiFaZhan c49b2bf404 fix: 修正阿里云镜像仓库域名拼写(ohlkkvgc) 2026-10-29 13:14:25 +08:00
MiFaZhan fd72d088fc fix: 修正阿里云镜像仓库域名(crpi-...ohlkkvgc,原为 ohlLkvgc 拼写错误)
17 处镜像地址全部更正。已验证:域名解析正常(47.108.26.223),
/v2/ 返回 401(可访问,需登录)。
2026-10-29 13:14:09 +08:00
MiFaZhan 73adad97f3 docs: 部署手册适配一键部署,去掉环境变量与示例配置
部署仓已改为开箱即用:config/ 直接是配置文件、口令等值内置在
docker-compose.yml、不再有 .env。

同步调整:
- 概览:部署流程改为 git clone + docker compose up -d 三步
- 配置说明:重写为「编排层 + 应用层」,说明为何不用环境变量
  (GoFrame 不展开配置文件中的 ${VAR})、改口令需同步三处、
  口令不能含 @ : / 等字符
- 启动与验证:去掉准备步骤,schema 脚本数改为 13 个
- 环境准备:镜像仓库改为描述编排文件中的实际地址
- 升级备份/离线/分离/自建镜像:.env 相关操作改为修改编排文件
2026-10-29 09:20:09 +08:00
MiFaZhan 5ddad0c5e5 feat: 一键部署——配置内置默认值,去掉示例复制与环境变量
目标:git clone 后 docker compose up -d 直接可用,无需任何手工准备。

变更:
- config.example/ → config/,config.example.yaml → config.yaml
  不再需要 cp -r 与逐份修改
- 删除 .env / .env.example,值全部内置到 docker-compose.yml
  (GoFrame 不展开配置文件中的 ${VAR},环境变量方案在 Go 侧不可用)
- 配置文件里的 ${VAR} 全部替换为字面默认值
- schema/04-users.sh 与 05-reset-passwords.sh 改为脚本内字面口令,
  不再读取环境变量
- 新增 schema/13-seed-flow-package.sql 占位文件,说明套餐表为空的原因与补齐方式
- .gitignore 不再排除 config/ 与 .env(已无 .env)
- README 改为「一键启动」+「上线前必改」两张表

已验证:docker compose config 通过;compose 口令、各配置文件 DSN、
初始化脚本三处口令哈希一致;库名与 schema 中一致。
2026-10-29 09:17:26 +08:00
Dai0814 f169afc1bc docs: 补充其他部署方式(二进制、自建镜像、分离、离线)
新增「其他部署方式」分组,共 5 页:
- 方式对比与选型:5 种方式对照、服务依赖表、服务间通信易错点
- 二进制部署:各服务 Go 版本/CGO/构建目录/产物名对照表,
  admin-svr 需 CGO 的说明,交叉编译限制,systemd unit 模板,
  静态前端交给 nginx,升级回滚流程
- 自建镜像:各仓 Dockerfile 位置与构建上下文、构建参数、
  镜像命名规则、批量构建脚本、常见问题
- 分离部署:中间件独立,业务机精简 compose,配置改为外部地址
- 离线部署:镜像导出导入、校验、内网镜像仓库方案

同时记录一个实际阻塞:openapi/app/admin-ui 共 5 个 Dockerfile
的 FROM 仍指向私有镜像仓库,外部环境无法构建,页内给出替换方法。
2026-10-28 14:20:06 +08:00
MiFaZhan 0bfc0ebe80 docs: README 去掉对文档站的外链,补可选服务启动命令与 LICENSE
- 移除「文档」章节:手动归入文档站后,README 不再引用任何外部仓库地址
- 补三个可选 profile 的启动命令
- 补 Apache-2.0 LICENSE(与其余开源仓一致)
2026-10-28 14:02:44 +08:00
MiFaZhan 249b1a03a9 docs: 移除 docs/ 目录,部署手册归入文档站
部署手册属于文档站(iotlcm-public/iotlcm-docs)的 /deploy/ 章节,
不应放在部署仓里。本仓保留 README 作为快速开始与索引。
2026-10-28 14:01:06 +08:00
Dai0814 e43640d0fe docs: 部署手册改为平台部署,替换原文档站自身部署说明
原 /deploy/ 内容是文档站自身(VitePress/Node/nginx)的部署说明,
与用户所需的「IotLCM 平台部署手册」不符。

改为基于 iotlcm-public/iotlcm-deploy 部署仓的平台部署手册:
- 部署概览:部署构成、三步流程、端口规划、关键约束
- 环境准备:硬件分档、系统与 Docker 版本、镜像仓库、防火墙、可选外部依赖
- 配置说明:.env 必填 8 项与可选 4 项、10 份应用配置逐项说明
- 启动与验证:首次初始化流程、7 步验证清单、首次登录必做项
- 可选服务:运营商挡板 onelink-mock、飞书 bot、反向代理(含 HTTPS)
- 升级与备份:版本固定、升级回滚、备份与恢复、日志清理、监控指标
- 故障排查:启动/功能/性能/网络四类共 20 个问题

删除原 6 页(docker-nginx / path-prefix / static-hosting / local-dev /
offline / faq),侧边栏同步改为「部署手册 + 运维」两组。
2026-10-28 14:00:58 +08:00
MiFaZhan 503fe59fe7 docs: 部署文档
- README:项目概览、快速开始、目录结构、重要提示
- 01 环境准备:硬件/系统/Docker 版本、镜像仓库、端口、防火墙、可选外部服务
- 02 配置说明:.env 必填与可选项、10 份应用配置逐项说明、启动前自检
- 03 启动与验证:首次初始化流程、状态检查、6 步验证清单、首次登录必做项
- 04 可选服务:挡板 onelink-mock、飞书 bot、自带 nginx(含 HTTPS)、组合启动
- 05 升级与备份:版本固定、升级回滚、备份脚本与恢复、日志清理、监控指标
- 06 故障排查:启动/功能/性能/网络四类共 20 个问题
2026-10-28 13:54:39 +08:00
MiFaZhan bc2c7bcc6b init: IotLCM 开源版部署仓
内容:
- docker-compose.yml:14 个基础/业务服务 + 3 个可选 profile(with-mock 挡板、with-bot、with-nginx)
- .env.example:集中管理镜像仓库、口令、端口;必填项缺失时 compose 直接报错退出
- config.example/:10 份配置模板,全部为占位值与注释说明
- schema/:12 个初始化脚本,按文件名顺序由 MySQL initdb 自动执行

脱敏处理:
- 生产/内网 IP、镜像仓库地址、公司名、真实域名全部清除
- 演示账号保留 18 个(出库与划拨依赖账号与部门树的关联),
  昵称/邮箱/手机号/登录IP/头像改为占位或编造值
- 数据库账号与 admin 口令改为初始化时按 .env 注入计算
- 通道模板与 Mock 通道的上游密钥置空
- Mock 通道地址指向 compose 内 onelink-mock 服务

部署时用 --profile with-mock 启动运营商接口挡板,即可在无真实运营商环境时跑通全链路。
2026-10-28 13:40:12 +08:00
Dai0814 22a177c569 docs: 部署手册拆分为多页,左侧形成三层目录
原单页塞了 13 个二级标题、右侧目录过长。拆成 7 页:
- 概览(含选型指引与环境对照表)
- 路径前缀必读(独立成页,最高频故障)
- 推荐环境:Docker + nginx
- 静态托管部署(nginx 子路径/根路径、Apache、Caddy、对象存储、pnpm)
- 内网与离线部署(4 种方案 + 对比表)
- 本地开发与预览(含新增页面步骤)
- 常见问题(部署/构建/运行三类 + 自检清单)

侧边栏分为三组:部署手册 / 部署方式 / 开发与排错
2026-10-26 17:12:18 +08:00
Dai0814 07ddaa9968 docs: 新增部署手册页并修复容器部署白屏问题
- 新增 docs/deploy/ 部署手册:推荐环境(Node 22 / nginx / Docker)、
  编译方法(npm ci + build)、四类部署方式(Docker+nginx 推荐路径、
  静态服务器、对象存储/CDN、内网离线),含路径前缀要点与环境对照表
- 修复 docker/nginx.conf:base=/docs/ 使产物资源引用为绝对路径,
  原配置从根路径服务导致资源全部 404(白屏)。改用 alias 剥离前缀,
  并加 / -> /docs/ 重定向
- README 补部署指引与路径前缀告警,修正 dev 入口地址为 /docs/
- 导航与首页增加「部署手册」入口
2026-10-26 16:51:33 +08:00
Dai0814 846bac3d6c docs: publish API examples from open-source 2026-10-26 16:19:42 +08:00
Dai0814 37a1d00f01 docs: update API endpoint examples safely
Use the documented API host and replace credential fragments with placeholders.
2026-10-26 16:12:43 +08:00
Dai0814 19455e1cbc docs: sync reviewed public manuals
Sync manuals, navigation and screenshots from open-source c8fa783 while preserving public README, LICENSE and history.
2026-10-26 16:10:40 +08:00
MiFaZhan 92f239fcc4 chore: 初始开源发布 2026-10-26 15:30:14 +08:00
MiFaZhan 88dfe5c640 chore: 初始开源发布
iotlcm-openapi:对外 OpenAPI 网关(V3 接口 + 旧版兼容接口)。

- Go module:iotlcm-openapi → git.5iot.com/iotlcm-public/iotlcm-openapi(178 文件)
- 凭据与地址脱敏:JWT signingKey、MySQL DSN、Redis 口令、
  RabbitMQ DSN 全部改为 ${*} 占位;私网 IP → 文档地址段
- 7 处真实手机号 → 合法 11 位编造号
- 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名
- 移除内部资产:CI 触发器
- Dockerfile 改用公共基础镜像,移除 GOPRIVATE 内网设置
- README 重写(原为 GoFrame 模板占位文案),补 Apache-2.0 LICENSE,
  LF 行尾 + .gitattributes
2026-10-23 14:15:47 +08:00
MiFaZhan 2bec257289 chore: 初始开源发布
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
2026-10-22 16:37:56 +08:00
MiFaZhan 5732bf3384 chore: 初始开源发布
iotlcm-bot:独立飞书应用机器人(读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片)。

- Go module:git.5iot.com/iotlcm/iotlcm-bot → git.5iot.com/iotlcm-public/iotlcm-bot
- 飞书凭据占位:appId/appSecret/chatId → ${FEISHU_APP_ID}/${FEISHU_APP_SECRET}/${FEISHU_CHAT_ID}
- 移除内部资产:CI 触发器
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → scratch)
- README 重写(命令/配置/挂载契约/常见问题),去掉内部环境路径
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes
2026-10-22 16:14:24 +08:00
MiFaZhan 82466d587d chore: 初始开源发布
iotlcm-upstream-proxy:渠道缓存与转发网关。

- 内联原私有依赖:yunze-public → internal/pub/(18 文件),
  yunze-pb/pb → internal/pb/(9 文件);Consul 注册改用官方
  gogf/gf/contrib/registry/consul/v2(API 兼容)
- Go module:yz-svr → git.5iot.com/iotlcm-public/iotlcm-upstream-proxy
- 凭据与内部标识脱敏:MySQL/Redis/Consul 凭据、私网与生产 IP、内网域名
- 移除内部资产:CI 触发器
- Dockerfile 改用公共基础镜像,移除写死的 registry 凭据
- 补 Apache-2.0 LICENSE,重写 README,LF 行尾 + .gitattributes
2026-10-22 16:08:08 +08:00
MiFaZhan 96b1edff45 chore: 初始开源发布
iotlcm-upstream-chan:运营商上游通道适配服务(统一 /sim/* 协议)。

- 内联原私有依赖:yunze-public → internal/pub/(10 包 18 文件),
  yunze-pb/pb → internal/pb/(9 文件);Consul 注册改用官方
  gogf/gf/contrib/registry/consul/v2(API 兼容)
- Go module:yz-svr → git.5iot.com/iotlcm-public/iotlcm-upstream-chan
- 修复 mingqian 包测试:实现重构后 `adapter.operations` 字段已移除,
  测试断言按当前实现语义重写(不改产品行为)
- 凭据与内部标识脱敏:MySQL/Redis/Consul 凭据、私网与生产 IP、
  内网域名、真实手机号、注释里的真实 token/transid
- 移除内部资产:CI 触发器、生产通道种子 SQL、真实凭据测试资源文档
- Dockerfile 改用公共基础镜像,移除写死的 registry 凭据
- 补 Apache-2.0 LICENSE,重写 README,LF 行尾 + .gitattributes
2026-10-22 16:08:06 +08:00
MiFaZhan 4e2cd43a55 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
2026-10-22 15:08:27 +08:00
MiFaZhan 4fb1016977 chore: 初始开源发布
iotlcm-sms:独立短信 HTTP/gRPC 服务,支持阿里云与腾讯云。

- Go module 迁移至 git.5iot.com/iotlcm-public/iotlcm-sms
- 补 Apache-2.0 LICENSE,重写 README(特性/配置/接口/部署/目录/测试)
- 配置改为 config.example.yaml + SMS_* 环境变量注入,仓库不含真实凭证
- 清理内部标识(私有镜像仓库 / 公司名 / 客户签名 / 真实模板ID / 真实手机号)
- 统一 LF 行尾并加 .gitattributes
2026-10-22 14:21:47 +08:00
1584 changed files with 189760 additions and 743 deletions
+10 -1
View File
@@ -4,11 +4,20 @@
# 二进制文件不做行尾转换
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.pdf binary
*.zip binary
*.gz binary
*.tar binary
*.exe binary
*.xlsx binary
*.so binary
*.dll binary
*.dylib binary
*.jar binary
@@ -1,6 +1,6 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 把本仓镜像到 Gitee 企业仓 <GITEE_OWNER>/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
@@ -27,21 +27,37 @@ jobs:
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
# 仓库名取当前仓;取不到时回退到单仓名
REPO_NAME="${GITHUB_REPOSITORY#*/}"
if [ -z "${REPO_NAME:-}" ] || [ "$REPO_NAME" = "${GITHUB_REPOSITORY:-}" ]; then
REPO_NAME="iotlcm"
fi
SRC="git.5iot.com/iotlcm-public/${REPO_NAME}.git"
DST="gitee.com/${DST_OWNER}/${REPO_NAME}.git"
echo "源:$SRC"
echo "目标:$DST"
# Gitee 不支持 push 建仓,目标仓不存在时先按企业 API 创建(已存在会报 400,忽略)
if ! git ls-remote "https://${DST_USER}:${DST_TOKEN}@${DST}" >/dev/null 2>&1; then
echo "目标仓不存在,尝试创建 ${DST_OWNER}/${REPO_NAME}"
curl -sS -X POST \
"https://gitee.com/api/v5/enterprises/${DST_OWNER}/repos?access_token=${DST_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"${REPO_NAME}\",\"auto_init\":false,\"description\":\"iotLCM 物联网卡全生命周期管理平台(开源版)\"}" \
-o /tmp/repo-create.json -w '建仓 HTTP %{http_code}\n' || true
head -c 300 /tmp/repo-create.json 2>/dev/null; echo
else
echo "目标仓已存在"
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
git clone --mirror "https://jenkins:${SRC_TOKEN}@${SRC%/}" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
@@ -51,7 +67,7 @@ jobs:
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@${DST}" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
@@ -1,12 +1,18 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
# 单仓多服务:一次 tag 构建全部服务,镜像 tag 形如 iotlcm-<服务>-<版本>。
# main 分支的日常提交不触发构建。
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
services:
description: '要构建的服务(逗号分隔,如 sms,docs;留空 = 全部)'
required: false
default: ''
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
@@ -23,12 +29,12 @@ jobs:
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
INPUT_SERVICES: ${{ github.event.inputs.services }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
# 只有 tag 推送才构建;手动触发时按当前 ref 取 tag 名
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
@@ -44,12 +50,16 @@ jobs:
exit 1
fi
# 单仓:默认整仓构建;手动触发可指定服务子集(目录名或其 iotlcm-* 镜像名)
SERVICES="${INPUT_SERVICES:-}"
if [ -z "$SERVICES" ]; then SERVICES="all"; fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 workflow。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
echo "仓库:${GITHUB_REPOSITORY:-iotlcm} 版本:$TAG_NAME 服务:$SERVICES job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
@@ -58,10 +68,10 @@ jobs:
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
# 触发参数化任务
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "SERVICES=$SERVICES" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
+22
View File
@@ -0,0 +1,22 @@
# 编辑器与系统文件
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# 依赖与构建产物(各子目录另有更细的 .gitignore)
node_modules/
dist/
dist-ssr/
.output/
.nitro/
out/
bin/
# 日志与临时文件
*.log
*.tmp
# 运行数据(deploy 首次启动自动创建)
data/
View File
+70
View File
@@ -0,0 +1,70 @@
# iotLCM 开源版 —— 常用构建入口
#
# make build 编译全部 Go 服务
# make test 跑全部 Go 模块的单元测试
# make images 本地构建全部镜像(默认 tag 用 git describe)
# make docs 本地构建文档站
# make clean 清理构建产物
#
# 镜像仓库与版本可覆盖:
# make images REGISTRY=registry.example.com/iotlcm/iotlcm VERSION=v1.0.4
REGISTRY ?= iotlcm
VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || git rev-parse --short HEAD)
BASE_REGISTRY ?= docker.io/library
GO_MODULES := admin-svr/server app/server bot onelink-mock openapi sms upstream-chan upstream-proxy worker-svr
# 目录|镜像名|构建上下文|Dockerfile
IMAGE_MAP := \
admin-svr|iotlcm-admin-svr|admin-svr/server|build/Dockerfile \
admin-ui|iotlcm-admin-ui|admin-ui|vue/build/Dockerfile \
openapi|iotlcm-openapi|openapi|build/Dockerfile \
worker-svr|iotlcm-worker-svr|worker-svr|build/Dockerfile \
upstream-chan|iotlcm-upstream-chan|upstream-chan|build/Dockerfile \
upstream-proxy|iotlcm-upstream-proxy|upstream-proxy|build/Dockerfile \
app|iotlcm-app-server|app/server|Dockerfile \
app|iotlcm-app-web|app/app|Dockerfile \
sms|iotlcm-sms|sms|build/Dockerfile \
bot|iotlcm-bot|bot|Dockerfile \
docs|iotlcm-docs|docs|Dockerfile \
onelink-mock|iotlcm-onelink-mock|onelink-mock|Dockerfile
.PHONY: help build test images docs clean
help:
@sed -n '2,12p' Makefile | sed 's/^# \{0,1\}//'
build:
@set -e; for m in $(GO_MODULES); do \
echo "==> go build ./... ($$m)"; \
( cd "$$m" && go build ./... ); \
done
@echo "✅ 全部 Go 服务编译通过"
test:
@set -e; for m in $(GO_MODULES); do \
echo "==> go test ./... ($$m)"; \
( cd "$$m" && go test ./... ); \
done
images:
@set -e; echo "镜像前缀: $(REGISTRY) 版本: $(VERSION)"; \
echo "$(IMAGE_MAP)" | tr ' ' '\n' | while IFS='|' read -r dir img ctx df; do \
[ -z "$$dir" ] && continue; \
echo ""; echo "==> $$img:$$VERSION (context=$$ctx dockerfile=$$df)"; \
docker build -f "$$ctx/$$df" \
--build-arg BASE_REGISTRY="$(BASE_REGISTRY)" \
--build-arg BuildVersion="$(VERSION)" \
--build-arg CommitID="$$(git rev-parse --short=7 HEAD)" \
--build-arg BuildTime="$$(date -u +%FT%TZ)" \
-t "$(REGISTRY):$$img-$(VERSION)" "$$ctx"; \
done
docs:
cd docs && npm ci && npm run build
@echo "✅ 文档站产物:docs/.vitepress/dist"
clean:
@rm -rf docs/.vitepress/dist docs/.vitepress/cache
@echo "✅ 已清理"
+88
View File
@@ -0,0 +1,88 @@
# iotLCM —— 物联网卡全生命周期管理平台(开源版)
一站式物联网 SIM 卡管理平台:卡片从运营商渠道入库 → 划拨给代理商 → 激活 / 续费 / 停机 → 流量与状态同步 → 销户。对接中国移动 OneLink、中国电信 5G CMP、中国联通 10646 等运营商接口,并通过共享 Token 模式接入多家三方渠道商。
**单仓多服务**:平台全部后端服务、前端应用、文档站与部署编排都在本仓,一次 clone 即可获得完整源码。
## 目录
| 目录 | 说明 | 镜像 |
| --- | --- | --- |
| `admin-svr/` | 管理端后端(GoFrame):卡信息、通信管理、资费、告警工单、统计、账号治理 | `iotlcm-admin-svr` |
| `admin-ui/` | 管理端前端(Vue 3 + Vben Admin) | `iotlcm-admin-ui` |
| `openapi/` | 对外 API 网关:V3 接口、旧版兼容、运营商能力代理 | `iotlcm-openapi` |
| `worker-svr/` | 异步执行:RabbitMQ 消费者、全量同步、批量任务 | `iotlcm-worker-svr` |
| `upstream-chan/` | 运营商 / 三方渠道对接层(OneLink、CMP、10646、共享 Token) | `iotlcm-upstream-chan` |
| `upstream-proxy/` | 通道缓存转发层(挡在 chan 前面的本地缓存) | `iotlcm-upstream-proxy` |
| `app/server/` | 用户端服务(GoFrame) | `iotlcm-app-server` |
| `app/app/` | 用户端前端(uni-app,基于 unibest,MIT) | `iotlcm-app-web` |
| `sms/` | 短信 HTTP / gRPC 服务(阿里云、腾讯云) | `iotlcm-sms` |
| `bot/` | 飞书通知机器人(运维自用,可选) | `iotlcm-bot` |
| `docs/` | 文档站(VitePress):使用手册 + OpenAPI 合同 + 部署手册 | `iotlcm-docs` |
| `onelink-mock/` | OneLink 接口挡板(联调 / 演示,可选) | `iotlcm-onelink-mock` |
| `deploy/` | Docker Compose 编排与数据库初始化 | 参考 `deploy/README.md` |
每个子目录都有自己的 `README.md`,说明该服务的职责、配置项与本地运行方式。
## 快速开始(只跑起来)
不需要编译,直接用预构建镜像拉起整栈:
```bash
git clone <本仓库地址> iotlcm
cd iotlcm/deploy
docker compose up -d
```
首次启动自动完成 MySQL 建库建表(95 张)、种子数据导入、时序库建表,实测 30 秒 ~ 1 分钟。
默认账号 `admin`,口令见 `deploy/README.md`(**正式使用前必须修改**)。详细的端口、配置项、可选服务(挡板 / 飞书通知 / 反向代理)、离线部署与排错,见 `deploy/README.md` 与文档站 `docs/deploy/`。
> 对外端口默认只监听 `127.0.0.1`,需要局域网或公网访问时,用 `deploy/` 里的 nginx 编排改绑定地址,或并入宿主机已有的反向代理。
## 从源码构建
每个 Go 服务是独立模块(各自带 `go.mod`),在对应目录内构建:
```bash
cd worker-svr && go build -o out/ . # 其它 Go 服务同理
```
前端:
```bash
cd admin-ui/vue && pnpm install && pnpm build
cd app/app && pnpm install && pnpm build
cd docs && npm ci && npm run build
```
或一键编译检查全部 Go 模块:
```bash
make build # 所有 Go 服务编译
make images # 本地构建全部镜像
```
构建镜像所需的 Dockerfile 位置与构建上下文,见 `docs/docs/deploy/build-image.md`。
## 发布与镜像
所有镜像发布到**同一个镜像仓库**,用 tag 区分服务与版本:
```text
<镜像仓库>/iotlcm/iotlcm:iotlcm-worker-svr-v1.0.4
└─ 仓库 ┘ └─ 服务名 ─┘└版本┘
```
打 `v*` tag 会经由内网 CI 构建全部镜像并推送,见 `.gitea/workflows/`。
## 许可
本仓整体采用 [Apache License 2.0](LICENSE)。
以下子目录基于上游 MIT 项目衍生,保留其原始许可:
| 目录 | 上游 | 许可 |
| --- | --- | --- |
| `admin-ui/` | [Vben Admin](https://github.com/vbenjs/vue-vben-admin) | MIT,见 `admin-ui/LICENSE` |
| `app/app/` | [unibest](https://github.com/unibest-tech/unibest) | MIT,见 `app/app/LICENSE` |
@@ -1,64 +0,0 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
# Gitea Actions 的 cron 用 UTC:18:30 UTC = 次日 02:30 北京时间
- cron: '30 18 * * *'
workflow_dispatch:
concurrency:
group: mirror-${{ github.ref }}
cancel-in-progress: true
jobs:
mirror:
runs-on: host
steps:
- name: 同步到 Gitee
env:
SRC_TOKEN: ${{ secrets.SRC_GIT_TOKEN }}
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
# Gitee 在推送密集时会返回 429 Pending queue overflowed / 408 Authorization server timeout,
# 属服务端限流,退避重试即可。
attempt=0
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
wait_s=$((attempt * 30))
echo "推送失败,${wait_s}s 后重试"
sleep "$wait_s"
done
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
exit 1
@@ -1,80 +0,0 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
on:
push:
tags:
- 'v*'
workflow_dispatch:
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
trigger:
runs-on: host
steps:
- name: 触发 Jenkins 镜像构建
env:
JU: ${{ secrets.JENKINS_USER }}
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
echo "非 tag 推送,跳过:${GITHUB_REF:-unknown}"
exit 0
fi
;;
esac
if [ -z "${TAG_NAME:-}" ]; then
echo "未取到 tag 名,退出"
exit 1
fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# 取 CSRF crumb
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
echo "Jenkins 触发 HTTP $code"
if [ "$code" != "201" ] && [ "$code" != "200" ] && [ "$code" != "302" ] && [ "$code" != "303" ]; then
echo "触发失败:"
cat "$WORK/out"
exit 1
fi
LINK="$JURL/$JP/"
echo "=========================================="
echo "Jenkins 构建:$LINK"
echo "=========================================="
echo "::notice title=Jenkins 镜像构建::[$LINK]($LINK)"
+6 -6
View File
@@ -2,7 +2,7 @@
iotLCM 物联网卡全生命周期管理平台的**管理后台服务** —— 承载客户门户、卡信息管理、通信管理、资费管理、告警工单、统计分析与系统治理。
配套前端:[iotlcm-admin-ui](https://git.5iot.com/iotlcm-public/iotlcm-admin-ui)。
配套前端:同仓 [`admin-ui/`](../admin-ui)。
## 特性
@@ -11,7 +11,7 @@ iotLCM 物联网卡全生命周期管理平台的**管理后台服务** ——
- **资费与交易**:套餐、订单、账单、余额账户与结算批次
- **告警与工单**:报警、待办、故障、工单与规则引擎
- **账号治理**:组织、用户、角色、权限与 API 凭证
- **异步协作**:耗时操作投递到 RabbitMQ,由 [iotlcm-worker-svr](https://git.5iot.com/iotlcm-public/iotlcm-worker-svr) 执行
- **异步协作**:耗时操作投递到 RabbitMQ,由同仓 [`worker-svr/`](../worker-svr) 执行
- **定时任务**:`sys_job` 表驱动的调度(流量同步、状态同步、账单等)
## 技术栈
@@ -53,7 +53,7 @@ http://127.0.0.1:8199/swagger # SwaggerUI
`server/storage/data/xiu_admin.sql` 是**框架基座初始化数据**(系统菜单、字典、角色、示例管理员),是运行起点的必要输入。
> **数据库 schema 不随本仓发布** —— 生产库的完整 DDL 已过时(见《项目清单》决策)。请按需自建表结构,或参考 [iotlcm-docs](https://git.5iot.com/iotlcm-public/iotlcm-docs) 中的接口契约。
> **表结构以部署编排为准** —— 开箱可用的建库建表脚本在同仓 [`deploy/schema/init.sql`](../deploy/schema/init.sql)(95 张表 + 种子数据),接口契约见同仓 [`docs/`](../docs)。
## 目录结构
@@ -93,9 +93,9 @@ go test ./...
## 相关项目
- [iotlcm-admin-ui](https://git.5iot.com/iotlcm-public/iotlcm-admin-ui) —— 管理前端
- [iotlcm-openapi](https://git.5iot.com/iotlcm-public/iotlcm-openapi) —— 对外 API 网关
- [iotlcm-worker-svr](https://git.5iot.com/iotlcm-public/iotlcm-worker-svr) —— 异步执行
- [`admin-ui/`](../admin-ui) —— 管理前端
- [`openapi/`](../openapi) —— 对外 API 网关
- [`worker-svr/`](../worker-svr) —— 异步执行
## License
-14
View File
@@ -1,14 +0,0 @@
# 统一使用 LF,避免跨平台协作时产生整文件 diff
* text=auto eol=lf
# 二进制文件不做行尾转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.pdf binary
*.exe binary
*.xlsx binary
@@ -1,80 +0,0 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
on:
push:
tags:
- 'v*'
workflow_dispatch:
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
trigger:
runs-on: host
steps:
- name: 触发 Jenkins 镜像构建
env:
JU: ${{ secrets.JENKINS_USER }}
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
echo "非 tag 推送,跳过:${GITHUB_REF:-unknown}"
exit 0
fi
;;
esac
if [ -z "${TAG_NAME:-}" ]; then
echo "未取到 tag 名,退出"
exit 1
fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# 取 CSRF crumb
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
echo "Jenkins 触发 HTTP $code"
if [ "$code" != "201" ] && [ "$code" != "200" ] && [ "$code" != "302" ] && [ "$code" != "303" ]; then
echo "触发失败:"
cat "$WORK/out"
exit 1
fi
LINK="$JURL/$JP/"
echo "=========================================="
echo "Jenkins 构建:$LINK"
echo "=========================================="
echo "::notice title=Jenkins 镜像构建::[$LINK]($LINK)"
+1 -1
View File
@@ -2,7 +2,7 @@
iotLCM 物联网卡全生命周期管理平台的**管理前端** —— 卡资产、通信管理、资费交易、告警工单、统计分析、账号治理。
配套后端:[iotlcm-admin-svr](https://git.5iot.com/iotlcm-public/iotlcm-admin-svr)。
配套后端:同仓 [`admin-svr/`](../admin-svr)。
## 上游与许可
-14
View File
@@ -1,14 +0,0 @@
# 统一使用 LF,避免跨平台协作时产生整文件 diff
* text=auto eol=lf
# 二进制文件不做行尾转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.pdf binary
*.exe binary
*.xlsx binary
-64
View File
@@ -1,64 +0,0 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
# Gitea Actions 的 cron 用 UTC:18:30 UTC = 次日 02:30 北京时间
- cron: '30 18 * * *'
workflow_dispatch:
concurrency:
group: mirror-${{ github.ref }}
cancel-in-progress: true
jobs:
mirror:
runs-on: host
steps:
- name: 同步到 Gitee
env:
SRC_TOKEN: ${{ secrets.SRC_GIT_TOKEN }}
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
# Gitee 在推送密集时会返回 429 Pending queue overflowed / 408 Authorization server timeout,
# 属服务端限流,退避重试即可。
attempt=0
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
wait_s=$((attempt * 30))
echo "推送失败,${wait_s}s 后重试"
sleep "$wait_s"
done
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
exit 1
-201
View File
@@ -1,201 +0,0 @@
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.
+1 -1
View File
@@ -5,7 +5,7 @@ iotLCM 物联网卡全生命周期管理平台的**小程序端 + 用户端服
- `app/` —— uni-app 小程序前端(基于 [unibest](https://github.com/unibest-tech/unibest),MIT)
- `server/` —— 用户端 GoFrame 服务(基于 XiuAdmin 框架,Apache-2.0)
配套管理后台:[iotlcm-admin-svr](https://git.5iot.com/iotlcm-public/iotlcm-admin-svr) / [iotlcm-admin-ui](https://git.5iot.com/iotlcm-public/iotlcm-admin-ui)
配套管理后台:同仓 [`admin-svr/`](../admin-svr) / [`admin-ui/`](../admin-ui)
## 特性
-201
View File
@@ -1,201 +0,0 @@
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.
+12
View File
@@ -0,0 +1,12 @@
.env
.env.*
!.env.example
bin/
*.exe
coverage.out
/state/
*.log
# Deployment secrets must never be checked into the public repository.
config.yaml
config.*.yaml
!config.example.yaml
+17
View File
@@ -0,0 +1,17 @@
ARG BASE_REGISTRY=docker.io/library
FROM ${BASE_REGISTRY}/golang:1.25-alpine AS build
ARG GOPROXY=https://goproxy.cn|https://goproxy.io|https://proxy.golang.org|direct
ENV GOPROXY=${GOPROXY}
WORKDIR /src
COPY go.mod go.sum ./
RUN apk add --no-cache ca-certificates
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/iotlcm-bot ./cmd/iotlcm-bot
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/iotlcm-bot /usr/local/bin/iotlcm-bot
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/iotlcm-bot"]
+90
View File
@@ -0,0 +1,90 @@
# iotlcm-bot
V1 独立飞书应用机器人:读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片。
## V1 范围
- 只接受当前版本 `version=1` 且包含 `run` 和非空 `rounds` 的归档 JSON。
- 不兼容旧版 `rounds=[]` 文件。
- 不读取 Redis、Worker HTTP 接口或日志。
- 不实现 Replay、事件回调、卡片按钮和错误聚合。
- 按 `runId` 去重,避免重复发送。
## 本地验证
```bash
go test ./...
go run ./cmd/iotlcm-bot render --file testdata/current-run.json
go run ./cmd/iotlcm-bot render --file testdata/current-run.json --json
```
`render` 只渲染通知内容,不调用飞书 API。
## 本地单次发送
机器人加入测试群,且 YAML 配置已填写 `feishu.appSecret` 和 `feishu.chatId` 后,可以发送指定 Run:
```bash
go run ./cmd/iotlcm-bot send --config /path/to/iotlcm-bot.yaml --file /path/to/run.json
```
发送成功后会写入 `state/sent/<runId>.json`;同一 Run 再次执行会跳过。确需重发时使用:
```bash
go run ./cmd/iotlcm-bot send --config /path/to/iotlcm-bot.yaml --file /path/to/run.json --force
```
正常运行时不要使用 `--force`。
## 已部署容器的发送测试
只验证飞书连接时,无需提供 Run JSON。Bot 容器运行后,发送一条自定义文本:
```bash
docker exec iotlcm-bot /usr/local/bin/iotlcm-bot send-text --config /app/config/config.yaml --text "iotlcm-bot 飞书连接测试"
```
`send-text` 不扫描归档、不写 Run 发送标记,可以重复执行。它直接通过飞书消息 API 向配置文件中的群发送文本,不是 HTTP 测试接口。
需要测试完整的 Run 卡片链路时,才使用下面的单次发送命令:
```bash
docker exec iotlcm-bot /usr/local/bin/iotlcm-bot send --config /app/config/config.yaml --file /data/full-sync/run-<runId>.json
```
如该 Run 已发送,明确需要再次发送时加 `--force`:
```bash
docker exec iotlcm-bot /usr/local/bin/iotlcm-bot send --config /app/config/config.yaml --file /data/full-sync/run-<runId>.json --force
```
发送成功会写入 `/data/state/sent/<runId>.json`。检查容器和日志:
```bash
docker ps --filter name=iotlcm-bot
docker logs --tail 100 iotlcm-bot
```
## 配置文件
复制 `config.example.yaml` 为未跟踪的 `config.yaml`,填入实际飞书应用凭据和群 ID。
配置加载器直接读取 YAML,不展开环境变量;请勿把真实凭据提交到仓库。
部署时通过只读挂载将配置注入容器,容器内路径可为 `/app/config/config.yaml`。
## 自动通知
Compose 默认以 `watch` 模式启动 Bot。Bot 每 10 秒扫描挂载的终态 Run JSON,校验通过且未发送过的 Run 会自动发到配置的飞书群。
本地启动方式:
```bash
go run ./cmd/iotlcm-bot watch --config /path/to/iotlcm-bot.yaml
```
## 目录挂载
```text
Worker 宿主归档目录:<host-worker-full-sync-dir>
Bot 只读挂载目录:/data/full-sync
Bot 可写状态目录:<host-bot-state-dir> -> /data/state
```
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/config"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/feishu"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/notify"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/render"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "render":
renderCommand(os.Args[2:])
case "send":
sendCommand(os.Args[2:])
case "send-text":
sendTextCommand(os.Args[2:])
case "watch":
watchCommand(os.Args[2:])
default:
usage()
}
}
func renderCommand(args []string) {
fs := flag.NewFlagSet("render", flag.ExitOnError)
file := fs.String("file", "", "path to a Worker Run JSON")
environment := fs.String("environment", "preview", "environment label")
jsonOutput := fs.Bool("json", false, "print Feishu interactive-card JSON")
fs.Parse(args)
if *file == "" {
fmt.Fprintln(os.Stderr, "render requires --file")
os.Exit(2)
}
a, err := archive.Load(*file)
if err != nil {
fail(err)
}
if *jsonOutput {
body, err := render.Card(a, *environment)
if err != nil {
fail(err)
}
fmt.Println(string(body))
return
}
fmt.Println(render.Text(a, *environment))
}
func sendCommand(args []string) {
fs := flag.NewFlagSet("send", flag.ExitOnError)
file := fs.String("file", "", "path to a Worker Run JSON")
configPath := fs.String("config", "", "path to YAML config")
force := fs.Bool("force", false, "send even when the Run was already marked sent")
fs.Parse(args)
if *file == "" || *configPath == "" {
fmt.Fprintln(os.Stderr, "send requires --file and --config")
os.Exit(2)
}
cfg, err := config.Load(*configPath)
if err != nil {
fail(err)
}
if err := cfg.ValidateSend(); err != nil {
fail(err)
}
client := feishu.NewClient(cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.ChatID)
svc := notify.New(cfg.ArchiveDir, cfg.Environment, cfg.PollDuration, state.New(cfg.StateDir), client, notify.DefaultLogger())
if err := svc.SendFile(context.Background(), *file, *force); err != nil {
fail(err)
}
}
func sendTextCommand(args []string) {
fs := flag.NewFlagSet("send-text", flag.ExitOnError)
configPath := fs.String("config", "", "path to YAML config")
text := fs.String("text", "", "text to send")
fs.Parse(args)
if *configPath == "" || *text == "" {
fmt.Fprintln(os.Stderr, "send-text requires --config and --text")
os.Exit(2)
}
cfg, err := config.Load(*configPath)
if err != nil {
fail(err)
}
if err := cfg.ValidateSend(); err != nil {
fail(err)
}
client := feishu.NewClient(cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.ChatID)
if err := client.SendText(context.Background(), *text); err != nil {
fail(err)
}
fmt.Println("Feishu text message sent")
}
func watchCommand(args []string) {
fs := flag.NewFlagSet("watch", flag.ExitOnError)
configPath := fs.String("config", "", "path to YAML config")
fs.Parse(args)
if *configPath == "" {
fmt.Fprintln(os.Stderr, "watch requires --config")
os.Exit(2)
}
cfg, err := config.Load(*configPath)
if err != nil {
fail(err)
}
if err := cfg.ValidateSend(); err != nil {
fail(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
client := feishu.NewClient(cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.ChatID)
svc := notify.New(cfg.ArchiveDir, cfg.Environment, cfg.PollDuration, state.New(cfg.StateDir), client, notify.DefaultLogger())
if err := svc.Watch(ctx); err != nil && err != context.Canceled {
fail(err)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: iotlcm-bot <render|send|send-text|watch> [options]")
os.Exit(2)
}
func fail(err error) { fmt.Fprintln(os.Stderr, "iotlcm-bot:", err); os.Exit(1) }
+10
View File
@@ -0,0 +1,10 @@
# Copy to config.yaml (untracked) and replace these placeholders before sending.
# The bot reads YAML literally; it does not expand environment variables.
environment: preview
archiveDir: /data/full-sync
stateDir: ./state
pollInterval: 10s
feishu:
appId: "YOUR_FEISHU_APP_ID"
appSecret: "YOUR_FEISHU_APP_SECRET"
chatId: "YOUR_FEISHU_CHAT_ID"
+5
View File
@@ -0,0 +1,5 @@
module git.5iot.com/iotlcm-public/iotlcm-bot
go 1.23
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+159
View File
@@ -0,0 +1,159 @@
package archive
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
type RunArchive struct {
Version int `json:"version"`
Run RunSummary `json:"run"`
Rounds []RoundStats `json:"rounds"`
}
type RunSummary struct {
SupplyObservation json.RawMessage `json:"supplyObservation,omitempty"`
SyncType string `json:"syncType,omitempty"`
RunID string `json:"runId"`
Operation string `json:"operation"`
Status string `json:"status"`
SuccessCount int64 `json:"successCount"`
FailedCount int64 `json:"failedCount"`
CreatedAt string `json:"createdAt"`
CompletedAt string `json:"completedAt"`
}
type RoundStats struct {
RunID string `json:"runId"`
Round int `json:"round"`
EnqueuedCount int64 `json:"enqueuedCount"`
ActiveCount int64 `json:"activeCount"`
NextRoundCount int64 `json:"nextRoundCount"`
SuccessCount int64 `json:"successCount"`
FailedCount int64 `json:"failedCount"`
UpstreamAttemptCount int64 `json:"upstreamAttemptCount"`
BatchCount int64 `json:"batchCount"`
StartedAt string `json:"startedAt"`
CompletedAt string `json:"completedAt"`
ErrorCounts map[string]int `json:"errorCounts"`
}
func Load(path string) (RunArchive, error) {
body, err := os.ReadFile(path)
if err != nil {
return RunArchive{}, err
}
var a RunArchive
if err := json.Unmarshal(body, &a); err != nil {
return RunArchive{}, fmt.Errorf("parse %s: %w", path, err)
}
if err := a.Validate(); err != nil {
return RunArchive{}, fmt.Errorf("validate %s: %w", path, err)
}
if err := ValidateFilename(path, a.Run.RunID); err != nil {
return RunArchive{}, err
}
return a, nil
}
func ValidateFilename(path, runID string) error {
want := "run-" + runID + ".json"
if filepath.Base(path) != want {
return fmt.Errorf("archive filename %q does not match RunID; expected %q", filepath.Base(path), want)
}
return nil
}
func (a *RunArchive) Validate() error {
if a.Version != 1 {
return fmt.Errorf("unsupported version %d", a.Version)
}
if a.Run.RunID == "" || a.Run.Operation == "" {
return fmt.Errorf("runId and operation are required")
}
if a.Run.Status != "completed" && a.Run.Status != "completed_with_failures" {
return fmt.Errorf("run is not terminal: %s", a.Run.Status)
}
if len(a.Rounds) == 0 {
return fmt.Errorf("rounds must be non-empty")
}
copyRounds := append([]RoundStats(nil), a.Rounds...)
sort.Slice(copyRounds, func(i, j int) bool { return copyRounds[i].Round < copyRounds[j].Round })
var success, failed int64
for i, r := range copyRounds {
if r.Round != i+1 {
return fmt.Errorf("round sequence is not contiguous at %d", r.Round)
}
if r.RunID != "" && r.RunID != a.Run.RunID {
return fmt.Errorf("round %d runId does not match root runId", r.Round)
}
if r.EnqueuedCount < 0 || r.ActiveCount < 0 || r.NextRoundCount < 0 || r.SuccessCount < 0 || r.FailedCount < 0 || r.UpstreamAttemptCount < 0 || r.BatchCount < 0 {
return fmt.Errorf("round %d contains negative count", r.Round)
}
if r.EnqueuedCount != r.SuccessCount+r.FailedCount+r.NextRoundCount {
return fmt.Errorf("round %d count does not close", r.Round)
}
if _, err := parseTime(r.StartedAt); err != nil {
return fmt.Errorf("round %d invalid startedAt: %w", r.Round, err)
}
if _, err := parseTime(r.CompletedAt); err != nil {
return fmt.Errorf("round %d invalid completedAt: %w", r.Round, err)
}
success += r.SuccessCount
failed += r.FailedCount
}
if a.Run.SuccessCount != success || a.Run.FailedCount != failed {
return fmt.Errorf("run totals do not match rounds: run success=%d failed=%d, rounds success=%d failed=%d", a.Run.SuccessCount, a.Run.FailedCount, success, failed)
}
if _, err := parseTime(a.Run.CreatedAt); err != nil {
return fmt.Errorf("invalid createdAt: %w", err)
}
if _, err := parseTime(a.Run.CompletedAt); err != nil {
return fmt.Errorf("invalid completedAt: %w", err)
}
return nil
}
func (a RunArchive) TotalCount() int64 { return a.Run.SuccessCount + a.Run.FailedCount }
func (a RunArchive) SuccessRate() float64 {
if a.TotalCount() == 0 {
return 0
}
return float64(a.Run.SuccessCount) / float64(a.TotalCount()) * 100
}
func (a RunArchive) Duration() time.Duration {
start, err1 := parseTime(a.Run.CreatedAt)
end, err2 := parseTime(a.Run.CompletedAt)
if err1 != nil || err2 != nil {
return 0
}
return end.Sub(start)
}
func (r RoundStats) SuccessRate() float64 {
if r.EnqueuedCount == 0 {
return 0
}
return float64(r.SuccessCount) / float64(r.EnqueuedCount) * 100
}
func (r RoundStats) Duration() time.Duration {
start, err1 := parseTime(r.StartedAt)
end, err2 := parseTime(r.CompletedAt)
if err1 != nil || err2 != nil {
return 0
}
return end.Sub(start)
}
func parseTime(value string) (time.Time, error) {
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05.999", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, value); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unsupported time %q", value)
}
+48
View File
@@ -0,0 +1,48 @@
package archive
import "testing"
func validArchive() RunArchive {
return RunArchive{Version: 1, Run: RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed_with_failures", SuccessCount: 175893, FailedCount: 10504, CreatedAt: "2026-09-22T11:42:33Z", CompletedAt: "2026-09-22T12:32:01Z"}, Rounds: []RoundStats{
{RunID: "run-1", Round: 1, EnqueuedCount: 186397, ActiveCount: 0, NextRoundCount: 9023, SuccessCount: 175870, FailedCount: 1504, UpstreamAttemptCount: 0, BatchCount: 1872, StartedAt: "2026-09-22T11:42:34Z", CompletedAt: "2026-09-22T12:21:49Z"},
{RunID: "run-1", Round: 2, EnqueuedCount: 9023, ActiveCount: 0, NextRoundCount: 9000, SuccessCount: 23, FailedCount: 0, UpstreamAttemptCount: 0, BatchCount: 92, StartedAt: "2026-09-22T12:21:51Z", CompletedAt: "2026-09-22T12:26:55Z"},
{RunID: "run-1", Round: 3, EnqueuedCount: 9000, ActiveCount: 0, NextRoundCount: 0, SuccessCount: 0, FailedCount: 9000, UpstreamAttemptCount: 0, BatchCount: 90, StartedAt: "2026-09-22T12:26:56Z", CompletedAt: "2026-09-22T12:32:01Z"},
}}
}
func TestValidateThreeRounds(t *testing.T) {
a := validArchive()
if err := a.Validate(); err != nil {
t.Fatal(err)
}
if len(a.Rounds) != 3 || a.Run.FailedCount != 10504 {
t.Fatalf("unexpected archive: %+v", a)
}
}
func TestValidateRejectsEmptyRounds(t *testing.T) {
a := RunArchive{Version: 1, Run: RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed"}}
if err := a.Validate(); err == nil {
t.Fatal("expected empty rounds to be rejected")
}
}
func TestValidateRejectsUnclosedRound(t *testing.T) {
a := validArchive()
a.Rounds[0].NextRoundCount++
if err := a.Validate(); err == nil {
t.Fatal("expected count closure error")
}
}
func TestValidateFilenameRequiresExactRunID(t *testing.T) {
if err := ValidateFilename(`/archive/run-full-run-id.json`, "full-run-id"); err != nil {
t.Fatal(err)
}
if err := ValidateFilename(`/archive/run-other-id.json`, "full-run-id"); err == nil {
t.Fatal("expected mismatched filename to be rejected")
}
if err := ValidateFilename(`/archive/action-full-run-id.json`, "full-run-id"); err == nil {
t.Fatal("expected non-run filename to be rejected")
}
}
+47
View File
@@ -0,0 +1,47 @@
package archive
import "encoding/json"
type SupplyWait struct {
Count int64 `json:"count"`
TotalMS int64 `json:"totalMs"`
LongestMS int64 `json:"longestMs"`
}
type SupplyChannel struct {
Key string `json:"chanKey"`
Startup SupplyWait `json:"startupWait"`
Confirmed SupplyWait `json:"confirmedWait"`
Unconfirmed SupplyWait `json:"unconfirmedIdle"`
UnknownMS int64 `json:"unknownMs"`
Stages map[string]int64 `json:"producerStageMs"`
}
type SupplySummary struct {
UnknownMS int64 `json:"unknownChannelMs"`
UnconfirmedMS int64 `json:"unconfirmedChannelMs"`
ConsumerMS int64 `json:"consumerStartupChannelMs"`
Targets int `json:"targetChannels"`
Valid int `json:"validChannels"`
Confirmed int `json:"confirmedChannels"`
Unknown int `json:"unknownChannels"`
TotalMS int64 `json:"confirmedChannelMs"`
LongestMS int64 `json:"longestMs"`
}
type Supply struct {
SchemaVersion int `json:"schemaVersion"`
ResolutionMS int `json:"resolutionMs"`
Partial bool `json:"partial"`
Truncated bool `json:"truncated"`
Summary SupplySummary `json:"summary"`
Channels []SupplyChannel `json:"channels"`
}
func (r RunSummary) Supply() (*Supply, bool) {
if len(r.SupplyObservation) == 0 || string(r.SupplyObservation) == "null" {
return nil, false
}
var s Supply
if json.Unmarshal(r.SupplyObservation, &s) != nil || s.SchemaVersion != 1 || s.ResolutionMS != 1000 || s.Summary.Targets < 0 || s.Summary.Valid < 0 || s.Summary.Valid > s.Summary.Targets || s.Summary.TotalMS < 0 || s.Summary.LongestMS < 0 {
return nil, true
}
return &s, true
}
+63
View File
@@ -0,0 +1,63 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Environment string `yaml:"environment"`
ArchiveDir string `yaml:"archiveDir"`
StateDir string `yaml:"stateDir"`
PollInterval string `yaml:"pollInterval"`
Feishu FeishuConfig `yaml:"feishu"`
PollDuration time.Duration `yaml:"-"`
}
type FeishuConfig struct {
AppID string `yaml:"appId"`
AppSecret string `yaml:"appSecret"`
ChatID string `yaml:"chatId"`
}
func Load(path string) (Config, error) {
body, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(body, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config %s: %w", path, err)
}
if cfg.Environment == "" {
cfg.Environment = "preview"
}
if cfg.ArchiveDir == "" {
cfg.ArchiveDir = "/data/full-sync"
}
if cfg.StateDir == "" {
cfg.StateDir = "/data/state"
}
if cfg.PollInterval == "" {
cfg.PollInterval = "10s"
}
cfg.PollDuration, err = time.ParseDuration(cfg.PollInterval)
if err != nil || cfg.PollDuration <= 0 {
return Config{}, fmt.Errorf("invalid pollInterval %q", cfg.PollInterval)
}
return cfg, nil
}
func (c Config) ValidateSend() error {
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
return fmt.Errorf("feishu.appId and feishu.appSecret are required")
}
if c.Feishu.ChatID == "" {
return fmt.Errorf("feishu.chatId is required")
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadYAML(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
body := []byte("environment: preview\narchiveDir: /data/full-sync\nstateDir: /data/state\npollInterval: 5s\nfeishu:\n appId: app-id\n appSecret: secret\n chatId: chat-id\n")
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.PollDuration != 5*time.Second || cfg.Feishu.AppID != "app-id" || cfg.ArchiveDir != "/data/full-sync" {
t.Fatalf("unexpected config: %+v", cfg)
}
if err := cfg.ValidateSend(); err != nil {
t.Fatal(err)
}
}
func TestValidateSendRejectsMissingCredentials(t *testing.T) {
if err := (Config{}).ValidateSend(); err == nil {
t.Fatal("expected validation error")
}
}
+155
View File
@@ -0,0 +1,155 @@
package feishu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
)
type Client struct {
appID string
appSecret string
chatID string
http *http.Client
baseURL string
mu sync.Mutex
token string
tokenExp time.Time
}
func NewClient(appID, appSecret, chatID string) *Client {
return &Client{appID: appID, appSecret: appSecret, chatID: chatID, http: &http.Client{Timeout: 10 * time.Second}, baseURL: "https://open.feishu.cn"}
}
func (c *Client) SendText(ctx context.Context, text string) error {
if c.chatID == "" {
return fmt.Errorf("feishu chatId is required")
}
if text == "" {
return fmt.Errorf("text is required")
}
token, err := c.accessToken(ctx, false)
if err != nil {
return err
}
if err := c.sendTextWithToken(ctx, token, text); err != nil && err.Error() == "feishu authentication failed" {
token, err = c.accessToken(ctx, true)
if err != nil {
return err
}
return c.sendTextWithToken(ctx, token, text)
}
return err
}
func (c *Client) Send(ctx context.Context, card []byte) error {
if c.chatID == "" {
return fmt.Errorf("feishu chatId is required")
}
token, err := c.accessToken(ctx, false)
if err != nil {
return err
}
if err := c.sendWithToken(ctx, token, card); err != nil && err.Error() == "feishu authentication failed" {
token, err = c.accessToken(ctx, true)
if err != nil {
return err
}
return c.sendWithToken(ctx, token, card)
} else {
return err
}
}
func (c *Client) accessToken(ctx context.Context, force bool) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !force && c.token != "" && time.Until(c.tokenExp) > time.Minute {
return c.token, nil
}
body, _ := json.Marshal(map[string]string{"app_id": c.appID, "app_secret": c.appSecret})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || result.Code != 0 || result.TenantAccessToken == "" {
return "", fmt.Errorf("feishu token request failed: http=%d code=%d msg=%s", resp.StatusCode, result.Code, result.Msg)
}
c.token, c.tokenExp = result.TenantAccessToken, time.Now().Add(time.Duration(result.Expire)*time.Second)
return c.token, nil
}
func (c *Client) sendTextWithToken(ctx context.Context, token, text string) error {
payload, _ := json.Marshal(map[string]string{"receive_id": c.chatID, "msg_type": "text", "content": fmt.Sprintf(`{"text":%q}`, text)})
return c.postMessage(ctx, token, payload)
}
func (c *Client) sendWithToken(ctx context.Context, token string, card []byte) error {
var content json.RawMessage
if err := json.Unmarshal(card, &content); err != nil {
return fmt.Errorf("invalid card: %w", err)
}
var outer map[string]any
if err := json.Unmarshal(content, &outer); err != nil {
return err
}
msgType, ok := outer["msg_type"].(string)
if !ok {
return fmt.Errorf("card msg_type is required")
}
cardBody, ok := outer["card"]
if !ok {
return fmt.Errorf("card body is required")
}
cardJSON, _ := json.Marshal(cardBody)
payload, _ := json.Marshal(map[string]any{"receive_id": c.chatID, "msg_type": msgType, "content": string(cardJSON)})
return c.postMessage(ctx, token, payload)
}
func (c *Client) postMessage(ctx context.Context, token string, payload []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/open-apis/im/v1/messages?receive_id_type="+url.QueryEscape("chat_id"), bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(data, &result)
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("feishu authentication failed")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || result.Code != 0 {
return fmt.Errorf("feishu send failed: http=%d code=%d msg=%s", resp.StatusCode, result.Code, result.Msg)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendText(t *testing.T) {
var gotText string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open-apis/auth/v3/tenant_access_token/internal":
_, _ = w.Write([]byte(`{"code":0,"tenant_access_token":"test-token","expire":7200}`))
case "/open-apis/im/v1/messages":
if r.URL.Query().Get("receive_id_type") != "chat_id" || r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("unexpected request: %s %s", r.URL.String(), r.Header.Get("Authorization"))
}
var payload struct {
ReceiveID string `json:"receive_id"`
MsgType string `json:"msg_type"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
}
if payload.ReceiveID != "test-chat" || payload.MsgType != "text" {
t.Errorf("unexpected payload: %+v", payload)
}
var content struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(payload.Content), &content); err != nil {
t.Error(err)
}
gotText = content.Text
_, _ = w.Write([]byte(`{"code":0,"msg":"success"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient("test-app", "test-secret", "test-chat")
client.baseURL = server.URL
message := "连接测试:含引号 \" 和换行\n第二行"
if err := client.SendText(context.Background(), message); err != nil {
t.Fatal(err)
}
if gotText != message {
t.Fatalf("text mismatch: %q", gotText)
}
}
func TestSendTextRejectsEmpty(t *testing.T) {
client := NewClient("test-app", "test-secret", "test-chat")
if err := client.SendText(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "text is required") {
t.Fatalf("unexpected error: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package notify
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// alertState 记录告警节流信息。
//
// 为什么必须落盘:进程检测到挂载失效后会主动退出、由 restart 策略拉起。
// 若节流时间只存在内存里,重启后 lastAlert 归零,会立刻再次告警 ——
// 持续故障就变成「每轮重启都发一条飞书」,把真正的告警淹成骚扰。
type alertState struct {
// LastDegradedAlert 是上次发送「归档目录不可用」告警的时间。
LastDegradedAlert time.Time `json:"lastDegradedAlert,omitempty"`
// LastDegradedReason 用于在故障原因变化时立即重新告警。
LastDegradedReason string `json:"lastDegradedReason,omitempty"`
// ExitCount 统计因挂载失效而退出的累计次数,用于识别「反复重启」。
ExitCount int `json:"exitCount,omitempty"`
}
func alertStatePath(stateDir string) string {
return filepath.Join(stateDir, "alert-state.json")
}
func loadAlertState(stateDir string) alertState {
var st alertState
if stateDir == "" {
return st
}
body, err := os.ReadFile(alertStatePath(stateDir))
if err != nil {
return st
}
_ = json.Unmarshal(body, &st)
return st
}
func saveAlertState(stateDir string, st alertState) error {
if stateDir == "" {
return nil
}
path := alertStatePath(stateDir)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// shouldAlert 判断是否该发告警:间隔到了,或故障原因发生变化。
func (st alertState) shouldAlert(reason string, now time.Time, interval time.Duration) bool {
if st.LastDegradedAlert.IsZero() {
return true
}
if reason != st.LastDegradedReason {
// 原因变了(例如从「目录不存在」变成「不可读」)值得立刻提醒。
return true
}
return now.Sub(st.LastDegradedAlert) >= interval
}
+107
View File
@@ -0,0 +1,107 @@
package notify
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestAlertStateSurvivesRestart(t *testing.T) {
dir := t.TempDir()
now := time.Now()
// 首次:没有任何记录 → 应该告警
first := loadAlertState(dir)
if !first.shouldAlert("dir gone", now, DegradedAlertInterval) {
t.Fatal("first alert must be sent")
}
first.LastDegradedAlert = now
first.LastDegradedReason = "dir gone"
if err := saveAlertState(dir, first); err != nil {
t.Fatal(err)
}
// 模拟进程退出后重启:重新从磁盘加载
reloaded := loadAlertState(dir)
if reloaded.shouldAlert("dir gone", now.Add(time.Second), DegradedAlertInterval) {
t.Fatal("after restart the same failure must NOT alert again immediately (this is the alert-flood bug)")
}
// 间隔到了才允许再报
if !reloaded.shouldAlert("dir gone", now.Add(DegradedAlertInterval+time.Second), DegradedAlertInterval) {
t.Fatal("alert must be sent again after the interval elapses")
}
// 故障原因变化时立即告警
if !reloaded.shouldAlert("permission denied", now.Add(time.Second), DegradedAlertInterval) {
t.Fatal("a changed failure reason must alert immediately")
}
}
func TestExitCountIsPersistedAndIncremented(t *testing.T) {
dir := t.TempDir()
st := loadAlertState(dir)
if st.ExitCount != 0 {
t.Fatalf("fresh state exitCount=%d want 0", st.ExitCount)
}
st.ExitCount++
if err := saveAlertState(dir, st); err != nil {
t.Fatal(err)
}
// 重启后计数必须延续,否则无法识别「反复重启」
if got := loadAlertState(dir).ExitCount; got != 1 {
t.Fatalf("exitCount after reload=%d want 1", got)
}
}
// 关键回归:挂载失效 → 退出 → 重启后,同一次故障只能推一条飞书。
func TestWatchAlertsOnceAcrossRestarts(t *testing.T) {
shortenExitDelay(t)
if !ExitOnDegraded {
t.Skip("ExitOnDegraded disabled")
}
stateDir := t.TempDir()
archiveDir := filepath.Join(t.TempDir(), "gone")
fake := &fakeFeishu{}
newSvc := func() Service {
return Service{
archiveDir: archiveDir,
environment: "test",
stateDir: stateDir,
pollInterval: 10 * time.Millisecond,
store: newTestStore(stateDir),
client: fake,
logger: discardLogger(),
}
}
// 第一次运行:应告警并退出
if err := newSvc().Watch(context.Background()); err == nil {
t.Fatal("Watch must exit when the mount is degraded")
}
if fake.sent != 1 {
t.Fatalf("first run must send exactly one alert, sent=%d", fake.sent)
}
// 模拟被 restart 策略拉起:新进程、新内存,但 state 目录相同
if err := newSvc().Watch(context.Background()); err == nil {
t.Fatal("Watch must exit again")
}
if fake.sent != 1 {
t.Fatalf("restart must NOT resend the same alert (alert flood), sent=%d", fake.sent)
}
// 退出计数应累计到 2
if got := loadAlertState(stateDir).ExitCount; got != 2 {
t.Fatalf("exitCount=%d want 2", got)
}
}
func TestAlertStateMissingDirIsNotFatal(t *testing.T) {
// 状态目录不可写时不应影响告警逻辑(退化为每次都报,但至少不崩)
st := loadAlertState("")
if !st.shouldAlert("x", time.Now(), DegradedAlertInterval) {
t.Fatal("empty state must allow alerting")
}
if err := saveAlertState("", st); err != nil {
t.Fatalf("saving to empty state dir must be a no-op, got %v", err)
}
_ = os.Remove("nonexistent")
}
+118
View File
@@ -0,0 +1,118 @@
package notify
import (
"fmt"
"os"
"time"
)
// Health 描述归档目录的可用性。
//
// 背景:归档目录通过 bind mount 进容器后,宿主机一旦把它删除,容器里的路径
// 会变成 mountinfo 中带 "//deleted" 的空壳目录(硬链接数 nlink=0),读起来
// "存在但为空",bot 会静默地再也发不出任何通知 —— 2026-10-07 预演环境实际
// 发生过(宿主日志清理脚本删空归档目录,bot 从 10-07 静默到 10-12)。
// 这里把该状态显式暴露出来,用于日志与飞书告警。
type Health struct {
Mounted bool
Nlink uint64
Entries int
Degraded bool
Reason string
}
// StaleAfter 是「目录持续为空多久后开始高频复查」的阈值。
// 归档只在全量同步结束后写入,中间的空窗是正常的,因此不能一空就缩短周期。
const StaleAfter = 10 * time.Minute
// DegradedAlertInterval 是同一个失效状态重复告警的最小间隔。
const DegradedAlertInterval = 6 * time.Hour
// DegradedExitDelay 是检测到挂载失效后,退出前的等待时间。
//
// 为什么不立即退出:compose 的 restart 策略会立即拉起容器,若进程马上又检测到
// 失效并退出,就变成几秒一次的崩溃循环(实测 8 次重启只花了约 9 秒),既刷日志
// 又毫无意义。等一会再退,既给宿主一个修复窗口,也让重启频率可控。
// 实测:目录真的被删掉时,等几十秒后再重启依然能自愈(重启会重新解析挂载)。
//
// 声明为变量便于测试缩短(生产用默认值)。
var DegradedExitDelay = 30 * time.Second
// DegradedMaxExits 是同一进程内连续退出的上限,防止极端情况下无限退出。
// 超过后不再退出,只保留告警与日志,避免变成无意义的重启风暴。
const DegradedMaxExits = 3
// ExitOnDegraded 为 true 时,检测到挂载失效(目录被宿主删除,nlink=0)
// 就告警并退出进程,交给 restart policy 拉起。
//
// 为什么退出就能修好:bind mount 的源路径是**容器启动时**解析的。宿主把目录删掉
// 再重建后,容器仍指向旧 inode(mountinfo 里带 //deleted、nlink=0),看不到任何
// 新文件;但重新启动容器会重新解析路径,拿到新 inode 并立即恢复正常。
// 实测(2026-10-13,dev 100.29):手工 docker restart 后 inode 从旧变新、文件立即可见;
// 进程自行退出后由 restart=unless-stopped 拉起,同样自愈(restarts=1)。
//
// 这样就不再需要「重建 worker 必须同批重建 bot」这种人工纪律:
// worker 重建把目录换掉后,bot 自己会在下一轮扫描退出并被拉起,自动重新挂载。
var ExitOnDegraded = true
// Health 检查归档目录当前是否可用。
func (s Service) Health() Health {
h := Health{Mounted: true}
info, err := os.Stat(s.archiveDir)
if err != nil {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory is not readable: %v", err)
return h
}
if !info.IsDir() {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive path is not a directory: %s", s.archiveDir)
return h
}
if nlink, ok := linkCount(info); ok {
h.Nlink = nlink
if nlink == 0 {
// 宿主机目录已被删除,容器仍挂着它的空壳。
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory was deleted on the host (nlink=0); recreate this container to rebind %s", s.archiveDir)
return h
}
}
entries, err := os.ReadDir(s.archiveDir)
if err != nil {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory is not listable: %v", err)
return h
}
h.Entries = len(entries)
if h.Entries == 0 {
h.Reason = "archive directory is empty"
}
return h
}
// Interval 返回下一次扫描前的等待时长。
//
// 正常情况下保持配置的轮询周期;只有在目录长期为空(可能正被写入,也可能已失效)
// 时才缩短到 10s,避免把通知延迟留给下一次发布。
func (s Service) Interval(emptySince time.Time) time.Duration {
base := s.pollInterval
if base <= 0 {
base = 10 * time.Second
}
if base <= 10*time.Second {
return base
}
h := s.Health()
if h.Entries > 0 {
return base
}
if emptySince.IsZero() || time.Since(emptySince) < StaleAfter {
return base
}
return 10 * time.Second
}
+19
View File
@@ -0,0 +1,19 @@
//go:build linux
package notify
import (
"os"
"syscall"
)
// linkCount 返回目录的硬链接数。
// 宿主机把 bind mount 的源目录删掉后,容器内该目录的 nlink 会变成 0
// (mountinfo 里也能看到 "//deleted"),这是唯一可靠的失效信号。
func linkCount(info os.FileInfo) (uint64, bool) {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(st.Nlink), true
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package notify
import "os"
// linkCount 在非 Linux 平台不可用(syscall.Stat_t 没有 Nlink 字段)。
// bot 实际只跑在 Linux 容器里,这里让本机测试仍可编译运行。
func linkCount(info os.FileInfo) (uint64, bool) { return 0, false }
+198
View File
@@ -0,0 +1,198 @@
package notify
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
func writeArchive(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 1}))
}
// countingLogger 统计 ERROR 级日志条数,用于验证去重告警。
func countingLogger(count *int) *slog.Logger {
return slog.New(slog.NewTextHandler(countingWriter{count}, &slog.HandlerOptions{Level: slog.LevelError}))
}
type countingWriter struct{ count *int }
func (w countingWriter) Write(p []byte) (int, error) {
*w.count++
return len(p), nil
}
const validArchiveBody = `{"version":1,"run":{"runId":"z-good","operation":"op","status":"completed","successCount":1,"failedCount":0,"createdAt":"2026-10-12T00:00:00Z","completedAt":"2026-10-12T00:01:00Z"},"rounds":[{"round":1,"enqueuedCount":1,"activeCount":0,"nextRoundCount":0,"successCount":1,"failedCount":0,"startedAt":"2026-10-12T00:00:00Z","completedAt":"2026-10-12T00:01:00Z"}]}`
// 一个坏归档不能挡住后面的归档,但也不能每轮扫描都刷一条日志。
func TestScanSkipsBrokenArchiveAndLogsOnlyOnce(t *testing.T) {
root := t.TempDir()
// 字典序在前:旧格式(rounds 为空)会校验失败。
writeArchive(t, root, "run-broken.json", `{"version":1,"run":{"runId":"broken","operation":"op","status":"completed"},"rounds":[]}`)
writeArchive(t, root, "run-z-good.json", validArchiveBody)
fake := &fakeFeishu{}
var logged int
svc := Service{archiveDir: root, environment: "preview", store: state.New(t.TempDir()), client: fake, logger: countingLogger(&logged), warned: map[string]struct{}{}}
for i := 0; i < 3; i++ {
if err := svc.Scan(context.Background()); err != nil {
t.Fatalf("Scan() returned error for a broken archive: %v", err)
}
}
if fake.sent != 1 {
t.Fatalf("expected the valid archive to be sent once, sent=%d", fake.sent)
}
if logged != 1 {
t.Fatalf("broken archive should be reported once across 3 scans, logged=%d", logged)
}
}
// 文件被重写(内容变了)后应该重新告警。
func TestScanWarnsAgainAfterArchiveIsRewritten(t *testing.T) {
root := t.TempDir()
writeArchive(t, root, "run-broken.json", `{"version":1,"run":{"runId":"broken","operation":"op","status":"completed"},"rounds":[]}`)
var logged int
svc := Service{archiveDir: root, environment: "preview", store: state.New(t.TempDir()), client: &fakeFeishu{}, logger: countingLogger(&logged), warned: map[string]struct{}{}}
if err := svc.Scan(context.Background()); err != nil {
t.Fatal(err)
}
// 重写为另一个仍然无效的内容,mtime/大小变化
time.Sleep(10 * time.Millisecond)
writeArchive(t, root, "run-broken.json", `{"version":1,"run":{"runId":"broken","operation":"op","status":"completed","queryMonth":"202610"},"rounds":[]}`)
if err := svc.Scan(context.Background()); err != nil {
t.Fatal(err)
}
if logged != 2 {
t.Fatalf("rewritten broken archive should be reported again, logged=%d", logged)
}
}
// 发送失败(飞书临时故障)必须每轮都报,不能被去重掉:
// 否则一次网络抖动就会让通知静默丢失。
func TestScanSendFailureIsNotDeduped(t *testing.T) {
root := t.TempDir()
writeArchive(t, root, "run-z-good.json", validArchiveBody)
fail := &fakeFeishu{err: errors.New("feishu timeout")}
var logged int
svc := Service{archiveDir: root, environment: "preview", store: state.New(t.TempDir()), client: fail, logger: countingLogger(&logged), warned: map[string]struct{}{}}
for i := 0; i < 3; i++ {
if err := svc.Scan(context.Background()); err != nil {
t.Fatal(err)
}
}
if logged != 3 {
t.Fatalf("transient send failures must be reported every scan, logged=%d want=3", logged)
}
}
func TestHealthReportsEmptyDirectory(t *testing.T) {
svc := Service{archiveDir: t.TempDir()}
h := svc.Health()
if !h.Mounted || h.Degraded {
t.Fatalf("empty but mounted directory should not be degraded: %+v", h)
}
if h.Entries != 0 || h.Reason == "" {
t.Fatalf("unexpected health: %+v", h)
}
}
func TestHealthReportsMissingDirectory(t *testing.T) {
svc := Service{archiveDir: filepath.Join(t.TempDir(), "gone")}
h := svc.Health()
if h.Mounted || !h.Degraded {
t.Fatalf("missing directory must be degraded: %+v", h)
}
}
// 挂载失效时必须退出进程,交给 restart policy 重新拉起以重新解析 bind mount。
// 这是「不再需要人工同批重建 bot」的关键:重启后挂载指向宿主当前目录。
func TestWatchExitsWhenArchiveMountIsDegraded(t *testing.T) {
shortenExitDelay(t)
if !ExitOnDegraded {
t.Skip("ExitOnDegraded disabled")
}
stateDir := t.TempDir()
svc := Service{
archiveDir: filepath.Join(t.TempDir(), "gone"),
environment: "test",
stateDir: stateDir,
pollInterval: 10 * time.Millisecond,
store: state.New(stateDir),
logger: discardLogger(),
}
done := make(chan error, 1)
go func() { done <- svc.Watch(context.Background()) }()
select {
case err := <-done:
if err == nil {
t.Fatal("Watch must return an error so the process exits non-zero and restarts")
}
case <-time.After(3 * time.Second):
t.Fatal("Watch did not exit after detecting a degraded archive mount")
}
}
// 目录长期为空时才缩短轮询;有内容或空窗很短时保持配置周期。
func TestIntervalShortensOnlyAfterLongEmptyPeriod(t *testing.T) {
root := t.TempDir()
svc := Service{archiveDir: root, pollInterval: time.Minute}
if got := svc.Interval(time.Time{}); got != time.Minute {
t.Fatalf("fresh empty dir should keep poll interval, got %s", got)
}
if got := svc.Interval(time.Now().Add(-StaleAfter - time.Second)); got != 10*time.Second {
t.Fatalf("long-empty dir should poll every 10s, got %s", got)
}
writeArchive(t, root, "run-anything.json", "{}")
if got := svc.Interval(time.Now().Add(-time.Hour)); got != time.Minute {
t.Fatalf("non-empty dir should keep poll interval, got %s", got)
}
}
type fakeFeishu struct {
sent int
err error
textCount int
texts []string
}
func (f *fakeFeishu) Send(ctx context.Context, card []byte) error {
if f.err != nil {
return f.err
}
f.sent++
return nil
}
func (f *fakeFeishu) SendText(ctx context.Context, text string) error {
f.textCount++
f.texts = append(f.texts, text)
return nil
}
// newTestStore 构造一个 state.Store 并返回其 Dir,供 alertstate 测试使用。
func newTestStore(dir string) state.Store { return state.New(dir) }
// shortenExitDelay 把退出等待改短,避免测试白等 30 秒(仅测试内使用)。
func shortenExitDelay(t *testing.T) {
t.Helper()
original := DegradedExitDelay
DegradedExitDelay = 10 * time.Millisecond
t.Cleanup(func() { DegradedExitDelay = original })
}
+350
View File
@@ -0,0 +1,350 @@
package notify
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/render"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
type Service struct {
archiveDir, environment string
stateDir string
pollInterval time.Duration
store state.Store
client Sender
logger *slog.Logger
// warned 记录已经报过错的归档,避免坏文件每轮扫描都刷一条日志。
// 键为「路径 + mtime + 大小」:文件一旦被重写就会重新告警。
warned map[string]struct{}
// transient 记录临时错误的重报节奏与累计次数。
// 临时错误必须持续可见(一次网络抖动不能被吞掉),但 bot 被移出群这类
// 长期故障不能每秒刷一条,否则日志被淹、真告警失联。
transient map[string]*transientWarn
}
// Sender 是发送端抽象:*feishu.Client 满足它,测试可注入替身。
type Sender interface {
Send(ctx context.Context, card []byte) error
SendText(ctx context.Context, text string) error
}
func New(archiveDir, environment string, pollInterval time.Duration, store state.Store, client Sender, logger *slog.Logger) Service {
if logger == nil {
logger = DefaultLogger()
}
return Service{archiveDir: archiveDir, environment: environment, stateDir: store.Dir(), pollInterval: pollInterval, store: store, client: client, logger: logger, warned: map[string]struct{}{}, transient: map[string]*transientWarn{}}
}
func (s Service) Render(path string) (string, error) {
a, err := archive.Load(path)
if err != nil {
return "", err
}
return render.Text(a, s.environment), nil
}
func (s Service) SendFile(ctx context.Context, path string, force bool) error {
if err := s.store.Prepare(); err != nil {
return err
}
return s.process(ctx, path, force)
}
func (s Service) runFiles() ([]string, error) {
files, err := filepath.Glob(filepath.Join(s.archiveDir, "run-*.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
// skipKey 生成归档文件的「内容版本」标识,用于去重告警。
// 读不到元数据时退化为路径本身,宁可少报警也不要刷屏。
func skipKey(path string) string {
info, err := os.Stat(path)
if err != nil {
return path
}
return fmt.Sprintf("%s|%d|%d", path, info.ModTime().UnixNano(), info.Size())
}
// archiveError 区分「这个归档永远不会成功」与「这次发送碰上了临时故障」。
// 前者(格式不对、旧版本、rounds 为空)是常态,只该报一次;
// 后者(飞书超时、鉴权失败、写状态失败)必须每轮重试并持续可见,
// 否则一次网络抖动就会被去重逻辑吞掉,通知静默丢失。
type archiveError struct {
err error
permanent bool
}
func (e *archiveError) Error() string { return e.err.Error() }
func (e *archiveError) Unwrap() error { return e.err }
// permanentErr 标记「重试也不会好」的错误。
func permanentErr(err error) error { return &archiveError{err: err, permanent: true} }
// isPermanent 判断错误是否属于「归档本身无效」。
func isPermanent(err error) bool {
var ae *archiveError
return errors.As(err, &ae) && ae.permanent
}
// warnOnce 对同一个归档文件的重报做节流。
//
// 永久错误(归档格式坏、旧版本)只记一次:重试也不会好。
// 临时错误(飞书超时、bot 不在群)按退避节奏重报:
// - 前 transientWarnFastPhase 次每次都报,保证瞬时抖动可见;
// - 之后按指数退避(1min→2min→4min…上限 transientWarnMaxInterval);
// - 超过 transientWarnMaxCount 次后只记一条汇总并停止重报,避免日志风暴。
//
// 历史上 230002(bot 不在群)持续 10 小时刷了 18,266 条 ERROR,占日志 99.9%,
// 把真正的告警完全淹没——这正是本函数要防的情况。
func (s Service) warnOnce(path string, err error) {
if !isPermanent(err) {
s.warnTransient(path, err)
return
}
key := skipKey(path)
if s.warned != nil {
if _, seen := s.warned[key]; seen {
return
}
s.warned[key] = struct{}{}
}
s.logger.Error("skip archive", "path", path, "error", err)
}
const (
// transientWarnFastPhase 是「每次都报」的次数,覆盖瞬时抖动。
transientWarnFastPhase = 5
// transientWarnMaxInterval 是退避重报的间隔上限。
transientWarnMaxInterval = 10 * time.Minute
// transientWarnMaxCount 是同一归档同一错误的最大重报次数,超过后静默。
transientWarnMaxCount = 30
)
// transientWarn 记录一个归档的临时错误重报状态。
type transientWarn struct {
count int
lastLog time.Time
suppressed bool
}
// warnTransient 按退避节奏记录临时错误。
func (s Service) warnTransient(path string, err error) {
key := skipKey(path)
now := time.Now()
if s.transient == nil {
s.transient = map[string]*transientWarn{}
}
st, ok := s.transient[key]
if !ok {
st = &transientWarn{}
s.transient[key] = st
}
if st.suppressed {
return
}
// 前 fastPhase 次每次都报。
if st.count < transientWarnFastPhase {
st.count++
st.lastLog = now
s.logger.Error("process archive failed", "path", path, "error", err, "attempt", st.count)
return
}
// 之后按指数退避。
interval := time.Minute << uint(st.count-transientWarnFastPhase)
if interval > transientWarnMaxInterval {
interval = transientWarnMaxInterval
}
if now.Sub(st.lastLog) < interval {
return
}
st.count++
st.lastLog = now
if st.count > transientWarnMaxCount {
st.suppressed = true
s.logger.Error("process archive failed, suppressing further identical alerts",
"path", path, "error", err, "attempts", st.count-1,
"hint", "this is likely a persistent configuration problem (e.g. bot removed from chat), fix the cause to resume notifications")
return
}
s.logger.Error("process archive failed", "path", path, "error", err, "attempt", st.count)
}
// Scan 扫描归档目录并发送尚未通知过的 Run。
//
// 单个归档出错(旧格式、写了一半、权限不足)只跳过并记日志:
// 过去这里直接 return,一个坏文件会挡住后面所有归档,通知会静默丢失。
// 坏文件是常态(例如 manual 部分同步的 Run 天然没有 rounds),所以只记一次。
func (s Service) Scan(ctx context.Context) error {
if err := s.store.Prepare(); err != nil {
return err
}
files, err := s.runFiles()
if err != nil {
return err
}
for _, path := range files {
if err := s.process(ctx, path, false); err != nil {
s.warnOnce(path, err)
}
}
return nil
}
func (s Service) Watch(ctx context.Context) error {
if s.pollInterval <= 0 {
s.pollInterval = 10 * time.Second
}
var emptySince time.Time
for {
h := s.Health()
if h.Degraded {
// 目录已被宿主删除等硬故障:告警节流状态落盘,避免退出重启后
// 把同一次故障反复推送成飞书骚扰。
now := time.Now()
st := loadAlertState(s.stateDir)
if st.shouldAlert(h.Reason, now, DegradedAlertInterval) {
s.logger.Error("archive directory is not usable", "path", s.archiveDir, "reason", h.Reason, "entries", h.Entries)
s.alertMount(ctx, h.Reason, st.ExitCount+1)
st.LastDegradedAlert = now
st.LastDegradedReason = h.Reason
if err := saveAlertState(s.stateDir, st); err != nil {
s.logger.Error("save alert state failed", "error", err)
}
}
if ExitOnDegraded {
// 退出并交给 restart policy 重新拉起:重启会重新解析 bind mount,
// 从而重新挂载到宿主当前的目录(详见 ExitOnDegraded 注释)。
st.ExitCount++
if err := saveAlertState(s.stateDir, st); err != nil {
s.logger.Error("save alert state failed", "error", err)
}
if st.ExitCount > DegradedMaxExits {
// 已经反复重启仍未恢复(例如宿主把目录删了且不再重建):
// 继续退出只会变成重启风暴,改为留在原地持续观测+告警。
s.logger.Error("archive mount still unusable after repeated restarts, staying up to keep reporting",
"path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount)
} else {
// 等一会再退:restart 策略是立即拉起的,立刻退出会造成几秒一次的崩溃循环。
s.logger.Error("exit to rebind archive mount", "path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount, "delay", DegradedExitDelay)
timer := time.NewTimer(DegradedExitDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
return fmt.Errorf("archive directory unusable, restarting to rebind mount: %s", h.Reason)
}
}
} else if h.Entries == 0 {
if emptySince.IsZero() {
emptySince = time.Now()
}
} else {
emptySince = time.Time{}
}
if err := s.Scan(ctx); err != nil {
s.logger.Error("scan failed", "error", err)
}
timer := time.NewTimer(s.Interval(emptySince))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
// alert 在飞书客户端可用时发一条文本告警;失败只记日志,不影响主循环。
func (s Service) alert(ctx context.Context, title, reason string) {
if s.client == nil {
return
}
text := fmt.Sprintf("[iotlcm-bot][%s] %s: %s", s.environment, title, reason)
if err := s.client.SendText(ctx, text); err != nil {
s.logger.Error("send alert failed", "error", err)
}
}
// alertMount 发送「归档目录不可用」告警卡片。
//
// 用卡片而非纯文本:text 消息在飞书里是灰底等宽块,长错误串会把气泡撑得很宽;
// 卡片带红色 header 与分区字段,一眼能看出是故障,且与同步结果卡片风格一致。
func (s Service) alertMount(ctx context.Context, reason string, exitCount int) {
if s.client == nil {
return
}
summary := "正在重启以重新挂载"
if exitCount > DegradedMaxExits {
summary = "反复重启仍未恢复,已改为持续监控"
}
card, err := render.AlertCard(render.Alert{
Environment: s.environment,
Title: "归档目录不可用",
Summary: summary,
Reason: render.FriendlyReason(reason),
ExitCount: exitCount,
Path: s.archiveDir,
})
if err != nil {
s.logger.Error("render alert card failed", "error", err)
return
}
if err := s.client.Send(ctx, card); err != nil {
s.logger.Error("send alert failed", "error", err)
}
}
func (s Service) process(ctx context.Context, path string, force bool) error {
a, err := archive.Load(path)
if err != nil {
// 归档本身无效(格式/版本/rounds 为空):重试也不会好。
return permanentErr(err)
}
if !force {
sent, err := s.store.Has(a.Run.RunID)
if err != nil {
return err
}
if sent {
return nil
}
}
card, err := render.Card(a, s.environment)
if err != nil {
return permanentErr(err)
}
if s.client == nil {
return fmt.Errorf("feishu client is not configured")
}
if err := s.client.Send(ctx, card); err != nil {
return err
}
if err := s.store.Mark(a.Run.RunID); err != nil {
return err
}
s.logger.Info("run notification sent", "runId", a.Run.RunID, "path", path)
return nil
}
func DefaultLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
+40
View File
@@ -0,0 +1,40 @@
package notify
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestRunFilesSelectsOnlyTopLevelRunJSON(t *testing.T) {
root := t.TempDir()
for _, name := range []string{
"run-abc.json",
"run-full-id.json",
"action-abc.json",
"sim-status-legacy.json",
"run-temp.json.tmp",
} {
if err := os.WriteFile(filepath.Join(root, name), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
nested := filepath.Join(root, "2026", "09")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "run-nested.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
svc := Service{archiveDir: root}
got, err := svc.runFiles()
if err != nil {
t.Fatal(err)
}
want := []string{filepath.Join(root, "run-abc.json"), filepath.Join(root, "run-full-id.json")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("runFiles() = %#v, want %#v", got, want)
}
}
+147
View File
@@ -0,0 +1,147 @@
package notify
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
// captureHandler 收集所有日志记录,用于断言重报次数。
type captureHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r)
return nil
}
func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *captureHandler) WithGroup(_ string) slog.Handler { return h }
func (h *captureHandler) count() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.records)
}
func (h *captureHandler) lastMessage() string {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.records) == 0 {
return ""
}
return h.records[len(h.records)-1].Message
}
func newCaptureLogger() (*slog.Logger, *captureHandler) {
h := &captureHandler{}
return slog.New(h), h
}
// TestTransientWarnThrottlesRepeatedErrors 验证同一个归档的临时错误
// 不会每轮都刷一条 ERROR:前几次可见,之后进入退避,超过上限后静默。
//
// 背景:2026-10-21/22 bot 被移出飞书群 10 小时,230002 错误刷了 18,266 条,
// 占日志 99.9%,真正的同步结果通知被完全淹没。
func TestTransientWarnThrottlesRepeatedErrors(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
transient := errors.New("feishu send failed: http=400 code=230002 msg=Bot/User can NOT be out of the chat.")
// 模拟 500 轮扫描都失败(约等于 8 分钟的 1 秒轮询)。
for i := 0; i < 500; i++ {
svc.warnOnce("/data/full-sync/run-abc.json", transient)
}
got := h.count()
// 前 5 次必报,之后受退避限制。500 轮里退避间隔从 1 分钟起翻倍,
// 最多也就多出个位数。这里给一个宽松上界,重点是不能是 500。
if got > transientWarnMaxCount+2 {
t.Fatalf("logged %d times for 500 failures, want <= %d (throttle is not working)", got, transientWarnMaxCount+2)
}
if got < transientWarnFastPhase {
t.Fatalf("logged %d times, want at least %d (fast phase should always report)", got, transientWarnFastPhase)
}
}
// TestTransientWarnDifferentArchivesAreIndependent 验证节流按归档文件隔离,
// 一个坏文件不能掩盖另一个文件的错误。
func TestTransientWarnDifferentArchivesAreIndependent(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
errA := errors.New("boom-a")
for i := 0; i < 20; i++ {
svc.warnOnce("/data/full-sync/run-a.json", errA)
}
afterA := h.count()
// 另一个文件应该独立计数,立刻能看到自己的错误。
svc.warnOnce("/data/full-sync/run-b.json", errors.New("boom-b"))
if h.count() != afterA+1 {
t.Fatalf("second archive was throttled by the first: before=%d after=%d", afterA, h.count())
}
}
// TestPermanentWarnOnlyOnce 验证永久错误仍然只报一次。
func TestPermanentWarnOnlyOnce(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
permanent := permanentErr(errors.New("archive rounds is empty"))
for i := 0; i < 50; i++ {
svc.warnOnce("/data/full-sync/run-bad.json", permanent)
}
if got := h.count(); got != 1 {
t.Fatalf("permanent error logged %d times, want exactly 1", got)
}
}
// TestTransientWarnSuppressionMessage 验证超过上限时给出的汇总日志包含排查提示,
// 让运维一眼看出这是配置问题而不是代码 bug。
func TestTransientWarnSuppressionMessage(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
transient := errors.New("code=230002 Bot/User can NOT be out of the chat.")
// 直接构造已进入退避阶段的状态,避免测试里真的等几分钟。
key := skipKey("/data/full-sync/run-abc.json")
svc.transient[key] = &transientWarn{
count: transientWarnMaxCount,
lastLog: time.Now().Add(-time.Hour),
}
svc.warnOnce("/data/full-sync/run-abc.json", transient)
msg := h.lastMessage()
if !strings.Contains(msg, "suppressing") {
t.Fatalf("suppression message = %q, want it to mention suppressing", msg)
}
}
// TestTransientWarnZeroValueService 验证未初始化的 Service(transient 为 nil)
// 不会 panic——生产上 Service 由 New 构造,但零值也应安全。
func TestTransientWarnZeroValueService(t *testing.T) {
svc := Service{logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 10}))}
defer func() {
if r := recover(); r != nil {
t.Fatalf("zero-value Service panicked: %v", r)
}
}()
svc.warnOnce("/tmp/run-x.json", errors.New("transient"))
}
+90
View File
@@ -0,0 +1,90 @@
package render
import (
"encoding/json"
"fmt"
"strings"
)
// Alert 描述一条运维告警。
type Alert struct {
Environment string
// Title 是卡片标题,如「归档目录不可用」。
Title string
// Summary 是一句人话结论,如「正在重启以重新挂载」。
Summary string
// Reason 是技术细节(英文错误原样保留,便于排查)。
Reason string
// ExitCount 为累计退出次数,>1 时提示可能反复出现。
ExitCount int
// Path 是涉及的路径。
Path string
}
// AlertCard 把告警渲染成飞书交互卡片。
//
// 为什么不用纯文本:text 消息在飞书里渲染成灰底等宽块,长错误串会把气泡撑得很宽,
// 而且和同步结果卡片(interactive)风格不一致。卡片可以带红色 header、分区字段,
// 一眼能看出是「出事了」而不是一条普通消息。
func AlertCard(a Alert) ([]byte, error) {
title := fmt.Sprintf("%s · %s", displayEnvironment(a.Environment), a.Title)
fields := []any{}
if a.Summary != "" {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**" + a.Summary + "**"},
})
}
if a.Path != "" {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**路径**\n`" + a.Path + "`"},
})
}
if a.Reason != "" {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**原因**\n" + a.Reason},
})
}
if a.ExitCount > 1 {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": fmt.Sprintf("**已重复 %d 次**,若持续出现请检查宿主机上该目录是否被反复删除。", a.ExitCount)},
})
}
fields = append(fields, map[string]any{"tag": "hr"})
fields = append(fields, map[string]any{
"tag": "note",
"elements": []any{
map[string]any{"tag": "plain_text", "content": "iotlcm-bot 自动检测 · 恢复后不再重复提醒"},
},
})
card := map[string]any{
"msg_type": "interactive",
"card": map[string]any{
// red 让告警在消息流里一眼可辨,与结果卡片的 blue 区分开。
"header": map[string]any{"template": "red", "title": map[string]any{"tag": "plain_text", "content": title}},
"elements": fields,
},
}
return json.Marshal(card)
}
// FriendlyReason 把常见的技术错误翻成人话,保留原文以便排查。
func FriendlyReason(reason string) string {
switch {
case strings.Contains(reason, "deleted on the host"):
return "宿主机上的归档目录已被删除,容器仍指向旧目录(挂载失效)。"
case strings.Contains(reason, "no such file or directory"):
return "目录不存在。"
case strings.Contains(reason, "permission denied"):
return "没有读取权限。"
case strings.Contains(reason, "not a directory"):
return "该路径不是目录。"
default:
return reason
}
}
+86
View File
@@ -0,0 +1,86 @@
package render
import (
"encoding/json"
"strings"
"testing"
)
func TestAlertCardIsInteractiveWithRedHeader(t *testing.T) {
body, err := AlertCard(Alert{
Environment: "preview",
Title: "归档目录不可用",
Summary: "正在重启以重新挂载",
Reason: "宿主机上的归档目录已被删除,容器仍指向旧目录(挂载失效)。",
Path: "/data/full-sync",
})
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("alert card must be valid JSON: %v", err)
}
if decoded["msg_type"] != "interactive" {
t.Fatalf("alert must be an interactive card, got %v", decoded["msg_type"])
}
card := decoded["card"].(map[string]any)
header := card["header"].(map[string]any)
if header["template"] != "red" {
t.Fatalf("alert header should be red so it stands out, got %v", header["template"])
}
title := header["title"].(map[string]any)["content"].(string)
if !strings.Contains(title, "预演") || !strings.Contains(title, "归档目录不可用") {
t.Fatalf("unexpected title: %q", title)
}
}
func TestAlertCardShowsPathAndRepeatCount(t *testing.T) {
body, err := AlertCard(Alert{
Environment: "dev",
Title: "归档目录不可用",
Summary: "正在重启以重新挂载",
Reason: "目录不存在。",
Path: "/data/full-sync",
ExitCount: 3,
})
if err != nil {
t.Fatal(err)
}
text := string(body)
if !strings.Contains(text, "/data/full-sync") {
t.Fatal("card should show the path")
}
if !strings.Contains(text, "已重复 3 次") {
t.Fatal("card should warn about repeated exits")
}
}
// 单次故障时不该出现「已重复」这种话,避免误导。
func TestAlertCardOmitsRepeatNoticeOnFirstOccurrence(t *testing.T) {
body, err := AlertCard(Alert{Environment: "dev", Title: "归档目录不可用", ExitCount: 1})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "已重复") {
t.Fatal("first occurrence must not claim repeats")
}
}
func TestFriendlyReasonTranslatesKnownErrors(t *testing.T) {
cases := map[string]string{
"archive directory was deleted on the host (nlink=0); recreate this container": "宿主机上的归档目录已被删除",
"stat /data/x: no such file or directory": "目录不存在。",
"open /data/x: permission denied": "没有读取权限。",
}
for input, want := range cases {
if got := FriendlyReason(input); !strings.Contains(got, want) {
t.Fatalf("FriendlyReason(%q) = %q, want it to contain %q", input, got, want)
}
}
// 未知错误原样保留,便于排查
unknown := "some unexpected failure"
if got := FriendlyReason(unknown); got != unknown {
t.Fatalf("unknown reason must be preserved, got %q", got)
}
}
+187
View File
@@ -0,0 +1,187 @@
package render
import (
"encoding/json"
"fmt"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
)
// Kind 返回卡片标题用的中文名称。流量同步有日、月历史、本月快照三条链路,
// 标题必须能区分,否则运营无法从卡片判断是哪一类同步。
func Kind(operation string) string {
switch operation {
case "previous_day_usage_full_sync":
return "日流量同步"
case "previous_month_usage_full_sync":
return "月历史同步"
case "current_month_usage_full_sync":
return "本月流量快照同步"
}
if strings.Contains(operation, "sim_status") {
return "卡状态全量同步"
}
if isTrafficUsage(operation) {
return "流量全量同步"
}
return operation
}
// isTrafficUsage 判定 operation 是否属于流量族(日、月历史、本月快照及兼容旧操作名)。
// 供料观测等按链路区分的行为用它判断,避免依赖 Kind 返回的中文标题。
func isTrafficUsage(operation string) bool {
return strings.Contains(operation, "traffic") || strings.Contains(operation, "usage")
}
func Text(a archive.RunArchive, environment string) string {
title := fmt.Sprintf("【%s|%s】", environment, Kind(a.Run.Operation))
startAt, endAt := executionTimes(a)
lines := []string{
title,
"",
fmt.Sprintf("总数:%s", number(a.TotalCount())),
fmt.Sprintf("成功:%s", number(a.Run.SuccessCount)),
fmt.Sprintf("失败:%s", number(a.Run.FailedCount)),
fmt.Sprintf("成功率:%.2f%%", a.SuccessRate()),
fmt.Sprintf("开始时间:%s", timestamp(startAt)),
fmt.Sprintf("结束时间:%s", timestamp(endAt)),
fmt.Sprintf("总耗时:%s", duration(a.Duration())),
fmt.Sprintf("实际执行:%d 轮", len(a.Rounds)),
"",
"轮次结果:",
}
for _, r := range a.Rounds {
lines = append(lines, roundLine(r))
}
if block := supplyBlock(a); block != "" {
lines = append(lines, "", block)
}
lines = append(lines, "", "Run:"+a.Run.RunID)
return strings.Join(lines, "\n")
}
func Card(a archive.RunArchive, environment string) ([]byte, error) {
title := fmt.Sprintf("%s · %s", displayEnvironment(environment), Kind(a.Run.Operation))
startAt, endAt := executionTimes(a)
// Full sync runs commonly have final failures; use one neutral header color.
template := "blue"
elements := []any{
fields(
field("总数", number(a.TotalCount())),
field("成功", number(a.Run.SuccessCount)),
field("失败", number(a.Run.FailedCount)),
field("成功率", fmt.Sprintf("%.2f%%", a.SuccessRate())),
field("开始时间", timestamp(startAt)),
field("结束时间", timestamp(endAt)),
field("总耗时", duration(a.Duration())),
field("实际轮次", fmt.Sprintf("%d轮", len(a.Rounds))),
),
map[string]any{"tag": "hr"},
}
for _, r := range a.Rounds {
elements = append(elements, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": roundBlock(r)},
})
}
if block := supplyBlock(a); block != "" {
elements = append(elements, map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": block}})
}
elements = append(elements,
map[string]any{"tag": "hr"},
map[string]any{"tag": "note", "elements": []any{map[string]any{"tag": "plain_text", "content": "Run: " + a.Run.RunID}}},
)
card := map[string]any{
"msg_type": "interactive",
"card": map[string]any{
"header": map[string]any{"template": template, "title": map[string]any{"tag": "plain_text", "content": title}},
"elements": elements,
},
}
return json.Marshal(card)
}
func fields(values ...map[string]any) map[string]any {
entries := make([]any, len(values))
for i, value := range values {
entries[i] = value
}
return map[string]any{"tag": "div", "fields": entries}
}
func field(label, value string) map[string]any {
return map[string]any{
"is_short": true,
"text": map[string]any{"tag": "lark_md", "content": fmt.Sprintf("**%s**\n%s", label, value)},
}
}
func roundBlock(r archive.RoundStats) string {
return fmt.Sprintf("**第 %d 轮 · %s 张 · %s**\n成功 %s(%.2f%%)\n进入下轮 %s · 终态失败 %s", r.Round, number(r.EnqueuedCount), duration(r.Duration()), number(r.SuccessCount), r.SuccessRate(), number(r.NextRoundCount), number(r.FailedCount))
}
func displayEnvironment(environment string) string {
switch environment {
case "preview":
return "预演"
case "prod", "production":
return "生产"
default:
return environment
}
}
func roundLine(r archive.RoundStats) string {
return fmt.Sprintf("第 %d 轮|%s 张|成功 %s(%.2f%%)|进入下轮 %s|终态失败 %s|%s", r.Round, number(r.EnqueuedCount), number(r.SuccessCount), r.SuccessRate(), number(r.NextRoundCount), number(r.FailedCount), duration(r.Duration()))
}
func executionTimes(a archive.RunArchive) (string, string) {
if len(a.Rounds) == 0 {
return a.Run.CreatedAt, a.Run.CompletedAt
}
return a.Rounds[0].StartedAt, a.Rounds[len(a.Rounds)-1].CompletedAt
}
func timestamp(value string) string {
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return value
}
return t.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")
}
func number(n int64) string {
negative := n < 0
if negative {
n = -n
}
s := fmt.Sprintf("%d", n)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "," + s[i:]
}
if negative {
return "-" + s
}
return s
}
func duration(d time.Duration) string {
seconds := int64(d / time.Second)
if seconds < 0 {
return "未记录"
}
h, m, s := seconds/3600, (seconds%3600)/60, seconds%60
parts := make([]string, 0, 3)
if h > 0 {
parts = append(parts, fmt.Sprintf("%d小时", h))
}
if m > 0 {
parts = append(parts, fmt.Sprintf("%d分", m))
}
if s > 0 || len(parts) == 0 {
parts = append(parts, fmt.Sprintf("%d秒", s))
}
return strings.Join(parts, "")
}
+110
View File
@@ -0,0 +1,110 @@
package render
import (
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
)
func sample() archive.RunArchive {
return archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed_with_failures", SuccessCount: 175893, FailedCount: 10504, CreatedAt: "2026-09-22T11:42:33Z", CompletedAt: "2026-09-22T12:32:01Z"}, Rounds: []archive.RoundStats{
{Round: 1, EnqueuedCount: 186397, NextRoundCount: 9023, SuccessCount: 175870, FailedCount: 1504, StartedAt: "2026-09-22T11:42:34Z", CompletedAt: "2026-09-22T12:21:49Z"},
{Round: 2, EnqueuedCount: 9023, NextRoundCount: 9000, SuccessCount: 23, FailedCount: 0, StartedAt: "2026-09-22T12:21:51Z", CompletedAt: "2026-09-22T12:26:55Z"},
{Round: 3, EnqueuedCount: 9000, NextRoundCount: 0, SuccessCount: 0, FailedCount: 9000, StartedAt: "2026-09-22T12:26:56Z", CompletedAt: "2026-09-22T12:32:01Z"},
}}
}
func TestKindNamesEachSyncType(t *testing.T) {
cases := []struct{ operation, want string }{
{"previous_day_usage_full_sync", "日流量同步"},
{"previous_month_usage_full_sync", "月历史同步"},
{"current_month_usage_full_sync", "本月流量快照同步"},
{"sim_status_queue_full_sync", "卡状态全量同步"},
{"traffic_usage_full_sync", "流量全量同步"},
{"usage_full_sync", "流量全量同步"},
{"imei_full_sync", "imei_full_sync"},
}
for _, c := range cases {
if got := Kind(c.operation); got != c.want {
t.Errorf("Kind(%q) = %q, want %q", c.operation, got, c.want)
}
}
}
func TestKindKeepsTrafficSyncTitlesDistinct(t *testing.T) {
seen := map[string]string{}
for _, operation := range []string{"previous_day_usage_full_sync", "previous_month_usage_full_sync", "current_month_usage_full_sync"} {
title := Kind(operation)
if other, exists := seen[title]; exists {
t.Fatalf("%q and %q share title %q", other, operation, title)
}
seen[title] = operation
}
}
func TestCardTitleNamesTrafficSyncType(t *testing.T) {
cases := []struct{ operation, want string }{
{"previous_day_usage_full_sync", "生产 · 日流量同步"},
{"previous_month_usage_full_sync", "生产 · 月历史同步"},
{"current_month_usage_full_sync", "生产 · 本月流量快照同步"},
}
for _, c := range cases {
a := archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: "run-title", Operation: c.operation}}
body, err := Card(a, "prod")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `"content":"`+c.want+`"`) {
t.Fatalf("card for %q missing title %q: %s", c.operation, c.want, body)
}
}
}
func TestTextContainsRoundThreeFailure(t *testing.T) {
text := Text(sample(), "preview")
for _, want := range []string{"开始时间:2026-09-22 19:42:34", "结束时间:2026-09-22 20:32:01", "实际执行:3 轮", "第 3 轮", "终态失败 9,000", "5分5秒", "0.00%"} {
if !strings.Contains(text, want) {
t.Fatalf("text missing %q:\n%s", want, text)
}
}
if strings.Contains(text, "2026-09-22 20:26:56") {
t.Fatalf("text exposes per-round start time: %s", text)
}
}
func TestCardIsStructuredAndShowsFullRunID(t *testing.T) {
body, err := Card(sample(), "preview")
if err != nil {
t.Fatal(err)
}
value := string(body)
for _, want := range []string{
`"msg_type":"interactive"`,
`"content":"预演 · 卡状态全量同步"`,
`"fields"`,
`**开始时间**\n2026-09-22 19:42:34`,
`**结束时间**\n2026-09-22 20:32:01`,
`第 3 轮 · 9,000 张 · 5分5秒`,
`进入下轮 0 · 终态失败 9,000`,
`Run: run-1`,
} {
if !strings.Contains(value, want) {
t.Fatalf("card missing %q: %s", want, body)
}
}
if strings.Contains(value, "2026-09-22 20:26:56") {
t.Fatalf("card exposes per-round start time: %s", body)
}
}
func TestCardShowsFullRunID(t *testing.T) {
runID := "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41"
card, err := Card(archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: runID}}, "preview")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(card), "Run: "+runID) {
t.Fatalf("card truncated Run ID: %s", card)
}
}
+68
View File
@@ -0,0 +1,68 @@
package render
import (
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"sort"
"strings"
)
func supplyBlock(a archive.RunArchive) string {
if !isTrafficUsage(a.Run.Operation) || a.Run.SyncType == "replay" {
return ""
}
s, present := a.Run.Supply()
if !present {
return ""
}
if s == nil {
return "供料观测:不可用(遥测格式非法);业务结果不受影响"
}
status := []string{}
if s.Partial {
status = append(status, "partial")
}
if s.Truncated {
status = append(status, "truncated")
}
if len(status) == 0 {
status = append(status, "完整")
}
unknown, unconfirmed := s.Summary.UnknownMS, s.Summary.UnconfirmedMS
lines := []string{"供料观测(Run 首轮扫描窗口,约1秒分辨率,时间为采样估计)", fmt.Sprintf("目标 %d · 有效 %d · 确认缺料 %d · unknown 通道 %d", s.Summary.Targets, s.Summary.Valid, s.Summary.Confirmed, s.Summary.Unknown), fmt.Sprintf("最长中断 %.1f秒 · 累计 %.1f通道秒(允许通道重叠)", float64(s.Summary.LongestMS)/1000, float64(s.Summary.TotalMS)/1000), fmt.Sprintf("待确认空闲 %.1f通道秒 · unknown %.1f通道秒 · %s", float64(unconfirmed)/1000, float64(unknown)/1000, strings.Join(status, " / "))}
if s.Truncated {
lines = append(lines, "明细已截断;Run摘要覆盖全部目标,归档保留最长等待前100通道")
}
if s.Summary.Confirmed == 0 && !s.Partial && !s.Truncated && s.Summary.Unknown == 0 && unconfirmed == 0 && s.Summary.Valid == s.Summary.Targets {
lines = append(lines, "约1秒分辨率下未发现确认缺料")
}
channels := append([]archive.SupplyChannel(nil), s.Channels...)
sort.Slice(channels, func(i, j int) bool {
a, b := channels[i], channels[j]
av, bv := max(a.Startup.LongestMS, a.Confirmed.LongestMS), max(b.Startup.LongestMS, b.Confirmed.LongestMS)
if av == bv {
return a.Key < b.Key
}
return av > bv
})
for _, c := range channels[:min(5, len(channels))] {
longest := max(c.Startup.LongestMS, c.Confirmed.LongestMS)
if longest == 0 {
continue
}
phase := "未记录"
var phaseMS int64
for k, v := range c.Stages {
if v > phaseMS || (v == phaseMS && k < phase) {
phase, phaseMS = k, v
}
}
signal := ""
if longest >= 5000 {
signal = "(≥5秒排查信号)"
}
lines = append(lines, fmt.Sprintf("%s:最长 %.1f秒%s;启动 %.1f秒 / 后续 %.1f秒;同期主要生产者阶段 %s %.1f秒", c.Key, float64(longest)/1000, signal, float64(c.Startup.TotalMS)/1000, float64(c.Confirmed.TotalMS)/1000, phase, float64(phaseMS)/1000))
}
lines = append(lines, "小于采样间隔的中断可能漏检;同期阶段不证明因果,5秒提示不构成SLA。完整明细见归档。")
return strings.Join(lines, "\n")
}
+49
View File
@@ -0,0 +1,49 @@
package render
import (
"encoding/json"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"strings"
"testing"
)
func TestSupplyBlockCoversEveryTrafficOperation(t *testing.T) {
observation := json.RawMessage(`{"schemaVersion":1,"resolutionMs":1000,"summary":{"targetChannels":1,"validChannels":1,"confirmedChannels":1,"confirmedChannelMs":6000,"longestMs":6000}}`)
for _, operation := range []string{"previous_day_usage_full_sync", "previous_month_usage_full_sync", "current_month_usage_full_sync", "traffic_usage_full_sync"} {
a := archive.RunArchive{Run: archive.RunSummary{Operation: operation, SupplyObservation: observation}}
if supplyBlock(a) == "" {
t.Fatalf("traffic operation %q must show supply block", operation)
}
}
for _, operation := range []string{"sim_status_queue_full_sync", "imei_full_sync"} {
a := archive.RunArchive{Run: archive.RunSummary{Operation: operation, SupplyObservation: observation}}
if supplyBlock(a) != "" {
t.Fatalf("non-traffic operation %q must omit supply block", operation)
}
}
}
func TestSupplyCompatibilityAndPartial(t *testing.T) {
a := archive.RunArchive{Run: archive.RunSummary{Operation: "current_month_usage_full_sync"}}
if supplyBlock(a) != "" {
t.Fatal("legacy archive must omit telemetry")
}
a.Run.SupplyObservation = json.RawMessage(`{"schemaVersion":1,"resolutionMs":1000,"partial":true,"summary":{"targetChannels":2,"validChannels":2,"confirmedChannels":1,"confirmedChannelMs":6000,"longestMs":6000},"channels":[{"chanKey":"a","confirmedWait":{"totalMs":6000,"longestMs":6000},"producerStageMs":{"global_capacity":6000}}]}`)
text := supplyBlock(a)
for _, want := range []string{"partial", "6.0通道秒", "global_capacity", "约1秒分辨率", "≥5秒"} {
if !strings.Contains(text, want) {
t.Fatalf("missing %s in %s", want, text)
}
}
if strings.Contains(text, "全程供料充足") {
t.Fatal("unsupported conclusion")
}
a.Run.SupplyObservation = json.RawMessage(`"bad"`)
if !strings.Contains(supplyBlock(a), "不可用") {
t.Fatal("bad optional telemetry must degrade")
}
a.Run.SyncType = "replay"
if supplyBlock(a) != "" {
t.Fatal("replay changed")
}
}
+64
View File
@@ -0,0 +1,64 @@
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type State struct {
RunID string `json:"runId"`
SentAt time.Time `json:"sentAt"`
}
type Store struct{ dir string }
func New(dir string) Store { return Store{dir: dir} }
func (s Store) Prepare() error {
dir := filepath.Join(s.dir, "sent")
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create sent marker directory: %w", err)
}
file, err := os.CreateTemp(dir, ".write-check-*")
if err != nil {
return fmt.Errorf("check sent marker directory: %w", err)
}
name := file.Name()
if err := file.Close(); err != nil {
_ = os.Remove(name)
return fmt.Errorf("close sent marker write check: %w", err)
}
if err := os.Remove(name); err != nil {
return fmt.Errorf("remove sent marker write check: %w", err)
}
return nil
}
func (s Store) Has(runID string) (bool, error) {
_, err := os.Stat(filepath.Join(s.dir, "sent", runID+".json"))
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("check sent marker: %w", err)
}
func (s Store) Mark(runID string) error {
if err := s.Prepare(); err != nil {
return err
}
dir := filepath.Join(s.dir, "sent")
body, _ := json.Marshal(State{RunID: runID, SentAt: time.Now()})
tmp := filepath.Join(dir, fmt.Sprintf(".%s.tmp", runID))
final := filepath.Join(dir, runID+".json")
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return err
}
return os.Rename(tmp, final)
}
// Dir 返回状态目录,供调用方复用(例如告警节流状态落盘)。
func (s Store) Dir() string { return s.dir }
+31
View File
@@ -0,0 +1,31 @@
package state
import "testing"
func TestPrepareCreatesWritableSentDirectory(t *testing.T) {
s := New(t.TempDir())
if err := s.Prepare(); err != nil {
t.Fatal(err)
}
}
func TestMarkIsIdempotentForLookup(t *testing.T) {
s := New(t.TempDir())
sent, err := s.Has("run-1")
if err != nil {
t.Fatal(err)
}
if sent {
t.Fatal("run should not be marked before send")
}
if err := s.Mark("run-1"); err != nil {
t.Fatal(err)
}
sent, err = s.Has("run-1")
if err != nil {
t.Fatal(err)
}
if !sent {
t.Fatal("run should be marked")
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"version": 1,
"run": {
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"operation": "sim_status_queue_full_sync",
"queryMonth": "",
"status": "completed_with_failures",
"producerDone": true,
"currentRound": 3,
"activeCount": 0,
"inflightCount": 0,
"nextRoundCount": 0,
"successCount": 175893,
"failedCount": 10504,
"createdAt": "2026-09-22T11:42:33.562765565Z",
"producerCompletedAt": "2026-09-22T11:43:36.656900096Z",
"completedAt": "2026-09-22T12:32:01.555760358Z",
"syncType": "normal"
},
"rounds": [
{
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"round": 1,
"enqueuedCount": 186397,
"activeCount": 0,
"nextRoundCount": 9023,
"successCount": 175870,
"failedCount": 1504,
"upstreamAttemptCount": 0,
"batchCount": 1872,
"startedAt": "2026-09-22T11:42:34.483546236Z",
"completedAt": "2026-09-22T12:21:49.958362058Z",
"errorCounts": {}
},
{
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"round": 2,
"enqueuedCount": 9023,
"activeCount": 0,
"nextRoundCount": 9000,
"successCount": 23,
"failedCount": 0,
"upstreamAttemptCount": 0,
"batchCount": 92,
"startedAt": "2026-09-22T12:21:51.441846314Z",
"completedAt": "2026-09-22T12:26:55.781082913Z",
"errorCounts": {}
},
{
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"round": 3,
"enqueuedCount": 9000,
"activeCount": 0,
"nextRoundCount": 0,
"successCount": 0,
"failedCount": 9000,
"upstreamAttemptCount": 0,
"batchCount": 90,
"startedAt": "2026-09-22T12:26:56.677525864Z",
"completedAt": "2026-09-22T12:32:01.555013325Z",
"errorCounts": {}
}
]
}
+11
View File
@@ -0,0 +1,11 @@
# 运行时数据(含数据库文件、日志、上传件)
data/
# 本地覆盖配置(如需保留自己的修改而不进版本库,可放 config/local.yaml)
config/*/local.yaml
# 编辑器与系统文件
.DS_Store
*.swp
.idea/
.vscode/
+129
View File
@@ -0,0 +1,129 @@
# IotLCM 部署仓
IotLCM 物联网卡管理平台的部署编排与数据库初始化脚本。使用 Docker Compose 在单机上拉起完整平台。
## 一键启动
```bash
git clone <仓库地址> iotlcm
cd iotlcm/deploy
docker compose up -d
```
无需复制或修改任何文件,配置里已带可用默认值。首次启动会自动完成 MySQL 建库建表(95 张)、种子数据导入、时序库建表,实测约 30 秒~1 分钟。
等待 MySQL 健康后其余服务依次启动,观察进度:
```bash
docker compose logs -f mysql
```
启动完成后访问 `http://<宿主机IP>:8009`:
| 项 | 值 |
| --- | --- |
| 账号 | `admin` |
| 口令 | `Iotlcm2026Default` |
> ⚠️ **正式使用前必须修改默认口令**,见下方「上线前必改」。
## 可选服务
```bash
docker compose --profile with-mock up -d # 运营商接口挡板
docker compose --profile with-bot up -d # 飞书通知(运维自用)
docker compose --profile with-nginx up -d # 自带反向代理
```
多个 profile 可组合:`--profile with-mock --profile with-nginx up -d`
`with-bot` 是把全量同步等运维事件推送到飞书群的**内部运维工具**,不是平台功能。它需要你自己的飞书自建应用凭证(`config/bot/config.yaml` 的 `appId` / `appSecret` / `chatId`),没填就不启动。
**默认不启动,也不影响平台功能**;不需要飞书通知就完全不用管它。
## 镜像版本
所有镜像(含基础组件)都在**同一个镜像仓库** `<镜像仓库>/iotlcm/iotlcm` 下,靠 tag 区分服务与版本:
```text
crpi-.../iotlcm/iotlcm:mysql-8.0
crpi-.../iotlcm/iotlcm:redis-7.2
crpi-.../iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4
crpi-.../iotlcm/iotlcm:iotlcm-app-server-v1.0.2
```
tag 格式统一为 **`<服务名>-<版本>`**。
当前业务版本:大部分服务为 `v1.0.4`,`iotlcm-app-server` / `iotlcm-app-web` 为 `v1.0.2`(源仓最新 tag 就是 v1.0.2)。
升级时在 `docker-compose.yml` 中同步更新 tag 的版本部分,不要改用 `latest`。
## 部署内容
| 类别 | 服务 |
| --- | --- |
| 基础组件 | MySQL、Redis、RabbitMQ、Consul、TDengine(流量历史) |
| 业务服务 | admin-svr(管理端后端)、admin-ui(管理端前端)、openapi(对外 API)、app-server(小程序后端)、app-web(小程序前端)、upstream-chan(运营商对接)、upstream-proxy(通道缓存转发)、worker-svr(异步任务)、sms(短信)、docs(文档站) |
| 可选 | onelink-mock(运营商接口挡板)、bot(飞书通知)、nginx(反向代理) |
## 目录结构
```
iotlcm/deploy/
├── docker-compose.yml # 服务编排(含全部默认值)
├── config/ # 各服务配置,已带可用默认值
│ ├── admin-svr/config.yaml
│ ├── admin-ui/default.conf
│ ├── app-server/config.yaml
│ ├── bot/config.yaml
│ ├── nginx/iotlcm.conf
│ ├── openapi/config.yaml
│ ├── sms/config.yaml
│ ├── upstream-chan/config.yaml
│ ├── upstream-proxy/config.yaml
│ └── worker-svr/config.yaml
├── schema/init.sql # MySQL 初始化(单文件,首次启动自动执行)
├── tdengine-init/ # TDengine 建库建表(首次启动自动执行)
└── data/ # 运行数据(首次启动自动创建,不入版本库)
```
全新初始化生成 95 张业务表(原有 91 张 + worker 异步恢复所需 4 张);已部署旧库不会自动执行新增迁移,见 `schema/README.md`。
编排已内置 TDengine(流量历史库),由 `tdengine-init` 服务在 TDengine 健康后自动建库建表:
| 库 | 分片 | 保留 | 超级表 |
| --- | --- | --- | --- |
| `iotlcm_daily` | 7 天 | 3 年 | `t_sim_card_usage_daily` |
| `iotlcm_monthly` | 30 天 | 3 年 | `t_sim_card_usage_monthly` |
结构取自生产环境,`tdengine-init` 会校验建表结果,缺表则报错退出。业务代码只读写不建表,因此这一步不能跳过。
TDengine 版本为 `3.3.6.13`(公开镜像可得),生产用的是 `3.4.0.9`。两者在流量历史的读写上一致;如需与生产完全对齐,可自行构建镜像并替换 `docker-compose.yml` 里的 tag。
## 上线前必改
默认口令仅用于快速试用。**正式部署前必须修改以下内容**,具体位置见[部署手册](../docs/docs/deploy/index.md):
| 项 | 位置 |
| --- | --- |
| 数据库口令(root 与应用账号) | `docker-compose.yml` + `config/*/config.yaml` + `schema/init.sql` |
| Redis 口令 | `docker-compose.yml` + `config/*/config.yaml` |
| RabbitMQ 口令 | `docker-compose.yml` + `config/*/config.yaml` |
| admin 初始口令 | `docker-compose.yml` + `schema/init.sql` |
| JWT 签名密钥 | `config/admin-svr`、`config/openapi`、`config/app-server` 三处 |
| 对外域名 | `config/nginx/iotlcm.conf` |
::: warning 改口令必须多处同步
因为配置文件里是字面值(未使用环境变量),改口令时**编排、配置、初始化脚本三处必须一致**,漏改会导致服务连不上数据库。
:::
## 重要提示
- **`JWT_SIGNING_KEY` 必须在 `admin-svr`、`openapi`、`app-server` 三者间完全一致**,否则令牌跨服务不通
- **镜像标签不要用 `latest`**,生产环境请固定到具体版本
- **改 `schema/` 下的脚本不会自动生效**,需要清空 `data/mysql` 重新初始化(会丢失全部数据)
- 默认只监听 `127.0.0.1` 的服务需通过反向代理对外
## 许可
[Apache License 2.0](LICENSE)
+229
View File
@@ -0,0 +1,229 @@
# ============================================================
# iotlcm-admin-svr 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
system:
name: "IotLCM"
version: "1.0"
description: "IotLCM Server"
gfmode: "prod" # prod / dev
upload:
local:
path: "resource/upload"
workerDiagnostic:
enabled: true
server:
address: ":8008"
openapiPath: "/api.json"
swaggerPath: "/swagger"
indexFiles: ["index.html", "index.htm"]
indexFolder: false
fileServerEnabled: true
initLoadMemoryDB: false
maxHeaderBytes: "20KB"
clientMaxBodySize: "200MB"
logger:
path: "/app/logs"
file: "{Y-m-d-H}.log"
prefix: ""
level: "info"
timeFormat: "2006-01-02T15:04:05"
ctxKeys: []
header: true
stdout: true
rotateSize: 0
rotateExpire: 0
rotateBackupLimit: 0
rotateBackupExpire: 0
rotateBackupCompress: 0
rotateCheckInterval: "1h"
stdoutColorDisabled: false
writerColorEnable: false
# ---------- JWT ----------
# 必改:signingKey 必须与 openapi、app-server 完全一致。
jwt:
signingKey: "1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e"
expiresTime: 7d
bufferTime: 1d
issuer: iotlcm
captcha:
keyLong: 6
imgWidth: 240
imgHeight: 80
openCaptcha: 0
openCaptchaTimeout: 3600
# ---------- 数据库 ----------
database:
# 旧平台库(历史数据对接)。不使用时空 link 即可。
legacyCard:
link: ""
debug: false
maxOpen: 5
maxIdle: 2
logger:
path: "/app/logs"
level: "all"
stdout: true
default:
# 使用与其余服务相同的 u_iotlcm 应用账号。
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
maxIdle: 30
maxOpen: 100
maxLifetime: 120
memorydb:
link: "sqlite::@file(:memory:)"
debug: false
maxIdle: 1
maxOpen: 1
maxLifetime: 30
cache:
adapter: "redis"
fileDir: "/app/cache"
batch:
allocate:
chunkSize: 5000
maxFileSize: 52428800 # 50MB
# ---------- 流量预警 ----------
warning:
expire:
firstWarnDays: 30
secondWarnDays: 15
thirdWarnDays: 7
# 短信通道:指向 iotlcm-sms 服务。不用短信时 enabled 改 false。
sms:
enabled: true
url: "http://iotlcm-sms:11190/api/v1/sms/send"
grpcAddr: "iotlcm-sms:11200"
timeout: "5s"
# 以下为短信服务商参数,一般留空(由 sms 服务统一持有)
routerId: 0
serviceType: ""
regionId: ""
accessKeyId: ""
accessKeySecret: ""
sdkAppId: ""
signName: ""
templateId: ""
msgContentTemplate: ""
# 微信小程序订阅消息。不用时 enabled 改 false。
wechat:
enabled: false
appId: ""
appSecret: ""
templateId: ""
page: "pages/message/index"
timeout: "5s"
# ---------- 注册验证码 ----------
register:
# 走 HTTP 短信网关
sms:
url: ""
method: "POST"
headers: {}
bodyTemplate: '{"phone":"{contact}","code":"{code}"}'
successStatus: 200
# 走 SMTP 邮件
email:
host: "" # 如 smtp.example.com
port: 465
username: ""
password: ""
from: ""
fromName: "物联网卡管理平台"
subject: "注册验证码"
bodyTemplate: "您的注册验证码是:{code},6小时内有效。"
ssl: true
# ---------- Redis ----------
redis:
default:
mode: "single" # single / cluster / sentinel
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
idleTimeout: "60s"
ClientName: "SystemCache"
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
# ---------- TDengine(流量历史) ----------
# 需要 TDengine 3.x 的 WebSocket 接口(6041)。
# 不启用时序库时 link 留空,历史流量相关功能不可用。
tsdb:
database: "TdEngine"
tdengine:
type: "taosWS"
# TDengine 3.x WebSocket 地址(容器内服务名 tdengine,端口 6041)
# 口令为 TDengine 默认 root/taosdata;改过口令需同步改这里
dsn: "root:taosdata@ws(tdengine:6041)/"
# dbName 只是连接池键名与兜底值,不决定实际访问哪个库
dbName: "iotlcm_daily"
dailyDbName: "iotlcm_daily"
monthlyDbName: "iotlcm_monthly"
maxOpenConns: 100
maxIdleConns: 10
# ---------- 免登录白名单 ----------
router:
exceptLogin:
- /api/v1/system/auth/login
- /api/v1/system/auth/refresh
- /api/v1/system/captcha
- /api/v1/system/auth/getTenantList
- /openapi/v3/query/token
- /api/v1/system/tTplatformSetting/view
- /api/v1/pay/alipay/notify
- /api/v1/pay/alipay/return
# ---------- 支付宝(可选) ----------
# 不使用支付功能时整段删掉。
alipay:
appId: ""
privateKey: ""
alipayPublicKey: ""
notifyUrl: "https://your-domain.example.com/api/v1/pay/alipay/notify"
returnUrl: "https://your-domain.example.com/#/enterpriseManagement/tBalanceDetail/tAgentBalance"
isProduction: false
pay:
expireTime: 60
balanceRecharge:
timeoutDelay: "30m"
# ---------- 上游服务地址 ----------
# 指向同 compose 内的服务名,一般无需修改。
upstream:
# 控制台智能诊断内部凭据,须与 upstream-proxy 的 appDiagnosis.internalToken 一致
appDiagnosisToken: "iotlcm_diagnosis_2026"
proxy:
address: "upstream-proxy:8510"
timeout: 10s
gateway:
address: "upstream-chan:8110"
timeout: 10s
+61
View File
@@ -0,0 +1,61 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
client_max_body_size 200m;
access_log /var/log/nginx/access_admin-ui.log;
error_log /var/log/nginx/error_admin-ui.log;
# 入口 HTML 不强缓存,保证发布后立即生效
location = /index.html {
try_files $uri =404;
add_header Cache-Control no-cache,no-store,must-revalidate always;
add_header Pragma no-cache always;
add_header Expires 0 always;
}
# 运行时配置文件不强缓存
location ~* ^/(?:_app[.]config[.]js|config[.]json|menu-setting[.]json|setting[.]json)$ {
try_files $uri =404;
add_header Cache-Control no-cache,no-store,must-revalidate always;
add_header Pragma no-cache always;
add_header Expires 0 always;
}
# 带 hash 的静态资源长期缓存
location ~* ^/(?:js|jse|css)/ {
try_files $uri =404;
add_header Cache-Control public,max-age=31536000,immutable always;
}
location / {
try_files $uri $uri/ /index.html;
}
# 后端 API 转发(同 compose 内服务名)
location /api/ {
proxy_set_header Connection upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_pass http://admin-svr:8008/api/v1/;
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;
}
# 上传文件目录(与 admin-svr 共享同一挂载卷)
location /upload/ {
alias /app/upload/;
}
location /app/upload/ {
alias /app/upload/;
}
gzip on;
gzip_types text/plain application/javascript text/css application/json;
}
+266
View File
@@ -0,0 +1,266 @@
# ============================================================
# iotlcm-app-server 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
system:
name: "IotLCM"
version: "1.0"
description: "IotLCM App Server"
gfmode: "prod"
upload:
local:
path: "resource/upload"
server:
address: ":8010"
openapiPath: "/api.json"
swaggerPath: "/swagger"
indexFiles: ["index.html", "index.htm"]
indexFolder: false
serverRoot: "resource/public"
searchPaths: ["/resource/public"]
fileServerEnabled: true
initLoadMemoryDB: false
maxHeaderBytes: "20KB"
clientMaxBodySize: "200MB"
readTimeout: "20m"
writeTimeout: "20m"
allowedDomains: []
enabledDBOperationLog: false
logger:
path: "/app/resource/log/server"
file: "{Y-m-d-H}.log"
prefix: ""
level: "info"
timeFormat: "2006-01-02T15:04:05"
ctxKeys: []
header: true
stdout: true
rotateSize: 0
rotateExpire: 0
rotateBackupLimit: 0
rotateBackupExpire: 0
rotateBackupCompress: 0
rotateCheckInterval: "1h"
stdoutColorDisabled: false
writerColorEnable: false
# ---------- JWT ----------
# 必改:必须与 admin-svr、openapi 完全一致。
jwt:
signingKey: "1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e"
expiresTime: "7d"
bufferTime: "1d"
issuer: "iotlcm"
captcha:
keyLong: 6
imgWidth: 240
imgHeight: 80
openCaptcha: 0
openCaptchaTimeout: 3600
# ---------- 注册验证码:走短信服务 ----------
register:
sms:
url: "http://iotlcm-sms:11190/api/v1/sms/send"
method: "POST"
headers: {}
bodyTemplate: '{"config":{"scene":"{scene}"},"params":{"phoneNumber":"{contact}","msgContent":"{code}"}}'
successStatus: 200
# ---------- 数据库 ----------
database:
# 旧平台库(历史数据对接)。不使用时空 link 即可。
legacyCard:
link: ""
debug: false
maxOpen: 5
maxIdle: 2
logger:
path: "/app/resource/log/server/sql"
level: "all"
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
maxIdle: 30
maxOpen: 100
maxLifetime: 120
memorydb:
link: "sqlite::@file(:memory:)"
debug: false
maxIdle: 1
maxOpen: 1
maxLifetime: 30
cache:
adapter: "redis"
fileDir: "/app/resource/cache"
# ---------- Redis ----------
redis:
default:
mode: "single"
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
idleTimeout: "60s"
ClientName: "AppServerCache"
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
# ---------- 异步业务 ----------
workerBatchAsync:
enabled: true
exchange: "iotlcm.batch"
queueName: "iotlcm.batch.execute"
failOnPublish: false
appBusinessAsync:
enabled: true
exchange: "iotlcm.app"
queueName: "iotlcm.app.business"
failOnPublish: false
# ---------- 支付(可选) ----------
# 不使用支付功能时把 enabled 全部改 false。
payment:
balanceRecharge:
enabled: false
mock:
enabled: false
wechat:
enabled: false
envFile: ""
apiBase: "https://api.mch.weixin.qq.com"
mchId: ""
mchSerialNo: ""
apiV3Key: ""
# 微信支付证书需单独放入 data/certs 目录,不随仓库分发
privateKeyPath: "resource/certs/wechatpay_apiclient_key.pem"
notifyUrl: "https://your-domain.example.com/app/api/v1/app/payment/wechat/notify"
notifyBaseUrl: "https://your-domain.example.com/app"
refundNotifyUrl: "https://your-domain.example.com/app/api/v1/app/payment/wechat/refund-notify"
timeoutMinutes: 5
platformPublicKeyId: ""
platformPublicKeyPath: "resource/certs/wechatpay_platform_public_key.pem"
h5:
enabled: false
appId: ""
appName: ""
appUrl: "https://your-domain.example.com/app"
payerClientIp: ""
mini:
enabled: false
appId: ""
appSecret: ""
# ---------- 卡号识别(拍照查卡) ----------
# 走 OpenAI 兼容接口的视觉模型。不需要该功能时 enabled 改 false。
appCardRecognition:
enabled: false
provider: "openai-compatible"
baseUrl: ""
endpoint: ""
model: ""
apiKey: ""
timeout: "25s"
maxImageBytes: 8388608
maxTokens: 256
rateLimit:
enabled: true
count: 20
period: "1m"
# 微信订阅消息模板
appWarning:
wechatSubscribeTemplateId: ""
# ---------- TDengine(流量历史) ----------
tsdb:
database: "TdEngine"
tdengine:
type: "taosWS"
# TDengine 3.x WebSocket 地址(容器内服务名 tdengine,端口 6041)
# 口令为 TDengine 默认 root/taosdata;改过口令需同步改这里
dsn: "root:taosdata@ws(tdengine:6041)/"
# dbName 只是连接池键名与兜底值,不决定实际访问哪个库
dbName: "iotlcm_daily"
dailyDbName: "iotlcm_daily"
monthlyDbName: "iotlcm_monthly"
maxOpenConns: 100
maxIdleConns: 10
# ---------- 免登录白名单 ----------
router:
exceptLogin:
- /api/v1/auth/captcha
- /api/v1/auth/side-captcha
- /api/v1/auth/check-side-captcha
- /api/v1/auth/sms
- /api/v1/auth/register
- /api/v1/auth/login
- /api/v1/auth/wechat-login
- /api/v1/auth/wechat-bind-phone
- /api/v1/auth/forgot-password
- /api/v1/card/public-query
- /api/v1/card/recognize
- /api/v1/card/public-package-detail
- /api/v1/open/diagnosis/info
- /api/v1/open/diagnosis/flow
- /api/v1/payment/wechat/notify
- /api/v1/payment/wechat/refund-notify
- /api/v1/app/auth/captcha
- /api/v1/app/auth/side-captcha
- /api/v1/app/auth/check-side-captcha
- /api/v1/app/auth/sms
- /api/v1/app/auth/register
- /api/v1/app/auth/login
- /api/v1/app/auth/wechat-login
- /api/v1/app/auth/wechat-bind-phone
- /api/v1/app/auth/forgot-password
- /api/v1/app/card/public-query
- /api/v1/app/card/recognize
- /api/v1/app/card/public-package-detail
- /api/v1/app/open/diagnosis/info
- /api/v1/app/open/diagnosis/flow
- /api/v1/app/payment/wechat/notify
- /api/v1/app/payment/wechat/refund-notify
- /openapi/v3/query/token
# ---------- 上游服务地址 ----------
upstream:
# 控制台智能诊断内部凭据,须与 admin-svr 一致
appDiagnosisToken: "iotlcm_diagnosis_2026"
provider: "onelink_mock"
default:
clientName: "http://upstream-proxy:8510"
timeout: "10s"
gateway:
clientName: "http://upstream-chan:8110"
timeout: "10s"
# 移动 OneLink 挡板(联调用)。对接真实运营商时 enabled 改 false。
onelinkMock:
enabled: false
baseUrl: "http://onelink-mock:8080/v5"
appid: ""
password: ""
timeout: "10s"
tokenTTL: 3600
accounts: []
+28
View File
@@ -0,0 +1,28 @@
# ============================================================
# iotlcm-bot 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
# 说明:本服务把全量同步等运维事件推送到飞书群,属可选的运维通知工具,
# 不是平台功能本身。默认不启动(需显式 --profile with-bot),
# 不启动不影响任何平台功能。
environment: prod
# 全量同步归档目录与状态目录(容器内路径,compose 已挂载)
archiveDir: /data/full-sync
stateDir: /data/state
# 轮询间隔
pollInterval: 10s
# ---------- 飞书(自建应用,自己的凭证) ----------
# appId / appSecret:在飞书开放平台创建自建应用后获取
# chatId:接收通知的群会话 ID
feishu:
appId: ""
appSecret: ""
chatId: ""
+141
View File
@@ -0,0 +1,141 @@
# ============================================================
# IotLCM 对外入口 nginx 配置示例
#
# 作用:把多个服务按路径分流到统一域名下。
# 若宿主机已有 nginx 或面板,可不启动本容器,
# 把下面的 location 段落并入宿主机配置即可。
#
# 把 your-domain.example.com 替换为你的实际域名。
# ============================================================
upstream iotlcm_admin_api {
server admin-svr:8008;
}
upstream iotlcm_admin_ui {
server admin-ui:80;
}
upstream iotlcm_openapi_api {
server openapi:8000;
}
upstream iotlcm_docs {
server docs:80;
}
upstream iotlcm_app_web {
server app-web:80;
}
upstream iotlcm_app_server {
server app-server:8010;
}
# ---------- OpenAPI 对外域名 ----------
server {
listen 80;
server_name api.your-domain.example.com;
client_max_body_size 200m;
location ^~ /openapi/v3/ {
proxy_pass http://iotlcm_openapi_api;
proxy_http_version 1.1;
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;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# 运营商能力代理(白名单转发 / 共享 Token)
location ^~ /route/open/ {
proxy_pass http://iotlcm_openapi_api;
proxy_http_version 1.1;
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;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location / {
proxy_pass http://iotlcm_openapi_api;
proxy_http_version 1.1;
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;
}
}
# ---------- 管理端 ----------
server {
listen 80;
server_name admin.your-domain.example.com;
client_max_body_size 200m;
location / {
proxy_pass http://iotlcm_admin_ui;
proxy_http_version 1.1;
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;
}
# 文档站(子路径 /docs/)
location = /docs {
return 301 /docs/;
}
location /docs/ {
proxy_pass http://iotlcm_docs;
proxy_http_version 1.1;
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;
}
}
# ---------- 小程序 / H5 ----------
server {
listen 80;
server_name app.your-domain.example.com;
client_max_body_size 200m;
location ^~ /app/api/v1/ {
proxy_pass http://iotlcm_app_server/api/v1/;
proxy_http_version 1.1;
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;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location ^~ /app/ {
proxy_pass http://iotlcm_app_web;
proxy_http_version 1.1;
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;
}
location / {
proxy_pass http://iotlcm_app_web;
proxy_http_version 1.1;
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;
}
}
+93
View File
@@ -0,0 +1,93 @@
# ============================================================
# iotlcm-openapi 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
system:
name: "IotLCM-OpenAPI"
version: "1.0"
description: "IotLCM OpenAPI Service"
gfmode: "prod"
server:
address: ":8000"
openapiPath: "/api.json"
swaggerPath: "/swagger"
maxHeaderBytes: "20KB"
clientMaxBodySize: "200MB"
readTimeout: "20m"
writeTimeout: "20m"
logger:
path: "/app/logs"
file: "{Y-m-d-H}.log"
level: "info"
timeFormat: "2006-01-02T15:04:05"
stdout: true
header: true
# ---------- JWT ----------
# 必改:必须与 admin-svr、app-server 完全一致,
# 否则 admin 签发的令牌在 openapi 侧校验不通过。
jwt:
signingKey: "1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e"
expiresTime: "7d"
bufferTime: "1d"
issuer: "iotlcm"
# ---------- 数据库 ----------
# 建议为 OpenAPI 单独建只读账号,与 admin 的账号隔离。
database:
logger:
path: "/app/logs/sql"
level: "all"
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
maxIdle: 30
maxOpen: 100
maxLifetime: 120
cache:
adapter: "redis"
fileDir: "resource/cache"
# ---------- Redis ----------
redis:
default:
mode: "single"
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
idleTimeout: "60s"
ClientName: "OpenAPICache"
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
# ---------- 免登录白名单 ----------
router:
exceptLogin:
- /openapi/v3/query/token
# ---------- 上游服务地址 ----------
upstream:
proxy:
address: "upstream-proxy:8510"
timeout: "10s"
gateway:
address: "upstream-chan:8110"
timeout: "10s"
+28
View File
@@ -0,0 +1,28 @@
# ============================================================
# iotlcm-sms 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
# 服务商:1=阿里云,2=腾讯云
serviceType: "1"
# ---------- 必改:服务商凭据 ----------
regionId: cn-hangzhou
accessKeyId: ""
accessKeySecret: ""
# 短信签名,需已在服务商侧审核通过
signName: "示例签名"
# 仅腾讯云需要:
# sdkAppId: ""
# ---------- 场景模板库(可选) ----------
# 配置后按 scene 从 t_sms_template_config 表读取模板与签名,
# 改库即生效(短 TTL 缓存)。
# 不配置则 scene 功能关闭,调用方必须自带 templateId。
sceneDbDsn: "u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=true&loc=Local"
sceneDbCacheTtl: 60s
+104
View File
@@ -0,0 +1,104 @@
# ============================================================
# iotlcm-upstream-chan 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
system:
name: "IotLCM-Upstream-Chan"
version: "1.0"
description: "IotLCM Upstream Channel Service"
gfmode: "prod"
upload:
local:
path: "/app/upload"
server:
name: "iotlcmupstreamchan-http-svc"
address: ":8110"
openapiPath: "/api.json"
swaggerPath: "/swagger"
indexFiles: ["index.html", "index.htm"]
indexFolder: false
fileServerEnabled: true
initLoadMemoryDB: false
maxHeaderBytes: "20KB"
clientMaxBodySize: "200MB"
# ---------- 服务注册 ----------
registry:
type: "consul"
address: "consul:8500"
logger:
path: "/app/logs"
file: "{Y-m-d-H}.log"
prefix: ""
level: "info"
timeFormat: "2006-01-02T15:04:05.000"
ctxKeys: []
header: true
stdout: true
rotateSize: 0
rotateExpire: 0
rotateBackupLimit: 0
rotateBackupExpire: 0
rotateBackupCompress: 0
rotateCheckInterval: "1h"
stdoutColorDisabled: false
writerColorEnable: false
# ---------- 数据库 ----------
database:
logger:
path: "/app/logs"
level: "all"
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
maxIdle: 30
maxOpen: 100
maxLifetime: 120
memorydb:
link: "sqlite::@file(:memory:)"
debug: false
maxIdle: 1
maxOpen: 1
maxLifetime: 30
cache:
adapter: "redis"
fileDir: "resource/cache"
# ---------- Redis ----------
redis:
default:
mode: "single"
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
idleTimeout: "60s"
ClientName: "SystemCache"
# ---------- 依赖行为 ----------
dependencies:
# 运营商 Token 刷新策略
token_refresh:
mode: "expired_only" # expired_only / always
skew_seconds: 600 # 提前刷新的时间窗(秒)
# 移动 OneLink 挡板地址。
# 启动了 onelink-mock(--profile with-mock)时保持此值;
# 对接真实运营商时删掉本段或改为真实地址。
sim_yd_mock_v5:
url: "http://onelink-mock:8080/v5/"
+110
View File
@@ -0,0 +1,110 @@
# ============================================================
# iotlcm-upstream-proxy 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
system:
name: "IotLCM-Upstream-Proxy"
version: "1.0"
description: "IotLCM Upstream Proxy Service"
gfmode: "prod"
upload:
local:
path: "/app/upload"
# ---------- 智能诊断内部凭据 ----------
# 控制台「智能诊断」调用本服务的内部凭据,
# 必须与 admin-svr 的 upstream.appDiagnosisToken 一致。
# 生成:openssl rand -hex 16
appDiagnosis:
internalToken: "iotlcm_diagnosis_2026"
server:
name: "iotlcmupstreamproxy-grpc-svc"
address: ":8510"
openapiPath: "/api.json"
swaggerPath: "/swagger"
indexFiles: ["index.html", "index.htm"]
indexFolder: false
fileServerEnabled: true
initLoadMemoryDB: false
maxHeaderBytes: "20KB"
clientMaxBodySize: "200MB"
# ---------- 服务注册 ----------
registry:
type: "consul"
address: "consul:8500"
logger:
path: "/app/logs"
file: "{Y-m-d-H}.log"
prefix: ""
level: "info"
timeFormat: "2006-01-02T15:04:05.000"
ctxKeys: []
header: true
stdout: true
rotateSize: 0
rotateExpire: 0
rotateBackupLimit: 0
rotateBackupExpire: 0
rotateBackupCompress: 0
rotateCheckInterval: "1h"
stdoutColorDisabled: false
writerColorEnable: false
# ---------- 数据库 ----------
database:
logger:
path: "/app/logs"
level: "all"
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
maxIdle: 30
maxOpen: 100
maxLifetime: 120
memorydb:
link: "sqlite::@file(:memory:)"
debug: false
maxIdle: 1
maxOpen: 1
maxLifetime: 30
cache:
adapter: "redis"
fileDir: "resource/cache"
# ---------- Redis ----------
redis:
default:
mode: "single"
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
idleTimeout: "60s"
ClientName: "SystemCache"
# ---------- 上游通道服务 ----------
gateway:
# 通道服务地址:必须带端口,只写服务名会走默认 80 端口被拒
address: "upstream-chan:8110"
timeout: 10s
# 单次批量转发的总预算上限。
# 非 APN 批量按「单卡超时 × 卡数」计算并以此封顶,
# 避免通道逐卡串行办理时请求仍在执行就被判失败
# (批量重启、停复机、批量查询)。
# worker 侧的超时配置必须大于该值。
batchTimeout: 20m
+140
View File
@@ -0,0 +1,140 @@
# ============================================================
# iotlcm-worker-svr 配置
#
# 本文件随部署仓分发,已带可运行默认值,无需复制或改名。
# 正式上线前请修改口令等敏感项;修改时需与 docker-compose.yml、
# schema/init.sql 及 config/ 下其他文件保持一致(详见 README)。
# ============================================================
system:
name: "IotLCM"
version: "1.0"
description: "IotLCM Worker"
gfmode: "prod"
upload:
local:
path: "resource/upload"
logger:
path: "/app/logs"
file: "{Y-m-d-H}.log"
prefix: ""
level: "all"
timeFormat: "2006-01-02T15:04:05"
ctxKeys: []
header: true
stdout: true
rotateSize: 0
rotateExpire: 0
rotateBackupLimit: 0
rotateBackupExpire: 0
rotateBackupCompress: 0
rotateCheckInterval: "1h"
stdoutColorDisabled: false
writerColorEnable: false
# ---------- 数据库 ----------
database:
logger:
path: "/app/logs/sql"
level: "all"
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
maxIdle: 30
maxOpen: 100
maxLifetime: 120
memorydb:
link: "sqlite::@file(:memory:)"
debug: false
maxIdle: 1
maxOpen: 1
maxLifetime: 30
cache:
adapter: "redis"
fileDir: "resource/cache"
# ---------- Redis ----------
redis:
default:
mode: "single"
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
idleTimeout: "60s"
ClientName: "WorkerCache"
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
balanceRecharge:
retryDelay: "1m"
batchExecute:
executionTimeout: "0s"
finalizationTimeout: "60s"
retryDelay: "30s"
maxRetries: 3
# ---------- 批次执行 ----------
batch:
allocate:
processingRetryAfter: "30m"
inbound:
upstreamBatchSize: 1
upstreamConcurrency: 300
# 运营商接口限流。必须按上游实际配额设置,
# 调太大会被运营商限流甚至封禁。
upstreamRateLimit:
enabled: true
mode: "redis"
redisGroup: "default"
keyPrefix: "iotlcm:worker:inbound:upstream_basic_info"
redisFailureFallback: "error"
default:
enabled: false
carriers:
mobile:
enabled: false
telecom:
enabled: true
qps: 20
burst: 1
requestCost: 1
channels: {}
stockQueryBatchSize: 1000
writeBatchSize: 1000
# ---------- TDengine(流量历史) ----------
# 不启用时序库时 dsn 留空,全量同步中的流量历史部分不可用。
tsdb:
database: "TdEngine"
tdengine:
type: "taosWS"
# TDengine 3.x WebSocket 地址(容器内服务名 tdengine,端口 6041)
# 口令为 TDengine 默认 root/taosdata;改过口令需同步改这里
dsn: "root:taosdata@ws(tdengine:6041)/"
# dbName 只是连接池键名与兜底值,不决定实际访问哪个库
dbName: "iotlcm_daily"
dailyDbName: "iotlcm_daily"
monthlyDbName: "iotlcm_monthly"
maxOpenConns: 100
maxIdleConns: 10
# ---------- 上游服务地址 ----------
upstream:
proxy:
address: "upstream-proxy:8510"
# 异步提交协议版本,保持默认
apnAsyncProtocolVersion: 1
timeout: 10s
+431
View File
@@ -0,0 +1,431 @@
name: iotlcm
# ============================================================
# IotLCM 单机部署编排
#
# 快速开始:
# 1. docker compose up -d
# 配置文件和初始口令均已内置;正式部署前按 README.md 修改默认口令。
#
# 首次启动会自动建库并导入 schema(见 schema/README.md)。
#
# 可选服务(默认不启动):
# docker compose --profile with-mock up -d # 运营商接口挡板
# docker compose --profile with-nginx up -d # 自带反向代理
# ============================================================
services:
# ---------------- 基础组件 ----------------
mysql:
# 基础组件统走同一镜像仓库(`<REGISTRY>/iotlcm/`),版本在此固定
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:mysql-8.0'
container_name: iotlcm-mysql
restart: always
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: 'Iotlcm2026Default'
MYSQL_DATABASE: 'db_iotlcm'
TZ: 'Asia/Shanghai'
volumes:
- ./data/mysql:/var/lib/mysql
# 仅在 data/mysql 为空(首次启动)时执行 schema/init.sql
- ./schema:/docker-entrypoint-initdb.d:ro
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_general_ci
- --default-time-zone=+08:00
networks:
- iotlcm-network
healthcheck:
test: ["CMD-SHELL", "MYSQL_PWD=\"$$MYSQL_ROOT_PASSWORD\" mysqladmin ping -h 127.0.0.1 -u root --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
redis:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:redis-7.2'
container_name: iotlcm-redis
restart: always
ports:
- "6379:6379"
environment:
TZ: 'Asia/Shanghai'
command:
- redis-server
- --requirepass
- 'Iotlcm2026Default'
- --appendonly
- "yes"
volumes:
- ./data/redis:/data
networks:
- iotlcm-network
healthcheck:
test: ["CMD", "redis-cli", "-a", "Iotlcm2026Default", "ping"]
interval: 10s
timeout: 5s
retries: 5
rabbitmq:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:rabbitmq-4.3.5-management'
container_name: iotlcm-rabbitmq
hostname: rabbitmq
restart: always
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: 'iotlcm'
RABBITMQ_DEFAULT_PASS: 'Iotlcm2026Default'
TZ: 'Asia/Shanghai'
volumes:
- ./data/rabbitmq:/var/lib/rabbitmq
networks:
- iotlcm-network
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# 时序数据库:存流量历史(daily / monthly),worker-svr 全量同步写这里
tdengine:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:tdengine-3.3.6.13'
container_name: iotlcm-tdengine
restart: always
ports:
- "${TDENGINE_PORT:-6041}:6041" # taosAdapter(WebSocket,应用用这个)
- "${TDENGINE_NATIVE_PORT:-6030}:6030"
environment:
TZ: '${TZ:-Asia/Shanghai}'
volumes:
- ./data/tdengine/data:/var/lib/taos
- ./data/tdengine/log:/var/log/taos
networks:
- iotlcm-network
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:6041/-/ping || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
# 建库建表:业务代码不负责建 TDengine 库表,由本服务在 tdengine 健康后执行一次
tdengine-init:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:tdengine-3.3.6.13'
container_name: iotlcm-tdengine-init
restart: "no"
entrypoint: ["sh", "/init/run.sh"]
volumes:
- ./tdengine-init:/init:ro
depends_on:
tdengine:
condition: service_healthy
networks:
- iotlcm-network
consul:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:consul-1.22'
container_name: iotlcm-consul
restart: always
ports:
- "8500:8500"
environment:
TZ: 'Asia/Shanghai'
CONSUL_LOCAL_CONFIG: |
{
"node_name": "iotlcm-node",
"bind_addr": "0.0.0.0",
"client_addr": "0.0.0.0",
"datacenter": "dc1",
"data_dir": "/consul/data",
"server": true,
"bootstrap_expect": 1,
"ui": true,
"connect": { "enabled": true }
}
volumes:
- ./data/consul:/consul/data
networks:
- iotlcm-network
# ---------------- 业务服务 ----------------
admin-svr:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4'
container_name: iotlcm-admin-svr
restart: always
ports:
- "127.0.0.1:8008:8008"
environment:
TZ: 'Asia/Shanghai'
volumes:
- ./config/admin-svr:/app/config:ro
- ./data/logs/admin-svr:/app/logs
- ./data/upload:/app/upload
- ./data/upload:/app/resource/upload
networks:
- iotlcm-network
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
admin-ui:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-admin-ui-v1.0.4'
container_name: iotlcm-admin-ui
restart: always
ports:
- "127.0.0.1:8009:80"
environment:
TZ: 'Asia/Shanghai'
volumes:
- ./config/admin-ui/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./data/logs/admin-ui:/var/log/nginx
- ./data/upload:/app/upload
networks:
- iotlcm-network
depends_on:
- admin-svr
openapi:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-openapi-v1.0.4'
container_name: iotlcm-openapi
restart: always
ports:
- "127.0.0.1:8000:8000"
environment:
TZ: 'Asia/Shanghai'
volumes:
- ./config/openapi:/app/config:ro
- ./data/logs/openapi:/app/logs
networks:
- iotlcm-network
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
upstream-chan:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-upstream-chan-v1.0.4'
container_name: iotlcm-upstream-chan
restart: always
ports:
- "8110:8110"
- "8310:8310"
environment:
TZ: 'Asia/Shanghai'
volumes:
- ./config/upstream-chan:/app/config:ro
- ./data/logs/upstream-chan:/app/logs
- ./data/upload:/app/upload
- ./data/upload:/app/resource/upload
networks:
- iotlcm-network
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
consul:
condition: service_started
upstream-proxy:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-upstream-proxy-v1.0.4'
container_name: iotlcm-upstream-proxy
restart: always
ports:
- "8510:8510"
environment:
TZ: 'Asia/Shanghai'
volumes:
- ./config/upstream-proxy:/app/config:ro
- ./data/logs/upstream-proxy:/app/logs
- ./data/upload:/app/upload
- ./data/upload:/app/resource/upload
- ./data/tmp:/app/tmp
networks:
- iotlcm-network
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
consul:
condition: service_started
worker-svr:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-worker-svr-v1.0.4'
container_name: iotlcm-worker-svr
restart: always
environment:
TZ: 'Asia/Shanghai'
volumes:
- ./config/worker-svr:/app/config:ro
- ./data/logs/worker-svr:/app/logs
- ./data/logs/worker-svr/full-sync:/app/logs/full-sync
- ./data/upload:/app/upload
- ./data/upload:/app/resource/upload
networks:
- iotlcm-network
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
app-server:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-app-server-v1.0.2'
container_name: iotlcm-app-server
restart: always
ports:
- "127.0.0.1:8010:8010"
environment:
TZ: 'Asia/Shanghai'
APP_UPLOAD_DIR: /app/resource/upload
volumes:
- ./config/app-server:/app/config:ro
- ./data/upload:/app/resource/upload
- ./data/logs/app-server:/app/resource/log/server
networks:
- iotlcm-network
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
app-web:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-app-web-v1.0.2'
container_name: iotlcm-app-web
restart: always
ports:
- "127.0.0.1:8018:80"
environment:
TZ: 'Asia/Shanghai'
volumes:
- ./data/logs/app-web:/var/log/nginx
networks:
- iotlcm-network
sms:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-sms-v1.0.4'
container_name: iotlcm-sms
hostname: iotlcm-sms
restart: always
environment:
TZ: 'Asia/Shanghai'
HTTP_ADDR: ':11190'
GRPC_ADDR: ':11200'
ports:
- "11190:11190"
- "11200:11200"
volumes:
- ./config/sms:/app/config:ro
- ./data/logs/sms:/app/logs
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1:11190/healthz"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
networks:
- iotlcm-network
bot:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-bot-v1.0.4'
container_name: iotlcm-bot
restart: unless-stopped
environment:
TZ: 'Asia/Shanghai'
command: ["watch", "--config", "/app/config/config.yaml"]
volumes:
- ./config/bot/config.yaml:/app/config/config.yaml:ro
- ./data/logs/worker-svr/full-sync:/data/full-sync:ro
- ./data/bot-state:/data/state
networks:
- iotlcm-network
profiles:
- with-bot
docs:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-docs-v1.0.4'
container_name: iotlcm-docs
restart: unless-stopped
ports:
- "127.0.0.1:8019:80"
networks:
- iotlcm-network
# ---------------- 可选:运营商接口挡板 ----------------
# 模拟中国移动 OneLink 接口,用于在没有真实运营商环境时联调整条链路:
# admin-svr → worker-svr → upstream-chan → onelink-mock
#
# 启用:docker compose --profile with-mock up -d
# 启用后 upstream-chan 的 sim_yd_mock_v5.url 指向 http://onelink-mock:8080/v5/
# (config/upstream-chan 已预置该地址)
onelink-mock:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:iotlcm-onelink-mock-v1.0.4'
container_name: iotlcm-onelink-mock
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
TZ: 'Asia/Shanghai'
BAFFLE_ADDR: ':8080'
BAFFLE_SQLITE_PATH: /app/data/onelink_mock.db
volumes:
- ./data/mock:/app/data
- ./data/logs/mock:/app/logs
networks:
- iotlcm-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1:8080/api.json"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
profiles:
- with-mock
# ---------------- 可选:反向代理 ----------------
# 若宿主机已有 nginx / 面板,可不启动本容器,
# 参考 config/nginx/iotlcm.conf 把 location 段落到宿主机配置。
nginx:
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:nginx-1.30'
container_name: iotlcm-nginx
restart: always
ports:
- "80:80"
volumes:
- ./config/nginx:/etc/nginx/conf.d:ro
- ./data/logs/nginx:/var/log/nginx
- ./data/upload:/app/upload
networks:
- iotlcm-network
depends_on:
- admin-ui
- openapi
- app-web
- docs
profiles:
- with-nginx
networks:
iotlcm-network:
driver: bridge
name: iotlcm-network
+74
View File
@@ -0,0 +1,74 @@
# 数据库初始化
`init.sql` 是**单文件**初始化脚本,MySQL 容器首次以空数据卷启动时由 `docker-entrypoint-initdb.d` 自动执行。已有数据目录不会自动执行新增 DDL,升级时需先核对四张表状态,再按上游迁移规范处理,不能删除数据卷。
## 内容组成
按文件内顺序执行,整体幂等:
| 段 | 内容 |
| --- | --- |
| 表结构与基础数据 | 建 `db_iotlcm` 库 + 全部表 + 菜单/字典/角色/租户/部门/演示账号 |
| 结构补齐 | `card_channel_assoc` 表 + 27 个列(用存储过程按 `information_schema` 判断) |
| 结构对齐生产 | `t_sms_template_config` 表、`batch_no` 默认值 |
| 通道模板 | 3 条(上游密钥已置空) |
| 数据字典 | 86 类 / 436 条 |
| Mock 通道 | `channel_yd_mock`。**卡全部挂在该通道上**,缺则卡片管理的通道列、筛选、开停复机均不可用 |
| 角色链 | 代理下发模板参数与角色下发标记 |
| 定时任务 | 20 条,**默认全部停用**(避免自动全量同步打满机器) |
| 菜单增量 | 达量断网按钮权限、全量同步页 |
| v1.0.4 前置 | 导出中心表 `t_export_task`、通知已读表 `sys_notice_user`、`card_channel_assoc.channel_id` 扩宽、菜单权限 |
| APN 异步恢复 | `data_operation_parent`、`data_operation_sub_batch`、`data_operation_sub_detail`、`data_operation_card_claim` 四表;须先建表后启动 worker |
| 数据修复 | `sys_dept.ancestors` 尾逗号归一化(全新库为空操作) |
| 业务账号与口令 | 建 `u_iotlcm` 账号、可选库 `iotdb`、种子账号口令统一重置 |
## 来源
内容取自**演示环境正在使用的种子库**,以生产 89 表基线为基础,补齐 v1.0.4 的 2 张表及 worker 异步恢复所需的 4 张表,初始化后为 **95 表**。四张恢复表直接采用内部数据库仓 `feat/data-operation-reconcile-20261015` 的 `SQL/20261015_data_operation_sub_batch.sql`(提交 `bf5f7fd`);原有 91 表统计不含这四张表。脱敏及部署适配包括:
- 演示账号昵称、邮箱、手机号、登录 IP、头像 → 占位或编造值
- 公司名、组织名 → 「示例企业」等通用名
- 内网/生产 IP、内部域名 → 删除或改为服务名
- 通道模板中真实上游密钥 → 置空
- 数据库账号与种子账号口令 → 统一为部署口令(字面值,见下)
## 改口令
口令是**字面值**,不使用环境变量。修改时必须同步以下三处:
| 位置 | 内容 |
| --- | --- |
| `docker-compose.yml` | `MYSQL_ROOT_PASSWORD`(MySQL 健康检查会引用此值) |
| `config/*/config.yaml` | 各服务 `database link` 的口令 |
| `schema/init.sql` 末段 | `u_iotlcm` 账号口令、种子账号重置口令 |
```bash
OLD='原口令'; NEW='新口令'
grep -rl "$OLD" docker-compose.yml config/ schema/ | xargs sed -i "s/$OLD/$NEW/g"
```
::: warning 口令不能含 @ : / 等字符
连接串格式为 `mysql:用户:口令` 再接 `tcp(主机:端口)/库`,口令里的 `@`、`:`、`/` 会被当作分隔符,导致连接失败。用字母数字组合。
:::
## 重新初始化
`init.sql` 只在 `data/mysql` 为空时执行。改完必须清空数据卷才会重跑:
```bash
docker compose stop mysql
sudo rm -rf ./data/mysql
docker compose up -d mysql
```
> ⚠️ 会删除全部数据,生产环境禁止执行。
## 空表说明
以下表为空属预期:
| 表 | 说明 |
| --- | --- |
| `t_sim_flow_package_info` | 流量套餐。为空时无法办理套餐/续费/改套餐,需在管理端「套餐定价」中创建 |
| `t_sim_card` | 卡数据,可通过挡板服务造卡或自行入库 |
| 其余 `t_*` 业务表 | 随业务操作产生数据 |
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
-- ============================================================
-- TDengine 初始化:流量历史库与超级表
--
-- 结构取自生产环境(SHOW CREATE STABLE 导出),仅结构、无数据。
-- 由 compose 的 tdengine-init 服务在 tdengine 健康后执行。
--
-- 注意:`taos -f` 是**按行**执行语句的,每条 SQL 必须写在一行内,
-- 跨行会被判为 "Incomplete SQL statement"。改本文件时务必保持单行。
--
-- 业务代码(worker-svr / admin-svr)只连接读写,不负责建库建表,
-- 因此这一步必须在首次启动时完成,否则单卡流量重试写入会报库表不存在。
-- ============================================================
CREATE DATABASE IF NOT EXISTS iotlcm_daily PRECISION 'ms' KEEP 1095d DURATION 7d;
CREATE DATABASE IF NOT EXISTS iotlcm_monthly PRECISION 'ms' KEEP 1095d DURATION 30d;
CREATE STABLE IF NOT EXISTS iotlcm_daily.t_sim_card_usage_daily (ts TIMESTAMP, usage_mb DOUBLE, package_code VARCHAR(128)) TAGS (iccid VARCHAR(64));
CREATE STABLE IF NOT EXISTS iotlcm_monthly.t_sim_card_usage_monthly (ts TIMESTAMP, usage_mb DOUBLE, package_code VARCHAR(128)) TAGS (iccid VARCHAR(64));
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# ============================================================
# TDengine 初始化执行脚本
#
# 由 compose 的 tdengine-init 服务调用,在 tdengine 健康后执行一次。
# 做三件事:
# 1. 过滤掉 init.sql 里的注释与空行(taos -f 会逐行执行,
# 注释行会被当成不完整 SQL 报错)
# 2. 执行建库建表
# 3. 校验超级表确实存在,缺表则以非零码退出(让 compose 暴露失败)
# ============================================================
set -eu
echo "[tdengine-init] 准备 SQL(过滤注释与空行)"
grep -vE '^[[:space:]]*(--|$)' /init/init.sql > /tmp/init.sql
echo "[tdengine-init] 待执行语句数:$(wc -l < /tmp/init.sql)"
echo "[tdengine-init] 执行建库建表"
taos -h tdengine -f /tmp/init.sql
echo "[tdengine-init] 校验超级表"
missing=0
for pair in "iotlcm_daily:t_sim_card_usage_daily" "iotlcm_monthly:t_sim_card_usage_monthly"; do
db=${pair%%:*}
stable=${pair##*:}
if taos -h tdengine -s "show ${db}.stables;" 2>/dev/null | grep -q "$stable"; then
echo " OK ${db}.${stable}"
else
echo " MISS ${db}.${stable}"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "[tdengine-init] 失败:超级表缺失,请检查 taos 输出"
exit 1
fi
echo "[tdengine-init] 完成"
+17
View File
@@ -0,0 +1,17 @@
node_modules
.vitepress/cache
.vitepress/dist
.vitepress/.temp
dist
*.zip
*.tar
*.war
*.log
.DS_Store
.playwright-mcp/
.kilo/
.git/
.gitignore
Dockerfile
README.md
npm-debug.log*
+15
View File
@@ -0,0 +1,15 @@
node_modules
.vitepress/cache
.vitepress/dist
.vitepress/.temp
dist
*.zip
*.tar
*.war
*.log
.DS_Store
.playwright-mcp/
.kilo/
.playwright-cli/
.apitest-output/
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/
+164
View File
@@ -0,0 +1,164 @@
import { defineConfig } from 'vitepress'
const webManualItems = [
{ text: '快速开始', link: '/manual/web/' },
{ text: '首页与通用操作', link: '/manual/web/home' },
{ text: '卡信息', link: '/manual/web/card-info' },
{ text: '智能诊断', link: '/manual/web/intelligent-diagnosis' },
{ text: '业务受理与达量断网', link: '/manual/web/business-operations' },
{ text: '套餐配置与定价', link: '/manual/web/packages-and-pricing' },
{ text: '预警管理', link: '/manual/web/warnings' },
{ text: '入库、库存与出库', link: '/manual/web/inventory' },
{ text: '通道与接入管理', link: '/manual/web/channels' },
{ text: '企业、用户与角色', link: '/manual/web/enterprise-management' },
{ text: '工单管理', link: '/manual/web/work-orders' },
{ text: 'API 凭证与执行日志', link: '/manual/web/api-and-logs' },
{ text: '常见问题', link: '/manual/web/faq' },
]
const miniProgramManualItems = [
{ text: '快速开始', link: '/manual/mini-program/' },
{ text: '首页公开查卡', link: '/manual/mini-program/home' },
{ text: '公开卡片详情', link: '/manual/mini-program/devices' },
{ text: '卡片排查', link: '/manual/mini-program/diagnosis' },
{ text: '登录后办理概览', link: '/manual/mini-program/business' },
{ text: '客户管理与卡片划拨', link: '/manual/mini-program/customers-and-allocation' },
{ text: '工单服务', link: '/manual/mini-program/work-orders' },
{ text: '批次执行日志', link: '/manual/mini-program/execution-logs' },
{ text: '消息与个人中心', link: '/manual/mini-program/messages-and-profile' },
{ text: '常见问题', link: '/manual/mini-program/faq' },
]
function manualSidebar(_active: 'web' | 'mini' | 'both' = 'both') {
return [
{
text: 'Web端',
collapsed: false,
items: webManualItems,
},
{
text: '小程序端',
collapsed: false,
items: miniProgramManualItems,
},
]
}
export default defineConfig({
srcDir: './docs',
base: '/docs/',
title: 'iotLCM',
description: 'iotLCM documentation center',
lang: 'zh-CN',
cleanUrls: true,
lastUpdated: false,
head: [
['link', { rel: 'icon', type: 'image/png', href: '/docs/favicon.png' }]
],
vite: {
plugins: [
{
name: 'serve-root-favicon-in-dev',
configureServer(server) {
server.middlewares.use('/favicon.ico', (_request, response) => {
response.writeHead(302, { Location: '/docs/favicon.png' })
response.end()
})
}
}
]
},
themeConfig: {
logo: undefined,
nav: [
{ text: '文档首页', link: '/' },
{ text: '使用手册', link: '/manual/' },
{ text: 'OpenAPI', link: '/openapi/' },
{ text: '共享 Token 接口', link: '/openapi/operator/shared-token/' },
{ text: '部署手册', link: '/deploy/' }
],
sidebar: {
'/deploy/': [
{
text: '部署手册',
collapsed: false,
items: [
{ text: '部署概览', link: '/deploy/' },
{ text: '环境准备', link: '/deploy/prerequisites' },
{ text: '配置说明', link: '/deploy/configuration' },
{ text: '启动与验证', link: '/deploy/install' },
]
},
{
text: '其他部署方式',
collapsed: false,
items: [
{ text: '方式对比与选型', link: '/deploy/alternatives' },
{ text: '二进制部署', link: '/deploy/binary' },
{ text: '自建镜像', link: '/deploy/build-image' },
{ text: '分离部署', link: '/deploy/split' },
{ text: '离线部署', link: '/deploy/offline' },
]
},
{
text: '运维',
collapsed: false,
items: [
{ text: '可选服务', link: '/deploy/optional' },
{ text: '升级与备份', link: '/deploy/operations' },
{ text: '故障排查', link: '/deploy/troubleshooting' },
]
},
],
'/manual/': manualSidebar(),
'/manual/web/': manualSidebar('web'),
'/manual/mini-program/': manualSidebar('mini'),
'/openapi/operator/': [
{
text: '共享 Token 接口',
collapsed: false,
items: [
{ text: '概览', link: '/openapi/operator/' },
{ text: '共享 Token 接口', link: '/openapi/operator/shared-token/' },
{ text: '白名单转发', link: '/openapi/operator/mobile-v5/' },
]
},
],
'/openapi/': [
{
text: 'V3 接口',
collapsed: false,
items: [
{ text: '概览与鉴权', link: '/openapi/v3/' },
{ text: '获取调用凭证', link: '/openapi/v3/token' },
{ text: '查询类', link: '/openapi/v3/query' },
{ text: '业务类', link: '/openapi/v3/common' },
]
},
{
text: '旧版兼容接口',
collapsed: false,
items: [
{ text: '概览与鉴权', link: '/openapi/legacy/' },
{ text: '查询类', link: '/openapi/legacy/query' },
]
},
]
},
outline: {
level: [2, 3],
label: '本页目录'
},
docFooter: {
prev: '上一页',
next: '下一页'
},
search: {
provider: 'local'
},
footer: {
message: 'iotLCM 文档中心',
copyright: ''
}
}
})
+198
View File
@@ -0,0 +1,198 @@
/* 截图点击放大(medium-zoom)—— 全站生效,范围由 .vitepress/theme/index.ts 的 ZOOM_SELECTOR 定义 */
/* 叠加层必须盖住导航和侧边栏:默认主题 --vp-z-index-nav 为 30、
--vp-z-index-sidebar 为 60,所以取 100。 */
.medium-zoom-overlay {
z-index: 100;
}
.medium-zoom-image--opened {
z-index: 101;
}
/* JS 尚未挂载 medium-zoom 时也提前给出「可点击」的鼠标提示 */
.vp-doc img:not(a img):not(.no-zoom) {
cursor: zoom-in;
}
/* 单张图片保持原始尺寸,并在正文区域水平居中。 */
.vp-doc img {
display: block;
margin: 24px auto;
}
/* 单行只有一张图片时缩放到内容区域的 40%。 */
.vp-doc p > img:only-child,
.vp-doc li > img:only-child {
width: 40%;
max-width: 40%;
height: auto;
}
/* Web 端手册截图恢复为内容区域全宽展示。 */
.vp-doc img[src*="/images/manual/"]:not([src*="mini-program"]) {
width: 100%;
max-width: 100%;
height: auto;
}
/* 同一行包含两张图片时,整行缩放到内容区域的 40%。 */
.vp-doc p:has(> img:nth-child(2):last-child) {
display: flex;
width: 80%;
max-width: 80%;
height: auto;
line-height: 0;
margin: 32px auto;
align-items: center;
justify-content: center;
gap: 8px;
}
.vp-doc p:has(> img:nth-child(2):last-child) > img {
width: calc(50% - 4px);
max-width: calc(50% - 4px);
height: auto;
margin: 0;
}
/* Markdown 内嵌的双图网格统一缩放到内容区域的 80%。 */
.vp-doc div[style*="repeat(2"] {
width: 80% !important;
max-width: 80% !important;
margin: 32px auto !important;
}
.vp-doc div[style*="repeat(2"] img {
width: 100% !important;
max-width: 100% !important;
height: auto;
margin: 0 !important;
}
.vp-doc div[style*="repeat(3"] img {
margin: 0 !important;
}
.vp-doc div[style*="repeat(2"] figcaption,
.vp-doc div[style*="repeat(3"] figcaption {
margin-top: 6px;
color: var(--vp-c-brand-1);
font-size: 16px;
font-weight: 600;
line-height: 1.4;
}
.vp-doc[class*="_docs_openapi_"] table {
display: table;
width: 100%;
max-width: 100%;
table-layout: auto;
}
.openapi-page .custom-block.warning,
.openapi-page .custom-block.danger {
background-color: var(--vp-code-block-bg);
}
.openapi-page .custom-block.warning code,
.openapi-page .custom-block.danger code {
background-color: var(--vp-code-bg);
}
@media (max-width: 767px) {
.vp-doc[class*="_docs_openapi_"] table {
display: block;
overflow-x: auto;
}
}
.manual-entry-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin: 28px 0 36px;
}
.manual-entry-card {
display: flex;
min-height: 190px;
padding: 24px;
flex-direction: column;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
background: var(--vp-c-bg-soft);
color: inherit;
text-decoration: none;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
}
.manual-entry-card:hover {
border-color: var(--vp-c-brand-1);
box-shadow: 0 8px 24px rgb(60 90 180 / 12%);
transform: translateY(-2px);
}
.manual-entry-title {
color: var(--vp-c-brand-1);
font-size: 22px;
font-weight: 700;
}
.manual-entry-desc {
margin-top: 12px;
color: var(--vp-c-text-2);
font-size: 14px;
line-height: 1.7;
}
.manual-entry-link {
margin-top: auto;
padding-top: 18px;
color: var(--vp-c-brand-1);
font-size: 14px;
font-weight: 600;
}
.mini-program-entry {
display: flex;
width: min(100%, 520px);
margin: 28px auto 12px;
flex-direction: column;
align-items: center;
text-align: center;
}
.vp-doc .mini-program-entry + h2 {
margin-top: 12px;
}
.mini-program-entry__qr {
margin: 0;
}
.mini-program-entry__qr img {
width: 196px;
max-width: 58vw;
margin: 0 auto;
}
.mini-program-entry__qr figcaption {
margin-top: 12px;
color: var(--vp-c-brand-1);
font-size: 15px;
font-weight: 600;
}
.mini-program-entry__hint {
margin: 14px 0 0;
color: var(--vp-c-text-2);
font-size: 14px;
line-height: 1.7;
}
@media (max-width: 640px) {
.manual-entry-grid {
grid-template-columns: 1fr;
}
}
+78
View File
@@ -0,0 +1,78 @@
import DefaultTheme from 'vitepress/theme'
import mediumZoom from 'medium-zoom'
import { onContentUpdated } from 'vitepress'
import { onMounted } from 'vue'
import type { Theme } from 'vitepress'
import './custom.css'
/**
* 参与「点击放大」的图片范围。
*
* `.vp-doc` 是 VitePress 默认主题给正文容器加的类名,使用手册 9 页与接口文档
* 全部页面共用这一套布局,所以在这里定义一次即对全站生效。
*
* 排除项:
* a img 图片本身是链接(如带外链的截图),点击应跳转而不是放大
* .no-zoom 需要单独关闭放大的图片,示例:
* `<img src="/images/manual/xx.png" class="no-zoom" alt="xx">`
*/
const ZOOM_SELECTOR = '.vp-doc img:not(a img):not(.no-zoom)'
/**
* 全站只保留一个 medium-zoom 实例,但【必须惰性创建】。
*
* 两个坑叠在一起,才得到这个写法:
*
* 1. 不能在模块顶层创建实例。medium-zoom 1.1.0 的工厂函数第一件事就是读
* `window.Promise`(dist/medium-zoom.min.js 里即 `var Promise = window.Promise || ...`),
* 而主题模块在 `vitepress build` 时会被 Node 端 SSR 打包并执行 —— 没有 window,
* 直接 `ReferenceError: window is not defined` 构建失败。
* 所以实例只能在浏览器生命周期回调里创建。
*
* 2. 也不能每次路由切换都 `mediumZoom(...)` 新建。每个实例都会在 document 上注册
* click / keyup / scroll、在 window 上注册 resize 监听器,且没有卸载入口
* (见 dist/medium-zoom.esm.js:576-581)。翻 N 个页面就会留下 N 组永不释放的
* 全局监听器。正确做法是复用实例,路由切换时只做 attach —— attach 内部会跳过
* 已挂载的图片(dist/medium-zoom.esm.js:199-204),因此重复调用是幂等的。
*/
let zoom: ReturnType<typeof mediumZoom> | null = null
const attachZoom = () => {
if (typeof window === 'undefined') return
if (!zoom) {
zoom = mediumZoom({
background: 'var(--vp-c-bg)',
margin: 24,
})
}
zoom.attach(ZOOM_SELECTOR)
}
const closeZoomOnClick = (event: MouseEvent) => {
const target = event.target as Element | null
if (!zoom || !target) return
if (target.closest('.medium-zoom-image--opened, .medium-zoom-overlay')) {
void zoom.close()
}
}
const theme: Theme = {
extends: DefaultTheme,
setup() {
// onContentUpdated 由 VitePress 在「新页面内容挂载完成后」触发,覆盖首次进入
// 与 SPA 客户端路由切换。它挂在路由组件的 onVnodeMounted 上
// (见 vitepress/dist/client/app/components/Content.js:13-20),
// 比 watch(route.path) + nextTick 更可靠 —— 后者可能在异步页面组件挂载前就执行,
// 导致某些页面图片没挂上监听、点击放大静默失效。
onContentUpdated(attachZoom)
// 兜底:主题 setup 时内容已就绪(不经过 onVnodeMounted 的极端情况)也能挂上
onMounted(attachZoom)
if (typeof document !== 'undefined') {
document.addEventListener('click', closeZoomOnClick, true)
}
},
}
export default theme
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine AS builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache git
WORKDIR /app
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run docs:build
FROM nginx:1.27-alpine
COPY --from=builder /app/.vitepress/dist /usr/share/nginx/html
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
+89
View File
@@ -0,0 +1,89 @@
# iotlcm-docs
iotLCM 物联网卡全生命周期管理平台的**使用手册与 OpenAPI 合同**。
- **使用手册**:Web 管理端(企业客户视角)与小程序端的操作说明,含截图
- **OpenAPI 合同**:V3 接口、旧版兼容接口、运营商能力代理(白名单转发 / 共享 Token)的鉴权方式、请求参数与响应结构
## 技术栈
VitePress 1.6 + Vue 3,产物为纯静态站点。
## 目录结构
```text
iotlcm-docs/
├── docs/ # 文档源(VitePress srcDir)
│ ├── index.md # 文档首页
│ ├── manual/ # 使用手册
│ │ ├── web/ # Web 管理端
│ │ └── mini-program/ # 小程序端
│ ├── openapi/ # OpenAPI 合同
│ │ ├── v3/ # V3 接口
│ │ ├── legacy/ # 旧版兼容接口
│ │ └── operator/ # 运营商能力代理
│ └── public/ # 静态资源(favicon、截图)
├── .vitepress/ # VitePress 配置与主题定制
├── docker/ # nginx 配置
├── Dockerfile
└── docker-compose.yml
```
## 本地开发
```bash
npm ci
npm run dev # http://127.0.0.1:5173/docs/
```
> 入口地址带 `/docs/`:配置里 `base: '/docs/'`,直接访问根路径会 404。
构建与预览:
```bash
npm run build # 产物在 .vitepress/dist
npm run preview
```
站点部署在子路径 `/docs/`(见 `.vitepress/config.mts` 的 `base`)。本地开发时根路径会自动重定向 favicon。
## 部署
**完整部署手册见文档站:[部署手册](./docs/deploy/index.md)**,包含推荐环境、编译方法、nginx / 对象存储 / 离线环境部署方式与常见问题。
最短路径(容器,推荐):
```bash
docker build -t iotlcm-docs:$(git rev-parse --short HEAD) .
docker run -d --name iotlcm-docs -p 8019:80 iotlcm-docs:latest
# 访问 http://127.0.0.1:8019/docs/
```
多阶段构建:Node 阶段执行 `vitepress build`,最终镜像只含 nginx 与静态产物。
> ⚠️ **路径前缀**:`base: '/docs/'` 使资源引用为绝对路径 `/docs/assets/*`,而 dist 是平铺的。`docker/nginx.conf` 已用 `alias` 剥掉前缀,**不要**改成从根路径直接服务产物,否则白屏。
`docker-compose.yml` 里的镜像名需替换成你自己的镜像仓库地址;对外署时请用不可变标签(commit sha)而不是 `latest`。
## 文档里的占位符
文档中的服务地址、凭据等均为占位示例:
| 占位 | 含义 |
| --- | --- |
| `iotlcm.example.com` | Web 管理端地址 |
| `api.iotlcm.example.com` | OpenAPI 服务地址 |
| `YOUR_APP_ID` / `YOUR_APP_SECRET` | 平台分配的应用凭据 |
| `YOUR_ACCESS_TOKEN` / `YOUR_TRANSID` | 调用方凭据与流水号 |
| `141****5152` | 示例手机号 |
替换成你自己的环境即可。
## 相关项目
- [`openapi/`](../openapi) —— OpenAPI 服务实现
- [`admin-svr/`](../admin-svr) —— Web 管理端后端
## License
[Apache License 2.0](./LICENSE)
+12
View File
@@ -0,0 +1,12 @@
services:
docs:
image: iotlcm-docs:latest
container_name: iotlcm-docs
restart: unless-stopped
networks:
- iotlcm-network
networks:
iotlcm-network:
external: true
name: iotlcm-network
+25
View File
@@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# VitePress 配置 base: '/docs/',构建产物里的资源引用是绝对路径 /docs/assets/xxx,
# 而 dist 目录是平铺的(index.html、assets/ 直接在根)。
# 因此必须在 /docs/ 路径下提供服务,用 alias 把前缀剥掉再落到产物目录,
# 否则页面能返回 200 但 JS/CSS 全部 404 → 白屏。
location /docs/ {
alias /usr/share/nginx/html/;
try_files $uri $uri.html $uri/ $uri/index.html =404;
}
# 根路径跳到文档入口,避免访问 / 时白屏
location = / {
return 302 /docs/;
}
location / {
try_files $uri $uri.html $uri/ =404;
}
}
+64
View File
@@ -0,0 +1,64 @@
---
title: 其他部署方式
description: 二进制部署、自建镜像、分离部署与离线部署
outline: deep
---
# 其他部署方式
[启动与验证](install) 描述的是 **Docker Compose 单机部署**,也是推荐方式。本组文档覆盖其他场景。
## 方式对比
| 方式 | 适用场景 | 复杂度 | 参考 |
| --- | --- | --- | --- |
| **单机 Compose**(推荐) | 试用、中小规模生产 | 低 | [启动与验证](install) |
| **二进制部署** | 不能装 Docker,或需纳入 systemd 体系 | 中 | [二进制部署](binary) |
| **自建镜像** | 需改代码、加依赖、适配私有基础镜像 | 中 | [自建镜像](build-image) |
| **分离部署** | 数据库走云托管,或中间件已有独立集群 | 中 | [分离部署](split) |
| **离线部署** | 服务器无法访问镜像仓库 | 中 | [离线部署](offline) |
## 服务依赖关系
选择部署方式前需了解各服务依赖哪些组件:
| 服务 | 依赖 |
| --- | --- |
| admin-svr | MySQL、Redis、RabbitMQ |
| openapi | MySQL、Redis、RabbitMQ |
| worker-svr | MySQL、Redis、RabbitMQ |
| app-server | MySQL、Redis |
| upstream-chan | MySQL、Redis、Consul |
| upstream-proxy | MySQL、Redis、Consul |
| admin-ui | admin-svr(反向代理) |
| nginx | admin-ui、openapi、app-web、docs |
| sms | 无 |
| docs | 无 |
| onelink-mock | 无 |
## 通用前提
无论哪种方式,这三项不变:
1. **配置文件必须能被服务读到**——容器部署时挂载到 `/app/config`,二进制部署时放在工作目录的 `config/`
2. **`JWT_SIGNING_KEY` 在 admin-svr、openapi、app-server 三处必须一致**
3. **数据库需先完成初始化**——`schema/` 脚本按顺序执行一次
## 服务间通信方式
配置文件里通过**服务名**互访,例如:
```yaml
database:
default:
link: "mysql:u_iotlcm:...@tcp(mysql:3306)/db_iotlcm"
upstream:
proxy:
address: "upstream-proxy:8510"
```
这些名称在单机 Compose 中由 Docker 网络解析。**切换到二进制部署或分离部署时必须改为实际地址**(`127.0.0.1` 或内网 IP),这是最容易遗漏的一步。
::: danger 最常见的部署失败原因
只改了数据库地址,忘了改 `upstream-proxy:8510`、`upstream-chan:8110` 这类服务名。症状是服务能启动、数据库也通,但批次执行、智能诊断全部超时。
:::
+323
View File
@@ -0,0 +1,323 @@
---
title: 二进制部署
description: 直接编译 Go 程序运行,不依赖 Docker
outline: deep
---
# 二进制部署
不使用容器,把各服务编译为可执行文件,用 systemd 托管。
## 适用场景
- 服务器不允许安装 Docker
- 需要把服务纳入既有的 systemd / 进程管理体系统一运维
- 需要针对特定 CPU 架构或内核参数做优化
## 构建环境
| 项 | 要求 |
| --- | --- |
| Go | 按各服务 `go.mod` 要求,最低 1.23(见下表) |
| GCC | admin-svr 需要(`CGO_ENABLED=1`,内存库用 SQLite) |
| 系统 | 建议与目标服务器同发行版,避免 libc 差异 |
```bash
# Debian / Ubuntu
apt-get install -y build-essential
# CentOS / Rocky
yum groupinstall -y "Development Tools"
```
::: tip 其余服务可静态编译
除 admin-svr 外,其余服务 `CGO_ENABLED=0`,产物为静态二进制,可直接拷到任意同架构 Linux 运行。
:::
## 各服务构建参数
全部服务在同一仓的不同目录下,下表「目录」即 `git clone` 后的相对路径。
| 服务 | 目录 | Go 版本 | CGO | 构建目录 | 产物名 | 监听端口 |
| --- | --- | --- | --- | --- | --- | --- |
| admin-svr | `admin-svr/` | 1.23 | **需开启** | `server/` | `iotlcmadmin_svc` | 8008 |
| openapi | `openapi/` | 1.23 | 关闭 | 根目录 | `iotlcm-openapi` | 8000 |
| worker-svr | `worker-svr/` | 1.25 | 关闭 | 根目录 | `iotlcm-worker-svr` | 无(队列消费) |
| upstream-chan | `upstream-chan/` | 1.25 | 关闭 | 根目录 | `iotlcmupstreamchan_svc` | 8110 / 8310 |
| upstream-proxy | `upstream-proxy/` | 1.25 | 关闭 | 根目录 | `iotlcmupstreamproxy_svc` | 8510 |
| app-server | `app/server/` | 1.23 | 关闭 | `server/` | `iotlcm-app-server` | 8010 |
| sms | `sms/` | 1.25 | 关闭 | 根目录 | `iotlcm-sms` | 11190 / 11200 |
| bot | `bot/` | 1.23 | 关闭 | `cmd/iotlcm-bot` | `iotlcm-bot` | 无 |
::: tip 基础镜像可替换
所有构建用 Dockerfile 的 `FROM` 都走 `ARG BASE_REGISTRY`(默认 `docker.io/library`),需要私有基础镜像时用 `--build-arg BASE_REGISTRY=...` 覆盖即可,见[自建镜像](build-image)。
:::
## 构建
### admin-svr(需要 CGO)
```bash
git clone <仓库地址> iotlcm
cd iotlcm/admin-svr/server
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -trimpath \
-ldflags "-s -w \
-X main.BuildVersion=$(git describe --tags --always) \
-X main.CommitID=$(git rev-parse --short HEAD) \
-X main.BuildTime=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
-o iotlcmadmin_svc .
```
### 其余服务(静态编译)
```bash
git clone <仓库地址> iotlcm
cd iotlcm/openapi
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath \
-ldflags "-s -w \
-X main.BuildVersion=$(git describe --tags --always) \
-X main.CommitID=$(git rev-parse --short HEAD) \
-X main.BuildTime=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
-o iotlcm-openapi .
```
替换服务目录与产物名即可用于其他服务,`bot` 需要指定子目录:
```bash
cd iotlcm/bot
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o iotlcm-bot ./cmd/iotlcm-bot
```
### 交叉编译说明
| 服务 | 能否交叉编译 |
| --- | --- |
| admin-svr(CGO) | ❌ 需在目标架构上编译,或用对应架构的交叉工具链 |
| 其余(静态) | ✅ 可直接 `GOOS=linux GOARCH=arm64` |
查看服务器架构:
```bash
uname -m # x86_64 → amd64;aarch64 → arm64
```
## 部署布局
```text
/opt/iotlcm/
├── admin-svr/
│ ├── iotlcmadmin_svc
│ ├── config/config.yaml ← 从 config/admin-svr 复制
│ ├── logs/
│ ├── upload/
│ └── cache/
├── openapi/
│ ├── iotlcm-openapi
│ ├── config/config.yaml
│ └── logs/
├── worker-svr/
│ ├── iotlcm-worker-svr
│ ├── config/config.yaml
│ └── logs/
├── upstream-chan/
├── upstream-proxy/
├── app-server/
│ ├── iotlcm-app-server
│ ├── config/config.yaml
│ └── resource/
│ ├── log/server/
│ └── upload/
├── sms/
└── bot/
```
**配置文件放 `config/config.yaml`**(相对工作目录),这是 GoFrame 的默认搜索路径。启动时工作目录必须在服务目录内。
### 目录准备
```bash
sudo mkdir -p /opt/iotlcm/{admin-svr,openapi,worker-svr,upstream-chan,upstream-proxy,app-server,sms,bot}
sudo useradd -r -s /sbin/nologin iotlcm
sudo chown -R iotlcm:iotlcm /opt/iotlcm
```
## 修改配置中的服务地址
容器部署时配置里写的是服务名(`mysql:3306`、`upstream-proxy:8510`),二进制部署必须改为实际地址:
```yaml
# admin-svr / openapi / worker-svr / app-server
database:
default:
link: "mysql:u_iotlcm:<口令>@tcp(127.0.0.1:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
redis:
default:
address: "127.0.0.1:6379"
rabbitmq:
dsn: "amqp://<用户>:<口令>@127.0.0.1:5672/"
# upstream-chan / upstream-proxy
registry:
address: "127.0.0.1:8500"
# 上游服务地址
upstream:
proxy:
address: "127.0.0.1:8510"
gateway:
address: "127.0.0.1:8110"
```
::: danger logger.path 也要改
配置里的 `logger.path` 在容器版本中是 `/app/logs`,二进制部署要改为实际路径,例如 `/opt/iotlcm/admin-svr/logs`,否则会因目录不存在启动失败。
:::
## systemd 托管
以为 `admin-svr` 为例,保存为 `/etc/systemd/system/iotlcm-admin-svr.service`:
```ini
[Unit]
Description=IotLCM Admin Service
After=network-online.target mysql.service redis.service
Wants=network-online.target
[Service]
Type=simple
User=iotlcm
Group=iotlcm
WorkingDirectory=/opt/iotlcm/admin-svr
ExecStart=/opt/iotlcm/admin-svr/iotlcmadmin_svc
Restart=always
RestartSec=5s
LimitNOFILE=65535
# 环境变量(JWT 密钥等敏感值建议放独立文件)
EnvironmentFile=-/etc/iotlcm/admin-svr.env
# 日志交给 journald,也可用配置里的文件日志
StandardOutput=journal
StandardError=journal
# 安全加固
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/opt/iotlcm/admin-svr
[Install]
WantedBy=multi-user.target
```
`/etc/iotlcm/admin-svr.env`:
```bash
JWT_SIGNING_KEY=<与其它服务一致的密钥>
```
启用:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now iotlcm-admin-svr
sudo systemctl status iotlcm-admin-svr
```
### 各服务的差异点
| 服务 | 特殊处理 |
| --- | --- |
| admin-svr | 需 `ReadWritePaths` 包含 upload 与 logs 目录 |
| worker-svr | 无监听端口,`After` 需包含 rabbitmq |
| upstream-chan / proxy | `After` 需包含 consul |
| app-server | 需 `resource/log/server` 与 `resource/upload` 可写 |
| bot | `ExecStart` 需带参数:`/opt/iotlcm/bot/iotlcm-bot watch --config /opt/iotlcm/bot/config/config.yaml` |
### 启动顺序
```text
MySQL / Redis / RabbitMQ / Consul
↓
admin-svr、openapi、worker-svr、upstream-chan、upstream-proxy、app-server、sms
↓
admin-ui、app-web、docs(静态文件,由 nginx 托管)
```
用 systemd 依赖表达:
```ini
[Unit]
After=iotlcm-admin-svr.service
Requires=iotlcm-admin-svr.service
```
## 静态前端
`admin-ui`、`app-web`、`docs` 是静态文件,二进制部署时不需要跑容器,把构建产物交给 nginx:
```bash
# admin-ui:需 Node 构建
cd iotlcm-admin-ui/vue
npm ci && npm run build
sudo cp -r apps/web-antd/dist /var/www/iotlcm/admin-ui
```
nginx 配置参考部署仓的 `config/admin-ui/default.conf` 与 `config/nginx/iotlcm.conf`,把 `proxy_pass http://admin-svr:8008` 改为 `http://127.0.0.1:8008`。
## 验证
```bash
# 进程状态
systemctl status iotlcm-admin-svr
journalctl -u iotlcm-admin-svr -n 50 --no-pager
# 端口监听
ss -lntp | grep -E '8008|8000|8510|8110'
# 接口
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8008/api.json
```
## 与容器部署的差异
| 项 | 容器部署 | 二进制部署 |
| --- | --- | --- |
| 配置路径 | `/app/config`(挂载) | `<服务目录>/config` |
| 日志路径 | `/app/logs` | 需在配置里改成实际路径 |
| 服务互访 | 服务名(Docker DNS) | `127.0.0.1` 或内网 IP |
| 进程管理 | Docker restart policy | systemd |
| 升级 | 换镜像 + `up -d` | 停服务 → 换二进制 → 起服务 |
| 时区 | 镜像已内置 | 依赖宿主机 tzdata |
## 升级流程
```bash
# 1. 备份配置与程序
sudo tar czf /backup/iotlcm-admin-svr-$(date +%F).tar.gz -C /opt/iotlcm admin-svr
# 2. 停服务
sudo systemctl stop iotlcm-admin-svr
# 3. 替换二进制
sudo cp iotlcmadmin_svc /opt/iotlcm/admin-svr/
sudo chown iotlcm:iotlcm /opt/iotlcm/admin-svr/iotlcmadmin_svc
# 4. 起服务并验证
sudo systemctl start iotlcm-admin-svr
sudo systemctl status iotlcm-admin-svr
```
## 回滚
保留上一版二进制,回滚只需换回:
```bash
sudo systemctl stop iotlcm-admin-svr
sudo cp /opt/iotlcm/admin-svr/iotlcmadmin_svc.bak /opt/iotlcm/admin-svr/iotlcmadmin_svc
sudo systemctl start iotlcm-admin-svr
```
+297
View File
@@ -0,0 +1,297 @@
---
title: 自建镜像
description: 从源码构建各服务的 Docker 镜像
outline: deep
---
# 自建镜像
平台镜像由官方仓库分发。如有定制需求(改代码、加依赖、适配私有基础镜像),可从源码自行构建。
## 构建环境
| 项 | 要求 |
| --- | --- |
| Docker | 20.10 以上 |
| 网络 | 需访问基础镜像仓库与 Go/npm 依赖源 |
| 磁盘 | 单服务构建约 1~2 GB(含构建缓存) |
## 各服务构建文件
全部服务在同一仓内,`git clone` 后按下表的**构建上下文**执行 `docker build`:
| 服务 | 目录 | 构建上下文 | Dockerfile(相对上下文) |
| --- | --- | --- | --- |
| admin-svr | `admin-svr/` | `admin-svr/server` | `build/Dockerfile` |
| admin-ui | `admin-ui/` | `admin-ui` | `vue/build/Dockerfile` |
| openapi | `openapi/` | `openapi` | `build/Dockerfile` |
| worker-svr | `worker-svr/` | `worker-svr` | `build/Dockerfile` |
| upstream-chan | `upstream-chan/` | `upstream-chan` | `build/Dockerfile` |
| upstream-proxy | `upstream-proxy/` | `upstream-proxy` | `build/Dockerfile` |
| app-server | `app/server/` | `app/server` | `Dockerfile` |
| app-web | `app/app/` | `app/app` | `Dockerfile` |
| sms | `sms/` | `sms` | `build/Dockerfile` |
| bot | `bot/` | `bot` | `Dockerfile` |
| docs | `docs/` | `docs` | `Dockerfile` |
| onelink-mock | `onelink-mock/` | `onelink-mock` | `Dockerfile` |
> `admin-ui` 的上下文必须是 `admin-ui/`(不是 `admin-ui/vue/`)——它的 Dockerfile 里引用了 `vue/build/nginx.conf`。
一次构建全部:`make images REGISTRY=<镜像仓库>/iotlcm/iotlcm VERSION=<版本>`。
## 基础镜像可替换
所有构建用 Dockerfile 都通过 `ARG BASE_REGISTRY` 拼接 `FROM`,默认值是公共镜像仓库 `docker.io/library`,**开箱即可构建**:
```dockerfile
ARG BASE_REGISTRY=docker.io/library
FROM ${BASE_REGISTRY}/golang:1.25-alpine AS builder
```
内网或离线环境改走自己的镜像仓库时,构建时覆盖即可,不需要改 Dockerfile:
```bash
docker build --build-arg BASE_REGISTRY=harbor.internal.example.com/library \
-f admin-svr/server/build/Dockerfile -t ... admin-svr/server
```
各服务实际用到的基础镜像版本:
| 服务 | 基础镜像 |
| --- | --- |
| Go 服务(admin-svr / openapi / worker-svr / upstream-chan / upstream-proxy / app-server / sms / bot) | `golang:1.25-alpine`(bot 用 `golang:1.25-alpine`)、运行期 `alpine:3.23` |
| admin-ui / app-web | `node:24-alpine` 构建、`nginx:1.30-alpine` / `nginx:1.27-alpine` 运行 |
| docs | `node:22-alpine` 构建、`nginx:1.27-alpine` 运行 |
| onelink-mock | `golang:1.24-alpine` |
> `*/manifest/docker/Dockerfile` 是 GoFrame 脚手架留下的模板,不参与构建,可以忽略。
## 构建命令
### 后端服务
```bash
COMMIT=$(git rev-parse --short HEAD)
VERSION=$(git describe --tags --always)
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
# 在仓库根目录执行(上下文 = openapi/)
docker build \
--build-arg BuildVersion=$VERSION \
--build-arg CommitID=$COMMIT \
--build-arg BuildTime=$BUILD_TIME \
-t your-registry/iotlcm/iotlcm:iotlcm-openapi-$VERSION \
-f openapi/build/Dockerfile openapi/
```
`admin-svr` 与 `app-server` 的上下文是模块目录:
```bash
docker build \
--build-arg BuildVersion=$VERSION \
--build-arg CommitID=$COMMIT \
--build-arg BuildTime=$BUILD_TIME \
-t your-registry/iotlcm/iotlcm:iotlcm-admin-svr-$VERSION \
-f admin-svr/server/build/Dockerfile admin-svr/server/
```
### 前端服务
```bash
docker build -t your-registry/iotlcm/iotlcm:iotlcm-admin-ui-$VERSION \
-f admin-ui/vue/build/Dockerfile admin-ui/
```
```bash
docker build -t your-registry/iotlcm/iotlcm:iotlcm-app-web-$VERSION \
-f app/app/Dockerfile app/app/
```
```bash
docker build -t your-registry/iotlcm/iotlcm:iotlcm-app-server-$VERSION \
-f app/server/Dockerfile app/server/
```
### 构建参数说明
| 参数 | 用途 |
| --- | --- |
| `BuildVersion` | 写入二进制,可通过版本接口查询 |
| `CommitID` | 提交号,便于追溯 |
| `BuildTime` | 构建时间 |
`openapi`、`worker-svr` 等 Go 服务支持用 `GOPROXY` 构建参数替换代理:
```bash
docker build --build-arg GOPROXY=https://goproxy.cn,direct -t ... .
```
## 镜像命名规则
官方仓库把**所有镜像放在同一个镜像仓库** `iotlcm/iotlcm` 下,靠 tag 区分服务与版本:
```text
<Registry>/iotlcm/iotlcm:<服务名>-<版本>
```
| 服务 | tag | 完整示例 |
| --- | --- | --- |
| admin-svr | `iotlcm-admin-svr-<TAG>` | `.../iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4` |
| admin-ui | `iotlcm-admin-ui-<TAG>` | `.../iotlcm/iotlcm:iotlcm-admin-ui-v1.0.4` |
| openapi | `iotlcm-openapi-<TAG>` | `.../iotlcm/iotlcm:iotlcm-openapi-v1.0.4` |
| app-server | `iotlcm-app-server-<TAG>` | `.../iotlcm/iotlcm:iotlcm-app-server-v1.0.4` |
| app-web | `iotlcm-app-web-<TAG>` | `.../iotlcm/iotlcm:iotlcm-app-web-v1.0.4` |
| upstream-chan | `iotlcm-upstream-chan-<TAG>` | `.../iotlcm/iotlcm:iotlcm-upstream-chan-v1.0.4` |
| upstream-proxy | `iotlcm-upstream-proxy-<TAG>` | `.../iotlcm/iotlcm:iotlcm-upstream-proxy-v1.0.4` |
| worker-svr | `iotlcm-worker-svr-<TAG>` | `.../iotlcm/iotlcm:iotlcm-worker-svr-v1.0.4` |
| sms | `iotlcm-sms-<TAG>` | `.../iotlcm/iotlcm:iotlcm-sms-v1.0.4` |
| bot | `iotlcm-bot-<TAG>` | `.../iotlcm/iotlcm:iotlcm-bot-v1.0.4` |
| docs | `iotlcm-docs-<TAG>` | `.../iotlcm/iotlcm:iotlcm-docs-v1.0.4` |
| onelink-mock | `iotlcm-onelink-mock-<TAG>` | `.../iotlcm/iotlcm:iotlcm-onelink-mock-v1.0.4` |
基础组件(`mysql`、`redis`、`rabbitmq`、`consul`、`nginx`)不需要构建,从上游同步到同一仓库即可,tag 为 `<组件>-<版本>`,见下节。
::: warning tag 必须带上服务名
同一个仓库里 tag 不能重复。只用版本号(如 `v1.0.4`)会让 12 个服务互相覆盖,因此 tag 固定为 `<服务名>-<版本>`。
:::
## 同步基础组件
平台编排不再从 Docker Hub 直连基础组件(公网不稳定,且 `8.0` 这类 tag 会随补丁升级而漂)。部署前先把五个镜像同步到官方仓库:
```bash
REPO=crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com
docker login --username=sdqmx $REPO
# 按 docker-compose.yml 中固定的版本拉取并推送
sync_image() {
local src=$1 dst=$2
docker pull "$src"
docker tag "$src" "$REPO/iotlcm/iotlcm:$dst"
docker push "$REPO/iotlcm/iotlcm:$dst"
}
# 参数:<上游镜像> <目标 tag(组件名-版本)>
sync_image mysql:8.0 mysql-8.0
sync_image redis:7.2 redis-7.2
sync_image rabbitmq:4.3.5-management rabbitmq-4.3.5-management
sync_image hashicorp/consul:1.22 consul-1.22
sync_image nginx:1.30 nginx-1.30
```
::: warning 同步时把版本号固定到补丁位
`mysql:8.0` 这类浮动 tag 会随上游发新补丁而变化。建议同步时锁定到具体补丁版(如 `mysql:8.0.43`),tag 写成 `mysql-8.0.43`,并把 `docker-compose.yml` 里对应的 `image` 同步改掉,保证多台机器拉到的镜像完全一致。
:::
同步完成后校验:
```bash
for tag in mysql-8.0 redis-7.2 rabbitmq-4.3.5-management consul-1.22 nginx-1.30; do
docker manifest inspect "$REPO/iotlcm/iotlcm:$tag" >/dev/null 2>&1 \
&& echo "✅ $tag" || echo "❌ $tag"
done
```
## 推送到自己的仓库
自建镜像不需要推送回官方仓库。推到自有仓库后,把 `docker-compose.yml` 里的镜像前缀改指向它:
```bash
REG=harbor.internal.example.com
TAG=1.0.3
docker tag iotlcm-openapi:local $REG/iotlcm/iotlcm:iotlcm-openapi-$TAG
docker login $REG
docker push $REG/iotlcm/iotlcm:iotlcm-openapi-$TAG
```
```yaml
# docker-compose.yml —— 逐个替换 image 前缀
image: 'harbor.internal.example.com/iotlcm/iotlcm:iotlcm-openapi-v1.0.4'
```
::: tip 只覆盖部分服务
只想替换其中一两个服务时,把对应镜像推到自有仓库,其余保留官方地址。此时把 `docker-compose.yml` 里那几个 `image` 单独写成完整地址,其余保持官方仓库前缀即可。
:::
## 批量构建脚本
```bash
#!/bin/bash
set -euo pipefail
REPO=${REPO:?请设置 REPO}
TAG=${TAG:-$(date +%Y%m%d)}
WORKDIR=${WORKDIR:-./repos}
# 服务:仓库:上下文:产物镜像名
SERVICES=(
"admin-svr:iotlcm-admin-svr:server/:iotlcm-admin-svr"
"openapi:iotlcm-openapi:.:iotlcm-openapi"
"worker-svr:iotlcm-worker-svr:.:iotlcm-worker-svr"
"upstream-chan:iotlcm-upstream-chan:.:iotlcm-upstream-chan"
"upstream-proxy:iotlcm-upstream-proxy:.:iotlcm-upstream-proxy"
"app-server:iotlcm-app:server/:iotlcm-app-server"
"sms:iotlcm-sms:.:iotlcm-sms"
"bot:iotlcm-bot:.:iotlcm-bot"
)
mkdir -p "$WORKDIR"
for item in "${SERVICES[@]}"; do
IFS=':' read -r name repo ctx image <<< "$item"
echo "=== 构建 $name ==="
if [ ! -d "$WORKDIR/$repo" ]; then
git clone "https://git.5iot.com/iotlcm-public/$repo.git" "$WORKDIR/$repo"
fi
VERSION=$(git -C "$WORKDIR/$repo" describe --tags --always 2>/dev/null || echo unknown)
COMMIT=$(git -C "$WORKDIR/$repo" rev-parse --short HEAD 2>/dev/null || echo unknown)
find "$WORKDIR/$repo" -name Dockerfile -exec \
sed -i 's#FROM crpi-[^ ]*/\([a-z]*\):\([0-9.]*-alpine[0-9.]*\)#FROM \1:\2#' {} \;
docker build \
--build-arg BuildVersion="$VERSION" \
--build-arg CommitID="$COMMIT" \
--build-arg BuildTime="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
-t "$REPO/iotlcm/$image:$TAG" \
-f "$WORKDIR/$repo/${ctx}Dockerfile" \
"$WORKDIR/$repo/$ctx"
done
echo "全部完成,镜像标签:$REPO/iotlcm/*:$TAG"
```
::: warning 脚本里的 sed 只能应急
上面的 `sed` 是粗略替换,把私有仓库地址映射为公共镜像名。仅适用于地址格式规整的情况,用前先 `git diff` 确认改对了。正式做法应把 Dockerfile 的修正提交回仓库。
:::
## 验证构建结果
```bash
# 镜像大小
docker images | grep iotlcm
# 二进制能否启动
docker run --rm your-registry/iotlcm/iotlcm:iotlcm-openapi-$TAG --help 2>&1 | head
# 检查架构
docker inspect your-registry/iotlcm/iotlcm:iotlcm-openapi-$TAG \
--format '{{.Architecture}}/{{.Os}}'
```
构建后按[启动与验证](install)的清单做一次完整部署验证,不要只看镜像是否构建成功。
## 常见问题
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `pull access denied` / `unauthorized` | Dockerfile 里是私有仓库地址 | 按上文替换 `FROM` |
| `go mod download` 超时 | 网络无法访问 Go 模块代理 | 加 `--build-arg GOPROXY=https://goproxy.cn,direct` |
| `npm ci` 失败 | 前端仓库 lock 文件与 package.json 不一致 | 改用 `npm install`,并注意提交 lock 变更 |
| 镜像体积过大 | 未使用多阶段构建,或缓存未清理 | 确认用的是仓库自带的 Dockerfile(均为多阶段) |
| 容器启动后立即退出 | 镜像内缺少配置文件 | 容器认为 `config/config.yaml` 必须存在,需挂载或用占位配置 |
| `exec format error` | 构建架构与运行架构不一致 | 加 `--platform linux/amd64` |
+189
View File
@@ -0,0 +1,189 @@
---
title: 配置说明
description: 部署配置的结构、默认值与修改方式
outline: deep
---
# 配置说明
平台配置分两层,**都随仓库分发、都带可用默认值**,无需复制或初始化即可启动:
| 层 | 位置 | 管什么 |
| --- | --- | --- |
| 编排层 | `docker-compose.yml` | 镜像地址、端口、数据库/Redis/MQ 口令、容器编排 |
| 应用层 | `config/<服务>/config.yaml` | 业务参数、连接串、功能开关 |
::: warning 配置里是字面值,不是环境变量
配置文件与编排文件里直接写的是具体值,**不使用 `${VAR}` 占位**。
原因:GoFrame 不会展开配置文件中的 `${VAR}`(环境变量只用于覆盖配置键,不参与文件内文本替换),用占位符会导致连接串变成字面量 `${X}`,服务直接连不上库。
代价是改口令要改多处,见下方「改口令的正确姿势」。
:::
## 编排层:`docker-compose.yml`
### 镜像地址
```yaml
image: 'crpi-.../iotlcm/iotlcm:mysql-8.0'
image: 'crpi-.../iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4'
```
所有镜像(基础组件 + 业务服务)都在**同一个镜像仓库** `<Registry>/iotlcm/iotlcm` 下,靠 tag 区分:
| tag 形式 | 示例 |
| --- | --- |
| 基础组件:`<组件>-<版本>` | `mysql-8.0`、`redis-7.2`、`nginx-1.30` |
| 业务服务:`<服务>-<版本>` | `iotlcm-admin-svr-v1.0.4`、`iotlcm-app-server-v1.0.2` |
改用自有仓库时,全局替换 Registry 前缀即可。
### 默认口令
| 变量 | 默认值 | 用途 |
| --- | --- | --- |
| `MYSQL_ROOT_PASSWORD` | 见文件 | MySQL root |
| `MYSQL_DATABASE` | `db_iotlcm` | 库名 |
| `RABBITMQ_DEFAULT_PASS` | 见文件 | RabbitMQ |
| redis `--requirepass` | 见文件 | Redis |
### 端口
| 端口 | 服务 | 对外 |
| --- | --- | --- |
| 8008 | admin-svr | 经反向代理 |
| 8009 | admin-ui | **浏览器直接访问这个** |
| 8000 | openapi | 经反向代理 |
| 8010 / 8018 | app-server / app-web | 经反向代理 |
| 8019 | docs | 经反向代理 |
| 8080 | onelink-mock | 仅联调 |
| 3306 / 6379 / 5672 / 8500 | 基础组件 | 仅内网 |
| 8110 / 8310 / 8510 | chan / proxy | 仅内网 |
| 11190 / 11200 | sms | 仅内网 |
改端口直接改 `docker-compose.yml` 中对应 `ports` 的冒号左侧。
## 应用层:`config/`
```
config/
├── admin-svr/config.yaml
├── admin-ui/default.conf
├── app-server/config.yaml
├── bot/config.yaml
├── nginx/iotlcm.conf
├── openapi/config.yaml
├── sms/config.yaml
├── upstream-chan/config.yaml
├── upstream-proxy/config.yaml
└── worker-svr/config.yaml
```
### 各服务要点
#### admin-svr(管理端后端)
| 项 | 默认情况 |
| --- | --- |
| `database.default.link` | 指向 `mysql:3306`,口令与应用账号一致 |
| `database.legacyCard.link` | 空,不使用旧平台库 |
| `jwt.signingKey` | 已填固定密钥,需与 openapi、app-server 一致 |
| `warning.sms.enabled` | `true`,未配短信服务商时建议改 `false` |
| `warning.wechat.enabled` | `false` |
| `register.email.*` | 空,不发邮件 |
| `tsdb.tdengine.dsn` | 空,不启用时序库 |
| `alipay.*` | 空,不启用支付 |
| `upstream.appDiagnosisToken` | 已填,需与 upstream-proxy 一致 |
#### openapi(对外 API)
`jwt.signingKey` 与 admin-svr 一致;`database.default.link` 同库。建议另建只读账号用于生产。
#### worker-svr(异步任务)
| 项 | 说明 |
| --- | --- |
| `batch.inbound.upstreamConcurrency` | 默认 300,机器弱或上游配额小时调低 |
| `batch.inbound.upstreamRateLimit` | 运营商限流,**必须按上游实际配额设置**,调大易被封禁 |
| `tsdb.tdengine.dsn` | 空,不启用时序库 |
#### upstream-chan(运营商对接)
| 项 | 说明 |
| --- | --- |
| `registry.address` | `consul:8500` |
| `dependencies.sim_yd_mock_v5.url` | 指向挡板 `http://onelink-mock:8080/v5/`,接真实运营商时删除本段 |
运营商通道本身(AppKey、AppSecret、接口地址)在平台「通道管理」中录入,存数据库,不在本文件。
#### upstream-proxy(通道缓存转发)
| 项 | 说明 |
| --- | --- |
| `appDiagnosis.internalToken` | 与 admin-svr 的 `upstream.appDiagnosisToken` 一致 |
| `gateway.batchTimeout` | 默认 20m,**worker 侧超时必须大于此值** |
#### app-server(小程序后端)
`jwt.signingKey` 同上;`payment.wechat.enabled` 默认 `false`;`appCardRecognition.enabled` 默认 `false`(拍照识别需自备视觉模型 API)。
#### sms(短信服务)
| 项 | 默认 |
| --- | --- |
| `serviceType` | `1`(阿里云) |
| `accessKeyId` / `accessKeySecret` | 空,需自行填写 |
| `signName` | `示例签名` |
不填则无法发送验证码,其余功能正常。
#### bot(飞书通知)
`feishu.appId` / `appSecret` / `chatId` 均为空。不启动该容器即可。
#### admin-ui / nginx
`admin-ui/default.conf` 把 `/api/` 反代到 `admin-svr:8008`;`nginx/iotlcm.conf` 按域名分流,需把 `your-domain.example.com` 换成实际域名。
## 改口令的正确姿势
改数据库口令时,以下**三处必须同时改**,漏一处就起不来:
| 位置 | 内容 |
| --- | --- |
| `docker-compose.yml` | `MYSQL_ROOT_PASSWORD`、redis `--requirepass`、`RABBITMQ_DEFAULT_PASS` |
| `config/*/config.yaml` | 各服务的 `database.default.link`、`redis.default.pass`、`rabbitmq.dsn` |
| `schema/04-users.sh`、`schema/05-reset-passwords.sh` | 脚本内的 `APP_PASSWORD` / `ADMIN_PASSWORD` / `ROOT_PASSWORD` |
::: tip 一次性全局替换
```bash
OLD='原口令'
NEW='新口令'
# 编排 + 配置
grep -rl "$OLD" docker-compose.yml config/ | xargs sed -i "s/$OLD/$NEW/g"
# 初始化脚本
grep -rl "$OLD" schema/ | xargs sed -i "s/$OLD/$NEW/g"
```
:::
::: danger 口令不能含 @ : / 等字符
连接串格式为 `mysql:用户:口令@tcp(主机:端口)/库`,口令里的 `@`、`:`、`/` 会被解析成分隔符,导致连接失败。用字母数字组合。
:::
## 配置自检
```bash
# 编排语法
docker compose config --quiet
# 看某个服务最终的配置展开
docker compose config | sed -n '/admin-svr:/,/^ [a-z]/p'
```
配置语法错误会导致服务启动后立刻退出,看日志定位:
```bash
docker compose logs --tail=50 admin-svr
```
+97
View File
@@ -0,0 +1,97 @@
---
title: 部署概览
description: IotLCM 平台的部署方式、构成与前置条件
outline: deep
---
# 部署概览
IotLCM 平台通过 Docker Compose 在单机上部署。部署编排与数据库初始化脚本在**代码仓的 `deploy/` 目录**:
```bash
git clone <仓库地址> iotlcm
cd iotlcm/deploy
```
```text
deploy/
├── docker-compose.yml # 服务编排(含默认口令与端口)
├── config/ # 各服务配置,10 份,已带可用默认值
└── schema/ # 数据库初始化脚本,13 个,首次启动自动执行
```
## 部署内容
| 类别 | 服务 |
| --- | --- |
| 基础组件 | MySQL、Redis、RabbitMQ、Consul |
| 管理端 | admin-svr(后端)、admin-ui(前端) |
| 对外接口 | openapi |
| 小程序 / H5 | app-server(后端)、app-web(前端) |
| 运营商对接 | upstream-chan(协议适配)、upstream-proxy(缓存转发) |
| 异步任务 | worker-svr(批次执行、全量同步) |
| 配套 | sms(短信)、docs(本文档站) |
| 可选 | onelink-mock(运营商接口挡板)、bot(飞书通知)、nginx(反向代理) |
## 部署流程
```bash
git clone <仓库地址> iotlcm
cd iotlcm/deploy
docker compose up -d
```
三步,不需要复制或修改任何文件——配置与编排都随仓库分发且带可用默认值。首次启动自动建库并导入 schema,约 1~2 分钟。
启动后访问 `http://<宿主机IP>:8009`,账号 `admin`,口令见 `docker-compose.yml`。
::: warning 默认口令只用于试用
正式部署前必须修改数据库、Redis、RabbitMQ、admin 账号口令与 JWT 密钥,详见[配置说明](configuration)。
:::
## 文档导航
| 文档 | 内容 |
| --- | --- |
| [环境准备](prerequisites) | 硬件、系统、Docker 版本、镜像仓库、端口规划 |
| [配置说明](configuration) | `docker-compose.yml` 与 `config/` 各项含义 |
| [启动与验证](install) | 启动流程、验证清单、首次登录 |
| [其他部署方式](alternatives) | 二进制、自建镜像、分离、离线 |
| [可选服务](optional) | 接口挡板、飞书通知、反向代理 |
| [升级与备份](operations) | 版本固定、升级回滚、数据备份 |
| [故障排查](troubleshooting) | 常见问题与处理 |
## 关键约束
::: warning JWT 密钥必须三处一致
`admin-svr`、`openapi`、`app-server` 共用同一个 `JWT_SIGNING_KEY`。不一致的表现是能登录管理端,但调用外部 API 或小程序端提示未授权。
更换该值会使已签发的全部登录态与接口令牌立即失效,需在维护窗口操作。
:::
::: tip 镜像标签不要用 latest
生产环境必须把 `TAG` 固定到具体版本号。`latest` 会随仓库推送变化,同一份编排在不同时间拉到的镜像不同,出问题无法定位,也无法明确回滚。
:::
::: danger 修改 schema 脚本不会自动生效
数据库初始化只在 `data/mysql` 为空时执行。改了 `schema/` 下的脚本必须清空数据卷才会重跑,而清空会丢失全部数据。生产环境禁止这样操作。
:::
## 服务端口
默认端口规划:
| 端口 | 服务 | 是否对外 |
| --- | --- | --- |
| 8000 | openapi | 经反向代理 |
| 8008 | admin-svr | 经反向代理 |
| 8009 | admin-ui | 经反向代理 |
| 8010 | app-server | 经反向代理 |
| 8018 | app-web | 经反向代理 |
| 8019 | docs | 经反向代理 |
| 3306 / 6379 / 5672 / 8500 | MySQL / Redis / RabbitMQ / Consul | 仅内网 |
| 8110 / 8310 / 8510 | upstream-chan / upstream-proxy | 仅内网 |
| 11190 / 11200 | sms | 仅内网 |
| 8080 | onelink-mock | 仅联调 |
端口冲突时修改 `docker-compose.yml` 中对应变量,不要直接改 `docker-compose.yml`。
+187
View File
@@ -0,0 +1,187 @@
---
title: 启动与验证
description: 首次启动流程、状态检查与验证清单
outline: deep
---
# 启动与验证
## 启动
```bash
git clone <仓库地址> iotlcm
cd iotlcm/deploy
# 首次部署建议前台启动,便于观察初始化过程
docker compose up
# 确认无误后转后台
docker compose up -d
```
不需要复制或修改任何文件,配置已带可用默认值。
### 首次启动发生什么
1. **MySQL 建库并导入 schema**(约 1~2 分钟)
`schema/` 下 13 个脚本按文件名顺序执行:建表 → 结构补齐 → 创建应用账号 → 重置种子账号口令 → 导入通道模板/字典/菜单等种子数据
2. MySQL 通过健康检查后,`admin-svr`、`openapi`、`worker-svr` 依次启动
3. 各服务向 Consul 注册
观察初始化进度:
```bash
docker compose logs -f mysql
```
看到 `[initdb] 应用账号 u_iotlcm 已创建` 与 `[initdb] 种子账号口令已统一重置` 表示初始化完成。
::: warning 初始化失败需清空数据卷
MySQL 只在 `data/mysql` 为空时执行 `schema/`。若中途失败,改完脚本必须清空卷才会重跑:
```bash
docker compose down
sudo rm -rf ./data/mysql
docker compose up -d
```
这会删除全部数据,仅限首次部署阶段使用。
:::
## 检查状态
```bash
docker compose ps
```
期望结果:
| 服务 | 状态 |
| --- | --- |
| iotlcm-mysql | Up (healthy) |
| iotlcm-redis | Up (healthy) |
| iotlcm-rabbitmq | Up (healthy) |
| iotlcm-consul | Up |
| iotlcm-admin-svr | Up |
| iotlcm-admin-ui | Up |
| iotlcm-openapi | Up |
| iotlcm-upstream-chan | Up |
| iotlcm-upstream-proxy | Up |
| iotlcm-worker-svr | Up |
| iotlcm-app-server | Up |
| iotlcm-app-web | Up |
| iotlcm-sms | Up (healthy) |
| iotlcm-docs | Up |
出现 `Restarting` 或 `Exited` 需查日志:
```bash
docker compose logs --tail=100 <服务名>
```
## 验证清单
按顺序逐项确认,任一步失败先解决再继续。
### 1. 数据库表数量
```bash
docker compose exec mysql sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e \
"SELECT COUNT(*) AS tables FROM information_schema.tables WHERE table_schema=\"db_iotlcm\";"'
```
预期 `tables` = **89**。
### 2. 种子数据
```bash
docker compose exec mysql sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm -e \
"SELECT (SELECT COUNT(*) FROM sys_menu) AS menus, (SELECT COUNT(*) FROM sys_user) AS users;"'
```
预期菜单约 **447** 条,账号 **18** 个。
### 3. 管理端后端
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8008/api.json
```
预期 `200`。
### 4. 管理端前端
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8009/
```
预期 `200`。
### 5. 对外 API
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api.json
```
预期 `200`。
### 6. 短信服务
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:11190/healthz
```
预期 `200`。
### 7. 浏览器登录
访问 `http://<宿主机IP>:8009`:
| 项 | 值 |
| --- | --- |
| 账号 | `admin` |
| 口令 | `docker-compose.yml` 中 admin 的默认口令(首次登录后立即修改) |
能进入首页工作台、左侧菜单完整显示即部署成功。
## 首次登录后必做
1. **修改 admin 口令**:右上角个人中心 → 安全设置
2. **处理其余种子账号**:`user02`、`2admin` 等 17 个账号默认与 admin 同一口令,不使用请停用或删除
3. **确认字典与菜单加载**:进入「卡信息」页,确认筛选下拉选项不为空
## 后端自检
```bash
# admin-svr 是否连上数据库
docker compose logs admin-svr | grep -iE 'error|panic' | tail -20
# worker-svr 是否连上 RabbitMQ
docker compose logs worker-svr | tail -30
# upstream-chan 是否连上 Redis 与 Consul
docker compose logs upstream-chan | tail -30
```
正常日志不应出现 `connection refused`、`access denied`、`panic`。
## 停止与重启
```bash
docker compose stop # 停止,保留数据
docker compose restart # 重启全部
docker compose restart admin-svr # 重启单个
docker compose down # 停止并删除容器(./data 不受影响)
```
::: danger 不要执行 down -v
`-v` 会删除命名数据卷。本编排数据都在 `./data` 宿主机目录,但 `-v` 仍可能影响同项目的其他卷。停止服务用 `down` 即可。
:::
## 下一步
- 需要运营商挡板联调 → [可选服务](optional)
- 需要域名访问 → [可选服务](optional) 的反向代理章节
- 遇到报错 → [故障排查](troubleshooting)
+170
View File
@@ -0,0 +1,170 @@
---
title: 离线部署
description: 服务器无法访问镜像仓库时的部署方式
outline: deep
---
# 离线部署
服务器无法访问外部镜像仓库时使用。核心思路:**在有网机器上导出镜像 → 传到目标机 → 导入**。
## 适用场景
- 生产服务器在内网,不能访问外网
- 镜像仓库在内网不可达,或需要走审批才能拉取
- 需要把某一批镜像固化下来,保证多台机器部署一致
## 一、确定镜像清单
```bash
docker compose config --images | sort -u
```
输出示例(含可选 profile 时用 `--profile` 指定):
```text
registry.example.com/iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4
registry.example.com/iotlcm/iotlcm:iotlcm-admin-ui-v1.0.4
registry.example.com/iotlcm/iotlcm:iotlcm-app-server-v1.0.2
...
registry.example.com/iotlcm/iotlcm:mysql-8.0
registry.example.com/iotlcm/iotlcm:redis-7.2
```
把清单保存下来,作为这次部署的镜像基线:
```bash
docker compose config --images | sort -u > images.txt
```
## 二、在有网机器导出
```bash
# 拉取全部镜像
docker compose pull
# 导出为单个压缩包
docker save $(cat images.txt) | gzip > iotlcm-images-$(date +%Y%m%d).tar.gz
```
单包体积参考:全部 14 个服务约 **1.5~2 GB**(压缩后)。
若目标机磁盘紧张,可按服务分包:
```bash
for img in $(cat images.txt); do
name=$(echo "$img" | tr '/:' '__')
docker save "$img" | gzip > "img-${name}.tar.gz"
done
```
## 三、传输
```bash
scp iotlcm-images-*.tar.gz user@target-host:/opt/iotlcm/
```
无网络直连时用移动介质拷入,注意校验完整性:
```bash
# 源端生成校验值
sha256sum iotlcm-images-*.tar.gz > images.sha256
# 目标端校验
sha256sum -c images.sha256
```
## 四、在目标机导入
```bash
gunzip -c iotlcm-images-*.tar.gz | docker load
```
分包的逐个导入:
```bash
for f in img-*.tar.gz; do gunzip -c "$f" | docker load; done
```
验证:
```bash
docker images | grep iotlcm
```
## 五、部署
导入后镜像已在本机,compose 不会再尝试拉取:
```bash
# 把 docker-compose.yml 里的镜像地址与 TAG 改成与导入的镜像一致
cp -r config.example config
vi config/*/config.yaml
docker compose up -d
```
::: warning REGISTRY 与 TAG 必须与导入的镜像名一致
`docker load` 只恢复镜像名,不会改标签。若 `docker-compose.yml` 里写 `TAG=latest` 而导入的是 `1.0.3`,compose 会认为镜像不存在并尝试拉取,离线环境直接失败。
用这条命令核对:
```bash
docker compose config --images | sort -u > expected.txt
docker images --format '{{.Repository}}:{{.Tag}}' | sort -u > actual.txt
comm -23 expected.txt actual.txt # 输出为空才说明镜像齐全
```
:::
## 六、长期方案:内网镜像仓库
如果需要经常部署或多机部署,建议搭内网镜像仓库,而不是每次传 tar 包。
### 同步镜像
```bash
# 在能访问外网的机器
for img in $(cat images.txt); do
docker pull "$img"
newimg="${img/registry.example.com/harbor.internal.example.com}"
docker tag "$img" "$newimg"
docker push "$newimg"
done
```
然后目标机的 `docker-compose.yml` 改为:
```bash
REGISTRY=harbor.internal.example.com
```
### 差异更新
版本升级时只需推送变化的镜像:
```bash
docker compose pull
# 对比 images.txt 找出新增/变更的标签,只同步这些
```
## 七、常见问题
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `docker compose up` 卡在 `Pulling` | 镜像标签不匹配,compose 尝试联网拉取 | 按上文核对 `expected.txt` 与 `actual.txt` |
| `no space left on device` | 镜像包 + 解压后镜像双份占盘 | 导入后删除 tar 包,或换挂载点 |
| `load` 后 `docker images` 为空 | 压缩包损坏 | 用 `sha256sum -c` 校验 |
| 容器启动报 `exec format error` | 架构不匹配(x86 镜像跑在 ARM) | 在目标架构上重新导出 |
| 导出包过大 | 包含多个无用层 | 用 `docker save` 只导出必要标签 |
## 八、离线环境的其他准备
镜像只是第一步,以下也需提前准备:
| 资源 | 说明 |
| --- | --- |
| 部署代码 | `git clone` 或直接打包 `deploy/` 目录带过去 |
| 配置模板 | 已包含在部署仓 `config/` |
| 数据库脚本 | 已包含在部署仓 `schema/` |
| Docker 与 Compose | 目标机需预先安装,离线安装包需单独准备 |
| 时区数据 | 若使用 `Asia/Shanghai`,确认容器内 tzdata 完整(官方镜像已内置) |
+184
View File
@@ -0,0 +1,184 @@
---
title: 升级与备份
description: 版本固定、升级回滚、数据备份与日志清理
outline: deep
---
# 升级与备份
## 版本固定
生产环境**必须固定镜像标签**,不要用 `latest`:
- `latest` 会随仓库推送变化,同一份编排在不同时间拉到的镜像不同
- 出问题无法确定运行版本
- 回滚时没有明确目标
`docker-compose.yml` 中指定:
```bash
TAG=1.0.3
```
记录当前部署版本:
```bash
echo "$(date '+%Y-%m-%d %H:%M') 镜像=$(grep -m1 'iotlcm-admin-svr:' docker-compose.yml)" >> deploy-history.log
```
## 升级
```bash
# 1. 备份(不要跳过,见下节)
./backup.sh
# 2. 拉新镜像
vi docker-compose.yml # 修改镜像 TAG
docker compose pull
# 3. 更新
docker compose up -d
# 4. 验证
docker compose ps
docker compose logs --tail=50 admin-svr
```
滚动更新单个服务减少中断:
```bash
docker compose up -d --no-deps admin-svr
```
## 回滚
```bash
vi docker-compose.yml # TAG 改回旧版本
docker compose pull
docker compose up -d
```
::: warning 数据库变更不能随代码回滚
若新版本含数据库结构变更,回滚代码后数据库仍是新结构。升级前确认变更可逆,或保留升级前的完整备份。
:::
## 数据库重建
`schema/` 脚本只在 `data/mysql` 为空时执行:
| 情况 | 是否需要重建 |
| --- | --- |
| 升级应用镜像 | 否 |
| 修改 `config/` | 否,重启对应服务即可 |
| 修改 `schema/` 脚本 | 是(会丢数据) |
| 首次初始化失败 | 是 |
```bash
docker compose down
sudo rm -rf ./data/mysql
docker compose up -d
```
::: danger 生产环境禁止清空 data/mysql
生产数据必须通过数据库工具单独备份与迁移,不能依赖 schema 重建。
:::
## 备份
### 备份脚本
```bash
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
STAMP=$(date '+%Y%m%d-%H%M%S')
DEST="./backups/${STAMP}"
mkdir -p "$DEST"
echo "[1/3] 导出数据库..."
docker compose exec -T mysql sh -c \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers --events db_iotlcm' \
| gzip > "${DEST}/db_iotlcm.sql.gz"
echo "[2/3] 备份配置..."
tar czf "${DEST}/config.tar.gz" config/ docker-compose.yml
echo "[3/3] 备份上传文件..."
tar czf "${DEST}/upload.tar.gz" data/upload/ 2>/dev/null || echo " 上传目录为空,跳过"
echo "完成:${DEST}"
ls -lh "$DEST"
```
```bash
chmod +x backup.sh && ./backup.sh
```
### 只备份数据库
```bash
docker compose exec -T mysql sh -c \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction db_iotlcm' \
| gzip > "db_iotlcm-$(date +%Y%m%d).sql.gz"
```
### 恢复
```bash
gunzip -c db_iotlcm-20260101.sql.gz | \
docker compose exec -T mysql sh -c \
'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm'
```
### 备份内容与优先级
| 内容 | 位置 | 重要性 |
| --- | --- | --- |
| 数据库 | MySQL 容器内 `db_iotlcm` | **最高**,业务数据全部在此 |
| 配置 | `config/` + `docker-compose.yml` | 高,丢失需重新配置 |
| 上传文件 | `data/upload/` | 中,附件与图片 |
| Redis 数据 | `data/redis/` | 低,缓存可重建 |
| RabbitMQ 队列 | `data/rabbitmq/` | 低,未消费消息会丢失 |
| 日志 | `data/logs/` | 低,排障用 |
### 备份策略
| 场景 | 频率 | 保留 |
| --- | --- | --- |
| 生产 | 每日一次 + 升级前 | 30 天 |
| 试用 | 有数据变更时 | 7 天 |
备份文件应转存到异机或对象存储,不要与数据库同盘。
## 日志清理
日志写在 `data/logs/` 下按服务分目录,应用侧已按小时轮转(`{Y-m-d-H}.log`)。清理旧日志:
```bash
# 删除 7 天前的日志
find ./data/logs -name '*.log' -mtime +7 -delete
```
建议配置 cron 定期执行:
```cron
0 3 * * * root find /path/to/iotlcm/deploy/data/logs -name '*.log' -mtime +7 -delete
```
## 监控
基础组件自带端点:
| 服务 | 端点 |
| --- | --- |
| RabbitMQ | `http://<host>:15672` 管理界面 |
| Consul | `http://<host>:8500/ui` |
| MySQL | `docker compose exec mysql mysqladmin status` |
建议告警指标:
- MySQL 连接数与磁盘使用率
- Redis 内存使用率
- RabbitMQ 队列积压(`iotlcm.batch.execute`、`iotlcm.app.business`)
- worker-svr 全量同步任务失败数
+171
View File
@@ -0,0 +1,171 @@
---
title: 可选服务
description: 运营商接口挡板、飞书通知与反向代理
outline: deep
---
# 可选服务
以下服务默认不启动,按需通过 profile 开启。
## 运营商接口挡板
### 用途
模拟中国移动 OneLink 接口。**没有真实运营商环境时,靠它跑通完整链路**:
```text
admin-svr → worker-svr → upstream-chan → onelink-mock
```
不开挡板时,`upstream-chan` 会尝试连接真实运营商接口。没有凭据则批次执行、状态同步、流量查询全部失败。
### 启动
```bash
docker compose --profile with-mock up -d
```
验证:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api.json
```
预期 `200`。接口文档在 `http://127.0.0.1:8080/swagger`。
### 配置
挡板自身无需配置,数据存在容器内 SQLite(`./data/mock/onelink_mock.db`)。
`upstream-chan` 配置中已预置指向:
```yaml
dependencies:
sim_yd_mock_v5:
url: "http://onelink-mock:8080/v5/"
```
数据库种子脚本 `schema/08-seed-mock-channel.sql` 已插入 Mock 通道(`channel_yd_mock`)并指向该地址。
`app-server` 若使用挡板,把 `upstream.onelinkMock.enabled` 改为 `true`,`baseUrl` 填 `http://onelink-mock:8080/v5`。
### 造测试卡
挡板支持生成模拟卡片,用于验证查询、开停复机、续费流程。接口见挡板 Swagger 页面。
### 切换到真实运营商
1. 删除或注释 `upstream-chan` 配置中的 `sim_yd_mock_v5` 段
2. 在平台「通道管理」录入真实运营商通道(AppKey、AppSecret、接口地址)
3. 停用 Mock 通道,避免卡片误挂
```bash
docker compose --profile with-mock stop onelink-mock
```
## 飞书通知
### 用途
把全量同步完成、失败告警等运维事件推送到飞书群。
### 准备
在飞书开放平台创建自建应用,获取 `appId`、`appSecret` 与目标群的 `chatId`,填入 `docker-compose.yml`:
```bash
FEISHU_APP_ID=cli_xxxxxxxx
FEISHU_APP_SECRET=xxxxxxxx
FEISHU_CHAT_ID=oc_xxxxxxxx
```
### 启动
```bash
docker compose --profile with-bot up -d
```
bot 读取 `worker-svr` 生成的全量同步归档(`./data/logs/worker-svr/full-sync`),该目录有数据后才会产生通知。
## 反向代理
### 用途
把分散在各端口的服务统一到域名下按路径分流。宿主机已有 nginx 或面板时不需要。
### 域名与分流
编辑 `config/nginx/iotlcm.conf`:
| 域名 | 分流到 |
| --- | --- |
| `api.your-domain.example.com` | openapi(`/openapi/v3/`、`/route/open/`) |
| `admin.your-domain.example.com` | 管理端前端 + `/docs/` 文档站 |
| `app.your-domain.example.com` | 小程序前端 + `/app/api/v1/` 后端 |
### 启动
```bash
docker compose --profile with-nginx up -d
```
默认监听 80,改 `docker-compose.yml` 的 `NGINX_HTTP_PORT` 可调整。
### 使用宿主机 nginx
不启动容器时,把 `config/nginx/iotlcm.conf` 中的 `upstream` 与 `location` 段落复制到宿主机 nginx 配置。
::: warning 宿主机 nginx 无法解析容器名
需要把 `upstream` 里的服务名换成宿主机地址:
```nginx
# 容器内写法
server admin-svr:8008;
# 宿主机写法
server 127.0.0.1:8008;
```
:::
### 启用 HTTPS
自带配置未包含证书,启用需三步:
1. 准备证书,放入 `data/certs/`
2. 在 server 块加入 SSL 配置:
```nginx
listen 443 ssl;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
```
3. 给 nginx 服务增加证书目录挂载,并加 80 → 443 跳转:
```yaml
volumes:
- ./data/certs:/etc/nginx/certs:ro
```
```nginx
server {
listen 80;
server_name admin.your-domain.example.com;
return 301 https://$host$request_uri;
}
```
## 组合启动
多个 profile 可同时启用:
```bash
docker compose --profile with-mock --profile with-nginx up -d
```
查看指定 profile 下的服务清单:
```bash
docker compose --profile with-mock config --services
```
+134
View File
@@ -0,0 +1,134 @@
---
title: 环境准备
description: 部署 IotLCM 所需的硬件、系统、Docker 与网络条件
outline: deep
---
# 环境准备
## 硬件要求
按数据规模选择:
| 场景 | CPU | 内存 | 磁盘 | 说明 |
| --- | --- | --- | --- | --- |
| 试用 / 演示 | 2 核 | 4 GB | 40 GB | 少量卡片,建议停用定时同步任务 |
| 生产(中小规模) | 4 核 | 8 GB | 200 GB | 万级卡片,开启全量同步 |
| 生产(大规模) | 8 核+ | 16 GB+ | 500 GB+ | 十万级卡片,建议数据库独立部署 |
磁盘估算:MySQL 数据约 1~2 KB/张卡;日志随同步任务量增长,建议单独挂载并配置轮转。
生产 8 GB 场景的内存分配参考:
| 服务 | 占用 |
| --- | --- |
| MySQL | 1~2 GB |
| Redis | 200~500 MB |
| RabbitMQ | 300~500 MB |
| worker-svr | 500 MB~1 GB(与并发同步卡片数相关) |
| 其余业务服务 | 各 100~200 MB |
## 系统要求
| 项 | 要求 |
| --- | --- |
| 操作系统 | Linux x86_64(CentOS 7+ / Ubuntu 20.04+ / Debian 11+) |
| 内核 | 3.10 以上 |
| 文件系统 | ext4 / xfs |
| 时区 | 建议 `Asia/Shanghai`,与 `docker-compose.yml` 的 `TZ` 保持一致 |
## Docker 要求
| 组件 | 最低版本 | 说明 |
| --- | --- | --- |
| Docker Engine | 20.10 | 需支持 `healthcheck` 与 `profiles` |
| Docker Compose | v2.0(插件版) | 使用 `docker compose` 而非 `docker-compose` |
验证:
```bash
docker version
docker compose version
```
`docker compose version` 需 ≥ v2.0。若只有 `docker-compose`(v1),请升级到 Compose V2 插件。
## 镜像仓库
平台镜像发布在阿里云容器镜像服务,**仓库类型为公开,无需登录即可拉取**。
| 项 | 值 |
| --- | --- |
| 公网地址 | `crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com` |
| VPC 地址 | `crpi-qflrqrxoohlkkvgc-vpc.cn-chengdu.personal.cr.aliyuncs.com` |
| 命名空间 | `iotlcm` |
| 地域 | 西南1(成都) |
**所有镜像都放在同一个镜像仓库** `iotlcm/iotlcm` 下,靠 tag 区分服务与版本:
```text
crpi-.../iotlcm/iotlcm:mysql-8.0
crpi-.../iotlcm/iotlcm:redis-7.2
crpi-.../iotlcm/iotlcm:iotlcm-admin-svr-v1.0.4
crpi-.../iotlcm/iotlcm:iotlcm-openapi-v1.0.4
crpi-.../iotlcm/iotlcm:iotlcm-app-server-v1.0.2
```
tag 格式统一为 **`<服务名>-<版本>`**。
地址结构是 `<Registry>/<命名空间>/<镜像仓库>:<tag>`,即:
```text
crpi-...cn-chengdu.personal.cr.aliyuncs.com / iotlcm / iotlcm : iotlcm-admin-svr-v1.0.4
↑ 命名空间 ↑ 镜像仓库 ↑ tag
```
`docker-compose.yml` 里的镜像地址已预置为上述公网地址,一般无需修改。
::: tip 基础组件也在同一仓库
MySQL、Redis、RabbitMQ、Consul、nginx 同样在这个仓库里(`mysql-8.0`、`redis-7.2` 等 tag),不从 Docker Hub 直连(公网不稳定且 tag 会漂)。版本已在编排文件中固定。
:::
::: tip 阿里云 VPC 地址
若部署在阿里云同地域 ECS 上,可把编排文件里的 Registry 前缀改为 VPC 地址,走内网流量、不消耗公网带宽:
```yaml
image: 'crpi-qflrqrxoohlkkvgc-vpc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:mysql-8.0'
```
:::
## 网络与防火墙
仅需对外开放反向代理端口(默认 80 / 443),其余服务端口应限制为内网访问。
```bash
# 示例:firewalld 仅放行 80/443
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
```
::: tip 默认只监听本机
`admin-svr`、`admin-ui`、`openapi`、`app-server`、`app-web`、`docs` 默认绑定 `127.0.0.1`,只能本机访问。对外需通过反向代理,见[可选服务](optional)。
:::
## 外部依赖(可选)
以下能力需要外部账号或服务,**不影响平台主流程**:
| 服务 | 用途 | 不配置的影响 |
| --- | --- | --- |
| 短信服务商 | 发送验证码 | 无法发送短信 |
| 微信支付 | 在线支付 | 支付功能不可用 |
| 飞书 | 运维通知 | 不启动 bot 即可 |
::: tip 时序库已内置
流量历史使用的 TDengine **已包含在编排里**,由 `tdengine-init` 服务在首次启动时自动建库建表,无需额外申请或配置:
| 库 | 分片 | 保留 | 超级表 |
| --- | --- | --- | --- |
| `iotlcm_daily` | 7 天 | 3 年 | `t_sim_card_usage_daily` |
| `iotlcm_monthly` | 30 天 | 3 年 | `t_sim_card_usage_monthly` |
编排使用的版本是 `3.3.6.13`(公开镜像可得),生产为 `3.4.0.9`。如需与生产完全一致,可自行构建镜像并替换 `docker-compose.yml` 中的 tag。
:::
+181
View File
@@ -0,0 +1,181 @@
---
title: 分离部署
description: 中间件独立部署,业务服务单独一台或多台
outline: deep
---
# 分离部署
把 MySQL、Redis、RabbitMQ、Consul 放到独立主机或使用云托管,业务服务单独部署。
## 适用场景
- 数据库已有云托管实例(RDS、云 Redis、云 MQ)
- 中间件已有独立集群,不希望在业务机上再跑一套
- 业务机配置较低,不适合同时承载数据库
## 部署结构
```text
┌─────────────────────┐ ┌──────────────────────┐
│ 中间件主机 / 云服务 │ │ 业务主机 │
│ │ │ │
│ MySQL :3306 │◄───────┤ admin-svr :8008 │
│ Redis :6379 │ │ admin-ui :80 │
│ RabbitMQ :5672 │ │ openapi :8000 │
│ Consul :8500 │ │ app-server :8010 │
│ │ │ worker-svr │
└─────────────────────┘ │ upstream-chan/-proxy│
└──────────────────────┘
```
## 一、准备中间件
在中间件侧完成(云托管通常已就绪):
| 组件 | 需准备 |
| --- | --- |
| MySQL | 建库 `db_iotlcm`、执行 `schema/` 脚本、建应用账号 `u_iotlcm` |
| Redis | 开启认证,记录地址与口令 |
| RabbitMQ | 建用户与 vhost,记录地址与口令 |
| Consul | 单机模式即可,记录地址 |
### 初始化数据库
在能连到目标 MySQL 的机器上执行:
```bash
# 按文件名顺序执行全部脚本
for f in schema/0*.sql; do
echo "执行 $f"
mysql -h <MySQL地址> -uroot -p<口令> < "$f"
done
```
::: warning `.sh` 脚本需单独执行
`schema/04-users.sh` 与 `schema/05-reset-passwords.sh` 依赖环境变量,不能直接重定向执行:
```bash
MYSQL_ROOT_PASSWORD='<口令>' MYSQL_APP_PASSWORD='<应用口令>' \
MYSQL_HOST=<地址> bash schema/04-users.sh
```
:::
## 二、准备业务机 Compose
复制 `docker-compose.yml`,删除四个中间件服务(`mysql`、`redis`、`rabbitmq`、`consul`),并移除各处 `depends_on` 对它们的引用。
```yaml
# docker-compose.app.yml
services:
admin-svr:
image: '${REGISTRY}/iotlcm/iotlcm:iotlcm-admin-svr-${TAG}'
# ... 保留 ports / volumes / environment
# 删除 depends_on 中对 mysql/redis/rabbitmq 的依赖
```
::: tip 网络要求
业务机必须能访问中间件主机的对应端口。若有防火墙,放行:
- 业务机 → 中间件:3306、6379、5672、8500
- 外部 → 业务机:仅反向代理端口(80 / 443)
:::
## 三、修改配置指向外部地址
这是本节最关键的一步。所有配置里以**服务名**书写的地址都要改成实际地址。
### admin-svr
```yaml
database:
default:
link: "mysql:u_iotlcm:<口令>@tcp(<MySQL地址>:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
redis:
default:
address: "<Redis地址>:6379"
pass: "<Redis口令>"
rabbitmq:
dsn: "amqp://<用户>:<口令>@<RabbitMQ地址>:5672/"
```
### openapi
同上三处:`database.default.link`、`redis.default.address`、`rabbitmq.dsn`。
### worker-svr
同上三处。注意 `tsdb.tdengine.dsn` 若使用 TDengine 也要改为实际地址。
### app-server
改 `database.default.link` 与 `redis.default`。
### upstream-chan / upstream-proxy
```yaml
registry:
type: "consul"
address: "<Consul地址>:8500"
```
同时改 `database.default.link` 与 `redis.default`。
### 服务互访地址
若业务服务分散在多台机器,`admin-svr`、`worker-svr`、`app-server` 配置里的上游地址也要改:
```yaml
upstream:
proxy:
address: "<upstream-proxy所在主机>:8510"
gateway:
address: "<upstream-chan所在主机>:8110"
```
`upstream-proxy` 的 `gateway.address` 同理。
::: danger 最容易出的错
只改数据库地址,忘了改 `upstream-proxy:8510`、`upstream-chan:8110` 这类服务名。症状是服务能启动、数据库也通,但批次执行、智能诊断全部超时。
:::
## 四、启动
```bash
# 按需修改 docker-compose.app.yml 的镜像地址、端口与口令
cp -r config.example config
vi config/*/config.yaml # 按上节改为外部地址
docker compose -f docker-compose.app.yml up -d
```
此时 `docker-compose.yml` 中的 `MYSQL_*`、`REDIS_PASSWORD`、`RABBITMQ_PASSWORD` 不再用于启动中间件容器,但仍作为配置中的变量来源,**取值要与中间件实际口令一致**。
## 五、验证
```bash
docker compose -f docker-compose.app.yml ps
# 数据库连通性
docker compose -f docker-compose.app.yml exec admin-svr sh -c 'nc -zv <MySQL地址> 3306'
docker compose -f docker-compose.app.yml exec admin-svr sh -c 'nc -zv <Redis地址> 6379'
# Redis 认证
docker compose -f docker-compose.app.yml exec admin-svr sh -c 'redis-cli -h <地址> -a "<口令>" ping'
# 服务日志
docker compose -f docker-compose.app.yml logs --tail=50 admin-svr
```
浏览器登录管理端,确认能查卡、能发起业务办理。
## 六、注意事项
| 项 | 说明 |
| --- | --- |
| 网络延迟 | 业务机到中间件的延迟建议 < 5ms。跨地域部署会造成批次执行明显变慢 |
| 连接池 | 外部数据库延迟较高时,适当调大 `database.default.maxIdle` 与 `maxOpen` |
| 备份 | 分离后数据库备份由中间件侧负责,本仓的 `backup.sh` 需调整连接参数 |
| 时钟 | 各机器需开启 NTP 同步,否则全量同步的时间窗口判断会出错 |
+253
View File
@@ -0,0 +1,253 @@
---
title: 故障排查
description: 部署与运行中的常见问题及处理
outline: deep
---
# 故障排查
## 通用排查
```bash
docker compose ps # 容器状态
docker compose logs --tail=100 <服务> # 最近日志
docker compose logs -f <服务> # 跟随日志
docker compose exec <服务> sh # 进容器
```
## 启动类
### MySQL 一直不 healthy
**现象**:`iotlcm-mysql` 反复重启或一直 `starting`,其余服务不启动。
```bash
docker compose logs mysql | tail -50
```
| 日志关键词 | 原因 | 处理 |
| --- | --- | --- |
| `Access denied` | root 口令与旧数据卷不一致 | 改 `docker-compose.yml` 与卷内一致,或清空 `data/mysql` 重建 |
| `Can't create/write to file` | 目录权限 | `sudo chown -R 999:999 data/mysql` |
| `No space left on device` | 磁盘满 | 清理磁盘 |
| `Can't open shared library` | 架构不匹配 | 确认镜像与宿主机架构一致 |
### schema 初始化失败
```bash
docker compose logs mysql | grep -iE 'error' | head -30
```
| 原因 | 处理 |
| --- | --- |
| `MYSQL_APP_PASSWORD` 未设置,`04-users.sh` 退出 | 补上后清空 `data/mysql` 重建 |
| `MYSQL_ADMIN_PASSWORD` 未设置,`05-reset-passwords.sh` 退出 | 同上 |
| SQL 脚本语法问题 | 单独执行该脚本定位 |
::: warning 改脚本后必须清空数据卷
MySQL 只在 `data/mysql` 为空时执行 `schema/`,文件改了不会自动重跑:
```bash
docker compose down && sudo rm -rf ./data/mysql && docker compose up -d
```
:::
### 业务服务反复重启
```bash
docker compose logs --tail=80 admin-svr
```
| 报错 | 原因 | 处理 |
| --- | --- | --- |
| `possible config files "config" not found` | `config/` 不存在或挂载失败 | `cp -r config.example config`,确认 `config/admin-svr/config.yaml` 存在 |
| `dial tcp mysql:3306: connection refused` | MySQL 未就绪 | 等 MySQL healthy |
| `Access denied for user 'u_iotlcm'` | 应用账号口令与库内不一致 | 确认 `docker-compose.yml` 的 `MYSQL_APP_PASSWORD` 与初始化时一致 |
| `panic: unsupported provider xxx` | 通道模板与镜像版本不匹配 | 确认镜像版本与 schema 版本配套 |
## 功能类
### 登录后提示未授权 / 接口 401
**原因**:`JWT_SIGNING_KEY` 在服务间不一致。
确认 `admin-svr`、`openapi`、`app-server` 三份配置的 `jwt.signingKey` 都引用 `${JWT_SIGNING_KEY}` 且 `docker-compose.yml` 有值,然后重启:
```bash
docker compose restart admin-svr openapi app-server
```
### 登录成功但页面空白 / 菜单不显示
1. 浏览器 F12 看 Network 有无 4xx/5xx
2. 检查菜单数据:
```bash
docker compose exec mysql sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm -e "SELECT COUNT(*) FROM sys_menu WHERE status=0;"'
```
菜单为 0 说明种子数据未导入,需重建库。
3. 检查角色菜单关联:
```bash
docker compose exec mysql sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm -e "SELECT COUNT(*) FROM sys_role_menu;"'
```
### 卡信息页通道列空白 / 筛选无选项
**原因**:Mock 通道未插入。
```bash
docker compose exec mysql sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm -e "SELECT channel_id, channel_name FROM t_sim_channels;"'
```
为空则手工执行种子脚本:
```bash
docker compose exec -T mysql sh -c \
'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm' < schema/08-seed-mock-channel.sql
```
### 批次执行一直失败
按顺序排查:
1. **挡板是否启动**(无真实运营商时)
```bash
docker compose ps | grep onelink-mock
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api.json
```
未启动则 `docker compose --profile with-mock up -d`。
2. **worker-svr 是否连上 RabbitMQ**
```bash
docker compose logs worker-svr | grep -i 'rabbit\|amqp' | tail -20
```
3. **upstream-chan 是否能连到上游**
```bash
docker compose logs upstream-chan --tail=50
```
4. **是否被运营商限流**
检查 worker-svr 日志中的 `rate limit`、`429`、`12021`。核对 `batch.inbound.upstreamRateLimit` 的 QPS 是否超过上游配额。
### 历史流量查询无数据
**原因**:TDengine 未启动、未完成建表,或配置里的地址不对。
```bash
docker compose logs worker-svr | grep -i 'tdengine\|taos' | tail -20
docker compose logs tdengine-init | tail -20 # 建库建表是否成功
docker compose exec tdengine taos -s "show databases;"
```
常见原因与处理:
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `tdengine-init` 报 MISS | 建表失败 | 看该容器日志里的 `DB error`;改 `tdengine-init/init.sql` 后重跑 `docker compose up -d tdengine-init` |
| worker 日志报库表不存在 | 未执行建表 | 同上,确保 init 容器退出码为 0 |
| 连接超时 | 地址/端口不对 | 配置里应为 `ws(tdengine:6041)`,容器内用服务名而非 IP |
建表脚本全部带 `IF NOT EXISTS`,可重复执行。
### 短信发不出去
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:11190/healthz
docker compose logs sms --tail=50
```
| 报错 | 原因 |
| --- | --- |
| `InvalidAccessKeyId` | `docker-compose.yml` 中 AK 未填或错误 |
| `isv.SMS_SIGNATURE_ILLEGAL` | `signName` 与服务商审核签名不一致 |
| `isv.SMS_TEMPLATE_ILLEGAL` | 模板 ID 未审核通过 |
| `缺少 config.templateId` | 场景模板库未配置,调用方需自带 templateId |
### 图片上传失败 / 不显示
```bash
ls -la data/upload/
sudo chown -R 1000:1000 data/upload
docker compose restart admin-svr admin-ui
```
## 性能类
### CPU 被 worker-svr 打满
**原因**:定时任务开启全量同步,大量卡片并发查询上游。
1. 降低并发与限流:
```yaml
batch:
inbound:
upstreamConcurrency: 50 # 从 300 降低
upstreamBatchSize: 1
```
2. 演示 / 试用环境直接停用定时任务:
```bash
docker compose exec mysql sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm -e "UPDATE sys_job SET status=1;"'
```
种子数据中定时任务默认已全部停用(`status=1`)。
### 磁盘持续增长
```bash
du -sh data/* | sort -rh
```
| 目录 | 处理 |
| --- | --- |
| `data/logs` | 清理旧日志,见[升级与备份](operations) |
| `data/upload` | 检查异常大的上传文件 |
| `data/mysql` | 数据库增长正常,关注 binlog |
| `data/redis` | AOF 文件,正常 |
## 网络类
### 容器间无法互访
```bash
docker network inspect iotlcm-network
docker compose exec admin-svr sh -c 'nc -zv mysql 3306'
```
配置中应使用 `mysql:3306`、`redis:6379` 这样的服务名,不要写 `127.0.0.1`。
### 宿主机访问不了 127.0.0.1 绑定的服务
`admin-svr`、`admin-ui`、`openapi`、`app-server`、`app-web`、`docs` 默认只绑 `127.0.0.1`。对外访问需:
1. 用 `--profile with-nginx` 启动反向代理,或
2. 宿主机 nginx 转发到 `127.0.0.1:<端口>`
::: danger 不要直接把端口改成 0.0.0.0
这会把管理端、数据库等内部服务暴露到公网。
:::
## 求助时提供的信息
```bash
docker compose version
docker version --format '{{.Server.Version}}'
docker compose ps
docker compose logs --tail=200 <服务名>
df -h && free -h
```
+28
View File
@@ -0,0 +1,28 @@
---
layout: home
hero:
name: iotLCM
text: 文档中心
tagline: iotLCM 平台文档与接口规范。
actions:
- theme: brand
text: 使用手册
link: /manual/
- theme: alt
text: OpenAPI 接口文档
link: /openapi/
- theme: alt
text: 共享 Token 接口文档
link: /openapi/operator/
features:
- title: 使用手册
details: 按使用端提供 Web 管理平台与小程序端手册,覆盖查卡、诊断、业务管理和账号服务。
- title: OpenAPI 接口文档
details: 客户开放接口的鉴权方式、请求参数、响应结构、调用示例与兼容接口说明。
- title: 共享 Token 接口文档
details: 共享 Token 获取与白名单转发说明。
- title: 部署手册
details: 文档站的推荐环境、Node/Docker 编译方法、nginx 与对象存储部署方式,以及路径前缀配置要点。
---
+23
View File
@@ -0,0 +1,23 @@
---
layout: home
hero:
name: iotLCM 使用手册
text: 操作指南
tagline: 按使用端查看卡片查询、业务管理与移动服务说明。
actions:
- theme: brand
text: Web端手册
link: /manual/web/
- theme: alt
text: 小程序端手册
link: /manual/mini-program/
features:
- title: 版本选型指引
details: 使用浏览器登录企业管理后台,请阅读 Web 端手册;使用 IOTLCM 物联卡用户服务小程序,请阅读小程序端手册。不同端的功能入口、权限和页面结构有所区别。
- title: Web端
details: 面向企业管理员和业务人员,覆盖首页、卡信息、智能诊断、业务受理、企业管理、工单、API 凭证与执行日志。
- title: 小程序端
details: 对应 iotlcm-app v1.0.1,覆盖首页公开查卡、扫码与拍照识别、卡片详情、卡片排查和账号密码登录;办理、绑定、微信登录及注册等功能暂未开放。
---
+61
View File
@@ -0,0 +1,61 @@
# 登录后办理概览
登录后的页面会根据账号工作区和权限动态显示。看不到某个入口时,通常是账号未配置对应权限,或该业务仍受版本开关限制。
## 登录与身份选择
1. 在首页或需要账号数据的页面进入登录,选择“密码登录”或“验证码登录”。
![账号密码登录入口](/images/manual/mini-program/business-account-login-entry.png)
2. 密码登录支持手机号或用户名;验证码登录需要手机号、图形验证和短信验证码。
![账号登录中的密码登录和验证码登录选项](/images/manual/mini-program/business-login-methods.jpg)
3. 同一登录凭据匹配多个身份时,按页面提示选择本次进入的账号。
注册、微信登录和“忘记密码”页面当前关闭。无法登录时联系账号管理员或客服。
## 设备与卡片操作
登录后点击底部“设备”进入设备列表,或从“办理”进入“我的卡片”。设备页支持关键词搜索、状态筛选、刷新和加载更多;卡片详情显示账号权限范围内的字段及会话、流量信息。首页最多预览三张卡片,并优先展示即将到期的卡;点击“管理”进入全部设备。
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; width: 100%; max-width: 440px; margin: 24px auto;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/business-device-tab.png" alt="从底部设备入口查看我的设备" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">底部“设备”入口</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/business-my-cards-entry.jpg" alt="从办理页面进入我的卡片" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">“办理”中的“我的卡片”入口</figcaption>
</figure>
</div>
续费、变更加油包、停机、复机等卡片更多操作当前暂未开放。重启、数据服务操作和卡片划拨可按页面位置、账号权限及卡片状态开放。页面只显示当前账号可执行的操作,提交前仍会再次校验。
## 办理入口
“办理”页包含卡片排查、卡片与客户、工单和执行记录等分组。当前版本按权限开放以下能力:
| 能力 | 入口与说明 |
| --- | --- |
| 卡片排查 | 输入 ICCID、MSISDN 或 IMSI,查看状态、会话和流量信息 |
| 卡片划拨 | 选择启用的直属下级客户和卡片;单次最多100张,提交后到批次日志查看结果 |
| 客户管理 | 查看授权范围内的企业客户及客户详情;终端账号不显示此入口 |
| 工单服务 | 按列表、提交、详情和处理权限使用相应操作 |
| 批次执行日志 | 按列表、概览和明细权限查看批次进度与结果 |
续费、变更与加油包当前暂未开放;订单、预警和余额充值等页面仍受版本开关限制,进入时可能提示暂未开放。
## 专题说明
- [客户管理与卡片划拨](./customers-and-allocation)
- [工单服务](./work-orders)
- [批次执行日志](./execution-logs)
- [消息与个人中心](./messages-and-profile)
## 操作提示
- 划拨提交后不要重复创建任务;结果未确认时先核查批次日志和卡片归属。
- 工单附件上传失败时检查网络后重试;不要在反馈中提交无关敏感信息。
- 所有状态和排查结果仅供业务处理参考,最终以运营商和平台实际结果为准。
@@ -0,0 +1,60 @@
# 客户管理与卡片划拨
本页介绍企业、代理及平台工作区中的客户查询和卡片划拨。入口按账号身份和权限显示;终端账号不提供客户管理入口。
## 客户管理
1. 登录后从“我的”或“办理”进入“客户管理”。
<div class="manual-image-grid" style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; width: 100%; max-width: 440px; margin: 24px auto;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/customer-management-from-profile.png" alt="从我的页面进入客户管理" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">“我的”中的客户管理入口</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/customer-management-from-business.png" alt="从办理页面进入客户管理" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">“办理”中的客户管理入口</figcaption>
</figure>
</div>
2. 输入企业名称或手机号搜索客户,也可刷新列表或加载更多。
![客户管理中的搜索框和刷新入口](/images/manual/mini-program/customer-management-search.png)
3. 点击客户查看详情,包括客户资料、客户类型和实名信息。
![客户详情中的客户类型、实名资料状态和持卡统计](/images/manual/mini-program/customer-details-v102.png)
4. 客户详情中的卡片数量和状态统计仅统计该客户直接持有的卡,不包含下级客户。
列表及详情分别需要对应权限。若搜索无结果,检查名称或手机号;若没有入口,联系账号管理员确认账号类型和授权范围。
## 单卡划拨
从卡片详情有权限时显示的“划拨这张卡”入口开始。确认目标客户后检查卡片信息并提交。单卡入口会锁定当前卡,不能改选其他卡。划拨成功后卡片归属转给目标客户。
## 批量划拨
1. 从“办理”进入“卡片划拨”。
![从办理页面进入卡片划拨](/images/manual/mini-program/card-allocation-entry-v102.png)
2. 选择目标客户:仅支持已启用的直属下级客户,可按客户名称或编码搜索。
![批量卡片划拨中的目标客户选择入口](/images/manual/mini-program/allocation-target-customer-v102.jpg)
3. 搜索并勾选要划拨的卡片。可以选择当前结果;更换搜索条件后,先前选择仍保留。打开已选列表可移除单张或清空选择。
![批量划拨中选择目标客户、勾选卡片和查看已选入口](/images/manual/mini-program/allocation-select-cards-v102.jpg)
4. 每次最多选择100张。停机、销户或套餐过期的卡不可划拨。
5. 核对目标客户和卡数后提交。后台会整批校验;存在失败卡时整批不执行。
6. 提交后进入批次日志查看进度和结果。任务处理中时使用原页面的刷新操作查看状态。
![卡片划拨提交后的批次执行日志、进度结果和刷新入口](/images/manual/mini-program/allocation-batch-logs-v102.png)
若网络中断导致结果未知,不要再次新建划拨任务。先通过原批次号查看日志并核对卡片归属;只有确认原任务未受理且没有待执行任务后,才按页面提示核查并重试原任务。仍无法确认时联系管理员处理。
## 权限与边界
划拨入口要求企业、代理或平台工作区及卡片划拨权限。目标客户和卡片仍受账号数据范围约束。执行记录列表、概览和明细分别受批次日志权限控制;划拨页面可能提供与本次任务关联的结果查看入口。
+39
View File
@@ -0,0 +1,39 @@
# 卡片详情
从首页查询结果点击“详情”后进入卡片详情页。首页会以公开来源打开详情,因此未登录或公开来源都会使用受限展示模式。
## 公开详情展示内容
公开模式包含两组信息:
- 基础信息:卡号、卡状态、在线状态;
- 流量信息:已用流量。
![卡片详情中的基础信息和流量信息](/images/manual/mini-program/card-details-traffic.png)
卡号可能已经脱敏,页面显示什么就以什么为准。点击或长按卡号行可以复制当前显示值。
公开模式不会展示完整 IMSI、IMEI、备注、总流量、剩余流量、会话信息、APN、IPv4、IPv6 和服务状态,也不会提供重启、开通数据、停用数据等操作按钮。
## 登录后详情
登录并通过权限校验后,设备列表和卡片详情可以展示更完整的账号范围数据,例如 MSISDN、ICCID、IMSI、设备 IMEI、套餐流量和同步时间。字段是否出现取决于接口返回值和账号权限。
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: start;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/devices-list-account-data-v2.jpg" alt="登录后的设备列表和账号范围数据" style="display: block; width: 100%; height: auto;">
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/devices-search-account-data-v2.jpg" alt="全部设备中的账号范围数据" style="display: block; width: 100%; height: auto;">
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/card-details-account-data-v2.jpg" alt="登录后的卡片详情账号范围数据" style="display: block; width: 100%; height: auto;">
</figure>
</div>
## 查不到详情时
- 确认查询号码没有空格或遗漏字符;
- 从首页重新查询后再点击“详情”;
- 若页面提示当前账号没有权限,请使用公开查询,或联系账号管理员确认数据范围。
@@ -0,0 +1,61 @@
# 卡片排查
卡片排查用于根据卡片状态、在线状态、流量和设备信息整理处理方向。
## 进入排查
可以从以下位置进入:
1. 首页点击“卡片排查”,手动输入卡号;
2. 首页查询成功后点击“卡片排查”,系统会带入当前查询值;
![首页查询结果中的卡片排查入口](/images/manual/mini-program/diagnosis-entry-from-query-v2.png)
3. 直接打开卡片排查页,在输入框填写 `ICCID`、`MSISDN` 或 `IMSI`。
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/diagnosis-entry-from-home.png" alt="从首页常用入口进入卡片排查" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">首页卡片排查入口</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/diagnosis-manual-input.jpg" alt="卡片排查页手动输入卡号" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">输入卡号开始排查</figcaption>
</figure>
</div>
点击“开始排查”提交。排查过程中不要连续重复点击。
## 结果摘要
普通用户或未登录用户通常可以看到:
- 卡片状态;
- 在线状态;
- 已用流量或流量状态;
管理员账号可能看到更多卡片、数据服务、停机冻结和限制策略信息。页面会按实际接口返回值隐藏没有数据的字段。
## 流量使用分析
当前页面提供“近1个月、近3个月、近6个月”三个周期。**`v1.0.1` 默认只对顶层管理员显示流量趋势模块;其他账号即使登录,也不保证看到该模块。**
有数据时会显示周期总用量、日均或月均用量、单期峰值和趋势图;没有数据时显示“暂无历史流量数据”。趋势只用于辅助排查,不等同于实时计费结果。
## 排查结果的处理方向
| 结果 | 建议核对 |
| --- | --- |
| 在线 | 继续核对设备业务表现和应用侧请求 |
| 离线 | 检查设备供电、开机状态、信号、天线和 APN |
| 未查询到会话 | 稍后重试,并确认设备是否产生数据会话;不等同于卡已销户 |
| 流量不足或用量异常 | 核对套餐流量、设备上报和业务使用情况 |
| 机卡分离 | 核对卡片与设备的使用关系 |
| 区域受限 | 核对业务限制区域、订购状态和触发状态 |
| 停机或冻结 | 根据页面提示联系业务管理员确认 |
## 当前版本限制
排查页的“重启卡”“开通数据服务”“停用数据服务”按钮只有在登录且账号拥有对应权限时才会出现。业务办理路由、套餐、预警、订单和工单在 `v1.0.1` 中暂未开放。
排查接口或流量接口提示请求过于频繁时,请等待一段时间后再试,不要连续提交。
@@ -0,0 +1,34 @@
# 批次执行日志
批量划拨等任务提交后,可通过“我的”或“办理”中的“批次执行日志”查看执行记录。入口、批次概览和明细分别受权限控制。
## 查询批次
1. 按批次编号搜索,或展开筛选条件。
2. 可按渠道、操作模式、录入方式和执行状态组合筛选;点击刷新重新查询,清除筛选可恢复完整列表。
![批次执行日志中的批次编号搜索、筛选条件和刷新入口](/images/manual/mini-program/execution-log-filters-v102.png)
3. 点击记录查看批次概览和执行明细;需要时刷新详情,或切换为仅查看失败项。
4. 列表支持加载更多。划拨任务可能从划拨页面直接打开关联批次详情。
<div class="manual-image-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; align-items: start;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/execution-log-load-more.png" alt="批次执行日志列表中的加载更多入口" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">加载更多执行记录</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/execution-log-allocation-entry.png" alt="卡片划拨批次的查看详情入口" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">卡片划拨详情入口</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/execution-log-allocation-details.png" alt="划拨任务概览和逐卡执行明细" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">划拨任务概览与卡片明细</figcaption>
</figure>
</div>
记录会显示批次号、提交渠道、操作类型、录入方式和执行状态。明细权限不足时仍可能看到列表或概览,但无法查看逐卡结果;具体字段按接口权限和返回数据展示。
## 状态未确认
任务处于待处理或处理中时,刷新原批次查看进度。若提交后网络中断且页面提示结果未确认,不要新建相同任务;保留原批次号,先查看日志并核对卡片归属。只有确认任务未受理且不存在待执行任务后,才能按页面提示重试原任务。
+45
View File
@@ -0,0 +1,45 @@
# 常见问题
## v1.0.1 有哪些可用功能?
无需登录即可在首页手动输入、扫码或拍照识别卡号,并查看公开详情和卡片排查结果。已有账号可以使用账号密码登录,查看权限范围内的设备数据。
## 为什么底部没有“办理”Tab?
`v1.0.1` 关闭了办理 Tab。套餐续费、加油包、套餐变更、停复机、预警、订单、工单和客户管理等路由也暂未开放,直接访问会提示当前版本暂未开放此功能。
## 为什么设备页看不到卡片?
设备页读取登录账号的卡片范围。未登录时请回到首页使用公开查卡;登录后仍为空时,请确认账号已分配卡片或具备相应权限。
## 支持哪些查卡方式?
首页支持输入 `ICCID`、`MSISDN`、`IMSI` 或 `IMEI`,也支持扫码读取内容和从相机或相册拍照识别 ICCID。
## 拍照识别失败怎么办?
请拍摄单张、平整、完整、无反光的卡片,并确保 ICCID 清晰。识别超时或频繁请求时等待后重试;也可以改用扫码或手动输入。
## 公开详情为什么字段少?
公开模式只展示卡号、卡状态、在线状态和已用流量。完整 IMSI、IMEI、会话、APN、IP、服务状态等字段属于受限数据。
## 为什么不能绑定卡片或执行重启?
卡片绑定和“更多操作”在 `v1.0.1` 中统一关闭。即使登录,当前版本也不会提供绑定、套餐办理、停复机、数据服务变更和预警配置入口。
## 为什么看不到流量趋势?
流量趋势模块当前只对顶层管理员开放,且接口必须返回有效历史数据。其他账号或没有历史数据时不会显示趋势图。
## 为什么排查显示离线或未查询到会话?
先检查设备供电、开机状态、信号、天线和 APN,再稍后重新排查。“未查询到会话”只表示当前没有查到数据会话,不等同于卡片已销户。
## 账号密码登录有什么限制?
当前版本只开放已有账号的账号密码登录;微信登录和注册账号未开放。忘记密码页面会提示短信服务接入后开放,请联系管理员或客服处理账号问题。
## 排查结果是否是最终结论?
不是。排查结果仅用于辅助判断,最终以运营商和业务系统状态为准。请勿在查询、排查和反馈内容中提交身份证号、银行卡号或其他无关敏感信息。
+67
View File
@@ -0,0 +1,67 @@
# 首页查卡
首页是当前版本的主要使用入口。查询不要求登录,支持手动输入、微信小程序扫码和图片识别三种录入方式。
## 登录入口
首页和登录页都保留“未登录也可查卡”入口。需要查看账号名下设备时,再通过账号密码登录;微信登录和注册账号暂未开放。
## 手动查询
1. 在“卡片查询”输入框输入完整的 `ICCID`、`MSISDN`、`IMSI` 或 `IMEI`。
2. 点击“查询”,等待查询完成。
![点击查询并等待查询完成](/images/manual/mini-program/query-complete.png)
3. 查询成功后,结果区域显示卡号、使用流量和在线状态。
4. 点击“详情”查看公开卡片详情,或点击“卡片排查”查看状态摘要。
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: start;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/query-actions-v2.jpg" alt="查询结果中的详情和卡片排查入口" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">详情与卡片排查入口</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/card-details-v2.jpg" alt="卡片详情页展示的公开字段" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">卡片详情</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/card-diagnosis-v2.jpg" alt="卡片排查页展示的状态摘要" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">卡片排查</figcaption>
</figure>
</div>
输入内容请去除前后空格并核对末位字符。查询失败时先检查输入值,再重新提交。
## 扫码查询
1. 点击“扫码”。
![首页扫码入口](/images/manual/mini-program/scan-entry-highlight.png)
2. 将卡片条码或二维码放入扫描区域。
3. 扫描成功后,识别出的内容会自动填入输入框并立即发起查询。
光线不足、反光或距离不合适时可能扫描失败;此时请调整卡片位置,或改用手动输入。
## 拍照识别 ICCID
1. 点击“拍照”。
![首页拍照入口](/images/manual/mini-program/photo-entry.png)
2. 从相机或相册选择一张卡片图片。
3. 确保图片中只有一张卡片,并且 ICCID 完整、清晰、无反光。
4. 识别成功后,系统会将 ICCID 填入输入框并继续查询。
常见提示及处理方式:
| 提示 | 处理方式 |
| --- | --- |
| 图片中存在多个可能的 ICCID | 重新拍摄单张卡片 |
| 未能识别清晰的 ICCID | 保持卡片平整、对焦清楚后重拍 |
| 图片识别超时或请求过于频繁 | 等待后重试,或使用扫码、手动输入 |
## 首页结果
首页结果中的“剩余流量”按“剩余 / 套餐总量”展示;在线状态可能为在线、离线、未知或未查询到会话。结果区域的查询和排查说明均提示:内容仅供参考,以业务系统结果为准。
+99
View File
@@ -0,0 +1,99 @@
---
title: 小程序端使用手册
---
# 小程序端使用手册
本文介绍 iotLCM 平台当前已开放的页面及操作功能,具体可用内容以账号实际权限为准。当前版本以**公开查卡和状态排查**为主,业务办理入口暂未开放。
<div class="mini-program-entry">
<figure class="mini-program-entry__qr">
<img src="/images/manual/mini-program/mini-program-code.jpg" alt="扫码进入 iotLCM 小程序">
<figcaption>扫码进入 iotLCM 小程序</figcaption>
</figure>
<p class="mini-program-entry__hint">使用微信扫一扫即可打开小程序,体验公开查卡和卡片状态排查功能。</p>
</div>
## 版本范围
| 页面或功能 | 当前状态 | 说明 |
| --- | --- | --- |
| 首页 | 已开放 | 手动输入、扫码、拍照识别并查询卡片 |
| 卡片详情 | 已开放 | 公开查询只展示卡号、卡状态、在线状态和已用流量 |
| 卡片排查 | 已开放 | 输入卡号查看状态摘要;流量趋势仅对管理员账号显示 |
| 账号密码登录 | 已开放 | 企业、代理商和终端用户使用已有账号登录 |
| 设备列表 | 登录后使用 | 显示当前账号权限范围内的卡片;未登录不要用此页进行公开查卡 |
| 办理、订单、工单、预警、开停数据等 | 暂未开放 | 入口或路由会提示“当前版本暂未开放此功能” |
| 微信登录、注册、绑定卡片、卡片更多操作 | 暂未开放 | 本版本不提供这些入口 |
## 推荐流程
1. 打开小程序首页,在“卡片查询”中输入 ICCID、MSISDN、IMSI 或 IMEI。
![小程序首页卡片查询输入框](/images/manual/mini-program/home-card-query.png)
2. 也可以点击“扫码”读取卡片条码,或点击“拍照”识别卡片上的 ICCID。
![首页扫码和拍照入口](/images/manual/mini-program-scan-photo-entry.png)
3. 点击“查询”,查看卡号、卡状态、使用流量和在线状态。
![点击查询查看卡号、卡状态、使用流量和在线状态](/images/manual/mini-program/query-result.png)
4. 需要查看公开字段时点击“详情”;需要判断异常原因时点击“卡片排查”。
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: start;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/query-actions-v2.jpg" alt="首页查询结果中的详情和卡片排查入口" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">首页入口</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/card-details-v2.jpg" alt="卡片详情页展示的公开字段" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">卡片详情</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/card-diagnosis-v2.jpg" alt="卡片排查页展示的状态摘要" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">卡片排查</figcaption>
</figure>
</div>
5. 如果需要停复机、开通数据服务、套餐办理、预警或工单,请等待后续版本开放。
## 账号密码登录
1. 在需要账号数据的页面进入登录页,或在首页点击登录入口。
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start;">
<figure style="margin: 0;">
<img src="/images/manual/mini-program/login-entry-home.jpg" alt="从首页底部的我的入口进入登录" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">首页“我的”入口</figcaption>
</figure>
<figure style="margin: 0;">
<img src="/images/manual/mini-program/login-page-entry.jpg" alt="登录页中的账号密码登录入口" style="display: block; width: 100%; height: auto;">
<figcaption style="text-align: center;">账号密码登录</figcaption>
</figure>
</div>
2. 输入已有账号和密码,完成页面要求的安全验证后点击“登录”。
![账号密码登录表单](/images/manual/mini-program/account-password-login.png)
3. 如果同一账号匹配多个登录身份,按页面提示选择本次要进入的账号。
4. 登录成功后返回原页面;设备页会按账号权限加载卡片数据。
![登录后设备页按账号权限加载卡片数据](/images/manual/mini-program/devices-page.png)
当前版本不开放注册和微信登录。“忘记密码”页面会提示短信服务接入后开放,无法登录时请联系账号管理员或客服。
## 使用边界
- 公开查询和排查结果仅供辅助判断,最终以运营商和业务系统状态为准。
- 未登录查询不会展示完整 IMSI、IMEI、APN、IP、套餐内部信息等受限字段。
- 请勿在查询、排查或反馈内容中提交身份证号、银行卡号等无关敏感信息;密码只应填写在登录表单中。
## 阅读指引
- [首页查卡、扫码与拍照](./home)
- [卡片详情](./devices)
- [卡片排查](./diagnosis)
- [常见问题](./faq)
@@ -0,0 +1,24 @@
# 消息与个人中心
登录后从底部“我的”进入“消息中心”及账号功能。可见内容按当前账号及接口返回数据决定。
## 消息中心
1. 在“我的”点击“消息中心”,查看流量预警、卡片消息、订单通知和系统通知模块及未读数。平台公告等消息可在实际归属的模块中查看,通常归入“系统通知”。
![从我的页面进入消息中心](/images/manual/mini-program/message-center-entry.png)
2. 进入模块后,可按消息标题或内容搜索,并选择最新优先、最近30天或时间正序;点击刷新重新加载。
3. 点击消息查看详情。进入模块后可将当前模块未读消息全部标记为已读。
4. 工单相关通知可跳转工单详情,但仍需工单详情权限;无效链接或无权限时返回消息页并联系管理员。
“消息提醒”设置入口会转到消息中心。当前版本的流量预警通知通过站内消息查看,不代表会发送微信模板消息或推送。
## 我的
- **业务工作台**:有对应权限时显示工单、客户管理和批次执行日志入口。只有工单提交权限时进入提交页面。
- **个人资料**:终端用户可编辑个人及客户资料并保存;企业、代理及平台账号只能查看资料,资料变更由管理平台维护。账号安全区域可查看手机号、邮箱、密码入口和微信绑定状态。
- **实名认证**:查看实名状态、登记名称、联系电话和客户类型;企业类账号还可查看客户编码及联系地址。该页面用于查看信息。
- **微信绑定**:可在微信小程序中绑定、更换或解除当前微信。解除绑定会移除当前微信与账号的关联;操作前先确认可通过密码或短信验证码登录。微信登录入口在本版本仍未开放。
- **退出登录**:在“我的”页或个人资料账号安全区域点击退出并确认。
帮助与反馈页面当前为静态说明入口,不提供在线提交反馈或工单操作;问题请通过工单服务或页面列出的客服联系渠道处理。

Some files were not shown because too many files have changed in this diff Show More