chore: 并入 iotlcm-sms 历史到 sms/(原提交 1d38bc4)
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/iotlcm-sms
|
||||||
|
/bin/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
examples/requests/*.local.json
|
||||||
|
|
||||||
|
# 真实凭证文件,仅存在于服务器或本地,不提交
|
||||||
|
/config.yaml
|
||||||
|
/deploy/config.yaml
|
||||||
+201
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc v5.29.3
|
||||||
|
// source: notice.proto
|
||||||
|
|
||||||
|
package pb
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
Notice_Send_FullMethodName = "/PbNotice.Notice/Send"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoticeClient is the client API for Notice service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
type NoticeClient interface {
|
||||||
|
Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type noticeClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNoticeClient(cc grpc.ClientConnInterface) NoticeClient {
|
||||||
|
return ¬iceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *noticeClient) Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(SendRes)
|
||||||
|
err := c.cc.Invoke(ctx, Notice_Send_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeServer is the server API for Notice service.
|
||||||
|
// All implementations must embed UnimplementedNoticeServer
|
||||||
|
// for forward compatibility.
|
||||||
|
type NoticeServer interface {
|
||||||
|
Send(context.Context, *SendReq) (*SendRes, error)
|
||||||
|
mustEmbedUnimplementedNoticeServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedNoticeServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedNoticeServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedNoticeServer) Send(context.Context, *SendReq) (*SendRes, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Send not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedNoticeServer) mustEmbedUnimplementedNoticeServer() {}
|
||||||
|
func (UnimplementedNoticeServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeNoticeServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to NoticeServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeNoticeServer interface {
|
||||||
|
mustEmbedUnimplementedNoticeServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterNoticeServer(s grpc.ServiceRegistrar, srv NoticeServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedNoticeServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&Notice_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Notice_Send_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SendReq)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(NoticeServer).Send(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Notice_Send_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(NoticeServer).Send(ctx, req.(*SendReq))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notice_ServiceDesc is the grpc.ServiceDesc for Notice service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var Notice_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "PbNotice.Notice",
|
||||||
|
HandlerType: (*NoticeServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "Send",
|
||||||
|
Handler: _Notice_Send_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "notice.proto",
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("配置文件是目录时应返回错误,避免静默使用空凭证")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user