From e6997f51ede35adf3922894b87ba191b4a418c00 Mon Sep 17 00:00:00 2001 From: MiFaZhan Date: Thu, 29 Oct 2026 14:17:14 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20=E5=90=8C=E6=AD=A5=E7=9F=AD=E4=BF=A1?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=20v1.0.4=20=E5=B9=B6=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 236 +++++++++++++++---------------- api/pb/common.pb.go | 2 +- api/pb/notice.pb.go | 2 +- build/Dockerfile | 5 +- config.example.yaml | 7 +- deploy/docker-compose.yml | 6 +- examples/http_client/main.go | 2 +- examples/requests/aliyun.json | 10 +- examples/requests/tencent.json | 10 +- go.mod | 2 + go.sum | 4 + internal/grpcapi/server_test.go | 4 +- internal/httpapi/handler.go | 2 +- internal/httpapi/handler_test.go | 4 +- internal/sms/config.go | 79 +++++++++-- internal/sms/config_test.go | 12 +- internal/sms/scene_store.go | 119 ++++++++++++++++ internal/sms/sms.go | 73 +++++++++- internal/sms/sms_test.go | 6 +- main.go | 29 +++- proto/common.proto | 2 +- proto/notice.proto | 2 +- 22 files changed, 450 insertions(+), 168 deletions(-) create mode 100644 internal/sms/scene_store.go diff --git a/README.md b/README.md index 59126bb..fe6b1e8 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,79 @@ # 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 go mod tidy go run . ``` -默认监听 `:8080`,gRPC 默认监听 `:11200`: +默认监听 `:8080`,可通过 `HTTP_ADDR` 修改: ```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 /iotlcm-sms:latest . +docker push /iotlcm-sms:latest + +cd deploy +# 先在服务器放置真实 config.yaml(不入库) +docker compose pull +docker compose up -d +``` + +生产部署建议通过 `IOTLCM_SMS_IMAGE` 指定不可变版本标签: + +```bash +IOTLCM_SMS_IMAGE=/iotlcm-sms: docker compose up -d ``` 健康检查: @@ -32,43 +82,11 @@ HTTP_ADDR=:9090 GRPC_ADDR=:11201 go run . 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 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" }, "params": { - "phoneNumber": "138****8000", + "phoneNumber": "13800008000", "msgContent": "{\"code\":\"123456\"}" } }' ``` -### 腾讯云 +腾讯云请求示例: ```bash 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" }, "params": { - "phoneNumber": "138****8000", + "phoneNumber": "+8613800138000", "msgContent": "123456|5" } }' ``` -- `serviceType=1`(阿里云)时 `msgContent` 为模板参数 JSON -- `serviceType=2`(腾讯云)时多个模板参数用 `|` 分隔 -- 多个手机号用英文逗号分隔;腾讯云号码使用 E.164 格式 +`serviceType` 为 `1` 时使用阿里云,`msgContent` 应为模板参数 JSON。`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 接口测试: + +```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 接口 -接口名:`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 grpcurl -plaintext 127.0.0.1:11200 list grpcurl -plaintext 127.0.0.1:11200 describe PbNotice.Notice ``` -## 容器化部署 +已保存的 HTTP 请求示例位于 `examples/requests`。其中 `aliyun.json` 和 +`tencent.json` 使用环境变量占位符,可用于文档和版本管理;同目录的 +`*.local.json` 是包含本机测试凭证的可直接重放请求,已被 `.gitignore` 排除。 ```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=/iotlcm-sms: 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) diff --git a/api/pb/common.pb.go b/api/pb/common.pb.go index c707214..64def28 100644 --- a/api/pb/common.pb.go +++ b/api/pb/common.pb.go @@ -153,7 +153,7 @@ const file_common_proto_rawDesc = "" + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1b\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 ( file_common_proto_rawDescOnce sync.Once diff --git a/api/pb/notice.pb.go b/api/pb/notice.pb.go index 5aacaba..d5e1fa1 100644 --- a/api/pb/notice.pb.go +++ b/api/pb/notice.pb.go @@ -137,7 +137,7 @@ const file_notice_proto_rawDesc = "" + "\aSendRes\x12%\n" + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head28\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 ( file_notice_proto_rawDescOnce sync.Once diff --git a/build/Dockerfile b/build/Dockerfile index 4807715..22ae5f0 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -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 @@ -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}" \ -o /out/iotlcm-sms . -FROM alpine:3.23 +FROM ${BASE_REGISTRY}/alpine:3.23 RUN apk add --no-cache ca-certificates tzdata diff --git a/config.example.yaml b/config.example.yaml index 194a9b6..34849da 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -13,8 +13,13 @@ serviceType: "1" # 1=阿里云,2=腾讯云 regionId: cn-hangzhou accessKeyId: "" accessKeySecret: "" -signName: "示例签名" +signName: "示例企业" # 模板 ID 由调用方按业务场景传入(通常来自框架系统参数),此处留空即可。 # templateId: "" # 仅腾讯云需要: # 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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f5b4ab0..8104d58 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,6 +1,5 @@ services: iotlcm-sms: - # 默认使用本地构建的镜像;推到自己的镜像仓库时用 IOTLCM_SMS_IMAGE 指定。 image: ${IOTLCM_SMS_IMAGE:-iotlcm-sms:latest} container_name: iotlcm-sms hostname: iotlcm-sms @@ -12,7 +11,10 @@ services: # 默认凭证来自 config.yaml(只读挂载);如需临时覆盖可用下面的环境变量。 # SMS_ACCESS_KEY_ID: "" # SMS_ACCESS_KEY_SECRET: "" - # SMS_SIGN_NAME: "示例签名" + # SMS_SIGN_NAME: "示例企业" + # 场景模板配置库(可选):默认读 config.yaml 的 sceneDbDsn,也可用环境变量临时覆盖。 + # SMS_DB_DSN: "" + # SMS_DB_CONFIG_CACHE_TTL: 60s volumes: # 真实凭证文件只放服务器,不提交仓库。 - ./config.yaml:/app/config/config.yaml:ro diff --git a/examples/http_client/main.go b/examples/http_client/main.go index 5b44ca0..4b6614e 100644 --- a/examples/http_client/main.go +++ b/examples/http_client/main.go @@ -26,7 +26,7 @@ func main() { "templateId": "SMS_000000001" }, "params": { - "phoneNumber": "138****8000", + "phoneNumber": "13800008000", "msgContent": "{\"code\":\"123456\"}" } }`) diff --git a/examples/requests/aliyun.json b/examples/requests/aliyun.json index eca6baa..75b4ba5 100644 --- a/examples/requests/aliyun.json +++ b/examples/requests/aliyun.json @@ -1,14 +1,14 @@ { "config": { "serviceType": "1", - "regionId": "cn-hangzhou", + "regionId": "cn-qingdao", "accessKeyId": "${ALIYUN_ACCESS_KEY_ID}", "accessKeySecret": "${ALIYUN_ACCESS_KEY_SECRET}", - "signName": "示例签名", - "templateId": "SMS_000000000" + "signName": "示例企业", + "templateId": "SMS_305105966" }, "params": { - "phoneNumber": "138****8000", - "msgContent": "{\"code\":\"123456\"}" + "phoneNumber": "18600001858", + "msgContent": "{\"code\":\"482731\"}" } } diff --git a/examples/requests/tencent.json b/examples/requests/tencent.json index 22843ab..2b027fb 100644 --- a/examples/requests/tencent.json +++ b/examples/requests/tencent.json @@ -1,14 +1,14 @@ { "config": { "serviceType": "2", - "sdkAppId": "1400000000", + "sdkAppId": "1400929370", "accessKeyId": "${TENCENTCLOUD_SECRET_ID}", "accessKeySecret": "${TENCENTCLOUD_SECRET_KEY}", - "signName": "示例签名", - "templateId": "000000" + "signName": "示例企业", + "templateId": "2234038" }, "params": { - "phoneNumber": "138****8000", - "msgContent": "123456|5" + "phoneNumber": "+8618610281858", + "msgContent": "482731|5" } } diff --git a/go.mod b/go.mod index 4d28147..6ac3168 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( 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/sms v1.3.57 google.golang.org/grpc v1.82.1 @@ -12,6 +13,7 @@ require ( ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 4a98b95..254c36e 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ 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/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= @@ -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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/internal/grpcapi/server_test.go b/internal/grpcapi/server_test.go index c1b2ac4..6fa8b1f 100644 --- a/internal/grpcapi/server_test.go +++ b/internal/grpcapi/server_test.go @@ -34,7 +34,7 @@ func TestSend(t *testing.T) { defer closeServer() 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{ Head: &pb.HeadReq{RouterId: 42, TraceId: "trace-1"}, 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" { 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) } } diff --git a/internal/httpapi/handler.go b/internal/httpapi/handler.go index 73628ea..b0efce3 100644 --- a/internal/httpapi/handler.go +++ b/internal/httpapi/handler.go @@ -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) if err != nil { 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 } writeJSON(w, status, result) diff --git a/internal/httpapi/handler_test.go b/internal/httpapi/handler_test.go index 0304e3b..f804903 100644 --- a/internal/httpapi/handler_test.go +++ b/internal/httpapi/handler_test.go @@ -30,7 +30,7 @@ func TestSendSuccess(t *testing.T) { server := httptest.NewServer(NewHandler(fake)) 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)) if err != nil { t.Fatal(err) @@ -40,7 +40,7 @@ func TestSendSuccess(t *testing.T) { if 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) } } diff --git a/internal/sms/config.go b/internal/sms/config.go index 5c03b20..598da8e 100644 --- a/internal/sms/config.go +++ b/internal/sms/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "time" "gopkg.in/yaml.v3" ) @@ -48,41 +49,54 @@ func configFileCandidates() (paths []string, explicit bool) { // 返回的第二个值为实际命中的路径;未命中时路径为空。 // 显式指定 SMS_CONFIG_FILE 但读取或解析失败时返回错误,避免静默使用空凭证。 func LoadDefaultConfigFromFile() (Config, string, error) { + data, path, err := readDefaultConfigFile() + if err != nil { + return Config{}, "", err + } + if path == "" { + return Config{}, "", nil + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, "", fmt.Errorf("解析配置文件 %s 失败: %w", path, err) + } + return cfg, path, 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 Config{}, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr) + return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr) } continue } // Docker 在挂载源不存在时会自动创建同名目录;必须显式报错, // 否则会静默回退到空凭证,表现为短信发送全部失败。 if info.IsDir() { - return Config{}, "", fmt.Errorf( + return nil, "", fmt.Errorf( "配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+ "请在服务器创建该文件后重新挂载", path) } data, err := os.ReadFile(path) if err != nil { if explicit { - return Config{}, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err) + return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err) } continue } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return Config{}, "", fmt.Errorf("解析配置文件 %s 失败: %w", path, err) - } - return cfg, path, nil + return data, path, nil } if explicit { - return Config{}, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0]) + return nil, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0]) } - return Config{}, "", nil + return nil, "", nil } - // envOverrides 定义环境变量到配置字段的映射,环境变量优先于配置文件。 var envOverrides = []struct { name string @@ -122,3 +136,46 @@ func LoadDefaultConfig() (Config, ConfigSource, error) { } 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 +} diff --git a/internal/sms/config_test.go b/internal/sms/config_test.go index 6b59202..bf29de4 100644 --- a/internal/sms/config_test.go +++ b/internal/sms/config_test.go @@ -9,7 +9,7 @@ import ( func TestLoadDefaultConfig_FileThenEnvOverride(t *testing.T) { dir := t.TempDir() 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 { t.Fatal(err) } @@ -38,8 +38,8 @@ func TestLoadDefaultConfig_FileThenEnvOverride(t *testing.T) { if cfg.RegionID != "cn-qingdao" { t.Errorf("RegionID = %q, want cn-qingdao", cfg.RegionID) } - if cfg.SignName != "示例签名" { - t.Errorf("SignName = %q, want 示例签名", cfg.SignName) + if 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) { dir := t.TempDir() 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.Setenv("SMS_CONFIG_FILE", path) @@ -70,12 +70,12 @@ func TestLoadDefaultConfig_RegionDefault(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"}) if got.TemplateID != "SMS_298990049" { 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) } } diff --git a/internal/sms/scene_store.go b/internal/sms/scene_store.go new file mode 100644 index 0000000..e5316c5 --- /dev/null +++ b/internal/sms/scene_store.go @@ -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() +} diff --git a/internal/sms/sms.go b/internal/sms/sms.go index eacb3e6..79ceebf 100644 --- a/internal/sms/sms.go +++ b/internal/sms/sms.go @@ -2,6 +2,7 @@ package sms import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -14,13 +15,18 @@ const ( ) var ( - ErrProviderNotFound = errors.New("未选择有效的短信发送供应商") - ErrInvalidRequest = errors.New("短信请求参数不正确") + ErrProviderNotFound = errors.New("未选择有效的短信发送供应商") + ErrInvalidRequest = errors.New("短信请求参数不正确") + ErrSceneNotConfigured = errors.New("短信场景未配置或已停用") ) // Config contains the provider credentials and template configuration. // serviceType: 1=Aliyun, 2=Tencent Cloud. +// +// Scene 为可选业务场景码(如 bind_phone)。当 Scene 非空时,短信服务会按该场景从数据库 +// 配置表读取模板ID/签名等配置并回填到本次请求,调用方无需关心具体模板。 type Config struct { + Scene string `json:"scene" yaml:"scene"` ServiceType string `json:"serviceType" yaml:"serviceType"` RegionID string `json:"regionId" yaml:"regionId"` AccessKeyID string `json:"accessKeyId" yaml:"accessKeyId"` @@ -49,6 +55,7 @@ type Registry struct { mu sync.RWMutex providers map[string]Provider defaultConfig Config + scenes SceneProvider } // NewRegistry 创建一个无默认配置的注册表。 @@ -58,8 +65,9 @@ func NewRegistry() *Registry { // NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段 // 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。 -func NewRegistryWithDefault(dc Config) *Registry { - return &Registry{providers: make(map[string]Provider), defaultConfig: dc} +// scenes 为可选的场景配置提供方(按 scene 从数据库读取模板配置),可为 nil。 +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) { @@ -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) { + // 按业务场景加载模板配置(来自数据库),实现“改配置不改代码、不重启服务”。 + 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) 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) } +// 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 { missing := make([]string, 0, 6) if strings.TrimSpace(config.ServiceType) == "" { diff --git a/internal/sms/sms_test.go b/internal/sms/sms_test.go index 2e3f87c..4865ab6 100644 --- a/internal/sms/sms_test.go +++ b/internal/sms/sms_test.go @@ -21,7 +21,7 @@ func TestRegistrySend(t *testing.T) { ServiceType: ServiceAliyun, AccessKeyID: "id", AccessKeySecret: "secret", 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 { 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) { - got := SplitAndTrim(" 138****8000, ,138****8000 ", ",") - if len(got) != 2 || got[0] != "138****8000" || got[1] != "138****8000" { + got := SplitAndTrim(" 13800008000, ,13900009000 ", ",") + if len(got) != 2 || got[0] != "13800008000" || got[1] != "13900009000" { t.Fatalf("unexpected split result: %#v", got) } } diff --git a/main.go b/main.go index 718de55..81b3a14 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "log" "net" @@ -11,6 +12,7 @@ import ( "syscall" "time" + _ "github.com/go-sql-driver/mysql" "google.golang.org/grpc" "google.golang.org/grpc/reflection" @@ -41,7 +43,32 @@ func main() { log.Fatalf("load sms default config: %v", err) } 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.ServiceTencent, tencent.New()) diff --git a/proto/common.proto b/proto/common.proto index d961ecb..b1cb45a 100644 --- a/proto/common.proto +++ b/proto/common.proto @@ -2,7 +2,7 @@ syntax = "proto3"; 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 { int64 router_id = 1; diff --git a/proto/notice.proto b/proto/notice.proto index eead3b2..0ea1d0d 100644 --- a/proto/notice.proto +++ b/proto/notice.proto @@ -4,7 +4,7 @@ package PbNotice; 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 { rpc Send(SendReq) returns (SendRes) {}