Compare commits
15
Commits
e0c35e4e9b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89cc7761b5 | ||
|
|
a16b8f8e89 | ||
|
|
5c1da5bf55 | ||
|
|
38d071b137 | ||
|
|
e376ff02aa | ||
|
|
451c8e2abc | ||
|
|
c968c2f1f0 | ||
|
|
ce4ed32dfc | ||
|
|
63a7978d50 | ||
|
|
d83720c8fd | ||
|
|
dce95412dd | ||
|
|
372e119e7c | ||
|
|
43db5e5450 | ||
|
|
af1e194396 | ||
|
|
d94e2ed225 |
+2
-3
@@ -6,12 +6,11 @@
|
||||
.codegraph
|
||||
gva
|
||||
web/node_modules
|
||||
web/dist
|
||||
node_modules
|
||||
dist
|
||||
bin
|
||||
coverage
|
||||
*.log
|
||||
*.tmp
|
||||
configs/*.local.yaml
|
||||
uploads
|
||||
docs
|
||||
deploy
|
||||
|
||||
+8
-10
@@ -1,11 +1,8 @@
|
||||
FROM golang:1.25 AS builder
|
||||
FROM docker.m.daocloud.io/debian:stable-slim
|
||||
|
||||
COPY . /src
|
||||
WORKDIR /src
|
||||
|
||||
RUN GOPROXY=https://goproxy.cn make build
|
||||
|
||||
FROM debian:stable-slim
|
||||
# BIN_NAME 必须与 Makefile build 的 -o 产物名一致(Jenkins 会传 --build-arg)
|
||||
ARG BIN_NAME=kratos-admin
|
||||
ENV BIN_NAME=${BIN_NAME}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
@@ -13,9 +10,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/ \
|
||||
&& apt-get autoremove -y && apt-get autoclean -y
|
||||
|
||||
RUN groupadd --system kra && useradd --system --gid kra --home-dir /app kra
|
||||
# Fixed UID/GID so the init container can prepare shared storage.
|
||||
RUN groupadd --system --gid 1000 kra && useradd --system --uid 1000 --gid kra --home-dir /app kra
|
||||
|
||||
COPY --from=builder --chown=kra:kra /src/bin /app
|
||||
COPY --chown=kra:kra bin /app
|
||||
|
||||
RUN mkdir -p /data/conf /app/uploads/file && chown -R kra:kra /data/conf /app/uploads
|
||||
|
||||
@@ -27,4 +25,4 @@ EXPOSE 8000
|
||||
EXPOSE 9000
|
||||
VOLUME /data/conf
|
||||
|
||||
CMD ["./kratos-admin", "-conf", "/data/conf"]
|
||||
CMD ["sh", "-c", "exec ./$BIN_NAME -conf /data/conf"]
|
||||
|
||||
@@ -19,13 +19,13 @@ web/ Vue 管理端
|
||||
|
||||
## 启动后端
|
||||
|
||||
先修改 `configs/config.yaml`。首次启动时,如果目标数据库不可用,服务会保留初始化接口;在管理端初始化页面完成建库、建表和种子数据写入后即可登录。
|
||||
先按本地环境填写 `configs/config.yaml`,或通过 `KRA_DATA_DATABASE_*` / `KRA_ADMIN_JWT_SIGNING_KEY` 环境变量提供敏感配置。默认模板不包含数据库口令,首次启动时如果目标数据库不可用,服务会保留初始化接口;在管理端初始化页面完成建库、建表和种子数据写入后即可登录。
|
||||
|
||||
```shell
|
||||
go run ./cmd -conf ./configs
|
||||
```
|
||||
|
||||
默认 HTTP 地址为 `http://127.0.0.1:8000`。生产环境必须替换 JWT 签名密钥和数据库凭据。
|
||||
默认 HTTP 地址为 `http://127.0.0.1:8000`。生产环境必须通过受控配置提供 JWT 签名密钥和数据库凭据;不要把真实密钥提交到 Git。
|
||||
|
||||
配置文件发生变化时会自动刷新内存配置;管理端“重载服务”会进一步重建数据库、Redis、对象存储连接并重新载入定时任务。路由前缀和 HTTP 监听地址需要重启进程后生效。
|
||||
|
||||
|
||||
Generated
+16
-10
@@ -8,22 +8,23 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/go-kratos/kratos/v3"
|
||||
integration3 "kra/internal/biz/integration"
|
||||
"kra/internal/biz/integration"
|
||||
payment3 "kra/internal/biz/payment"
|
||||
system2 "kra/internal/biz/system"
|
||||
task2 "kra/internal/biz/task"
|
||||
"kra/internal/config"
|
||||
"kra/internal/data"
|
||||
"kra/internal/data/integration"
|
||||
integration3 "kra/internal/data/integration"
|
||||
payment2 "kra/internal/data/payment"
|
||||
"kra/internal/data/system"
|
||||
"kra/internal/data/task"
|
||||
"kra/internal/initialize"
|
||||
integration2 "kra/internal/integration"
|
||||
integration4 "kra/internal/integration"
|
||||
"kra/internal/integration/cache"
|
||||
"kra/internal/integration/email"
|
||||
"kra/internal/integration/mq"
|
||||
"kra/internal/integration/payment"
|
||||
"kra/internal/integration/sms"
|
||||
"kra/internal/integration/storage"
|
||||
"kra/internal/integration/systeminfo"
|
||||
"kra/internal/integration/websocket"
|
||||
@@ -32,7 +33,7 @@ import (
|
||||
"kra/internal/server"
|
||||
"kra/internal/server/handler"
|
||||
"kra/internal/server/router"
|
||||
integration4 "kra/internal/service/integration"
|
||||
integration2 "kra/internal/service/integration"
|
||||
payment4 "kra/internal/service/payment"
|
||||
system3 "kra/internal/service/system"
|
||||
task3 "kra/internal/service/task"
|
||||
@@ -77,6 +78,11 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
||||
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
||||
registry := taskRegistry()
|
||||
runtimeconfigStore := data.NewIntegrationRuntime(dataData)
|
||||
smsSender := sms.NewSender(runtimeconfigStore)
|
||||
smsUsecase := integration.NewSMSUsecase(smsSender)
|
||||
smsService := integration2.NewSMSService(smsUsecase)
|
||||
handlerSMS := handler.NewSMS(smsService)
|
||||
authorityUsecase := system2.NewAuthorityUsecase(authorityAccessRepo)
|
||||
authorityService := system3.NewAuthorityService(authorityUsecase)
|
||||
authority := handler.NewAuthority(authorityService)
|
||||
@@ -106,7 +112,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
emailUsecase := system2.NewEmailUsecase(emailRepo)
|
||||
emailService := system3.NewEmailService(emailUsecase)
|
||||
handlerEmail := handler.NewEmail(emailService)
|
||||
paymentConfigReader := integration.NewPaymentConfigReader(dataData)
|
||||
paymentConfigReader := integration3.NewPaymentConfigReader(dataData)
|
||||
factory := payment.NewFactory()
|
||||
paymentOrderRepo := payment2.NewPaymentOrderRepo(dataData)
|
||||
paymentRepo := payment2.NewPaymentRepo(dataData, paymentConfigReader, factory, paymentOrderRepo)
|
||||
@@ -164,13 +170,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
user := handler.NewUser(userService, authService)
|
||||
navigation := handler.NewNavigation(userService)
|
||||
session := handler.NewSession(tokenService)
|
||||
integrationConfigRepo := integration.NewIntegrationConfigRepo(dataData)
|
||||
runtimeconfigStore := data.NewIntegrationRuntime(dataData)
|
||||
connectivityTester := integration2.NewConnectivityTester(runtimeconfigStore)
|
||||
integrationConfigUsecase := integration3.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester)
|
||||
integrationConfigService := integration4.NewIntegrationConfigService(integrationConfigUsecase)
|
||||
integrationConfigRepo := integration3.NewIntegrationConfigRepo(dataData)
|
||||
connectivityTester := integration4.NewConnectivityTester(runtimeconfigStore)
|
||||
integrationConfigUsecase := integration.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester)
|
||||
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||
set := &handler.Set{
|
||||
SMS: handlerSMS,
|
||||
Authority: authority,
|
||||
Menu: menu,
|
||||
API: api,
|
||||
|
||||
+4
-4
@@ -6,12 +6,12 @@ server:
|
||||
data:
|
||||
database:
|
||||
driver: mysql
|
||||
source: root:Xu950329.@tcp(127.0.0.1:3306)/kra?charset=utf8mb4&parseTime=True&loc=Local
|
||||
source: root:Xu950329.@tcp(127.0.0.1:3306)/kra_new?charset=utf8mb4&parseTime=True&loc=Local
|
||||
host: 127.0.0.1
|
||||
port: "3306"
|
||||
user: root
|
||||
password: Xu950329.
|
||||
name: kra
|
||||
name: kra_new
|
||||
config: charset=utf8mb4&parseTime=True&loc=Local
|
||||
path: ""
|
||||
alias_name: ""
|
||||
@@ -87,8 +87,8 @@ admin:
|
||||
# The administration system router prefix maps to this transport prefix.
|
||||
router_prefix: ""
|
||||
jwt:
|
||||
# Production deployments must override this value with a private secret.
|
||||
signing_key: 593e26b1-54dc-4465-9245-498ae08a957a
|
||||
# Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally.
|
||||
signing_key: 757fc924-629e-457d-a1df-39f9970e48ee
|
||||
expires_time: 604800s
|
||||
buffer_time: 86400s
|
||||
issuer: kra
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# KRA 部署与配置文档
|
||||
|
||||
本文档完整描述 kra 框架项目从代码推送到双集群部署的全链路:各组件如何串联、GitLab / Jenkins / Harbor 三块各自需要配置什么、首次部署步骤与排障方法。
|
||||
|
||||
> 快速接入新项目只看 [README.md](README.md);本文是完整运维文档。
|
||||
> 文中账号密码遵循内网环境约定,不写入仓库,凭据统一存放在 Jenkins Credentials 中。
|
||||
|
||||
## 1. 环境拓扑
|
||||
|
||||
| 节点 | 角色 | 关键端口 / 路径 |
|
||||
|------|------|----------------|
|
||||
| 192.168.200.128 | Rancher + RKE2 集群1(dev) | 前端 30080 / 后端 30081,命名空间 `<APP_NAME>-dev` |
|
||||
| 192.168.200.129 | RKE2 集群2(prod) | 前端 30080 / 后端 30081,命名空间 `<APP_NAME>-prod` |
|
||||
| 192.168.200.130 | GitLab | 仓库 `http://192.168.200.130/root/kra`,分支 `main` / `dev` |
|
||||
| 192.168.200.131 | Jenkins | 8080(容器挂载宿主机 docker.sock) |
|
||||
| 192.168.200.132 | Harbor | HTTPS(自签名证书),项目 `library` |
|
||||
|
||||
所有节点 SSH 使用 root;各系统账号密码见内网环境约定。
|
||||
|
||||
## 2. 总体流程
|
||||
|
||||
```
|
||||
开发者
|
||||
│ git push (dev 或 main)
|
||||
▼
|
||||
GitLab ──(Jenkins 集成,请求内嵌凭据)──► Jenkins job「kra」
|
||||
│ gitlab-plugin 解析 push payload
|
||||
│ 注入环境变量 gitlabBranch
|
||||
┌─────────────────────────────┤
|
||||
▼ Test/Build(宿主机 Docker) ▼ Build & push
|
||||
Go 编译 / pnpm 构建 ────────► Harbor(镜像 tag: {分支}-{commit前12位})
|
||||
│
|
||||
▼ Deploy(kubectl + 对应集群 kubeconfig)
|
||||
┌─────────┴─────────┐
|
||||
▼ ▼
|
||||
128 / kra-dev 129 / kra-prod
|
||||
(3 后端 + 3 前端 + MySQL + Redis)
|
||||
```
|
||||
|
||||
分支路由的核心:GitLab 集成发送的 push payload 中带 `ref`(分支),Jenkins 的 gitlab-plugin 把它注入为环境变量 `gitlabBranch`,并把 push 的最终提交注入为 `gitlabAfter`。`deploy/Jenkinsfile` 的 `Select environment` 阶段按分支派生出命名空间、环境标识、目标集群与 kubeconfig 凭据,随后优先按 `gitlabAfter` 的 SHA 检出构建内容。**单 job 同时服务两个环境,不会串**:
|
||||
|
||||
- 镜像 tag 含分支与 commit(如 `dev-f0657c1abc12`),按 commit 不可变;
|
||||
- 构建不并发(`disableConcurrentBuilds`),同时推送时排队执行;
|
||||
- job 的 SCM 分支规格 `*/main` 只影响 Jenkinsfile 脚本本身的加载(两分支脚本内容同步),不影响实际构建内容。
|
||||
|
||||
> tag 推送不触发部署(GitLab 集成未启用标签事件,Jenkinsfile 也只认 dev/main),tag 仅作 GitLab 版本标记。
|
||||
|
||||
## 3. GitLab 侧配置
|
||||
|
||||
**只需配置一项:Jenkins 集成**(不需要手工建 webhook)。
|
||||
|
||||
路径:项目 → 设置 → 集成 → Jenkins
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| Jenkins 服务器 URL | `http://192.168.200.131:8080` |
|
||||
| Project name | `kra`(与 Jenkins job 名一致) |
|
||||
| Username / Password | Jenkins 的 admin 账号 |
|
||||
| 触发事件 | 仅勾选"推送" |
|
||||
|
||||
**原理**:GitLab 把 Jenkins 用户名/密码内嵌进 URL,POST 到 `http://<user>:<pass>@131:8080/project/kra`,请求体为完整 push 事件 JSON(含分支 ref)。带凭据的请求天然通过 Jenkins 权限检查,无需匿名授权、无需维护自定义请求头。
|
||||
|
||||
- 交付记录:集成编辑页底部的"最近事件"(hook_logs),可看每次请求体与响应码,是排障第一入口。
|
||||
- 修改 Jenkins 密码后:只需回此表单改一次密码,所有分支自动生效。
|
||||
- 注意:GitLab 的 Jenkins 集成每个项目只能配一个(所以采用单 job 分支路由模型)。
|
||||
|
||||
## 4. Harbor 侧配置
|
||||
|
||||
1. **项目**:`library`(镜像最终路径 `library/kra-backend`、`library/kra-frontend`)。
|
||||
2. **机器人账号**:项目管理 → library → 机器人账户 → 创建 `kra-ci`(push/pull 权限),凭据存入 Jenkins 凭据 `harbor-library-push`。
|
||||
3. **自签名证书信任**(Harbor 用自签 HTTPS,docker/kubelet 需要信任):
|
||||
|
||||
```bash
|
||||
# ① Jenkins 宿主机(131)——docker login/push 用
|
||||
mkdir -p /etc/docker/certs.d/192.168.200.132
|
||||
cp harbor-ca.crt /etc/docker/certs.d/192.168.200.132/ca.crt
|
||||
# docker daemon 自动读取,无需重启
|
||||
|
||||
# ② 两台 RKE2 节点(128/129)——kubelet 拉镜像用
|
||||
cat >/etc/rancher/rke2/registries.yaml <<'EOF'
|
||||
mirrors:
|
||||
"192.168.200.132":
|
||||
endpoint:
|
||||
- "https://192.168.200.132"
|
||||
rewrite:
|
||||
"^rancher/(.*)": "library/$1"
|
||||
configs:
|
||||
"192.168.200.132":
|
||||
tls:
|
||||
insecure_skip_verify: true
|
||||
auth:
|
||||
username: kra-ci
|
||||
password: "<robot 密码>"
|
||||
EOF
|
||||
systemctl restart rke2-server
|
||||
```
|
||||
|
||||
> 集群内另有流水线自动创建/更新的 `harbor-registry` imagePullSecret(每次部署时同步 robot 凭据),Pod 拉镜像走它;`registries.yaml` 是节点级兜底。
|
||||
|
||||
## 5. Jenkins 侧配置
|
||||
|
||||
### 5.1 部署形态与系统依赖
|
||||
|
||||
- Jenkins 以容器运行在 131,容器内挂载宿主机 `/var/run/docker.sock`:流水线里的 `docker build/push` 实际跑在宿主机 daemon 上(注意容器内 docker CLI 无 buildx,Dockerfile 不能用 BuildKit 语法)。
|
||||
- 宿主机依赖缓存:`/var/lib/kra-ci-cache/{go-mod,go-build,pnpm-store,corepack}` 挂进构建容器,避免重复下载。
|
||||
- 容器内需有 `kubectl`、`envsubst`、`git`。
|
||||
|
||||
### 5.2 插件清单(关键)
|
||||
|
||||
| 插件 | 作用 |
|
||||
|------|------|
|
||||
| gitlab-plugin | `/project/<job>` 接收端点、触发构建、注入 `gitlabBranch` |
|
||||
| Pipeline | 流水线核心(Pipeline script from SCM) |
|
||||
| Git | SCM 检出 |
|
||||
| Credentials / Credentials Binding | 凭据管理与注入 |
|
||||
|
||||
### 5.3 凭据(6 个,ID 与 `deploy/Jenkinsfile` 中变量对应)
|
||||
|
||||
| 凭据 ID | 类型 | 来源 / 获取方式 |
|
||||
|---------|------|----------------|
|
||||
| `gitlab-root-http` | 用户名/密码 | GitLab 账号(代码检出用) |
|
||||
| `harbor-library-push` | 用户名/密码 | Harbor robot 账号 `kra-ci` |
|
||||
| `kra-rke2-128-kubeconfig` | Secret file | 128 节点 `/etc/rancher/rke2/rke2.yaml`,**把 server 的 127.0.0.1 改为 192.168.200.128** 后保存 |
|
||||
| `kra-rke2-129-kubeconfig` | Secret file | 129 节点同上,server 改为 192.168.200.129 |
|
||||
| `kra-mysql-root-password` | Secret text | 自定的 MySQL root 密码(与节点上 MySQL 一致) |
|
||||
| `kra-jwt-signing-key` | Secret text | 首次生成运行时配置时使用的 JWT 签名密钥;必须非空且为单行文本 |
|
||||
|
||||
kubeconfig 添加路径:系统管理 → 凭据 → Add Credentials → 类型 Secret file → 上传文件 → 填 ID。
|
||||
|
||||
> kubeconfig 可用性验证:`kubectl --kubeconfig <文件> -n kra-dev get pods`。若报证书错误,在节点 `/etc/rancher/rke2/config.yaml` 追加 `tls-san: <节点IP>` 并 `systemctl restart rke2-server`。
|
||||
|
||||
### 5.4 Job 配置(唯一 job「kra」)
|
||||
|
||||
1. 新建 Item → Pipeline,命名 `kra`。
|
||||
2. 勾选"不允许并发构建"。
|
||||
3. 触发器:勾选 **Build when a change is pushed to GitLab**(gitlab-plugin 的 `/project/<job>` 端点依赖该触发器存在)。
|
||||
4. 流水线:定义 = **Pipeline script from SCM**;SCM = Git;仓库 `http://192.168.200.130/root/kra.git`;凭据 `gitlab-root-http`;分支规格 `*/main`;脚本路径 `deploy/Jenkinsfile`;勾选轻量级检出。
|
||||
5. Jenkins 安全策略保持"登录用户可做任何事"即可——集成请求自带凭据,天然有构建权限。
|
||||
|
||||
### 5.5 分支解析优先级(Jenkinsfile)
|
||||
|
||||
```
|
||||
gitlabBranch(集成/推送触发)
|
||||
> BRANCH_NAME(多分支流水线场景)
|
||||
> JOB_NAME(手动触发兜底:以 -dev 结尾按 dev,否则默认 main)
|
||||
```
|
||||
|
||||
## 6. Kubernetes 节点配置
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| Harbor 拉镜像信任 | 见 §4 第 3 步 `registries.yaml` |
|
||||
| 应用配置文件 | Jenkins 将 [k8s/config.example.yaml](k8s/config.example.yaml) 渲染为 `<APP_NAME>-config-template` Secret,初始化容器首次复制到 `/var/lib/<APP_NAME>/conf/config.yaml` |
|
||||
| 配置与上传存储 | `/var/lib/<APP_NAME>/conf`、`/var/lib/<APP_NAME>/uploads`;required podAffinity 保证后端与 MySQL 同节点,仅适用于当前单节点方案 |
|
||||
| 数据持久化 | `/var/lib/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/redis`(节点本地目录;Redis/后端 initContainer 需要允许 root 修正 hostPath 权限) |
|
||||
| kubectl | 节点用 `/var/lib/rancher/rke2/bin/kubectl`,并 `export KUBECONFIG=/etc/rancher/rke2/rke2.yaml` |
|
||||
|
||||
**首次部署初始化流程**(kra 框架约定,勿用环境变量注入数据库 DSN):
|
||||
|
||||
1. 服务启动时数据库未就绪,首页显示"前往初始化"。
|
||||
2. 走完 `/init/initdb` 向导:建库建表 + 写入种子数据(默认账号 admin)。
|
||||
3. 向导最后一步把数据库连接**写回**节点目录中的 `/data/conf/config.yaml`,配置不会随 Pod 重建消失。
|
||||
4. 当前 Jenkins 默认将前后端都部署为 1 个副本,更新策略为 `Recreate`,MySQL 和 Redis 也默认启动。若环境已有外部 MySQL,可将 `KRA_DEPLOY_MYSQL` 设为 `false`;若不需要 Redis,可将 `KRA_DEPLOY_REDIS` 设为 `false`。关闭时对应工作负载副本数为 0,backend 不等待内置 MySQL,并关闭 Redis 使用。未来如需扩容,必须先迁移到真正的共享存储,再调整副本数和更新策略。
|
||||
|
||||
> `<APP_NAME>-config-template` Secret 只负责首次创建配置文件。初始化后,运行时配置以 hostPath 中的 `/data/conf/config.yaml` 为准;更新 Secret 或 Jenkins JWT 凭据不会覆盖已有配置,避免覆盖初始化写入的数据库连接。JWT 后续轮换应通过系统设置完成并滚动重启后端。
|
||||
|
||||
## 7. 首次部署清单(按顺序)
|
||||
|
||||
1. Harbor:建 `library` 项目 + robot 账号;证书分发到 131 宿主机与 128/129 节点。
|
||||
2. RKE2 集群:确认 `/var/lib/<APP_NAME>/` 所在磁盘容量足够,并允许清单中的 root initContainer 修正目录权限。
|
||||
3. Jenkins:安装插件;添加 §5.3 所列 6 个凭据;按 §5.4 建 `kra` job。
|
||||
4. GitLab:配置 Jenkins 集成(§3)。
|
||||
5. 推送任意分支触发构建,观察首次构建(有依赖缓存预热,耗时会比后续长)。
|
||||
6. 初始化向导(§6),完成后浏览器验证。
|
||||
|
||||
## 8. 部署验证
|
||||
|
||||
```bash
|
||||
curl -I http://192.168.200.128:30080/ # 前端 200
|
||||
curl http://192.168.200.128:30081/health # "ok"
|
||||
curl -I http://192.168.200.129:30080/ # 同上验证 129
|
||||
```
|
||||
|
||||
构建日志里的 `resolved branch='xxx'` 和 `building commit <sha>` 两行可分别确认部署环境和实际构建版本。
|
||||
|
||||
## 9. 排障速查
|
||||
|
||||
| 现象 | 排查点 |
|
||||
|------|--------|
|
||||
| push 后没有构建 | GitLab 集成"最近事件"(hook_logs)看响应码与请求体;确认集成启用且勾选推送 |
|
||||
| 构建失败 | Jenkins 构建日志;先看 `resolved branch` 是否符合预期 |
|
||||
| Pod 镜像拉取失败 | 节点 `registries.yaml`、Harbor robot 是否有效、`harbor-registry` Secret |
|
||||
| 后端 503 / 初始化异常 | `/var/lib/<APP_NAME>/conf/config.yaml` 权限、配置模板 Secret、后端日志是否连上 `<APP_NAME>-mysql:3306` |
|
||||
| 登录态全部失效 | 是否在系统设置中更换了 JWT 签名密钥(换值即失效所有 token) |
|
||||
| 构建慢 | 缓存目录是否挂载成功;镜像源(goproxy/npmmirror/daocloud)偶发卡顿需重试 |
|
||||
|
||||
## 10. 附录:安全与维护
|
||||
|
||||
- 所有账号密码只存 Jenkins Credentials / 各系统自身,不写入仓库;`configs/config.yaml` 不要提交真实密钥。
|
||||
- 轮换 Jenkins 密码:GitLab 集成表单改一次即可。
|
||||
- 轮换 Harbor robot:更新 Jenkins 凭据后重新部署即可。轮换 MySQL 密码必须先在 MySQL 中执行 `ALTER USER` 并确认连接正常,再更新 Jenkins 凭据并重启相关 Pod;仅更新 Secret 不会修改已初始化 MySQL 数据目录中的 root 密码。JWT 签名密钥通过系统设置修改并保存到 hostPath 配置文件,随后滚动重启后端;换值会使现有登录态失效。
|
||||
Vendored
+262
@@ -0,0 +1,262 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
|
||||
environment {
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// 项目级参数 —— 基于 kra 模板的新项目只需修改本块。
|
||||
// 这些变量自动 export 到 shell,由 envsubst 白名单渲染进 k8s 清单。
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
APP_NAME = 'kra' // 派生:服务名/Secret名/库名/存储声明/镜像后缀
|
||||
IMAGE_REPO = 'library/kra' // Harbor 中镜像仓库路径
|
||||
REGISTRY = '192.168.200.132'
|
||||
BIN_NAME = 'kratos-admin' // Makefile build 的产物名
|
||||
// 当前单节点部署固定使用单副本,避免首次初始化和 hostPath 数据共享产生竞争。
|
||||
KRA_REPLICAS = '1'
|
||||
// 中间件按环境开关;关闭时对应工作负载缩容到 0,不创建运行 Pod。
|
||||
KRA_DEPLOY_MYSQL = 'true'
|
||||
KRA_DEPLOY_REDIS = 'true'
|
||||
KRA_FRONTEND_NODEPORT = '30080'
|
||||
KRA_BACKEND_NODEPORT = '30081'
|
||||
KRA_MYSQL_IMAGE = 'docker.m.daocloud.io/library/mysql:8.4'
|
||||
KRA_REDIS_IMAGE = 'docker.m.daocloud.io/library/redis:7-alpine'
|
||||
GO_IMAGE = 'docker.m.daocloud.io/golang:1.25'
|
||||
NODE_IMAGE = 'docker.m.daocloud.io/node:20-slim'
|
||||
PNPM_VERSION = '10.15.1'
|
||||
CACHE_DIR = '/var/lib/kra-ci-cache' // Jenkins 宿主机依赖缓存根目录
|
||||
|
||||
// Jenkins 凭据 ID(新项目若沿用同一套凭据可不改)
|
||||
CRED_HARBOR_PUSH = 'harbor-library-push'
|
||||
CRED_KUBECONFIG_DEV = 'kra-rke2-128-kubeconfig'
|
||||
CRED_KUBECONFIG_PROD = 'kra-rke2-129-kubeconfig'
|
||||
CRED_MYSQL_PASSWORD = 'kra-mysql-root-password'
|
||||
CRED_JWT_SIGNING_KEY = 'kra-jwt-signing-key'
|
||||
|
||||
// k8s 清单渲染白名单:只替换列出的变量,避免误伤清单内 shell 变量(如 probe 里的 $MYSQL_ROOT_PASSWORD)
|
||||
K8S_RENDER_VARS = '$KRA_APP $KRA_NAMESPACE $KRA_ENV $KRA_BACKEND_IMAGE $KRA_FRONTEND_IMAGE $KRA_REPLICAS $KRA_DEPLOY_MYSQL $KRA_DEPLOY_REDIS $KRA_MYSQL_REPLICAS $KRA_REDIS_REPLICAS $KRA_MYSQL_WAIT_IMAGE $KRA_FRONTEND_NODEPORT $KRA_BACKEND_NODEPORT $KRA_MYSQL_IMAGE $KRA_REDIS_IMAGE $BIN_NAME'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Select environment') {
|
||||
steps {
|
||||
script {
|
||||
// 分支来源优先级:
|
||||
// 1) env.gitlabBranch —— GitLab Jenkins 集成/webhook 触发时由 gitlab-plugin 注入(push payload 的 ref)
|
||||
// 2) env.BRANCH_NAME —— 多分支流水线场景
|
||||
// 3) JOB_NAME —— 手动触发兜底:名字以 -dev 结尾按 dev 部署,否则默认 main
|
||||
def branch = env.gitlabBranch ?: env.BRANCH_NAME
|
||||
if (!branch) {
|
||||
branch = env.JOB_NAME.endsWith('-dev') ? 'dev' : 'main'
|
||||
}
|
||||
branch = branch.trim().replaceFirst(/^refs\/heads\//, '')
|
||||
// The backend NodePort service has the longest generated name:
|
||||
// <APP_NAME>-backend-nodeport, which must stay within 63 characters.
|
||||
if (!(env.APP_NAME ==~ /^[a-z0-9](?:[a-z0-9-]{0,44}[a-z0-9])?$/)) {
|
||||
error("APP_NAME must be a lowercase Kubernetes name with at most 46 characters: ${env.APP_NAME}")
|
||||
}
|
||||
echo "gitlabBranch='${env.gitlabBranch ?: ''}' BRANCH_NAME='${env.BRANCH_NAME ?: ''}' -> resolved branch='${branch}'"
|
||||
env.KRA_BRANCH = branch
|
||||
if (branch == 'dev') {
|
||||
env.KRA_NAMESPACE = "${env.APP_NAME}-dev"
|
||||
env.KRA_ENV = 'development'
|
||||
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_DEV
|
||||
} else if (branch == 'main') {
|
||||
env.KRA_NAMESPACE = "${env.APP_NAME}-prod"
|
||||
env.KRA_ENV = 'production'
|
||||
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_PROD
|
||||
} else {
|
||||
error("Unsupported branch: ${branch}. Only dev and main deploy.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout triggered commit') {
|
||||
steps {
|
||||
// job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出,
|
||||
// 保证单 job 同时服务 dev/main 两个环境。
|
||||
script {
|
||||
// gitlabAfter 为 push 事件的目标 commit;手动触发时为空。
|
||||
// 注意:不能把 null 赋给 env.*(会被转成字符串 "null"),这里用局部变量。
|
||||
def triggerCommit = env.gitlabAfter?.trim()
|
||||
if (triggerCommit && !(triggerCommit ==~ /[0-9a-fA-F]{40}/)) {
|
||||
error("Invalid GitLab commit SHA: ${triggerCommit}")
|
||||
}
|
||||
if (triggerCommit && triggerCommit.replace('0', '').isEmpty()) {
|
||||
error('GitLab event points to a deleted branch; refusing to deploy')
|
||||
}
|
||||
if (triggerCommit) {
|
||||
env.KRA_TRIGGER_COMMIT = triggerCommit
|
||||
}
|
||||
checkout([$class: 'GitSCM',
|
||||
branches: [[name: "*/${env.KRA_BRANCH}"]],
|
||||
userRemoteConfigs: [[credentialsId: 'gitlab-root-http', url: 'http://192.168.200.130/root/kra.git']]])
|
||||
if (env.KRA_TRIGGER_COMMIT) {
|
||||
sh "git fetch --no-tags origin ${env.KRA_TRIGGER_COMMIT}"
|
||||
sh "git checkout --detach ${env.KRA_TRIGGER_COMMIT}"
|
||||
}
|
||||
env.KRA_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
if (env.KRA_TRIGGER_COMMIT && env.KRA_COMMIT != env.KRA_TRIGGER_COMMIT) {
|
||||
error("Checked out ${env.KRA_COMMIT}, expected GitLab commit ${env.KRA_TRIGGER_COMMIT}")
|
||||
}
|
||||
echo "building commit ${env.KRA_COMMIT}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#')
|
||||
docker run --rm -e GOPROXY=https://goproxy.cn,direct \\
|
||||
-v "$HOST_WORKSPACE:/src" \\
|
||||
-v "$CACHE_DIR/go-mod:/go/pkg/mod" \\
|
||||
-v "$CACHE_DIR/go-build:/root/.cache/go-build" \\
|
||||
-w /src "$GO_IMAGE" sh -c "git config --global --add safe.directory /src && go test ./... && make build"
|
||||
'''
|
||||
sh '''
|
||||
set -eu
|
||||
HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#')
|
||||
docker run --rm \\
|
||||
-e COREPACK_NPM_REGISTRY=https://registry.npmmirror.com \\
|
||||
-v "$HOST_WORKSPACE/web:/app" \\
|
||||
-v "$CACHE_DIR/pnpm-store:/root/.local/share/pnpm/store" \\
|
||||
-v "$CACHE_DIR/corepack:/root/.cache/node/corepack" \\
|
||||
-w /app "$NODE_IMAGE" bash -lc "corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate && pnpm install --frozen-lockfile && pnpm build"
|
||||
# 前端 Nginx 配置模板渲染(proxy_pass 指向 ${APP_NAME}-backend 服务)。
|
||||
# KRA_APP 必须在此显式导出:envsubst 会把未设置的变量替换为空串。
|
||||
export KRA_APP="$APP_NAME"
|
||||
envsubst '$KRA_APP' < web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build and push images') {
|
||||
steps {
|
||||
script {
|
||||
env.IMAGE_TAG = "${env.KRA_BRANCH}-${env.KRA_COMMIT.take(12)}"
|
||||
}
|
||||
withCredentials([usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
umask 077
|
||||
DOCKER_CONFIG_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$DOCKER_CONFIG_DIR"' EXIT
|
||||
echo "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||
docker --config "$DOCKER_CONFIG_DIR" build --build-arg BIN_NAME="$BIN_NAME" -t "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" .
|
||||
docker --config "$DOCKER_CONFIG_DIR" build -t "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" web
|
||||
docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
||||
docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
||||
docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy to Rancher cluster') {
|
||||
steps {
|
||||
script {
|
||||
if (!(env.KRA_DEPLOY_MYSQL in ['true', 'false'])) {
|
||||
error("KRA_DEPLOY_MYSQL must be true or false: ${env.KRA_DEPLOY_MYSQL}")
|
||||
}
|
||||
if (!(env.KRA_DEPLOY_REDIS in ['true', 'false'])) {
|
||||
error("KRA_DEPLOY_REDIS must be true or false: ${env.KRA_DEPLOY_REDIS}")
|
||||
}
|
||||
def credentialBindings = [
|
||||
file(credentialsId: "${env.KUBECONFIG_CREDENTIALS}", variable: 'KUBECONFIG_FILE'),
|
||||
string(credentialsId: env.CRED_JWT_SIGNING_KEY, variable: 'KRA_JWT_SIGNING_KEY'),
|
||||
usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')
|
||||
]
|
||||
if (env.KRA_DEPLOY_MYSQL == 'true') {
|
||||
credentialBindings << string(credentialsId: env.CRED_MYSQL_PASSWORD, variable: 'KRA_MYSQL_ROOT_PASSWORD')
|
||||
}
|
||||
withCredentials(credentialBindings) {
|
||||
sh '''
|
||||
set -eu
|
||||
export KUBECONFIG="$KUBECONFIG_FILE"
|
||||
umask 077
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
kubectl create namespace "$KRA_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
||||
DOCKER_CONFIG_DIR="$TMP_DIR/docker"
|
||||
mkdir -p "$DOCKER_CONFIG_DIR"
|
||||
printf '%s' "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||
kubectl -n "$KRA_NAMESPACE" create secret generic harbor-registry \\
|
||||
--type=kubernetes.io/dockerconfigjson \\
|
||||
--from-file=.dockerconfigjson="$DOCKER_CONFIG_DIR/config.json" \\
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||
if [ "$KRA_DEPLOY_MYSQL" = 'true' ]; then
|
||||
test -n "$KRA_MYSQL_ROOT_PASSWORD" || { echo 'MySQL root password credential is empty' >&2; exit 1; }
|
||||
printf '%s' "$KRA_MYSQL_ROOT_PASSWORD" > "$TMP_DIR/mysql-root-password"
|
||||
kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-runtime" \\
|
||||
--from-file=mysql-root-password="$TMP_DIR/mysql-root-password" \\
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
fi
|
||||
test -n "$KRA_JWT_SIGNING_KEY" || { echo 'JWT signing key credential is empty' >&2; exit 1; }
|
||||
if [ "$(printf '%s' "$KRA_JWT_SIGNING_KEY" | wc -l)" -ne 0 ]; then
|
||||
echo 'JWT signing key credential must be a single line' >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$KRA_JWT_SIGNING_KEY" in
|
||||
*"'"*) echo "JWT signing key credential must not contain a single quote" >&2; exit 1 ;;
|
||||
esac
|
||||
CONFIG_TEMPLATE="$TMP_DIR/config.yaml"
|
||||
envsubst '$APP_NAME $KRA_ENV $KRA_JWT_SIGNING_KEY' < deploy/k8s/config.example.yaml > "$CONFIG_TEMPLATE"
|
||||
kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-config-template" \\
|
||||
--from-file=config.yaml="$CONFIG_TEMPLATE" \\
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl -n "$KRA_NAMESPACE" delete configmap "${APP_NAME}-config-template" --ignore-not-found
|
||||
export KRA_APP="$APP_NAME"
|
||||
export KRA_BACKEND_IMAGE="$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
||||
export KRA_FRONTEND_IMAGE="$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
||||
case "$KRA_DEPLOY_MYSQL" in
|
||||
true) export KRA_MYSQL_REPLICAS=1; export KRA_MYSQL_WAIT_IMAGE="$KRA_MYSQL_IMAGE" ;;
|
||||
false) export KRA_MYSQL_REPLICAS=0; export KRA_MYSQL_WAIT_IMAGE="$KRA_BACKEND_IMAGE" ;;
|
||||
*) echo 'KRA_DEPLOY_MYSQL must be true or false' >&2; exit 1 ;;
|
||||
esac
|
||||
case "$KRA_DEPLOY_REDIS" in
|
||||
true) export KRA_REDIS_REPLICAS=1 ;;
|
||||
false) export KRA_REDIS_REPLICAS=0 ;;
|
||||
*) echo 'KRA_DEPLOY_REDIS must be true or false' >&2; exit 1 ;;
|
||||
esac
|
||||
RENDERED_MANIFEST="$TMP_DIR/manifest.yaml"
|
||||
envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml > "$RENDERED_MANIFEST"
|
||||
# Groovy 三单引号字符串内反斜杠仍是转义前缀:
|
||||
# 本行正则里的反斜杠序列必须双写,shell 才能收到 grep -E 需要的单反斜杠模式。
|
||||
if grep -nE '\\$\\{[A-Z_][A-Z0-9_]*\\}' "$RENDERED_MANIFEST"; then
|
||||
echo 'Rendered Kubernetes manifest contains unresolved template variables' >&2
|
||||
exit 1
|
||||
fi
|
||||
MYSQL_SERVICE_IP=$(kubectl -n "$KRA_NAMESPACE" get service "${APP_NAME}-mysql" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)
|
||||
if [ -n "$MYSQL_SERVICE_IP" ] && [ "$MYSQL_SERVICE_IP" != 'None' ]; then
|
||||
echo "${APP_NAME}-mysql already exists as a ClusterIP Service; delete it during a maintenance window before switching to headless mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
kubectl apply --dry-run=server -f "$RENDERED_MANIFEST"
|
||||
kubectl apply -f "$RENDERED_MANIFEST"
|
||||
if [ "$KRA_DEPLOY_MYSQL" = 'true' ]; then
|
||||
RUNTIME_SECRET_VERSION=$(kubectl -n "$KRA_NAMESPACE" get secret "${APP_NAME}-runtime" -o jsonpath='{.metadata.resourceVersion}')
|
||||
PATCH_FILE="$TMP_DIR/mysql-patch.json"
|
||||
printf '{"spec":{"template":{"metadata":{"annotations":{"kra.dev/runtime-secret-version":"%s"}}}}}\n' \\
|
||||
"$RUNTIME_SECRET_VERSION" > "$PATCH_FILE"
|
||||
kubectl -n "$KRA_NAMESPACE" patch statefulset "${APP_NAME}-mysql" --type=merge \\
|
||||
--patch-file="$PATCH_FILE"
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status statefulset/${APP_NAME}-mysql --timeout=10m
|
||||
fi
|
||||
if [ "$KRA_DEPLOY_REDIS" = 'true' ]; then
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-redis --timeout=10m
|
||||
fi
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-backend --timeout=10m
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-frontend --timeout=10m
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# KRA CI/CD 部署
|
||||
|
||||
deploy/ 目录是一套与项目名解耦的部署模板,基于 kra 框架的新项目可以整体复制后只改一个参数块。
|
||||
|
||||
> 完整的环境配置、凭据来源与排障说明见 [DEPLOYMENT.md](DEPLOYMENT.md)。
|
||||
|
||||
分支和集群的对应关系:
|
||||
|
||||
- `dev` -> Rancher 集群 `128` -> 命名空间 `<APP_NAME>-dev`
|
||||
- `main` -> Rancher 集群 `129` -> 命名空间 `<APP_NAME>-prod`
|
||||
|
||||
代码仓库:`http://192.168.200.130/root/kra`。自动触发使用 **GitLab Jenkins 集成**(项目 设置 → 集成 → Jenkins):
|
||||
|
||||
- Jenkins 服务器 URL:`http://192.168.200.131:8080`,Project name:`kra`,填入 Jenkins 用户名/密码(凭据由 GitLab 保管,无需手动维护 webhook 或请求头)
|
||||
- 集成会把完整 push 事件(含 `ref` 分支)POST 到 Jenkins `/project/<job>` 端点,gitlab-plugin 注入 `gitlabBranch` 环境变量
|
||||
- `deploy/Jenkinsfile` 的 `Select environment` 阶段按 `gitlabBranch` 路由:单 job `kra` 同时服务 dev/main 两个环境,构建阶段优先按 push payload 的最终提交 SHA 检出
|
||||
- 手动构建兜底:`kra` job 手动触发时默认部署 main(不识别 tag,tag 仅作版本标记,不触发部署)
|
||||
|
||||
每个环境包含 3 个后端 Pod、3 个前端 Pod、1 个 MySQL Pod 和 1 个 Redis Pod。前端通过 `<APP_NAME>-backend` ClusterIP Service 访问后端,Kubernetes Service 在 Pod 之间做负载均衡;前端通过 NodePort `30080` 暴露,后端调试入口通过 NodePort `30081` 暴露。
|
||||
|
||||
MySQL、Redis、后端配置和上传文件都使用 `/var/lib/<APP_NAME>/` 下的节点本地目录。Redis 和后端会先用 root initContainer 修正 hostPath 根目录权限;后端通过 required podAffinity 固定到 MySQL 所在节点。当前两个 Rancher 集群都只有一个节点,这种方式适合当前环境,但不是真正的 RWX,也不提供跨节点高可用;扩展到多节点前必须迁移到 NFS、CephFS 等共享存储。
|
||||
|
||||
## Jenkins 前置条件
|
||||
|
||||
Jenkins 节点需要可用的 Docker CLI/daemon、`kubectl` 和 `envsubst`(Go 编译和前端构建在临时容器内完成)。Jenkins 凭据 ID 需要与 `deploy/Jenkinsfile` 中的 `CRED_*` 变量一致:
|
||||
|
||||
- `harbor-library-push`: Harbor `192.168.200.132` 的推送账号
|
||||
- `kra-rke2-128-kubeconfig`: 集群 128 的 kubeconfig 文件
|
||||
- `kra-rke2-129-kubeconfig`: 集群 129 的 kubeconfig 文件
|
||||
- `kra-mysql-root-password`: 应用专用 MySQL root 密码
|
||||
- `kra-jwt-signing-key`: 首次生成配置文件时使用的 JWT 签名密钥
|
||||
|
||||
Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-registry` 镜像拉取 Secret。不要把真实密码写入 Git。
|
||||
|
||||
## 访问地址
|
||||
|
||||
- 集群 128: `http://192.168.200.128:30080`
|
||||
- 集群 129: `http://192.168.200.129:30080`
|
||||
- 后端调试: 对应节点的 `30081`
|
||||
|
||||
## 首次部署初始化
|
||||
|
||||
### 前置:放置配置文件
|
||||
|
||||
后端启动读取 `/data/conf/config.yaml`。流水线会把 `deploy/k8s/config.example.yaml` 渲染成 Kubernetes Secret,并由初始化容器首次复制到节点目录 `/var/lib/<APP_NAME>/conf`;容器内运行用户为 UID/GID 1000,初始化向导会把数据库连接和 JWT 密钥写回该文件。
|
||||
|
||||
### 初始化
|
||||
|
||||
部署后数据库未配置,应用以 bootstrap 模式启动,首页会出现**前往初始化**。在初始化页面填写数据库连接(以 kra 项目为例):
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| 数据库类型 | mysql |
|
||||
| 主机 | `kra-mysql`(集群内 Service 名) |
|
||||
| 端口 | `3306` |
|
||||
| 用户名 | `root` |
|
||||
| 密码 | Jenkins 凭据 `kra-mysql-root-password` 的值 |
|
||||
| 库名 | `kra`(与应用名一致) |
|
||||
|
||||
提交后框架自动建表、写入种子数据(管理员账号、菜单、casbin)并把连接配置写回节点目录中的 `config.yaml`,Pod 重建后配置不丢失。当前单节点部署固定使用 1 个后端副本,避免多个 Pod 同时执行首次初始化。未来如需扩容,必须先迁移到真正的共享存储,再调整 `KRA_REPLICAS` 并滚动重启,让所有副本加载新配置。**不要**通过环境变量注入数据库 DSN:那会让应用跳过初始化向导,导致数据库有表无种子数据、无法登录。
|
||||
|
||||
## 基于 kra 模板接入新项目
|
||||
|
||||
复制 `deploy/` 目录到新项目,然后只需修改 `deploy/Jenkinsfile` 顶部的 **项目级参数块**:
|
||||
|
||||
| 变量 | 说明 | 默认值(kra 项目) |
|
||||
|---|---|---|
|
||||
| `APP_NAME` | 应用名,派生所有 K8s 资源名、数据库名、本地存储路径、镜像后缀 | `kra` |
|
||||
| `IMAGE_REPO` | Harbor 镜像仓库路径 | `library/kra` |
|
||||
| `REGISTRY` | Harbor 地址 | `192.168.200.132` |
|
||||
| `BIN_NAME` | 后端二进制名,须与 `Makefile` 的 `make build` 产物一致 | `kratos-admin` |
|
||||
| `KRA_REPLICAS` | 前后端副本数(当前单节点部署使用 1) | `1` |
|
||||
| `KRA_DEPLOY_MYSQL` | 是否启动清单内 MySQL(`true`/`false`,关闭时副本数为 0) | `true` |
|
||||
| `KRA_DEPLOY_REDIS` | 是否启动清单内 Redis(`true`/`false`,关闭时副本数为 0) | `true` |
|
||||
| `KRA_FRONTEND_NODEPORT` / `KRA_BACKEND_NODEPORT` | NodePort 端口,同一集群多个项目必须错开 | `30080` / `30081` |
|
||||
| `KRA_MYSQL_IMAGE` / `KRA_REDIS_IMAGE` | 中间件镜像 | daocloud mysql:8.4 / redis:7-alpine |
|
||||
| `GO_IMAGE` / `NODE_IMAGE` / `PNPM_VERSION` | 构建镜像与工具版本 | 见文件 |
|
||||
| `CACHE_DIR` | Jenkins 宿主机依赖缓存目录(建议每项目独立) | `/var/lib/kra-ci-cache` |
|
||||
| `CRED_*` | Jenkins 凭据 ID,沿用现有凭据可不改 | 见文件 |
|
||||
|
||||
### 模板渲染机制
|
||||
|
||||
- `deploy/k8s/kra.yaml` 是 K8s 清单模板,由 Jenkins 用 `envsubst "$K8S_RENDER_VARS"` 白名单渲染。所有资源名都以 `${KRA_APP}` 为前缀(由 `APP_NAME` 注入),不要把新的 `$变量` 混入清单(白名单外的 `$...` 会原样保留,例如 MySQL probe 里的 `$MYSQL_ROOT_PASSWORD`)。
|
||||
- 前端 Nginx 配置 `web/.docker-compose/nginx/conf.d/my.conf` 中的后端服务名写作 `${KRA_APP}-backend`,Jenkins 构建前端镜像前渲染为 `web/nginx.k8s.conf`(已 gitignore)。本地手动构建 web 镜像前需先执行:
|
||||
|
||||
```sh
|
||||
envsubst '$KRA_APP' < web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf
|
||||
```
|
||||
|
||||
### 命名约定
|
||||
|
||||
| 资源 | 名称 |
|
||||
|---|---|
|
||||
| Deployment/Service | `<APP_NAME>-backend`、`<APP_NAME>-frontend`、`<APP_NAME>-redis` |
|
||||
| StatefulSet/Service | `<APP_NAME>-mysql` |
|
||||
| 配置模板 Secret | `<APP_NAME>-config-template` |
|
||||
| 运行时 Secret | `<APP_NAME>-runtime`(key:`mysql-root-password`) |
|
||||
| 数据库名 | `<APP_NAME>` |
|
||||
|
||||
`internal/logging` 等框架代码与项目名无关,接入新项目时无需修改。
|
||||
@@ -0,0 +1,72 @@
|
||||
# 后端配置文件模板。
|
||||
# Jenkins 会渲染本文件并通过 Secret/初始化容器写入节点共享目录。
|
||||
# 容器内运行用户为 UID/GID 1000,初始化向导会把运行时配置写回本文件。
|
||||
#
|
||||
# 说明:
|
||||
# - data.database 保持为空:应用启动后首页会出现"前往初始化",
|
||||
# 在初始化页面填写数据库连接后,框架会自动把连接配置写回本文件。
|
||||
# - data.redis.addr 默认指向集群内 Redis Service(<APP_NAME>-redis:6379);
|
||||
# 关闭内置 Redis 时,Jenkins 会同时关闭 admin.system.use_redis。
|
||||
# - admin.app.env / admin.app.app_id / admin.jwt.issuer 由 Jenkins 按项目和分支渲染。
|
||||
server:
|
||||
http:
|
||||
network: tcp
|
||||
addr: 0.0.0.0:8000
|
||||
timeout: 600s
|
||||
data:
|
||||
database:
|
||||
driver: mysql
|
||||
source: ""
|
||||
host: ""
|
||||
port: ""
|
||||
user: ""
|
||||
password: ""
|
||||
name: ""
|
||||
config: ""
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
conn_max_lifetime: 3600
|
||||
engine: InnoDB
|
||||
log_mode: info
|
||||
singular: false
|
||||
redis:
|
||||
network: tcp
|
||||
name: default
|
||||
addr: ${APP_NAME}-redis:6379
|
||||
password: ""
|
||||
db: 0
|
||||
use_cluster: false
|
||||
read_timeout: 200ms
|
||||
write_timeout: 200ms
|
||||
admin:
|
||||
router_prefix: ""
|
||||
jwt:
|
||||
signing_key: '${KRA_JWT_SIGNING_KEY}'
|
||||
expires_time: 604800s
|
||||
buffer_time: 86400s
|
||||
issuer: ${APP_NAME}
|
||||
system:
|
||||
use_redis: false
|
||||
use_multipoint: false
|
||||
use_strict_auth: false
|
||||
disable_auto_migrate: false
|
||||
use_mongo: false
|
||||
addr: 8000
|
||||
local:
|
||||
store_path: uploads/file
|
||||
path_prefix: uploads/file
|
||||
media:
|
||||
session_ttl: 24
|
||||
max_file_size: "0"
|
||||
chunk_dir: uploads/chunks
|
||||
zap:
|
||||
level: info
|
||||
format: json
|
||||
director: logs
|
||||
log_in_console: true
|
||||
cors:
|
||||
mode: whitelist
|
||||
whitelist: []
|
||||
app:
|
||||
app_id: ${APP_NAME}
|
||||
env: ${KRA_ENV}
|
||||
@@ -0,0 +1,453 @@
|
||||
# Template variables (rendered by Jenkins `envsubst "$K8S_RENDER_VARS"`):
|
||||
# KRA_APP application name, derives every resource name
|
||||
# KRA_NAMESPACE target namespace
|
||||
# KRA_ENV development | production
|
||||
# KRA_REPLICAS replicas per deployment
|
||||
# KRA_DEPLOY_MYSQL true/false; whether the bundled MySQL runs
|
||||
# KRA_DEPLOY_REDIS true/false; whether the bundled Redis runs
|
||||
# KRA_MYSQL_REPLICAS rendered MySQL replica count (0 or 1)
|
||||
# KRA_REDIS_REPLICAS rendered Redis replica count (0 or 1)
|
||||
# KRA_MYSQL_WAIT_IMAGE image used by the optional MySQL readiness wait
|
||||
# KRA_FRONTEND_NODEPORT node port for the frontend service
|
||||
# KRA_BACKEND_NODEPORT node port for the backend debug service
|
||||
# KRA_MYSQL_IMAGE mysql server image
|
||||
# KRA_REDIS_IMAGE redis server image
|
||||
# KRA_BACKEND_IMAGE backend image built by Jenkins
|
||||
# KRA_FRONTEND_IMAGE frontend image built by Jenkins
|
||||
# BIN_NAME backend binary name inside the image
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ${KRA_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
app.kubernetes.io/managed-by: jenkins
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: ${KRA_APP}-mysql
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
serviceName: ${KRA_APP}-mysql
|
||||
replicas: ${KRA_MYSQL_REPLICAS}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
containers:
|
||||
- name: mysql
|
||||
image: ${KRA_MYSQL_IMAGE}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: mysql
|
||||
containerPort: 3306
|
||||
env:
|
||||
- name: MYSQL_DATABASE
|
||||
value: ${KRA_APP}
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${KRA_APP}-runtime
|
||||
key: mysql-root-password
|
||||
volumeMounts:
|
||||
- name: mysql-data
|
||||
mountPath: /var/lib/mysql
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$MYSQL_ROOT_PASSWORD\" --silent"]
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 12
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$MYSQL_ROOT_PASSWORD\" --silent"]
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 2Gi
|
||||
volumes:
|
||||
- name: mysql-data
|
||||
hostPath:
|
||||
path: /var/lib/${KRA_APP}/mysql
|
||||
type: DirectoryOrCreate
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${KRA_APP}-mysql
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||
ports:
|
||||
- name: mysql
|
||||
port: 3306
|
||||
targetPort: mysql
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${KRA_APP}-redis
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ${KRA_APP}-redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: redis
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${KRA_APP}-redis
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-redis
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
replicas: ${KRA_REDIS_REPLICAS}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-redis
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
initContainers:
|
||||
- name: prepare-redis-storage
|
||||
image: ${KRA_REDIS_IMAGE}
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
runAsGroup: 0
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- chown -R redis:redis /data
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
affinity:
|
||||
podAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
topologyKey: kubernetes.io/hostname
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||
containers:
|
||||
- name: redis
|
||||
image: ${KRA_REDIS_IMAGE}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
volumes:
|
||||
- name: redis-data
|
||||
hostPath:
|
||||
path: /var/lib/${KRA_APP}/redis
|
||||
type: DirectoryOrCreate
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${KRA_APP}-backend
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
replicas: ${KRA_REPLICAS}
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
# 单节点 hostPath 不能让新旧 backend Pod 并存,否则会同时操作同一份配置和上传目录。
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
automountServiceAccountToken: false
|
||||
initContainers:
|
||||
- name: wait-for-mysql
|
||||
image: ${KRA_MYSQL_WAIT_IMAGE}
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${KRA_APP}-runtime
|
||||
key: mysql-root-password
|
||||
optional: true
|
||||
command: ["sh", "-c"]
|
||||
# 用字面量块(|)而非折叠标量(>-):嵌套缩进会让折叠标量保留换行,
|
||||
# 把一条命令拆成多行导致 "command not found"。
|
||||
args:
|
||||
- |
|
||||
if [ "${KRA_DEPLOY_MYSQL}" = "true" ]; then
|
||||
test -n "$MYSQL_ROOT_PASSWORD" || { echo "MYSQL_ROOT_PASSWORD is required when bundled MySQL is enabled" >&2; exit 1; }
|
||||
until mysqladmin ping -h "${KRA_APP}-mysql" -P 3306 -uroot -p"$MYSQL_ROOT_PASSWORD" --connect-timeout=2 --silent; do sleep 2; done;
|
||||
fi
|
||||
# 这里只等待 MySQL 服务可达,不要求应用数据库已经初始化;首次部署
|
||||
# 仍需进入 bootstrap 模式完成 /init/initdb 向导。
|
||||
- name: initialize-shared-storage
|
||||
image: ${KRA_BACKEND_IMAGE}
|
||||
imagePullPolicy: Always
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
runAsGroup: 0
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
mkdir -p /data/conf /app/uploads;
|
||||
if [ ! -f /data/conf/config.yaml ]; then
|
||||
cp /bootstrap/config.yaml /data/conf/config.yaml;
|
||||
fi;
|
||||
chown 1000:1000 /data/conf /data/conf/config.yaml /app/uploads;
|
||||
chmod u+rwX,g+rwX /data/conf /data/conf/config.yaml /app/uploads
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /data/conf
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
- name: config-template
|
||||
mountPath: /bootstrap
|
||||
readOnly: true
|
||||
# /data/conf 由初始化容器从 Secret 初始化到节点共享目录,
|
||||
# 目录必须可写:初始化向导会把数据库连接写回该文件。
|
||||
imagePullSecrets:
|
||||
- name: harbor-registry
|
||||
affinity:
|
||||
podAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
topologyKey: kubernetes.io/hostname
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
topologyKey: kubernetes.io/hostname
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||
containers:
|
||||
- name: backend
|
||||
image: ${KRA_BACKEND_IMAGE}
|
||||
imagePullPolicy: Always
|
||||
command: ["./${BIN_NAME}"]
|
||||
args: ["-conf", "/data/conf"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
env:
|
||||
# 数据库连接不在这里注入:应用启动时未配置数据库会进入 bootstrap 模式,
|
||||
# 首页出现"前往初始化",由 /init/initdb 建表、写入种子数据并持久化连接配置。
|
||||
# JWT 签名密钥由 /data/conf/config.yaml 唯一管理,避免环境变量覆盖管理页面的配置变更;
|
||||
# Redis 地址已在下方 config.yaml 中指定。
|
||||
- name: KRA_ADMIN_SYSTEM_USE_REDIS
|
||||
value: "${KRA_DEPLOY_REDIS}"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /data/conf
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
# 当前是单节点集群;required podAffinity 保证所有后端副本落在
|
||||
# MySQL 所在节点,从而共享同一组 hostPath 目录。它不是真正的 RWX。
|
||||
# 扩展到多节点前,必须换成 PVC + 真正的共享存储(如 NFS/CephFS)。
|
||||
- name: config
|
||||
hostPath:
|
||||
path: /var/lib/${KRA_APP}/conf
|
||||
type: DirectoryOrCreate
|
||||
- name: uploads
|
||||
hostPath:
|
||||
path: /var/lib/${KRA_APP}/uploads
|
||||
type: DirectoryOrCreate
|
||||
- name: config-template
|
||||
secret:
|
||||
secretName: ${KRA_APP}-config-template
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${KRA_APP}-backend
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||
ports:
|
||||
- name: http
|
||||
port: 8000
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${KRA_APP}-backend-nodeport
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||
ports:
|
||||
- name: http
|
||||
port: 8000
|
||||
targetPort: http
|
||||
nodePort: ${KRA_BACKEND_NODEPORT}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${KRA_APP}-frontend
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-frontend
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
replicas: ${KRA_REPLICAS}
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
# 单节点部署只保留一个前端 Pod,避免更新期间短暂扩出第二个 Pod。
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-frontend
|
||||
app.kubernetes.io/part-of: ${KRA_APP}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: harbor-registry
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
topologyKey: kubernetes.io/hostname
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${KRA_APP}-frontend
|
||||
containers:
|
||||
- name: frontend
|
||||
image: ${KRA_FRONTEND_IMAGE}
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${KRA_APP}-frontend
|
||||
namespace: ${KRA_NAMESPACE}
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app.kubernetes.io/name: ${KRA_APP}-frontend
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
nodePort: ${KRA_FRONTEND_NODEPORT}
|
||||
@@ -0,0 +1,16 @@
|
||||
# 运行时 Secret 示例。
|
||||
# 真实环境由 Jenkinsfile 的 Deploy 阶段自动创建(kubectl create secret generic ${APP_NAME}-runtime),
|
||||
# 本文件仅用于手工初始化环境的场景。尖括号为模板变量,使用前先替换成实际值。
|
||||
#
|
||||
# 模板变量:
|
||||
# APP_NAME 应用名(Jenkinsfile 中 APP_NAME,kra 项目即为 kra)
|
||||
# KRA_NAMESPACE 目标命名空间(如 kra-dev / kra-prod)
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: <APP_NAME>-runtime
|
||||
namespace: <KRA_NAMESPACE>
|
||||
type: Opaque
|
||||
stringData:
|
||||
# 替换为随机值;不要把真实密码提交进 Git。
|
||||
mysql-root-password: "CHANGE_ME_TO_A_RANDOM_VALUE"
|
||||
@@ -0,0 +1,42 @@
|
||||
# 短信集成
|
||||
|
||||
管理入口:**扩展功能 → 短信服务**。支持阿里云和腾讯云官方 Go SDK。
|
||||
配置保存在现有集成配置表中,保存后立即生效,服务重启后自动恢复。
|
||||
新安装自动创建菜单;已有安装启动时通过 `202609290001_sms_admin_surface` 迁移补上菜单和超级管理员发送权限。
|
||||
其他角色需要分配短信菜单、集成配置读写权限及短信发送权限。
|
||||
|
||||
## 配置
|
||||
|
||||
- 阿里云:AccessKey ID、AccessKey Secret、审核通过的短信签名、模板 CODE。当前接入中国大陆 `SendSms` 接口。
|
||||
- 腾讯云:Secret ID、Secret Key、短信应用 SDK App ID、地域(默认 `ap-guangzhou`)、签名、模板 ID。
|
||||
- 保存并启用后才能发送。保留密钥的 `******` 占位符表示沿用原值;不在配置文件中存储凭证。
|
||||
- 每个服务商当前配置一个签名及模板。切换模板需修改配置。
|
||||
|
||||
## 发送
|
||||
|
||||
`POST /integration/sms/:provider/send`,`:provider` 为 `aliyun` 或 `tencent`。
|
||||
接口位于现有鉴权和权限控制中,遵循 `{code,data,msg}` 响应格式。每次只发送给一个号码。
|
||||
|
||||
阿里云参数示例:
|
||||
|
||||
```json
|
||||
{"phone":"+8613800138000","params":{"code":"123456"}}
|
||||
```
|
||||
|
||||
腾讯云参数示例:
|
||||
|
||||
```json
|
||||
{"phone":"+8613800138000","params":["123456","5"]}
|
||||
```
|
||||
|
||||
参数值必须为字符串;阿里云使用命名对象,腾讯云按模板顺序填写数组。
|
||||
无参数模板分别传 `{}` 或 `[]`。手机号使用包含 `+` 和国家区号的格式。
|
||||
|
||||
成功返回 `data.requestId` 和 `data.messageId`,表示服务商已受理,不代表手机已收到。
|
||||
失败返回服务商错误码(例如余额不足、签名未审核、频率限制),不回传原始 SDK 请求或凭证。
|
||||
网络失败不能保证短信未发送,核对服务商记录后再决定是否重试。
|
||||
|
||||
测试按钮会实际发送并可能计费。应用和 SDK 均不自动重试发送请求;腾讯云调用受请求上下文控制,阿里云 SDK 的连接和读取各限制为最多 10 秒。
|
||||
发送日志隐藏手机号及模板参数,操作审计保留操作者和接口信息。真实发送需要自行配置云账户凭证、审核通过的签名及模板。
|
||||
|
||||
此集成提供模板短信发送能力,不包含验证码生成、校验或登录流程。
|
||||
@@ -3,6 +3,10 @@ module kra
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.11
|
||||
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3
|
||||
github.com/alibabacloud-go/tea v1.2.2
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.6
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10
|
||||
@@ -19,6 +23,7 @@ require (
|
||||
github.com/go-kratos/kratos/v3 v3.0.0
|
||||
github.com/go-pay/crypto v0.0.1
|
||||
github.com/go-pay/gopay v1.5.122
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -38,6 +43,8 @@ require (
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.172
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.172
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.60
|
||||
github.com/xuri/excelize/v2 v2.9.0
|
||||
go.mongodb.org/mongo-driver v1.17.2
|
||||
@@ -62,6 +69,12 @@ require (
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
|
||||
github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
||||
github.com/aliyun/credentials-go v1.4.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||
@@ -83,6 +96,7 @@ require (
|
||||
github.com/casbin/govaluate v1.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.5.5 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
@@ -110,7 +124,6 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gofrs/flock v0.8.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
@@ -159,6 +172,7 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/thoas/go-funk v0.7.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
@@ -187,6 +201,7 @@ require (
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/fileutil v1.0.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
@@ -18,6 +19,7 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
@@ -29,8 +31,55 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
|
||||
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
|
||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
|
||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.11 h1:GkVQ9AphMCmgAYakcTpH/OuFz0mQUypO/JiOvo0wgVA=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.11/go.mod h1:wHxkgZT1ClZdcwEVP/pDgYK/9HucsnCfMipmJgCz4xY=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
|
||||
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3 h1:32N2pGk28weVZ5/rjNk9gPx/jrRkR0rX9i8Id6IlyUY=
|
||||
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3/go.mod h1:gPbHx4BTxLIDNRfYNGGmp6aIpeNBamtdDlPcK4UTUto=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.1 h1:ujGErJjG8ncRW6XtBBMphzHTvCxn4DjrVw4m04HsS28=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw=
|
||||
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU=
|
||||
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.6 h1:ZkmUlhlQbaDC+Eba/GARMPy6hKdCLiSke5RsN5LcyQ0=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
|
||||
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
@@ -85,14 +134,19 @@ github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQu
|
||||
github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
|
||||
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
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/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E=
|
||||
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/dave/jennifer v1.6.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -112,6 +166,9 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
@@ -202,10 +259,25 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -221,6 +293,8 @@ 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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
@@ -254,8 +328,10 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -300,6 +376,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
|
||||
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 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
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/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
@@ -331,6 +409,7 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk=
|
||||
github.com/qiniu/go-sdk/v7 v7.25.2 h1:URwgZpxySdiwu2yQpHk93X4LXWHyFRp1x3Vmlk/YWvo=
|
||||
github.com/qiniu/go-sdk/v7 v7.25.2/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o=
|
||||
@@ -365,6 +444,9 @@ github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1
|
||||
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
|
||||
github.com/sijms/go-ora/v2 v2.7.17 h1:M/pYIqjaMUeBxyzOWp2oj4ntF6fHSBloJWGNH9vbmsU=
|
||||
github.com/sijms/go-ora/v2 v2.7.17/go.mod h1:EHxlY6x7y9HAsdfumurRfTd+v8NrEOTR3Xl4FWlH6xk=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -376,11 +458,13 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -399,13 +483,20 @@ github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRd
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.172 h1:q2hBMhsSSV1dIawliBqDDIU9L1G+tWpu3hdmJWvYG44=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.172/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.172 h1:8YBaibwP8YXcYP+4qdgoqIwNULYNt5/AyVP7t3J3hCA=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.172/go.mod h1:eLllmEVIjbeQpcXHpd/68caL4/EgHBbHruRCmExcv+M=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.60 h1:/e/tmvRmfKexr/QQIBzWhOkZWsmY3EK72NrI6G/Tv0o=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.60/go.mod h1:8+hG+mQMuRP/OIS9d83syAvXvrMj9HhkND6Q1fLghw0=
|
||||
github.com/thoas/go-funk v0.7.0 h1:GmirKrs6j6zJbhJIficOsz2aAI7700KsU/5YrdHRM1Y=
|
||||
github.com/thoas/go-funk v0.7.0/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
@@ -428,6 +519,8 @@ github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNh
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
@@ -461,12 +554,17 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
@@ -475,8 +573,13 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -485,9 +588,16 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
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.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -498,15 +608,22 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
@@ -516,9 +633,13 @@ golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -533,6 +654,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
@@ -550,6 +672,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
@@ -578,7 +701,13 @@ golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
@@ -586,15 +715,30 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
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=
|
||||
@@ -603,6 +747,9 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
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.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -623,6 +770,8 @@ gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w=
|
||||
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
IntegrationKindSMS = "sms"
|
||||
IntegrationKindPayment = "payment"
|
||||
IntegrationKindMQ = "mq"
|
||||
IntegrationKindWebSocket = "websocket"
|
||||
@@ -255,6 +256,8 @@ func ValidateIntegrationConfig(kind, provider string, values map[string]any) err
|
||||
kind = normalizeIntegrationPart(kind)
|
||||
provider = normalizeIntegrationPart(provider)
|
||||
switch kind {
|
||||
case IntegrationKindSMS:
|
||||
return validateSMSConfig(provider, values)
|
||||
case IntegrationKindPayment:
|
||||
return validatePaymentIntegrationConfig(provider, values)
|
||||
case IntegrationKindMQ, IntegrationKindWebSocket:
|
||||
|
||||
@@ -17,6 +17,31 @@ func integrationSelect(key, label string, required bool, values ...string) Integ
|
||||
return IntegrationConfigField{Key: key, Label: label, Required: required, Type: "select", Options: options}
|
||||
}
|
||||
|
||||
var paymentOptionLabels = map[string]string{
|
||||
"production": "生产环境", "sandbox": "沙箱环境",
|
||||
"alipay.trade.create": "统一收单交易创建", "alipay.trade.pay": "付款码支付",
|
||||
"alipay.trade.precreate": "当面付扫码", "alipay.trade.app.pay": "App 支付",
|
||||
"alipay.trade.page.pay": "电脑网站支付", "alipay.trade.wap.pay": "手机网站支付",
|
||||
"JSAPI": "公众号 / 小程序支付", "APP": "App 支付", "NATIVE": "扫码支付",
|
||||
"MWEB": "H5 支付", "MICROPAY": "付款码支付",
|
||||
"jsapi": "公众号 / 小程序支付", "app": "App 支付", "native": "扫码支付",
|
||||
"h5": "H5 支付", "codepay": "付款码支付", "mini": "小程序支付",
|
||||
"qrcode": "二维码支付", "native_jsapi": "扫码后小程序支付", "sdk": "SDK 支付",
|
||||
"web": "网页支付", "retail": "门店收银", "retail_qrcode": "门店扫码",
|
||||
"Wechat": "微信支付", "Alipay": "支付宝", "UnionPay": "银联",
|
||||
"reqsn": "商户订单号", "trxid": "平台交易号",
|
||||
}
|
||||
|
||||
func paymentSelect(key, label string, required bool, values ...string) IntegrationConfigField {
|
||||
field := integrationSelect(key, label, required, values...)
|
||||
for i := range field.Options {
|
||||
if name := paymentOptionLabels[values[i]]; name != "" {
|
||||
field.Options[i].Label = name + " (" + values[i] + ")"
|
||||
}
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
func paymentDefinition(provider, name, description string, defaults map[string]any, fields ...IntegrationConfigField) IntegrationConfigDefinition {
|
||||
common := []IntegrationConfigField{
|
||||
integrationField("notify_url", "支付回调地址", false, false, "url"),
|
||||
@@ -95,6 +120,22 @@ func genericPaymentDefinition(provider, name, description string) IntegrationCon
|
||||
}
|
||||
|
||||
var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||
IntegrationKindSMS: {
|
||||
{Kind: IntegrationKindSMS, Provider: "aliyun", Name: "阿里云短信", Description: "中国大陆短信,使用已审核的签名和模板。", Defaults: map[string]any{"access_key_id": "", "access_key_secret": "", "sign_name": "", "template_id": ""}, Fields: []IntegrationConfigField{
|
||||
integrationField("access_key_id", "AccessKey ID", true, false, "text"),
|
||||
integrationField("access_key_secret", "AccessKey Secret", true, true, "password"),
|
||||
integrationField("sign_name", "短信签名", true, false, "text"),
|
||||
integrationField("template_id", "模板 CODE", true, false, "text"),
|
||||
}},
|
||||
{Kind: IntegrationKindSMS, Provider: "tencent", Name: "腾讯云短信", Description: "使用短信应用、已审核的签名和模板,模板参数按顺序填写。", Defaults: map[string]any{"secret_id": "", "secret_key": "", "sdk_app_id": "", "region": "ap-guangzhou", "sign_name": "", "template_id": ""}, Fields: []IntegrationConfigField{
|
||||
integrationField("secret_id", "Secret ID", true, true, "password"),
|
||||
integrationField("secret_key", "Secret Key", true, true, "password"),
|
||||
integrationField("sdk_app_id", "短信应用 SDK App ID", true, false, "text"),
|
||||
integrationField("region", "地域", true, false, "text"),
|
||||
integrationField("sign_name", "短信签名", true, false, "text"),
|
||||
integrationField("template_id", "模板 ID", true, false, "text"),
|
||||
}},
|
||||
},
|
||||
IntegrationKindMQ: {
|
||||
{
|
||||
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
|
||||
@@ -172,29 +213,29 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||
IntegrationKindPayment: {
|
||||
paymentDefinition(bizpayment.PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
||||
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationSelect("sign_type", "签名算法", false, "RSA2", "RSA"), integrationField("gateway_url", "网关地址", false, false, "url"),
|
||||
integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||
paymentSelect("environment", "环境", false, "production", "sandbox"), integrationSelect("sign_type", "签名算法", false, "RSA2", "RSA"), integrationField("gateway_url", "网关地址", false, false, "url"),
|
||||
paymentSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||
paymentDefinition(bizpayment.PaymentAlipayV3, "支付宝 V3", "支付宝证书模式 V3 接口", map[string]any{"app_id": "", "private_key": "", "app_cert": "", "root_cert": "", "public_cert": "", "environment": "production", "api_base_url": "https://openapi.alipay.com", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("app_cert", "应用公钥证书", true, true, "textarea"), integrationField("root_cert", "支付宝根证书", true, true, "textarea"), integrationField("public_cert", "支付宝公钥证书", true, true, "textarea"),
|
||||
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationField("api_base_url", "API 地址", false, false, "url"), integrationField("gateway_url", "网关地址", false, false, "url"), integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||
paymentSelect("environment", "环境", false, "production", "sandbox"), integrationField("api_base_url", "API 地址", false, false, "url"), integrationField("gateway_url", "网关地址", false, false, "url"), paymentSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||
paymentDefinition(bizpayment.PaymentWechatV2, "微信支付 V2", "微信支付 V2,含退款双向证书", map[string]any{"app_id": "", "merchant_id": "", "mch_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "client_cert": "", "client_key": ""},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("mch_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "APP", "NATIVE", "MWEB", "MICROPAY"), integrationField("client_cert", "商户证书", true, true, "textarea"), integrationField("client_key", "证书私钥", true, true, "textarea")),
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("mch_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), paymentSelect("trade_type", "默认交易类型", false, "JSAPI", "APP", "NATIVE", "MWEB", "MICROPAY"), integrationField("client_cert", "商户证书", true, true, "textarea"), integrationField("client_key", "证书私钥", true, true, "textarea")),
|
||||
paymentDefinition(bizpayment.PaymentWechatV3, "微信支付 V3", "微信支付 API v3", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "private_key": "", "api_v3_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("api_v3_key", "API v3 密钥", true, true, "password"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", false, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "jsapi", "app", "native", "h5", "codepay")),
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("api_v3_key", "API v3 密钥", true, true, "password"), IntegrationConfigField{Key: "platform_cert", Label: "平台证书 / 微信支付公钥", Type: "textarea", Secret: true, Description: "可留空,使用商户凭证自动获取平台证书;使用微信支付公钥模式时填写公钥和对应 ID。"}, integrationField("platform_serial_no", "证书序列号 / 公钥 ID", false, false, "text"), paymentSelect("trade_type", "默认交易类型", false, "jsapi", "app", "native", "h5", "codepay")),
|
||||
paymentDefinition(bizpayment.PaymentApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production", "test_product_id": "", "test_transaction_id": ""},
|
||||
integrationField("issuer_id", "Issuer ID", true, false, "text"), integrationField("key_id", "Key ID", true, false, "text"), integrationField("bundle_id", "Bundle ID", true, false, "text"), integrationField("private_key", "P8 私钥", true, true, "textarea"), integrationField("price_divisor", "价格除数", false, false, "number"), integrationSelect("environment", "环境", false, "production", "sandbox"), integrationField("test_product_id", "测试商品 ID", false, false, "text"), integrationField("test_transaction_id", "沙箱交易 ID", false, false, "text")),
|
||||
integrationField("issuer_id", "Issuer ID", true, false, "text"), integrationField("key_id", "Key ID", true, false, "text"), integrationField("bundle_id", "Bundle ID", true, false, "text"), integrationField("private_key", "P8 私钥", true, true, "textarea"), integrationField("price_divisor", "价格除数", false, false, "number"), paymentSelect("environment", "环境", false, "production", "sandbox"), integrationField("test_product_id", "测试商品 ID", false, false, "text"), integrationField("test_transaction_id", "沙箱交易 ID", false, false, "text")),
|
||||
paymentDefinition(bizpayment.PaymentDouyin, "抖音支付", "抖音开放平台支付", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "api_key": "", "private_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi", "environment": "production"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", true, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "app", "jsapi", "h5", "native"), integrationSelect("environment", "环境", false, "production")),
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", true, false, "text"), paymentSelect("trade_type", "默认交易类型", false, "app", "jsapi", "h5", "native"), paymentSelect("environment", "环境", false, "production")),
|
||||
paymentDefinition(bizpayment.PaymentQQ, "QQ 钱包", "QQ 钱包支付", map[string]any{"mch_id": "", "api_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "cert_file": "", "key_file": "", "environment": "production"},
|
||||
integrationField("mch_id", "商户号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "NATIVE", "APP", "MICROPAY"), integrationField("cert_file", "退款证书路径", false, false, "text"), integrationField("key_file", "退款私钥路径", false, true, "text"), integrationSelect("environment", "环境", false, "production")),
|
||||
integrationField("mch_id", "商户号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), paymentSelect("trade_type", "默认交易类型", false, "JSAPI", "NATIVE", "APP", "MICROPAY"), integrationField("cert_file", "退款证书路径", false, false, "text"), integrationField("key_file", "退款私钥路径", false, true, "text"), paymentSelect("environment", "环境", false, "production")),
|
||||
paymentDefinition(bizpayment.PaymentAllinPay, "通联支付", "通联收银宝支付", map[string]any{"cus_id": "", "app_id": "", "private_key": "", "public_key": "", "org_id": "", "pay_type": "W02", "query_order_type": "reqsn", "currency": "CNY", "environment": "production"},
|
||||
integrationField("cus_id", "商户号", true, false, "text"), integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("public_key", "平台公钥", true, true, "textarea"), integrationField("org_id", "机构号", false, false, "text"), integrationField("pay_type", "支付类型", false, false, "text"), integrationSelect("query_order_type", "查单标识", false, "reqsn", "trxid"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
||||
integrationField("cus_id", "商户号", true, false, "text"), integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("public_key", "平台公钥", true, true, "textarea"), integrationField("org_id", "机构号", false, false, "text"), integrationField("pay_type", "支付类型", false, false, "text"), paymentSelect("query_order_type", "查单标识", false, "reqsn", "trxid"), integrationField("currency", "币种", false, false, "text"), paymentSelect("environment", "环境", false, "production", "sandbox")),
|
||||
paymentDefinition(bizpayment.PaymentLakala, "拉卡拉", "拉卡拉聚合支付", map[string]any{"partner_code": "", "credential_code": "", "channel": "Wechat", "method": "jsapi", "currency": "CNY", "environment": "production"},
|
||||
integrationField("partner_code", "合作方编号", true, false, "text"), integrationField("credential_code", "凭证码", true, true, "password"), integrationSelect("channel", "支付渠道", false, "Wechat", "Alipay", "UnionPay"), integrationSelect("method", "默认支付方式", false, "jsapi", "h5", "mini", "native", "qrcode", "native_jsapi", "sdk", "web", "retail", "retail_qrcode"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production")),
|
||||
integrationField("partner_code", "合作方编号", true, false, "text"), integrationField("credential_code", "凭证码", true, true, "password"), paymentSelect("channel", "支付渠道", false, "Wechat", "Alipay", "UnionPay"), paymentSelect("method", "默认支付方式", false, "jsapi", "h5", "mini", "native", "qrcode", "native_jsapi", "sdk", "web", "retail", "retail_qrcode"), integrationField("currency", "币种", false, false, "text"), paymentSelect("environment", "环境", false, "production")),
|
||||
paymentDefinition(bizpayment.PaymentPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true, "test_currency": "USD"},
|
||||
integrationField("client_id", "Client ID", true, false, "text"), integrationField("client_secret", "Client Secret", true, true, "password"), integrationField("webhook_id", "Webhook ID", true, false, "text"), integrationSelect("environment", "环境", false, "sandbox", "production"), integrationField("cancel_url", "取消跳转地址", false, false, "url"), integrationField("auto_capture", "自动捕获", false, false, "switch")),
|
||||
integrationField("client_id", "Client ID", true, false, "text"), integrationField("client_secret", "Client Secret", true, true, "password"), integrationField("webhook_id", "Webhook ID", true, false, "text"), paymentSelect("environment", "环境", false, "sandbox", "production"), integrationField("cancel_url", "取消跳转地址", false, false, "url"), integrationField("auto_capture", "自动捕获", false, false, "switch")),
|
||||
paymentDefinition(bizpayment.PaymentSaobei, "扫呗", "扫呗聚合支付", map[string]any{"inst_no": "", "key": "", "merchant_no": "", "terminal_id": "", "access_token": "", "pay_type": "010", "currency": "CNY", "environment": "production"},
|
||||
integrationField("inst_no", "机构号", true, false, "text"), integrationField("key", "机构密钥", true, true, "password"), integrationField("merchant_no", "商户号", true, false, "text"), integrationField("terminal_id", "终端号", true, false, "text"), integrationField("access_token", "访问令牌", true, true, "password"), integrationField("pay_type", "支付类型", false, false, "text"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
||||
integrationField("inst_no", "机构号", true, false, "text"), integrationField("key", "机构密钥", true, true, "password"), integrationField("merchant_no", "商户号", true, false, "text"), integrationField("terminal_id", "终端号", true, false, "text"), integrationField("access_token", "访问令牌", true, true, "password"), integrationField("pay_type", "支付类型", false, false, "text"), paymentSelect("environment", "环境", false, "production", "sandbox")),
|
||||
genericPaymentDefinition(bizpayment.PaymentChinaums, "银联商务", "按商户协议配置的银联商务适配器"),
|
||||
genericPaymentDefinition(bizpayment.PaymentSFT, "商福通", "按商户协议配置的商福通适配器"),
|
||||
genericPaymentDefinition(bizpayment.PaymentSuperPay, "Supper Pay", "按商户协议配置的 Supper Pay 适配器"),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPaymentSelectLabelsKeepProtocolValues(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
provider, key, value, label string
|
||||
}{
|
||||
{bizpayment.PaymentWechatV3, "trade_type", "native", "扫码支付 (native)"},
|
||||
{bizpayment.PaymentWechatV2, "trade_type", "MICROPAY", "付款码支付 (MICROPAY)"},
|
||||
{bizpayment.PaymentAlipay, "method", "alipay.trade.wap.pay", "手机网站支付 (alipay.trade.wap.pay)"},
|
||||
} {
|
||||
definition, ok := IntegrationDefinition(IntegrationKindPayment, test.provider)
|
||||
if !ok {
|
||||
t.Fatalf("missing definition %s", test.provider)
|
||||
}
|
||||
found := false
|
||||
for _, field := range definition.Fields {
|
||||
if field.Key != test.key {
|
||||
continue
|
||||
}
|
||||
for _, option := range field.Options {
|
||||
if option.Value == test.value {
|
||||
found = true
|
||||
if option.Label != test.label {
|
||||
t.Errorf("%s/%s label = %q, want %q", test.provider, test.value, option.Label, test.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing option %s/%s", test.provider, test.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatV3PlatformCertificateIsOptional(t *testing.T) {
|
||||
definition, _ := IntegrationDefinition(IntegrationKindPayment, bizpayment.PaymentWechatV3)
|
||||
for _, field := range definition.Fields {
|
||||
if field.Key == "platform_cert" && field.Required {
|
||||
t.Fatal("platform certificate is marked required")
|
||||
}
|
||||
}
|
||||
values := DefaultIntegrationConfig(IntegrationKindPayment, bizpayment.PaymentWechatV3)
|
||||
values["app_id"] = "wx-app"
|
||||
values["merchant_id"] = "merchant"
|
||||
values["serial_no"] = "merchant-serial"
|
||||
values["private_key"] = "merchant-private-key"
|
||||
values["api_v3_key"] = "01234567890123456789012345678901"
|
||||
if err := ValidateIntegrationConfig(IntegrationKindPayment, bizpayment.PaymentWechatV3, values); err != nil {
|
||||
t.Fatalf("valid merchant credentials rejected without platform certificate: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,4 @@ package integration
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet wires communication and integration-configuration usecases.
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigUsecase)
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigUsecase, NewSMSUsecase)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSMSInvalid = errors.BadRequest("SMS_INVALID", "短信参数无效")
|
||||
ErrSMSDisabled = errors.BadRequest("SMS_DISABLED", "短信渠道未配置或未启用")
|
||||
ErrSMSFailed = errors.BadRequest("SMS_SEND_FAILED", "短信提交失败,请检查服务商配置和账户状态")
|
||||
)
|
||||
|
||||
// SMSMessage carries one recipient and provider-specific template parameters.
|
||||
type SMSMessage struct {
|
||||
Provider string
|
||||
Phone string
|
||||
NamedParams map[string]string
|
||||
Params []string
|
||||
}
|
||||
|
||||
type SMSResult struct {
|
||||
RequestID string
|
||||
MessageID string
|
||||
}
|
||||
|
||||
type SMSSender interface {
|
||||
Send(context.Context, *SMSMessage) (*SMSResult, error)
|
||||
}
|
||||
|
||||
type SMSUsecase struct{ sender SMSSender }
|
||||
|
||||
func NewSMSUsecase(sender SMSSender) *SMSUsecase { return &SMSUsecase{sender: sender} }
|
||||
|
||||
var smsPhonePattern = regexp.MustCompile(`^\+[1-9][0-9]{6,14}$`)
|
||||
|
||||
func ValidateSMSMessage(m *SMSMessage) error {
|
||||
if m == nil || (m.Provider != "aliyun" && m.Provider != "tencent") || !smsPhonePattern.MatchString(m.Phone) {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
// The Aliyun adapter uses the mainland China SendSms API.
|
||||
if m.Provider == "aliyun" && (!strings.HasPrefix(m.Phone, "+861") || len(m.Phone) != 14 || len(m.Params) != 0) {
|
||||
return errors.BadRequest("SMS_INVALID", "阿里云短信请使用 +86 开头的中国大陆手机号及对象格式模板参数")
|
||||
}
|
||||
if m.Provider == "tencent" && len(m.NamedParams) != 0 {
|
||||
return errors.BadRequest("SMS_INVALID", "腾讯云短信模板参数必须是按模板顺序排列的字符串数组")
|
||||
}
|
||||
if len(m.NamedParams) > 20 || len(m.Params) > 20 {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
for key, value := range m.NamedParams {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 64 || len(value) > 500 {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
}
|
||||
for _, value := range m.Params {
|
||||
if len(value) > 500 {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *SMSUsecase) Send(ctx context.Context, m *SMSMessage) (*SMSResult, error) {
|
||||
if err := ValidateSMSMessage(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.sender.Send(ctx, m)
|
||||
}
|
||||
|
||||
func validateSMSConfig(provider string, values map[string]any) error {
|
||||
definition, ok := IntegrationDefinition(IntegrationKindSMS, provider)
|
||||
if !ok {
|
||||
return errors.BadRequest("SMS_PROVIDER_INVALID", "不支持的短信服务商")
|
||||
}
|
||||
for _, field := range definition.Fields {
|
||||
value, ok := values[field.Key].(string)
|
||||
if !ok || len(value) > 512 || (field.Required && strings.TrimSpace(value) == "") {
|
||||
return errors.BadRequest("SMS_CONFIG_INVALID", "请填写有效的"+field.Label)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -435,7 +435,14 @@ func (uc *PaymentUsecase) OrderEvents(ctx context.Context, provider, tradeNo str
|
||||
return uc.orders.ListPaymentEvents(ctx, provider, tradeNo, limit)
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
||||
func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (result *PaymentResult, err error) {
|
||||
started := time.Now()
|
||||
provider, tradeNo := "", ""
|
||||
if req != nil {
|
||||
provider, tradeNo = req.Provider, req.TradeNo
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.create.started", "provider", provider, "trade_no", tradeNo)
|
||||
defer func() { uc.logPaymentOperation(ctx, "create", started, provider, tradeNo, result, err) }()
|
||||
if req == nil || !validPaymentText(req.Provider, 64) || !validPaymentText(req.TradeNo, 128) || !validPaymentText(req.BusinessType, 64) || !validPaymentText(req.BusinessID, 128) {
|
||||
return nil, errors.New("支付参数不完整")
|
||||
}
|
||||
@@ -450,6 +457,8 @@ func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*Pay
|
||||
return nil, err
|
||||
}
|
||||
req = prepared
|
||||
uc.logger.Info(ctx, "payment.order.prepared", "provider", provider, "trade_no", tradeNo,
|
||||
"business_type", req.BusinessType, "amount", req.Amount, "currency", req.Currency)
|
||||
if !validPaymentText(req.Currency, 16) || !validPaymentText(req.Subject, 256) || req.Amount <= 0 {
|
||||
return nil, errors.New("支付订单金额、币种或标题无效")
|
||||
}
|
||||
@@ -542,7 +551,10 @@ func IsDemoPaymentOrder(order *PaymentOrder) bool {
|
||||
return order != nil && order.BusinessType == DemoBusinessType && strings.HasPrefix(strings.TrimSpace(order.TradeNo), "DEMO-PAY-")
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (*PaymentResult, error) {
|
||||
func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (result *PaymentResult, err error) {
|
||||
started := time.Now()
|
||||
uc.logger.Info(ctx, "payment.query.started", "provider", provider, "trade_no", tradeNo)
|
||||
defer func() { uc.logPaymentOperation(ctx, "query", started, provider, tradeNo, result, err) }()
|
||||
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
|
||||
return nil, errors.New("查询支付参数不完整")
|
||||
}
|
||||
@@ -568,7 +580,8 @@ func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (
|
||||
}
|
||||
queryID = order.ProviderTradeNo
|
||||
}
|
||||
result, err := uc.repo.Query(ctx, provider, queryID)
|
||||
uc.logger.Info(ctx, "payment.provider.query.started", "provider", provider, "trade_no", tradeNo)
|
||||
result, err = uc.repo.Query(ctx, provider, queryID)
|
||||
if err != nil {
|
||||
uc.logger.Error(ctx, "payment.query.failed", "provider", provider, "trade_no", tradeNo, "query_id", queryID, "error", err)
|
||||
return nil, err
|
||||
@@ -580,10 +593,15 @@ func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (
|
||||
if result.TradeNo != tradeNo {
|
||||
return nil, errors.New("支付平台查单商户订单号不匹配")
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.provider.query.checked", "provider", provider, "trade_no", tradeNo,
|
||||
"provider_status", result.Status, "provider_trade_no", result.ProviderTradeNo)
|
||||
return uc.reconcilePaymentResult(ctx, result, nil)
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string, amount int64) (*PaymentResult, error) {
|
||||
func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string, amount int64) (result *PaymentResult, err error) {
|
||||
started := time.Now()
|
||||
uc.logger.Info(ctx, "payment.refund.started", "provider", provider, "trade_no", tradeNo, "amount", amount)
|
||||
defer func() { uc.logPaymentOperation(ctx, "refund", started, provider, tradeNo, result, err) }()
|
||||
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) || amount <= 0 {
|
||||
return nil, errors.New("退款参数不完整")
|
||||
}
|
||||
@@ -603,8 +621,26 @@ func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string,
|
||||
// TestProvider exercises the configured provider without requiring a business
|
||||
// module or a real customer order. Provider adapters use their configured
|
||||
// sandbox/test endpoint when one is selected in integration settings.
|
||||
func (uc *PaymentUsecase) TestProvider(ctx context.Context, provider string) (*PaymentTestResult, error) {
|
||||
func (uc *PaymentUsecase) TestProvider(ctx context.Context, provider string) (result *PaymentTestResult, err error) {
|
||||
started := time.Now()
|
||||
provider = strings.TrimSpace(provider)
|
||||
uc.logger.Info(ctx, "payment.test.started", "provider", provider)
|
||||
defer func() {
|
||||
if result != nil {
|
||||
for _, stage := range result.Stages {
|
||||
uc.logger.Info(ctx, "payment.test.stage", "provider", provider, "trade_no", stage.TradeNo,
|
||||
"stage", stage.Name, "status", stage.Status, "duration_ms", stage.Duration)
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.test.summary", "provider", provider, "trade_no", result.TradeNo,
|
||||
"passed", result.Passed, "full_flow", result.FullFlow)
|
||||
}
|
||||
var paymentResult *PaymentResult
|
||||
tradeNo := ""
|
||||
if result != nil {
|
||||
paymentResult, tradeNo = result.Result, result.TradeNo
|
||||
}
|
||||
uc.logPaymentOperation(ctx, "test", started, provider, tradeNo, paymentResult, err)
|
||||
}()
|
||||
if !supportedPaymentProvider(provider) {
|
||||
return nil, errors.New("不支持的支付渠道")
|
||||
}
|
||||
@@ -618,7 +654,10 @@ func (uc *PaymentUsecase) TestProvider(ctx context.Context, provider string) (*P
|
||||
// Fulfill retries delivery for an already-paid order. It is intentionally
|
||||
// idempotent: the repository lease prevents concurrent attempts and a
|
||||
// succeeded order is returned as a duplicate without invoking the handler.
|
||||
func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string) (*PaymentResult, error) {
|
||||
func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string) (result *PaymentResult, err error) {
|
||||
started := time.Now()
|
||||
uc.logger.Info(ctx, "payment.fulfill.started", "provider", provider, "trade_no", tradeNo)
|
||||
defer func() { uc.logPaymentOperation(ctx, "fulfill", started, provider, tradeNo, result, err) }()
|
||||
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
|
||||
return nil, errors.New("发货参数不完整")
|
||||
}
|
||||
@@ -632,7 +671,7 @@ func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string)
|
||||
if IsDemoPaymentOrder(order) {
|
||||
return nil, errors.New("演示订单不执行真实发货")
|
||||
}
|
||||
result := paymentResultFromOrder(order)
|
||||
result = paymentResultFromOrder(order)
|
||||
if result == nil {
|
||||
return nil, ErrPaymentOrderNotFound
|
||||
}
|
||||
@@ -645,7 +684,20 @@ func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string)
|
||||
// Callback verifies the notification in the adapter, then always performs a
|
||||
// provider-side query before it can dispatch fulfillment. Callback payloads
|
||||
// are never trusted for amount, currency, or final payment state.
|
||||
func (uc *PaymentUsecase) Callback(ctx context.Context, callback *PaymentCallback) (*PaymentResult, error) {
|
||||
func (uc *PaymentUsecase) Callback(ctx context.Context, callback *PaymentCallback) (result *PaymentResult, err error) {
|
||||
started := time.Now()
|
||||
provider := ""
|
||||
if callback != nil {
|
||||
provider = callback.Provider
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.callback.received", "provider", provider)
|
||||
defer func() {
|
||||
tradeNo := ""
|
||||
if result != nil {
|
||||
tradeNo = result.TradeNo
|
||||
}
|
||||
uc.logPaymentOperation(ctx, "callback", started, provider, tradeNo, result, err)
|
||||
}()
|
||||
if callback == nil || strings.TrimSpace(callback.Provider) == "" || len(callback.Body) == 0 {
|
||||
err := errors.New("支付回调参数不完整")
|
||||
uc.logger.Error(ctx, "payment.callback.invalid", "error", err)
|
||||
@@ -686,6 +738,8 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.order.persisted", "provider", req.Provider, "trade_no", req.TradeNo,
|
||||
"created", created, "order_status", order.PaymentStatus)
|
||||
if order.RequestFingerprint != paymentOrderFingerprint(req, extra) {
|
||||
return nil, ErrPaymentOrderConflict
|
||||
}
|
||||
@@ -736,7 +790,7 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
|
||||
return uc.reconcilePaymentResult(ctx, result, nil)
|
||||
}
|
||||
|
||||
uc.logger.Info(ctx, "payment.order.create", "provider", req.Provider, "trade_no", req.TradeNo, "business_type", req.BusinessType, "business_id", req.BusinessID)
|
||||
uc.logger.Info(ctx, "payment.provider.create.started", "provider", req.Provider, "trade_no", req.TradeNo)
|
||||
result, err := uc.repo.Create(ctx, req)
|
||||
if err != nil {
|
||||
uc.logger.Error(ctx, "payment.order.provider_create_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
|
||||
@@ -746,6 +800,8 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
|
||||
uc.logger.Error(ctx, "payment.order.provider_create_invalid", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.provider.create.checked", "provider", req.Provider, "trade_no", req.TradeNo,
|
||||
"provider_status", result.Status, "provider_trade_no", result.ProviderTradeNo)
|
||||
update := paymentProviderUpdate(result)
|
||||
update.EventSource = req.Provider
|
||||
order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update)
|
||||
@@ -769,23 +825,27 @@ func (uc *PaymentUsecase) callbackWithOrder(ctx context.Context, callback *Payme
|
||||
err = errors.New("支付回调渠道或商户订单号校验失败")
|
||||
return nil, callbackResultError(callbackResult, err)
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.callback.verified", "provider", callback.Provider, "trade_no", callbackResult.TradeNo)
|
||||
queryID := callbackResult.QueryID
|
||||
if queryID == "" {
|
||||
queryID = callbackResult.TradeNo
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.callback.provider_query.started", "provider", callback.Provider, "trade_no", callbackResult.TradeNo)
|
||||
queryResult, err := uc.repo.Query(ctx, callbackResult.Provider, queryID)
|
||||
if err != nil {
|
||||
uc.logger.Error(ctx, "payment.callback.query_failed", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "event_id", callbackResult.EventID, "error", err)
|
||||
uc.logger.Error(ctx, "payment.callback.query_failed", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "error", err)
|
||||
return nil, callbackResultError(callbackResult, err)
|
||||
}
|
||||
if err = validatePaymentQueryResult(callbackResult.Provider, queryID, queryResult); err != nil {
|
||||
uc.logger.Error(ctx, "payment.callback.query_invalid", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "event_id", callbackResult.EventID, "error", err)
|
||||
uc.logger.Error(ctx, "payment.callback.query_invalid", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "error", err)
|
||||
return nil, callbackResultError(callbackResult, err)
|
||||
}
|
||||
if queryResult.TradeNo != callbackResult.TradeNo {
|
||||
err = errors.New("支付回调商户订单号与主动查单结果不匹配")
|
||||
return nil, callbackResultError(callbackResult, err)
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.callback.provider_query.checked", "provider", callback.Provider,
|
||||
"trade_no", callbackResult.TradeNo, "provider_status", queryResult.Status)
|
||||
queryResult = clonePaymentResult(queryResult)
|
||||
queryResult.EventID = callbackResult.EventID
|
||||
queryResult.SuccessAck = callbackResult.SuccessAck
|
||||
@@ -829,6 +889,8 @@ func (uc *PaymentUsecase) reconcilePaymentResult(ctx context.Context, result, ca
|
||||
uc.logger.Error(ctx, "payment.order.result_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err)
|
||||
return fail(err)
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.order.reconciled", "provider", result.Provider, "trade_no", result.TradeNo,
|
||||
"source", update.EventSource, "provider_status", result.Status, "order_status", order.PaymentStatus)
|
||||
result = attachOrderResult(result, order)
|
||||
if !strings.EqualFold(result.Status, "success") {
|
||||
return result, nil
|
||||
@@ -853,8 +915,10 @@ func (uc *PaymentUsecase) fulfillOrder(ctx context.Context, order *PaymentOrder,
|
||||
if duplicate {
|
||||
result.Duplicate = true
|
||||
result.Status = "fulfilled"
|
||||
uc.logger.Info(ctx, "payment.fulfillment.duplicate", "provider", result.Provider, "trade_no", result.TradeNo)
|
||||
return attachOrderResult(result, order), nil
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.fulfillment.started", "provider", result.Provider, "trade_no", result.TradeNo, "business_type", order.BusinessType)
|
||||
handler := uc.fulfillments.Handler(order.BusinessType)
|
||||
if handler == nil {
|
||||
err = fmt.Errorf("支付发货 handler 未注册: %s", order.BusinessType)
|
||||
@@ -877,6 +941,8 @@ func (uc *PaymentUsecase) fulfillOrder(ctx context.Context, order *PaymentOrder,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.fulfillment.completed", "provider", result.Provider, "trade_no", result.TradeNo,
|
||||
"fulfillment_status", order.FulfillmentStatus)
|
||||
result.Status = "fulfilled"
|
||||
return attachOrderResult(result, order), nil
|
||||
}
|
||||
@@ -898,6 +964,8 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.refund.reserved", "provider", provider, "trade_no", tradeNo,
|
||||
"refund_no", order.RefundNo, "amount", amount, "currency", order.Currency)
|
||||
if provider == PaymentInternal || current.PaymentMode == PaymentModeInternal {
|
||||
processor, ok := handler.(PaymentInternalRefundProcessor)
|
||||
if !ok {
|
||||
@@ -930,6 +998,7 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
|
||||
}
|
||||
return attachOrderResult(&PaymentResult{Provider: provider, TradeNo: tradeNo, ProviderTradeNo: refund.RefundID, Amount: amount, Currency: current.Currency, Payload: refund.Payload, Status: "refunded"}, order), nil
|
||||
}
|
||||
uc.logger.Info(ctx, "payment.provider.refund.started", "provider", provider, "trade_no", tradeNo, "refund_no", order.RefundNo)
|
||||
result, providerErr := uc.repo.Refund(ctx, &PaymentRefundRequest{
|
||||
Provider: order.Provider,
|
||||
TradeNo: order.TradeNo,
|
||||
@@ -943,6 +1012,10 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
|
||||
if providerErr == nil {
|
||||
providerErr = validatePaymentRefundResult(provider, tradeNo, result)
|
||||
}
|
||||
if providerErr == nil {
|
||||
uc.logger.Info(ctx, "payment.provider.refund.checked", "provider", provider, "trade_no", tradeNo,
|
||||
"refund_no", order.RefundNo, "provider_status", result.Status)
|
||||
}
|
||||
if providerErr != nil && !errors.Is(providerErr, ErrPaymentOperationRejected) {
|
||||
// A timeout or malformed response cannot prove that the provider did not
|
||||
// accept the refund. Keep the durable refund operation and its idempotency
|
||||
@@ -967,7 +1040,9 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
|
||||
|
||||
// ConfirmRefund is called by a provider-specific refund notification or a
|
||||
// reconciliation worker after the platform reports the final refund state.
|
||||
func (uc *PaymentUsecase) ConfirmRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*PaymentResult, error) {
|
||||
func (uc *PaymentUsecase) ConfirmRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (result *PaymentResult, err error) {
|
||||
started := time.Now()
|
||||
defer func() { uc.logPaymentOperation(ctx, "refund_confirmation", started, provider, tradeNo, result, err) }()
|
||||
if uc.orders == nil {
|
||||
return nil, errors.New("支付订单仓储未接入")
|
||||
}
|
||||
@@ -978,7 +1053,7 @@ func (uc *PaymentUsecase) ConfirmRefund(ctx context.Context, provider, tradeNo,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &PaymentResult{Provider: provider, TradeNo: tradeNo, Status: "refund_failed"}
|
||||
result = &PaymentResult{Provider: provider, TradeNo: tradeNo, Status: "refund_failed"}
|
||||
if success {
|
||||
result.Status = "refunded"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PaymentLogger isolates payment audit logging from the payment workflow and
|
||||
@@ -28,5 +31,58 @@ func (l *slogPaymentLogger) Info(ctx context.Context, message string, args ...an
|
||||
}
|
||||
|
||||
func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) {
|
||||
for i := 0; i+1 < len(args); i += 2 {
|
||||
if key, ok := args[i].(string); ok && (key == "error" || key == "err") {
|
||||
args[i] = "error_code"
|
||||
if err, ok := args[i+1].(error); ok {
|
||||
args[i+1] = paymentErrorCode(err)
|
||||
} else {
|
||||
args[i+1] = "redacted"
|
||||
}
|
||||
}
|
||||
}
|
||||
l.logger.ErrorContext(ctx, message, args...)
|
||||
}
|
||||
|
||||
func paymentErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrPaymentOrderNotFound):
|
||||
return "order_not_found"
|
||||
case errors.Is(err, ErrPaymentProviderNotFound):
|
||||
return "provider_not_configured"
|
||||
case errors.Is(err, ErrPaymentOrderConflict):
|
||||
return "order_conflict"
|
||||
case errors.Is(err, ErrPaymentProviderConflict):
|
||||
return "provider_conflict"
|
||||
case errors.Is(err, ErrPaymentOrderBusy):
|
||||
return "order_busy"
|
||||
case errors.Is(err, ErrPaymentOrderState):
|
||||
return "invalid_order_state"
|
||||
case errors.Is(err, ErrPaymentOperationRejected):
|
||||
return "provider_rejected"
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "cancelled"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "timeout"
|
||||
default:
|
||||
var networkError net.Error
|
||||
if errors.As(err, &networkError) && networkError.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
return "operation_failed"
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) logPaymentOperation(ctx context.Context, operation string, started time.Time, provider, tradeNo string, result *PaymentResult, err error) {
|
||||
attrs := []any{"provider", provider, "trade_no", tradeNo, "duration_ms", time.Since(started).Milliseconds()}
|
||||
if result != nil {
|
||||
attrs = append(attrs, "provider_trade_no", result.ProviderTradeNo, "provider_status", result.Status,
|
||||
"order_status", result.OrderStatus, "fulfillment_status", result.FulfillmentStatus,
|
||||
"refund_status", result.RefundStatus, "duplicate", result.Duplicate)
|
||||
}
|
||||
if err != nil {
|
||||
uc.logger.Error(ctx, "payment."+operation+".failed", append(attrs, "error_code", paymentErrorCode(err))...)
|
||||
return
|
||||
}
|
||||
uc.logger.Info(ctx, "payment."+operation+".completed", attrs...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPaymentLifecycleLogsDoNotIncludeProviderErrorsOrPayloads(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
repo := &paymentRepoStub{createErr: errors.New("provider response: secret-token"), callbackErr: errors.New("callback body: secret-token")}
|
||||
orders := &paymentOrderRepoStub{}
|
||||
sources := NewPaymentOrderSourceRegistry()
|
||||
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{
|
||||
Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1",
|
||||
Subject: "test item", Amount: 100, Currency: "CNY",
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc := NewPaymentUsecase(repo, orders, nil, sources, nil, logger)
|
||||
_, _ = uc.Create(context.Background(), &PaymentRequest{
|
||||
Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1",
|
||||
Extra: map[string]any{"api_key": "secret-token"},
|
||||
})
|
||||
_, _ = uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("secret-token")})
|
||||
|
||||
text := output.String()
|
||||
if strings.Contains(text, "secret-token") {
|
||||
t.Fatal("provider error or request payload leaked to payment log")
|
||||
}
|
||||
for _, stage := range []string{
|
||||
"payment.create.started", "payment.order.prepared", "payment.order.persisted",
|
||||
"payment.provider.create.started", "payment.create.failed",
|
||||
"payment.callback.received", "payment.callback.verify_failed", "payment.callback.failed",
|
||||
} {
|
||||
if !strings.Contains(text, stage) {
|
||||
t.Errorf("missing lifecycle stage %s", stage)
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
t.Fatalf("invalid structured log %q: %v", line, err)
|
||||
}
|
||||
if entry["mod"] != "payment" {
|
||||
t.Fatalf("payment log module = %v", entry["mod"])
|
||||
}
|
||||
if entry["msg"] == "payment.create.failed" && entry["duration_ms"] == nil {
|
||||
t.Fatal("create failure did not record duration")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentLogErrorUsesStableReasonWithoutOriginalMessage(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := NewPaymentLogger(slog.New(slog.NewJSONHandler(&output, nil)))
|
||||
logger.Error(context.Background(), "payment.test.failed", "error", PaymentRejected(errors.New("secret-token")))
|
||||
if strings.Contains(output.String(), "secret-token") || !strings.Contains(output.String(), `"error_code":"provider_rejected"`) {
|
||||
t.Fatalf("unsafe or missing error code in %s", output.String())
|
||||
}
|
||||
if got := paymentErrorCode(context.DeadlineExceeded); got != "timeout" {
|
||||
t.Fatalf("deadline error code = %q, want timeout", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentLogCorrelatesQueryFulfillmentAndRefund(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
pending := successPaymentResult()
|
||||
pending.Status = "pending"
|
||||
repo := &paymentRepoStub{query: pending, refund: &PaymentResult{
|
||||
Provider: PaymentAlipay, TradeNo: "order-1", Status: "pending", Amount: 40, Currency: "CNY",
|
||||
}}
|
||||
orders := &paymentOrderRepoStub{order: testBizPaymentOrder()}
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
if err := registry.Register(&paymentFulfillmentStub{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc := NewPaymentUsecase(repo, orders, nil, nil, registry, logger)
|
||||
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orders.order.PaymentStatus = PaymentStatusPaid
|
||||
if _, err := uc.Fulfill(context.Background(), PaymentAlipay, "order-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := uc.Refund(context.Background(), PaymentAlipay, "order-1", 40); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := uc.ConfirmRefund(context.Background(), PaymentAlipay, "order-1", "refund-1", 40, true, "confirmed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stage := range []string{
|
||||
"payment.provider.query.checked", "payment.order.reconciled", "payment.query.completed",
|
||||
"payment.fulfillment.started", "payment.fulfillment.completed",
|
||||
"payment.refund.reserved", "payment.provider.refund.checked",
|
||||
"payment.refund.completed", "payment.refund_confirmation.completed",
|
||||
} {
|
||||
if !strings.Contains(output.String(), stage) {
|
||||
t.Errorf("missing lifecycle stage %s", stage)
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") {
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry["trade_no"] != "order-1" {
|
||||
t.Fatalf("uncorrelated payment log: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@ import (
|
||||
|
||||
type paymentRepoStub struct {
|
||||
create *PaymentResult
|
||||
createErr error
|
||||
createdReq *PaymentRequest
|
||||
refund *PaymentResult
|
||||
refundErr error
|
||||
refundedReq *PaymentRefundRequest
|
||||
callback *PaymentResult
|
||||
query *PaymentResult
|
||||
queryErr error
|
||||
queryCalls int
|
||||
callbackErr error
|
||||
}
|
||||
@@ -25,7 +27,7 @@ type paymentRepoStub struct {
|
||||
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
||||
copyReq := *req
|
||||
r.createdReq = ©Req
|
||||
return r.create, nil
|
||||
return r.create, r.createErr
|
||||
}
|
||||
|
||||
type paymentOrderRepoStub struct {
|
||||
@@ -111,7 +113,7 @@ func (s *paymentSourceStub) PreparePayment(context.Context, string, string, stri
|
||||
}
|
||||
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
|
||||
r.queryCalls++
|
||||
return r.query, nil
|
||||
return r.query, r.queryErr
|
||||
}
|
||||
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
|
||||
if req != nil {
|
||||
|
||||
@@ -37,6 +37,29 @@ type SecurityConfig struct {
|
||||
ForceNewUserChangePassword bool
|
||||
}
|
||||
|
||||
// ErrDatabaseNotInitialized is returned by storage-backed settings that are
|
||||
// intentionally unavailable during the first-install bootstrap phase.
|
||||
var ErrDatabaseNotInitialized = errors.New("database not initialized")
|
||||
|
||||
// DefaultSecurityConfig matches the seed values used for a fresh installation.
|
||||
// It is owned by biz so bootstrap callers do not need to depend on data.
|
||||
func DefaultSecurityConfig() *SecurityConfig {
|
||||
return &SecurityConfig{
|
||||
ID: 1,
|
||||
CaptchaTimeout: 3600,
|
||||
KeyLong: 6,
|
||||
ImgWidth: 240,
|
||||
ImgHeight: 80,
|
||||
PwdMinLength: 8,
|
||||
LimitWindow: 60,
|
||||
LimitCount: 30,
|
||||
LockThreshold: 5,
|
||||
LockDuration: 30,
|
||||
PwdExpireDays: 90,
|
||||
ForceNewUserChangePassword: false,
|
||||
}
|
||||
}
|
||||
|
||||
type SecurityRepo interface {
|
||||
SecurityConfig(context.Context) (*SecurityConfig, error)
|
||||
SaveSecurityConfig(context.Context, *SecurityConfig) error
|
||||
@@ -99,8 +122,17 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
|
||||
uc.mu.RUnlock()
|
||||
value, err := uc.repo.SecurityConfig(ctx)
|
||||
if err != nil {
|
||||
// The login page and captcha are also the entry point for first install.
|
||||
// Do not cache this fallback: after initdb succeeds the next request must
|
||||
// load the persisted row instead of serving bootstrap defaults forever.
|
||||
if errors.Is(err, ErrDatabaseNotInitialized) {
|
||||
return DefaultSecurityConfig(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
return nil, errors.New("security config is nil")
|
||||
}
|
||||
uc.mu.Lock()
|
||||
copy := *value
|
||||
uc.cachedConfig = ©
|
||||
|
||||
@@ -15,6 +15,27 @@ type securityUpdateRepo struct {
|
||||
persistedCopy *SecurityConfig
|
||||
}
|
||||
|
||||
type bootstrapSecurityRepo struct {
|
||||
initialized bool
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *bootstrapSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
|
||||
r.calls++
|
||||
if !r.initialized {
|
||||
return nil, ErrDatabaseNotInitialized
|
||||
}
|
||||
return &SecurityConfig{ID: 1, LimitCount: 42}, nil
|
||||
}
|
||||
|
||||
func (*bootstrapSecurityRepo) SaveSecurityConfig(context.Context, *SecurityConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*bootstrapSecurityRepo) BackfillPasswordUpdatedAt(context.Context, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *securityUpdateRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
|
||||
r.callOrder = append(r.callOrder, "get")
|
||||
copy := *r.current
|
||||
@@ -110,3 +131,31 @@ func TestSecurityReadsPersistedValueWhileCurrentUsesCache(t *testing.T) {
|
||||
t.Fatalf("Security() returned stale value: %d", fresh.LimitCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUsesBootstrapDefaultsWithoutCachingThem(t *testing.T) {
|
||||
repo := &bootstrapSecurityRepo{}
|
||||
uc := NewSecurityUsecase(repo, nil, nil, nil)
|
||||
|
||||
bootstrap, err := uc.Current(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Current() during bootstrap error = %v", err)
|
||||
}
|
||||
if bootstrap.ID != 1 || bootstrap.KeyLong != 6 || bootstrap.ImgWidth != 240 || bootstrap.ImgHeight != 80 || bootstrap.LimitEnable {
|
||||
t.Fatalf("bootstrap config = %+v", bootstrap)
|
||||
}
|
||||
if repo.calls != 1 {
|
||||
t.Fatalf("bootstrap repository calls = %d, want 1", repo.calls)
|
||||
}
|
||||
|
||||
repo.initialized = true
|
||||
active, err := uc.Current(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Current() after initialization error = %v", err)
|
||||
}
|
||||
if active.LimitCount != 42 {
|
||||
t.Fatalf("active config = %+v, want persisted config", active)
|
||||
}
|
||||
if repo.calls != 2 {
|
||||
t.Fatalf("repository calls = %d, want fallback to be uncached", repo.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func databaseDSN(c *config.Database, name string) (string, error) {
|
||||
}
|
||||
query := c.Config
|
||||
if query == "" {
|
||||
query = "timeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
|
||||
query = "timeout=5s&readTimeout=5s&writeTimeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
|
||||
}
|
||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.User, c.Password, net.JoinHostPort(host, port), name, query), nil
|
||||
case "pgsql":
|
||||
@@ -179,6 +179,21 @@ func openWithDriverConfig(driver, dsn string, config *config.Database, appLogger
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func bootstrapDatabaseDSN(c *config.Database) (string, error) {
|
||||
bootstrap := *c
|
||||
// An empty name passed to databaseDSN falls back to the configured name.
|
||||
// Clear both fields so MySQL can connect before the target database exists.
|
||||
bootstrap.Name = ""
|
||||
bootstrap.Source = ""
|
||||
switch normalizedDriver(c.Driver) {
|
||||
case "pgsql":
|
||||
bootstrap.Name = "postgres"
|
||||
case "mssql":
|
||||
bootstrap.Name = "master"
|
||||
}
|
||||
return databaseDSN(&bootstrap, "")
|
||||
}
|
||||
|
||||
func openDatabase(c *config.Database, create bool, template string, appLogger *slog.Logger) (*gorm.DB, error) {
|
||||
driver := normalizedDriver(c.Driver)
|
||||
if driver == "" {
|
||||
@@ -198,14 +213,7 @@ func openDatabase(c *config.Database, create bool, template string, appLogger *s
|
||||
if !databaseNamePattern.MatchString(c.Name) {
|
||||
return nil, fmt.Errorf("invalid database name %q", c.Name)
|
||||
}
|
||||
bootstrap := ""
|
||||
switch driver {
|
||||
case "pgsql":
|
||||
bootstrap = "postgres"
|
||||
case "mssql":
|
||||
bootstrap = "master"
|
||||
}
|
||||
dsn, err := databaseDSN(c, bootstrap)
|
||||
dsn, err := bootstrapDatabaseDSN(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestMySQLBootstrapConnectsWithoutTargetDatabase(t *testing.T) {
|
||||
for _, host := range []string{"", "mysql"} {
|
||||
t.Run("host="+host, func(t *testing.T) {
|
||||
cfg := &config.Database{Driver: "mysql", Host: host, Name: "kra_new", Source: "root@tcp(localhost:3306)/kra_new"}
|
||||
original := *cfg
|
||||
dsn, err := bootstrapDatabaseDSN(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := mysqldriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.DBName != "" {
|
||||
t.Fatalf("bootstrap selected database %q before creating it", parsed.DBName)
|
||||
}
|
||||
if *cfg != original {
|
||||
t.Fatal("bootstrap changed the target configuration")
|
||||
}
|
||||
target, err := databaseDSN(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err = mysqldriver.ParseDSN(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.DBName != "kra_new" {
|
||||
t.Fatalf("target connection selected database %q", parsed.DBName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLDatabaseDSNIncludesNetworkTimeouts(t *testing.T) {
|
||||
dsn, err := databaseDSN(&config.Database{
|
||||
Driver: "mysql",
|
||||
User: "root",
|
||||
Password: "secret",
|
||||
Host: "mysql",
|
||||
Name: "kra",
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, option := range []string{"timeout=5s", "readTimeout=5s", "writeTimeout=5s"} {
|
||||
if !strings.Contains(dsn, option) {
|
||||
t.Fatalf("DSN %q does not contain %q", dsn, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,14 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
}
|
||||
}
|
||||
}()
|
||||
releaseLock, err := acquireInitializationLock(ctx, candidate, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseLock()
|
||||
if persistedDatabaseConfigured(d.runtime) {
|
||||
return errors.New("数据库已初始化,无需重复初始化")
|
||||
}
|
||||
db := candidate.WithContext(ctx)
|
||||
if err := migrateAll(db, d.catalog); err != nil {
|
||||
return err
|
||||
|
||||
@@ -54,6 +54,23 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedDatabaseConfiguredReadsSharedRuntimeConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("data:\n database:\n driver: mysql\n name: kra\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{ConfigPath: configPath}})
|
||||
if !persistedDatabaseConfigured(runtime) {
|
||||
t.Fatal("persisted database configuration was not detected")
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte("data:\n database:\n driver: mysql\n name: \"\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persistedDatabaseConfigured(runtime) {
|
||||
t.Fatal("empty persisted database configuration was treated as initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistDatabaseConfigPreservesTemplateAndRotatesJWTKey(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, "config.yaml")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
configpkg "kra/internal/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// acquireInitializationLock serializes first-install work across backend Pods.
|
||||
// The Kubernetes deployment uses MySQL, whose named lock is connection-scoped
|
||||
// and therefore remains held while the candidate database is migrated and seeded.
|
||||
func acquireInitializationLock(ctx context.Context, db *gorm.DB, database *configpkg.Database) (func(), error) {
|
||||
if normalizedDriver(database.Driver) != "mysql" {
|
||||
return func() {}, nil
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open initialization lock connection: %w", err)
|
||||
}
|
||||
conn, err := sqlDB.Conn(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reserve initialization lock connection: %w", err)
|
||||
}
|
||||
lockName := initializationLockName(database)
|
||||
var acquired sql.NullInt64
|
||||
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, ?)", lockName, 60).Scan(&acquired); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("acquire database initialization lock: %w", err)
|
||||
}
|
||||
if !acquired.Valid || acquired.Int64 != 1 {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("database initialization lock is busy")
|
||||
}
|
||||
return func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, _ = conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", lockName)
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func initializationLockName(database *configpkg.Database) string {
|
||||
identity := fmt.Sprintf("%s\x00%s\x00%s\x00%s", database.Driver, database.Host, database.Port, database.Name)
|
||||
sum := sha256.Sum256([]byte(identity))
|
||||
return "kra-init-" + hex.EncodeToString(sum[:])[:48]
|
||||
}
|
||||
|
||||
func persistedDatabaseConfigured(runtime *configpkg.Store) bool {
|
||||
if runtime == nil || runtime.ConfigPath() == "" {
|
||||
return false
|
||||
}
|
||||
persisted, err := configpkg.Load(runtime.ConfigPath())
|
||||
if err != nil || persisted == nil || persisted.Data == nil {
|
||||
return false
|
||||
}
|
||||
return databaseConnectionConfigured(persisted.Data.Database)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func ReadRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
|
||||
}
|
||||
var rows []ConfigPO
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind IN ?", []string{"mq", "websocket"}).
|
||||
Where("kind IN ?", []string{"mq", "websocket", "sms"}).
|
||||
Order("kind ASC, provider ASC").
|
||||
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMSSecretsSurviveMaskedSaveAndRuntimeReload(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ConfigPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db)}, store: runtimeconfig.NewStore()}
|
||||
repo := NewIntegrationConfigRepo(p)
|
||||
for _, provider := range []string{"aliyun", "tencent"} {
|
||||
values := biz.DefaultIntegrationConfig("sms", provider)
|
||||
for k, v := range values {
|
||||
if v == "" {
|
||||
values[k] = "test-" + k
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: "sms", Provider: provider, Enabled: true, Values: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
masked, err := repo.FindIntegrationConfig(context.Background(), "sms", provider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var public map[string]string
|
||||
_ = json.Unmarshal(masked.Values, &public)
|
||||
for k := range values {
|
||||
if biz.IsIntegrationSecretKey(k) && public[k] != "******" {
|
||||
t.Fatalf("unmasked key %s", k)
|
||||
}
|
||||
}
|
||||
if err = repo.SaveIntegrationConfig(context.Background(), masked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rows, err := ReadRuntime(db)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("runtime rows=%d error=%v", len(rows), err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
var values map[string]string
|
||||
_ = json.Unmarshal(row.Values, &values)
|
||||
key := "access_key_secret"
|
||||
if row.Provider == "tencent" {
|
||||
key = "secret_key"
|
||||
}
|
||||
if values[key] != "test-"+key {
|
||||
t.Fatal("saved secret did not survive runtime reload")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 13 {
|
||||
t.Fatalf("migration versions = %d, want 13", versions)
|
||||
if versions != 14 {
|
||||
t.Fatalf("migration versions = %d, want 14", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -43,7 +43,7 @@ type SecurityConfigPO struct {
|
||||
func (SecurityConfigPO) TableName() string { return "sys_security_config" }
|
||||
|
||||
func DefaultSecurityConfig() SecurityConfigPO {
|
||||
return SecurityConfigPO{ID: 1, CaptchaTimeout: 3600, KeyLong: 6, ImgWidth: 240, ImgHeight: 80, PwdMinLength: 8, LimitWindow: 60, LimitCount: 30, LockThreshold: 5, LockDuration: 30, PwdExpireDays: 90}
|
||||
return securityToPO(system.DefaultSecurityConfig())
|
||||
}
|
||||
|
||||
type securityRepo struct{ data DatabaseProvider }
|
||||
@@ -60,7 +60,7 @@ func securityToPO(v *system.SecurityConfig) SecurityConfigPO {
|
||||
|
||||
func (r *securityRepo) SecurityConfig(ctx context.Context) (*system.SecurityConfig, error) {
|
||||
if !r.data.DatabaseReady() {
|
||||
return nil, errors.New("数据库未初始化")
|
||||
return nil, system.ErrDatabaseNotInitialized
|
||||
}
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
var po SecurityConfigPO
|
||||
|
||||
@@ -39,15 +39,22 @@ func (a *wechatV3Adapter) Create(ctx context.Context, req *bizpayment.PaymentReq
|
||||
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
|
||||
amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency))
|
||||
})
|
||||
mergeGoPayExtras(bm, req.Extra,
|
||||
"appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer",
|
||||
"trade_type", "pay_type", "method", "openid", "open_id", "auth_code", "authcode", "barcode",
|
||||
)
|
||||
|
||||
tradeType := normalizeWechatV3TradeType(firstAny(req.Extra, "trade_type", "pay_type", "method"))
|
||||
if tradeType == "" {
|
||||
tradeType = normalizeWechatV3TradeType(firstAny(c, "trade_type", "pay_type", "method"))
|
||||
}
|
||||
if tradeType == "native" || tradeType == "qr" || tradeType == "qrcode" {
|
||||
for _, key := range []string{"time_expire", "attach", "goods_tag", "support_fapiao", "detail", "scene_info", "settle_info"} {
|
||||
if value, ok := req.Extra[key]; ok {
|
||||
bm[key] = value
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mergeGoPayExtras(bm, req.Extra,
|
||||
"appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer",
|
||||
"trade_type", "pay_type", "method", "openid", "open_id", "auth_code", "authcode", "barcode",
|
||||
)
|
||||
}
|
||||
switch tradeType {
|
||||
case "", "jsapi", "js_api", "mini", "miniapp", "mini_app", "miniprogram", "mini_program", "applet":
|
||||
if openID := firstAny(req.Extra, "openid", "open_id"); openID != "" {
|
||||
@@ -418,23 +425,10 @@ func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.Cli
|
||||
return nil, err
|
||||
}
|
||||
|
||||
platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert")
|
||||
if platformCert == "" {
|
||||
return nil, errors.New("微信支付 v3 缺少 platform_cert")
|
||||
}
|
||||
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial", "wechatpay_serial_no")
|
||||
if platformSerial == "" {
|
||||
platformSerial, err = wechatV3PlatformSerial(platformCert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = client.AutoVerifySignByCert([]byte(platformCert), platformSerial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.SetBodySize(8)
|
||||
client.GetHttpClient().SetTimeout(20 * time.Second)
|
||||
|
||||
endpointConfigured := false
|
||||
if endpointKey != "" {
|
||||
if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" {
|
||||
parsed, parseErr := url.Parse(endpoint)
|
||||
@@ -446,15 +440,37 @@ func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.Cli
|
||||
}
|
||||
httpClient := client.GetHttpClient()
|
||||
httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed})
|
||||
return client, nil
|
||||
endpointConfigured = true
|
||||
}
|
||||
}
|
||||
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
|
||||
parsed, parseErr := url.Parse(baseURL)
|
||||
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, errors.New("微信支付 v3 base_url 配置无效")
|
||||
if !endpointConfigured {
|
||||
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
|
||||
parsed, parseErr := url.Parse(baseURL)
|
||||
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, errors.New("微信支付 v3 base_url 配置无效")
|
||||
}
|
||||
client.SetProxyHost(baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert")
|
||||
if platformCert == "" {
|
||||
// Fetch the current platform certificates using the merchant credentials.
|
||||
// This client is request-scoped, so avoid starting a background refresher.
|
||||
if err = client.AutoVerifySign(false); err != nil {
|
||||
return nil, fmt.Errorf("微信支付 v3 自动获取平台证书失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial", "wechatpay_serial_no")
|
||||
if platformSerial == "" {
|
||||
platformSerial, err = wechatV3PlatformSerial(platformCert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = client.AutoVerifySignByCert([]byte(platformCert), platformSerial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.SetProxyHost(baseURL)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWechatV3WithoutPlatformCertificateLoadsVerificationKey(t *testing.T) {
|
||||
_, merchantPrivatePEM, _ := testRSAKeyPair(t)
|
||||
_, _, platformPublicPEM := testRSAKeyPair(t)
|
||||
apiV3Key := "01234567890123456789012345678901"
|
||||
block, err := aes.NewCipher([]byte(apiV3Key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce, associatedData := "0123456789ab", "certificate"
|
||||
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), []byte(platformPublicPEM), []byte(associatedData)))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v3/certificates" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{
|
||||
"serial_no": "platform-serial",
|
||||
"effective_time": time.Now().Add(-time.Hour).Format(time.RFC3339),
|
||||
"expire_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"encrypt_certificate": map[string]any{
|
||||
"algorithm": "AEAD_AES_256_GCM", "nonce": nonce,
|
||||
"associated_data": associatedData, "ciphertext": ciphertext,
|
||||
},
|
||||
}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := newWechatV3Client(map[string]any{
|
||||
"merchant_id": "merchant", "serial_no": "merchant-serial",
|
||||
"api_v3_key": apiV3Key, "private_key": merchantPrivatePEM,
|
||||
"create_url": server.URL,
|
||||
}, "create_url")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.WxPublicKeyMap()["platform-serial"] == nil {
|
||||
t.Fatal("fetched platform key was not installed for signature verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatV3WithoutPlatformCertificateFetchesFromConfiguredEndpoint(t *testing.T) {
|
||||
_, merchantPrivatePEM, _ := testRSAKeyPair(t)
|
||||
requestPath := ""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := newWechatV3Client(map[string]any{
|
||||
"merchant_id": "merchant", "serial_no": "merchant-serial",
|
||||
"api_v3_key": "01234567890123456789012345678901",
|
||||
"private_key": merchantPrivatePEM, "create_url": server.URL,
|
||||
}, "create_url")
|
||||
if requestPath != "/v3/certificates" {
|
||||
t.Fatalf("certificate request path = %q", requestPath)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "自动获取平台证书失败") {
|
||||
t.Fatalf("certificate fetch error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
|
||||
)
|
||||
|
||||
func TestWechatV3NativeOnlySendsSupportedExtras(t *testing.T) {
|
||||
platformKey, _, platformPublicPEM := testRSAKeyPair(t)
|
||||
_, merchantPrivatePEM, _ := testRSAKeyPair(t)
|
||||
const (
|
||||
platformSerial = "PLATFORM-SERIAL"
|
||||
timestamp = "1787068800"
|
||||
nonce = "native-response-nonce"
|
||||
)
|
||||
response := []byte(`{"code_url":"weixin://wxpay/bizpayurl?pr=test"}`)
|
||||
signature := testRSA2Sign(t, platformKey, []byte(timestamp+"\n"+nonce+"\n"+string(response)+"\n"))
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v3/pay/transactions/native" {
|
||||
t.Errorf("request path = %q", r.URL.Path)
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read request: %v", err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, &requestBody); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set(gopayWechatV3.HeaderTimestamp, timestamp)
|
||||
w.Header().Set(gopayWechatV3.HeaderNonce, nonce)
|
||||
w.Header().Set(gopayWechatV3.HeaderSerial, platformSerial)
|
||||
w.Header().Set(gopayWechatV3.HeaderSignature, signature)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, test := range []struct {
|
||||
name, method, configMethod string
|
||||
}{
|
||||
{"native", "native", "jsapi"},
|
||||
{"qr alias", "qr", "jsapi"},
|
||||
{"qrcode alias", "qrcode", "jsapi"},
|
||||
{"config fallback", "", "native"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requestBody = nil
|
||||
extra := map[string]any{
|
||||
"appid": "wrong-app", "mchid": "wrong-merchant",
|
||||
"amount": map[string]any{"total": 999}, "payer": map[string]any{"openid": "wrong"},
|
||||
"openid": "wrong", "trade_type": test.method, "unexpected": "not-a-native-field",
|
||||
"time_expire": "2026-09-24T12:00:00+08:00", "attach": "order metadata",
|
||||
"goods_tag": "tag", "support_fapiao": true,
|
||||
"detail": map[string]any{"cost_price": 123},
|
||||
"scene_info": map[string]any{"payer_client_ip": "127.0.0.1"},
|
||||
"settle_info": map[string]any{"profit_sharing": false},
|
||||
}
|
||||
result, err := (&wechatV3Adapter{}).Create(context.Background(), &bizpayment.PaymentRequest{
|
||||
TradeNo: "T-NATIVE", Subject: "subject", Amount: 123, Currency: "cny",
|
||||
NotifyURL: "https://merchant.test/payment/callback", Extra: extra,
|
||||
}, map[string]any{
|
||||
"app_id": "wx-test", "merchant_id": "mch-test", "serial_no": "merchant-serial",
|
||||
"private_key": merchantPrivatePEM, "api_v3_key": "01234567890123456789012345678901",
|
||||
"platform_cert": platformPublicPEM, "platform_serial_no": platformSerial,
|
||||
"trade_type": test.configMethod, "create_url": server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "created" || requestBody == nil {
|
||||
t.Fatalf("result = %+v, request = %#v", result, requestBody)
|
||||
}
|
||||
for _, key := range []string{"unexpected", "payer", "openid", "trade_type", "method"} {
|
||||
if _, exists := requestBody[key]; exists {
|
||||
t.Errorf("native request unexpectedly includes %s", key)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"appid": "wx-test", "mchid": "mch-test", "description": "subject",
|
||||
"out_trade_no": "T-NATIVE", "notify_url": "https://merchant.test/payment/callback",
|
||||
"amount": map[string]any{"total": float64(123), "currency": "CNY"},
|
||||
} {
|
||||
if !reflect.DeepEqual(requestBody[key], want) {
|
||||
t.Errorf("%s = %#v, want %#v", key, requestBody[key], want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"time_expire", "attach", "goods_tag", "support_fapiao", "detail", "scene_info", "settle_info"} {
|
||||
if _, exists := requestBody[key]; !exists {
|
||||
t.Errorf("native request missing %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"kra/internal/integration/email"
|
||||
mqintegration "kra/internal/integration/mq"
|
||||
paymentintegration "kra/internal/integration/payment"
|
||||
"kra/internal/integration/sms"
|
||||
"kra/internal/integration/storage"
|
||||
"kra/internal/integration/systeminfo"
|
||||
websocketintegration "kra/internal/integration/websocket"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
var ProviderSet = wire.NewSet(
|
||||
cache.New,
|
||||
email.NewEmailRepo,
|
||||
sms.NewSender,
|
||||
paymentintegration.NewFactory,
|
||||
wire.Bind(new(bizpayment.PaymentAdapterFactory), new(*paymentintegration.Factory)),
|
||||
storage.NewFileStorage,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package sms implements cloud SMS delivery using saved integration settings.
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
aliyun "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
|
||||
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
tcerrors "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
tencent "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
|
||||
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/config"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
)
|
||||
|
||||
type aliyunClient interface {
|
||||
SendSmsWithOptions(*aliyun.SendSmsRequest, *util.RuntimeOptions) (*aliyun.SendSmsResponse, error)
|
||||
}
|
||||
type tencentClient interface {
|
||||
SendSmsWithContext(context.Context, *tencent.SendSmsRequest) (*tencent.SendSmsResponse, error)
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
store *runtimeconfig.Store
|
||||
newAliyun func(*openapi.Config) (aliyunClient, error)
|
||||
newTencent func(string, string, string) (tencentClient, error)
|
||||
}
|
||||
|
||||
func NewSender(store *runtimeconfig.Store) biz.SMSSender {
|
||||
return &Sender{store: store,
|
||||
newAliyun: func(c *openapi.Config) (aliyunClient, error) { return aliyun.NewClient(c) },
|
||||
newTencent: func(id, secret, region string) (tencentClient, error) {
|
||||
p := profile.NewClientProfile()
|
||||
p.HttpProfile.Endpoint = "sms.tencentcloudapi.com"
|
||||
p.HttpProfile.ReqTimeout = 10
|
||||
p.DisableRegionBreaker = true
|
||||
p.NetworkFailureMaxRetries = 0
|
||||
p.RateLimitExceededMaxRetries = 0
|
||||
p.UnsafeRetryOnConnectionFailure = false
|
||||
return tencent.NewClient(common.NewCredential(id, secret), region, p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(ctx context.Context, m *biz.SMSMessage) (*biz.SMSResult, error) {
|
||||
if err := biz.ValidateSMSMessage(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, ok := s.store.Get(biz.IntegrationKindSMS, m.Provider)
|
||||
if !ok || !c.Enabled {
|
||||
return nil, biz.ErrSMSDisabled
|
||||
}
|
||||
values := map[string]any{}
|
||||
if json.Unmarshal(c.Values, &values) != nil {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindSMS, m.Provider, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range values {
|
||||
if text, ok := value.(string); ok && biz.IsIntegrationSecretKey(key) && config.IsMaskedSecret(text) {
|
||||
return nil, biz.ErrSMSDisabled
|
||||
}
|
||||
}
|
||||
get := func(key string) string { v, _ := values[key].(string); return v }
|
||||
if m.Provider == "aliyun" {
|
||||
return s.sendAliyun(ctx, m, get)
|
||||
}
|
||||
return s.sendTencent(ctx, m, get)
|
||||
}
|
||||
|
||||
func (s *Sender) sendAliyun(ctx context.Context, m *biz.SMSMessage, get func(string) string) (*biz.SMSResult, error) {
|
||||
// This SDK has no context-aware SendSms; bound both connection and read time.
|
||||
timeout := 10000
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
remaining := int(time.Until(deadline).Milliseconds())
|
||||
if remaining <= 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
if remaining < timeout {
|
||||
timeout = remaining
|
||||
}
|
||||
}
|
||||
client, err := s.newAliyun(&openapi.Config{
|
||||
AccessKeyId: tea.String(get("access_key_id")), AccessKeySecret: tea.String(get("access_key_secret")),
|
||||
Endpoint: tea.String("dysmsapi.aliyuncs.com"), Protocol: tea.String("https"),
|
||||
ConnectTimeout: tea.Int(timeout), ReadTimeout: tea.Int(timeout),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
params := m.NamedParams
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
response, err := client.SendSmsWithOptions(&aliyun.SendSmsRequest{
|
||||
PhoneNumbers: tea.String(strings.TrimPrefix(m.Phone, "+86")), SignName: tea.String(get("sign_name")),
|
||||
TemplateCode: tea.String(get("template_id")), TemplateParam: tea.String(string(raw)),
|
||||
}, &util.RuntimeOptions{Autoretry: tea.Bool(false), MaxAttempts: tea.Int(1), ConnectTimeout: tea.Int(timeout), ReadTimeout: tea.Int(timeout)})
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
if response == nil || response.Body == nil {
|
||||
return nil, biz.ErrSMSFailed
|
||||
}
|
||||
if tea.StringValue(response.Body.Code) != "OK" {
|
||||
return nil, providerError(tea.StringValue(response.Body.Code))
|
||||
}
|
||||
return &biz.SMSResult{RequestID: tea.StringValue(response.Body.RequestId), MessageID: tea.StringValue(response.Body.BizId)}, nil
|
||||
}
|
||||
|
||||
func (s *Sender) sendTencent(ctx context.Context, m *biz.SMSMessage, get func(string) string) (*biz.SMSResult, error) {
|
||||
client, err := s.newTencent(get("secret_id"), get("secret_key"), get("region"))
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
request := tencent.NewSendSmsRequest()
|
||||
request.PhoneNumberSet = common.StringPtrs([]string{m.Phone})
|
||||
request.SmsSdkAppId = common.StringPtr(get("sdk_app_id"))
|
||||
request.SignName = common.StringPtr(get("sign_name"))
|
||||
request.TemplateId = common.StringPtr(get("template_id"))
|
||||
request.TemplateParamSet = common.StringPtrs(m.Params)
|
||||
response, err := client.SendSmsWithContext(ctx, request)
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
if response == nil || response.Response == nil || len(response.Response.SendStatusSet) != 1 || response.Response.SendStatusSet[0] == nil {
|
||||
return nil, biz.ErrSMSFailed
|
||||
}
|
||||
status := response.Response.SendStatusSet[0]
|
||||
if tea.StringValue(status.Code) != "Ok" {
|
||||
return nil, providerError(tea.StringValue(status.Code))
|
||||
}
|
||||
return &biz.SMSResult{RequestID: tea.StringValue(response.Response.RequestId), MessageID: tea.StringValue(status.SerialNo)}, nil
|
||||
}
|
||||
|
||||
var providerCodePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
func providerError(code string) error {
|
||||
if !providerCodePattern.MatchString(code) {
|
||||
return biz.ErrSMSFailed
|
||||
}
|
||||
return kratoserrors.BadRequest("SMS_SEND_FAILED", "短信提交失败,服务商错误码:"+code)
|
||||
}
|
||||
|
||||
// Return only provider codes: raw SDK errors can contain signed requests and PII.
|
||||
func sdkError(err error) error {
|
||||
var tc *tcerrors.TencentCloudSDKError
|
||||
if errors.As(err, &tc) {
|
||||
return providerError(tc.Code)
|
||||
}
|
||||
var ali *tea.SDKError
|
||||
if errors.As(err, &ali) {
|
||||
return providerError(tea.StringValue(ali.Code))
|
||||
}
|
||||
return biz.ErrSMSFailed
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
aliyun "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
|
||||
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
tencent "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
)
|
||||
|
||||
type aliFake struct {
|
||||
calls int
|
||||
request *aliyun.SendSmsRequest
|
||||
code string
|
||||
}
|
||||
|
||||
func (f *aliFake) SendSmsWithOptions(req *aliyun.SendSmsRequest, opts *util.RuntimeOptions) (*aliyun.SendSmsResponse, error) {
|
||||
f.calls++
|
||||
f.request = req
|
||||
if tea.BoolValue(opts.Autoretry) {
|
||||
panic("SMS must not retry automatically")
|
||||
}
|
||||
return &aliyun.SendSmsResponse{Body: &aliyun.SendSmsResponseBody{Code: tea.String(f.code), BizId: tea.String("ali-message"), RequestId: tea.String("ali-request")}}, nil
|
||||
}
|
||||
|
||||
type tcFake struct {
|
||||
calls int
|
||||
request *tencent.SendSmsRequest
|
||||
code string
|
||||
}
|
||||
|
||||
func (f *tcFake) SendSmsWithContext(_ context.Context, req *tencent.SendSmsRequest) (*tencent.SendSmsResponse, error) {
|
||||
f.calls++
|
||||
f.request = req
|
||||
return &tencent.SendSmsResponse{Response: &tencent.SendSmsResponseParams{RequestId: tea.String("tc-request"), SendStatusSet: []*tencent.SendStatus{{Code: tea.String(f.code), SerialNo: tea.String("tc-message")}}}}, nil
|
||||
}
|
||||
|
||||
func testSender(t *testing.T, provider string) *Sender {
|
||||
t.Helper()
|
||||
store := runtimeconfig.NewStore()
|
||||
values := biz.DefaultIntegrationConfig("sms", provider)
|
||||
for k, v := range values {
|
||||
if v == "" {
|
||||
values[k] = "test-" + k
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
store.Set(runtimeconfig.Config{Kind: "sms", Provider: provider, Enabled: true, Values: raw})
|
||||
return NewSender(store).(*Sender)
|
||||
}
|
||||
|
||||
func TestAliyunSendAndRejection(t *testing.T) {
|
||||
s := testSender(t, "aliyun")
|
||||
f := &aliFake{code: "OK"}
|
||||
s.newAliyun = func(c *openapi.Config) (aliyunClient, error) {
|
||||
if tea.StringValue(c.Endpoint) != "dysmsapi.aliyuncs.com" || tea.StringValue(c.AccessKeySecret) != "test-access_key_secret" {
|
||||
t.Fatal("incorrect client configuration")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
m := &biz.SMSMessage{Provider: "aliyun", Phone: "+8613800138000", NamedParams: map[string]string{"code": "123456"}}
|
||||
result, err := s.Send(context.Background(), m)
|
||||
if err != nil || result.MessageID != "ali-message" {
|
||||
t.Fatalf("result=%v error=%v", result, err)
|
||||
}
|
||||
if tea.StringValue(f.request.PhoneNumbers) != "13800138000" || tea.StringValue(f.request.TemplateParam) != `{"code":"123456"}` || tea.StringValue(f.request.TemplateCode) != "test-template_id" {
|
||||
t.Fatal("incorrect Aliyun request")
|
||||
}
|
||||
f.code = "isv.BUSINESS_LIMIT_CONTROL"
|
||||
if _, err = s.Send(context.Background(), m); err == nil || !strings.Contains(err.Error(), f.code) {
|
||||
t.Fatalf("provider rejection not surfaced: %v", err)
|
||||
}
|
||||
if f.calls != 2 {
|
||||
t.Fatal("unexpected retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTencentSendAndRejection(t *testing.T) {
|
||||
s := testSender(t, "tencent")
|
||||
f := &tcFake{code: "Ok"}
|
||||
s.newTencent = func(id, key, region string) (tencentClient, error) { return f, nil }
|
||||
m := &biz.SMSMessage{Provider: "tencent", Phone: "+8613800138000", Params: []string{"123456", "5"}}
|
||||
result, err := s.Send(context.Background(), m)
|
||||
if err != nil || result.MessageID != "tc-message" {
|
||||
t.Fatalf("result=%v error=%v", result, err)
|
||||
}
|
||||
if len(f.request.PhoneNumberSet) != 1 || *f.request.PhoneNumberSet[0] != m.Phone || len(f.request.TemplateParamSet) != 2 || *f.request.TemplateParamSet[1] != "5" || *f.request.SmsSdkAppId != "test-sdk_app_id" {
|
||||
t.Fatal("incorrect Tencent request")
|
||||
}
|
||||
f.code = "FailedOperation.SignatureIncorrectOrUnapproved"
|
||||
if _, err = s.Send(context.Background(), m); err == nil || !strings.Contains(err.Error(), f.code) {
|
||||
t.Fatalf("provider rejection not surfaced: %v", err)
|
||||
}
|
||||
c, _ := s.store.Get("sms", "tencent")
|
||||
c.Enabled = false
|
||||
s.store.Set(c)
|
||||
if _, err = s.Send(context.Background(), m); err == nil {
|
||||
t.Fatal("disabled channel sent SMS")
|
||||
}
|
||||
if f.calls != 2 {
|
||||
t.Fatal("disabled channel reached SDK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKErrorDoesNotExposeRawMessage(t *testing.T) {
|
||||
err := sdkError(&tea.SDKError{Code: tea.String("InvalidAccessKeyId.NotFound"), Message: tea.String("secret +8613800138000 signed request")})
|
||||
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "13800138000") || !strings.Contains(err.Error(), "InvalidAccessKeyId.NotFound") {
|
||||
t.Fatalf("unsafe error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,8 @@ func formatConsoleEntry(entry zapcore.Entry, module string, fields []zapcore.Fie
|
||||
|
||||
caller := ""
|
||||
if entry.Caller.Defined {
|
||||
caller = filepath.Base(entry.Caller.File) + fmt.Sprintf(":%d", entry.Caller.Line)
|
||||
callerPath := strings.ReplaceAll(entry.Caller.File, "\\", "/")
|
||||
caller = filepath.Base(callerPath) + fmt.Sprintf(":%d", entry.Caller.Line)
|
||||
}
|
||||
if module == "" {
|
||||
module = "app"
|
||||
@@ -432,6 +433,8 @@ func routedLogPaths(module string, level zapcore.Level) []string {
|
||||
paths = append(paths, filepath.Join("http", "access.log"))
|
||||
case "timedTask":
|
||||
paths = append(paths, filepath.Join("timedTask", "task.log"))
|
||||
case "payment", "payment-callback":
|
||||
paths = append(paths, filepath.Join("payment", "payment.log"))
|
||||
case "websocket":
|
||||
paths = append(paths, filepath.Join("websocket", "websocket.log"))
|
||||
case "error":
|
||||
|
||||
@@ -42,6 +42,8 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||
logger := slog.New(handler)
|
||||
logger.Info("request", "mod", "http", "request_id", "req-1")
|
||||
logger.Info("handshake", "mod", "websocket")
|
||||
logger.Info("payment started", "mod", "payment", "trade_no", "order-1")
|
||||
logger.Info("callback received", "mod", "payment-callback", "payment_provider", "wechat-v3")
|
||||
logger.Error("failed", "mod", "users")
|
||||
cleanup()
|
||||
|
||||
@@ -54,6 +56,7 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||
filepath.Join(root, date, "application.log"),
|
||||
filepath.Join(root, date, "http", "access.log"),
|
||||
filepath.Join(root, date, "websocket", "websocket.log"),
|
||||
filepath.Join(root, date, "payment", "payment.log"),
|
||||
filepath.Join(root, date, "users", "application.log"),
|
||||
filepath.Join(root, date, "error", "error.log"),
|
||||
}
|
||||
|
||||
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||
}
|
||||
}
|
||||
if got := catalog.MigrationSteps(); len(got) != 13 {
|
||||
t.Fatalf("module migrations = %d, want 13", len(got))
|
||||
if got := catalog.MigrationSteps(); len(got) != 14 {
|
||||
t.Fatalf("module migrations = %d, want 14", len(got))
|
||||
}
|
||||
var menus, apis int
|
||||
for _, definition := range catalog.Definitions {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 3 || apis != 17 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis)
|
||||
if menus != 4 || apis != 18 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 4/18", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -3,18 +3,31 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
datasystem "kra/internal/data/system"
|
||||
"kra/internal/modules/surface"
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
func Definition() module.Definition {
|
||||
smsMenu := module.Menu{Name: "sms", Path: "sms", ParentName: "extensions", Component: "view/systemTools/sms/index.vue", Title: "短信服务", Icon: "message", Sort: 9}
|
||||
migrations := append(dataintegration.Migrations(), migration.Step{ID: "202609290001_sms_admin_surface", Migrate: func(db *gorm.DB) error {
|
||||
return datasystem.EnsureAdminSurface(db, module.Surface{
|
||||
Menus: []module.Menu{smsMenu},
|
||||
APIs: []module.API{{Path: "/integration/sms/:provider/send", Method: "POST", Group: "短信服务", Description: "发送单条模板短信"}},
|
||||
}, 888)
|
||||
}})
|
||||
return module.Definition{
|
||||
Name: "integration",
|
||||
Migrations: dataintegration.Migrations(),
|
||||
Migrations: migrations,
|
||||
Surface: module.Surface{
|
||||
Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}},
|
||||
APIs: surface.APIsForPrefix("/integration/configs", 5),
|
||||
Menus: []module.Menu{
|
||||
{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8},
|
||||
smsMenu,
|
||||
},
|
||||
APIs: surface.APIsForPrefix("/integration/", 6),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import "testing"
|
||||
|
||||
func TestDefinitionOwnsCommunicationSurface(t *testing.T) {
|
||||
definition := Definition()
|
||||
if definition.Name != "integration" || len(definition.Migrations) != 5 || len(definition.Surface.Menus) != 1 || len(definition.Surface.APIs) != 5 {
|
||||
if definition.Name != "integration" || len(definition.Migrations) != 6 || len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 6 {
|
||||
t.Fatalf("integration surface = %#v", definition.Surface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type RouteBodyPolicy string
|
||||
|
||||
const (
|
||||
BodyPolicyDefault RouteBodyPolicy = "default"
|
||||
BodyPolicySMS RouteBodyPolicy = "sms"
|
||||
BodyPolicyIntegrationConfig RouteBodyPolicy = "integration_config"
|
||||
BodyPolicyPaymentConfig RouteBodyPolicy = "payment_config"
|
||||
BodyPolicyPaymentCallback RouteBodyPolicy = "payment_callback"
|
||||
@@ -168,6 +169,7 @@ var routes = map[string]routeValue{
|
||||
"POST /init/checkdb": {group: "初始化", description: "检查数据库", public: true},
|
||||
"POST /init/initdb": {group: "初始化", description: "初始化数据库", public: true},
|
||||
"POST /integration/configs/:kind/:provider/test": {group: "集成配置", description: "测试通信集成连接", audit: true, bodyPolicy: BodyPolicyIntegrationConfig},
|
||||
"POST /integration/sms/:provider/send": {group: "短信服务", description: "发送单条模板短信", audit: true, bodyPolicy: BodyPolicySMS},
|
||||
"POST /jwt/jsonInBlacklist": {group: "jwt", description: "jwt加入黑名单(退出,必选)"},
|
||||
"POST /mediaUpload/chunk": {group: "媒体上传", description: "上传分片", bodyPolicy: BodyPolicyUpload},
|
||||
"POST /mediaUpload/complete": {group: "媒体上传", description: "完成大文件上传"},
|
||||
|
||||
+21
-9
@@ -36,7 +36,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
prefix = strings.TrimSuffix(snapshot.Admin.RouterPrefix, "/")
|
||||
}
|
||||
public := engine.Group(prefix)
|
||||
public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") })
|
||||
health := func(c *gin.Context) { c.JSON(http.StatusOK, "ok") }
|
||||
engine.GET("/health", health)
|
||||
if prefix != "" {
|
||||
public.GET("/health", health)
|
||||
}
|
||||
|
||||
private := engine.Group(prefix)
|
||||
// The reference administration behavior installs operation recording after JWT,
|
||||
@@ -65,10 +69,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
handleWebSocket := func(c *gin.Context) {
|
||||
wsLogger := logger
|
||||
hasToken := middleware.RequestToken(c, true) != ""
|
||||
if ws == nil || !ws.Enabled() || c.Request.URL.Path != ws.Path() {
|
||||
expectedPath := websocketRoutePath(prefix, ws)
|
||||
if ws == nil || !ws.Enabled() || c.Request.URL.Path != expectedPath {
|
||||
if wsLogger != nil {
|
||||
wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: endpoint unavailable",
|
||||
"mod", "websocket", "path", c.Request.URL.Path, "expected_path", websocketPath(ws),
|
||||
"mod", "websocket", "path", c.Request.URL.Path, "expected_path", expectedPath,
|
||||
"enabled", ws != nil && ws.Enabled(), "has_token", hasToken)
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
@@ -102,11 +107,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
}
|
||||
}
|
||||
if ws != nil && ws.Enabled() {
|
||||
engine.GET(ws.Path(), handleWebSocket)
|
||||
engine.GET(websocketRoutePath(prefix, ws), handleWebSocket)
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("websocket endpoint initialized", "mod", "websocket",
|
||||
"enabled", ws != nil && ws.Enabled(), "path", websocketPath(ws))
|
||||
"enabled", ws != nil && ws.Enabled(), "path", websocketRoutePath(prefix, ws))
|
||||
}
|
||||
env := ""
|
||||
if snapshot != nil && snapshot.Admin != nil && snapshot.Admin.App != nil {
|
||||
@@ -124,10 +129,6 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
}
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == ws.Path() {
|
||||
handleWebSocket(c)
|
||||
return
|
||||
}
|
||||
if staticfiles.Serve(c, runtime) {
|
||||
return
|
||||
}
|
||||
@@ -163,6 +164,17 @@ func websocketPath(ws *websocket.Server) string {
|
||||
return ws.Path()
|
||||
}
|
||||
|
||||
func websocketRoutePath(prefix string, ws *websocket.Server) string {
|
||||
path := websocketPath(ws)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if prefix == "" {
|
||||
return path
|
||||
}
|
||||
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
}
|
||||
|
||||
func NewGinServer(c *config.Server, engine *gin.Engine) *kratoshttp.Server {
|
||||
network, address := "tcp", ":8000"
|
||||
if c != nil && c.HTTP != nil {
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":196`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":197`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,17 @@ func TestSwaggerSupportsRouterPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpointKeepsRootProbePathWithRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
for _, path := range []string{"/health", "/admin/health"} {
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if response.Code != http.StatusOK || response.Body.String() != `"ok"` {
|
||||
t.Fatalf("health path %s status=%d body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
@@ -10,5 +10,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession,
|
||||
NewIntegrationConfig,
|
||||
NewSMS,
|
||||
wire.Struct(new(Set), "*"),
|
||||
)
|
||||
|
||||
@@ -138,6 +138,8 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc {
|
||||
Write(c, CodeSuccess, gin.H{}, "自动创建数据库成功")
|
||||
return
|
||||
}
|
||||
// Preserve the underlying error for ErrorAudit without exposing it in the response.
|
||||
_ = c.Error(err).SetType(gin.ErrorTypePrivate)
|
||||
Fail(c, "自动创建数据库失败,请查看后台日志,检查后在进行初始化")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
SMS *SMS
|
||||
Authority *Authority
|
||||
Menu *Menu
|
||||
API *API
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/integration"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SMS struct{ service *service.SMSService }
|
||||
|
||||
func NewSMS(s *service.SMSService) *SMS { return &SMS{service: s} }
|
||||
|
||||
func (h *SMS) Send(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var req dto.SMSSendRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, "短信参数无效")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Send(c.Request.Context(), c.Param("provider"), &req)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
@@ -87,7 +87,9 @@ func AccessLog(runtime *config.Store, logger *slog.Logger, version string) gin.H
|
||||
paymentCallback := bodyPolicy == routecatalog.BodyPolicyPaymentCallback
|
||||
paymentConfigWrite := bodyPolicy == routecatalog.BodyPolicyPaymentConfig
|
||||
requestText := ""
|
||||
if paymentCallback {
|
||||
if bodyPolicy == routecatalog.BodyPolicySMS {
|
||||
requestText = "[短信发送参数已隐藏]"
|
||||
} else if paymentCallback {
|
||||
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
|
||||
} else if paymentConfigWrite {
|
||||
requestText = paymentConfigSummary(requestBody)
|
||||
|
||||
@@ -101,6 +101,9 @@ func OperationAudit(runtime *config.Store, recorder *systemservice.AuditRecorder
|
||||
if !capturedBody && routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig {
|
||||
operationBody = paymentConfigSummary(requestBody)
|
||||
}
|
||||
if routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicySMS {
|
||||
operationBody = "[短信发送参数已隐藏]"
|
||||
}
|
||||
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
||||
// Preserve the business response, but expose audit persistence failures
|
||||
// to the global access/error logging pipeline.
|
||||
|
||||
@@ -20,6 +20,7 @@ var sensitivePayloadKeys = map[string]struct{}{
|
||||
"passwd": {}, "pwd": {}, "token": {}, "accesstoken": {}, "refreshtoken": {},
|
||||
"secret": {}, "clientsecret": {}, "apikey": {}, "privatekey": {}, "idcard": {},
|
||||
"key": {}, "signingkey": {}, "secretkey": {},
|
||||
"accesskeysecret": {}, "secretid": {},
|
||||
"appkey": {}, "mchkey": {}, "apiv3key": {}, "clientcert": {}, "clientkey": {},
|
||||
"platformcert": {}, "platformserialno": {}, "publiccert": {}, "credentialcode": {}, "certfile": {},
|
||||
"keyfile": {}, "publickey": {}, "rootcert": {}, "appcert": {}, "webhookid": {},
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/config"
|
||||
"kra/internal/routecatalog"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMSAuditHidesPhoneAndTemplateParameters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var output bytes.Buffer
|
||||
engine := gin.New()
|
||||
engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), slog.New(slog.NewJSONHandler(&output, nil)), "test"))
|
||||
engine.POST("/api/integration/sms/:provider/send", func(c *gin.Context) {
|
||||
if body, _ := c.Get(ctxReqBodyKey); body != "[短信发送参数已隐藏]" {
|
||||
t.Fatalf("unsafe audit body: %v", body)
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 0})
|
||||
})
|
||||
path := "/api/integration/sms/aliyun/send"
|
||||
request := httptest.NewRequest("POST", path, strings.NewReader(`{"phone":"+8613800138000","params":{"code":"987654"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(httptest.NewRecorder(), request)
|
||||
for _, value := range []string{"13800138000", "987654"} {
|
||||
if strings.Contains(output.String(), value) {
|
||||
t.Fatal("SMS content leaked into access log")
|
||||
}
|
||||
}
|
||||
if !routecatalog.ShouldAudit("POST", path) {
|
||||
t.Fatal("send route is not audited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSConfigSecretsAreRedacted(t *testing.T) {
|
||||
text := redactJSON([]byte(`{"config":{"access_key_secret":"ali-value","secret_id":"tc-id-value","secret_key":"tc-value"}}`), "application/json", 4096)
|
||||
for _, secret := range []string{"ali-value", "tc-id-value", "tc-value"} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Fatalf("secret leaked: %s", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
func() { RegisterAnnouncement(private, public, r.handlers.Announcement) },
|
||||
func() { RegisterEmail(private, r.handlers.Email) },
|
||||
func() { RegisterIntegrationConfig(private, r.handlers.IntegrationConfig) },
|
||||
func() { private.POST("/integration/sms/:provider/send", r.handlers.SMS.Send) },
|
||||
func() { RegisterPayment(private, public, r.handlers.Payment) },
|
||||
}
|
||||
for _, register := range registrations {
|
||||
|
||||
@@ -71,4 +71,52 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketRouteSupportsRouterPrefix(t *testing.T) {
|
||||
store := runtimeconfig.NewStore()
|
||||
raw, err := json.Marshal(map[string]any{"path": "/ws"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Set(runtimeconfig.Config{Kind: "websocket", Provider: websocketintegration.ProviderMelody, Enabled: true, Values: raw})
|
||||
ws, cleanup, err := websocketintegration.New(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}}
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}})
|
||||
engine := NewGinEngineWithRuntime(runtime, nil, auth, nil, nil, nil, "test", nil, ws)
|
||||
httpServer := httptest.NewServer(engine)
|
||||
defer httpServer.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/admin/ws?token=login-token"
|
||||
connection, response, err := gorillawebsocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("prefixed websocket handshake failed: %v", err)
|
||||
}
|
||||
if connection == nil {
|
||||
t.Fatal("prefixed websocket handshake returned nil connection")
|
||||
}
|
||||
_ = connection.Close()
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/ws" {
|
||||
t.Fatal("unprefixed websocket route should not be registered")
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/admin/ws" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("prefixed websocket route was not registered")
|
||||
}
|
||||
}
|
||||
|
||||
var _ platformws.Hub = (*websocketintegration.Server)(nil)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package dto
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type SMSSendRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type SMSSendResponse struct {
|
||||
RequestID string `json:"requestId"`
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
@@ -3,4 +3,4 @@ package integration
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet wires integration transport services.
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigService)
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigService, NewSMSService)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type smsUsecase interface {
|
||||
Send(context.Context, *biz.SMSMessage) (*biz.SMSResult, error)
|
||||
}
|
||||
|
||||
type SMSService struct{ uc smsUsecase }
|
||||
|
||||
func NewSMSService(uc *biz.SMSUsecase) *SMSService { return &SMSService{uc: uc} }
|
||||
|
||||
func (s *SMSService) Send(ctx context.Context, provider string, req *dto.SMSSendRequest) (*dto.SMSSendResponse, error) {
|
||||
if req == nil || len(req.Params) > 12000 {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m := &biz.SMSMessage{Provider: provider, Phone: strings.TrimSpace(req.Phone)}
|
||||
params := bytes.TrimSpace(req.Params)
|
||||
var values any
|
||||
if json.Unmarshal(params, &values) != nil {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
switch provider {
|
||||
case "aliyun":
|
||||
object, ok := values.(map[string]any)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.NamedParams = make(map[string]string, len(object))
|
||||
for key, value := range object {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.NamedParams[key] = text
|
||||
}
|
||||
case "tencent":
|
||||
array, ok := values.([]any)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.Params = make([]string, 0, len(array))
|
||||
for _, value := range array {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.Params = append(m.Params, text)
|
||||
}
|
||||
default:
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
if err := biz.ValidateSMSMessage(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.uc.Send(ctx, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.SMSSendResponse{RequestID: result.RequestID, MessageID: result.MessageID}, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/service/dto"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type smsSenderFake struct{ calls int }
|
||||
|
||||
func (f *smsSenderFake) Send(context.Context, *biz.SMSMessage) (*biz.SMSResult, error) {
|
||||
f.calls++
|
||||
return &biz.SMSResult{MessageID: "test"}, nil
|
||||
}
|
||||
|
||||
func TestSMSRejectsInvalidInputBeforeDelivery(t *testing.T) {
|
||||
fake := &smsSenderFake{}
|
||||
s := &SMSService{uc: fake}
|
||||
for _, tt := range []struct{ provider, phone, params string }{
|
||||
{"aliyun", "+8613800138000", `[]`}, {"tencent", "+8613800138000", `{}`},
|
||||
{"tencent", "+8613800138000", `null`}, {"tencent", "+8613800138000", `[123]`},
|
||||
{"tencent", "+8613800138000", `[null]`}, {"aliyun", "+8613800138000", `{"code":null}`},
|
||||
{"aliyun", "13800138000", `{}`}, {"aliyun", "+12025550123", `{}`},
|
||||
{"aliyun", "+8613800138000,+8613800138001", `{}`}, {"other", "+8613800138000", `{}`},
|
||||
} {
|
||||
if _, err := s.Send(context.Background(), tt.provider, &dto.SMSSendRequest{Phone: tt.phone, Params: json.RawMessage(tt.params)}); err == nil {
|
||||
t.Fatalf("accepted invalid input: %+v", tt)
|
||||
}
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Fatal("invalid input reached sender")
|
||||
}
|
||||
for _, provider := range []string{"aliyun", "tencent"} {
|
||||
params := `{}`
|
||||
if provider == "tencent" {
|
||||
params = `[]`
|
||||
}
|
||||
if _, err := s.Send(context.Background(), provider, &dto.SMSSendRequest{Phone: "+8613800138000", Params: json.RawMessage(params)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,50 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
#charset koi8-r;
|
||||
#access_log logs/host.access.log main;
|
||||
|
||||
# K8s 部署:proxy_pass 的服务名由 Jenkins envsubst '$KRA_APP' 渲染为 <APP_NAME>-backend,
|
||||
# 产物写至 web/nginx.k8s.conf(不入库)。本地手动构建 web 镜像前需先执行渲染。
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
location = /api {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
rewrite ^/api/(.*)$ /$1 break; #重写
|
||||
proxy_pass http://177.7.0.12:8888; # 设置代理服务器的协议和地址
|
||||
}
|
||||
proxy_pass http://${KRA_APP}-backend:8000; # Kubernetes Service
|
||||
}
|
||||
|
||||
location /api/swagger/index.html {
|
||||
proxy_pass http://127.0.0.1:8888/swagger/index.html;
|
||||
}
|
||||
}
|
||||
location ^~ /api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 600s;
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass http://${KRA_APP}-backend:8000;
|
||||
}
|
||||
|
||||
location = /api/swagger/index.html {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://${KRA_APP}-backend:8000/swagger/index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,6 @@ ENV = 'production'
|
||||
#下方为上线需要用到的程序代理前缀,一般用于nginx代理转发
|
||||
VITE_BASE_API = /api
|
||||
VITE_FILE_API = /api
|
||||
|
||||
# 后端与前端端口不一致时配置,例如 VITE_SERVER_PORT = 30081。
|
||||
# 也可以使用 VITE_WEBSOCKET_URL = http://192.168.200.128:30081 指定后端地址。
|
||||
|
||||
@@ -4,3 +4,5 @@ package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
config.yaml
|
||||
# 渲染产物:Jenkins 从 .docker-compose/nginx/conf.d/my.conf 生成
|
||||
/nginx.k8s.conf
|
||||
|
||||
+5
-24
@@ -1,24 +1,5 @@
|
||||
# 如果需要用 cicd ,请设置环境变量:
|
||||
# variables:
|
||||
# DOCKER_BUILDKIT: 1
|
||||
|
||||
FROM node:20-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
FROM base AS prod-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod
|
||||
|
||||
FROM base AS build
|
||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install && pnpm run build
|
||||
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=base /app/.docker-compose/nginx/conf.d/my.conf /etc/nginx/conf.d/my.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
RUN ls -al /usr/share/nginx/html
|
||||
FROM docker.m.daocloud.io/nginx:alpine
|
||||
# nginx.k8s.conf is rendered by Jenkins from
|
||||
# .docker-compose/nginx/conf.d/my.conf (envsubst '$KRA_APP').
|
||||
COPY nginx.k8s.conf /etc/nginx/conf.d/default.conf
|
||||
COPY dist /usr/share/nginx/html
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"spark-md5": "^3.0.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"universal-cookie": "^7",
|
||||
"vite-auto-import-svg": "^2.9.8",
|
||||
"vue": "^3.5.31",
|
||||
"vue-cropper": "^1.1.4",
|
||||
"vue-qr": "^4.0.9",
|
||||
@@ -65,7 +64,6 @@
|
||||
"globals": "^16.3.0",
|
||||
"sass": "^1.78.0",
|
||||
"vite": "^8.0.10",
|
||||
"vite-check-multiple-dom": "0.2.1",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vue-eslint-parser": "^9.4.3"
|
||||
}
|
||||
|
||||
Generated
-22
@@ -89,9 +89,6 @@ importers:
|
||||
universal-cookie:
|
||||
specifier: ^7
|
||||
version: 7.2.2
|
||||
vite-auto-import-svg:
|
||||
specifier: ^2.9.8
|
||||
version: 2.9.8
|
||||
vue:
|
||||
specifier: ^3.5.31
|
||||
version: 3.5.41
|
||||
@@ -162,9 +159,6 @@ importers:
|
||||
vite:
|
||||
specifier: ^8.0.10
|
||||
version: 8.2.1(jiti@2.7.0)(sass@1.102.0)
|
||||
vite-check-multiple-dom:
|
||||
specifier: 0.2.1
|
||||
version: 0.2.1(vite@8.2.1(jiti@2.7.0)(sass@1.102.0))(vue@3.5.41)
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: ^8.1.1
|
||||
version: 8.2.1(supports-color@7.2.0)(vite@8.2.1(jiti@2.7.0)(sass@1.102.0))(vue@3.5.41)
|
||||
@@ -2519,15 +2513,6 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
vite-auto-import-svg@2.9.8:
|
||||
resolution: {integrity: sha512-oL67VbbfBnU7iThG3lRYdCTXJ02QMn0s6acQKpjvgZkasbQicquFYvZTRUZ6wVQ62w6RdWyqv2f7xWRV1V9l1w==}
|
||||
|
||||
vite-check-multiple-dom@0.2.1:
|
||||
resolution: {integrity: sha512-3UbOgHB7MKEHkxtrDKM0vamvW6155U1QvADbhc+l+AtrKdvyK8Dmx4/TFKkXu5d4lz5hgyacQqtjO9f9mteLqw==}
|
||||
peerDependencies:
|
||||
vite: '>=4.0.0'
|
||||
vue: '>=3.0.0'
|
||||
|
||||
vite-dev-rpc@2.0.0:
|
||||
resolution: {integrity: sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA==}
|
||||
peerDependencies:
|
||||
@@ -5140,13 +5125,6 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite-auto-import-svg@2.9.8: {}
|
||||
|
||||
vite-check-multiple-dom@0.2.1(vite@8.2.1(jiti@2.7.0)(sass@1.102.0))(vue@3.5.41):
|
||||
dependencies:
|
||||
vite: 8.2.1(jiti@2.7.0)(sass@1.102.0)
|
||||
vue: 3.5.41
|
||||
|
||||
vite-dev-rpc@2.0.0(vite@8.2.1(jiti@2.7.0)(sass@1.102.0)):
|
||||
dependencies:
|
||||
birpc: 4.1.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': set this to true or false
|
||||
'@vue-office/docx': set this to true or false
|
||||
'@vue-office/excel': set this to true or false
|
||||
'@vue-office/pdf': set this to true or false
|
||||
es5-ext: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
'@parcel/watcher': true
|
||||
'@vue-office/docx': true
|
||||
'@vue-office/excel': true
|
||||
'@vue-office/pdf': true
|
||||
es5-ext: true
|
||||
vue-demi: true
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
export const sendSMS = (provider, data) => service({
|
||||
url: `/integration/sms/${encodeURIComponent(provider)}/send`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const getIntegrationConfigs = (kind) => service({
|
||||
url: `/integration/configs/${encodeURIComponent(kind)}`,
|
||||
method: 'get'
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
const isRightSize = file.size / 1024 < props.fileSize
|
||||
if (!isRightSize) {
|
||||
// 压缩
|
||||
const compress = new ImageCompress(file, props.fileSize, props.maxWH)
|
||||
const compress = new ImageCompress(file, props.maxWH)
|
||||
return compress.compress()
|
||||
}
|
||||
return isRightSize
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import 'uno.css'
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { setupVueRootValidator } from 'vite-check-multiple-dom/client';
|
||||
|
||||
import 'element-plus/dist/index.css'
|
||||
// 主题扩展样式(菜单风格/卡片盒子模式/圆角),置于 EP 基础样式之后以便覆盖
|
||||
@@ -24,10 +23,6 @@ const app = createApp(App)
|
||||
|
||||
app.config.productionTip = false
|
||||
|
||||
setupVueRootValidator(app, {
|
||||
lang: 'zh'
|
||||
})
|
||||
|
||||
app
|
||||
.use(run)
|
||||
.use(ElementPlus)
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"/src/view/systemTools/logViewer/index.vue": "LogViewer",
|
||||
"/src/view/systemTools/payment/config.vue": "PaymentConfig",
|
||||
"/src/view/systemTools/payment/orders.vue": "PaymentOrders",
|
||||
"/src/view/systemTools/sms/index.vue": "SmsService",
|
||||
"/src/view/systemTools/sysError/sysError.vue": "SysError",
|
||||
"/src/view/systemTools/system/system.vue": "Config",
|
||||
"/src/view/systemTools/timedTask/index.vue": "TimedTask",
|
||||
|
||||
+1
-15
@@ -1,14 +1,12 @@
|
||||
export default class ImageCompress {
|
||||
constructor(file, fileSize, maxWH = 1920) {
|
||||
constructor(file, maxWH = 1920) {
|
||||
this.file = file
|
||||
this.fileSize = fileSize
|
||||
this.maxWH = maxWH // 最大长宽
|
||||
}
|
||||
|
||||
compress() {
|
||||
// 压缩
|
||||
const fileType = this.file.type
|
||||
const fileSize = this.file.size / 1024
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(this.file)
|
||||
@@ -28,12 +26,6 @@ export default class ImageCompress {
|
||||
|
||||
const newImgData = canvas.toDataURL(fileType, 0.9)
|
||||
|
||||
// 压缩宽高后的图像大小
|
||||
const newImgSize = this.fileSizeKB(newImgData)
|
||||
|
||||
if (newImgSize > this.fileSize) {
|
||||
}
|
||||
|
||||
const blob = this.dataURLtoBlob(newImgData, fileType)
|
||||
const nfile = new File([blob], this.file.name)
|
||||
resolve(nfile)
|
||||
@@ -66,12 +58,6 @@ export default class ImageCompress {
|
||||
}
|
||||
}
|
||||
|
||||
fileSizeKB(dataURL) {
|
||||
let sizeKB = 0
|
||||
sizeKB = Math.round((dataURL.split(',')[1].length * 3) / 4 / 1024)
|
||||
return sizeKB
|
||||
}
|
||||
|
||||
/**
|
||||
* 转为Blob
|
||||
*/
|
||||
|
||||
@@ -112,7 +112,7 @@ router.isReady().then(() => {
|
||||
let query = router.currentRoute.value.query
|
||||
classId.value = query.id
|
||||
token.value = query.token
|
||||
}).catch((err) => {
|
||||
}).catch(() => {
|
||||
})
|
||||
|
||||
const uploadRef = ref(null)
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
<el-button
|
||||
v-if="isWebSocket(selected)"
|
||||
:icon="DocumentCopy"
|
||||
:disabled="isBusy(selected)"
|
||||
:disabled="isBusy(selected) || !canTestConnectionInfo(selected)"
|
||||
@click="openConnectionInfo"
|
||||
>
|
||||
连接信息
|
||||
@@ -326,9 +326,29 @@ const selected = computed(
|
||||
const integrationKey = (item) => `${item.kind}/${item.provider}`
|
||||
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
|
||||
const isWebSocket = (item) => integrationKey(item) === 'websocket/melody'
|
||||
const websocketApiBase = () => String(import.meta.env.VITE_BASE_API || '').trim()
|
||||
const websocketUrlOverride = () => String(import.meta.env.VITE_WEBSOCKET_URL || '').trim()
|
||||
const websocketUrlPath = (value) => {
|
||||
try {
|
||||
return new URL(value, window.location.origin).pathname.replace(/\/+$/, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const websocketBackendIsExplicit = () =>
|
||||
Boolean(
|
||||
websocketUrlOverride() ||
|
||||
String(import.meta.env.VITE_SERVER_PORT || '').trim() ||
|
||||
/^https?:\/\//i.test(websocketApiBase())
|
||||
)
|
||||
const websocketOrigin = () => {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_SERVER_PORT) {
|
||||
const configuredBase = String(import.meta.env.VITE_BASE_PATH || window.location.origin)
|
||||
if (websocketBackendIsExplicit()) {
|
||||
const configuredBase = String(
|
||||
websocketUrlOverride() ||
|
||||
(/^https?:\/\//i.test(websocketApiBase())
|
||||
? websocketApiBase()
|
||||
: import.meta.env.VITE_BASE_PATH || window.location.origin)
|
||||
)
|
||||
const backend = new URL(configuredBase, window.location.origin)
|
||||
if (
|
||||
['127.0.0.1', 'localhost'].includes(backend.hostname) &&
|
||||
@@ -336,18 +356,32 @@ const websocketOrigin = () => {
|
||||
) {
|
||||
backend.hostname = window.location.hostname
|
||||
}
|
||||
backend.port = String(import.meta.env.VITE_SERVER_PORT)
|
||||
if (import.meta.env.VITE_SERVER_PORT) {
|
||||
backend.port = String(import.meta.env.VITE_SERVER_PORT)
|
||||
}
|
||||
backend.protocol = backend.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${backend.protocol}//${backend.host}`
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}`
|
||||
}
|
||||
const websocketPathPrefix = () => {
|
||||
const override = websocketUrlOverride()
|
||||
if (override) return websocketUrlPath(override)
|
||||
const base = websocketApiBase()
|
||||
if (!base) return ''
|
||||
if (/^https?:\/\//i.test(base)) {
|
||||
return websocketUrlPath(base)
|
||||
}
|
||||
if (String(import.meta.env.VITE_SERVER_PORT || '').trim()) return ''
|
||||
return `/${base.replace(/^\/+|\/+$/g, '')}`
|
||||
}
|
||||
const connectionInfo = computed(() => {
|
||||
const path = String(selected.value?.config?.path || '/ws').trim() || '/ws'
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
const routePath = `${websocketPathPrefix()}${normalizedPath}`
|
||||
const token = String(userStore.token || '').trim()
|
||||
const url = `${websocketOrigin()}${normalizedPath}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||
const url = `${websocketOrigin()}${routePath}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||
return {
|
||||
url,
|
||||
example: `const socket = new WebSocket(${JSON.stringify(url)})\n\nsocket.onopen = () => console.log('connected')\nsocket.onmessage = (event) => console.log(event.data)`
|
||||
@@ -357,7 +391,7 @@ const connectionTestMessage = computed(() => {
|
||||
if (connectionTestStatus.value === 'testing') return '等待服务器握手包...'
|
||||
if (connectionTestStatus.value === 'success') return '握手成功,WebSocket 已连接。'
|
||||
if (connectionTestStatus.value === 'error') return '握手失败,请查看 websocket/websocket.log。'
|
||||
return '点击“测试此地址”验证浏览器到服务器的真实握手。'
|
||||
return '“测试连接”仅校验当前参数;保存并启用后可测试正式地址。'
|
||||
})
|
||||
const connectionTestAlertType = computed(() => {
|
||||
if (connectionTestStatus.value === 'success') return 'success'
|
||||
@@ -469,6 +503,8 @@ const isBusy = (item) => Boolean(pending[operationKey(item)])
|
||||
const isSaving = (item) => pending[operationKey(item)] === 'save'
|
||||
const isToggling = (item) => pending[operationKey(item)] === 'toggle'
|
||||
const isTesting = (item) => pending[operationKey(item)] === 'test'
|
||||
const canTestConnectionInfo = (item) =>
|
||||
Boolean(item?.configured && item.enabled && !isDirty(item))
|
||||
const numberConstraint = (fieldKey) =>
|
||||
NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined }
|
||||
|
||||
@@ -601,7 +637,7 @@ const testSelected = async () => {
|
||||
if (res.code !== 0) {
|
||||
return
|
||||
}
|
||||
ElMessage.success(`${item.name || providerMeta(item).name} 连接测试成功`)
|
||||
ElMessage.success(`${item.name || providerMeta(item).name} 配置测试成功`)
|
||||
} catch {
|
||||
// The request layer owns transport and server error presentation.
|
||||
} finally {
|
||||
|
||||
@@ -263,10 +263,29 @@ const testResultTitle = computed(() => {
|
||||
})
|
||||
|
||||
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
||||
const defaultNotifyURL = (provider) => {
|
||||
if (!provider || typeof window === 'undefined') return ''
|
||||
try {
|
||||
const base = new URL(import.meta.env.VITE_BASE_API || '/', window.location.origin)
|
||||
const backendPort = String(import.meta.env.VITE_SERVER_PORT || '').trim()
|
||||
if (backendPort) {
|
||||
base.hostname = window.location.hostname
|
||||
base.port = backendPort
|
||||
base.pathname = '/'
|
||||
}
|
||||
base.pathname = `${base.pathname.replace(/\/+$/, '')}/payment/callback/${encodeURIComponent(provider)}`
|
||||
base.search = ''
|
||||
base.hash = ''
|
||||
return base.href
|
||||
} catch { return '' }
|
||||
}
|
||||
const normalizeConfig = (item) => {
|
||||
const normalized = { ...item, enabled: Boolean(item.enabled), configured: Boolean(item.configured), config: cloneConfig(item.config), fields: Array.isArray(item.fields) ? item.fields : [] }
|
||||
normalized._savedEnabled = normalized.enabled
|
||||
normalized._savedConfig = cloneConfig(normalized.config)
|
||||
if (normalized.fields.some((field) => field.key === 'notify_url') && isEmpty(normalized.config.notify_url)) {
|
||||
normalized.config.notify_url = defaultNotifyURL(normalized.provider)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
const providerText = (provider) => PROVIDER_NAMES[provider] || provider
|
||||
@@ -300,7 +319,8 @@ const fieldHint = (field, item = selected.value) => {
|
||||
if (field.key === 'test_amount') return '使用最小货币单位,例如 CNY 1 表示 0.01 元。'
|
||||
if (field.key === 'test_extra') return '必须是 JSON 对象;可传 openid、auth_code 等测试参数。'
|
||||
if (isCallbackURLField(field)) {
|
||||
return isProductionEnvironment(item) ? '生产环境回调地址必须使用 HTTPS;沙箱环境可使用 HTTP。' : '沙箱环境可使用 HTTP;切换生产环境前请改为 HTTPS。'
|
||||
const hint = isProductionEnvironment(item) ? '生产环境回调地址必须使用 HTTPS;沙箱环境可使用 HTTP。' : '沙箱环境可使用 HTTP;切换生产环境前请改为 HTTPS。'
|
||||
return field.key === 'notify_url' ? `已按当前访问地址预填;请确认支付平台可从公网访问。${hint}` : hint
|
||||
}
|
||||
return field.description || ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div class="kra-table-box integration-page">
|
||||
<header class="page-heading">
|
||||
<div><h2>短信服务</h2><p>配置短信渠道,通过已审核的模板发送通知。</p></div>
|
||||
<el-button :icon="Refresh" :loading="loading" :disabled="busy" @click="refresh">刷新</el-button>
|
||||
</header>
|
||||
<div v-loading="loading" class="integration-layout">
|
||||
<aside class="provider-panel" aria-label="短信服务列表">
|
||||
<div class="panel-heading"><span>服务</span><span>{{ items.length }}</span></div>
|
||||
<button v-for="item in items" :key="item.provider" type="button" class="provider-item"
|
||||
:class="{ active: provider === item.provider }" :aria-pressed="provider === item.provider"
|
||||
:disabled="busy" @click="provider = item.provider">
|
||||
<span class="provider-icon" aria-hidden="true"><el-icon><Message /></el-icon></span>
|
||||
<span class="provider-copy"><strong>{{ item.name }}</strong><small>{{ item.provider === 'aliyun' ? 'Aliyun SMS' : 'Tencent Cloud SMS' }}</small></span>
|
||||
<span class="provider-state" :class="{ enabled: item.enabled }">{{ item.enabled ? '已启用' : '已停用' }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
<section v-if="selected" class="editor-panel">
|
||||
<header class="editor-heading">
|
||||
<div class="editor-title-group">
|
||||
<div class="editor-title-row"><h3>{{ selected.name }}</h3><el-tag v-if="dirty" type="warning" effect="plain">未保存</el-tag></div>
|
||||
<p>{{ selected.description }}</p>
|
||||
</div>
|
||||
<div class="enable-control">
|
||||
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
|
||||
<el-switch v-model="selected.enabled" :disabled="busy" aria-label="启用短信服务" />
|
||||
</div>
|
||||
</header>
|
||||
<el-alert v-if="hasMaskedSecret" class="secret-alert" type="info" :closable="false" show-icon
|
||||
title="密钥已脱敏;保留 ****** 将继续使用当前密钥。" />
|
||||
<el-form :model="selected.config" label-position="top" :disabled="busy" class="config-form" @submit.prevent>
|
||||
<div class="field-grid">
|
||||
<el-form-item v-for="field in selected.fields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<el-input v-model="selected.config[field.key]" :type="field.secret ? 'password' : 'text'"
|
||||
class="field-control" :show-password="field.secret" :maxlength="512" autocomplete="off" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<footer class="editor-actions">
|
||||
<span class="save-state">{{ selected.configured ? '配置已创建' : '尚未保存配置' }}</span>
|
||||
<el-button :icon="Message" :disabled="busy || !canSend" @click="testVisible = true">测试发送</el-button>
|
||||
<el-button type="primary" :icon="Check" :loading="saving" :disabled="busy || (!dirty && selected.configured)" @click="save">保存配置</el-button>
|
||||
</footer>
|
||||
</section>
|
||||
<el-empty v-else-if="!loading" description="暂无短信配置" />
|
||||
</div>
|
||||
<el-dialog v-model="testVisible" :title="`${selected?.name || '短信'} · 测试发送`" width="560px"
|
||||
:close-on-click-modal="false" :close-on-press-escape="!sending" :show-close="!sending">
|
||||
<template v-if="selected">
|
||||
<el-alert type="warning" :closable="false" show-icon title="会发送真实短信,并按服务商规则计费。请使用你有权联系的测试号码。" />
|
||||
<p class="field-description test-description">使用已保存且启用的签名与模板。提交成功表示服务商已受理,实际送达请查看服务商控制台。</p>
|
||||
<el-form label-position="top" :disabled="busy">
|
||||
<el-form-item label="接收号码" required>
|
||||
<el-input v-model="draft.phone" placeholder="例如 +8613800138000" maxlength="16" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板参数(JSON)" required>
|
||||
<el-input v-model="draft.params" type="textarea" :rows="5" :maxlength="12000" spellcheck="false" />
|
||||
<p class="field-description">{{ provider === 'aliyun' ? '填写命名参数对象,例如 {"code":"123456"};无参数时填写 {}。' : '按模板占位符顺序填写字符串数组,例如 ["123456","5"];无参数时填写 []。' }}</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="draft.result" type="success" :closable="false" title="服务商已受理">
|
||||
<p>请求 ID:{{ draft.result.requestId || '未返回' }}</p>
|
||||
<p>短信流水号:{{ draft.result.messageId || '未返回' }}</p>
|
||||
</el-alert>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button :disabled="sending" @click="testVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :icon="Message" :loading="sending" :disabled="busy || !canSend" @click="send">发送测试短信</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Message, Refresh } from '@element-plus/icons-vue'
|
||||
import { getIntegrationConfigs, saveIntegrationConfig, sendSMS } from '@/api/integration'
|
||||
|
||||
defineOptions({ name: 'SmsService' })
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const sending = ref(false)
|
||||
const testVisible = ref(false)
|
||||
const busy = computed(() => loading.value || saving.value || sending.value)
|
||||
const provider = ref('aliyun')
|
||||
const items = ref([])
|
||||
const drafts = reactive({
|
||||
aliyun: { phone: '', params: '{}', result: null },
|
||||
tencent: { phone: '', params: '[]', result: null }
|
||||
})
|
||||
const selected = computed(() => items.value.find(item => item.provider === provider.value))
|
||||
const draft = computed(() => drafts[provider.value])
|
||||
const snapshot = item => JSON.stringify({ enabled: item.enabled, config: item.config })
|
||||
const dirty = computed(() => selected.value && snapshot(selected.value) !== selected.value.saved)
|
||||
const canSend = computed(() => selected.value?.configured && selected.value.enabled && !dirty.value)
|
||||
const hasMaskedSecret = computed(() => selected.value?.fields.some(field => field.secret && selected.value.config[field.key] === '******'))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getIntegrationConfigs('sms')
|
||||
if (res.code !== 0) return
|
||||
items.value = (res.data || []).filter(item => drafts[item.provider]).map(item => ({ ...item, saved: snapshot(item) }))
|
||||
} catch { /* Request layer displays errors. */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (items.value.some(item => snapshot(item) !== item.saved)) {
|
||||
try { await ElMessageBox.confirm('刷新会丢弃未保存的配置修改,是否继续?', '刷新配置', { type: 'warning' }) } catch { return }
|
||||
}
|
||||
await load()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const item = selected.value
|
||||
if (!item || busy.value) return
|
||||
if (item.enabled) {
|
||||
const missing = item.fields.find(field => field.required && !String(item.config[field.key] || '').trim())
|
||||
if (missing) return ElMessage.warning(`请填写${missing.label}`)
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveIntegrationConfig('sms', item.provider, { enabled: item.enabled, config: { ...item.config } })
|
||||
if (res.code !== 0) return
|
||||
for (const field of item.fields) {
|
||||
if (field.secret && item.config[field.key]) item.config[field.key] = '******'
|
||||
}
|
||||
item.configured = true
|
||||
item.saved = snapshot(item)
|
||||
ElMessage.success('短信配置已保存')
|
||||
} catch { /* Request layer displays errors. */ } finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const item = selected.value
|
||||
if (busy.value || !item?.configured || !item.enabled || dirty.value) return
|
||||
const current = drafts[item.provider]
|
||||
const phone = current.phone.trim()
|
||||
if (!/^\+[1-9]\d{6,14}$/.test(phone)) return ElMessage.warning('请填写包含国家区号的号码,例如 +8613800138000')
|
||||
if (item.provider === 'aliyun' && !/^\+861\d{10}$/.test(phone)) return ElMessage.warning('阿里云短信请填写中国大陆手机号')
|
||||
let params
|
||||
try {
|
||||
params = JSON.parse(current.params)
|
||||
const valid = item.provider === 'aliyun'
|
||||
? params !== null && typeof params === 'object' && !Array.isArray(params) && Object.values(params).every(v => typeof v === 'string')
|
||||
: Array.isArray(params) && params.every(v => typeof v === 'string')
|
||||
if (!valid) throw new Error('invalid')
|
||||
} catch { return ElMessage.warning('模板参数格式不正确,请按下方示例填写字符串参数') }
|
||||
sending.value = true
|
||||
current.result = null
|
||||
try {
|
||||
try {
|
||||
await ElMessageBox.confirm(`将通过${item.name}向 ${phone} 发送一条真实短信,可能产生费用。是否发送?`, '确认发送', { confirmButtonText: '发送一条短信', cancelButtonText: '取消', type: 'warning' })
|
||||
} catch { return }
|
||||
const res = await sendSMS(item.provider, { phone, params })
|
||||
if (res.code === 0) {
|
||||
current.result = res.data
|
||||
ElMessage.success('短信已提交服务商')
|
||||
}
|
||||
} catch { /* Do not retry: a timed-out send may already have been accepted. */ } finally { sending.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.integration-page {
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.integration-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
min-height: 560px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.provider-panel {
|
||||
padding: 12px 9px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-item:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.provider-item.active {
|
||||
border-left-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.provider-item.active .provider-icon {
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.provider-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.provider-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-copy small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.provider-state {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-state.enabled {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: 22px 28px 20px;
|
||||
}
|
||||
|
||||
.editor-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.editor-title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.editor-title-row h3 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editor-title-group p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.enable-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secret-alert {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
flex: 1;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 22px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label small {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-description {
|
||||
width: 100%;
|
||||
margin: 5px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.copy-example {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.save-state {
|
||||
margin-right: auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.integration-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.provider-state {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.integration-page {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.provider-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.provider-state {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.editor-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.enable-control {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.test-description { margin: 14px 0 20px; }
|
||||
</style>
|
||||
+51
-5
@@ -4,10 +4,57 @@ import * as path from 'path'
|
||||
import { loadEnv } from 'vite'
|
||||
import vuePlugin from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import { svgBuilder } from 'vite-auto-import-svg'
|
||||
import vueRootValidator from 'vite-check-multiple-dom'
|
||||
import UnoCSS from '@unocss/vite'
|
||||
|
||||
const svgSpritePlugin = (roots) => ({
|
||||
name: 'kra-svg-sprite',
|
||||
transformIndexHtml(html) {
|
||||
const symbols = []
|
||||
const visit = (directory, modulePrefix = '') => {
|
||||
if (!fs.existsSync(directory)) return
|
||||
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
for (const entry of entries) {
|
||||
const filename = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
visit(filename, modulePrefix || entry.name + '-')
|
||||
continue
|
||||
}
|
||||
if (!entry.name.endsWith('.svg')) continue
|
||||
|
||||
const source = fs.readFileSync(filename, 'utf8')
|
||||
const openingTag = source.match(/<svg\b([^>]*)>/i)
|
||||
if (!openingTag) continue
|
||||
|
||||
const attrs = openingTag[1]
|
||||
.replace(/\s(?:width|height)=(['"]).*?\1/gi, '')
|
||||
.trim()
|
||||
const body = source
|
||||
.slice(openingTag[0].length)
|
||||
.replace(/<\/svg>\s*$/i, '')
|
||||
const iconName = entry.name.replace(/\.svg$/i, '')
|
||||
symbols.push(`<symbol id="${modulePrefix}${iconName}"${attrs ? ` ${attrs}` : ''}>${body}</symbol>`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
const directory = path.resolve(root)
|
||||
const moduleRoot = path.basename(path.dirname(directory)) === 'src' && path.basename(directory) === 'modules'
|
||||
if (!moduleRoot) {
|
||||
visit(directory)
|
||||
continue
|
||||
}
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) visit(path.join(directory, entry.name), entry.name + '-')
|
||||
}
|
||||
}
|
||||
|
||||
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0">${symbols.join('')}</svg>`
|
||||
return html.replace('<body>', `<body>\n${sprite}`)
|
||||
}
|
||||
})
|
||||
|
||||
const pathMapPlugin = () => ({
|
||||
name: 'kra-vue-path-map',
|
||||
buildStart() {
|
||||
@@ -97,10 +144,9 @@ export default ({ mode }) => {
|
||||
env.VITE_POSITION === 'open' &&
|
||||
vueDevTools({ launchEditor: env.VITE_EDITOR }),
|
||||
vuePlugin(),
|
||||
svgBuilder(['./src/modules/', './src/assets/icons/'], base, outDir, 'assets', mode),
|
||||
svgSpritePlugin(['./src/modules/', './src/assets/icons/']),
|
||||
pathMapPlugin(),
|
||||
UnoCSS(),
|
||||
vueRootValidator()
|
||||
UnoCSS()
|
||||
]
|
||||
}
|
||||
return config
|
||||
|
||||
Reference in New Issue
Block a user