sync: 同步短信服务 v1.0.4 并脱敏

This commit is contained in:
MiFaZhan
2026-10-29 14:17:14 +08:00
parent 6c26f7a0e1
commit e6997f51ed
22 changed files with 450 additions and 168 deletions
+118 -118
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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\"}"
} }
}`) }`)
+5 -5
View File
@@ -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\"}"
} }
} }
+5 -5
View File
@@ -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"
} }
} }
+2
View File
@@ -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
+4
View File
@@ -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=
+2 -2
View File
@@ -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)
} }
} }
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
}
+6 -6
View File
@@ -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)
} }
} }
+119
View File
@@ -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
View File
@@ -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) == "" {
+3 -3
View File
@@ -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)
} }
} }
+28 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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) {}