Compare commits
121 Commits
226495365e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f15ff6f9e | |||
| 6c8314a423 | |||
| 9cd518227d | |||
| d78379486c | |||
| e98f8561f7 | |||
| 70749bea7a | |||
| fd42b5976e | |||
| a7c15cf891 | |||
| 2548976f73 | |||
| 17c839daac | |||
| f76e988689 | |||
| 1634d27a43 | |||
| 42d12d905a | |||
| 89a15c4fca | |||
| 34669c9e1f | |||
| 12512b06ff | |||
| f7cab5ad5c | |||
| 2923c78755 | |||
| 6de2e503cc | |||
| 9c9f6c2a29 | |||
| 691b792391 | |||
| b10b8cfc51 | |||
| c3c1f09ebd | |||
| 029f81fb1c | |||
| fa1f96e942 | |||
| 05014446c3 | |||
| eb97c5300a | |||
| 01d1e39c34 | |||
| a8a5537c69 | |||
| f3985950cc | |||
| 947e22e3b9 | |||
| 31dbc1d910 | |||
| 1d38bc4226 | |||
| 44209ecaaf | |||
| 1e74497728 | |||
| 96f0f4eacd | |||
| 3b05d5a73a | |||
| 6b5fb707c2 | |||
| eeeb6572ba | |||
| 56e930c7d0 | |||
| dfeb5e8d13 | |||
| ce0db55e36 | |||
| 6ed0691134 | |||
| e6ea61380a | |||
| 623af9e3c7 | |||
| 69a136b05d | |||
| 27ce9df7a5 | |||
| 3b9b1bc045 | |||
| 468796d843 | |||
| 9710fc22eb | |||
| 6d71fa79b1 | |||
| cca4c5df9f | |||
| 0f963ac085 | |||
| e4298ef64c | |||
| a30c286b03 | |||
| 200846aee3 | |||
| 1e3e2c4bb9 | |||
| 10df6d65aa | |||
| 2f9e952df6 | |||
| 452f7c6549 | |||
| 767148a8a4 | |||
| 9fb667cc6a | |||
| d740123174 | |||
| 6d3b8dab43 | |||
| d84f93ce0a | |||
| c49aee102a | |||
| 98293f1813 | |||
| 1f07186679 | |||
| a93b7de14d | |||
| 5c54d32752 | |||
| 3ebe0adf3e | |||
| 1eed26991f | |||
| b1b83d806a | |||
| b4944d7c21 | |||
| 247e5b2a60 | |||
| 8543038dad | |||
| bf2656e95f | |||
| b60695b0e5 | |||
| 57e03233f8 | |||
| 94e09fd013 | |||
| 314e213a5a | |||
| e6abbf65dd | |||
| 08af8dfdbe | |||
| 616451b29d | |||
| 3ab3682d14 | |||
| 2d045591b7 | |||
| 3e5a7453b8 | |||
| 6893e21f07 | |||
| a8874ca6f2 | |||
| 4d43238c63 | |||
| 5299c13c1a | |||
| 24ae53bc0b | |||
| e6997f51ed | |||
| fed9459244 | |||
| 14f4bb64e1 | |||
| 32e4316ee4 | |||
| 6c26f7a0e1 | |||
| f42c9b29f6 | |||
| c49b2bf404 | |||
| fd72d088fc | |||
| 73adad97f3 | |||
| 5ddad0c5e5 | |||
| f169afc1bc | |||
| 0bfc0ebe80 | |||
| 249b1a03a9 | |||
| e43640d0fe | |||
| 503fe59fe7 | |||
| bc2c7bcc6b | |||
| 22a177c569 | |||
| 07ddaa9968 | |||
| 846bac3d6c | |||
| 37a1d00f01 | |||
| 19455e1cbc | |||
| 92f239fcc4 | |||
| 88dfe5c640 | |||
| 2bec257289 | |||
| 5732bf3384 | |||
| 82466d587d | |||
| 96b1edff45 | |||
| 4e2cd43a55 | |||
| 4fb1016977 |
@@ -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
|
||||
@@ -0,0 +1,101 @@
|
||||
name: mirror-to-gitee
|
||||
|
||||
# 把本仓镜像到 Gitee 企业仓 <GITEE_OWNER>/<同名仓库>。
|
||||
# 触发: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 }}
|
||||
run: |
|
||||
set -u
|
||||
|
||||
# 仓库名取当前仓;取不到时回退到单仓名
|
||||
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
|
||||
|
||||
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' ' ')"
|
||||
|
||||
# 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}@${DST}" 2>&1; then
|
||||
echo "✅ 已同步到 Gitee"
|
||||
|
||||
# 推送后校验:远端 main 与 tag 集合必须与本地一致
|
||||
# (避免“绿了但其实没推上去” —— Gitee 限流时容易发生)
|
||||
LOCAL_MAIN=$(git rev-parse refs/heads/main 2>/dev/null || true)
|
||||
REMOTE_MAIN=$(git ls-remote "https://${DST_USER}:${DST_TOKEN}@${DST}" refs/heads/main | awk '{print $1}')
|
||||
LOCAL_TAGS=$(git for-each-ref --format='%(refname:short)' refs/tags | sort | tr '\n' ' ')
|
||||
REMOTE_TAGS=$(git ls-remote --tags "https://${DST_USER}:${DST_TOKEN}@${DST}" \
|
||||
| awk '{print $2}' | sed 's#refs/tags/##' | grep -v '\^{}$' | sort | tr '\n' ' ')
|
||||
echo "本地 main: ${LOCAL_MAIN:-<无>}"
|
||||
echo "远端 main: ${REMOTE_MAIN:-<无>}"
|
||||
echo "本地 tag: ${LOCAL_TAGS:-<无>}"
|
||||
echo "远端 tag: ${REMOTE_TAGS:-<无>}"
|
||||
if [ -n "$LOCAL_MAIN" ] && [ "$LOCAL_MAIN" != "$REMOTE_MAIN" ]; then
|
||||
echo "❌ 校验失败:远端 main 与本地不一致"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${LOCAL_TAGS% }" != "${REMOTE_TAGS% }" ]; then
|
||||
echo "❌ 校验失败:远端 tag 与本地不一致"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 校验通过:Gitee 端 main 与 tag 均已对齐"
|
||||
exit 0
|
||||
fi
|
||||
wait_s=$((attempt * 30))
|
||||
echo "推送失败,${wait_s}s 后重试"
|
||||
sleep "$wait_s"
|
||||
done
|
||||
|
||||
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
|
||||
exit 1
|
||||
@@ -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
@@ -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/
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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|固定版本(留空=跟随 VERSION)
|
||||
# 小程序线 app 固定 v1.0.2:版本号与功能一一对应,平台发新 tag 时不跟着动。
|
||||
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|v1.0.2 \
|
||||
app|iotlcm-app-web|app/app|Dockerfile|v1.0.2 \
|
||||
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:
|
||||
@grep -E '^# make' Makefile | sed 's/^# //'
|
||||
|
||||
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 pin; do \
|
||||
[ -z "$$dir" ] && continue; \
|
||||
v="$${pin:-$(VERSION)}"; \
|
||||
echo ""; echo "==> $$img:$$v (context=$$ctx dockerfile=$$df$${pin:+ [固定版本]})"; \
|
||||
docker build -f "$$ctx/$$df" \
|
||||
--build-arg BASE_REGISTRY="$(BASE_REGISTRY)" \
|
||||
--build-arg BuildVersion="$$v" \
|
||||
--build-arg CommitID="$$(git rev-parse --short=7 HEAD)" \
|
||||
--build-arg BuildTime="$$(date -u +%FT%TZ)" \
|
||||
-t "$(REGISTRY):$$img-$$v" "$$ctx"; \
|
||||
done
|
||||
|
||||
docs:
|
||||
cd docs && npm ci && npm run build
|
||||
@echo "✅ 文档站产物:docs/.vitepress/dist"
|
||||
|
||||
clean:
|
||||
@rm -rf docs/.vitepress/dist docs/.vitepress/cache
|
||||
@echo "✅ 已清理"
|
||||
@@ -0,0 +1,120 @@
|
||||
# iotLCM 物联网卡全生命周期管理平台
|
||||
|
||||
iotLCM 面向物联网 SIM 卡的全生命周期管理:卡片从运营商渠道入库 → 划拨给代理商 → 激活 / 续费 / 停机 → 流量与状态同步 → 销户。
|
||||
|
||||
平台对接**中国移动 OneLink**、**中国电信 5G CMP**、**中国联通 10646** 等运营商接口,支持以共享 Token 模式接入三方渠道商,并提供对外的 OpenAPI 服务。
|
||||
|
||||
## 文档
|
||||
|
||||
| 手册 | 内容 | 入口 |
|
||||
| --- | --- | --- |
|
||||
| **使用手册 · Web 管理端** | 查卡、智能诊断、业务受理、套餐与定价、库存出入库、通道管理、企业用户与角色、工单、API 凭证、常见问题 | <http://open.5iot.com:5051/docs/manual/web/> |
|
||||
| **使用手册 · 小程序端** | 首页公开查卡、卡片排查、客户管理与划拨、工单、批次执行日志、消息与个人中心 | <http://open.5iot.com:5051/docs/manual/mini-program/> |
|
||||
| **部署手册** | 推荐环境、一键部署、配置说明、离线部署、拆分部署、自建镜像、运维与排错 | <http://open.5iot.com:5051/docs/deploy/> |
|
||||
| **OpenAPI 合同** | 鉴权、V3 接口、旧版兼容接口、运营商能力代理 | <http://open.5iot.com:5051/docs/openapi/> |
|
||||
| **共享 Token 接口** | 共享 Token 获取与白名单转发 | <http://open.5iot.com:5051/docs/openapi/operator/> |
|
||||
|
||||
在线文档站:**<http://open.5iot.com:5051/docs/>**。文档源码在本仓 [`docs/`](docs) 目录,随平台一起发布(自建实例访问 `http://<平台地址>:8019/docs/`)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 Docker 20.10+ 与 Docker Compose v2,单机 4 核 / 8 GB 起。预构建镜像已就绪,不需要编译:
|
||||
|
||||
```bash
|
||||
git clone <仓库地址> iotlcm
|
||||
cd iotlcm/deploy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
首次启动自动完成建库建表、种子数据导入与时序库建表,约 1 分钟。启动完成后访问 `http://127.0.0.1:8009`。
|
||||
|
||||
默认账号口令、端口清单、可选服务(接口挡板 / 飞书通知 / 反向代理)、修改口令与上线检查,见 [`deploy/README.md`](deploy/README.md) 与[部署手册](http://open.5iot.com:5051/docs/deploy/)。
|
||||
|
||||
> 业务端口默认只绑定 `127.0.0.1`。需要局域网或公网访问时,用 `--profile with-nginx` 起反向代理,或改用 `--profile with-gateway` 的单端口入口(默认 5051)。
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 域 | 能力 |
|
||||
| --- | --- |
|
||||
| 卡资产 | 卡信息、批量导入、入库 / 库存 / 出库、批量开停复机、续费、变更套餐、达量断网 |
|
||||
| 通信管理 | 通道与接入模板、运营商能力对接、共享 Token 接入三方渠道、通道缓存转发 |
|
||||
| 资费 | 套餐与流量池、定价、生效与到期策略 |
|
||||
| 代理商 | 代理商与分佣、划拨与回收 |
|
||||
| 数据同步 | 流量 / 状态 / IMEI 全量同步与增量对齐、历史账期时序数据 |
|
||||
| OpenAPI | V3 接口、旧版兼容接口、运营商能力代理(白名单转发 / 共享 Token) |
|
||||
| 运营 | 工单、预警、执行日志、审计、智能诊断 |
|
||||
| 端 | Web 管理端、小程序 / H5 用户端 |
|
||||
|
||||
## 服务构成
|
||||
|
||||
| 目录 | 说明 | 镜像 tag |
|
||||
| --- | --- | --- |
|
||||
| `admin-svr/` | 管理端后端(GoFrame) | `iotlcm-admin-svr` |
|
||||
| `admin-ui/` | 管理端前端(Vue 3 + Vben Admin) | `iotlcm-admin-ui` |
|
||||
| `openapi/` | 对外 API 网关 | `iotlcm-openapi` |
|
||||
| `worker-svr/` | 异步执行(RabbitMQ 消费者、全量同步、批量任务) | `iotlcm-worker-svr` |
|
||||
| `upstream-chan/` | 运营商 / 三方渠道对接层 | `iotlcm-upstream-chan` |
|
||||
| `upstream-proxy/` | 通道缓存转发层 | `iotlcm-upstream-proxy` |
|
||||
| `app/server/` | 用户端服务(GoFrame) | `iotlcm-app-server` |
|
||||
| `app/app/` | 用户端前端(uni-app,基于 unibest) | `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 编排、数据库初始化、反向代理 / 单端口网关配置 | — |
|
||||
|
||||
各服务职责、配置项与本地运行方式见各自目录下的 `README.md`。
|
||||
|
||||
## 从源码构建
|
||||
|
||||
每个 Go 服务是独立模块,在对应目录内构建:
|
||||
|
||||
```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 # 编译检查
|
||||
make images # 构建镜像(REGISTRY / VERSION 可覆盖)
|
||||
```
|
||||
|
||||
各服务的 Dockerfile 位置与构建上下文见[自建镜像](http://open.5iot.com:5051/docs/deploy/build-image)。
|
||||
|
||||
## 版本与镜像
|
||||
|
||||
镜像统一发布在同一个仓库,用 tag 区分服务与版本:
|
||||
|
||||
```text
|
||||
<镜像仓库>/iotlcm/iotlcm:iotlcm-worker-svr-v1.0.4
|
||||
└─ 仓库 ┘ └─ 服务名 ─┘└版本┘
|
||||
```
|
||||
|
||||
| 服务 | 当前版本 |
|
||||
| --- | --- |
|
||||
| 除小程序线外 | `v1.0.4` |
|
||||
| `iotlcm-app-server` / `iotlcm-app-web` | `v1.0.2`(小程序线独立版本线) |
|
||||
|
||||
升级时固定到具体版本,不要使用 `latest`。
|
||||
|
||||
## 反馈
|
||||
|
||||
问题与建议请提 Issue,或联系平台维护者。
|
||||
|
||||
## 许可
|
||||
|
||||
本仓整体采用 [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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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)"
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
iotLCM 物联网卡全生命周期管理平台的**管理前端** —— 卡资产、通信管理、资费交易、告警工单、统计分析、账号治理。
|
||||
|
||||
配套后端:[iotlcm-admin-svr](https://git.5iot.com/iotlcm-public/iotlcm-admin-svr)。
|
||||
配套后端:同仓 [`admin-svr/`](../admin-svr)。
|
||||
|
||||
## 上游与许可
|
||||
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
+4
-1
@@ -2,10 +2,13 @@
|
||||
|
||||
iotLCM 物联网卡全生命周期管理平台的**小程序端 + 用户端服务**。
|
||||
|
||||
> **版本线独立**:本目录对应小程序线的 **v1.0.2**,不跟随平台 tag(平台当前为 v1.0.4)。
|
||||
> 功能变更时才提升本线版本,镜像 tag 仍为 `iotlcm-app-server-<版本>` / `iotlcm-app-web-<版本>`。
|
||||
|
||||
- `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)
|
||||
|
||||
## 特性
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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) }
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.5iot.com/iotlcm-public/iotlcm-bot
|
||||
|
||||
go 1.23
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+64
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# 运行时数据(含数据库文件、日志、上传件)
|
||||
data/
|
||||
|
||||
# 本地覆盖配置(如需保留自己的修改而不进版本库,可放 config/local.yaml)
|
||||
config/*/local.yaml
|
||||
|
||||
# 编辑器与系统文件
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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://127.0.0.1:8009`(编排默认把业务端口绑定在 127.0.0.1,需要局域网/公网访问见下方「重要提示」):
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 账号 | `admin` |
|
||||
| 口令 | `Iotlcm2026Default` |
|
||||
|
||||
> ⚠️ **正式使用前必须修改默认口令**,见下方「上线前必改」。
|
||||
|
||||
## 可选服务
|
||||
|
||||
```bash
|
||||
docker compose --profile with-gateway up -d # 单端口入口(默认对外 5051)
|
||||
docker compose --profile with-mock up -d # 运营商接口挡板
|
||||
docker compose --profile with-bot up -d # 飞书通知(运维自用)
|
||||
docker compose --profile with-nginx up -d # 多域名反向代理(对外 80)
|
||||
```
|
||||
|
||||
多个 profile 可组合:`--profile with-gateway --profile with-mock up -d`
|
||||
|
||||
### 单端口入口(with-gateway)
|
||||
|
||||
只暴露一个端口时用它。容器内监听 80,宿主机端口由 `GATEWAY_PORT` 控制(默认 **5051**):
|
||||
|
||||
```bash
|
||||
docker compose --profile with-gateway up -d
|
||||
# 或换端口
|
||||
GATEWAY_PORT=8088 docker compose --profile with-gateway up -d
|
||||
```
|
||||
|
||||
| 路径 | 去向 |
|
||||
| --- | --- |
|
||||
| `/` | 管理端(admin-ui,内部已代理 `/api/` 到 admin-svr) |
|
||||
| `/docs/` | 文档站 |
|
||||
| `/openapi/` | 对外 API |
|
||||
| `/route/open/` | 运营商能力代理(白名单 / 共享 Token) |
|
||||
|
||||
配置见 `config/gateway/nginx.conf`。小程序 / H5 端的静态资源是根路径部署的,不能挂在同一域名的子路径下,需要单独域名或端口(见 `with-nginx` 的 app 段)。
|
||||
|
||||
`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 格式统一为 **`<服务名>-<版本>`**。
|
||||
|
||||
当前业务版本:
|
||||
|
||||
| 服务 | 版本 |
|
||||
| --- | --- |
|
||||
| 除 app 线外的全部业务服务 | `v1.0.4` |
|
||||
| `iotlcm-app-server` / `iotlcm-app-web` | **`v1.0.2`**(小程序线独立版本,不跟随平台 tag) |
|
||||
|
||||
> 版本号与功能一一对应:小程序线的功能停在 v1.0.2,因此镜像 tag 也停在 v1.0.2;平台后续发 v1.0.5 时它依然不动。
|
||||
|
||||
升级时在 `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`(admin-ui/admin-svr/openapi/app-web/docs/sms),要从其他机器访问,二选一:
|
||||
- 用 `--profile with-nginx` 起自带反向代理(对外 80,按域名/路径分流,配置见 `config/nginx/iotlcm.conf`)
|
||||
- 或把 `docker-compose.yml` 里对应服务的 `127.0.0.1:xxxx` 改成 `0.0.0.0:xxxx`(需自备防火墙策略)
|
||||
|
||||
## 许可
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
@@ -0,0 +1,175 @@
|
||||
# ============================================================
|
||||
# iotlcm 内存上限 override —— 8 vCPU / 32 GB 宿主机,与其他服务共用
|
||||
#
|
||||
# 定位:这台机器是「一机多环境」,iotlcm 只是其中一个占用方。
|
||||
# 所以只给全套 20 个服务留 **约 8.5 GB 上限**(实测常驻约 2 GB),
|
||||
# 剩余 20+ GB 留给宿主机上要新增的其它服务。
|
||||
#
|
||||
# 没有本文件会怎样:编排里没有内存限制,任何一个服务泄漏/跑批都会
|
||||
# 把整机内存吃到 OOM Killer 随机杀进程(表现为容器反复 Restarting
|
||||
# 且日志里看不到业务异常,只能 dmesg 里看到 oom-kill)。
|
||||
#
|
||||
# 用法(顺序不能反):
|
||||
# docker compose -f docker-compose.yml -f compose.memlimit.yml up -d
|
||||
#
|
||||
# 只调 resources(+ MySQL 自身缓存参数),不动镜像、端口、卷、依赖。
|
||||
# 换成更小的机器(4 GB)时按比例下调:mysql 1024M / tdengine 512M /
|
||||
# worker-svr 768M / 其余 320M;8 GB 机器可用 1024M / 768M / 512M。
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
# ---------- 基础组件 ----------
|
||||
mysql:
|
||||
# buffer pool 由 128M(默认)提到 768M,连接数 151 → 300
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_general_ci
|
||||
- --default-time-zone=+08:00
|
||||
- --innodb-buffer-pool-size=768M
|
||||
- --max-connections=300
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 768M
|
||||
|
||||
redis:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
reservations:
|
||||
memory: 96M
|
||||
|
||||
rabbitmq:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 192M
|
||||
|
||||
tdengine:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1024M
|
||||
reservations:
|
||||
memory: 256M
|
||||
|
||||
# 一次性建表任务,跑完即退出
|
||||
tdengine-init:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
# 单节点 consul 常驻约 110~120 MB,给足余量避免贴边
|
||||
consul:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 96M
|
||||
|
||||
# ---------- 业务服务 ----------
|
||||
admin-svr:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
admin-ui:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
openapi:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
upstream-chan:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
upstream-proxy:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
# 全量同步(流量/状态/IMEI)时内存抬升最明显,按文档「500 MB ~ 1 GB」给 1 GB
|
||||
worker-svr:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1024M
|
||||
reservations:
|
||||
memory: 256M
|
||||
|
||||
app-server:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
app-web:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
sms:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
|
||||
docs:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# ---------- profile 可选服务 ----------
|
||||
bot:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
|
||||
onelink-mock:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
|
||||
nginx:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
# 单端口入口(with-gateway);之前没有任何上限
|
||||
gateway:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
# DNS 解析:nginx 只在启动时解析一次 upstream 主机名,admin-svr 重建换 IP 后
|
||||
# /api/ 会一直 502。127.0.0.11 是 Docker 内置 DNS,resolve + zone 让运行期自动跟随。
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
upstream gw_admin_svr { zone gw_admin_svr 64k; server admin-svr:8008 resolve; }
|
||||
|
||||
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://gw_admin_svr/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;
|
||||
}
|
||||
@@ -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: []
|
||||
@@ -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: ""
|
||||
@@ -0,0 +1,72 @@
|
||||
# 单端口入口(默认对外 5051)
|
||||
#
|
||||
# 用途:把管理端、文档站、OpenAPI 收在一个端口下,便于在外部网关 / DNS
|
||||
# 只暴露一个地址。编排里由 gateway 服务(profile: with-gateway)加载本文件,
|
||||
# 容器内监听 80,宿主机发布端口用 GATEWAY_PORT 控制(默认 5051)。
|
||||
#
|
||||
# docker compose --profile with-gateway up -d
|
||||
# GATEWAY_PORT=8088 docker compose --profile with-gateway up -d
|
||||
#
|
||||
# 路由:
|
||||
# / -> 管理端(admin-ui,内部已代理 /api/ 到 admin-svr)
|
||||
# /docs/ -> 文档站(docs)
|
||||
# /openapi/ -> 对外 API(openapi)
|
||||
# /route/open/ -> 运营商能力代理(openapi)
|
||||
#
|
||||
# 小程序 / H5 端的静态资源是根路径部署的,不能挂在同一域名的子路径下,
|
||||
# 需要单独域名或单独端口(见 config/nginx/iotlcm.conf 的 app 段)。
|
||||
|
||||
# DNS 解析:nginx 默认只在启动时把 upstream 主机名解析一次,容器重建/重启换了 IP
|
||||
# 就一直往旧地址打(2026-11-03 踩到:整站 /openapi 全 502,重启网关才恢复)。
|
||||
# 127.0.0.11 是 Docker 内置 DNS;下面的 resolve + zone 让它在运行期自动跟随。
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
upstream gw_admin_ui { zone gw_admin_ui 64k; server admin-ui:80 resolve; }
|
||||
upstream gw_openapi { zone gw_openapi 64k; server openapi:8000 resolve; }
|
||||
upstream gw_docs { zone gw_docs 64k; server docs:80 resolve; }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
charset utf-8;
|
||||
client_max_body_size 200m;
|
||||
|
||||
access_log /var/log/nginx/access_gateway.log;
|
||||
error_log /var/log/nginx/error_gateway.log;
|
||||
|
||||
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 = /docs {
|
||||
return 301 /docs/;
|
||||
}
|
||||
|
||||
location ^~ /docs/ {
|
||||
proxy_pass http://gw_docs;
|
||||
}
|
||||
|
||||
# ---------- 对外 API:版本化接口 ----------
|
||||
location ^~ /openapi/ {
|
||||
proxy_pass http://gw_openapi;
|
||||
}
|
||||
|
||||
# ---------- 对外 API:运营商能力代理(白名单 / 共享 Token)----------
|
||||
location ^~ /route/open/ {
|
||||
proxy_pass http://gw_openapi;
|
||||
}
|
||||
|
||||
# ---------- 管理端(含 /api/ 与 /upload/,由 admin-ui 自行转发)----------
|
||||
location / {
|
||||
proxy_pass http://gw_admin_ui;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1k;
|
||||
gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
# ============================================================
|
||||
# IotLCM 对外入口 nginx 配置示例
|
||||
#
|
||||
# 作用:把多个服务按路径分流到统一域名下。
|
||||
# 若宿主机已有 nginx 或面板,可不启动本容器,
|
||||
# 把下面的 location 段落并入宿主机配置即可。
|
||||
#
|
||||
# 把 your-domain.example.com 替换为你的实际域名。
|
||||
# ============================================================
|
||||
|
||||
# ---------- 后端地址解析 ----------
|
||||
# 这些 upstream 写的是 compose 服务名。nginx 默认只在**启动时**解析一次,
|
||||
# 容器重建后 IP 变了就会持续 502(需重启 nginx)。resolve + zone 让它在运行期自动跟随。
|
||||
# 注意:127.0.0.11 是 Docker 内置 DNS,仅在容器内有效:
|
||||
# ・在容器内用本文件 → 保持下面的 resolver 不动
|
||||
# ・把本文件并入宿主机 nginx → 把 resolver 换成宿主机 DNS(或删掉 resolver 与各 upstream 的 resolve 参数)
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
upstream iotlcm_admin_api {
|
||||
zone iotlcm_admin_api 64k;
|
||||
server admin-svr:8008 resolve;
|
||||
}
|
||||
|
||||
upstream iotlcm_admin_ui {
|
||||
zone iotlcm_admin_ui 64k;
|
||||
server admin-ui:80 resolve;
|
||||
}
|
||||
|
||||
upstream iotlcm_openapi_api {
|
||||
zone iotlcm_openapi_api 64k;
|
||||
server openapi:8000 resolve;
|
||||
}
|
||||
|
||||
upstream iotlcm_docs {
|
||||
zone iotlcm_docs 64k;
|
||||
server docs:80 resolve;
|
||||
}
|
||||
|
||||
upstream iotlcm_app_web {
|
||||
zone iotlcm_app_web 64k;
|
||||
server app-web:80 resolve;
|
||||
}
|
||||
|
||||
upstream iotlcm_app_server {
|
||||
zone iotlcm_app_server 64k;
|
||||
server app-server:8010 resolve;
|
||||
}
|
||||
|
||||
# ---------- 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,487 @@
|
||||
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
|
||||
# 就绪探针:nginx 类容器只在启动时解析一次后端 IP,
|
||||
# 没有它就无法用 depends_on 的 service_healthy 保证启动顺序(见 gateway)
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1:8008/api.json"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
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:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1/"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
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
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1:8000/api.json"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
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
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1/docs/"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ---------------- 可选:运营商接口挡板 ----------------
|
||||
# 模拟中国移动 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:
|
||||
- "${NGINX_HTTP_PORT:-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:
|
||||
condition: service_healthy
|
||||
openapi:
|
||||
condition: service_healthy
|
||||
docs:
|
||||
condition: service_healthy
|
||||
app-web:
|
||||
condition: service_started
|
||||
profiles:
|
||||
- with-nginx
|
||||
|
||||
# 单端口入口:管理端 + 文档站 + OpenAPI 收在一个端口(默认 5051)对外
|
||||
gateway:
|
||||
image: 'crpi-qflrqrxoohlkkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm:nginx-1.30'
|
||||
container_name: iotlcm-gateway
|
||||
restart: always
|
||||
ports:
|
||||
- "${GATEWAY_PORT:-5051}:80"
|
||||
volumes:
|
||||
- ./config/gateway:/etc/nginx/conf.d:ro
|
||||
- ./data/logs/gateway:/var/log/nginx
|
||||
networks:
|
||||
- iotlcm-network
|
||||
# 后端必须"健康"后才起网关:否则 nginx 会在后端拿到 IP 之前把 upstream
|
||||
# 解析结果缓存下来,容器重启换 IP 后所有请求变成 502(2026-11-03 踩过)。
|
||||
# 配置里另有 resolver + resolve,可在运行期自动跟随 IP 变化。
|
||||
depends_on:
|
||||
admin-ui:
|
||||
condition: service_healthy
|
||||
openapi:
|
||||
condition: service_healthy
|
||||
docs:
|
||||
condition: service_healthy
|
||||
profiles:
|
||||
- with-gateway
|
||||
|
||||
networks:
|
||||
iotlcm-network:
|
||||
driver: bridge
|
||||
name: iotlcm-network
|
||||
@@ -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
@@ -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));
|
||||
Executable
+39
@@ -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] 完成"
|
||||
@@ -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*
|
||||
@@ -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/
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com/
|
||||
@@ -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: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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` 这类服务名。症状是服务能启动、数据库也通,但批次执行、智能诊断全部超时。
|
||||
:::
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
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.2` |
|
||||
| app-web | `iotlcm-app-web-<TAG>` | `.../iotlcm/iotlcm:iotlcm-app-web-v1.0.2` |
|
||||
| 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 为 `<组件>-<版本>`,见下节。
|
||||
|
||||
::: tip app 线是独立版本
|
||||
`iotlcm-app-server` 与 `iotlcm-app-web` 属于**小程序线**,版本号与平台 tag 解耦:平台发 v1.0.4 时它们仍是 `v1.0.2`。
|
||||
自建镜像时按 `iotlcm-app-<x>-v1.0.2` 命名,不要跟着平台版本走。
|
||||
:::
|
||||
|
||||
::: 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` |
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: 部署概览
|
||||
description: IotLCM 平台的部署方式、构成与前置条件
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 部署概览
|
||||
|
||||
IotLCM 平台通过 Docker Compose 在单机上部署。部署编排与数据库初始化脚本在**代码仓的 `deploy/` 目录**:
|
||||
|
||||
```bash
|
||||
git clone <仓库地址> iotlcm
|
||||
cd iotlcm/deploy
|
||||
```
|
||||
|
||||
```text
|
||||
deploy/
|
||||
├── docker-compose.yml # 服务编排(含默认口令与端口)
|
||||
├── config/ # 各服务配置 + gateway / nginx 两种入口配置
|
||||
├── schema/init.sql # MySQL 初始化(单文件,95 表 + 种子数据)
|
||||
├── tdengine-init/ # 时序库建库建表(首次启动自动执行)
|
||||
└── data/ # 运行数据,首次启动自动创建
|
||||
```
|
||||
|
||||
## 部署内容
|
||||
|
||||
| 类别 | 服务 |
|
||||
| --- | --- |
|
||||
| 基础组件 | MySQL、Redis、RabbitMQ、Consul、TDengine(流量历史) |
|
||||
| 管理端 | admin-svr(后端 8008)、admin-ui(前端 8009) |
|
||||
| 对外接口 | openapi(8000) |
|
||||
| 小程序 / H5 | app-server(8010)、app-web(8018) |
|
||||
| 通道 | upstream-chan(8110 / 8310)、upstream-proxy(8510) |
|
||||
| 异步与配套 | worker-svr、sms(11190)、docs(8019) |
|
||||
| 可选 | onelink-mock(接口挡板)、bot(飞书通知)、nginx(多域名入口)、gateway(单端口入口 5051) |
|
||||
| 运营商对接 | 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`。
|
||||
@@ -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://127.0.0.1:8009`(编排默认把业务端口绑定在 `127.0.0.1`;需要从其他机器访问时,用 `--profile with-nginx` 起反向代理,或改绑定地址):
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 账号 | `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)
|
||||
@@ -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 完整(官方镜像已内置) |
|
||||
@@ -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 全量同步任务失败数
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
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`),该目录有数据后才会产生通知。
|
||||
|
||||
## 单端口入口
|
||||
|
||||
### 用途
|
||||
|
||||
只对外暴露**一个端口**(默认 5051),把管理端、文档站、OpenAPI 收在一起。适合外面还有一层网关 / DNS,
|
||||
或者只想开放一个端口的环境。
|
||||
|
||||
### 路由
|
||||
|
||||
| 路径 | 去向 |
|
||||
| --- | --- |
|
||||
| `/` | 管理端(admin-ui,内部已代理 `/api/` 到 admin-svr) |
|
||||
| `/docs/` | 文档站 |
|
||||
| `/openapi/` | 对外 API |
|
||||
| `/route/open/` | 运营商能力代理(白名单 / 共享 Token) |
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
docker compose --profile with-gateway up -d
|
||||
# 换端口
|
||||
GATEWAY_PORT=8088 docker compose --profile with-gateway up -d
|
||||
```
|
||||
|
||||
配置见 `config/gateway/nginx.conf`。
|
||||
|
||||
::: tip 小程序 / H5 端不在单端口内
|
||||
`app-web` 的静态资源是根路径部署的,挂到子路径会白屏。小程序端需要单独域名或端口,用下面的反向代理方式。
|
||||
:::
|
||||
|
||||
## 反向代理
|
||||
|
||||
### 用途
|
||||
|
||||
把分散在各端口的服务统一到域名下按路径分流。宿主机已有 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,对外端口由 `NGINX_HTTP_PORT` 控制:`NGINX_HTTP_PORT=8080 docker compose --profile with-nginx up -d`。
|
||||
|
||||
### 使用宿主机 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
|
||||
```
|
||||
@@ -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。
|
||||
:::
|
||||
@@ -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 同步,否则全量同步的时间窗口判断会出错 |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 与对象存储部署方式,以及路径前缀配置要点。
|
||||
---
|
||||
@@ -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,覆盖首页公开查卡、扫码与拍照识别、卡片详情、卡片排查和账号密码登录;办理、绑定、微信登录及注册等功能暂未开放。
|
||||
---
|
||||
@@ -0,0 +1,61 @@
|
||||
# 登录后办理概览
|
||||
|
||||
登录后的页面会根据账号工作区和权限动态显示。看不到某个入口时,通常是账号未配置对应权限,或该业务仍受版本开关限制。
|
||||
|
||||
## 登录与身份选择
|
||||
|
||||
1. 在首页或需要账号数据的页面进入登录,选择“密码登录”或“验证码登录”。
|
||||
|
||||

|
||||
|
||||
2. 密码登录支持手机号或用户名;验证码登录需要手机号、图形验证和短信验证码。
|
||||
|
||||

|
||||
|
||||
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. 输入企业名称或手机号搜索客户,也可刷新列表或加载更多。
|
||||
|
||||

|
||||
|
||||
3. 点击客户查看详情,包括客户资料、客户类型和实名信息。
|
||||
|
||||

|
||||
|
||||
4. 客户详情中的卡片数量和状态统计仅统计该客户直接持有的卡,不包含下级客户。
|
||||
|
||||
列表及详情分别需要对应权限。若搜索无结果,检查名称或手机号;若没有入口,联系账号管理员确认账号类型和授权范围。
|
||||
|
||||
## 单卡划拨
|
||||
|
||||
从卡片详情有权限时显示的“划拨这张卡”入口开始。确认目标客户后检查卡片信息并提交。单卡入口会锁定当前卡,不能改选其他卡。划拨成功后卡片归属转给目标客户。
|
||||
|
||||
## 批量划拨
|
||||
|
||||
1. 从“办理”进入“卡片划拨”。
|
||||
|
||||

|
||||
|
||||
2. 选择目标客户:仅支持已启用的直属下级客户,可按客户名称或编码搜索。
|
||||
|
||||

|
||||
|
||||
3. 搜索并勾选要划拨的卡片。可以选择当前结果;更换搜索条件后,先前选择仍保留。打开已选列表可移除单张或清空选择。
|
||||
|
||||

|
||||
|
||||
4. 每次最多选择100张。停机、销户或套餐过期的卡不可划拨。
|
||||
5. 核对目标客户和卡数后提交。后台会整批校验;存在失败卡时整批不执行。
|
||||
6. 提交后进入批次日志查看进度和结果。任务处理中时使用原页面的刷新操作查看状态。
|
||||
|
||||

|
||||
|
||||
若网络中断导致结果未知,不要再次新建划拨任务。先通过原批次号查看日志并核对卡片归属;只有确认原任务未受理且没有待执行任务后,才按页面提示核查并重试原任务。仍无法确认时联系管理员处理。
|
||||
|
||||
## 权限与边界
|
||||
|
||||
划拨入口要求企业、代理或平台工作区及卡片划拨权限。目标客户和卡片仍受账号数据范围约束。执行记录列表、概览和明细分别受批次日志权限控制;划拨页面可能提供与本次任务关联的结果查看入口。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 卡片详情
|
||||
|
||||
从首页查询结果点击“详情”后进入卡片详情页。首页会以公开来源打开详情,因此未登录或公开来源都会使用受限展示模式。
|
||||
|
||||
## 公开详情展示内容
|
||||
|
||||
公开模式包含两组信息:
|
||||
|
||||
- 基础信息:卡号、卡状态、在线状态;
|
||||
- 流量信息:已用流量。
|
||||
|
||||

|
||||
|
||||
卡号可能已经脱敏,页面显示什么就以什么为准。点击或长按卡号行可以复制当前显示值。
|
||||
|
||||
公开模式不会展示完整 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. 首页查询成功后点击“卡片排查”,系统会带入当前查询值;
|
||||
|
||||

|
||||
|
||||
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. 可按渠道、操作模式、录入方式和执行状态组合筛选;点击刷新重新查询,清除筛选可恢复完整列表。
|
||||
|
||||

|
||||
|
||||
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>
|
||||
|
||||
记录会显示批次号、提交渠道、操作类型、录入方式和执行状态。明细权限不足时仍可能看到列表或概览,但无法查看逐卡结果;具体字段按接口权限和返回数据展示。
|
||||
|
||||
## 状态未确认
|
||||
|
||||
任务处于待处理或处理中时,刷新原批次查看进度。若提交后网络中断且页面提示结果未确认,不要新建相同任务;保留原批次号,先查看日志并核对卡片归属。只有确认任务未受理且不存在待执行任务后,才能按页面提示重试原任务。
|
||||
@@ -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,再稍后重新排查。“未查询到会话”只表示当前没有查到数据会话,不等同于卡片已销户。
|
||||
|
||||
## 账号密码登录有什么限制?
|
||||
|
||||
当前版本只开放已有账号的账号密码登录;微信登录和注册账号未开放。忘记密码页面会提示短信服务接入后开放,请联系管理员或客服处理账号问题。
|
||||
|
||||
## 排查结果是否是最终结论?
|
||||
|
||||
不是。排查结果仅用于辅助判断,最终以运营商和业务系统状态为准。请勿在查询、排查和反馈内容中提交身份证号、银行卡号或其他无关敏感信息。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user