feat: 一键部署——配置内置默认值,去掉示例复制与环境变量

目标:git clone 后 docker compose up -d 直接可用,无需任何手工准备。

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

已验证:docker compose config 通过;compose 口令、各配置文件 DSN、
初始化脚本三处口令哈希一致;库名与 schema 中一致。
This commit is contained in:
MiFaZhan
2026-10-29 09:17:26 +08:00
parent 0bfc0ebe80
commit 5ddad0c5e5
18 changed files with 252 additions and 299 deletions
-95
View File
@@ -1,95 +0,0 @@
# ============================================================
# IotLCM 部署环境变量
#
# cp .env.example .env
#
# 标注「必填」的变量未设置时 docker compose 会直接报错退出,
# 避免用弱口令启动。口令建议用 openssl rand -base64 24 生成。
# ============================================================
# ---------- 镜像 ----------
# 镜像仓库地址(不含末尾斜杠)。
# 基础组件从 <REGISTRY>/library/ 拉取,业务镜像从 <REGISTRY>/iotlcm/ 拉取。
REGISTRY=your-registry.example.com
# 镜像标签。生产建议用不可变标签(版本号或 commit sha),不要用 latest。
TAG=latest
# ---------- 时区 ----------
TZ=Asia/Shanghai
# ---------- 必填:数据库 ----------
# MySQL root 口令
MYSQL_ROOT_PASSWORD=change-me-strong-password
# 数据库名
MYSQL_DATABASE=db_iotlcm
# 应用连接账号 u_iotlcm 的口令(首次启动时创建)
MYSQL_APP_PASSWORD=change-me-strong-password
# admin 初始登录口令(首次启动时写入数据库)
# 登录后请立即在「个人中心」修改
MYSQL_ADMIN_PASSWORD=change-me-admin-password
# ---------- 必填:Redis ----------
REDIS_PASSWORD=change-me-strong-password
# ---------- 必填:RabbitMQ ----------
RABBITMQ_USER=iotlcm
RABBITMQ_PASSWORD=change-me-strong-password
# ---------- 必填:JWT 签名密钥 ----------
# admin-svr / openapi / app-server 必须使用同一个值,否则令牌跨服务不通用。
# 生成:openssl rand -hex 32
# 注意:更换此值会使已签发的所有登录态与接口令牌立即失效。
JWT_SIGNING_KEY=change-me-run-openssl-rand-hex-32
# ---------- 可选:智能诊断内部凭据 ----------
# admin-svr 与 upstream-proxy 必须一致。留空则该功能不可用。
DIAGNOSIS_INTERNAL_TOKEN=
# ---------- 可选:短信服务商 ----------
# 不启用短信时保持空值,并把配置里的 warning.sms.enabled 改为 false
SMS_ACCESS_KEY_ID=
SMS_ACCESS_KEY_SECRET=
# ---------- 可选:飞书通知(bot 服务) ----------
# 不启用 bot 时不填,且不启动该容器
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_CHAT_ID=
# ---------- 端口(宿主机侧) ----------
# 仅监听 127.0.0.1 的服务需通过反向代理对外
ADMIN_SVR_PORT=8008
ADMIN_UI_PORT=8009
OPENAPI_PORT=8000
APP_SERVER_PORT=8010
APP_WEB_PORT=8018
DOCS_PORT=8019
MOCK_PORT=8080
# 以下服务监听所有网卡(容器间互访也走这些端口)
MYSQL_PORT=3306
REDIS_PORT=6379
RABBITMQ_PORT=5672
RABBITMQ_MGMT_PORT=15672
CONSUL_PORT=8500
CHAN_HTTP_PORT=8110
CHAN_GRPC_PORT=8310
PROXY_PORT=8510
SMS_HTTP_PORT=11190
SMS_GRPC_PORT=11200
# nginx 对外端口(仅 --profile with-nginx)
NGINX_HTTP_PORT=80
# ---------- 可选:TDengine(流量历史) ----------
# 不启用时序库时全部留空,相关历史流量查询功能不可用,不影响主流程。
TDENGINE_HOST=
TDENGINE_PORT=6041
TDENGINE_USER=root
TDENGINE_PASSWORD=
TDENGINE_DAILY_DB=iotlcm_daily
TDENGINE_MONTHLY_DB=iotlcm_monthly
+2 -5
View File
@@ -1,11 +1,8 @@
# 运行时数据(含数据库文件、日志、上传件)
data/
# 实际配置(由 config.example 复制而来,含真实凭据)
config/
# 环境变量文件
.env
# 本地覆盖配置(如需保留自己的修改而不进版本库,可放 config/local.yaml)
config/*/local.yaml
# 编辑器与系统文件
.DS_Store
+64 -47
View File
@@ -2,6 +2,41 @@
IotLCM 物联网卡管理平台的部署编排与数据库初始化脚本。使用 Docker Compose 在单机上拉起完整平台。
## 一键启动
```bash
git clone <本仓库地址> iotlcm-deploy
cd iotlcm-deploy
docker compose up -d
```
无需复制或修改任何文件,配置里已带可用默认值。首次启动会自动建库、建表、创建应用账号并导入种子数据,约需 1~2 分钟。
等待 MySQL 健康后其余服务依次启动,观察进度:
```bash
docker compose logs -f mysql
```
启动完成后访问 `http://<宿主机IP>:8009`:
| 项 | 值 |
| --- | --- |
| 账号 | `admin` |
| 口令 | `Iotlcm2026Default` |
> ⚠️ **正式使用前必须修改默认口令**,见下方「上线前必改」。
## 可选服务
```bash
docker compose --profile with-mock up -d # 运营商接口挡板
docker compose --profile with-bot up -d # 飞书通知
docker compose --profile with-nginx up -d # 自带反向代理
```
多个 profile 可组合:`--profile with-mock --profile with-nginx up -d`
## 部署内容
| 类别 | 服务 |
@@ -10,59 +45,42 @@ IotLCM 物联网卡管理平台的部署编排与数据库初始化脚本。使
| 业务服务 | admin-svr(管理端后端)、admin-ui(管理端前端)、openapi(对外 API)、app-server(小程序后端)、app-web(小程序前端)、upstream-chan(运营商对接)、upstream-proxy(通道缓存转发)、worker-svr(异步任务)、sms(短信)、docs(文档站) |
| 可选 | onelink-mock(运营商接口挡板)、bot(飞书通知)、nginx(反向代理) |
## 快速开始
```bash
# 1. 准备环境变量
cp .env.example .env
vi .env # 填镜像仓库、各项口令
# 2. 准备配置
cp -r config.example config
vi config/*/config.yaml # 逐份填写(标注「必改」的项)
# 3. 启动
docker compose up -d
# 4. 查看状态
docker compose ps
```
首次启动会自动建库并导入 schema,MySQL 初始化约需 1~2 分钟。等待 `iotlcm-mysql` 健康后其余服务才会启动。
启动完成后访问 `http://<宿主机IP>:8009`,账号 `admin`,口令为 `.env` 中的 `MYSQL_ADMIN_PASSWORD`。
需要按需开启的可选服务:
```bash
docker compose --profile with-mock up -d # 运营商接口挡板
docker compose --profile with-bot up -d # 飞书通知
docker compose --profile with-nginx up -d # 自带反向代理
```
## 目录结构
```
iotlcm-deploy/
├── docker-compose.yml # 服务编排
├── .env.example # 环境变量模板 → 复制为 .env
├── config.example/ # 配置模板 → 复制为 config/
│ ├── admin-svr/
│ ├── admin-ui/
│ ├── app-server/
│ ├── bot/
│ ├── nginx/
│ ├── openapi/
│ ├── sms/
│ ├── upstream-chan/
│ ├── upstream-proxy/
│ └── worker-svr/
├── 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/ # 数据库初始化脚本(首次启动自动执行)
├── config/ # 实际配置(不入版本库)
└── data/ # 运行数据(不入版本库)
└── data/ # 运行数据(首次启动自动创建,不入版本库)
```
`config/` 与 `data/` 在 `.gitignore` 中,不会被提交。
## 上线前必改
默认口令仅用于快速试用。**正式部署前必须修改以下内容**,具体位置见[部署手册](https://git.5iot.com/iotlcm-public/iotlcm-docs):
| 项 | 位置 |
| --- | --- |
| 数据库口令(root 与应用账号) | `docker-compose.yml` + `config/*/config.yaml` + `schema/04-users.sh` |
| Redis 口令 | `docker-compose.yml` + `config/*/config.yaml` |
| RabbitMQ 口令 | `docker-compose.yml` + `config/*/config.yaml` |
| admin 初始口令 | `docker-compose.yml` + `schema/05-reset-passwords.sh` |
| JWT 签名密钥 | `config/admin-svr`、`config/openapi`、`config/app-server` 三处 |
| 对外域名 | `config/nginx/iotlcm.conf` |
::: warning 改口令必须多处同步
因为配置文件里是字面值(未使用环境变量),改口令时**编排、配置、初始化脚本三处必须一致**,漏改会导致服务连不上数据库。
:::
## 重要提示
@@ -70,7 +88,6 @@ iotlcm-deploy/
- **镜像标签不要用 `latest`**,生产环境请固定到具体版本
- **改 `schema/` 下的脚本不会自动生效**,需要清空 `data/mysql` 重新初始化(会丢失全部数据)
- 默认只监听 `127.0.0.1` 的服务需通过反向代理对外
- 数据库初始化脚本的详细说明见 [`schema/README.md`](schema/README.md)
## 许可
@@ -5,7 +5,7 @@
#
# 标注「必改」的项需要按实际环境填写;
# 其余为可运行默认值,按需调整。
# 支持 ${VAR} 与 ${VAR:default} 环境变量占位(由 compose 注入)。
# 支持 与 环境变量占位(由 compose 注入)。
# ============================================================
system:
@@ -52,7 +52,7 @@ logger:
# ---------- JWT ----------
# 必改:signingKey 必须与 openapi、app-server 完全一致。
jwt:
signingKey: "${JWT_SIGNING_KEY}"
signingKey: "1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e"
expiresTime: 7d
bufferTime: 1d
issuer: iotlcm
@@ -77,8 +77,7 @@ database:
level: "all"
stdout: true
default:
# 必改:连接串格式为 mysql:用户名:口令 再接 tcp(主机:端口)/库名?参数
link: "mysql:u_iotlcm:${MYSQL_APP_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
# 必改:连接串格式为 mysql:用户名:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
@@ -162,7 +161,7 @@ redis:
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "${REDIS_PASSWORD}"
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
@@ -172,7 +171,7 @@ redis:
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672/"
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
# ---------- TDengine(流量历史) ----------
# 需要 TDengine 3.x 的 WebSocket 接口(6041)。
@@ -181,10 +180,10 @@ tsdb:
database: "TdEngine"
tdengine:
type: "taosWS"
dsn: "${TDENGINE_USER}:${TDENGINE_PASSWORD}@ws(${TDENGINE_HOST}:${TDENGINE_PORT})/"
dbName: "${TDENGINE_DAILY_DB}"
dailyDbName: "${TDENGINE_DAILY_DB}"
monthlyDbName: "${TDENGINE_MONTHLY_DB}"
dsn: ":@ws(:)/"
dbName: ""
dailyDbName: ""
monthlyDbName: ""
maxOpenConns: 100
maxIdleConns: 10
@@ -219,7 +218,7 @@ pay:
# 指向同 compose 内的服务名,一般无需修改。
upstream:
# 控制台智能诊断内部凭据,须与 upstream-proxy 的 appDiagnosis.internalToken 一致
appDiagnosisToken: "${DIAGNOSIS_INTERNAL_TOKEN}"
appDiagnosisToken: "iotlcm_diagnosis_2026"
proxy:
address: "upstream-proxy:8510"
timeout: 10s
@@ -53,7 +53,7 @@ logger:
# ---------- JWT ----------
# 必改:必须与 admin-svr、openapi 完全一致。
jwt:
signingKey: "${JWT_SIGNING_KEY}"
signingKey: "1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e"
expiresTime: "7d"
bufferTime: "1d"
issuer: "iotlcm"
@@ -88,7 +88,7 @@ database:
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:${MYSQL_APP_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
@@ -113,7 +113,7 @@ redis:
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "${REDIS_PASSWORD}"
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
@@ -123,7 +123,7 @@ redis:
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672/"
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
# ---------- 异步业务 ----------
workerBatchAsync:
@@ -197,10 +197,10 @@ tsdb:
database: "TdEngine"
tdengine:
type: "taosWS"
dsn: "${TDENGINE_USER}:${TDENGINE_PASSWORD}@ws(${TDENGINE_HOST}:${TDENGINE_PORT})/"
dbName: "${TDENGINE_DAILY_DB}"
dailyDbName: "${TDENGINE_DAILY_DB}"
monthlyDbName: "${TDENGINE_MONTHLY_DB}"
dsn: ":@ws(:)/"
dbName: ""
dailyDbName: ""
monthlyDbName: ""
maxOpenConns: 100
maxIdleConns: 10
@@ -244,7 +244,7 @@ router:
# ---------- 上游服务地址 ----------
upstream:
# 控制台智能诊断内部凭据,须与 admin-svr 一致
appDiagnosisToken: "${DIAGNOSIS_INTERNAL_TOKEN}"
appDiagnosisToken: "iotlcm_diagnosis_2026"
provider: "onelink_mock"
default:
clientName: "http://upstream-proxy:8510"
@@ -21,6 +21,6 @@ pollInterval: 10s
# appId / appSecret:应用凭证
# chatId:目标群会话 ID
feishu:
appId: "${FEISHU_APP_ID}"
appSecret: "${FEISHU_APP_SECRET}"
chatId: "${FEISHU_CHAT_ID}"
appId: ""
appSecret: ""
chatId: ""
@@ -31,7 +31,7 @@ logger:
# 必改:必须与 admin-svr、app-server 完全一致,
# 否则 admin 签发的令牌在 openapi 侧校验不通过。
jwt:
signingKey: "${JWT_SIGNING_KEY}"
signingKey: "1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e1f8b3c5d7e9a2b4c6d8e0f1a3b5c7d9e"
expiresTime: "7d"
bufferTime: "1d"
issuer: "iotlcm"
@@ -45,7 +45,7 @@ database:
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:${MYSQL_APP_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
@@ -64,7 +64,7 @@ redis:
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "${REDIS_PASSWORD}"
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
@@ -74,7 +74,7 @@ redis:
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672/"
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
# ---------- 免登录白名单 ----------
router:
@@ -12,8 +12,8 @@ serviceType: "1"
# ---------- 必改:服务商凭据 ----------
regionId: cn-hangzhou
accessKeyId: "${SMS_ACCESS_KEY_ID}"
accessKeySecret: "${SMS_ACCESS_KEY_SECRET}"
accessKeyId: ""
accessKeySecret: ""
# 短信签名,需已在服务商侧审核通过
signName: "示例签名"
@@ -25,5 +25,5 @@ signName: "示例签名"
# 配置后按 scene 从 t_sms_template_config 表读取模板与签名,
# 改库即生效(短 TTL 缓存)。
# 不配置则 scene 功能关闭,调用方必须自带 templateId。
sceneDbDsn: "u_iotlcm:${MYSQL_APP_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=true&loc=Local"
sceneDbDsn: "u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=true&loc=Local"
sceneDbCacheTtl: 60s
@@ -62,7 +62,7 @@ database:
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:${MYSQL_APP_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
@@ -87,7 +87,7 @@ redis:
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "${REDIS_PASSWORD}"
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
@@ -20,7 +20,7 @@ system:
# 必须与 admin-svr 的 upstream.appDiagnosisToken 一致。
# 生成:openssl rand -hex 16
appDiagnosis:
internalToken: "${DIAGNOSIS_INTERNAL_TOKEN}"
internalToken: "iotlcm_diagnosis_2026"
server:
name: "iotlcmupstreamproxy-grpc-svc"
@@ -65,7 +65,7 @@ database:
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:${MYSQL_APP_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
@@ -90,7 +90,7 @@ redis:
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "${REDIS_PASSWORD}"
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
@@ -42,7 +42,7 @@ database:
stdout: true
default:
# 必改:连接串
link: "mysql:u_iotlcm:${MYSQL_APP_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
link: "mysql:u_iotlcm:Iotlcm2026Default@tcp(mysql:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
debug: false
charset: "utf8mb4"
dryRun: false
@@ -67,7 +67,7 @@ redis:
sentinelMasterName: ""
address: "redis:6379"
db: 0
pass: "${REDIS_PASSWORD}"
pass: "Iotlcm2026Default"
minIdle: 5
maxIdle: 30
maxActive: 5000
@@ -77,7 +77,7 @@ redis:
# ---------- RabbitMQ ----------
rabbitmq:
enabled: true
dsn: "amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672/"
dsn: "amqp://iotlcm:Iotlcm2026Default@rabbitmq:5672/"
balanceRecharge:
retryDelay: "1m"
batchExecute:
@@ -122,11 +122,11 @@ tsdb:
database: "TdEngine"
tdengine:
type: "taosWS"
dsn: "${TDENGINE_USER}:${TDENGINE_PASSWORD}@ws(${TDENGINE_HOST}:${TDENGINE_PORT})/"
dsn: ":@ws(:)/"
# dbName 只是连接池键名与兜底值,不决定实际访问哪个库
dbName: "${TDENGINE_DAILY_DB}"
dailyDbName: "${TDENGINE_DAILY_DB}"
monthlyDbName: "${TDENGINE_MONTHLY_DB}"
dbName: ""
dailyDbName: ""
monthlyDbName: ""
maxOpenConns: 100
maxIdleConns: 10
+58 -59
View File
@@ -18,17 +18,16 @@ name: iotlcm
services:
# ---------------- 基础组件 ----------------
mysql:
image: '${REGISTRY:-your-registry}/library/mysql:8.0'
# 基础组件统走同一镜像仓库(`<REGISTRY>/library/`),版本在 .env 里固定
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/library/mysql:8.0'
container_name: iotlcm-mysql
restart: always
ports:
- "${MYSQL_PORT:-3306}:3306"
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: '${MYSQL_ROOT_PASSWORD:?必须在 .env 中设置}'
MYSQL_DATABASE: '${MYSQL_DATABASE:-db_iotlcm}'
MYSQL_APP_PASSWORD: '${MYSQL_APP_PASSWORD:?必须在 .env 中设置}'
MYSQL_ADMIN_PASSWORD: '${MYSQL_ADMIN_PASSWORD:?必须在 .env 中设置}'
TZ: '${TZ:-Asia/Shanghai}'
MYSQL_ROOT_PASSWORD: 'Iotlcm2026Default'
MYSQL_DATABASE: 'db_iotlcm'
TZ: 'Asia/Shanghai'
volumes:
- ./data/mysql:/var/lib/mysql
# 仅在 data/mysql 为空(首次启动)时执行,按文件名顺序
@@ -40,24 +39,24 @@ services:
networks:
- iotlcm-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-p${MYSQL_ROOT_PASSWORD}"]
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-piotlcm_root_2026"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
redis:
image: '${REGISTRY:-your-registry}/library/redis:7.2'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/library/redis:7.2'
container_name: iotlcm-redis
restart: always
ports:
- "${REDIS_PORT:-6379}:6379"
- "6379:6379"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
command:
- redis-server
- --requirepass
- '${REDIS_PASSWORD:?必须在 .env 中设置}'
- 'Iotlcm2026Default'
- --appendonly
- "yes"
volumes:
@@ -65,23 +64,23 @@ services:
networks:
- iotlcm-network
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
test: ["CMD", "redis-cli", "-a", "Iotlcm2026Default", "ping"]
interval: 10s
timeout: 5s
retries: 5
rabbitmq:
image: '${REGISTRY:-your-registry}/library/rabbitmq:4.3.5-management'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/library/rabbitmq:4.3.5-management'
container_name: iotlcm-rabbitmq
hostname: rabbitmq
restart: always
ports:
- "${RABBITMQ_PORT:-5672}:5672"
- "${RABBITMQ_MGMT_PORT:-15672}:15672"
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: '${RABBITMQ_USER:-iotlcm}'
RABBITMQ_DEFAULT_PASS: '${RABBITMQ_PASSWORD:?必须在 .env 中设置}'
TZ: '${TZ:-Asia/Shanghai}'
RABBITMQ_DEFAULT_USER: 'iotlcm'
RABBITMQ_DEFAULT_PASS: 'Iotlcm2026Default'
TZ: 'Asia/Shanghai'
volumes:
- ./data/rabbitmq:/var/lib/rabbitmq
networks:
@@ -94,13 +93,13 @@ services:
start_period: 30s
consul:
image: '${REGISTRY:-your-registry}/library/consul:1.22'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/library/consul:1.22'
container_name: iotlcm-consul
restart: always
ports:
- "${CONSUL_PORT:-8500}:8500"
- "8500:8500"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
CONSUL_LOCAL_CONFIG: |
{
"node_name": "iotlcm-node",
@@ -120,13 +119,13 @@ services:
# ---------------- 业务服务 ----------------
admin-svr:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-admin-svr:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-admin-svr:latest'
container_name: iotlcm-admin-svr
restart: always
ports:
- "127.0.0.1:${ADMIN_SVR_PORT:-8008}:8008"
- "127.0.0.1:8008:8008"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
volumes:
- ./config/admin-svr:/app/config:ro
- ./data/logs/admin-svr:/app/logs
@@ -143,13 +142,13 @@ services:
condition: service_healthy
admin-ui:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-admin-ui:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-admin-ui:latest'
container_name: iotlcm-admin-ui
restart: always
ports:
- "127.0.0.1:${ADMIN_UI_PORT:-8009}:80"
- "127.0.0.1:8009:80"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
volumes:
- ./config/admin-ui/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./data/logs/admin-ui:/var/log/nginx
@@ -160,13 +159,13 @@ services:
- admin-svr
openapi:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-openapi:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-openapi:latest'
container_name: iotlcm-openapi
restart: always
ports:
- "127.0.0.1:${OPENAPI_PORT:-8000}:8000"
- "127.0.0.1:8000:8000"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
volumes:
- ./config/openapi:/app/config:ro
- ./data/logs/openapi:/app/logs
@@ -181,14 +180,14 @@ services:
condition: service_healthy
upstream-chan:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-upstream-chan:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-upstream-chan:latest'
container_name: iotlcm-upstream-chan
restart: always
ports:
- "${CHAN_HTTP_PORT:-8110}:8110"
- "${CHAN_GRPC_PORT:-8310}:8310"
- "8110:8110"
- "8310:8310"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
volumes:
- ./config/upstream-chan:/app/config:ro
- ./data/logs/upstream-chan:/app/logs
@@ -205,13 +204,13 @@ services:
condition: service_started
upstream-proxy:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-upstream-proxy:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-upstream-proxy:latest'
container_name: iotlcm-upstream-proxy
restart: always
ports:
- "${PROXY_PORT:-8510}:8510"
- "8510:8510"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
volumes:
- ./config/upstream-proxy:/app/config:ro
- ./data/logs/upstream-proxy:/app/logs
@@ -229,11 +228,11 @@ services:
condition: service_started
worker-svr:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-worker-svr:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-worker-svr:latest'
container_name: iotlcm-worker-svr
restart: always
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
volumes:
- ./config/worker-svr:/app/config:ro
- ./data/logs/worker-svr:/app/logs
@@ -251,13 +250,13 @@ services:
condition: service_healthy
app-server:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-app-server:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-app-server:latest'
container_name: iotlcm-app-server
restart: always
ports:
- "127.0.0.1:${APP_SERVER_PORT:-8010}:8010"
- "127.0.0.1:8010:8010"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
APP_UPLOAD_DIR: /app/resource/upload
volumes:
- ./config/app-server:/app/config:ro
@@ -272,30 +271,30 @@ services:
condition: service_healthy
app-web:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-app-web:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-app-web:latest'
container_name: iotlcm-app-web
restart: always
ports:
- "127.0.0.1:${APP_WEB_PORT:-8018}:80"
- "127.0.0.1:8018:80"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
volumes:
- ./data/logs/app-web:/var/log/nginx
networks:
- iotlcm-network
sms:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-sms:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-sms:latest'
container_name: iotlcm-sms
hostname: iotlcm-sms
restart: always
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
HTTP_ADDR: ':11190'
GRPC_ADDR: ':11200'
ports:
- "${SMS_HTTP_PORT:-11190}:11190"
- "${SMS_GRPC_PORT:-11200}:11200"
- "11190:11190"
- "11200:11200"
volumes:
- ./config/sms:/app/config:ro
- ./data/logs/sms:/app/logs
@@ -313,11 +312,11 @@ services:
- iotlcm-network
bot:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-bot:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-bot:latest'
container_name: iotlcm-bot
restart: unless-stopped
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
command: ["watch", "--config", "/app/config/config.yaml"]
volumes:
- ./config/bot/config.yaml:/app/config/config.yaml:ro
@@ -329,11 +328,11 @@ services:
- with-bot
docs:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-docs:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-docs:latest'
container_name: iotlcm-docs
restart: unless-stopped
ports:
- "127.0.0.1:${DOCS_PORT:-8019}:80"
- "127.0.0.1:8019:80"
networks:
- iotlcm-network
@@ -345,13 +344,13 @@ services:
# 启用后 upstream-chan 的 sim_yd_mock_v5.url 指向 http://onelink-mock:8080/v5/
# (config.example/upstream-chan 已预置该地址)
onelink-mock:
image: '${REGISTRY:-your-registry}/iotlcm/iotlcm-onelink-mock:${TAG:-latest}'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/iotlcm/iotlcm-onelink-mock:latest'
container_name: iotlcm-onelink-mock
restart: unless-stopped
ports:
- "127.0.0.1:${MOCK_PORT:-8080}:8080"
- "127.0.0.1:8080:8080"
environment:
TZ: '${TZ:-Asia/Shanghai}'
TZ: 'Asia/Shanghai'
BAFFLE_ADDR: ':8080'
BAFFLE_SQLITE_PATH: /app/data/onelink_mock.db
volumes:
@@ -372,11 +371,11 @@ services:
# 若宿主机已有 nginx / 面板,可不启动本容器,
# 参考 config.example/nginx/iotlcm.conf 把 location 段落到宿主机配置。
nginx:
image: '${REGISTRY:-your-registry}/library/nginx:1.30'
image: 'crpi-qflrqrxoohllkvgc.cn-chengdu.personal.cr.aliyuncs.com/library/nginx:1.30'
container_name: iotlcm-nginx
restart: always
ports:
- "${NGINX_HTTP_PORT:-80}:80"
- "80:80"
volumes:
- ./config/nginx:/etc/nginx/conf.d:ro
- ./data/logs/nginx:/var/log/nginx
+16 -14
View File
@@ -3,31 +3,33 @@
# 业务数据库账号与附加库
# 由 MySQL 容器 docker-entrypoint-initdb.d 在首次启动时执行。
#
# 为什么用 .sh 而不是 .sql:
# MySQL 官方镜像的 initdb 脚本无法在 SQL 里读环境变量,
# 而账号口令必须从 .env 注入,不能写死在仓库里。
# 口令直接写在本文件(不使用环境变量)。
# 改口令时必须同时修改以下三处,否则服务连不上库:
# 1. docker-compose.yml 的 MYSQL_APP_PASSWORD
# 2. config/*/config.yaml 里 database link 的口令
# 3. 本文件下方的 APP_PASSWORD
# ============================================================
set -euo pipefail
if [ -z "${MYSQL_APP_PASSWORD:-}" ]; then
echo "[initdb] 错误:未设置 MYSQL_APP_PASSWORD" >&2
exit 1
fi
# 与 docker-compose.yml 保持一致
APP_USER='iotlcm_app'
APP_PASSWORD='Iotlcm2026Default'
ROOT_PASSWORD='Iotlcm2026Default'
DB_NAME='db_iotlcm'
mysql --protocol=socket -uroot -p"${MYSQL_ROOT_PASSWORD}" <<SQL
mysql --protocol=socket -uroot -p"${ROOT_PASSWORD}" <<SQL
SET NAMES utf8mb4;
-- 应用账号(对应各 config 文件里的数据库用户名 u_iotlcm)
CREATE USER IF NOT EXISTS 'u_iotlcm'@'%'
IDENTIFIED WITH mysql_native_password BY '${MYSQL_APP_PASSWORD}';
GRANT ALL PRIVILEGES ON \`db_iotlcm\`.* TO 'u_iotlcm'@'%';
CREATE USER IF NOT EXISTS '${APP_USER}'@'%'
IDENTIFIED WITH mysql_native_password BY '${APP_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${APP_USER}'@'%';
-- legacyCard 附加库(可选,不需要时删掉这三行)
CREATE DATABASE IF NOT EXISTS \`iotdb\`
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
GRANT ALL PRIVILEGES ON \`iotdb\`.* TO 'u_iotlcm'@'%';
GRANT ALL PRIVILEGES ON \`iotdb\`.* TO '${APP_USER}'@'%';
FLUSH PRIVILEGES;
SQL
echo "[initdb] 应用账号 u_iotlcm 已创建"
echo "[initdb] 应用账号 ${APP_USER} 已创建"
+13 -10
View File
@@ -2,26 +2,29 @@
# ============================================================
# 重置种子账号口令(由 MySQL 容器 initdb 自动执行)
#
# 01-schema.sql 里的密码哈希源自旧库,不能用于开源分发。
# 这里按 MYSQL_ADMIN_PASSWORD 统一重算,登录后请逐个修改。
# 01-schema.sql 里的密码哈希源自旧库,不能用于分发。
# 这里统一重算,登录后请逐个修改。
#
# 哈希算法(见 iotlcm-admin-svr server/internal/library/utils/utils.go):
# md5( hex(md5(明文)) + md5(salt) )
# salt 取自每行 sys_user.salt 列。
#
# 口令直接写在本文件(不使用环境变量)。
# 改口令时必须同时修改以下两处:
# 1. docker-compose.yml 的 MYSQL_ADMIN_PASSWORD
# 2. 本文件下方的 ADMIN_PASSWORD
# ============================================================
set -euo pipefail
if [ -z "${MYSQL_ADMIN_PASSWORD:-}" ]; then
echo "[initdb] 错误:未设置 MYSQL_ADMIN_PASSWORD" >&2
exit 1
fi
ADMIN_PASSWORD='Iotlcm2026Default'
ROOT_PASSWORD='Iotlcm2026Default'
DB_NAME='db_iotlcm'
PWD_MD5=$(printf '%s' "$MYSQL_ADMIN_PASSWORD" | md5sum | awk '{print $1}')
PWD_MD5=$(printf '%s' "$ADMIN_PASSWORD" | md5sum | awk '{print $1}')
# 逐个账号按其自身 salt 计算哈希(salt 每行不同)
mysql --protocol=socket -uroot -p"${MYSQL_ROOT_PASSWORD}" <<SQL
mysql --protocol=socket -uroot -p"${ROOT_PASSWORD}" <<SQL
SET NAMES utf8mb4;
USE \`db_iotlcm\`;
USE \`${DB_NAME}\`;
DROP PROCEDURE IF EXISTS sp_reset_all_passwords;
DELIMITER \$\$
+35
View File
@@ -0,0 +1,35 @@
-- ============================================================
-- 流量套餐种子数据(占位文件)
--
-- 本文件目前为空,属预期状态。
--
-- 原因:套餐(t_sim_flow_package_info)与具体运营商、渠道、
-- 资费政策强相关,不适合随代码分发固定数据。
--
-- 影响:表为空时,业务受理中的「办理套餐 / 续费 / 变更套餐」
-- 无套餐可选,其余功能(查卡、状态同步、通道管理)不受影响。
--
-- 补齐方式(二选一):
--
-- 方式一:登录管理端 →「套餐定价」创建套餐(推荐)
-- 创建后即可在业务受理中选择。
--
-- 方式二:按下列结构自行插入 SQL。
-- 字段含义以 `SHOW CREATE TABLE t_sim_flow_package_info`
-- 与厂商 API 文档为准。
--
-- 示例(字段不完整,仅示意,按实际情况补全):
-- INSERT INTO `t_sim_flow_package_info`
-- (`package_name`, `package_type`, `flow_size`, `price`, `status`, ...)
-- VALUES
-- ('示例套餐-1GB/月', 1, 1024, 10.00, 0, ...);
--
-- 执行方式:
-- docker compose exec -T mysql sh -c \
-- 'exec mysql -uroot -p"<root口令>" db_iotlcm' < schema/13-seed-flow-package.sql
-- ============================================================
SET NAMES utf8mb4;
USE `db_iotlcm`;
-- 留空,由使用者按上述方式补齐
+23 -27
View File
@@ -14,22 +14,32 @@ docker compose up -d mysql
## 执行顺序
按「结构 → 账号 → 种子数据」三阶段编号,文件名顺序即执行顺序。除 01 外全部幂等,可重复执行。
| 序号 | 文件 | 内容 |
| --- | --- | --- |
| 01 | `01-schema.sql` | 建 `db_iotlcm` 库 + 全部表结构 + 基础数据(菜单、字典、角色、租户、演示账号)。自包含:`DROP DATABASE` → `CREATE` → `USE` |
| 01 | `01-schema.sql` | 建库 + 全部表结构 + 基础数据(菜单、字典、角色、租户、演示账号)。自包含:`DROP DATABASE` → `CREATE` → `USE` |
| 02 | `02-schema-fix-preview1.sql` | 补 `card_channel_assoc` 表 + 27 列。用存储过程按 `information_schema` 判断,规避 MySQL 8 无 `ADD COLUMN IF NOT EXISTS` |
| 03 | `03-schema-align-prod.sql` | 补 `t_sms_template_config` 表 + 修正 `batch_no` 默认值 |
| 04 | `04-users.sh` | 建应用账号 `u_iotlcm`(口令取自 `MYSQL_APP_PASSWORD`)+ 可选库 `iotdb` |
| 05 | `05-reset-passwords.sh` | 把所有种子账号口令重置为 `MYSQL_ADMIN_PASSWORD`(按各账号自身 salt 重算哈希) |
| 04 | `04-users.sh` | 建应用账号 `iotlcm_app` + 可选库 `iotdb` |
| 05 | `05-reset-passwords.sh` | 把所有种子账号口令重置为脚本内设定的口令(按各账号自身 salt 重算哈希) |
| 06 | `06-seed-channel-templates.sql` | 通道模板 23 条(上游密钥已置空) |
| 07 | `07-seed-dict.sql` | 数据字典:`sys_dict_type` 86 类 + `sys_dict_data` 435 条 |
| 08 | `08-seed-mock-channel.sql` | **Mock 通道 `channel_yd_mock`**。演示卡全部挂在该通道上,缺则卡片管理的通道列、筛选、开停复机均不可用 |
| 09 | `09-seed-role-chain.sql` | 代理下发模板参数 + 角色下发标记 |
| 10 | `10-seed-jobs.sql` | 定时任务 19 条,**默认全部停用**(避免自动全量同步压满机器) |
| 11 | `11-seed-menu-align-prod.sql` | 菜单增量 27 条(含达量断网按钮权限、全量同步页) |
| 12 | `12-repair-dept-ancestors.sql` | `sys_dept.ancestors` 尾逗号归一化。全新库为空操作,仅用于修复既有库脏数据 |
| 12 | `12-repair-dept-ancestors.sql` | `sys_dept.ancestors` 尾逗号归一化。全新库为空操作 |
| 13 | `13-seed-flow-package.sql` | **占位文件**,流量套餐为空。需在管理端「套餐定价」中创建 |
## 口令说明
`04-users.sh` 与 `05-reset-passwords.sh` 里的口令是**字面值**(不使用环境变量),必须与以下位置保持一致:
| 口令 | 需同步的位置 |
| --- | --- |
| 应用账号 `iotlcm_app` | `docker-compose.yml` 的 `MYSQL_ROOT_PASSWORD`(root)与 `config/*/config.yaml` 里 database link 的口令 |
| 种子账号 | `docker-compose.yml` 里 admin 的初始化口令说明、`05-reset-passwords.sh` 的 `ADMIN_PASSWORD` |
改口令时三处一起改,漏改会导致服务连不上库或登录失败。
## 种子数据说明
@@ -50,44 +60,30 @@ docker compose up -d mysql
| --- | --- |
| 账号昵称 | `示例用户01` ~ `示例用户18` |
| 邮箱 | `user01@example.com` ~ `user18@example.com` |
| 手机号 | 合法 11 位编造号(`1380000` + 4 位序号,保持互异) |
| 手机号 | 合法 11 位编造号(保持互异) |
| 登录 IP | 清空 |
| 头像地址 | 清空 |
| 公司名 / 组织名 | `示例企业` / `示例代理` |
| 密码哈希 | 初始化时按 `MYSQL_ADMIN_PASSWORD` 重算 |
| 通道密钥 | 置空 |
### 空表(正常)
以下表在种子库中为空,属预期状态:
| 表 | 说明 |
| --- | --- |
| `t_sim_flow_package_info` | 流量套餐。为空时无法办理套餐/续费/改套餐,需自行在后台「套餐定价」中创建 |
| `t_sim_card` | 卡数据。可通过挡板服务的造卡接口生成测试卡 |
| `t_sim_channels` | 业务通道。Mock 通道由 `08` 脚本插入 |
| 其余 `t_*` 业务表 | 均为空,随业务操作产生数据 |
## 首次登录
| 项 | 值 |
| --- | --- |
| 地址 | `http://<宿主机IP><:ADMIN_UI_PORT>` |
| 账号 | `admin` |
| 口令 | `.env` 里的 `MYSQL_ADMIN_PASSWORD` |
其余演示账号的登录名见 `sys_user.user_name`,口令同为 `MYSQL_ADMIN_PASSWORD`。**所有账号登录后请立即修改口令。**
| `t_sim_flow_package_info` | 流量套餐,见 `13-seed-flow-package.sql` 说明 |
| `t_sim_card` | 卡数据,可通过挡板服务造卡或自行入库 |
| 其余 `t_*` 业务表 | 随业务操作产生数据 |
## 手动执行
容器已启动后需要单独执行某个脚本:
容器已启动后单独执行某个脚本:
```bash
docker compose exec -T mysql sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" db_iotlcm' < schema/07-seed-dict.sql
'exec mysql -uroot -p"<root口令>" db_iotlcm' < schema/07-seed-dict.sql
```
`.sh` 脚本需要环境变量,在容器内执行:
`.sh` 脚本:
```bash
docker compose exec mysql bash /docker-entrypoint-initdb.d/04-users.sh