chore: 初始开源发布

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
This commit is contained in:
MiFaZhan
2026-10-22 14:21:47 +08:00
commit 4fb1016977
31 changed files with 2351 additions and 0 deletions
+15
View File
@@ -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
+13
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
/iotlcm-sms
/bin/
*.log
.DS_Store
examples/requests/*.local.json
# 真实凭证文件,仅存在于服务器或本地,不提交
/config.yaml
/deploy/config.yaml
+201
View File
@@ -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.
+206
View File
@@ -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=<your-registry>/iotlcm-sms:<commit> docker compose -f deploy/docker-compose.yml up -d
```
`deploy/docker-compose.yml` 的约定:
- HTTP 端口 `11190`,gRPC 端口 `11200`
- 真实凭证放在 `deploy/config.yaml`(**不入库**),以只读方式挂载到 `/app/config/config.yaml`
- 容器 `read_only: true`,仅 `/tmp` 为 tmpfs,启用 `no-new-privileges`
## 目录结构
```text
iotlcm-sms/
├── main.go # HTTP + gRPC 装配、环境变量注入
├── api/pb/ # 生成的 protobuf 代码
├── proto/ # protobuf 源文件
├── internal/sms/ # Provider 接口 + Registry + 供应商实现
│ ├── aliyun/ # 阿里云实现
│ └── tencent/ # 腾讯云实现
├── internal/httpapi/ # HTTP 路由与处理器
├── internal/grpcapi/ # gRPC 服务实现
├── examples/ # HTTP / gRPC 示例客户端与请求样例
├── tools/ # 辅助工具(如列出阿里云模板)
├── build/Dockerfile # 多阶段构建
└── deploy/ # docker-compose 编排
```
## 测试
```bash
go test ./...
```
`go test ./...` 不访问真实短信平台。
示例客户端:
```bash
go run ./examples/http_client
go run ./examples/http_client http://127.0.0.1:9090/api/v1/sms/send
go run ./examples/grpc_client examples/requests/aliyun.local.json
go run ./examples/grpc_client examples/requests/tencent.local.json 127.0.0.1:11200
```
`examples/requests/aliyun.json`、`tencent.json` 使用环境变量占位符,可入库;同目录的 `*.local.json` 是含本机测试凭证的可直接重放请求,已被 `.gitignore` 排除。
## 相关项目
本服务是 [iotlcm](https://git.5iot.com/iotlcm-public) 物联网卡全生命周期管理平台的一部分,负责向客户与设备侧下发短信通知(到期提醒、告警通知等)。
## License
[Apache License 2.0](./LICENSE)
+205
View File
@@ -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
}
+196
View File
@@ -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
}
+121
View File
@@ -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 &noticeClient{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",
}
+36
View File
@@ -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"]
+20
View File
@@ -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: ""
+32
View File
@@ -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
+65
View File
@@ -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 <request.json> [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))
}
+44
View File
@@ -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)
}
+14
View File
@@ -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\"}"
}
}
+14
View File
@@ -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"
}
}
+25
View File
@@ -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
)
+134
View File
@@ -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=
+51
View File
@@ -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
}
+88
View File
@@ -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()
}
}
+71
View File
@@ -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)
}
+77
View File
@@ -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)
}
}
+56
View File
@@ -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
}
+124
View File
@@ -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
}
+94
View File
@@ -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("配置文件是目录时应返回错误,避免静默使用空凭证")
}
}
+151
View File
@@ -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
}
+43
View File
@@ -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)
}
}
+67
View File
@@ -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
}
+101
View File
@@ -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
}
+17
View File
@@ -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;
}
+21
View File
@@ -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;
}
+40
View File
@@ -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)
}
}