Merge branch 'v2-invite' of https://github.com/certd/certd into v2-invite

This commit is contained in:
xiaojunnuo
2026-05-26 09:02:53 +08:00
65 changed files with 1034 additions and 351 deletions
+1 -1
View File
@@ -177,6 +177,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- 优先沿用现有模块、插件、服务模式,再考虑新增抽象。 - 优先沿用现有模块、插件、服务模式,再考虑新增抽象。
- `packages/ui/certd-server/data/``logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。 - `packages/ui/certd-server/data/``logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。
- 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。 - 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。
- 使用 `/basic/file/upload` 上传文件后,接口返回的是临时缓存 key。业务保存表单或设置时,后端必须调用 `FileService.saveFile(userId, key, "public" | "private")` 转成永久文件 key 后再入库/入设置;不要直接保存 `tmpfile_key_...`,否则后续回显或下载会失效。
- 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。 - 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。
- 代码可读性优先于短写法。遇到包含业务分支的复杂三元表达式、内联对象、链式调用或条件组合时,优先拆成命名清晰的中间变量、独立分支或小函数,让读代码的人能一眼看出业务意图;不要为了少写几行把逻辑压成难读的一坨。 - 代码可读性优先于短写法。遇到包含业务分支的复杂三元表达式、内联对象、链式调用或条件组合时,优先拆成命名清晰的中间变量、独立分支或小函数,让读代码的人能一眼看出业务意图;不要为了少写几行把逻辑压成难读的一坨。
@@ -232,4 +233,3 @@ Get-ChildItem packages\ui\certd-client\src\views\certd
- 优先对改动包运行聚焦的测试;后端可按包运行单元测试,前端优先使用 Prettier/ESLint 做改动文件验证。只有跨包影响明显时再考虑全 monorepo 构建。 - 优先对改动包运行聚焦的测试;后端可按包运行单元测试,前端优先使用 Prettier/ESLint 做改动文件验证。只有跨包影响明显时再考虑全 monorepo 构建。
- 不要主动运行 `pnpm install` 安装依赖:用户会事先准备好 `node_modules`。如果 `pnpm install``test:unit` 因缺少依赖、TTY 或网络问题失败,立即停止尝试,告知用户解决环境问题。 - 不要主动运行 `pnpm install` 安装依赖:用户会事先准备好 `node_modules`。如果 `pnpm install``test:unit` 因缺少依赖、TTY 或网络问题失败,立即停止尝试,告知用户解决环境问题。
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Bug Fixes
* **pipeline-service:** 修复流水线运行时超过套餐部署次数仍然能够正常运行的bug ([5e59651](https://github.com/certd/certd/commit/5e59651d45bc91919629e35995ff1b3cff6b87ea))
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
* 新增阿里云直播证书部署插件 ([8edb6f8](https://github.com/certd/certd/commit/8edb6f8727bd148f106801bef25567880fd35e9e))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
### Bug Fixes ### Bug Fixes
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Bug Fixes
* **pipeline-service:** 修复流水线运行时超过套餐部署次数仍然能够正常运行的bug ([5e59651](https://github.com/certd/certd/commit/5e59651d45bc91919629e35995ff1b3cff6b87ea))
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
* 新增阿里云直播证书部署插件 ([8edb6f8](https://github.com/certd/certd/commit/8edb6f8727bd148f106801bef25567880fd35e9e))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
### Bug Fixes ### Bug Fixes
+9 -8
View File
@@ -1,5 +1,5 @@
# 任务插件 # 任务插件
`131` 款任务插件 `132` 款任务插件
## 1. 证书申请 ## 1. 证书申请
| 序号 | 名称 | 说明 | | 序号 | 名称 | 说明 |
@@ -93,13 +93,14 @@
| 9.| **阿里云-部署至ESA** | 部署证书到阿里云ESA(边缘安全加速),自动删除过期证书 | | 9.| **阿里云-部署至ESA** | 部署证书到阿里云ESA(边缘安全加速),自动删除过期证书 |
| 10.| **阿里云-部署至阿里云FC(3.0)** | 部署证书到阿里云函数计算(FC3.0) | | 10.| **阿里云-部署至阿里云FC(3.0)** | 部署证书到阿里云函数计算(FC3.0) |
| 11.| **阿里云-部署至GA** | 部署证书到阿里云GA(全球加速),支持更新默认证书和扩展证书 | | 11.| **阿里云-部署至GA** | 部署证书到阿里云GA(全球加速),支持更新默认证书和扩展证书 |
| 12.| **阿里云-部署至NLB(网络负载均衡)** | NLB,网络负载均衡,更新监听器的默认证书 | | 12.| **阿里云-部署至直播(Live** | 部署证书到阿里云视频直播(Live)域名 |
| 13.| **阿里云-部署证书至OSS** | 部署域名证书至阿里云OSS自定义域名,不是上传到阿里云oss | | 13.| **阿里云-部署至NLB(网络负载均衡)** | NLB,网络负载均衡,更新监听器的默认证书 |
| 14.| **阿里云-部署至CLB(传统负载均衡)** | 部署证书到阿里云CLB(传统负载均衡) | | 14.| **阿里云-部署证书至OSS** | 部署域名证书至阿里云OSS自定义域名,不是上传到阿里云oss |
| 15.| **阿里云-部署至VOD** | 部署证书到阿里云视频点播(vod | | 15.| **阿里云-部署至CLB(传统负载均衡)** | 部署证书到阿里云CLB(传统负载均衡) |
| 16.| **阿里云-部署至阿里云WAF(云产品接入)** | 部署证书到阿里云WAF(云产品接入),CNAME方式接入的请选择另外一个waf插件 | | 16.| **阿里云-部署至VOD** | 部署证书到阿里云视频点播(vod |
| 17.| **阿里云-部署至阿里云WAF(cname接入)** | 部署证书到阿里云WAF(cname接入),云资源的请选择另外一个waf插件 | | 17.| **阿里云-部署至阿里云WAF(云产品接入)** | 部署证书到阿里云WAF(云产品接入)CNAME方式接入的请选择另外一个waf插件 |
| 18.| **阿里云-上传证书到CAS** | 上传证书到阿里云证书管理服务(CAS),如果不想在阿里云上同一份证书上传多次,可以把此任务作为前置任务,其他阿里云任务证书那一项选择此任务的输出 | | 18.| **阿里云-部署至阿里云WAF(cname接入)** | 部署证书到阿里云WAF(cname接入),云资源的请选择另外一个waf插件 |
| 19.| **阿里云-上传证书到CAS** | 上传证书到阿里云证书管理服务(CAS),如果不想在阿里云上同一份证书上传多次,可以把此任务作为前置任务,其他阿里云任务证书那一项选择此任务的输出 |
## 6. 华为云 ## 6. 华为云
| 序号 | 名称 | 说明 | | 序号 | 名称 | 说明 |
+1 -1
View File
@@ -9,5 +9,5 @@
} }
}, },
"npmClient": "pnpm", "npmClient": "pnpm",
"version": "1.40.3" "version": "1.40.4"
} }
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/publishlab/node-acme-client/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/acme-client
## [1.40.3](https://github.com/publishlab/node-acme-client/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/publishlab/node-acme-client/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/acme-client **Note:** Version bump only for package @certd/acme-client
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Simple and unopinionated ACME client", "description": "Simple and unopinionated ACME client",
"private": false, "private": false,
"author": "nmorsman", "author": "nmorsman",
"version": "1.40.3", "version": "1.40.4",
"type": "module", "type": "module",
"module": "./dist/index.js", "module": "./dist/index.js",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -18,7 +18,7 @@
"types" "types"
], ],
"dependencies": { "dependencies": {
"@certd/basic": "^1.40.3", "@certd/basic": "^1.40.4",
"@peculiar/x509": "^1.11.0", "@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.5",
"axios": "^1.9.0", "axios": "^1.9.0",
@@ -76,5 +76,5 @@
"bugs": { "bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues" "url": "https://github.com/publishlab/node-acme-client/issues"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/basic
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/basic **Note:** Version bump only for package @certd/basic
+1 -1
View File
@@ -1 +1 @@
22:57 00:18
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@certd/basic", "name": "@certd/basic",
"private": false, "private": false,
"version": "1.40.3", "version": "1.40.4",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
@@ -52,5 +52,5 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/pipeline
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/pipeline **Note:** Version bump only for package @certd/pipeline
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@certd/pipeline", "name": "@certd/pipeline",
"private": false, "private": false,
"version": "1.40.3", "version": "1.40.4",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
@@ -19,8 +19,8 @@
"compile": "tsc --skipLibCheck --watch" "compile": "tsc --skipLibCheck --watch"
}, },
"dependencies": { "dependencies": {
"@certd/basic": "^1.40.3", "@certd/basic": "^1.40.4",
"@certd/plus-core": "^1.40.3", "@certd/plus-core": "^1.40.4",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"reflect-metadata": "^0.1.13" "reflect-metadata": "^0.1.13"
@@ -49,5 +49,5 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/lib-huawei
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-huawei **Note:** Version bump only for package @certd/lib-huawei
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@certd/lib-huawei", "name": "@certd/lib-huawei",
"private": false, "private": false,
"version": "1.40.3", "version": "1.40.4",
"main": "./dist/bundle.js", "main": "./dist/bundle.js",
"module": "./dist/bundle.js", "module": "./dist/bundle.js",
"types": "./dist/d/index.d.ts", "types": "./dist/d/index.d.ts",
@@ -27,5 +27,5 @@
"prettier": "^2.8.8", "prettier": "^2.8.8",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/lib-iframe
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-iframe **Note:** Version bump only for package @certd/lib-iframe
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@certd/lib-iframe", "name": "@certd/lib-iframe",
"private": false, "private": false,
"version": "1.40.3", "version": "1.40.4",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
@@ -34,5 +34,5 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/jdcloud
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/jdcloud **Note:** Version bump only for package @certd/jdcloud
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@certd/jdcloud", "name": "@certd/jdcloud",
"version": "1.40.3", "version": "1.40.4",
"description": "jdcloud openApi sdk", "description": "jdcloud openApi sdk",
"main": "./dist/bundle.js", "main": "./dist/bundle.js",
"module": "./dist/bundle.js", "module": "./dist/bundle.js",
@@ -59,5 +59,5 @@
"fetch" "fetch"
] ]
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/lib-k8s
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-k8s **Note:** Version bump only for package @certd/lib-k8s
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@certd/lib-k8s", "name": "@certd/lib-k8s",
"private": false, "private": false,
"version": "1.40.3", "version": "1.40.4",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
@@ -19,7 +19,7 @@
"compile": "tsc --skipLibCheck --watch" "compile": "tsc --skipLibCheck --watch"
}, },
"dependencies": { "dependencies": {
"@certd/basic": "^1.40.3", "@certd/basic": "^1.40.4",
"@kubernetes/client-node": "0.21.0" "@kubernetes/client-node": "0.21.0"
}, },
"devDependencies": { "devDependencies": {
@@ -36,5 +36,5 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-server **Note:** Version bump only for package @certd/lib-server
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "@certd/lib-server", "name": "@certd/lib-server",
"version": "1.40.3", "version": "1.40.4",
"description": "midway with flyway, sql upgrade way ", "description": "midway with flyway, sql upgrade way ",
"private": false, "private": false,
"type": "module", "type": "module",
@@ -29,11 +29,11 @@
], ],
"license": "AGPL", "license": "AGPL",
"dependencies": { "dependencies": {
"@certd/acme-client": "^1.40.3", "@certd/acme-client": "^1.40.4",
"@certd/basic": "^1.40.3", "@certd/basic": "^1.40.4",
"@certd/pipeline": "^1.40.3", "@certd/pipeline": "^1.40.4",
"@certd/plugin-lib": "^1.40.3", "@certd/plugin-lib": "^1.40.4",
"@certd/plus-core": "^1.40.3", "@certd/plus-core": "^1.40.4",
"@midwayjs/cache": "3.14.0", "@midwayjs/cache": "3.14.0",
"@midwayjs/core": "3.20.11", "@midwayjs/core": "3.20.11",
"@midwayjs/i18n": "3.20.13", "@midwayjs/i18n": "3.20.13",
@@ -69,5 +69,5 @@
"typeorm": "^0.3.11", "typeorm": "^0.3.11",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
@@ -8,6 +8,8 @@ export const Constants = {
guest: '_guest_', guest: '_guest_',
//无需登录 //无需登录
anonymous: '_guest_', anonymous: '_guest_',
//无需登录,有 token 时解析当前用户
guestOptionalAuth: '_guestOptionalAuth_',
//仅需要登录 //仅需要登录
authOnly: '_authOnly_', authOnly: '_authOnly_',
//仅需要登录 //仅需要登录
@@ -0,0 +1,43 @@
/// <reference types="mocha" />
/// <reference types="node" />
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { FileService } from "./file-service.js";
function createUploadFile(key: string) {
const uploadRootDir = "./data/upload";
const filePath = path.join(uploadRootDir, key);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "test");
return filePath;
}
describe("FileService.getFile", () => {
let cwd: string;
let oldCwd: string;
beforeEach(() => {
oldCwd = process.cwd();
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "certd-file-service-"));
process.chdir(cwd);
});
afterEach(() => {
process.chdir(oldCwd);
fs.rmSync(cwd, { recursive: true, force: true });
});
it("allows admin to read another user's private file", () => {
const service = new FileService();
const userIdMd5 = Buffer.from(Buffer.from("2").toString("base64")).toString("hex");
const key = `/private/${userIdMd5}/2026_05_25/qr.png`;
const expectedPath = createUploadFile(key);
const filePath = service.getFile(key, 1, true);
assert.equal(filePath, expectedPath);
});
});
@@ -56,7 +56,7 @@ export class FileService {
return key; return key;
} }
getFile(key: string, userId?: number) { getFile(key: string, userId?: number, allowAnyPrivateUser = false) {
if (!key) { if (!key) {
throw new ParamException('参数错误'); throw new ParamException('参数错误');
} }
@@ -70,7 +70,7 @@ export class FileService {
const keyArr = key.split('/'); const keyArr = key.split('/');
const permission = keyArr[1]; const permission = keyArr[1];
const userIdMd5 = keyArr[2]; const userIdMd5 = keyArr[2];
if (permission !== 'public') { if (permission !== 'public' && !allowAnyPrivateUser) {
//非公开文件需要验证用户 //非公开文件需要验证用户
const userIdStr = Buffer.from(Buffer.from(userIdMd5, 'hex').toString('base64')).toString(); const userIdStr = Buffer.from(Buffer.from(userIdMd5, 'hex').toString('base64')).toString();
const userIdInt: number = parseInt(userIdStr, 10); const userIdInt: number = parseInt(userIdStr, 10);
@@ -245,6 +245,8 @@ export class SysSuiteSetting extends BaseSettings {
enabled: boolean = false; enabled: boolean = false;
allowSuiteStack: boolean = false;
registerGift?: { registerGift?: {
productId: number; productId: number;
duration: number; duration: number;
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/midway-flyway-js
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/midway-flyway-js **Note:** Version bump only for package @certd/midway-flyway-js
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@certd/midway-flyway-js", "name": "@certd/midway-flyway-js",
"version": "1.40.3", "version": "1.40.4",
"description": "midway with flyway, sql upgrade way ", "description": "midway with flyway, sql upgrade way ",
"private": false, "private": false,
"type": "module", "type": "module",
@@ -49,5 +49,5 @@
"typeorm": "^0.3.11", "typeorm": "^0.3.11",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/plugin-cert
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/plugin-cert **Note:** Version bump only for package @certd/plugin-cert
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "@certd/plugin-cert", "name": "@certd/plugin-cert",
"private": false, "private": false,
"version": "1.40.3", "version": "1.40.4",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,10 +18,10 @@
"compile": "tsc --skipLibCheck --watch" "compile": "tsc --skipLibCheck --watch"
}, },
"dependencies": { "dependencies": {
"@certd/acme-client": "^1.40.3", "@certd/acme-client": "^1.40.4",
"@certd/basic": "^1.40.3", "@certd/basic": "^1.40.4",
"@certd/pipeline": "^1.40.3", "@certd/pipeline": "^1.40.4",
"@certd/plugin-lib": "^1.40.3", "@certd/plugin-lib": "^1.40.4",
"psl": "^1.9.0", "psl": "^1.9.0",
"punycode.js": "^2.3.1" "punycode.js": "^2.3.1"
}, },
@@ -41,5 +41,5 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Performance Improvements
* 新增阿里云直播证书部署插件 ([8edb6f8](https://github.com/certd/certd/commit/8edb6f8727bd148f106801bef25567880fd35e9e))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/plugin-lib **Note:** Version bump only for package @certd/plugin-lib
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "@certd/plugin-lib", "name": "@certd/plugin-lib",
"private": false, "private": false,
"version": "1.40.3", "version": "1.40.4",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -23,10 +23,10 @@
"@alicloud/pop-core": "^1.7.10", "@alicloud/pop-core": "^1.7.10",
"@alicloud/tea-util": "^1.4.11", "@alicloud/tea-util": "^1.4.11",
"@aws-sdk/client-s3": "^3.964.0", "@aws-sdk/client-s3": "^3.964.0",
"@certd/acme-client": "^1.40.3", "@certd/acme-client": "^1.40.4",
"@certd/basic": "^1.40.3", "@certd/basic": "^1.40.4",
"@certd/pipeline": "^1.40.3", "@certd/pipeline": "^1.40.4",
"@certd/plus-core": "^1.40.3", "@certd/plus-core": "^1.40.4",
"@kubernetes/client-node": "0.21.0", "@kubernetes/client-node": "0.21.0",
"ali-oss": "^6.22.0", "ali-oss": "^6.22.0",
"basic-ftp": "^5.0.5", "basic-ftp": "^5.0.5",
@@ -61,5 +61,5 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"gitHead": "01c91ba294f88bd07fddf9358c4301bbb4027916" "gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
} }
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
### Bug Fixes ### Bug Fixes
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@certd/ui-client", "name": "@certd/ui-client",
"version": "1.40.3", "version": "1.40.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite --open", "dev": "vite --open",
@@ -106,8 +106,8 @@
"zod-defaults": "^0.1.3" "zod-defaults": "^0.1.3"
}, },
"devDependencies": { "devDependencies": {
"@certd/lib-iframe": "^1.40.3", "@certd/lib-iframe": "^1.40.4",
"@certd/pipeline": "^1.40.3", "@certd/pipeline": "^1.40.4",
"@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^4.3.12", "@types/chai": "^4.3.12",
@@ -1,9 +1,9 @@
<template> <template>
<fs-page class="page-cert-dns-persist"> <fs-page class="page-cert-dns-persist">
<template #header> <template #header>
<div> <div class="title">
<div class="title">DNS持久验证记录</div> DNS持久验证记录
<div class="text-orange-500 mt-5">当前仅 Let's Encrypt 测试环境可以申请 DNS 持久验证证书</div> <span class="red sub" style="color: red">当前仅 Let's Encrypt 测试环境可以申请 DNS 持久验证证书</span>
</div> </div>
</template> </template>
<fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud> <fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud>
@@ -32,7 +32,9 @@
<div class="invite-info-row"> <div class="invite-info-row">
<span class="info-label">我的等级</span> <span class="info-label">我的等级</span>
<a-button type="link" class="level-button" @click="levelDialogOpen = true"> <a-button type="link" class="level-button" @click="levelDialogOpen = true">
<span v-if="inviteInfo.currentLevel" class="level-medal" :class="levelMedalClass(inviteInfo.currentLevel)">{{ levelMedal(inviteInfo.currentLevel) }}</span> <span v-if="inviteInfo.currentLevel" class="level-medal">
<fs-icon :icon="levelIcon(inviteInfo.currentLevel)" />
</span>
<span>{{ inviteInfo.currentLevel?.name || "未设置" }}</span> <span>{{ inviteInfo.currentLevel?.name || "未设置" }}</span>
<span v-if="inviteInfo.currentLevel" class="current-level-rate">{{ inviteInfo.currentLevel.commissionRate }}%</span> <span v-if="inviteInfo.currentLevel" class="current-level-rate">{{ inviteInfo.currentLevel.commissionRate }}%</span>
</a-button> </a-button>
@@ -61,9 +63,11 @@
<div class="level-card-grid modal-level-grid"> <div class="level-card-grid modal-level-grid">
<div v-for="level in visibleLevels" :key="level.id" class="level-card" :class="{ active: level.id === inviteInfo.currentLevel?.id }"> <div v-for="level in visibleLevels" :key="level.id" class="level-card" :class="{ active: level.id === inviteInfo.currentLevel?.id }">
<div class="level-name"> <div class="level-name">
<span class="level-medal" :class="levelMedalClass(level)">{{ levelMedal(level) }}</span> <span class="level-medal">
<fs-icon :icon="levelIcon(level)" />
</span>
{{ level.name }} {{ level.name }}
<a-tag v-if="level.isHidden" color="orange">专属</a-tag> <a-tag v-if="level.levelType === 'exclusive'" color="orange">专属</a-tag>
</div> </div>
<div class="level-rate-label">佣金比例</div> <div class="level-rate-label">佣金比例</div>
<div class="level-rate">{{ level.commissionRate }}%</div> <div class="level-rate">{{ level.commissionRate }}%</div>
@@ -86,7 +90,8 @@
@ok="handleAgreementOk" @ok="handleAgreementOk"
@cancel="closeAgreementDialog" @cancel="closeAgreementDialog"
> >
<div class="invite-agreement-content">{{ agreementText }}</div> <!-- eslint-disable-next-line vue/no-v-html -->
<div class="invite-agreement-content editor-content-view" v-html="agreementText"></div>
<div v-if="agreementDialogNeedOpen" class="invite-agreement-confirm"> <div v-if="agreementDialogNeedOpen" class="invite-agreement-confirm">
<a-checkbox v-model:checked="agreementAgree">我已阅读并同意推广协议</a-checkbox> <a-checkbox v-model:checked="agreementAgree">我已阅读并同意推广协议</a-checkbox>
</div> </div>
@@ -119,7 +124,7 @@ const agreementDialogOpen = ref(false);
const agreementDialogNeedOpen = ref(false); const agreementDialogNeedOpen = ref(false);
const agreementAgree = ref(false); const agreementAgree = ref(false);
const agreementSubmitting = ref(false); const agreementSubmitting = ref(false);
const defaultAgreementContent = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。"; const defaultAgreementContent = "<p>请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。</p>";
const inviteInfo = reactive<any>({ const inviteInfo = reactive<any>({
enabled: false, enabled: false,
@@ -170,38 +175,8 @@ const visibleLevels = computed(() => {
const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent); const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent);
function levelMedal(level: any) { function levelIcon(level: any) {
const name = `${level?.name || ""}`; return level?.icon || "ion:ribbon-outline";
if (name.includes("青铜")) {
return "铜";
}
if (name.includes("白银")) {
return "银";
}
if (name.includes("黄金")) {
return "金";
}
if (name.includes("钻石")) {
return "钻";
}
return name.slice(0, 1) || "L";
}
function levelMedalClass(level: any) {
const name = `${level?.name || ""}`;
if (name.includes("青铜")) {
return "bronze";
}
if (name.includes("白银")) {
return "silver";
}
if (name.includes("黄金")) {
return "gold";
}
if (name.includes("钻石")) {
return "diamond";
}
return "default";
} }
function openAgreementDialog(needOpenPlan: boolean) { function openAgreementDialog(needOpenPlan: boolean) {
@@ -419,33 +394,10 @@ onActivated(async () => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16; color: #8a5a16;
font-size: 12px; font-size: 20px;
font-weight: 700;
}
.level-medal.bronze {
background: #f5d6b7;
color: #9a5b22;
}
.level-medal.silver {
background: #e5e7eb;
color: #4b5563;
}
.level-medal.gold {
background: #f8df9b;
color: #926c15;
}
.level-medal.diamond {
background: #dbeafe;
color: #2563eb;
} }
.invite-tabs { .invite-tabs {
@@ -520,33 +472,10 @@ onActivated(async () => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16; color: #8a5a16;
font-size: 12px; font-size: 20px;
font-weight: 700;
}
.level-medal.bronze {
background: #f5d6b7;
color: #9a5b22;
}
.level-medal.silver {
background: #e5e7eb;
color: #4b5563;
}
.level-medal.gold {
background: #f8df9b;
color: #926c15;
}
.level-medal.diamond {
background: #dbeafe;
color: #2563eb;
} }
.level-rate-label { .level-rate-label {
@@ -591,11 +520,19 @@ onActivated(async () => {
max-height: 360px; max-height: 360px;
padding: 12px; padding: 12px;
overflow: auto; overflow: auto;
white-space: pre-wrap;
border: 1px solid #eee; border: 1px solid #eee;
border-radius: 6px; border-radius: 6px;
background: hsl(var(--card)); background: hsl(var(--card));
line-height: 1.7; line-height: 1.7;
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(p) {
margin-bottom: 10px;
}
} }
.invite-agreement-confirm { .invite-agreement-confirm {
@@ -8,7 +8,7 @@
<a-col :span="24"> <a-col :span="24">
<a-card> <a-card>
<div class="suite-intro-box"> <div class="suite-intro-box">
<div>说明 同一时间只有最新购买的一个套餐生效 可以购买多个加量包加量包立即生效 套餐和加量包内的数量可以叠加</div> <div>{{ buyHelperText }}</div>
<div v-if="suiteIntro" v-html="suiteIntro"></div> <div v-if="suiteIntro" v-html="suiteIntro"></div>
</div> </div>
</a-card> </a-card>
@@ -31,7 +31,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from "vue"; import { computed, ref } from "vue";
import * as api from "./api"; import * as api from "./api";
import ProductInfo from "/@/views/certd/suite/product-info.vue"; import ProductInfo from "/@/views/certd/suite/product-info.vue";
import OrderModal from "/@/views/certd/suite/order-modal.vue"; import OrderModal from "/@/views/certd/suite/order-modal.vue";
@@ -55,9 +55,17 @@ async function doOrder(req: any) {
} }
const suiteIntro = ref(""); const suiteIntro = ref("");
const allowSuiteStack = ref(false);
const buyHelperText = computed(() => {
if (allowSuiteStack.value) {
return "说明:可以购买多个套餐和加量包,套餐和加量包内的数量可以叠加";
}
return "说明:① 同一时间只有最新购买的一个套餐生效;② 可以购买多个加量包,加量包立即生效;③ 套餐和加量包内的数量可以叠加";
});
async function loadSuiteIntro() { async function loadSuiteIntro() {
const res = await api.GetSuiteSetting(); const res = await api.GetSuiteSetting();
suiteIntro.value = res.intro; suiteIntro.value = res.intro;
allowSuiteStack.value = !!res.allowSuiteStack;
} }
loadSuiteIntro(); loadSuiteIntro();
</script> </script>
@@ -8,6 +8,10 @@ export async function GetWithdrawSetting() {
return await request({ url: "/wallet/withdraw/setting/get", method: "post" }); return await request({ url: "/wallet/withdraw/setting/get", method: "post" });
} }
export async function GetWalletSetting() {
return await request({ url: "/wallet/settings/get", method: "post" });
}
export async function SaveWithdrawSetting(data: any) { export async function SaveWithdrawSetting(data: any) {
return await request({ url: "/wallet/withdraw/setting/save", method: "post", data }); return await request({ url: "/wallet/withdraw/setting/save", method: "post", data });
} }
@@ -47,22 +47,6 @@ export default function (): CreateCrudOptionsRet {
}), }),
column: { width: 110 }, column: { width: 110 },
}, },
realName: { title: "真实姓名", type: "text", column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } },
qrCode: {
title: "收款二维码",
type: "text",
column: {
width: 120,
cellRender({ value }) {
if (!value) {
return "-";
}
return <a-image src={`/api/basic/file/download?key=${value}`} width={48} />;
},
},
},
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } }, auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
}, },
}, },
@@ -6,17 +6,11 @@
<div class="wallet-body"> <div class="wallet-body">
<div class="wallet-summary-grid"> <div class="wallet-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card"> <div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-title">{{ item.title }}</div> <div class="summary-card-main">
<div class="summary-value" :class="item.className">{{ item.value }}</div> <div class="summary-title">{{ item.title }}</div>
</div> <div class="summary-value" :class="item.className">{{ item.value }}</div>
</div> </div>
<a-button v-if="item.key === 'availableAmount'" class="summary-action-button" type="primary" @click="openWithdrawDialog">申请提现</a-button>
<div class="wallet-action-panel">
<div class="wallet-action-title">提现操作</div>
<div class="wallet-action-content">
<a-button type="primary" @click="openWithdrawSetting">提现设置</a-button>
<a-input-number v-model:value="withdrawAmountYuan" class="withdraw-amount-input" :min="0" addon-before="提现金额" addon-after="" />
<a-button @click="applyWithdraw">申请提现</a-button>
</div> </div>
</div> </div>
@@ -33,9 +27,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onActivated, onMounted, reactive, ref } from "vue"; import { computed, h, onActivated, onMounted, reactive, ref } from "vue";
import { compute, dict, useFs } from "@fast-crud/fast-crud"; import { compute, dict, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue"; import { Button, notification } from "ant-design-vue";
import * as api from "./api"; import * as api from "./api";
import createLogsCrudOptions from "./crud-logs"; import createLogsCrudOptions from "./crud-logs";
import createWithdrawCrudOptions from "./crud-withdraw"; import createWithdrawCrudOptions from "./crud-withdraw";
@@ -46,7 +40,6 @@ import { useUserStore } from "/@/store/user";
defineOptions({ name: "MyWallet" }); defineOptions({ name: "MyWallet" });
const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 }); const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 });
const withdrawAmountYuan = ref(0);
const loaded = ref(false); const loaded = ref(false);
const activeTab = ref("withdraw"); const activeTab = ref("withdraw");
const { openFormDialog } = useFormDialog(); const { openFormDialog } = useFormDialog();
@@ -62,6 +55,10 @@ function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`; return `¥ ${amountToYuan(amount)}`;
} }
function buildPrivateFileUrl(key: string) {
return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`;
}
const summaryCards = computed(() => [ const summaryCards = computed(() => [
{ {
key: "availableAmount", key: "availableAmount",
@@ -95,8 +92,18 @@ async function loadWalletSummary() {
} }
async function openWithdrawSetting() { async function openWithdrawSetting() {
const setting: any = await api.GetWithdrawSetting(); const [setting, walletSetting]: any[] = await Promise.all([api.GetWithdrawSetting(), api.GetWalletSetting()]);
const enabledChannels = walletSetting?.withdrawChannels?.length ? walletSetting.withdrawChannels : ["alipay", "bank"];
const enabledBanks = walletSetting?.withdrawBanks?.length ? walletSetting.withdrawBanks : [];
const channelOptions = [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
].filter(item => enabledChannels.includes(item.value));
const bankOptions = enabledBanks.map((item: string) => ({ label: item, value: item }));
const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {}); const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {});
if (!enabledChannels.includes(initialForm.channel)) {
initialForm.channel = enabledChannels[0] || "alipay";
}
await openFormDialog({ await openFormDialog({
title: "提现设置", title: "提现设置",
wrapper: { wrapper: {
@@ -108,10 +115,7 @@ async function openWithdrawSetting() {
title: "提现渠道", title: "提现渠道",
type: "dict-radio", type: "dict-radio",
dict: dict({ dict: dict({
data: [ data: channelOptions,
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}), }),
form: { form: {
col: { span: 24 }, col: { span: 24 },
@@ -136,18 +140,13 @@ async function openWithdrawSetting() {
}, },
qrCode: { qrCode: {
title: "收款二维码", title: "收款二维码",
type: "cropper-uploader", type: "avatar-uploader",
form: { form: {
col: { span: 24 }, col: { span: 24 },
helper: "上传支付宝收款二维码图片",
show: compute(({ form }) => form.channel !== "bank"),
component: { component: {
vModel: "modelValue",
valueType: "key", valueType: "key",
cropper: {
aspectRatio: 1,
autoCropArea: 1,
viewMode: 0,
},
onReady: null,
uploader: { uploader: {
type: "form", type: "form",
action: "/basic/file/upload?token=" + userStore.getToken, action: "/basic/file/upload?token=" + userStore.getToken,
@@ -160,31 +159,85 @@ async function openWithdrawSetting() {
}, },
}, },
buildUrl(key: string) { buildUrl(key: string) {
return `/api/basic/file/download?key=` + key; return buildPrivateFileUrl(key);
}, },
}, },
}, },
}, },
bankName: { bankName: {
title: "开户银行", title: "开户银行",
type: "text",
form: { form: {
col: { span: 24 }, col: { span: 24 },
show: compute(({ form }) => form.channel === "bank"), show: compute(({ form }) => form.channel === "bank"),
component: {
name: "a-select",
vModel: "value",
options: bankOptions,
showSearch: true,
placeholder: "请选择开户银行",
},
rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }], rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }],
}, },
}, },
}, },
async onSubmit(form: any) { async onSubmit(form: any) {
if (form.channel === "bank") {
form.qrCode = "";
}
await api.SaveWithdrawSetting(form); await api.SaveWithdrawSetting(form);
notification.success({ message: "保存成功" }); notification.success({ message: "保存成功" });
}, },
}); });
} }
async function applyWithdraw() { async function openWithdrawDialog() {
await api.ApplyWithdraw(util.amount.toCent(withdrawAmountYuan.value || 0)); await openFormDialog({
withdrawAmountYuan.value = 0; title: "申请提现",
wrapper: {
width: 520,
},
initialForm: {
amountYuan: null,
},
body: () =>
h("div", { class: "withdraw-dialog-tip" }, [
h("span", "提现前需要先设置提现账号。"),
h(
Button,
{
size: "small",
type: "link",
onClick: openWithdrawSetting,
},
() => "提现设置"
),
]),
columns: {
amountYuan: {
title: "提现金额",
form: {
col: { span: 24 },
component: {
name: "a-input-number",
vModel: "value",
min: 0,
precision: 2,
addonAfter: "元",
style: { width: "100%" },
},
rules: [{ required: true, message: "请输入提现金额" }],
},
},
},
async onSubmit(form: any) {
await applyWithdraw(form.amountYuan);
},
});
}
async function applyWithdraw(amountYuan: number) {
await api.ApplyWithdraw(util.amount.toCent(amountYuan || 0));
activeTab.value = "withdraw";
await loadWalletSummary(); await loadWalletSummary();
await Promise.all([withdrawCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]); await Promise.all([withdrawCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
notification.success({ message: "提现申请已提交" }); notification.success({ message: "提现申请已提交" });
@@ -233,8 +286,7 @@ onActivated(async () => {
background: hsl(var(--background-deep)); background: hsl(var(--background-deep));
} }
.wallet-summary-grid, .wallet-summary-grid {
.wallet-action-panel {
flex: none; flex: none;
} }
@@ -246,7 +298,6 @@ onActivated(async () => {
} }
.summary-card, .summary-card,
.wallet-action-panel,
.wallet-tabs { .wallet-tabs {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 8px; border-radius: 8px;
@@ -255,10 +306,18 @@ onActivated(async () => {
} }
.summary-card { .summary-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 112px; min-height: 112px;
padding: 22px; padding: 22px;
} }
.summary-card-main {
min-width: 0;
}
.summary-title { .summary-title {
margin-bottom: 10px; margin-bottom: 10px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
@@ -284,32 +343,8 @@ onActivated(async () => {
color: #3478f6; color: #3478f6;
} }
.wallet-action-panel { .summary-action-button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding: 14px 18px;
margin-bottom: 18px;
}
.wallet-action-title {
flex: none; flex: none;
color: hsl(var(--foreground));
font-size: 15px;
font-weight: 600;
}
.wallet-action-content {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
}
.withdraw-amount-input {
width: 240px;
} }
.wallet-tabs { .wallet-tabs {
@@ -350,18 +385,23 @@ onActivated(async () => {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.wallet-action-panel { .summary-card {
align-items: stretch; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.wallet-action-content {
justify-content: flex-start;
}
.withdraw-amount-input {
width: 100%;
}
} }
} }
.withdraw-dialog-tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #d9e8ff;
border-radius: 6px;
background: #f5f9ff;
color: #315174;
}
</style> </style>
@@ -4,8 +4,14 @@
<a-popover> <a-popover>
<template #content> <template #content>
<div style="width: 300px"> <div style="width: 300px">
<div v-if="detail.addonList.length > 0" class="flex flex-wrap"> <div v-if="hiddenSuiteList.length > 0 || detail.addonList.length > 0" class="flex flex-wrap">
<a-tag v-for="(item, index) of detail.addonList" :key="index" color="green" class="pointer flex-o m-1"> <a-tag v-for="(item, index) of hiddenSuiteList" :key="`suite-${index}`" color="green" class="pointer flex-o m-1">
<span class="mr-5">
{{ item.title }}
</span>
<span>(<expires-time-text :value="item.expiresTime" />)</span>
</a-tag>
<a-tag v-for="(item, index) of detail.addonList" :key="`addon-${index}`" color="green" class="pointer flex-o m-1">
<span class="mr-5"> <span class="mr-5">
{{ item.title }} {{ item.title }}
</span> </span>
@@ -43,13 +49,13 @@
</template> </template>
<div class="flex-o"> <div class="flex-o">
<fs-icon icon="ant-design:gift-outlined" class="color-green mr-5" /> <fs-icon icon="ant-design:gift-outlined" class="color-green mr-5" />
<a-tag v-for="(item, index) of detail.suiteList" :key="index" color="green" class="pointer flex-o"> <a-tag v-if="currentSuite" color="green" class="pointer flex-o">
<span class="mr-5"> <span class="mr-5">
{{ item.title }} {{ currentSuite.title }}
</span> </span>
<span>(<expires-time-text :value="item.expiresTime" />)</span> <span>(<expires-time-text :value="currentSuite.expiresTime" />)</span>
<span v-if="hiddenCount > 0" class="ml-5">+{{ hiddenCount }}</span>
</a-tag> </a-tag>
<a-tag v-if="detail.addonList.length > 0" color="green" class="pointer flex-o">加量包+{{ detail.addonList.length }}</a-tag>
<div v-if="detail.suites?.length === 0" class="flex-o ml-5">暂无套餐 <a-button class="ml-5" type="primary" size="small" @click="goBuy">去购买</a-button></div> <div v-if="detail.suites?.length === 0" class="flex-o ml-5">暂无套餐 <a-button class="ml-5" type="primary" size="small" @click="goBuy">去购买</a-button></div>
</div> </div>
</a-popover> </a-popover>
@@ -59,7 +65,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue"; import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import { ref } from "vue"; import { computed, ref } from "vue";
import ExpiresTimeText from "/@/components/expires-time-text.vue"; import ExpiresTimeText from "/@/components/expires-time-text.vue";
import { mySuiteApi, SuiteDetail } from "/@/views/certd/suite/mine/api"; import { mySuiteApi, SuiteDetail } from "/@/views/certd/suite/mine/api";
import { FsIcon } from "@fast-crud/fast-crud"; import { FsIcon } from "@fast-crud/fast-crud";
@@ -71,6 +77,10 @@ defineOptions({
const detail = ref<SuiteDetail>({}); const detail = ref<SuiteDetail>({});
const currentSuite = computed(() => detail.value.suiteList?.[0]);
const hiddenSuiteList = computed(() => detail.value.suiteList?.slice(1) || []);
const hiddenCount = computed(() => hiddenSuiteList.value.length + (detail.value.addonList?.length || 0));
async function loadSuiteDetail() { async function loadSuiteDetail() {
detail.value = await mySuiteApi.SuiteDetailGet(); detail.value = await mySuiteApi.SuiteDetailGet();
const suites = detail.value.suites.filter(item => item.productType === "suite"); const suites = detail.value.suites.filter(item => item.productType === "suite");
@@ -24,6 +24,10 @@ export async function UpdateLevel(data: any) {
return await request({ url: "/sys/invite/level/update", method: "post", data }); return await request({ url: "/sys/invite/level/update", method: "post", data });
} }
export async function DeleteLevel(id: number) {
return await request({ url: "/sys/invite/level/delete", method: "post", params: { id } });
}
export async function GetUserLevels(query: any) { export async function GetUserLevels(query: any) {
return await request({ url: "/sys/invite/user/page", method: "post", data: query }); return await request({ url: "/sys/invite/user/page", method: "post", data: query });
} }
@@ -4,6 +4,7 @@ import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet { export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => { const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
query.sort = { prop: "sort", asc: true };
return await api.GetLevels(query); return await api.GetLevels(query);
}; };
const addRequest = async ({ form }: AddReq) => { const addRequest = async ({ form }: AddReq) => {
@@ -14,8 +15,7 @@ export default function (): CreateCrudOptionsRet {
return await api.UpdateLevel(form); return await api.UpdateLevel(form);
}; };
const delRequest = async ({ row }: DelReq) => { const delRequest = async ({ row }: DelReq) => {
row.disabled = true; return await api.DeleteLevel(row.id);
return await api.UpdateLevel(row);
}; };
return { return {
@@ -49,6 +49,25 @@ export default function (): CreateCrudOptionsRet {
}, },
column: { width: 140 }, column: { width: 140 },
}, },
icon: {
title: "等级图标",
type: "icon",
form: {
value: "ion:ribbon-outline",
rules: [{ required: true, message: "请选择等级图标" }],
},
column: {
width: 90,
align: "center",
component: {
name: "fs-icon",
vModel: "icon",
style: {
fontSize: "22px",
},
},
},
},
minAmount: { minAmount: {
title: "升级金额", title: "升级金额",
type: "number", type: "number",
@@ -70,23 +89,29 @@ export default function (): CreateCrudOptionsRet {
}, },
column: { width: 110, align: "center", cellRender: ({ value }) => `${value || 0}%` }, column: { width: 110, align: "center", cellRender: ({ value }) => `${value || 0}%` },
}, },
isHidden: { levelType: {
title: "隐藏等级", title: "等级类型",
type: "dict-switch", type: "dict-radio",
dict: dict({ dict: dict({
data: [ data: [
{ label: "普通等级", value: false, color: "success" }, { label: "普通等级", value: "normal", color: "success" },
{ label: "隐藏等级", value: true, color: "warning" }, { label: "专属等级", value: "exclusive", color: "warning" },
], ],
}), }),
form: { value: false }, form: {
value: "normal",
helper: "专属等级可由管理员手动指定,不参与普通用户自动升级。",
},
column: { width: 120, align: "center" }, column: { width: 120, align: "center" },
}, },
sort: { sort: {
title: "排序", title: "排序",
type: "number", type: "number",
form: { value: 10 }, form: {
column: { width: 90, align: "center" }, value: 10,
helper: "排序号越小越靠前。",
},
column: { width: 90, align: "center", sorter: true },
}, },
disabled: { disabled: {
title: "状态", title: "状态",
@@ -46,7 +46,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}), }),
form: { form: {
col: { span: 24 }, col: { span: 24 },
helper: "隐藏等级会自动锁定,不参与自动升级。", helper: "专属等级会自动锁定,不参与自动升级。",
}, },
}, },
}, },
@@ -111,13 +111,13 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}), }),
column: { width: 110, align: "center" }, column: { width: 110, align: "center" },
}, },
isHidden: { levelType: {
title: "隐藏等级", title: "等级类型",
type: "dict-switch", type: "dict-select",
dict: dict({ dict: dict({
data: [ data: [
{ label: "", value: false, color: "default" }, { label: "普通等级", value: "normal", color: "success" },
{ label: "", value: true, color: "warning" }, { label: "专属等级", value: "exclusive", color: "warning" },
], ],
}), }),
column: { width: 100, align: "center", show: compute(({ row }) => row.levelId) }, column: { width: 100, align: "center", show: compute(({ row }) => row.levelId) },
@@ -3,6 +3,12 @@ import { notification } from "ant-design-vue";
import * as api from "./api"; import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue"; import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { useFormDialog } from "/@/use/use-dialog"; import { useFormDialog } from "/@/use/use-dialog";
import { useUserStore } from "/@/store/user";
function buildPrivateFileUrl(key: string) {
const userStore = useUserStore();
return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`;
}
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog(); const { openFormDialog } = useFormDialog();
@@ -12,16 +18,17 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}; };
function renderWithdrawDetail(row: any) { function renderWithdrawDetail(row: any) {
const isBank = row.channel === "bank";
return ( return (
<a-descriptions class={"w-full"} bordered column={2} size={"small"}> <a-descriptions class={"w-full"} bordered column={1} size={"small"}>
<a-descriptions-item label="提现金额">
<span class={"text-red-500"}>{row.amount / 100} </span>
</a-descriptions-item>
<a-descriptions-item label="渠道类型">{row.channel === "bank" ? "银行卡" : "支付宝"}</a-descriptions-item> <a-descriptions-item label="渠道类型">{row.channel === "bank" ? "银行卡" : "支付宝"}</a-descriptions-item>
<a-descriptions-item label="用户名">{row.userDisplay || row.userId}</a-descriptions-item> <a-descriptions-item label="用户名">{row.userDisplay || row.userId}</a-descriptions-item>
<a-descriptions-item label="账号">{row.account || "-"}</a-descriptions-item> <a-descriptions-item label="账号">{row.account || "-"}</a-descriptions-item>
<a-descriptions-item label="开户行名称">{row.bankName || "-"}</a-descriptions-item> {isBank ? <a-descriptions-item label="开户行名称">{row.bankName || "-"}</a-descriptions-item> : null}
<a-descriptions-item label="收款二维码" span={2}> {!isBank ? <a-descriptions-item label="收款二维码">{row.qrCode ? <a-image src={buildPrivateFileUrl(row.qrCode)} width={160} /> : <span>-</span>}</a-descriptions-item> : null}
{row.qrCode ? <a-image src={`/api/basic/file/download?key=${row.qrCode}`} width={160} /> : <span>-</span>}
</a-descriptions-item>
<a-descriptions-item label="提现金额">{row.amount / 100} </a-descriptions-item>
</a-descriptions> </a-descriptions>
); );
} }
@@ -31,6 +38,11 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
title: "提现审核", title: "提现审核",
wrapper: { wrapper: {
width: 760, width: 760,
buttons: {
ok: {
text: "确认已转账完成",
},
},
}, },
body: () => renderWithdrawDetail(row), body: () => renderWithdrawDetail(row),
onSubmit: async () => { onSubmit: async () => {
@@ -147,17 +159,16 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}, },
realName: { title: "真实姓名", type: "text", search: { show: true }, column: { width: 120 } }, realName: { title: "真实姓名", type: "text", search: { show: true }, column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } }, account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } }, bankName: {
qrCode: { title: "开户银行",
title: "收款二维码",
type: "text", type: "text",
column: { column: {
width: 120, width: 160,
cellRender({ value }) { cellRender({ row, value }) {
if (!value) { if (row.channel !== "bank") {
return "-"; return "-";
} }
return <a-image src={`/api/basic/file/download?key=${value}`} width={48} />; return value || "-";
}, },
}, },
}, },
@@ -3,18 +3,94 @@
<template #header> <template #header>
<div class="title">推广等级</div> <div class="title">推广等级</div>
</template> </template>
<fs-crud ref="crudRef" v-bind="crudBinding" /> <fs-crud ref="crudRef" v-bind="crudBinding">
<a-empty v-if="levelList.length === 0" class="level-empty" />
<div v-else class="level-card-grid">
<div v-for="(item, index) of levelList" :key="item.id" class="level-card" :class="{ disabled: item.disabled }">
<div class="level-card-actions">
<a-tooltip title="编辑">
<a-button type="text" size="small" @click="openEdit({ index, row: item })">
<template #icon><fs-icon icon="ion:create-outline" /></template>
</a-button>
</a-tooltip>
<a-tooltip :title="item.disabled ? '启用' : '禁用'">
<a-button type="text" size="small" @click="toggleDisabled(item)">
<template #icon><fs-icon :icon="item.disabled ? 'ion:play-outline' : 'ion:pause-outline'" /></template>
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-button type="text" danger size="small" @click="confirmRemove({ index, row: item })">
<template #icon><fs-icon icon="ion:trash-outline" /></template>
</a-button>
</a-tooltip>
</div>
<div class="level-name">
<span class="level-medal">
<fs-icon :icon="levelIcon(item)" />
</span>
{{ item.name }}
<a-tag v-if="item.levelType === 'exclusive'" color="orange">专属</a-tag>
</div>
<div class="level-rate-label">佣金比例</div>
<div class="level-rate">{{ item.commissionRate || 0 }}%</div>
<div class="level-threshold">累计推广 {{ amountToYuan(item.minAmount) }} </div>
<div class="level-meta">
<a-tag :color="item.disabled ? 'default' : 'success'">{{ item.disabled ? "已禁用" : "已启用" }}</a-tag>
<span>排序 {{ item.sort || 0 }}</span>
</div>
</div>
</div>
</fs-crud>
</fs-page> </fs-page>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onActivated, onMounted } from "vue"; import { computed, onActivated, onMounted } from "vue";
import { Modal, notification } from "ant-design-vue";
import { useFs } from "@fast-crud/fast-crud"; import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-level"; import createCrudOptions from "./crud-level";
import * as api from "./api";
import { util } from "/@/utils";
defineOptions({ name: "SysInviteLevel" }); defineOptions({ name: "SysInviteLevel" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions }); const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
const levelList = computed(() => crudBinding.value?.data || []);
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function levelIcon(level: any) {
return level?.icon || "ion:ribbon-outline";
}
function openEdit(opts: any) {
crudExpose.openEdit(opts);
}
async function toggleDisabled(row: any) {
await api.UpdateLevel({
...row,
disabled: !row.disabled,
});
notification.success({ message: row.disabled ? "已启用" : "已禁用" });
await crudExpose.doRefresh();
}
function confirmRemove(opts: any) {
Modal.confirm({
title: "确认删除推广等级?",
content: "删除后不可恢复。如果该等级已被用户使用,可能会出现异常,请确认已完成数据处理。",
okText: "确认删除",
okType: "danger",
onOk: async () => {
await api.DeleteLevel(opts.row.id);
notification.success({ message: "已删除" });
await crudExpose.doRefresh();
},
});
}
onMounted(() => { onMounted(() => {
crudExpose.doRefresh(); crudExpose.doRefresh();
@@ -23,3 +99,130 @@ onActivated(() => {
crudExpose.doRefresh(); crudExpose.doRefresh();
}); });
</script> </script>
<style lang="less">
.page-sys-invite-level {
.fs-crud-table {
display: none;
}
.level-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding: 4px 0;
}
.level-empty {
padding: 64px 0;
}
.level-card {
position: relative;
min-height: 156px;
padding: 16px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
transition:
border-color 0.2s,
background-color 0.2s;
}
.level-card.disabled {
opacity: 0.66;
}
.level-card-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
}
.fs-icon {
font-size: 16px;
}
}
.level-name {
display: flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 72px;
gap: 6px;
color: hsl(var(--foreground));
font-weight: 700;
text-align: center;
}
.level-medal {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: #8a5a16;
font-size: 20px;
}
.level-rate-label {
margin-top: 12px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.level-rate {
margin-top: 2px;
color: #c58a35;
font-size: 24px;
font-weight: 700;
line-height: 30px;
text-align: center;
}
.level-threshold {
margin-top: 6px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.level-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
color: hsl(var(--muted-foreground));
font-size: 12px;
}
}
@media (max-width: 1200px) {
.page-sys-invite-level {
.level-card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
}
@media (max-width: 900px) {
.page-sys-invite-level {
.level-card-grid {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -8,16 +8,26 @@
<a-form-item label="开启激励计划" name="enabled"> <a-form-item label="开启激励计划" name="enabled">
<a-switch v-model:checked="settings.enabled" /> <a-switch v-model:checked="settings.enabled" />
</a-form-item> </a-form-item>
<a-form-item label="推广协议" name="agreementContent">
<a-textarea v-model:value="settings.agreementContent" :rows="10" placeholder="请输入用户开通激励计划前需要确认的推广协议内容" />
</a-form-item>
<a-form-item label="最低提现金额" name="minWithdrawAmountYuan"> <a-form-item label="最低提现金额" name="minWithdrawAmountYuan">
<a-input-number v-model:value="settings.minWithdrawAmountYuan" :min="0" addon-after="" /> <a-input-number v-model:value="settings.minWithdrawAmountYuan" :min="0" addon-after="" />
</a-form-item> </a-form-item>
<a-form-item label="提现渠道" name="withdrawChannels"> <a-form-item label="提现渠道" name="withdrawChannels">
<a-checkbox-group v-model:value="settings.withdrawChannels" :options="withdrawChannelOptions" /> <a-checkbox-group v-model:value="settings.withdrawChannels" :options="withdrawChannelOptions" />
</a-form-item> </a-form-item>
<a-form-item label=" "> <a-form-item v-if="bankChannelEnabled" label="开户银行" name="withdrawBanks">
<a-select v-model:value="settings.withdrawBanks" mode="tags" :options="bankOptions" placeholder="请选择或输入支持的开户银行" :token-separators="['', ',', '']" />
</a-form-item>
<a-form-item label="推广协议" name="agreementContent">
<fs-editor-wang5
v-model="settings.agreementContent"
:toolbar-config="{}"
:editor-config="{ placeholder: '请输入用户开通激励计划前需要确认的推广协议内容' }"
:uploader="editorUploader"
:container="{ class: 'agreement-editor' }"
style="height: 400px"
/>
</a-form-item>
<a-form-item label=" " :colon="false">
<a-button type="primary" @click="saveSettings">保存设置</a-button> <a-button type="primary" @click="saveSettings">保存设置</a-button>
</a-form-item> </a-form-item>
</a-form> </a-form>
@@ -26,20 +36,55 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, reactive } from "vue"; import { computed, onMounted, reactive } from "vue";
import { notification } from "ant-design-vue"; import { notification } from "ant-design-vue";
import * as api from "./api"; import * as api from "./api";
import { util } from "/@/utils"; import { util } from "/@/utils";
import { useSettingStore } from "/@/store/settings"; import { useSettingStore } from "/@/store/settings";
import { useUserStore } from "/@/store/user";
defineOptions({ name: "SysInviteCommissionSetting" }); defineOptions({ name: "SysInviteCommissionSetting" });
const defaultAgreement = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。"; const defaultAgreement = "<p>请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。</p>";
const settings = reactive<any>({ enabled: false, agreementContent: "", minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"] }); const defaultWithdrawBanks = [
"中国工商银行",
"中国农业银行",
"中国银行",
"中国建设银行",
"交通银行",
"招商银行",
"中国邮政储蓄银行",
"中信银行",
"中国光大银行",
"华夏银行",
"中国民生银行",
"广发银行",
"平安银行",
"兴业银行",
"浦发银行",
];
const settings = reactive<any>({ enabled: false, agreementContent: "", minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"], withdrawBanks: defaultWithdrawBanks });
const withdrawChannelOptions = [ const withdrawChannelOptions = [
{ label: "支付宝", value: "alipay" }, { label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" }, { label: "银行卡", value: "bank" },
]; ];
const bankOptions = computed(() => defaultWithdrawBanks.map(item => ({ label: item, value: item })));
const bankChannelEnabled = computed(() => settings.withdrawChannels?.includes("bank"));
const userStore = useUserStore();
const editorUploader = {
type: "form",
action: "/basic/file/upload?autoSave=true&token=" + userStore.getToken,
name: "file",
headers: {
Authorization: "Bearer " + userStore.getToken,
},
successHandle(res: any) {
return res;
},
buildUrl(res: any) {
return res.url || `/api/basic/file/download?key=${encodeURIComponent(res.key)}`;
},
};
async function loadSettings() { async function loadSettings() {
const data: any = await api.GetSettings(); const data: any = await api.GetSettings();
@@ -47,19 +92,34 @@ async function loadSettings() {
settings.agreementContent = data?.agreementContent || defaultAgreement; settings.agreementContent = data?.agreementContent || defaultAgreement;
settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0); settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0);
settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"]; settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"];
settings.withdrawBanks = data?.withdrawBanks?.length ? data.withdrawBanks : defaultWithdrawBanks;
} }
async function saveSettings() { async function saveSettings() {
const withdrawBanks = bankChannelEnabled.value ? (settings.withdrawBanks || []).map((item: string) => item?.trim()).filter(Boolean) : [];
if (isBlankAgreement(settings.agreementContent)) {
notification.warning({ message: "请填写推广协议内容" });
return;
}
await api.SaveSettings({ await api.SaveSettings({
enabled: settings.enabled, enabled: settings.enabled,
agreementContent: settings.agreementContent || "", agreementContent: settings.agreementContent || "",
minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0), minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0),
withdrawChannels: settings.withdrawChannels || [], withdrawChannels: settings.withdrawChannels || [],
withdrawBanks,
}); });
await useSettingStore().loadSysSettings(); await useSettingStore().loadSysSettings();
notification.success({ message: "保存成功" }); notification.success({ message: "保存成功" });
} }
function isBlankAgreement(content: string) {
const text = `${content || ""}`
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, "")
.trim();
return !text;
}
onMounted(loadSettings); onMounted(loadSettings);
</script> </script>
@@ -71,5 +131,8 @@ onMounted(loadSettings);
.settings-form { .settings-form {
max-width: 860px; max-width: 860px;
} }
.agreement-editor {
min-height: 420px;
}
} }
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div class="flex-o price-input"> <div class="flex-o price-input">
<a-input-number v-if="edit" prefix="¥" :value="priceValue" :precision="2" class="ml-5" @update:value="onPriceChange"> </a-input-number> <a-input-number v-if="edit" prefix="¥" :value="priceValue" :precision="2" class="price-input-number" @update:value="onPriceChange"> </a-input-number>
<span v-else class="price-text" :style="style">{{ priceLabel }}</span> <span v-else class="price-text" :style="style">{{ priceLabel }}</span>
</div> </div>
</template> </template>
@@ -55,6 +55,12 @@ const onPriceChange = (price: number) => {
<style lang="less"> <style lang="less">
.price-input { .price-input {
width: 100%;
.price-input-number {
width: 100%;
}
.price-text { .price-text {
color: red; color: red;
} }
@@ -21,6 +21,11 @@
<div class="helper">不建议设置免费套餐可以在下方配置注册赠送套餐或者在用户套餐管理中手动赠送套餐</div> <div class="helper">不建议设置免费套餐可以在下方配置注册赠送套餐或者在用户套餐管理中手动赠送套餐</div>
</a-form-item> </a-form-item>
<a-form-item label="套餐支持叠加" name="allowSuiteStack">
<a-switch v-model:checked="formState.allowSuiteStack" />
<div class="helper">默认只有一个套餐生效开启此开关可以让多个套餐可以叠加加量包无论是否开启此开关都可以叠加</div>
</a-form-item>
<a-form-item label="注册赠送套餐" name="registerGift"> <a-form-item label="注册赠送套餐" name="registerGift">
<suite-duration-selector ref="suiteDurationSelectedRef" v-model="formState.registerGift"></suite-duration-selector> <suite-duration-selector ref="suiteDurationSelectedRef" v-model="formState.registerGift"></suite-duration-selector>
<div class="helper">添加套餐后再选择</div> <div class="helper">添加套餐后再选择</div>
@@ -73,6 +78,7 @@ const formRef = ref<any>(null);
const formState = reactive< const formState = reactive<
Partial<{ Partial<{
enabled: boolean; enabled: boolean;
allowSuiteStack: boolean;
registerGift?: { registerGift?: {
productId?: number; productId?: number;
duration?: number; duration?: number;
+10
View File
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Bug Fixes
* **pipeline-service:** 修复流水线运行时超过套餐部署次数仍然能够正常运行的bug ([5e59651](https://github.com/certd/certd/commit/5e59651d45bc91919629e35995ff1b3cff6b87ea))
### Performance Improvements
* 新增阿里云直播证书部署插件 ([8edb6f8](https://github.com/certd/certd/commit/8edb6f8727bd148f106801bef25567880fd35e9e))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21) ## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
### Bug Fixes ### Bug Fixes
@@ -5,10 +5,11 @@ CREATE TABLE `cd_invite_level`
( (
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL, `id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`name` varchar(100), `name` varchar(100),
`icon` varchar(100) NOT NULL DEFAULT 'fluent-emoji-flat:2nd-place-medal',
`sort` bigint NOT NULL DEFAULT 0, `sort` bigint NOT NULL DEFAULT 0,
`min_amount` bigint NOT NULL DEFAULT 0, `min_amount` bigint NOT NULL DEFAULT 0,
`commission_rate` bigint NOT NULL DEFAULT 0, `commission_rate` bigint NOT NULL DEFAULT 0,
`is_hidden` boolean NOT NULL DEFAULT false, `level_type` varchar(30) NOT NULL DEFAULT 'normal',
`disabled` boolean NOT NULL DEFAULT false, `disabled` boolean NOT NULL DEFAULT false,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -28,8 +29,8 @@ CREATE TABLE `cd_invite_user_plan`
); );
CREATE UNIQUE INDEX `index_invite_user_plan_user_id` ON `cd_invite_user_plan` (`user_id`); CREATE UNIQUE INDEX `index_invite_user_plan_user_id` ON `cd_invite_user_plan` (`user_id`);
INSERT INTO `cd_invite_level` (`name`, `sort`, `min_amount`, `commission_rate`, `is_hidden`, `disabled`) INSERT INTO `cd_invite_level` (`name`, `icon`, `sort`, `min_amount`, `commission_rate`, `level_type`, `disabled`)
VALUES ('青铜', 10, 0, 10, false, false), VALUES ('青铜', 'fluent-emoji-flat:2nd-place-medal', 10, 0, 10, 'normal', false),
('白银', 20, 100000, 15, false, false), ('白银', 'fluent-emoji-flat:1st-place-medal', 20, 100000, 15, 'normal', false),
('黄金', 30, 500000, 20, false, false), ('黄金', 'fluent-emoji-flat:3rd-place-medal', 30, 500000, 20, 'normal', false),
('钻石', 40, 1000000, 30, false, false); ('钻石', 'streamline-color:diamond-2', 40, 1000000, 30, 'normal', false);
@@ -5,10 +5,11 @@ CREATE TABLE "cd_invite_level"
( (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL, "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100), "name" varchar(100),
"icon" varchar(100) NOT NULL DEFAULT 'fluent-emoji-flat:2nd-place-medal',
"sort" bigint NOT NULL DEFAULT 0, "sort" bigint NOT NULL DEFAULT 0,
"min_amount" bigint NOT NULL DEFAULT 0, "min_amount" bigint NOT NULL DEFAULT 0,
"commission_rate" bigint NOT NULL DEFAULT 0, "commission_rate" bigint NOT NULL DEFAULT 0,
"is_hidden" boolean NOT NULL DEFAULT (false), "level_type" varchar(30) NOT NULL DEFAULT 'normal',
"disabled" boolean NOT NULL DEFAULT (false), "disabled" boolean NOT NULL DEFAULT (false),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP), "create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP) "update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
@@ -28,8 +29,8 @@ CREATE TABLE "cd_invite_user_plan"
); );
CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id"); CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id");
INSERT INTO "cd_invite_level" ("name", "sort", "min_amount", "commission_rate", "is_hidden", "disabled") INSERT INTO "cd_invite_level" ("name", "icon", "sort", "min_amount", "commission_rate", "level_type", "disabled")
VALUES ('青铜', 10, 0, 10, false, false), VALUES ('青铜', 'fluent-emoji-flat:2nd-place-medal', 10, 0, 10, 'normal', false),
('白银', 20, 100000, 15, false, false), ('白银', 'fluent-emoji-flat:1st-place-medal', 20, 100000, 15, 'normal', false),
('黄金', 30, 500000, 20, false, false), ('黄金', 'fluent-emoji-flat:3rd-place-medal', 30, 500000, 20, 'normal', false),
('钻石', 40, 1000000, 30, false, false); ('钻石', 'streamline-color:diamond-2', 40, 1000000, 30, 'normal', false);
@@ -5,10 +5,11 @@ CREATE TABLE "cd_invite_level"
( (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"name" varchar(100), "name" varchar(100),
"icon" varchar(100) NOT NULL DEFAULT 'fluent-emoji-flat:2nd-place-medal',
"sort" integer NOT NULL DEFAULT 0, "sort" integer NOT NULL DEFAULT 0,
"min_amount" integer NOT NULL DEFAULT 0, "min_amount" integer NOT NULL DEFAULT 0,
"commission_rate" integer NOT NULL DEFAULT 0, "commission_rate" integer NOT NULL DEFAULT 0,
"is_hidden" boolean NOT NULL DEFAULT (false), "level_type" varchar(30) NOT NULL DEFAULT 'normal',
"disabled" boolean NOT NULL DEFAULT (false), "disabled" boolean NOT NULL DEFAULT (false),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) "update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
@@ -28,8 +29,8 @@ CREATE TABLE "cd_invite_user_plan"
); );
CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id"); CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id");
INSERT INTO "cd_invite_level" ("name", "sort", "min_amount", "commission_rate", "is_hidden", "disabled") INSERT INTO "cd_invite_level" ("name", "icon", "sort", "min_amount", "commission_rate", "level_type", "disabled")
VALUES ('青铜', 10, 0, 10, false, false), VALUES ('青铜', 'fluent-emoji-flat:2nd-place-medal', 10, 0, 10, 'normal', false),
('白银', 20, 100000, 15, false, false), ('白银', 'fluent-emoji-flat:1st-place-medal', 20, 100000, 15, 'normal', false),
('黄金', 30, 500000, 20, false, false), ('黄金', 'fluent-emoji-flat:3rd-place-medal', 30, 500000, 20, 'normal', false),
('钻石', 40, 1000000, 30, false, false); ('钻石', 'streamline-color:diamond-2', 40, 1000000, 30, 'normal', false);
@@ -0,0 +1,96 @@
showRunStrategy: false
default:
strategy:
runStrategy: 1
name: DeployCertToAliyunLive
title: 阿里云-部署至直播(Live
icon: svg:icon-aliyun
group: aliyun
desc: 部署证书到阿里云视频直播(Live)域名
needPlus: false
input:
cert:
title: 域名证书
helper: 请选择前置任务输出的域名证书
component:
name: output-selector
from:
- ':cert:'
- uploadCertToAliyun
template: false
required: true
order: 0
certDomains:
title: 当前证书域名
component:
name: cert-domains-getter
mergeScript: |2-
return {
component:{
inputKey: ctx.compute(({form})=>{
return form.cert
}),
}
}
template: false
required: false
order: 0
accessId:
title: Access授权
helper: 阿里云授权AccessKeyId、AccessKeySecret
component:
name: access-selector
type: aliyun
required: true
order: 0
endpoint:
title: 证书服务接入点
helper: 不会选就按默认
value: cas.aliyuncs.com
component:
name: a-select
options:
- value: cas.aliyuncs.com
label: 中国大陆
- value: cas.ap-southeast-1.aliyuncs.com
label: 新加坡
- value: cas.eu-central-1.aliyuncs.com
label: 德国(法兰克福)
required: true
order: 0
domainList:
title: 直播域名
component:
name: remote-select
vModel: value
mode: tags
type: plugin
typeName: DeployCertToAliyunLive
action: onGetDomainList
search: true
pager: true
multi: true
watches:
- certDomains
- accessId
- certDomains
- accessId
required: true
mergeScript: |2-
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
helper: 请选择要部署证书的直播域名
order: 0
output: {}
pluginType: deploy
type: builtIn
scriptFilePath: /plugins/plugin-aliyun/plugin/deploy-to-live/index.js
+14 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "@certd/ui-server", "name": "@certd/ui-server",
"version": "1.40.3", "version": "1.40.4",
"description": "fast-server base midway", "description": "fast-server base midway",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -53,20 +53,20 @@
"@aws-sdk/client-sts": "^3.990.0", "@aws-sdk/client-sts": "^3.990.0",
"@azure/arm-dns": "^5.1.0", "@azure/arm-dns": "^5.1.0",
"@azure/identity": "^4.13.1", "@azure/identity": "^4.13.1",
"@certd/acme-client": "^1.40.3", "@certd/acme-client": "^1.40.4",
"@certd/basic": "^1.40.3", "@certd/basic": "^1.40.4",
"@certd/commercial-core": "^1.40.3", "@certd/commercial-core": "^1.40.4",
"@certd/cv4pve-api-javascript": "^8.4.2", "@certd/cv4pve-api-javascript": "^8.4.2",
"@certd/jdcloud": "^1.40.3", "@certd/jdcloud": "^1.40.4",
"@certd/lib-huawei": "^1.40.3", "@certd/lib-huawei": "^1.40.4",
"@certd/lib-k8s": "^1.40.3", "@certd/lib-k8s": "^1.40.4",
"@certd/lib-server": "^1.40.3", "@certd/lib-server": "^1.40.4",
"@certd/midway-flyway-js": "^1.40.3", "@certd/midway-flyway-js": "^1.40.4",
"@certd/pipeline": "^1.40.3", "@certd/pipeline": "^1.40.4",
"@certd/plugin-cert": "^1.40.3", "@certd/plugin-cert": "^1.40.4",
"@certd/plugin-lib": "^1.40.3", "@certd/plugin-lib": "^1.40.4",
"@certd/plugin-plus": "^1.40.3", "@certd/plugin-plus": "^1.40.4",
"@certd/plus-core": "^1.40.3", "@certd/plus-core": "^1.40.4",
"@google-cloud/dns": "^5.3.1", "@google-cloud/dns": "^5.3.1",
"@google-cloud/publicca": "^1.3.0", "@google-cloud/publicca": "^1.3.0",
"@huaweicloud/huaweicloud-sdk-cdn": "3.1.185", "@huaweicloud/huaweicloud-sdk-cdn": "3.1.185",
@@ -137,6 +137,7 @@ export class MainConfiguration {
}); });
logger.info('当前环境:', this.app.getEnv()); // prod logger.info('当前环境:', this.app.getEnv()); // prod
// throw new Error("address family not supported")
} }
} }
@@ -3,7 +3,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { getImageDownloadOptions, isImageFile } from "./file-controller.js"; import { FileController, getImageDownloadOptions, isImageFile } from "./file-controller.js";
describe("FileController.isImageFile", () => { describe("FileController.isImageFile", () => {
it("detects uploaded logo image files", () => { it("detects uploaded logo image files", () => {
@@ -37,3 +37,23 @@ describe("FileController.isImageFile", () => {
assert.equal(getImageDownloadOptions("data/upload/private/user/cert.pem"), undefined); assert.equal(getImageDownloadOptions("data/upload/private/user/cert.pem"), undefined);
}); });
}); });
describe("FileController.upload", () => {
it("auto saves uploaded file to public directory when autoSave is true", async () => {
const controller = new FileController();
controller.fileService = {
async saveFile(userId: number, key: string, permission: string) {
assert.equal(userId, 1);
assert.equal(permission, "public");
assert.equal(key.startsWith("tmpfile_key_"), true);
return "/public/1/logo.png";
},
} as any;
controller.ctx = { user: { id: 1 } } as any;
const res = await controller.upload([{ filename: "logo.png", data: "tmp/logo.png" }] as any, {}, "true");
assert.equal(res.data.key, "/public/1/logo.png");
assert.equal(res.data.url, "/api/basic/file/download?key=%2Fpublic%2F1%2Flogo.png");
});
});
@@ -1,9 +1,10 @@
import { Controller, Fields, Files, Get, Inject, Post, Provide, Query } from '@midwayjs/core'; import { Controller, Fields, Files, Get, Inject, Post, Provide, Query } from '@midwayjs/core';
import { BaseController, Constants, FileService, UploadFileItem, uploadTmpFileCacheKey } from '@certd/lib-server'; import { BaseController, Constants, FileService, PermissionException, UploadFileItem, uploadTmpFileCacheKey } from '@certd/lib-server';
import send from 'koa-send'; import send from 'koa-send';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { cache } from '@certd/basic'; import { cache } from '@certd/basic';
import { UploadFileInfo } from '@midwayjs/upload'; import { UploadFileInfo } from '@midwayjs/upload';
import { AuthService } from '../../modules/sys/authority/service/auth-service.js';
const imageExtSet = new Set(['.apng', '.avif', '.bmp', '.gif', '.ico', '.jpeg', '.jpg', '.png', '.svg', '.webp']); const imageExtSet = new Set(['.apng', '.avif', '.bmp', '.gif', '.ico', '.jpeg', '.jpg', '.png', '.svg', '.webp']);
const imageCacheSeconds = 3 * 24 * 60 * 60; const imageCacheSeconds = 3 * 24 * 60 * 60;
@@ -32,8 +33,11 @@ export class FileController extends BaseController {
@Inject() @Inject()
fileService: FileService; fileService: FileService;
@Inject()
authService: AuthService;
@Post('/upload', { description: Constants.per.authOnly }) @Post('/upload', { description: Constants.per.authOnly })
async upload(@Files() files: UploadFileInfo<string>[], @Fields() fields: any) { async upload(@Files() files: UploadFileInfo<string>[], @Fields() fields: any, @Query('autoSave') autoSave: string) {
console.log('files', files, fields); console.log('files', files, fields);
const cacheKey = uploadTmpFileCacheKey + nanoid(); const cacheKey = uploadTmpFileCacheKey + nanoid();
const file = files[0]; const file = files[0];
@@ -47,22 +51,42 @@ export class FileController extends BaseController {
ttl: 1000 * 60 * 60, ttl: 1000 * 60 * 60,
} }
); );
if (autoSave === 'true') {
const key = await this.fileService.saveFile(this.getUserId(), cacheKey, 'public');
return this.ok({
key,
url: `/api/basic/file/download?key=${encodeURIComponent(key)}`,
});
}
return this.ok({ return this.ok({
key: cacheKey, key: cacheKey,
}); });
} }
@Get('/download', { description: Constants.per.guest }) @Get('/download', { description: Constants.per.guestOptionalAuth })
async download(@Query('key') key: string) { async download(@Query('key') key: string) {
let userId: any = null; const filePath = this.getDownloadFilePath(key);
if (!key.startsWith('/public')) {
userId = this.getUserId();
}
const filePath = this.fileService.getFile(key, userId);
const sendOptions = getImageDownloadOptions(filePath); const sendOptions = getImageDownloadOptions(filePath);
if (!sendOptions) { if (!sendOptions) {
this.ctx.response.attachment(filePath); this.ctx.response.attachment(filePath);
} }
await send(this.ctx, filePath, sendOptions); await send(this.ctx, filePath, sendOptions);
} }
private getDownloadFilePath(key: string) {
const isPrivateFile = !key.startsWith('/public');
const userId = isPrivateFile ? this.getUserId() : null;
try {
return this.fileService.getFile(key, userId);
} catch (e) {
if (!(e instanceof PermissionException) || !isPrivateFile || !this.authService.isAdmin(this.ctx)) {
throw e;
}
const adminFilePath = this.fileService.getFile(key, userId, true);
if (!isImageFile(adminFilePath)) {
throw e;
}
return adminFilePath;
}
}
} }
@@ -0,0 +1,56 @@
/// <reference types="mocha" />
/// <reference types="node" />
import assert from "node:assert/strict";
import jwt from "jsonwebtoken";
import { Constants } from "@certd/lib-server";
import { AuthorityMiddleware } from "./authority.js";
function createMiddleware(permission: string) {
const middleware = new AuthorityMiddleware();
middleware.secret = "test-secret";
middleware.webRouterService = {
async getMatchedRouterInfo() {
return { description: permission };
},
} as any;
return middleware;
}
function createCtx(token?: string) {
return {
path: "/api/basic/file/download",
method: "GET",
query: token ? { token } : {},
headers: {},
get() {
return "";
},
} as any;
}
describe("AuthorityMiddleware guestOptionalAuth", () => {
it("continues without user when token is not provided", async () => {
const middleware = createMiddleware(Constants.per.guestOptionalAuth);
const ctx = createCtx();
let called = false;
await middleware.resolve()(ctx, async () => {
called = true;
});
assert.equal(called, true);
assert.equal(ctx.user, undefined);
});
it("sets user when token is provided", async () => {
const middleware = createMiddleware(Constants.per.guestOptionalAuth);
const token = jwt.sign({ id: 1, roles: [1] }, middleware.secret);
const ctx = createCtx(token);
await middleware.resolve()(ctx, async () => {});
assert.equal(ctx.user.id, 1);
assert.deepEqual(ctx.user.roles, [1]);
});
});
@@ -52,29 +52,7 @@ export class AuthorityMiddleware implements IWebMiddleware {
return; return;
} }
let token = ctx.get('Authorization') || ''; const token = this.getTokenFromRequest(ctx);
token = token.replace('Bearer ', '').trim();
if (!token) {
//尝试从cookie中获取token
const cookie = ctx.headers.cookie;
if (cookie) {
const items = cookie.split(';');
for (const item of items) {
if (!item || !item.trim()) {
continue;
}
const [key, value] = item.split('=');
if (key.trim() === 'certd_token') {
token = value.trim();
break;
}
}
}
}
if (!token) {
//尝试从query中获取token
token = (ctx.query.token as string) || '';
}
if (token) { if (token) {
try { try {
@@ -84,6 +62,10 @@ export class AuthorityMiddleware implements IWebMiddleware {
return this.notAuth(ctx); return this.notAuth(ctx);
} }
} else { } else {
if (permission === Constants.per.guestOptionalAuth) {
await next();
return;
}
//找找openKey //找找openKey
const openKey = await this.doOpenHandler(ctx); const openKey = await this.doOpenHandler(ctx);
if (!openKey) { if (!openKey) {
@@ -101,6 +83,10 @@ export class AuthorityMiddleware implements IWebMiddleware {
await next(); await next();
return; return;
} }
if (permission === Constants.per.guestOptionalAuth) {
await next();
return;
}
const pass = await this.authService.checkPermission(ctx, permission); const pass = await this.authService.checkPermission(ctx, permission);
if (!pass) { if (!pass) {
@@ -123,6 +109,30 @@ export class AuthorityMiddleware implements IWebMiddleware {
return; return;
} }
private getTokenFromRequest(ctx: IMidwayKoaContext) {
let token = ctx.get('Authorization') || '';
token = token.replace('Bearer ', '').trim();
if (token) {
return token;
}
const cookie = ctx.headers.cookie;
if (cookie) {
const items = cookie.split(';');
for (const item of items) {
if (!item || !item.trim()) {
continue;
}
const [key, value] = item.split('=');
if (key.trim() === 'certd_token') {
return value.trim();
}
}
}
return (ctx.query.token as string) || '';
}
async doOpenHandler(ctx: IMidwayKoaContext) { async doOpenHandler(ctx: IMidwayKoaContext) {
//开放接口 //开放接口
const openKey = ctx.get('x-certd-token') || ''; const openKey = ctx.get('x-certd-token') || '';
@@ -0,0 +1,38 @@
import assert from "assert";
import { PipelineEntity } from "../entity/pipeline.js";
import { PipelineService } from "./pipeline-service.js";
describe("PipelineService", () => {
it("does not start a pipeline run when beforeCheck fails", async () => {
const service = new PipelineService();
let historyStarted = false;
service.beforeCheck = async () => {
throw new Error("部署次数不足");
};
service.userService = {
async isAdmin() {
return false;
},
} as any;
service.historyService = {
async start() {
historyStarted = true;
throw new Error("history should not start");
},
} as any;
const entity = new PipelineEntity();
entity.id = 1;
entity.userId = 1;
entity.projectId = 0;
entity.content = JSON.stringify({
stages: [{ id: "stage1", tasks: [] }],
triggers: [],
});
await service.doRun(entity, null, "ALL");
assert.equal(historyStarted, false);
});
});
@@ -659,6 +659,7 @@ export class PipelineService extends BaseService<PipelineEntity> {
suite = res.suite suite = res.suite
} catch (e) { } catch (e) {
logger.error(`流水线${entity.id}触发失败(${triggerId}):${e.message}`); logger.error(`流水线${entity.id}触发失败(${triggerId}):${e.message}`);
return;
} }
const id = entity.id; const id = entity.id;
@@ -55,9 +55,8 @@ export class CertApplyGetFormAliyunPlugin extends CertApplyBasePlugin {
@TaskInput( @TaskInput(
createRemoteSelectInputDefine({ createRemoteSelectInputDefine({
title: "证书订单 ID", title: "证书订单 ID",
helper: "订阅模式的证书订单 Id(在新建流水线时暂时无法获取,可以先随便填个数字,先创建,进入流水线编辑页面再获取选择即可)", helper: "订阅模式的证书订单 Id",
typeName: "CertApplyGetFormAliyun", typeName: "CertApplyGetFormAliyun",
pageSize: 50,
component: { component: {
name: "RemoteSelect", name: "RemoteSelect",
vModel: "value", vModel: "value",
+1 -1
View File
@@ -1 +1 @@
23:26 00:23
+1 -1
View File
@@ -1 +1 @@
23:48 00:32