chore: 并入 iotlcm-sms 历史到 sms/(原提交 1d38bc4)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:02 +08:00
34 changed files with 2776 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
+64
View File
@@ -0,0 +1,64 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
# Gitea Actions 的 cron 用 UTC:18:30 UTC = 次日 02:30 北京时间
- cron: '30 18 * * *'
workflow_dispatch:
concurrency:
group: mirror-${{ github.ref }}
cancel-in-progress: true
jobs:
mirror:
runs-on: host
steps:
- name: 同步到 Gitee
env:
SRC_TOKEN: ${{ secrets.SRC_GIT_TOKEN }}
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
# Gitee 在推送密集时会返回 429 Pending queue overflowed / 408 Authorization server timeout,
# 属服务端限流,退避重试即可。
attempt=0
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
wait_s=$((attempt * 30))
echo "推送失败,${wait_s}s 后重试"
sleep "$wait_s"
done
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
exit 1
+80
View File
@@ -0,0 +1,80 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
on:
push:
tags:
- 'v*'
workflow_dispatch:
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
trigger:
runs-on: host
steps:
- name: 触发 Jenkins 镜像构建
env:
JU: ${{ secrets.JENKINS_USER }}
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
echo "非 tag 推送,跳过:${GITHUB_REF:-unknown}"
exit 0
fi
;;
esac
if [ -z "${TAG_NAME:-}" ]; then
echo "未取到 tag 名,退出"
exit 1
fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# 取 CSRF crumb
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
echo "Jenkins 触发 HTTP $code"
if [ "$code" != "201" ] && [ "$code" != "200" ] && [ "$code" != "302" ] && [ "$code" != "303" ]; then
echo "触发失败:"
cat "$WORK/out"
exit 1
fi
LINK="$JURL/$JP/"
echo "=========================================="
echo "Jenkins 构建:$LINK"
echo "=========================================="
echo "::notice title=Jenkins 镜像构建::[$LINK]($LINK)"
+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 服务,支持阿里云和腾讯云短信。
## 启动
```bash
go mod tidy
go run .
```
默认监听 `:8080`,可通过 `HTTP_ADDR` 修改:
```bash
HTTP_ADDR=:9090 go run .
```
gRPC 默认监听 `:11200`,可通过 `GRPC_ADDR` 修改。HTTP 与 gRPC 使用同一套短信发送实现。
## 默认供应商配置
短信服务自身持有全局凭证与签名,调用方只需传模板 ID 和短信内容。默认配置按下面的顺序合并:
```text
config.yaml 默认值 → SMS_* 环境变量覆盖 → 调用方请求中显式提供的字段优先
```
配置文件查找顺序:`SMS_CONFIG_FILE` 指定路径 → `./config.yaml` → `./config/config.yaml` → `/app/config/config.yaml`。
```yaml
# config.yaml(真实凭证只放服务器,不要提交 Git)
serviceType: "1" # 1=阿里云,2=腾讯云
regionId: cn-hangzhou
accessKeyId: ""
accessKeySecret: ""
signName: "示例企业"
```
可用环境变量覆盖:`SMS_SERVICE_TYPE`、`SMS_REGION_ID`、`SMS_ACCESS_KEY_ID`、
`SMS_ACCESS_KEY_SECRET`、`SMS_SDK_APP_ID`、`SMS_SIGN_NAME`、`SMS_TEMPLATE_ID`。
仓库内提供 `config.example.yaml` 作为模板;`.gitignore` 已排除真实 `config.yaml`。
启动日志会打印实际加载的配置来源(`file=... env=...`),便于排查。
## 场景模板配置库(可选)
除全局凭证外,还支持按业务场景(scene)从数据库回填模板与签名:调用方只传 `scene`
和内容参数,无需关心具体模板 ID。连接参数与凭证同放 `config.yaml`:
```yaml
# 场景模板配置库(表 t_sms_template_config);DSN 留空或省略 = 关闭该能力
sceneDbDsn: "user:pass@tcp(127.0.0.1:3306)/db_iotlcm?charset=utf8mb4&parseTime=true&loc=Local"
sceneDbCacheTtl: 60s # 场景配置内存缓存时长,"改库即生效"的最长延迟
```
可用环境变量覆盖:`SMS_DB_DSN`、`SMS_DB_CONFIG_CACHE_TTL`。DSN 未配置时,
带 `scene` 的请求返回 400(`短信场景未配置或已停用`)。
生产环境使用 `deploy/docker-compose.yml` 容器化部署,HTTP 端口为 `11190`,
gRPC 端口为 `11200`。
```bash
docker build --platform linux/amd64 -f build/Dockerfile \
-t <your-registry>/iotlcm-sms:latest .
docker push <your-registry>/iotlcm-sms:latest
cd deploy
# 先在服务器放置真实 config.yaml(不入库)
docker compose pull
docker compose up -d
```
生产部署建议通过 `IOTLCM_SMS_IMAGE` 指定不可变版本标签:
```bash
IOTLCM_SMS_IMAGE=<your-registry>/iotlcm-sms:<commit> docker compose up -d
```
健康检查:
```bash
curl http://127.0.0.1:8080/healthz
```
## 发送短信
接口:`POST /api/v1/sms/send`
阿里云请求示例:
```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": "13800008000",
"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": "+8613800138000",
"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` 同步表示业务结果。
## 测试实例
运行不访问真实短信平台的 HTTP 接口测试:
```bash
go test ./...
```
运行示例客户端(先启动服务并替换示例中的测试凭证):
```bash
go run ./examples/http_client
```
也可以传入其他服务地址:
```bash
go run ./examples/http_client http://127.0.0.1:9090/api/v1/sms/send
```
## gRPC 接口
迁移后的接口保持原服务 protobuf 兼容:
```text
PbNotice.Notice/Send
```
请求中的 `config` 和 `params` 是 JSON 编码的 bytes,字段内容与 HTTP 接口一致。
响应结果放在 `head.code` 和 `head.message` 中;`routerId`、`traceId` 会原样返回。
protobuf 源文件位于 `proto`,生成的客户端和服务端代码位于 `api/pb`。
使用 Go 示例客户端发送请求:
```bash
go run ./examples/grpc_client examples/requests/aliyun.local.json
go run ./examples/grpc_client examples/requests/tencent.local.json 127.0.0.1:11200
```
服务注册了 gRPC reflection,可查看线上接口:
```bash
grpcurl -plaintext 127.0.0.1:11200 list
grpcurl -plaintext 127.0.0.1:11200 describe PbNotice.Notice
```
已保存的 HTTP 请求示例位于 `examples/requests`。其中 `aliyun.json` 和
`tencent.json` 使用环境变量占位符,可用于文档和版本管理;同目录的
`*.local.json` 是包含本机测试凭证的可直接重放请求,已被 `.gitignore` 排除。
```bash
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
-H 'Content-Type: application/json' \
--data-binary @examples/requests/aliyun.local.json
curl -X POST http://127.0.0.1:8080/api/v1/sms/send \
-H 'Content-Type: application/json' \
--data-binary @examples/requests/tencent.local.json
```
+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\x31Z\x2fgit.5iot.com/iotlcm-public/iotlcm-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\x31Z\x2fgit.5iot.com/iotlcm-public/iotlcm-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 @@
ARG BASE_REGISTRY=docker.io/library
FROM ${BASE_REGISTRY}/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 ${BASE_REGISTRY}/alpine:3.23
RUN apk add --no-cache ca-certificates tzdata
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"]
+25
View File
@@ -0,0 +1,25 @@
# 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: ""
# 场景模板配置库(可选,表 t_sms_template_config):按 scene 回填模板/签名,改库即生效。
# DSN 留空或省略本节 = 关闭该能力;真实 DSN 只放服务器 config.yaml,不要提交 Git。
# sceneDbDsn: "user:pass@tcp(127.0.0.1:3306)/db_iotlcm?charset=utf8mb4&parseTime=true&loc=Local"
# sceneDbCacheTtl: 60s
+34
View File
@@ -0,0 +1,34 @@
services:
iotlcm-sms:
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: "示例企业"
# 场景模板配置库(可选):默认读 config.yaml 的 sceneDbDsn,也可用环境变量临时覆盖。
# SMS_DB_DSN: ""
# SMS_DB_CONFIG_CACHE_TTL: 60s
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": "13800008000",
"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-qingdao",
"accessKeyId": "${ALIYUN_ACCESS_KEY_ID}",
"accessKeySecret": "${ALIYUN_ACCESS_KEY_SECRET}",
"signName": "示例企业",
"templateId": "SMS_305105966"
},
"params": {
"phoneNumber": "18600001858",
"msgContent": "{\"code\":\"482731\"}"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"config": {
"serviceType": "2",
"sdkAppId": "1400929370",
"accessKeyId": "${TENCENTCLOUD_SECRET_ID}",
"accessKeySecret": "${TENCENTCLOUD_SECRET_KEY}",
"signName": "示例企业",
"templateId": "2234038"
},
"params": {
"phoneNumber": "+8618610281858",
"msgContent": "482731|5"
}
}
+27
View File
@@ -0,0 +1,27 @@
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/go-sql-driver/mysql v1.10.1
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.71
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.57
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
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
)
+138
View File
@@ -0,0 +1,138 @@
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
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/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
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: "18600001858", 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 != "18600001858" {
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) || errors.Is(err, sms.ErrSceneNotConfigured) {
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":"13800008000","msgContent":"{\"code\":\"123456\"}"}}`
response, err := http.Post(server.URL+"/api/v1/sms/send", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
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 != "13800008000" {
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
}
+181
View File
@@ -0,0 +1,181 @@
package sms
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"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) {
data, path, err := readDefaultConfigFile()
if err != nil {
return Config{}, "", err
}
if path == "" {
return Config{}, "", nil
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, "", fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
}
return cfg, path, nil
}
// readDefaultConfigFile 按候选路径查找并读取配置文件,返回文件内容与命中路径。
// 未命中任何候选文件时返回空内容与空路径(不视为错误);
// 显式指定 SMS_CONFIG_FILE 时任何失败都返回错误,避免静默回退。
func readDefaultConfigFile() ([]byte, string, error) {
paths, explicit := configFileCandidates()
for _, path := range paths {
info, statErr := os.Stat(path)
if statErr != nil {
if explicit {
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr)
}
continue
}
// Docker 在挂载源不存在时会自动创建同名目录;必须显式报错,
// 否则会静默回退到空凭证,表现为短信发送全部失败。
if info.IsDir() {
return nil, "", fmt.Errorf(
"配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+
"请在服务器创建该文件后重新挂载", path)
}
data, err := os.ReadFile(path)
if err != nil {
if explicit {
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
}
continue
}
return data, path, nil
}
if explicit {
return nil, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0])
}
return nil, "", nil
}
// envOverrides 定义环境变量到配置字段的映射,环境变量优先于配置文件。
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
}
// SceneDbConfig 场景模板配置库(表 t_sms_template_config)连接参数。
// DSN 为空表示未启用场景库能力:Send 收到带 scene 的请求时返回 ErrSceneNotConfigured。
type SceneDbConfig struct {
DSN string `yaml:"sceneDbDsn"`
CacheTTL string `yaml:"sceneDbCacheTtl"`
}
// DefaultSceneDbCacheTTL 场景配置缓存默认时长。
const DefaultSceneDbCacheTTL = 60 * time.Second
// TTL 返回解析后的缓存时长;非法或非正值回退默认值。
func (c SceneDbConfig) TTL() time.Duration {
if v, err := time.ParseDuration(strings.TrimSpace(c.CacheTTL)); err == nil && v > 0 {
return v
}
return DefaultSceneDbCacheTTL
}
// LoadSceneDbConfig 从默认配置文件读取场景库参数(与凭证同一份 config.yaml),环境变量可覆盖。
// 未命中配置文件不报错(场景库能力可选);解析失败或显式指定的文件不存在才返回错误。
func LoadSceneDbConfig() (SceneDbConfig, ConfigSource, error) {
data, path, err := readDefaultConfigFile()
if err != nil {
return SceneDbConfig{}, ConfigSource{}, err
}
sc := SceneDbConfig{}
if path != "" {
if err := yaml.Unmarshal(data, &sc); err != nil {
return SceneDbConfig{}, ConfigSource{}, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
}
}
envs := make([]string, 0, 2)
if v := strings.TrimSpace(os.Getenv("SMS_DB_DSN")); v != "" {
sc.DSN = v
envs = append(envs, "SMS_DB_DSN")
}
if v := strings.TrimSpace(os.Getenv("SMS_DB_CONFIG_CACHE_TTL")); v != "" {
sc.CacheTTL = v
envs = append(envs, "SMS_DB_CONFIG_CACHE_TTL")
}
return sc, ConfigSource{File: path, Env: envs}, nil
}
+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("配置文件是目录时应返回错误,避免静默使用空凭证")
}
}
+119
View File
@@ -0,0 +1,119 @@
package sms
import (
"context"
"database/sql"
"strings"
"sync"
"time"
)
// 场景配置启用状态。
const sceneStatusEnabled = 1
// SceneConfig 是「业务场景 -> 短信模板」的配置,来源于数据库表 t_sms_template_config。
// 运营侧修改该表后,短信服务在缓存过期后即按新配置发送,无需改代码或重启。
type SceneConfig struct {
Scene string
ServiceType string
SignName string
TemplateID string
MsgContentTemplate string
Status int
}
// SceneProvider 提供按场景码查询模板配置的能力。
type SceneProvider interface {
Lookup(ctx context.Context, scene string) (*SceneConfig, error)
}
const sceneQuery = "SELECT `scene`, `service_type`, `sign_name`, `template_id`, `msg_content_template`, `status` " +
"FROM `t_sms_template_config` WHERE `scene` = ? LIMIT 1"
type sceneCacheEntry struct {
config *SceneConfig
expired time.Time
}
// SceneStore 从数据库读取场景模板配置,并做短 TTL 内存缓存:
// 改库后最多 ttl 时间即生效,同时避免每次发送都查库。
type SceneStore struct {
db *sql.DB
ttl time.Duration
mu sync.RWMutex
cache map[string]sceneCacheEntry
}
// NewSceneStore 创建一个基于数据库的场景配置存储。ttl<=0 时使用默认 60s。
func NewSceneStore(db *sql.DB, ttl time.Duration) *SceneStore {
if ttl <= 0 {
ttl = 60 * time.Second
}
return &SceneStore{
db: db,
ttl: ttl,
cache: make(map[string]sceneCacheEntry),
}
}
// Close 关闭底层数据库连接。
func (s *SceneStore) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// Lookup 返回指定场景的模板配置;场景未配置时返回 (nil, nil)。
// 命中与未命中都会缓存 ttl,避免对不存在的场景反复查库。
func (s *SceneStore) Lookup(ctx context.Context, scene string) (*SceneConfig, error) {
if s == nil || s.db == nil {
return nil, nil
}
scene = strings.TrimSpace(scene)
if scene == "" {
return nil, nil
}
if cfg, ok := s.cacheGet(scene); ok {
return cfg, nil
}
var item SceneConfig
row := s.db.QueryRowContext(ctx, sceneQuery, scene)
err := row.Scan(
&item.Scene,
&item.ServiceType,
&item.SignName,
&item.TemplateID,
&item.MsgContentTemplate,
&item.Status,
)
switch {
case err == nil:
result := item
s.cacheSet(scene, &result)
return &result, nil
case err == sql.ErrNoRows:
s.cacheSet(scene, nil)
return nil, nil
default:
// 查询失败不缓存,下次请求重试。
return nil, err
}
}
func (s *SceneStore) cacheGet(scene string) (*SceneConfig, bool) {
s.mu.RLock()
entry, ok := s.cache[scene]
s.mu.RUnlock()
if !ok || time.Now().After(entry.expired) {
return nil, false
}
return entry.config, true
}
func (s *SceneStore) cacheSet(scene string, cfg *SceneConfig) {
s.mu.Lock()
s.cache[scene] = sceneCacheEntry{config: cfg, expired: time.Now().Add(s.ttl)}
s.mu.Unlock()
}
+216
View File
@@ -0,0 +1,216 @@
package sms
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
)
const (
ServiceAliyun = "1"
ServiceTencent = "2"
)
var (
ErrProviderNotFound = errors.New("未选择有效的短信发送供应商")
ErrInvalidRequest = errors.New("短信请求参数不正确")
ErrSceneNotConfigured = errors.New("短信场景未配置或已停用")
)
// Config contains the provider credentials and template configuration.
// serviceType: 1=Aliyun, 2=Tencent Cloud.
//
// Scene 为可选业务场景码(如 bind_phone)。当 Scene 非空时,短信服务会按该场景从数据库
// 配置表读取模板ID/签名等配置并回填到本次请求,调用方无需关心具体模板。
type Config struct {
Scene string `json:"scene" yaml:"scene"`
ServiceType string `json:"serviceType" yaml:"serviceType"`
RegionID string `json:"regionId" yaml:"regionId"`
AccessKeyID string `json:"accessKeyId" yaml:"accessKeyId"`
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
scenes SceneProvider
}
// NewRegistry 创建一个无默认配置的注册表。
func NewRegistry() *Registry {
return &Registry{providers: make(map[string]Provider)}
}
// NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段
// 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。
// scenes 为可选的场景配置提供方(按 scene 从数据库读取模板配置),可为 nil。
func NewRegistryWithDefault(dc Config, scenes SceneProvider) *Registry {
return &Registry{providers: make(map[string]Provider), defaultConfig: dc, scenes: scenes}
}
func (r *Registry) Register(serviceType string, provider Provider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers[serviceType] = provider
}
func (r *Registry) Send(ctx context.Context, config Config, message Message) (Result, error) {
// 按业务场景加载模板配置(来自数据库),实现“改配置不改代码、不重启服务”。
if scene := strings.TrimSpace(config.Scene); scene != "" {
sceneCfg, err := r.loadSceneConfig(ctx, scene)
if err != nil {
if errors.Is(err, ErrSceneNotConfigured) {
return Result{Code: 400, Message: err.Error()}, err
}
return Result{Code: 500, Message: "查询短信场景配置失败: " + err.Error()}, err
}
config = mergeSceneConfig(sceneCfg, config)
message.MsgContent = renderSceneMsgContent(sceneCfg.MsgContentTemplate, message.MsgContent)
}
// 回退默认值:请求中未提供的字段使用短信服务自身的默认配置。
config = mergeConfig(r.defaultConfig, config)
if err := Validate(config, message); err != nil {
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)
}
// loadSceneConfig 按场景码加载模板配置,并校验场景已启用。
func (r *Registry) loadSceneConfig(ctx context.Context, scene string) (SceneConfig, error) {
if r.scenes == nil {
return SceneConfig{}, fmt.Errorf("%w: %s", ErrSceneNotConfigured, scene)
}
cfg, err := r.scenes.Lookup(ctx, scene)
if err != nil {
return SceneConfig{}, err
}
if cfg == nil || cfg.Status != sceneStatusEnabled {
return SceneConfig{}, fmt.Errorf("%w: %s", ErrSceneNotConfigured, scene)
}
return *cfg, nil
}
// mergeSceneConfig 用场景配置填充请求中未显式提供的字段,调用方显式值优先。
func mergeSceneConfig(scene SceneConfig, config Config) Config {
if strings.TrimSpace(config.ServiceType) == "" {
config.ServiceType = scene.ServiceType
}
if strings.TrimSpace(config.SignName) == "" {
config.SignName = scene.SignName
}
if strings.TrimSpace(config.TemplateID) == "" {
config.TemplateID = scene.TemplateID
}
return config
}
// renderSceneMsgContent 按场景内容模板渲染短信参数;模板为空时直接使用调用方内容。
// 支持 {content}(原样)与 {contentJson}(JSON 转义)两种占位符。
func renderSceneMsgContent(template string, content string) string {
template = strings.TrimSpace(template)
if template == "" {
return content
}
contentJSON, _ := json.Marshal(content)
replacer := strings.NewReplacer(
"{content}", content,
"{contentJson}", string(contentJSON),
)
return replacer.Replace(template)
}
func Validate(config Config, message Message) error {
missing := make([]string, 0, 6)
if strings.TrimSpace(config.ServiceType) == "" {
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: "13800008000"})
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(" 13800008000, ,13900009000 ", ",")
if len(got) != 2 || got[0] != "13800008000" || got[1] != "13900009000" {
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
}
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"context"
"database/sql"
"errors"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"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())
// 场景模板配置来自数据库(表 t_sms_template_config):改库即生效,无需改代码或重启。
// 连接参数与凭证同源于 config.yaml(SMS_DB_DSN 等环境变量可覆盖);DSN 为空时能力关闭。
var scenes sms.SceneProvider
var sceneStore *sms.SceneStore
sceneDb, sceneDbSrc, err := sms.LoadSceneDbConfig()
if err != nil {
log.Printf("load scene db config: %v(场景模板能力关闭)", err)
} else if sceneDb.DSN == "" {
log.Printf("scene db DSN not set, scene-based template config disabled")
} else {
db, err := sql.Open("mysql", sceneDb.DSN)
if err != nil {
log.Fatalf("open sms config database: %v", err)
}
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(time.Hour)
ttl := sceneDb.TTL()
sceneStore = sms.NewSceneStore(db, ttl)
defer func() { _ = sceneStore.Close() }()
scenes = sceneStore
log.Printf("sms scene config store enabled (source: %s), cache ttl=%s", sceneDbSrc.Describe(), ttl)
}
providers := sms.NewRegistryWithDefault(defaultConfig, scenes)
providers.Register(sms.ServiceAliyun, aliyun.New())
providers.Register(sms.ServiceTencent, tencent.New())
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 = "git.5iot.com/iotlcm-public/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 = "git.5iot.com/iotlcm-public/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)
}
}