sync: 同步短信服务 v1.0.4 并脱敏
This commit is contained in:
@@ -1,29 +1,79 @@
|
|||||||
# iotlcm-sms
|
# iotlcm-sms
|
||||||
|
|
||||||
独立短信发送服务,HTTP + gRPC 双协议共用同一套实现,支持**阿里云**与**腾讯云**短信。
|
独立短信 HTTP/gRPC 服务,支持阿里云和腾讯云短信。
|
||||||
|
|
||||||
服务自身可持有全局供应商凭证与签名,调用方只需传模板 ID 和短信内容;也可以在每次请求里显式传入完整凭证,由调用方自行管理。
|
## 启动
|
||||||
|
|
||||||
## 特性
|
|
||||||
|
|
||||||
- **双协议**:`POST /api/v1/sms/send`(HTTP)与 `PbNotice.Notice/Send`(gRPC)共用 `internal/sms` 实现
|
|
||||||
- **双供应商**:`serviceType=1` 阿里云、`serviceType=2` 腾讯云
|
|
||||||
- **配置三级合并**:`config.yaml` 默认值 → `SMS_*` 环境变量覆盖 → 调用方请求显式字段优先
|
|
||||||
- **凭证不落库**:真实凭证只放在服务器上的 `config.yaml`(只读挂载),仓库内只保留 `config.example.yaml`
|
|
||||||
- **健康检查**:`GET /healthz`
|
|
||||||
- **gRPC reflection**:可直接用 `grpcurl` 查看线上接口
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听 `:8080`,gRPC 默认监听 `:11200`:
|
默认监听 `:8080`,可通过 `HTTP_ADDR` 修改:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HTTP_ADDR=:9090 GRPC_ADDR=:11201 go run .
|
HTTP_ADDR=:9090 go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
gRPC 默认监听 `:11200`,可通过 `GRPC_ADDR` 修改。HTTP 与 gRPC 使用同一套短信发送实现。
|
||||||
|
|
||||||
|
## 默认供应商配置
|
||||||
|
|
||||||
|
短信服务自身持有全局凭证与签名,调用方只需传模板 ID 和短信内容。默认配置按下面的顺序合并:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.yaml 默认值 → SMS_* 环境变量覆盖 → 调用方请求中显式提供的字段优先
|
||||||
|
```
|
||||||
|
|
||||||
|
配置文件查找顺序:`SMS_CONFIG_FILE` 指定路径 → `./config.yaml` → `./config/config.yaml` → `/app/config/config.yaml`。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml(真实凭证只放服务器,不要提交 Git)
|
||||||
|
serviceType: "1" # 1=阿里云,2=腾讯云
|
||||||
|
regionId: cn-hangzhou
|
||||||
|
accessKeyId: ""
|
||||||
|
accessKeySecret: ""
|
||||||
|
signName: "示例企业"
|
||||||
|
```
|
||||||
|
|
||||||
|
可用环境变量覆盖:`SMS_SERVICE_TYPE`、`SMS_REGION_ID`、`SMS_ACCESS_KEY_ID`、
|
||||||
|
`SMS_ACCESS_KEY_SECRET`、`SMS_SDK_APP_ID`、`SMS_SIGN_NAME`、`SMS_TEMPLATE_ID`。
|
||||||
|
|
||||||
|
仓库内提供 `config.example.yaml` 作为模板;`.gitignore` 已排除真实 `config.yaml`。
|
||||||
|
启动日志会打印实际加载的配置来源(`file=... env=...`),便于排查。
|
||||||
|
|
||||||
|
## 场景模板配置库(可选)
|
||||||
|
|
||||||
|
除全局凭证外,还支持按业务场景(scene)从数据库回填模板与签名:调用方只传 `scene`
|
||||||
|
和内容参数,无需关心具体模板 ID。连接参数与凭证同放 `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 场景模板配置库(表 t_sms_template_config);DSN 留空或省略 = 关闭该能力
|
||||||
|
sceneDbDsn: "user:pass@tcp(127.0.0.1:3306)/db_iotlcm?charset=utf8mb4&parseTime=true&loc=Local"
|
||||||
|
sceneDbCacheTtl: 60s # 场景配置内存缓存时长,"改库即生效"的最长延迟
|
||||||
|
```
|
||||||
|
|
||||||
|
可用环境变量覆盖:`SMS_DB_DSN`、`SMS_DB_CONFIG_CACHE_TTL`。DSN 未配置时,
|
||||||
|
带 `scene` 的请求返回 400(`短信场景未配置或已停用`)。
|
||||||
|
|
||||||
|
生产环境使用 `deploy/docker-compose.yml` 容器化部署,HTTP 端口为 `11190`,
|
||||||
|
gRPC 端口为 `11200`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --platform linux/amd64 -f build/Dockerfile \
|
||||||
|
-t <your-registry>/iotlcm-sms:latest .
|
||||||
|
docker push <your-registry>/iotlcm-sms:latest
|
||||||
|
|
||||||
|
cd deploy
|
||||||
|
# 先在服务器放置真实 config.yaml(不入库)
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
生产部署建议通过 `IOTLCM_SMS_IMAGE` 指定不可变版本标签:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IOTLCM_SMS_IMAGE=<your-registry>/iotlcm-sms:<commit> docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
健康检查:
|
健康检查:
|
||||||
@@ -32,43 +82,11 @@ HTTP_ADDR=:9090 GRPC_ADDR=:11201 go run .
|
|||||||
curl http://127.0.0.1:8080/healthz
|
curl http://127.0.0.1:8080/healthz
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
|
|
||||||
查找顺序:`SMS_CONFIG_FILE` 指定路径 → `./config.yaml` → `./config/config.yaml` → `/app/config/config.yaml`。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# config.yaml(真实凭证只放服务器,不要提交 Git)
|
|
||||||
serviceType: "1" # 1=阿里云,2=腾讯云
|
|
||||||
regionId: cn-hangzhou
|
|
||||||
accessKeyId: ""
|
|
||||||
accessKeySecret: ""
|
|
||||||
signName: "示例签名"
|
|
||||||
# templateId: "" # 模板 ID 由调用方按业务场景传入
|
|
||||||
# sdkAppId: "" # 仅腾讯云需要
|
|
||||||
```
|
|
||||||
|
|
||||||
### 环境变量
|
|
||||||
|
|
||||||
| 变量 | 作用 |
|
|
||||||
|---|---|
|
|
||||||
| `SMS_SERVICE_TYPE` | 1=阿里云,2=腾讯云 |
|
|
||||||
| `SMS_REGION_ID` | 区域 ID,默认 `cn-hangzhou` |
|
|
||||||
| `SMS_ACCESS_KEY_ID` | AccessKey ID |
|
|
||||||
| `SMS_ACCESS_KEY_SECRET` | AccessKey Secret |
|
|
||||||
| `SMS_SDK_APP_ID` | 腾讯云 SDKAppID |
|
|
||||||
| `SMS_SIGN_NAME` | 短信签名 |
|
|
||||||
| `SMS_TEMPLATE_ID` | 默认模板 ID(通常由调用方按场景传入) |
|
|
||||||
| `SMS_CONFIG_FILE` | 指定配置文件路径 |
|
|
||||||
| `HTTP_ADDR` | HTTP 监听地址,默认 `:8080` |
|
|
||||||
| `GRPC_ADDR` | gRPC 监听地址,默认 `:11200` |
|
|
||||||
|
|
||||||
启动日志会打印实际加载的配置来源(`file=... env=...`),便于排查。
|
|
||||||
|
|
||||||
## 发送短信
|
## 发送短信
|
||||||
|
|
||||||
### 阿里云
|
接口:`POST /api/v1/sms/send`
|
||||||
|
|
||||||
|
阿里云请求示例:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
||||||
@@ -83,13 +101,13 @@ curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
|||||||
"templateId": "SMS_000000001"
|
"templateId": "SMS_000000001"
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"phoneNumber": "138****8000",
|
"phoneNumber": "13800008000",
|
||||||
"msgContent": "{\"code\":\"123456\"}"
|
"msgContent": "{\"code\":\"123456\"}"
|
||||||
}
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 腾讯云
|
腾讯云请求示例:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
||||||
@@ -104,15 +122,13 @@ curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
|||||||
"templateId": "000000"
|
"templateId": "000000"
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"phoneNumber": "138****8000",
|
"phoneNumber": "+8613800138000",
|
||||||
"msgContent": "123456|5"
|
"msgContent": "123456|5"
|
||||||
}
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
- `serviceType=1`(阿里云)时 `msgContent` 为模板参数 JSON
|
`serviceType` 为 `1` 时使用阿里云,`msgContent` 应为模板参数 JSON。`serviceType` 为 `2` 时使用腾讯云,多个模板参数继续沿用原项目的 `|` 分隔格式。多个手机号使用英文逗号分隔,腾讯云号码应使用 E.164 格式。
|
||||||
- `serviceType=2`(腾讯云)时多个模板参数用 `|` 分隔
|
|
||||||
- 多个手机号用英文逗号分隔;腾讯云号码使用 E.164 格式
|
|
||||||
|
|
||||||
成功响应:
|
成功响应:
|
||||||
|
|
||||||
@@ -129,78 +145,62 @@ curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
|||||||
|
|
||||||
参数错误返回 HTTP 400,上游短信平台调用失败返回 HTTP 502。响应体中的 `code` 同步表示业务结果。
|
参数错误返回 HTTP 400,上游短信平台调用失败返回 HTTP 502。响应体中的 `code` 同步表示业务结果。
|
||||||
|
|
||||||
|
## 测试实例
|
||||||
|
|
||||||
|
运行不访问真实短信平台的 HTTP 接口测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
运行示例客户端(先启动服务并替换示例中的测试凭证):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./examples/http_client
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以传入其他服务地址:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./examples/http_client http://127.0.0.1:9090/api/v1/sms/send
|
||||||
|
```
|
||||||
|
|
||||||
## gRPC 接口
|
## gRPC 接口
|
||||||
|
|
||||||
接口名:`PbNotice.Notice/Send`。
|
迁移后的接口保持原服务 protobuf 兼容:
|
||||||
|
|
||||||
请求中的 `config` 和 `params` 是 JSON 编码的 bytes,字段内容与 HTTP 接口一致;响应结果放在 `head.code` 和 `head.message`,`routerId`、`traceId` 原样返回。
|
```text
|
||||||
|
PbNotice.Notice/Send
|
||||||
|
```
|
||||||
|
|
||||||
protobuf 源文件位于 `proto/`,生成的客户端与服务端代码位于 `api/pb/`。
|
请求中的 `config` 和 `params` 是 JSON 编码的 bytes,字段内容与 HTTP 接口一致。
|
||||||
|
响应结果放在 `head.code` 和 `head.message` 中;`routerId`、`traceId` 会原样返回。
|
||||||
|
protobuf 源文件位于 `proto`,生成的客户端和服务端代码位于 `api/pb`。
|
||||||
|
|
||||||
|
使用 Go 示例客户端发送请求:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./examples/grpc_client examples/requests/aliyun.local.json
|
||||||
|
go run ./examples/grpc_client examples/requests/tencent.local.json 127.0.0.1:11200
|
||||||
|
```
|
||||||
|
|
||||||
|
服务注册了 gRPC reflection,可查看线上接口:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grpcurl -plaintext 127.0.0.1:11200 list
|
grpcurl -plaintext 127.0.0.1:11200 list
|
||||||
grpcurl -plaintext 127.0.0.1:11200 describe PbNotice.Notice
|
grpcurl -plaintext 127.0.0.1:11200 describe PbNotice.Notice
|
||||||
```
|
```
|
||||||
|
|
||||||
## 容器化部署
|
已保存的 HTTP 请求示例位于 `examples/requests`。其中 `aliyun.json` 和
|
||||||
|
`tencent.json` 使用环境变量占位符,可用于文档和版本管理;同目录的
|
||||||
|
`*.local.json` 是包含本机测试凭证的可直接重放请求,已被 `.gitignore` 排除。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t iotlcm-sms:latest -f build/Dockerfile .
|
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary @examples/requests/aliyun.local.json
|
||||||
|
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary @examples/requests/tencent.local.json
|
||||||
```
|
```
|
||||||
|
|
||||||
生产部署推荐用不可变版本标签:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
IOTLCM_SMS_IMAGE=<your-registry>/iotlcm-sms:<commit> docker compose -f deploy/docker-compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
`deploy/docker-compose.yml` 的约定:
|
|
||||||
|
|
||||||
- HTTP 端口 `11190`,gRPC 端口 `11200`
|
|
||||||
- 真实凭证放在 `deploy/config.yaml`(**不入库**),以只读方式挂载到 `/app/config/config.yaml`
|
|
||||||
- 容器 `read_only: true`,仅 `/tmp` 为 tmpfs,启用 `no-new-privileges`
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
iotlcm-sms/
|
|
||||||
├── main.go # HTTP + gRPC 装配、环境变量注入
|
|
||||||
├── api/pb/ # 生成的 protobuf 代码
|
|
||||||
├── proto/ # protobuf 源文件
|
|
||||||
├── internal/sms/ # Provider 接口 + Registry + 供应商实现
|
|
||||||
│ ├── aliyun/ # 阿里云实现
|
|
||||||
│ └── tencent/ # 腾讯云实现
|
|
||||||
├── internal/httpapi/ # HTTP 路由与处理器
|
|
||||||
├── internal/grpcapi/ # gRPC 服务实现
|
|
||||||
├── examples/ # HTTP / gRPC 示例客户端与请求样例
|
|
||||||
├── tools/ # 辅助工具(如列出阿里云模板)
|
|
||||||
├── build/Dockerfile # 多阶段构建
|
|
||||||
└── deploy/ # docker-compose 编排
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
`go test ./...` 不访问真实短信平台。
|
|
||||||
|
|
||||||
示例客户端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./examples/http_client
|
|
||||||
go run ./examples/http_client http://127.0.0.1:9090/api/v1/sms/send
|
|
||||||
go run ./examples/grpc_client examples/requests/aliyun.local.json
|
|
||||||
go run ./examples/grpc_client examples/requests/tencent.local.json 127.0.0.1:11200
|
|
||||||
```
|
|
||||||
|
|
||||||
`examples/requests/aliyun.json`、`tencent.json` 使用环境变量占位符,可入库;同目录的 `*.local.json` 是含本机测试凭证的可直接重放请求,已被 `.gitignore` 排除。
|
|
||||||
|
|
||||||
## 相关项目
|
|
||||||
|
|
||||||
本服务是 [iotlcm](https://git.5iot.com/iotlcm-public) 物联网卡全生命周期管理平台的一部分,负责向客户与设备侧下发短信通知(到期提醒、告警通知等)。
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[Apache License 2.0](./LICENSE)
|
|
||||||
|
|||||||
+1
-1
@@ -153,7 +153,7 @@ const file_common_proto_rawDesc = "" +
|
|||||||
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
|
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
|
||||||
"\amessage\x18\x02 \x01(\tR\amessage\x12\x1b\n" +
|
"\amessage\x18\x02 \x01(\tR\amessage\x12\x1b\n" +
|
||||||
"\trouter_id\x18\x03 \x01(\x03R\brouterId\x12\x19\n" +
|
"\trouter_id\x18\x03 \x01(\x03R\brouterId\x12\x19\n" +
|
||||||
"\btrace_id\x18\x04 \x01(\tR\atraceIdB\x16Z\x14iotlcm-sms/api/pb;pbb\x06proto3"
|
"\btrace_id\x18\x04 \x01(\tR\atraceIdB\x31Z\x2fgit.5iot.com/iotlcm-public/iotlcm-sms/api/pb;pbb\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_common_proto_rawDescOnce sync.Once
|
file_common_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
+1
-1
@@ -137,7 +137,7 @@ const file_notice_proto_rawDesc = "" +
|
|||||||
"\aSendRes\x12%\n" +
|
"\aSendRes\x12%\n" +
|
||||||
"\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head28\n" +
|
"\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head28\n" +
|
||||||
"\x06Notice\x12.\n" +
|
"\x06Notice\x12.\n" +
|
||||||
"\x04Send\x12\x11.PbNotice.SendReq\x1a\x11.PbNotice.SendRes\"\x00B\x16Z\x14iotlcm-sms/api/pb;pbb\x06proto3"
|
"\x04Send\x12\x11.PbNotice.SendReq\x1a\x11.PbNotice.SendRes\"\x00B\x31Z\x2fgit.5iot.com/iotlcm-public/iotlcm-sms/api/pb;pbb\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_notice_proto_rawDescOnce sync.Once
|
file_notice_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
FROM golang:1.25-alpine AS builder
|
ARG BASE_REGISTRY=docker.io/library
|
||||||
|
FROM ${BASE_REGISTRY}/golang:1.25-alpine AS builder
|
||||||
|
|
||||||
LABEL stage=gobuilder
|
LABEL stage=gobuilder
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ RUN CGO_ENABLED=0 go build -trimpath \
|
|||||||
-ldflags="-s -w -X main.BuildVersion=${BuildVersion} -X main.BuildTime=${BuildTime} -X main.CommitID=${CommitID}" \
|
-ldflags="-s -w -X main.BuildVersion=${BuildVersion} -X main.BuildTime=${BuildTime} -X main.CommitID=${CommitID}" \
|
||||||
-o /out/iotlcm-sms .
|
-o /out/iotlcm-sms .
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM ${BASE_REGISTRY}/alpine:3.23
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -13,8 +13,13 @@ serviceType: "1" # 1=阿里云,2=腾讯云
|
|||||||
regionId: cn-hangzhou
|
regionId: cn-hangzhou
|
||||||
accessKeyId: ""
|
accessKeyId: ""
|
||||||
accessKeySecret: ""
|
accessKeySecret: ""
|
||||||
signName: "示例签名"
|
signName: "示例企业"
|
||||||
# 模板 ID 由调用方按业务场景传入(通常来自框架系统参数),此处留空即可。
|
# 模板 ID 由调用方按业务场景传入(通常来自框架系统参数),此处留空即可。
|
||||||
# templateId: ""
|
# templateId: ""
|
||||||
# 仅腾讯云需要:
|
# 仅腾讯云需要:
|
||||||
# sdkAppId: ""
|
# sdkAppId: ""
|
||||||
|
|
||||||
|
# 场景模板配置库(可选,表 t_sms_template_config):按 scene 回填模板/签名,改库即生效。
|
||||||
|
# DSN 留空或省略本节 = 关闭该能力;真实 DSN 只放服务器 config.yaml,不要提交 Git。
|
||||||
|
# sceneDbDsn: "user:pass@tcp(127.0.0.1:3306)/db_iotlcm?charset=utf8mb4&parseTime=true&loc=Local"
|
||||||
|
# sceneDbCacheTtl: 60s
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
iotlcm-sms:
|
iotlcm-sms:
|
||||||
# 默认使用本地构建的镜像;推到自己的镜像仓库时用 IOTLCM_SMS_IMAGE 指定。
|
|
||||||
image: ${IOTLCM_SMS_IMAGE:-iotlcm-sms:latest}
|
image: ${IOTLCM_SMS_IMAGE:-iotlcm-sms:latest}
|
||||||
container_name: iotlcm-sms
|
container_name: iotlcm-sms
|
||||||
hostname: iotlcm-sms
|
hostname: iotlcm-sms
|
||||||
@@ -12,7 +11,10 @@ services:
|
|||||||
# 默认凭证来自 config.yaml(只读挂载);如需临时覆盖可用下面的环境变量。
|
# 默认凭证来自 config.yaml(只读挂载);如需临时覆盖可用下面的环境变量。
|
||||||
# SMS_ACCESS_KEY_ID: ""
|
# SMS_ACCESS_KEY_ID: ""
|
||||||
# SMS_ACCESS_KEY_SECRET: ""
|
# SMS_ACCESS_KEY_SECRET: ""
|
||||||
# SMS_SIGN_NAME: "示例签名"
|
# SMS_SIGN_NAME: "示例企业"
|
||||||
|
# 场景模板配置库(可选):默认读 config.yaml 的 sceneDbDsn,也可用环境变量临时覆盖。
|
||||||
|
# SMS_DB_DSN: ""
|
||||||
|
# SMS_DB_CONFIG_CACHE_TTL: 60s
|
||||||
volumes:
|
volumes:
|
||||||
# 真实凭证文件只放服务器,不提交仓库。
|
# 真实凭证文件只放服务器,不提交仓库。
|
||||||
- ./config.yaml:/app/config/config.yaml:ro
|
- ./config.yaml:/app/config/config.yaml:ro
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func main() {
|
|||||||
"templateId": "SMS_000000001"
|
"templateId": "SMS_000000001"
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"phoneNumber": "138****8000",
|
"phoneNumber": "13800008000",
|
||||||
"msgContent": "{\"code\":\"123456\"}"
|
"msgContent": "{\"code\":\"123456\"}"
|
||||||
}
|
}
|
||||||
}`)
|
}`)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"serviceType": "1",
|
"serviceType": "1",
|
||||||
"regionId": "cn-hangzhou",
|
"regionId": "cn-qingdao",
|
||||||
"accessKeyId": "${ALIYUN_ACCESS_KEY_ID}",
|
"accessKeyId": "${ALIYUN_ACCESS_KEY_ID}",
|
||||||
"accessKeySecret": "${ALIYUN_ACCESS_KEY_SECRET}",
|
"accessKeySecret": "${ALIYUN_ACCESS_KEY_SECRET}",
|
||||||
"signName": "示例签名",
|
"signName": "示例企业",
|
||||||
"templateId": "SMS_000000000"
|
"templateId": "SMS_305105966"
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"phoneNumber": "138****8000",
|
"phoneNumber": "18600001858",
|
||||||
"msgContent": "{\"code\":\"123456\"}"
|
"msgContent": "{\"code\":\"482731\"}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"serviceType": "2",
|
"serviceType": "2",
|
||||||
"sdkAppId": "1400000000",
|
"sdkAppId": "1400929370",
|
||||||
"accessKeyId": "${TENCENTCLOUD_SECRET_ID}",
|
"accessKeyId": "${TENCENTCLOUD_SECRET_ID}",
|
||||||
"accessKeySecret": "${TENCENTCLOUD_SECRET_KEY}",
|
"accessKeySecret": "${TENCENTCLOUD_SECRET_KEY}",
|
||||||
"signName": "示例签名",
|
"signName": "示例企业",
|
||||||
"templateId": "000000"
|
"templateId": "2234038"
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"phoneNumber": "138****8000",
|
"phoneNumber": "+8618610281858",
|
||||||
"msgContent": "123456|5"
|
"msgContent": "482731|5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107
|
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107
|
||||||
|
github.com/go-sql-driver/mysql v1.10.1
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.71
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.71
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.57
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.57
|
||||||
google.golang.org/grpc v1.82.1
|
google.golang.org/grpc v1.82.1
|
||||||
@@ -12,6 +13,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
|
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
|
||||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||||
@@ -16,6 +18,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestSend(t *testing.T) {
|
|||||||
defer closeServer()
|
defer closeServer()
|
||||||
|
|
||||||
config, _ := json.Marshal(sms.Config{ServiceType: sms.ServiceAliyun})
|
config, _ := json.Marshal(sms.Config{ServiceType: sms.ServiceAliyun})
|
||||||
params, _ := json.Marshal(sms.Message{PhoneNumber: "138****8000", MsgContent: `{"code":"123456"}`})
|
params, _ := json.Marshal(sms.Message{PhoneNumber: "18600001858", MsgContent: `{"code":"123456"}`})
|
||||||
response, err := client.Send(context.Background(), &pb.SendReq{
|
response, err := client.Send(context.Background(), &pb.SendReq{
|
||||||
Head: &pb.HeadReq{RouterId: 42, TraceId: "trace-1"},
|
Head: &pb.HeadReq{RouterId: 42, TraceId: "trace-1"},
|
||||||
Config: config,
|
Config: config,
|
||||||
@@ -46,7 +46,7 @@ func TestSend(t *testing.T) {
|
|||||||
if response.Head.Code != 0 || response.Head.RouterId != 42 || response.Head.TraceId != "trace-1" {
|
if response.Head.Code != 0 || response.Head.RouterId != 42 || response.Head.TraceId != "trace-1" {
|
||||||
t.Fatalf("unexpected response: %+v", response.Head)
|
t.Fatalf("unexpected response: %+v", response.Head)
|
||||||
}
|
}
|
||||||
if fake.config.ServiceType != sms.ServiceAliyun || fake.message.PhoneNumber != "138****8000" {
|
if fake.config.ServiceType != sms.ServiceAliyun || fake.message.PhoneNumber != "18600001858" {
|
||||||
t.Fatalf("unexpected request: config=%+v message=%+v", fake.config, fake.message)
|
t.Fatalf("unexpected request: config=%+v message=%+v", fake.config, fake.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (h *handler) send(w http.ResponseWriter, r *http.Request) {
|
|||||||
result, err := h.sender.Send(r.Context(), request.Config, request.Params)
|
result, err := h.sender.Send(r.Context(), request.Config, request.Params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := http.StatusBadGateway
|
status := http.StatusBadGateway
|
||||||
if errors.Is(err, sms.ErrInvalidRequest) || errors.Is(err, sms.ErrProviderNotFound) {
|
if errors.Is(err, sms.ErrInvalidRequest) || errors.Is(err, sms.ErrProviderNotFound) || errors.Is(err, sms.ErrSceneNotConfigured) {
|
||||||
status = http.StatusBadRequest
|
status = http.StatusBadRequest
|
||||||
}
|
}
|
||||||
writeJSON(w, status, result)
|
writeJSON(w, status, result)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestSendSuccess(t *testing.T) {
|
|||||||
server := httptest.NewServer(NewHandler(fake))
|
server := httptest.NewServer(NewHandler(fake))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
body := `{"config":{"serviceType":"1","accessKeyId":"id","accessKeySecret":"secret","signName":"测试签名","templateId":"SMS_1"},"params":{"phoneNumber":"138****8000","msgContent":"{\"code\":\"123456\"}"}}`
|
body := `{"config":{"serviceType":"1","accessKeyId":"id","accessKeySecret":"secret","signName":"测试签名","templateId":"SMS_1"},"params":{"phoneNumber":"13800008000","msgContent":"{\"code\":\"123456\"}"}}`
|
||||||
response, err := http.Post(server.URL+"/api/v1/sms/send", "application/json", strings.NewReader(body))
|
response, err := http.Post(server.URL+"/api/v1/sms/send", "application/json", strings.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -40,7 +40,7 @@ func TestSendSuccess(t *testing.T) {
|
|||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
|
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
|
||||||
}
|
}
|
||||||
if fake.config.ServiceType != sms.ServiceAliyun || fake.message.PhoneNumber != "138****8000" {
|
if fake.config.ServiceType != sms.ServiceAliyun || fake.message.PhoneNumber != "13800008000" {
|
||||||
t.Fatalf("unexpected request: config=%+v message=%+v", fake.config, fake.message)
|
t.Fatalf("unexpected request: config=%+v message=%+v", fake.config, fake.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-25
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -48,41 +49,54 @@ func configFileCandidates() (paths []string, explicit bool) {
|
|||||||
// 返回的第二个值为实际命中的路径;未命中时路径为空。
|
// 返回的第二个值为实际命中的路径;未命中时路径为空。
|
||||||
// 显式指定 SMS_CONFIG_FILE 但读取或解析失败时返回错误,避免静默使用空凭证。
|
// 显式指定 SMS_CONFIG_FILE 但读取或解析失败时返回错误,避免静默使用空凭证。
|
||||||
func LoadDefaultConfigFromFile() (Config, string, error) {
|
func LoadDefaultConfigFromFile() (Config, string, error) {
|
||||||
paths, explicit := configFileCandidates()
|
data, path, err := readDefaultConfigFile()
|
||||||
for _, path := range paths {
|
|
||||||
info, statErr := os.Stat(path)
|
|
||||||
if statErr != nil {
|
|
||||||
if explicit {
|
|
||||||
return Config{}, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Docker 在挂载源不存在时会自动创建同名目录;必须显式报错,
|
|
||||||
// 否则会静默回退到空凭证,表现为短信发送全部失败。
|
|
||||||
if info.IsDir() {
|
|
||||||
return Config{}, "", fmt.Errorf(
|
|
||||||
"配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+
|
|
||||||
"请在服务器创建该文件后重新挂载", path)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if explicit {
|
return Config{}, "", err
|
||||||
return Config{}, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
|
||||||
}
|
}
|
||||||
continue
|
if path == "" {
|
||||||
|
return Config{}, "", nil
|
||||||
}
|
}
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return Config{}, "", fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
return Config{}, "", fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||||
}
|
}
|
||||||
return cfg, path, nil
|
return cfg, path, nil
|
||||||
}
|
|
||||||
if explicit {
|
|
||||||
return Config{}, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0])
|
|
||||||
}
|
|
||||||
return Config{}, "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readDefaultConfigFile 按候选路径查找并读取配置文件,返回文件内容与命中路径。
|
||||||
|
// 未命中任何候选文件时返回空内容与空路径(不视为错误);
|
||||||
|
// 显式指定 SMS_CONFIG_FILE 时任何失败都返回错误,避免静默回退。
|
||||||
|
func readDefaultConfigFile() ([]byte, string, error) {
|
||||||
|
paths, explicit := configFileCandidates()
|
||||||
|
for _, path := range paths {
|
||||||
|
info, statErr := os.Stat(path)
|
||||||
|
if statErr != nil {
|
||||||
|
if explicit {
|
||||||
|
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Docker 在挂载源不存在时会自动创建同名目录;必须显式报错,
|
||||||
|
// 否则会静默回退到空凭证,表现为短信发送全部失败。
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+
|
||||||
|
"请在服务器创建该文件后重新挂载", path)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if explicit {
|
||||||
|
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return data, path, nil
|
||||||
|
}
|
||||||
|
if explicit {
|
||||||
|
return nil, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0])
|
||||||
|
}
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
// envOverrides 定义环境变量到配置字段的映射,环境变量优先于配置文件。
|
// envOverrides 定义环境变量到配置字段的映射,环境变量优先于配置文件。
|
||||||
var envOverrides = []struct {
|
var envOverrides = []struct {
|
||||||
name string
|
name string
|
||||||
@@ -122,3 +136,46 @@ func LoadDefaultConfig() (Config, ConfigSource, error) {
|
|||||||
}
|
}
|
||||||
return cfg, ConfigSource{File: path, Env: envApplied}, nil
|
return cfg, ConfigSource{File: path, Env: envApplied}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SceneDbConfig 场景模板配置库(表 t_sms_template_config)连接参数。
|
||||||
|
// DSN 为空表示未启用场景库能力:Send 收到带 scene 的请求时返回 ErrSceneNotConfigured。
|
||||||
|
type SceneDbConfig struct {
|
||||||
|
DSN string `yaml:"sceneDbDsn"`
|
||||||
|
CacheTTL string `yaml:"sceneDbCacheTtl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSceneDbCacheTTL 场景配置缓存默认时长。
|
||||||
|
const DefaultSceneDbCacheTTL = 60 * time.Second
|
||||||
|
|
||||||
|
// TTL 返回解析后的缓存时长;非法或非正值回退默认值。
|
||||||
|
func (c SceneDbConfig) TTL() time.Duration {
|
||||||
|
if v, err := time.ParseDuration(strings.TrimSpace(c.CacheTTL)); err == nil && v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return DefaultSceneDbCacheTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSceneDbConfig 从默认配置文件读取场景库参数(与凭证同一份 config.yaml),环境变量可覆盖。
|
||||||
|
// 未命中配置文件不报错(场景库能力可选);解析失败或显式指定的文件不存在才返回错误。
|
||||||
|
func LoadSceneDbConfig() (SceneDbConfig, ConfigSource, error) {
|
||||||
|
data, path, err := readDefaultConfigFile()
|
||||||
|
if err != nil {
|
||||||
|
return SceneDbConfig{}, ConfigSource{}, err
|
||||||
|
}
|
||||||
|
sc := SceneDbConfig{}
|
||||||
|
if path != "" {
|
||||||
|
if err := yaml.Unmarshal(data, &sc); err != nil {
|
||||||
|
return SceneDbConfig{}, ConfigSource{}, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
envs := make([]string, 0, 2)
|
||||||
|
if v := strings.TrimSpace(os.Getenv("SMS_DB_DSN")); v != "" {
|
||||||
|
sc.DSN = v
|
||||||
|
envs = append(envs, "SMS_DB_DSN")
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("SMS_DB_CONFIG_CACHE_TTL")); v != "" {
|
||||||
|
sc.CacheTTL = v
|
||||||
|
envs = append(envs, "SMS_DB_CONFIG_CACHE_TTL")
|
||||||
|
}
|
||||||
|
return sc, ConfigSource{File: path, Env: envs}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func TestLoadDefaultConfig_FileThenEnvOverride(t *testing.T) {
|
func TestLoadDefaultConfig_FileThenEnvOverride(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
content := "serviceType: \"1\"\nregionId: cn-qingdao\naccessKeyId: file-ak\naccessKeySecret: file-sk\nsignName: 示例签名\n"
|
content := "serviceType: \"1\"\nregionId: cn-qingdao\naccessKeyId: file-ak\naccessKeySecret: file-sk\nsignName: 示例企业\n"
|
||||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -38,8 +38,8 @@ func TestLoadDefaultConfig_FileThenEnvOverride(t *testing.T) {
|
|||||||
if cfg.RegionID != "cn-qingdao" {
|
if cfg.RegionID != "cn-qingdao" {
|
||||||
t.Errorf("RegionID = %q, want cn-qingdao", cfg.RegionID)
|
t.Errorf("RegionID = %q, want cn-qingdao", cfg.RegionID)
|
||||||
}
|
}
|
||||||
if cfg.SignName != "示例签名" {
|
if cfg.SignName != "示例企业" {
|
||||||
t.Errorf("SignName = %q, want 示例签名", cfg.SignName)
|
t.Errorf("SignName = %q, want 示例企业", cfg.SignName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ func TestLoadDefaultConfig_NoFile(t *testing.T) {
|
|||||||
func TestLoadDefaultConfig_RegionDefault(t *testing.T) {
|
func TestLoadDefaultConfig_RegionDefault(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
if err := os.WriteFile(path, []byte("signName: 示例签名\n"), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte("signName: 示例企业\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Setenv("SMS_CONFIG_FILE", path)
|
t.Setenv("SMS_CONFIG_FILE", path)
|
||||||
@@ -70,12 +70,12 @@ func TestLoadDefaultConfig_RegionDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeConfig_CallerWinsThenDefault(t *testing.T) {
|
func TestMergeConfig_CallerWinsThenDefault(t *testing.T) {
|
||||||
def := Config{ServiceType: "1", RegionID: "cn-hangzhou", AccessKeyID: "ak", SignName: "示例签名"}
|
def := Config{ServiceType: "1", RegionID: "cn-hangzhou", AccessKeyID: "ak", SignName: "示例企业"}
|
||||||
got := mergeConfig(def, Config{TemplateID: "SMS_298990049"})
|
got := mergeConfig(def, Config{TemplateID: "SMS_298990049"})
|
||||||
if got.TemplateID != "SMS_298990049" {
|
if got.TemplateID != "SMS_298990049" {
|
||||||
t.Errorf("TemplateID = %q, want SMS_298990049", got.TemplateID)
|
t.Errorf("TemplateID = %q, want SMS_298990049", got.TemplateID)
|
||||||
}
|
}
|
||||||
if got.AccessKeyID != "ak" || got.SignName != "示例签名" {
|
if got.AccessKeyID != "ak" || got.SignName != "示例企业" {
|
||||||
t.Errorf("凭证/签名未从默认配置回退: %+v", got)
|
t.Errorf("凭证/签名未从默认配置回退: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package sms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 场景配置启用状态。
|
||||||
|
const sceneStatusEnabled = 1
|
||||||
|
|
||||||
|
// SceneConfig 是「业务场景 -> 短信模板」的配置,来源于数据库表 t_sms_template_config。
|
||||||
|
// 运营侧修改该表后,短信服务在缓存过期后即按新配置发送,无需改代码或重启。
|
||||||
|
type SceneConfig struct {
|
||||||
|
Scene string
|
||||||
|
ServiceType string
|
||||||
|
SignName string
|
||||||
|
TemplateID string
|
||||||
|
MsgContentTemplate string
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SceneProvider 提供按场景码查询模板配置的能力。
|
||||||
|
type SceneProvider interface {
|
||||||
|
Lookup(ctx context.Context, scene string) (*SceneConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sceneQuery = "SELECT `scene`, `service_type`, `sign_name`, `template_id`, `msg_content_template`, `status` " +
|
||||||
|
"FROM `t_sms_template_config` WHERE `scene` = ? LIMIT 1"
|
||||||
|
|
||||||
|
type sceneCacheEntry struct {
|
||||||
|
config *SceneConfig
|
||||||
|
expired time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SceneStore 从数据库读取场景模板配置,并做短 TTL 内存缓存:
|
||||||
|
// 改库后最多 ttl 时间即生效,同时避免每次发送都查库。
|
||||||
|
type SceneStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
ttl time.Duration
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]sceneCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSceneStore 创建一个基于数据库的场景配置存储。ttl<=0 时使用默认 60s。
|
||||||
|
func NewSceneStore(db *sql.DB, ttl time.Duration) *SceneStore {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 60 * time.Second
|
||||||
|
}
|
||||||
|
return &SceneStore{
|
||||||
|
db: db,
|
||||||
|
ttl: ttl,
|
||||||
|
cache: make(map[string]sceneCacheEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭底层数据库连接。
|
||||||
|
func (s *SceneStore) Close() error {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup 返回指定场景的模板配置;场景未配置时返回 (nil, nil)。
|
||||||
|
// 命中与未命中都会缓存 ttl,避免对不存在的场景反复查库。
|
||||||
|
func (s *SceneStore) Lookup(ctx context.Context, scene string) (*SceneConfig, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
scene = strings.TrimSpace(scene)
|
||||||
|
if scene == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if cfg, ok := s.cacheGet(scene); ok {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var item SceneConfig
|
||||||
|
row := s.db.QueryRowContext(ctx, sceneQuery, scene)
|
||||||
|
err := row.Scan(
|
||||||
|
&item.Scene,
|
||||||
|
&item.ServiceType,
|
||||||
|
&item.SignName,
|
||||||
|
&item.TemplateID,
|
||||||
|
&item.MsgContentTemplate,
|
||||||
|
&item.Status,
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
result := item
|
||||||
|
s.cacheSet(scene, &result)
|
||||||
|
return &result, nil
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
s.cacheSet(scene, nil)
|
||||||
|
return nil, nil
|
||||||
|
default:
|
||||||
|
// 查询失败不缓存,下次请求重试。
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SceneStore) cacheGet(scene string) (*SceneConfig, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
entry, ok := s.cache[scene]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok || time.Now().After(entry.expired) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return entry.config, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SceneStore) cacheSet(scene string, cfg *SceneConfig) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cache[scene] = sceneCacheEntry{config: cfg, expired: time.Now().Add(s.ttl)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
+67
-2
@@ -2,6 +2,7 @@ package sms
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,11 +17,16 @@ const (
|
|||||||
var (
|
var (
|
||||||
ErrProviderNotFound = errors.New("未选择有效的短信发送供应商")
|
ErrProviderNotFound = errors.New("未选择有效的短信发送供应商")
|
||||||
ErrInvalidRequest = errors.New("短信请求参数不正确")
|
ErrInvalidRequest = errors.New("短信请求参数不正确")
|
||||||
|
ErrSceneNotConfigured = errors.New("短信场景未配置或已停用")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config contains the provider credentials and template configuration.
|
// Config contains the provider credentials and template configuration.
|
||||||
// serviceType: 1=Aliyun, 2=Tencent Cloud.
|
// serviceType: 1=Aliyun, 2=Tencent Cloud.
|
||||||
|
//
|
||||||
|
// Scene 为可选业务场景码(如 bind_phone)。当 Scene 非空时,短信服务会按该场景从数据库
|
||||||
|
// 配置表读取模板ID/签名等配置并回填到本次请求,调用方无需关心具体模板。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Scene string `json:"scene" yaml:"scene"`
|
||||||
ServiceType string `json:"serviceType" yaml:"serviceType"`
|
ServiceType string `json:"serviceType" yaml:"serviceType"`
|
||||||
RegionID string `json:"regionId" yaml:"regionId"`
|
RegionID string `json:"regionId" yaml:"regionId"`
|
||||||
AccessKeyID string `json:"accessKeyId" yaml:"accessKeyId"`
|
AccessKeyID string `json:"accessKeyId" yaml:"accessKeyId"`
|
||||||
@@ -49,6 +55,7 @@ type Registry struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
providers map[string]Provider
|
providers map[string]Provider
|
||||||
defaultConfig Config
|
defaultConfig Config
|
||||||
|
scenes SceneProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry 创建一个无默认配置的注册表。
|
// NewRegistry 创建一个无默认配置的注册表。
|
||||||
@@ -58,8 +65,9 @@ func NewRegistry() *Registry {
|
|||||||
|
|
||||||
// NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段
|
// NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段
|
||||||
// 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。
|
// 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。
|
||||||
func NewRegistryWithDefault(dc Config) *Registry {
|
// scenes 为可选的场景配置提供方(按 scene 从数据库读取模板配置),可为 nil。
|
||||||
return &Registry{providers: make(map[string]Provider), defaultConfig: dc}
|
func NewRegistryWithDefault(dc Config, scenes SceneProvider) *Registry {
|
||||||
|
return &Registry{providers: make(map[string]Provider), defaultConfig: dc, scenes: scenes}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) Register(serviceType string, provider Provider) {
|
func (r *Registry) Register(serviceType string, provider Provider) {
|
||||||
@@ -69,6 +77,19 @@ func (r *Registry) Register(serviceType string, provider Provider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) Send(ctx context.Context, config Config, message Message) (Result, error) {
|
func (r *Registry) Send(ctx context.Context, config Config, message Message) (Result, error) {
|
||||||
|
// 按业务场景加载模板配置(来自数据库),实现“改配置不改代码、不重启服务”。
|
||||||
|
if scene := strings.TrimSpace(config.Scene); scene != "" {
|
||||||
|
sceneCfg, err := r.loadSceneConfig(ctx, scene)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSceneNotConfigured) {
|
||||||
|
return Result{Code: 400, Message: err.Error()}, err
|
||||||
|
}
|
||||||
|
return Result{Code: 500, Message: "查询短信场景配置失败: " + err.Error()}, err
|
||||||
|
}
|
||||||
|
config = mergeSceneConfig(sceneCfg, config)
|
||||||
|
message.MsgContent = renderSceneMsgContent(sceneCfg.MsgContentTemplate, message.MsgContent)
|
||||||
|
}
|
||||||
|
|
||||||
// 回退默认值:请求中未提供的字段使用短信服务自身的默认配置。
|
// 回退默认值:请求中未提供的字段使用短信服务自身的默认配置。
|
||||||
config = mergeConfig(r.defaultConfig, config)
|
config = mergeConfig(r.defaultConfig, config)
|
||||||
if err := Validate(config, message); err != nil {
|
if err := Validate(config, message); err != nil {
|
||||||
@@ -84,6 +105,50 @@ func (r *Registry) Send(ctx context.Context, config Config, message Message) (Re
|
|||||||
return provider.Send(ctx, config, message)
|
return provider.Send(ctx, config, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadSceneConfig 按场景码加载模板配置,并校验场景已启用。
|
||||||
|
func (r *Registry) loadSceneConfig(ctx context.Context, scene string) (SceneConfig, error) {
|
||||||
|
if r.scenes == nil {
|
||||||
|
return SceneConfig{}, fmt.Errorf("%w: %s", ErrSceneNotConfigured, scene)
|
||||||
|
}
|
||||||
|
cfg, err := r.scenes.Lookup(ctx, scene)
|
||||||
|
if err != nil {
|
||||||
|
return SceneConfig{}, err
|
||||||
|
}
|
||||||
|
if cfg == nil || cfg.Status != sceneStatusEnabled {
|
||||||
|
return SceneConfig{}, fmt.Errorf("%w: %s", ErrSceneNotConfigured, scene)
|
||||||
|
}
|
||||||
|
return *cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeSceneConfig 用场景配置填充请求中未显式提供的字段,调用方显式值优先。
|
||||||
|
func mergeSceneConfig(scene SceneConfig, config Config) Config {
|
||||||
|
if strings.TrimSpace(config.ServiceType) == "" {
|
||||||
|
config.ServiceType = scene.ServiceType
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.SignName) == "" {
|
||||||
|
config.SignName = scene.SignName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.TemplateID) == "" {
|
||||||
|
config.TemplateID = scene.TemplateID
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSceneMsgContent 按场景内容模板渲染短信参数;模板为空时直接使用调用方内容。
|
||||||
|
// 支持 {content}(原样)与 {contentJson}(JSON 转义)两种占位符。
|
||||||
|
func renderSceneMsgContent(template string, content string) string {
|
||||||
|
template = strings.TrimSpace(template)
|
||||||
|
if template == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
contentJSON, _ := json.Marshal(content)
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"{content}", content,
|
||||||
|
"{contentJson}", string(contentJSON),
|
||||||
|
)
|
||||||
|
return replacer.Replace(template)
|
||||||
|
}
|
||||||
|
|
||||||
func Validate(config Config, message Message) error {
|
func Validate(config Config, message Message) error {
|
||||||
missing := make([]string, 0, 6)
|
missing := make([]string, 0, 6)
|
||||||
if strings.TrimSpace(config.ServiceType) == "" {
|
if strings.TrimSpace(config.ServiceType) == "" {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestRegistrySend(t *testing.T) {
|
|||||||
ServiceType: ServiceAliyun, AccessKeyID: "id", AccessKeySecret: "secret",
|
ServiceType: ServiceAliyun, AccessKeyID: "id", AccessKeySecret: "secret",
|
||||||
SignName: "sign", TemplateID: "template",
|
SignName: "sign", TemplateID: "template",
|
||||||
}
|
}
|
||||||
result, err := registry.Send(context.Background(), config, Message{PhoneNumber: "138****8000"})
|
result, err := registry.Send(context.Background(), config, Message{PhoneNumber: "13800008000"})
|
||||||
if err != nil || result.Code != 0 || !provider.called {
|
if err != nil || result.Code != 0 || !provider.called {
|
||||||
t.Fatalf("result=%+v err=%v called=%v", result, err, provider.called)
|
t.Fatalf("result=%+v err=%v called=%v", result, err, provider.called)
|
||||||
}
|
}
|
||||||
@@ -36,8 +36,8 @@ func TestRegistryValidatesBeforeProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitAndTrim(t *testing.T) {
|
func TestSplitAndTrim(t *testing.T) {
|
||||||
got := SplitAndTrim(" 138****8000, ,138****8000 ", ",")
|
got := SplitAndTrim(" 13800008000, ,13900009000 ", ",")
|
||||||
if len(got) != 2 || got[0] != "138****8000" || got[1] != "138****8000" {
|
if len(got) != 2 || got[0] != "13800008000" || got[1] != "13900009000" {
|
||||||
t.Fatalf("unexpected split result: %#v", got)
|
t.Fatalf("unexpected split result: %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/reflection"
|
"google.golang.org/grpc/reflection"
|
||||||
|
|
||||||
@@ -41,7 +43,32 @@ func main() {
|
|||||||
log.Fatalf("load sms default config: %v", err)
|
log.Fatalf("load sms default config: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("sms default config loaded: %s", source.Describe())
|
log.Printf("sms default config loaded: %s", source.Describe())
|
||||||
providers := sms.NewRegistryWithDefault(defaultConfig)
|
|
||||||
|
// 场景模板配置来自数据库(表 t_sms_template_config):改库即生效,无需改代码或重启。
|
||||||
|
// 连接参数与凭证同源于 config.yaml(SMS_DB_DSN 等环境变量可覆盖);DSN 为空时能力关闭。
|
||||||
|
var scenes sms.SceneProvider
|
||||||
|
var sceneStore *sms.SceneStore
|
||||||
|
sceneDb, sceneDbSrc, err := sms.LoadSceneDbConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("load scene db config: %v(场景模板能力关闭)", err)
|
||||||
|
} else if sceneDb.DSN == "" {
|
||||||
|
log.Printf("scene db DSN not set, scene-based template config disabled")
|
||||||
|
} else {
|
||||||
|
db, err := sql.Open("mysql", sceneDb.DSN)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open sms config database: %v", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(4)
|
||||||
|
db.SetMaxIdleConns(2)
|
||||||
|
db.SetConnMaxLifetime(time.Hour)
|
||||||
|
ttl := sceneDb.TTL()
|
||||||
|
sceneStore = sms.NewSceneStore(db, ttl)
|
||||||
|
defer func() { _ = sceneStore.Close() }()
|
||||||
|
scenes = sceneStore
|
||||||
|
log.Printf("sms scene config store enabled (source: %s), cache ttl=%s", sceneDbSrc.Describe(), ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := sms.NewRegistryWithDefault(defaultConfig, scenes)
|
||||||
providers.Register(sms.ServiceAliyun, aliyun.New())
|
providers.Register(sms.ServiceAliyun, aliyun.New())
|
||||||
providers.Register(sms.ServiceTencent, tencent.New())
|
providers.Register(sms.ServiceTencent, tencent.New())
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package PbCommon;
|
package PbCommon;
|
||||||
|
|
||||||
option go_package = "iotlcm-sms/api/pb;pb";
|
option go_package = "git.5iot.com/iotlcm-public/iotlcm-sms/api/pb;pb";
|
||||||
|
|
||||||
message HeadReq {
|
message HeadReq {
|
||||||
int64 router_id = 1;
|
int64 router_id = 1;
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ package PbNotice;
|
|||||||
|
|
||||||
import "common.proto";
|
import "common.proto";
|
||||||
|
|
||||||
option go_package = "iotlcm-sms/api/pb;pb";
|
option go_package = "git.5iot.com/iotlcm-public/iotlcm-sms/api/pb;pb";
|
||||||
|
|
||||||
service Notice {
|
service Notice {
|
||||||
rpc Send(SendReq) returns (SendRes) {}
|
rpc Send(SendReq) returns (SendRes) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user