From 4fb1016977adec01c36b9045c5d6b08330a3daea Mon Sep 17 00:00:00 2001 From: MiFaZhan Date: Thu, 22 Oct 2026 14:21:47 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=BC=80=E6=BA=90?= =?UTF-8?q?=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iotlcm-sms:独立短信 HTTP/gRPC 服务,支持阿里云与腾讯云。 - Go module 迁移至 git.5iot.com/iotlcm-public/iotlcm-sms - 补 Apache-2.0 LICENSE,重写 README(特性/配置/接口/部署/目录/测试) - 配置改为 config.example.yaml + SMS_* 环境变量注入,仓库不含真实凭证 - 清理内部标识(私有镜像仓库 / 公司名 / 客户签名 / 真实模板ID / 真实手机号) - 统一 LF 行尾并加 .gitattributes --- .dockerignore | 15 ++ .gitattributes | 13 ++ .gitignore | 9 ++ LICENSE | 201 +++++++++++++++++++++++++++ README.md | 206 ++++++++++++++++++++++++++++ api/pb/common.pb.go | 205 +++++++++++++++++++++++++++ api/pb/notice.pb.go | 196 ++++++++++++++++++++++++++ api/pb/notice_grpc.pb.go | 121 ++++++++++++++++ build/Dockerfile | 36 +++++ config.example.yaml | 20 +++ deploy/docker-compose.yml | 32 +++++ examples/grpc_client/main.go | 65 +++++++++ examples/http_client/main.go | 44 ++++++ examples/requests/aliyun.json | 14 ++ examples/requests/tencent.json | 14 ++ go.mod | 25 ++++ go.sum | 134 ++++++++++++++++++ internal/grpcapi/server.go | 51 +++++++ internal/grpcapi/server_test.go | 88 ++++++++++++ internal/httpapi/handler.go | 71 ++++++++++ internal/httpapi/handler_test.go | 77 +++++++++++ internal/sms/aliyun/aliyun.go | 56 ++++++++ internal/sms/config.go | 124 +++++++++++++++++ internal/sms/config_test.go | 94 +++++++++++++ internal/sms/sms.go | 151 ++++++++++++++++++++ internal/sms/sms_test.go | 43 ++++++ internal/sms/tencent/tencent.go | 67 +++++++++ main.go | 101 ++++++++++++++ proto/common.proto | 17 +++ proto/notice.proto | 21 +++ tools/list_aliyun_templates/main.go | 40 ++++++ 31 files changed, 2351 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api/pb/common.pb.go create mode 100644 api/pb/notice.pb.go create mode 100644 api/pb/notice_grpc.pb.go create mode 100644 build/Dockerfile create mode 100644 config.example.yaml create mode 100644 deploy/docker-compose.yml create mode 100644 examples/grpc_client/main.go create mode 100644 examples/http_client/main.go create mode 100644 examples/requests/aliyun.json create mode 100644 examples/requests/tencent.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/grpcapi/server.go create mode 100644 internal/grpcapi/server_test.go create mode 100644 internal/httpapi/handler.go create mode 100644 internal/httpapi/handler_test.go create mode 100644 internal/sms/aliyun/aliyun.go create mode 100644 internal/sms/config.go create mode 100644 internal/sms/config_test.go create mode 100644 internal/sms/sms.go create mode 100644 internal/sms/sms_test.go create mode 100644 internal/sms/tencent/tencent.go create mode 100644 main.go create mode 100644 proto/common.proto create mode 100644 proto/notice.proto create mode 100644 tools/list_aliyun_templates/main.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fde3c4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +*.log +bin/ +iotlcm-sms +examples/requests/*.local.json + +# 真实凭证文件绝不进入镜像构建上下文(Dockerfile 使用 COPY . .) +config.yaml +config.local.yaml +deploy/config.yaml +secrets/ +*.secret +*.pem +*.key diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5837541 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# 统一使用 LF,避免跨平台协作时产生整文件 diff +* text=auto eol=lf + +# 二进制文件不做行尾转换 +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.pdf binary +*.exe binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bcb10f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/iotlcm-sms +/bin/ +*.log +.DS_Store +examples/requests/*.local.json + +# 真实凭证文件,仅存在于服务器或本地,不提交 +/config.yaml +/deploy/config.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..59126bb --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# iotlcm-sms + +独立短信发送服务,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`: + +```bash +HTTP_ADDR=:9090 GRPC_ADDR=:11201 go run . +``` + +健康检查: + +```bash +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=...`),便于排查。 + +## 发送短信 + +### 阿里云 + +```bash +curl -X POST http://127.0.0.1:8080/api/v1/sms/send \ + -H 'Content-Type: application/json' \ + -d '{ + "config": { + "serviceType": "1", + "regionId": "cn-hangzhou", + "accessKeyId": "your-access-key-id", + "accessKeySecret": "your-access-key-secret", + "signName": "your-sign-name", + "templateId": "SMS_000000001" + }, + "params": { + "phoneNumber": "138****8000", + "msgContent": "{\"code\":\"123456\"}" + } + }' +``` + +### 腾讯云 + +```bash +curl -X POST http://127.0.0.1:8080/api/v1/sms/send \ + -H 'Content-Type: application/json' \ + -d '{ + "config": { + "serviceType": "2", + "sdkAppId": "1400000000", + "accessKeyId": "your-secret-id", + "accessKeySecret": "your-secret-key", + "signName": "your-sign-name", + "templateId": "000000" + }, + "params": { + "phoneNumber": "138****8000", + "msgContent": "123456|5" + } + }' +``` + +- `serviceType=1`(阿里云)时 `msgContent` 为模板参数 JSON +- `serviceType=2`(腾讯云)时多个模板参数用 `|` 分隔 +- 多个手机号用英文逗号分隔;腾讯云号码使用 E.164 格式 + +成功响应: + +```json +{ + "code": 0, + "message": "发送成功", + "data": { + "provider": "aliyun", + "requestId": "provider-request-id" + } +} +``` + +参数错误返回 HTTP 400,上游短信平台调用失败返回 HTTP 502。响应体中的 `code` 同步表示业务结果。 + +## gRPC 接口 + +接口名:`PbNotice.Notice/Send`。 + +请求中的 `config` 和 `params` 是 JSON 编码的 bytes,字段内容与 HTTP 接口一致;响应结果放在 `head.code` 和 `head.message`,`routerId`、`traceId` 原样返回。 + +protobuf 源文件位于 `proto/`,生成的客户端与服务端代码位于 `api/pb/`。 + +```bash +grpcurl -plaintext 127.0.0.1:11200 list +grpcurl -plaintext 127.0.0.1:11200 describe PbNotice.Notice +``` + +## 容器化部署 + +```bash +docker build -t iotlcm-sms:latest -f build/Dockerfile . +``` + +生产部署推荐用不可变版本标签: + +```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 new file mode 100644 index 0000000..c707214 --- /dev/null +++ b/api/pb/common.pb.go @@ -0,0 +1,205 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: common.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HeadReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + RouterId int64 `protobuf:"varint,1,opt,name=router_id,json=routerId,proto3" json:"router_id,omitempty"` + TraceId string `protobuf:"bytes,2,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeadReq) Reset() { + *x = HeadReq{} + mi := &file_common_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeadReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeadReq) ProtoMessage() {} + +func (x *HeadReq) ProtoReflect() protoreflect.Message { + mi := &file_common_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeadReq.ProtoReflect.Descriptor instead. +func (*HeadReq) Descriptor() ([]byte, []int) { + return file_common_proto_rawDescGZIP(), []int{0} +} + +func (x *HeadReq) GetRouterId() int64 { + if x != nil { + return x.RouterId + } + return 0 +} + +func (x *HeadReq) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +type HeadRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + RouterId int64 `protobuf:"varint,3,opt,name=router_id,json=routerId,proto3" json:"router_id,omitempty"` + TraceId string `protobuf:"bytes,4,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeadRes) Reset() { + *x = HeadRes{} + mi := &file_common_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeadRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeadRes) ProtoMessage() {} + +func (x *HeadRes) ProtoReflect() protoreflect.Message { + mi := &file_common_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeadRes.ProtoReflect.Descriptor instead. +func (*HeadRes) Descriptor() ([]byte, []int) { + return file_common_proto_rawDescGZIP(), []int{1} +} + +func (x *HeadRes) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *HeadRes) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *HeadRes) GetRouterId() int64 { + if x != nil { + return x.RouterId + } + return 0 +} + +func (x *HeadRes) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +var File_common_proto protoreflect.FileDescriptor + +const file_common_proto_rawDesc = "" + + "\n" + + "\fcommon.proto\x12\bPbCommon\"A\n" + + "\aHeadReq\x12\x1b\n" + + "\trouter_id\x18\x01 \x01(\x03R\brouterId\x12\x19\n" + + "\btrace_id\x18\x02 \x01(\tR\atraceId\"o\n" + + "\aHeadRes\x12\x12\n" + + "\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" + +var ( + file_common_proto_rawDescOnce sync.Once + file_common_proto_rawDescData []byte +) + +func file_common_proto_rawDescGZIP() []byte { + file_common_proto_rawDescOnce.Do(func() { + file_common_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc))) + }) + return file_common_proto_rawDescData +} + +var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_common_proto_goTypes = []any{ + (*HeadReq)(nil), // 0: PbCommon.HeadReq + (*HeadRes)(nil), // 1: PbCommon.HeadRes +} +var file_common_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_common_proto_init() } +func file_common_proto_init() { + if File_common_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_proto_goTypes, + DependencyIndexes: file_common_proto_depIdxs, + MessageInfos: file_common_proto_msgTypes, + }.Build() + File_common_proto = out.File + file_common_proto_goTypes = nil + file_common_proto_depIdxs = nil +} diff --git a/api/pb/notice.pb.go b/api/pb/notice.pb.go new file mode 100644 index 0000000..5aacaba --- /dev/null +++ b/api/pb/notice.pb.go @@ -0,0 +1,196 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: notice.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SendReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadReq `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + Config []byte `protobuf:"bytes,2,opt,name=config,proto3" json:"config,omitempty"` + Params []byte `protobuf:"bytes,3,opt,name=params,proto3" json:"params,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendReq) Reset() { + *x = SendReq{} + mi := &file_notice_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendReq) ProtoMessage() {} + +func (x *SendReq) ProtoReflect() protoreflect.Message { + mi := &file_notice_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendReq.ProtoReflect.Descriptor instead. +func (*SendReq) Descriptor() ([]byte, []int) { + return file_notice_proto_rawDescGZIP(), []int{0} +} + +func (x *SendReq) GetHead() *HeadReq { + if x != nil { + return x.Head + } + return nil +} + +func (x *SendReq) GetConfig() []byte { + if x != nil { + return x.Config + } + return nil +} + +func (x *SendReq) GetParams() []byte { + if x != nil { + return x.Params + } + return nil +} + +type SendRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadRes `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendRes) Reset() { + *x = SendRes{} + mi := &file_notice_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendRes) ProtoMessage() {} + +func (x *SendRes) ProtoReflect() protoreflect.Message { + mi := &file_notice_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendRes.ProtoReflect.Descriptor instead. +func (*SendRes) Descriptor() ([]byte, []int) { + return file_notice_proto_rawDescGZIP(), []int{1} +} + +func (x *SendRes) GetHead() *HeadRes { + if x != nil { + return x.Head + } + return nil +} + +var File_notice_proto protoreflect.FileDescriptor + +const file_notice_proto_rawDesc = "" + + "\n" + + "\fnotice.proto\x12\bPbNotice\x1a\fcommon.proto\"`\n" + + "\aSendReq\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadReqR\x04head\x12\x16\n" + + "\x06config\x18\x02 \x01(\fR\x06config\x12\x16\n" + + "\x06params\x18\x03 \x01(\fR\x06params\"0\n" + + "\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" + +var ( + file_notice_proto_rawDescOnce sync.Once + file_notice_proto_rawDescData []byte +) + +func file_notice_proto_rawDescGZIP() []byte { + file_notice_proto_rawDescOnce.Do(func() { + file_notice_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_notice_proto_rawDesc), len(file_notice_proto_rawDesc))) + }) + return file_notice_proto_rawDescData +} + +var file_notice_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_notice_proto_goTypes = []any{ + (*SendReq)(nil), // 0: PbNotice.SendReq + (*SendRes)(nil), // 1: PbNotice.SendRes + (*HeadReq)(nil), // 2: PbCommon.HeadReq + (*HeadRes)(nil), // 3: PbCommon.HeadRes +} +var file_notice_proto_depIdxs = []int32{ + 2, // 0: PbNotice.SendReq.head:type_name -> PbCommon.HeadReq + 3, // 1: PbNotice.SendRes.head:type_name -> PbCommon.HeadRes + 0, // 2: PbNotice.Notice.Send:input_type -> PbNotice.SendReq + 1, // 3: PbNotice.Notice.Send:output_type -> PbNotice.SendRes + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_notice_proto_init() } +func file_notice_proto_init() { + if File_notice_proto != nil { + return + } + file_common_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_notice_proto_rawDesc), len(file_notice_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_notice_proto_goTypes, + DependencyIndexes: file_notice_proto_depIdxs, + MessageInfos: file_notice_proto_msgTypes, + }.Build() + File_notice_proto = out.File + file_notice_proto_goTypes = nil + file_notice_proto_depIdxs = nil +} diff --git a/api/pb/notice_grpc.pb.go b/api/pb/notice_grpc.pb.go new file mode 100644 index 0000000..49091e3 --- /dev/null +++ b/api/pb/notice_grpc.pb.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: notice.proto + +package pb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Notice_Send_FullMethodName = "/PbNotice.Notice/Send" +) + +// NoticeClient is the client API for Notice service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type NoticeClient interface { + Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error) +} + +type noticeClient struct { + cc grpc.ClientConnInterface +} + +func NewNoticeClient(cc grpc.ClientConnInterface) NoticeClient { + return ¬iceClient{cc} +} + +func (c *noticeClient) Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendRes) + err := c.cc.Invoke(ctx, Notice_Send_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// NoticeServer is the server API for Notice service. +// All implementations must embed UnimplementedNoticeServer +// for forward compatibility. +type NoticeServer interface { + Send(context.Context, *SendReq) (*SendRes, error) + mustEmbedUnimplementedNoticeServer() +} + +// UnimplementedNoticeServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedNoticeServer struct{} + +func (UnimplementedNoticeServer) Send(context.Context, *SendReq) (*SendRes, error) { + return nil, status.Error(codes.Unimplemented, "method Send not implemented") +} +func (UnimplementedNoticeServer) mustEmbedUnimplementedNoticeServer() {} +func (UnimplementedNoticeServer) testEmbeddedByValue() {} + +// UnsafeNoticeServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to NoticeServer will +// result in compilation errors. +type UnsafeNoticeServer interface { + mustEmbedUnimplementedNoticeServer() +} + +func RegisterNoticeServer(s grpc.ServiceRegistrar, srv NoticeServer) { + // If the following call panics, it indicates UnimplementedNoticeServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Notice_ServiceDesc, srv) +} + +func _Notice_Send_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NoticeServer).Send(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Notice_Send_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NoticeServer).Send(ctx, req.(*SendReq)) + } + return interceptor(ctx, in, info, handler) +} + +// Notice_ServiceDesc is the grpc.ServiceDesc for Notice service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Notice_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "PbNotice.Notice", + HandlerType: (*NoticeServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Send", + Handler: _Notice_Send_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "notice.proto", +} diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..6541572 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,36 @@ +FROM golang:1.25-alpine AS builder + +LABEL stage=gobuilder + +ENV GOPROXY=https://goproxy.cn,direct + +WORKDIR /src + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +ARG BuildVersion +ARG BuildTime +ARG CommitID + +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 + +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai +ENV TZ=Asia/Shanghai + +# 注意:不 COPY 任何凭证。默认凭证由部署时只读挂载到 /app/config/config.yaml。 +COPY --from=builder /out/iotlcm-sms /usr/local/bin/iotlcm-sms + +EXPOSE 8080 +EXPOSE 11200 + +ENTRYPOINT ["iotlcm-sms"] diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..194a9b6 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,20 @@ +# iotlcm-sms 默认供应商配置示例。 +# +# 加载顺序:本文件提供默认值 → SMS_* 环境变量覆盖 → 调用方请求中显式提供的字段优先。 +# 真实凭证不要提交到 Git:生产/预演只在服务器上放置 config.yaml, +# 并通过只读挂载注入容器,例如: +# -v /data/secrets/iotlcm-sms/config.yaml:/app/config/config.yaml:ro +# +# 可用环境变量覆盖:SMS_SERVICE_TYPE、SMS_REGION_ID、SMS_ACCESS_KEY_ID、 +# SMS_ACCESS_KEY_SECRET、SMS_SDK_APP_ID、SMS_SIGN_NAME、SMS_TEMPLATE_ID。 +# 也可用 SMS_CONFIG_FILE 指定其他配置文件路径。 + +serviceType: "1" # 1=阿里云,2=腾讯云 +regionId: cn-hangzhou +accessKeyId: "" +accessKeySecret: "" +signName: "示例签名" +# 模板 ID 由调用方按业务场景传入(通常来自框架系统参数),此处留空即可。 +# templateId: "" +# 仅腾讯云需要: +# sdkAppId: "" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..f5b4ab0 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,32 @@ +services: + iotlcm-sms: + # 默认使用本地构建的镜像;推到自己的镜像仓库时用 IOTLCM_SMS_IMAGE 指定。 + image: ${IOTLCM_SMS_IMAGE:-iotlcm-sms:latest} + container_name: iotlcm-sms + hostname: iotlcm-sms + restart: always + environment: + TZ: Asia/Shanghai + HTTP_ADDR: :11190 + GRPC_ADDR: :11200 + # 默认凭证来自 config.yaml(只读挂载);如需临时覆盖可用下面的环境变量。 + # SMS_ACCESS_KEY_ID: "" + # SMS_ACCESS_KEY_SECRET: "" + # SMS_SIGN_NAME: "示例签名" + volumes: + # 真实凭证文件只放服务器,不提交仓库。 + - ./config.yaml:/app/config/config.yaml:ro + ports: + - "11190:11190" + - "11200:11200" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1:11190/healthz"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s diff --git a/examples/grpc_client/main.go b/examples/grpc_client/main.go new file mode 100644 index 0000000..78db238 --- /dev/null +++ b/examples/grpc_client/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/encoding/protojson" + + "git.5iot.com/iotlcm-public/iotlcm-sms/api/pb" +) + +type requestFile struct { + Config json.RawMessage `json:"config"` + Params json.RawMessage `json:"params"` +} + +func main() { + if len(os.Args) < 2 { + log.Fatalf("usage: go run ./examples/grpc_client [host:port]") + } + address := "127.0.0.1:11200" + if len(os.Args) > 2 { + address = os.Args[2] + } + + data, err := os.ReadFile(os.Args[1]) + if err != nil { + log.Fatal(err) + } + var input requestFile + if err := json.Unmarshal(data, &input); err != nil { + log.Fatal(err) + } + + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Fatal(err) + } + defer conn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + response, err := pb.NewNoticeClient(conn).Send(ctx, &pb.SendReq{ + Head: &pb.HeadReq{ + RouterId: time.Now().UnixNano(), + TraceId: fmt.Sprintf("grpc-example-%d", time.Now().Unix()), + }, + Config: input.Config, + Params: input.Params, + }) + if err != nil { + log.Fatal(err) + } + output, err := protojson.MarshalOptions{Indent: " "}.Marshal(response) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(output)) +} diff --git a/examples/http_client/main.go b/examples/http_client/main.go new file mode 100644 index 0000000..5b44ca0 --- /dev/null +++ b/examples/http_client/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "os" +) + +func main() { + endpoint := "http://127.0.0.1:8080/api/v1/sms/send" + if len(os.Args) > 1 { + endpoint = os.Args[1] + } + + // 阿里云 msgContent 是模板参数 JSON;请替换为测试账号、签名和模板。 + body := []byte(`{ + "config": { + "serviceType": "1", + "regionId": "cn-hangzhou", + "accessKeyId": "your-access-key-id", + "accessKeySecret": "your-access-key-secret", + "signName": "your-sign-name", + "templateId": "SMS_000000001" + }, + "params": { + "phoneNumber": "138****8000", + "msgContent": "{\"code\":\"123456\"}" + } +}`) + + response, err := http.Post(endpoint, "application/json", bytes.NewReader(body)) + if err != nil { + log.Fatal(err) + } + defer response.Body.Close() + responseBody, err := io.ReadAll(response.Body) + if err != nil { + log.Fatal(err) + } + fmt.Printf("HTTP %d\n%s", response.StatusCode, responseBody) +} diff --git a/examples/requests/aliyun.json b/examples/requests/aliyun.json new file mode 100644 index 0000000..eca6baa --- /dev/null +++ b/examples/requests/aliyun.json @@ -0,0 +1,14 @@ +{ + "config": { + "serviceType": "1", + "regionId": "cn-hangzhou", + "accessKeyId": "${ALIYUN_ACCESS_KEY_ID}", + "accessKeySecret": "${ALIYUN_ACCESS_KEY_SECRET}", + "signName": "示例签名", + "templateId": "SMS_000000000" + }, + "params": { + "phoneNumber": "138****8000", + "msgContent": "{\"code\":\"123456\"}" + } +} diff --git a/examples/requests/tencent.json b/examples/requests/tencent.json new file mode 100644 index 0000000..22843ab --- /dev/null +++ b/examples/requests/tencent.json @@ -0,0 +1,14 @@ +{ + "config": { + "serviceType": "2", + "sdkAppId": "1400000000", + "accessKeyId": "${TENCENTCLOUD_SECRET_ID}", + "accessKeySecret": "${TENCENTCLOUD_SECRET_KEY}", + "signName": "示例签名", + "templateId": "000000" + }, + "params": { + "phoneNumber": "138****8000", + "msgContent": "123456|5" + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d28147 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module git.5iot.com/iotlcm-public/iotlcm-sms + +go 1.25.0 + +require ( + github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 + 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 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + 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 + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a98b95 --- /dev/null +++ b/go.sum @@ -0,0 +1,134 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +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= +github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU= +github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +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/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= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= +github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.57/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.71 h1:8tFF0fuSlrSD/1vTrEBYdmpc3D9wcxGyHo/7uIJxq+I= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.71/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.57 h1:ZnJK+aTZYyzGN/4dmQXYWzuHsuZFrlj034uLoGaNVvQ= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.57/go.mod h1:jwLLFaeXXAnkWj37iTh0jfeXDYWf9eggaKJ1dRnc/1A= +github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= +github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/grpcapi/server.go b/internal/grpcapi/server.go new file mode 100644 index 0000000..f627347 --- /dev/null +++ b/internal/grpcapi/server.go @@ -0,0 +1,51 @@ +package grpcapi + +import ( + "context" + "encoding/json" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "git.5iot.com/iotlcm-public/iotlcm-sms/api/pb" + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms" +) + +type Sender interface { + Send(context.Context, sms.Config, sms.Message) (sms.Result, error) +} + +type Server struct { + pb.UnimplementedNoticeServer + sender Sender +} + +func NewServer(sender Sender) *Server { + return &Server{sender: sender} +} + +func (s *Server) Send(ctx context.Context, request *pb.SendReq) (*pb.SendRes, error) { + if request == nil { + return nil, status.Error(codes.InvalidArgument, "请求不能为空") + } + + var config sms.Config + if err := json.Unmarshal(request.GetConfig(), &config); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "config JSON 无效: %v", err) + } + var message sms.Message + if err := json.Unmarshal(request.GetParams(), &message); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "params JSON 无效: %v", err) + } + + result, _ := s.sender.Send(ctx, config, message) + responseHead := &pb.HeadRes{ + Code: int32(result.Code), + Message: result.Message, + } + if request.Head != nil { + responseHead.RouterId = request.Head.RouterId + responseHead.TraceId = request.Head.TraceId + } + return &pb.SendRes{Head: responseHead}, nil +} diff --git a/internal/grpcapi/server_test.go b/internal/grpcapi/server_test.go new file mode 100644 index 0000000..c1b2ac4 --- /dev/null +++ b/internal/grpcapi/server_test.go @@ -0,0 +1,88 @@ +package grpcapi + +import ( + "context" + "encoding/json" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + "git.5iot.com/iotlcm-public/iotlcm-sms/api/pb" + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms" +) + +type fakeSender struct { + config sms.Config + message sms.Message + result sms.Result +} + +func (f *fakeSender) Send(_ context.Context, config sms.Config, message sms.Message) (sms.Result, error) { + f.config = config + f.message = message + return f.result, nil +} + +func TestSend(t *testing.T) { + fake := &fakeSender{result: sms.Result{Code: 0, Message: "发送成功"}} + client, closeServer := newTestClient(t, fake) + defer closeServer() + + config, _ := json.Marshal(sms.Config{ServiceType: sms.ServiceAliyun}) + params, _ := json.Marshal(sms.Message{PhoneNumber: "138****8000", MsgContent: `{"code":"123456"}`}) + response, err := client.Send(context.Background(), &pb.SendReq{ + Head: &pb.HeadReq{RouterId: 42, TraceId: "trace-1"}, + Config: config, + Params: params, + }) + if err != nil { + t.Fatal(err) + } + 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" { + t.Fatalf("unexpected request: config=%+v message=%+v", fake.config, fake.message) + } +} + +func TestSendRejectsInvalidJSON(t *testing.T) { + client, closeServer := newTestClient(t, &fakeSender{}) + defer closeServer() + + _, err := client.Send(context.Background(), &pb.SendReq{Config: []byte(`{`), Params: []byte(`{}`)}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want %v; err=%v", status.Code(err), codes.InvalidArgument, err) + } +} + +func newTestClient(t *testing.T, sender Sender) (pb.NoticeClient, func()) { + t.Helper() + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + pb.RegisterNoticeServer(server, NewServer(sender)) + go func() { + _ = server.Serve(listener) + }() + + conn, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + ) + if err != nil { + t.Fatal(err) + } + return pb.NewNoticeClient(conn), func() { + _ = conn.Close() + server.Stop() + _ = listener.Close() + } +} diff --git a/internal/httpapi/handler.go b/internal/httpapi/handler.go new file mode 100644 index 0000000..73628ea --- /dev/null +++ b/internal/httpapi/handler.go @@ -0,0 +1,71 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms" +) + +const maxRequestBody = 1 << 20 + +type Sender interface { + Send(ctx context.Context, config sms.Config, message sms.Message) (sms.Result, error) +} + +type SendRequest struct { + Config sms.Config `json:"config"` + Params sms.Message `json:"params"` +} + +type handler struct { + sender Sender +} + +func NewHandler(sender Sender) http.Handler { + h := &handler{sender: sender} + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", h.health) + mux.HandleFunc("POST /api/v1/sms/send", h.send) + return mux +} + +func (h *handler) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *handler) send(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + var request SendRequest + if err := decoder.Decode(&request); err != nil { + writeJSON(w, http.StatusBadRequest, sms.Result{Code: 400, Message: "请求 JSON 无效: " + err.Error()}) + return + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, sms.Result{Code: 400, Message: "请求体只能包含一个 JSON 对象"}) + return + } + + 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) { + status = http.StatusBadRequest + } + writeJSON(w, status, result) + return + } + writeJSON(w, http.StatusOK, result) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/internal/httpapi/handler_test.go b/internal/httpapi/handler_test.go new file mode 100644 index 0000000..0304e3b --- /dev/null +++ b/internal/httpapi/handler_test.go @@ -0,0 +1,77 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms" +) + +type fakeSender struct { + config sms.Config + message sms.Message + result sms.Result + err error +} + +func (f *fakeSender) Send(_ context.Context, config sms.Config, message sms.Message) (sms.Result, error) { + f.config = config + f.message = message + return f.result, f.err +} + +func TestSendSuccess(t *testing.T) { + fake := &fakeSender{result: sms.Result{Code: 0, Message: "发送成功"}} + 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\"}"}}` + response, err := http.Post(server.URL+"/api/v1/sms/send", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + 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" { + t.Fatalf("unexpected request: config=%+v message=%+v", fake.config, fake.message) + } +} + +func TestSendRejectsInvalidJSON(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/v1/sms/send", strings.NewReader(`{"unknown":true}`)) + recorder := httptest.NewRecorder() + NewHandler(&fakeSender{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } +} + +func TestSendMapsProviderError(t *testing.T) { + fake := &fakeSender{ + result: sms.Result{Code: 502, Message: "provider failed"}, + err: errors.New("provider failed"), + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/sms/send", strings.NewReader(`{"config":{},"params":{}}`)) + recorder := httptest.NewRecorder() + NewHandler(fake).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadGateway) + } + var result sms.Result + if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Code != 502 { + t.Fatalf("code = %d, want 502", result.Code) + } +} diff --git a/internal/sms/aliyun/aliyun.go b/internal/sms/aliyun/aliyun.go new file mode 100644 index 0000000..883558e --- /dev/null +++ b/internal/sms/aliyun/aliyun.go @@ -0,0 +1,56 @@ +package aliyun + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi" + + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms" +) + +type Provider struct{} + +func New() *Provider { return &Provider{} } + +func (p *Provider) Send(_ context.Context, config sms.Config, message sms.Message) (sms.Result, error) { + regionID := config.RegionID + if regionID == "" { + regionID = "cn-hangzhou" + } + client, err := dysmsapi.NewClientWithAccessKey(regionID, config.AccessKeyID, config.AccessKeySecret) + if err != nil { + return failed(err) + } + + request := dysmsapi.CreateSendSmsRequest() + request.Scheme = "https" + request.PhoneNumbers = strings.Join(sms.SplitAndTrim(message.PhoneNumber, ","), ",") + request.SignName = config.SignName + request.TemplateCode = config.TemplateID + request.TemplateParam = message.MsgContent + + response, err := client.SendSms(request) + if err != nil { + return failed(err) + } + data := map[string]any{ + "provider": "aliyun", + "requestId": response.RequestId, + "bizId": response.BizId, + "providerCode": response.Code, + } + if response.Code != "OK" { + err = fmt.Errorf("阿里云短信发送失败: %s", response.Message) + return sms.Result{Code: 502, Message: err.Error(), Data: data}, err + } + return sms.Result{Code: 0, Message: "发送成功", Data: data}, nil +} + +func failed(err error) (sms.Result, error) { + var sdkDetail map[string]any + _ = json.Unmarshal([]byte(err.Error()), &sdkDetail) + return sms.Result{Code: 502, Message: err.Error(), Data: sdkDetail}, err +} diff --git a/internal/sms/config.go b/internal/sms/config.go new file mode 100644 index 0000000..5c03b20 --- /dev/null +++ b/internal/sms/config.go @@ -0,0 +1,124 @@ +package sms + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// 默认配置文件名。容器内固定挂载到 /app/config/config.yaml。 +const defaultConfigFileName = "config.yaml" + +// ConfigSource 描述默认配置的实际来源,供启动日志排查。 +type ConfigSource struct { + File string // 实际加载的配置文件路径,空字符串表示未加载到文件 + Env []string // 生效的环境变量名(覆盖了文件值或单独提供) +} + +// Describe 返回可读的来源说明。 +func (s ConfigSource) Describe() string { + file := s.File + if file == "" { + file = "(无配置文件)" + } + env := "(无)" + if len(s.Env) > 0 { + env = strings.Join(s.Env, ",") + } + return fmt.Sprintf("file=%s env=%s", file, env) +} + +// configFileCandidates 返回配置文件候选路径。 +// SMS_CONFIG_FILE 显式指定时只使用该路径;否则依次尝试工作目录、config/ 子目录、容器固定挂载点。 +func configFileCandidates() (paths []string, explicit bool) { + if p := strings.TrimSpace(os.Getenv("SMS_CONFIG_FILE")); p != "" { + return []string{p}, true + } + return []string{ + defaultConfigFileName, + filepath.Join("config", defaultConfigFileName), + filepath.Join("/app/config", defaultConfigFileName), + }, false +} + +// LoadDefaultConfigFromFile 读取默认配置文件。 +// 返回的第二个值为实际命中的路径;未命中时路径为空。 +// 显式指定 SMS_CONFIG_FILE 但读取或解析失败时返回错误,避免静默使用空凭证。 +func LoadDefaultConfigFromFile() (Config, 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) + } + continue + } + // Docker 在挂载源不存在时会自动创建同名目录;必须显式报错, + // 否则会静默回退到空凭证,表现为短信发送全部失败。 + if info.IsDir() { + return Config{}, "", fmt.Errorf( + "配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+ + "请在服务器创建该文件后重新挂载", path) + } + data, err := os.ReadFile(path) + if err != nil { + if explicit { + return Config{}, "", 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 + } + if explicit { + return Config{}, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0]) + } + return Config{}, "", nil +} + +// envOverrides 定义环境变量到配置字段的映射,环境变量优先于配置文件。 +var envOverrides = []struct { + name string + apply func(*Config, string) +}{ + {"SMS_SERVICE_TYPE", func(c *Config, v string) { c.ServiceType = v }}, + {"SMS_REGION_ID", func(c *Config, v string) { c.RegionID = v }}, + {"SMS_ACCESS_KEY_ID", func(c *Config, v string) { c.AccessKeyID = v }}, + {"SMS_ACCESS_KEY_SECRET", func(c *Config, v string) { c.AccessKeySecret = v }}, + {"SMS_SDK_APP_ID", func(c *Config, v string) { c.SDKAppID = v }}, + {"SMS_SIGN_NAME", func(c *Config, v string) { c.SignName = v }}, + {"SMS_TEMPLATE_ID", func(c *Config, v string) { c.TemplateID = v }}, +} + +// applyEnvOverrides 用环境变量覆盖配置文件中的值,返回实际生效的变量名。 +func applyEnvOverrides(cfg *Config) []string { + applied := make([]string, 0, len(envOverrides)) + for _, item := range envOverrides { + if v := strings.TrimSpace(os.Getenv(item.name)); v != "" { + item.apply(cfg, v) + applied = append(applied, item.name) + } + } + return applied +} + +// LoadDefaultConfig 加载默认供应商配置:配置文件提供默认值,环境变量覆盖。 +// 短信服务自身持有全局凭证与签名,调用方请求中未显式提供的字段回退到这里的值。 +func LoadDefaultConfig() (Config, ConfigSource, error) { + cfg, path, err := LoadDefaultConfigFromFile() + if err != nil { + return Config{}, ConfigSource{}, err + } + envApplied := applyEnvOverrides(&cfg) + if strings.TrimSpace(cfg.RegionID) == "" { + cfg.RegionID = "cn-hangzhou" + } + return cfg, ConfigSource{File: path, Env: envApplied}, nil +} diff --git a/internal/sms/config_test.go b/internal/sms/config_test.go new file mode 100644 index 0000000..6b59202 --- /dev/null +++ b/internal/sms/config_test.go @@ -0,0 +1,94 @@ +package sms + +import ( + "os" + "path/filepath" + "testing" +) + +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" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv("SMS_CONFIG_FILE", path) + t.Setenv("SMS_ACCESS_KEY_SECRET", "env-sk") + t.Setenv("SMS_ACCESS_KEY_ID", "") + t.Setenv("SMS_TEMPLATE_ID", "") + + cfg, source, err := LoadDefaultConfig() + if err != nil { + t.Fatalf("LoadDefaultConfig: %v", err) + } + if source.File != path { + t.Errorf("source.File = %q, want %q", source.File, path) + } + if len(source.Env) != 1 || source.Env[0] != "SMS_ACCESS_KEY_SECRET" { + t.Errorf("source.Env = %v, want [SMS_ACCESS_KEY_SECRET]", source.Env) + } + if cfg.AccessKeyID != "file-ak" { + t.Errorf("AccessKeyID = %q, want file-ak (文件值应保留)", cfg.AccessKeyID) + } + if cfg.AccessKeySecret != "env-sk" { + t.Errorf("AccessKeySecret = %q, want env-sk (环境变量应覆盖文件值)", cfg.AccessKeySecret) + } + if cfg.RegionID != "cn-qingdao" { + t.Errorf("RegionID = %q, want cn-qingdao", cfg.RegionID) + } + if cfg.SignName != "示例签名" { + t.Errorf("SignName = %q, want 示例签名", cfg.SignName) + } +} + +func TestLoadDefaultConfig_NoFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("SMS_CONFIG_FILE", filepath.Join(dir, "missing.yaml")) + if _, _, err := LoadDefaultConfig(); err == nil { + t.Fatal("显式指定配置文件但缺失时应返回错误") + } +} + +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 { + t.Fatal(err) + } + t.Setenv("SMS_CONFIG_FILE", path) + t.Setenv("SMS_REGION_ID", "") + + cfg, _, err := LoadDefaultConfig() + if err != nil { + t.Fatal(err) + } + if cfg.RegionID != "cn-hangzhou" { + t.Errorf("RegionID = %q, want cn-hangzhou", cfg.RegionID) + } +} + +func TestMergeConfig_CallerWinsThenDefault(t *testing.T) { + 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 != "示例签名" { + t.Errorf("凭证/签名未从默认配置回退: %+v", got) + } +} + +func TestLoadDefaultConfig_RejectsDirectory(t *testing.T) { + dir := t.TempDir() + // 模拟 Docker 在挂载源缺失时自动创建的目录 + bad := filepath.Join(dir, "config.yaml") + if err := os.Mkdir(bad, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("SMS_CONFIG_FILE", bad) + if _, _, err := LoadDefaultConfig(); err == nil { + t.Fatal("配置文件是目录时应返回错误,避免静默使用空凭证") + } +} diff --git a/internal/sms/sms.go b/internal/sms/sms.go new file mode 100644 index 0000000..eacb3e6 --- /dev/null +++ b/internal/sms/sms.go @@ -0,0 +1,151 @@ +package sms + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +const ( + ServiceAliyun = "1" + ServiceTencent = "2" +) + +var ( + ErrProviderNotFound = errors.New("未选择有效的短信发送供应商") + ErrInvalidRequest = errors.New("短信请求参数不正确") +) + +// Config contains the provider credentials and template configuration. +// serviceType: 1=Aliyun, 2=Tencent Cloud. +type Config struct { + ServiceType string `json:"serviceType" yaml:"serviceType"` + RegionID string `json:"regionId" yaml:"regionId"` + AccessKeyID string `json:"accessKeyId" yaml:"accessKeyId"` + AccessKeySecret string `json:"accessKeySecret" yaml:"accessKeySecret"` + SDKAppID string `json:"sdkAppId" yaml:"sdkAppId"` + SignName string `json:"signName" yaml:"signName"` + TemplateID string `json:"templateId" yaml:"templateId"` +} + +type Message struct { + PhoneNumber string `json:"phoneNumber"` + MsgContent string `json:"msgContent"` +} + +type Result struct { + Code int `json:"code"` + Message string `json:"message"` + Data map[string]any `json:"data,omitempty"` +} + +type Provider interface { + Send(context.Context, Config, Message) (Result, error) +} + +type Registry struct { + mu sync.RWMutex + providers map[string]Provider + defaultConfig Config +} + +// NewRegistry 创建一个无默认配置的注册表。 +func NewRegistry() *Registry { + return &Registry{providers: make(map[string]Provider)} +} + +// NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段 +// 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。 +func NewRegistryWithDefault(dc Config) *Registry { + return &Registry{providers: make(map[string]Provider), defaultConfig: dc} +} + +func (r *Registry) Register(serviceType string, provider Provider) { + r.mu.Lock() + defer r.mu.Unlock() + r.providers[serviceType] = provider +} + +func (r *Registry) Send(ctx context.Context, config Config, message Message) (Result, error) { + // 回退默认值:请求中未提供的字段使用短信服务自身的默认配置。 + config = mergeConfig(r.defaultConfig, config) + if err := Validate(config, message); err != nil { + return Result{Code: 400, Message: err.Error()}, err + } + + r.mu.RLock() + provider, ok := r.providers[config.ServiceType] + r.mu.RUnlock() + if !ok { + return Result{Code: 400, Message: ErrProviderNotFound.Error()}, ErrProviderNotFound + } + return provider.Send(ctx, config, message) +} + +func Validate(config Config, message Message) error { + missing := make([]string, 0, 6) + if strings.TrimSpace(config.ServiceType) == "" { + missing = append(missing, "config.serviceType") + } + if strings.TrimSpace(config.AccessKeyID) == "" { + missing = append(missing, "config.accessKeyId") + } + if strings.TrimSpace(config.AccessKeySecret) == "" { + missing = append(missing, "config.accessKeySecret") + } + if strings.TrimSpace(config.SignName) == "" { + missing = append(missing, "config.signName") + } + if strings.TrimSpace(config.TemplateID) == "" { + missing = append(missing, "config.templateId") + } + if strings.TrimSpace(message.PhoneNumber) == "" { + missing = append(missing, "params.phoneNumber") + } + if config.ServiceType == ServiceTencent && strings.TrimSpace(config.SDKAppID) == "" { + missing = append(missing, "config.sdkAppId") + } + if len(missing) != 0 { + return fmt.Errorf("%w: 缺少 %s", ErrInvalidRequest, strings.Join(missing, ", ")) + } + return nil +} + +// mergeConfig 用 defaultConfig 填充 config 中的空字段,调用方显式值优先。 +func mergeConfig(defaultConfig, config Config) Config { + if config.ServiceType == "" { + config.ServiceType = defaultConfig.ServiceType + } + if config.RegionID == "" { + config.RegionID = defaultConfig.RegionID + } + if config.AccessKeyID == "" { + config.AccessKeyID = defaultConfig.AccessKeyID + } + if config.AccessKeySecret == "" { + config.AccessKeySecret = defaultConfig.AccessKeySecret + } + if config.SDKAppID == "" { + config.SDKAppID = defaultConfig.SDKAppID + } + if config.SignName == "" { + config.SignName = defaultConfig.SignName + } + if config.TemplateID == "" { + config.TemplateID = defaultConfig.TemplateID + } + return config +} + +func SplitAndTrim(value, separator string) []string { + parts := strings.Split(value, separator) + result := make([]string, 0, len(parts)) + for _, part := range parts { + if item := strings.TrimSpace(part); item != "" { + result = append(result, item) + } + } + return result +} diff --git a/internal/sms/sms_test.go b/internal/sms/sms_test.go new file mode 100644 index 0000000..2e3f87c --- /dev/null +++ b/internal/sms/sms_test.go @@ -0,0 +1,43 @@ +package sms + +import ( + "context" + "errors" + "testing" +) + +type stubProvider struct{ called bool } + +func (p *stubProvider) Send(context.Context, Config, Message) (Result, error) { + p.called = true + return Result{Code: 0, Message: "ok"}, nil +} + +func TestRegistrySend(t *testing.T) { + provider := &stubProvider{} + registry := NewRegistry() + registry.Register(ServiceAliyun, provider) + config := Config{ + ServiceType: ServiceAliyun, AccessKeyID: "id", AccessKeySecret: "secret", + SignName: "sign", TemplateID: "template", + } + result, err := registry.Send(context.Background(), config, Message{PhoneNumber: "138****8000"}) + if err != nil || result.Code != 0 || !provider.called { + t.Fatalf("result=%+v err=%v called=%v", result, err, provider.called) + } +} + +func TestRegistryValidatesBeforeProvider(t *testing.T) { + registry := NewRegistry() + _, err := registry.Send(context.Background(), Config{ServiceType: ServiceTencent}, Message{}) + if !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("err = %v, want ErrInvalidRequest", err) + } +} + +func TestSplitAndTrim(t *testing.T) { + got := SplitAndTrim(" 138****8000, ,138****8000 ", ",") + if len(got) != 2 || got[0] != "138****8000" || got[1] != "138****8000" { + t.Fatalf("unexpected split result: %#v", got) + } +} diff --git a/internal/sms/tencent/tencent.go b/internal/sms/tencent/tencent.go new file mode 100644 index 0000000..deabcd0 --- /dev/null +++ b/internal/sms/tencent/tencent.go @@ -0,0 +1,67 @@ +package tencent + +import ( + "context" + "fmt" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + tencentsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20190711" + + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms" +) + +type Provider struct{} + +func New() *Provider { return &Provider{} } + +func (p *Provider) Send(_ context.Context, config sms.Config, message sms.Message) (sms.Result, error) { + credential := common.NewCredential(config.AccessKeyID, config.AccessKeySecret) + clientProfile := profile.NewClientProfile() + clientProfile.HttpProfile.Endpoint = "sms.tencentcloudapi.com" + client, err := tencentsdk.NewClient(credential, "ap-beijing", clientProfile) + if err != nil { + return failed(err) + } + + request := tencentsdk.NewSendSmsRequest() + request.SmsSdkAppid = common.StringPtr(config.SDKAppID) + request.Sign = common.StringPtr(config.SignName) + request.TemplateID = common.StringPtr(config.TemplateID) + request.TemplateParamSet = common.StringPtrs(sms.SplitAndTrim(message.MsgContent, "|")) + request.PhoneNumberSet = common.StringPtrs(sms.SplitAndTrim(message.PhoneNumber, ",")) + + response, err := client.SendSms(request) + if err != nil { + return failed(err) + } + if response.Response == nil || len(response.Response.SendStatusSet) == 0 { + err = fmt.Errorf("腾讯云未返回短信发送状态") + return failed(err) + } + + status := response.Response.SendStatusSet[0] + data := map[string]any{ + "provider": "tencent", + "requestId": stringValue(response.Response.RequestId), + "serialNo": stringValue(status.SerialNo), + "phoneNumber": stringValue(status.PhoneNumber), + "providerCode": stringValue(status.Code), + } + if stringValue(status.Code) != "Ok" { + err = fmt.Errorf("腾讯云短信发送失败: %s", stringValue(status.Message)) + return sms.Result{Code: 502, Message: err.Error(), Data: data}, err + } + return sms.Result{Code: 0, Message: "发送成功", Data: data}, nil +} + +func failed(err error) (sms.Result, error) { + return sms.Result{Code: 502, Message: err.Error()}, err +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..718de55 --- /dev/null +++ b/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "errors" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" + + "git.5iot.com/iotlcm-public/iotlcm-sms/api/pb" + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/grpcapi" + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/httpapi" + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms" + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms/aliyun" + "git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms/tencent" +) + +// 构建时通过 -ldflags 注入。 +var ( + BuildVersion = "0.0" + BuildTime = "" + CommitID = "" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + log.Printf("iotlcm-sms starting, version=%s buildTime=%s commit=%s", BuildVersion, BuildTime, CommitID) + + // 默认全局配置:config.yaml 提供默认值,SMS_* 环境变量可覆盖。 + defaultConfig, source, err := sms.LoadDefaultConfig() + if err != nil { + log.Fatalf("load sms default config: %v", err) + } + log.Printf("sms default config loaded: %s", source.Describe()) + providers := sms.NewRegistryWithDefault(defaultConfig) + providers.Register(sms.ServiceAliyun, aliyun.New()) + providers.Register(sms.ServiceTencent, tencent.New()) + + httpAddr := envOrDefault("HTTP_ADDR", ":8080") + httpServer := &http.Server{ + Addr: httpAddr, + Handler: httpapi.NewHandler(providers), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + grpcAddr := envOrDefault("GRPC_ADDR", ":11200") + grpcListener, err := net.Listen("tcp", grpcAddr) + if err != nil { + log.Fatalf("listen on gRPC address %s: %v", grpcAddr, err) + } + grpcServer := grpc.NewServer() + pb.RegisterNoticeServer(grpcServer, grpcapi.NewServer(providers)) + reflection.Register(grpcServer) + + errCh := make(chan error, 2) + go func() { + log.Printf("sms http server listening on %s", httpAddr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + go func() { + log.Printf("sms gRPC server listening on %s", grpcAddr) + if err := grpcServer.Serve(grpcListener); err != nil { + errCh <- err + } + }() + + select { + case err := <-errCh: + log.Printf("server stopped unexpectedly: %v", err) + stop() + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown HTTP server: %v", err) + } + grpcServer.GracefulStop() +} + +func envOrDefault(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/proto/common.proto b/proto/common.proto new file mode 100644 index 0000000..d961ecb --- /dev/null +++ b/proto/common.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package PbCommon; + +option go_package = "iotlcm-sms/api/pb;pb"; + +message HeadReq { + int64 router_id = 1; + string trace_id = 2; +} + +message HeadRes { + int32 code = 1; + string message = 2; + int64 router_id = 3; + string trace_id = 4; +} diff --git a/proto/notice.proto b/proto/notice.proto new file mode 100644 index 0000000..eead3b2 --- /dev/null +++ b/proto/notice.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package PbNotice; + +import "common.proto"; + +option go_package = "iotlcm-sms/api/pb;pb"; + +service Notice { + rpc Send(SendReq) returns (SendRes) {} +} + +message SendReq { + PbCommon.HeadReq head = 1; + bytes config = 2; + bytes params = 3; +} + +message SendRes { + PbCommon.HeadRes head = 1; +} diff --git a/tools/list_aliyun_templates/main.go b/tools/list_aliyun_templates/main.go new file mode 100644 index 0000000..30aae9c --- /dev/null +++ b/tools/list_aliyun_templates/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" + "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi" +) + +func main() { + keyID := os.Getenv("ALIYUN_ACCESS_KEY_ID") + keySecret := os.Getenv("ALIYUN_ACCESS_KEY_SECRET") + if keyID == "" || keySecret == "" { + log.Fatal("ALIYUN_ACCESS_KEY_ID and ALIYUN_ACCESS_KEY_SECRET are required") + } + region := os.Getenv("ALIYUN_REGION_ID") + if region == "" { + region = "cn-hangzhou" + } + + client, err := dysmsapi.NewClientWithAccessKey(region, keyID, keySecret) + if err != nil { + log.Fatal(err) + } + request := dysmsapi.CreateQuerySmsTemplateListRequest() + request.PageIndex = requests.NewInteger(1) + request.PageSize = requests.NewInteger(50) + response, err := client.QuerySmsTemplateList(request) + if err != nil { + log.Fatal(err) + } + if response.Code != "OK" { + log.Fatalf("query failed: code=%s message=%s", response.Code, response.Message) + } + for _, template := range response.SmsTemplateList { + fmt.Printf("%s\t%s\t%s\t%s\n", template.TemplateCode, template.AuditStatus, template.TemplateName, template.TemplateContent) + } +}