Compare commits

...

9 Commits

Author SHA1 Message Date
xiaojunnuo 0a77fe0169 chore: 钱包余额明细 2026-05-24 23:12:56 +08:00
xiaojunnuo 961abb0f80 chore: acme账号改为必填项 2026-05-24 19:58:58 +08:00
xiaojunnuo 4efe12d2d3 Merge branch 'codex/v2-persist-01' into v2-invite 2026-05-24 19:48:24 +08:00
xiaojunnuo 67b05e2d75 feat: 支持dns-persist-01持久化验证方式申请证书,优化Acme账号的存储方式 2026-05-24 05:42:51 +08:00
xiaojunnuo f4bb459b5e chore: 佣金等级功能 2026-05-20 00:17:29 +08:00
xiaojunnuo 83a5a21f95 perf: 商业版提现增加收款二维码上传 2026-05-19 23:37:03 +08:00
xiaojunnuo 85c633fddf Merge branch 'v2-dev' into v2-invite 2026-05-19 22:59:18 +08:00
xiaojunnuo f9a310b6c3 feat: 商业版支持邀请返佣功能 2026-05-18 13:25:35 +08:00
xiaojunnuo 1bdcfe646f chore: tsconfig.build 支持sourcemap控制 2026-05-18 13:25:01 +08:00
93 changed files with 5967 additions and 128 deletions
+12
View File
@@ -35,6 +35,8 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `packages/ui/certd-server/`:后端服务
- `packages/ui/certd-client/`:前端 Web 管理台
`packages/pro/` 是独立 Git 工作区,使用 `packages/pro/.git` 管理。根仓库的 `git status` / `git diff` 默认看不到这里的实际改动;修改商业版代码后,要在 `packages/pro` 目录内单独执行 `git status` / `git diff` 检查。
## 后端
主要后端包:`packages/ui/certd-server`
@@ -108,6 +110,14 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- 不要运行前端 `pnpm tsc` / `vue-tsc`:当前依赖组合中 `vue-tsc@1.8.27` 会直接抛内部错误 `Search string not found: "/supportedTSExtensions = .*(?=;)/"`,不是有效的项目类型检查结果。
- 前端暂不跑单元测试;当前 `test:unit` 只是占位脚本
前端列表管理页面约定:
- 列表管理、后台管理、记录查询、CRUD 表格类页面,默认优先使用 Fast Crud(`@fast-crud/fast-crud``fs-crud``useFs``createCrudOptions`)实现。
- 只有轻量只读展示、强交互自定义界面或已有页面模式明确不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明原因。
- 开发或重构这类页面前,先读取 `.trae/skills/fast-crud-page-dev/SKILL.md`,按仓库内 Fast Crud 页面拆分与验证方式实现。
- 前端对话框里只做纯确认时可以使用 `Modal.confirm`;只要对话框里有字段输入、表单校验或提交字段,统一使用 `useFormDialog` / `openFormDialog`,不要在 `Modal.confirm``content` 里手写输入框。
- 页面内嵌 Fast Crud 表格时,要显式给外层容器稳定高度或 `flex: 1; min-height: 0` 的撑满链路;Fast Crud 依赖外部元素高度,不能只依赖表格默认高度。
## 流水线与插件模型
项目最关键的架构概念是证书流水线。
@@ -168,6 +178,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `packages/ui/certd-server/data/``logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。
- 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。
- 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。
- 代码可读性优先于短写法。遇到包含业务分支的复杂三元表达式、内联对象、链式调用或条件组合时,优先拆成命名清晰的中间变量、独立分支或小函数,让读代码的人能一眼看出业务意图;不要为了少写几行把逻辑压成难读的一坨。
## 插件开发技能
@@ -182,6 +193,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `access-plugin-dev`:开发 Access 授权插件
- `dns-provider-dev`:开发 DNS Provider 插件
- `fast-crud-page-dev`:开发或重构前端 Fast Crud 列表管理页面
- `task-plugin-dev`:开发 Task 部署任务插件
- `plugin-converter`:将插件转换为 YAML 配置
+4
View File
@@ -467,6 +467,10 @@ class AcmeClient {
return createHash('sha256').update(result).digest('base64url');
}
if (challenge.type === 'dns-persist-01') {
return '';
}
/* https://datatracker.ietf.org/doc/html/rfc8737 */
if (challenge.type === 'tls-alpn-01') {
return result;
+5 -1
View File
@@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract {
token: string;
}
export type Challenge = HttpChallenge | DnsChallenge;
export interface DnsPersistChallenge extends ChallengeAbstract {
type: "dns-persist-01";
}
export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge;
/**
* Certificate
+20 -1
View File
@@ -170,7 +170,7 @@ export function createChallengeFn(opts = {}) {
if (txtRecords.length === 0) {
throw new Error(`没有找到TXT解析记录(${recordName}`);
throw new Error(`没有找到TXT解析记录(${recordName},请稍后重试`);
}
return txtRecords;
}
@@ -203,6 +203,24 @@ export function createChallengeFn(opts = {}) {
return true;
}
async function verifyDnsPersistChallenge(authz, challenge, keyAuthorization, prefix = '_validation-persist.') {
const recordName = `${prefix}${authz.identifier.value.replace(/^\*\./, '')}`;
log(`本地校验DNS持久验证TXT记录: ${recordName}`);
let recordValues = await walkTxtRecord(recordName, 0, walkFromAuthoritative);
recordValues = [...new Set(recordValues)];
const expected = challenge.expectedRecordValue;
if (!expected) {
log(`未提供dns-persist-01本地校验期望值,跳过精确匹配,仅确认TXT记录存在`);
return true;
}
log(`DNS查询成功, 找到 ${recordValues.length} 条TXT记录:${recordValues}`);
if (!recordValues.length || !recordValues.includes(expected)) {
throw new Error(`没有找到需要的DNS持久验证TXT记录: ${recordName},请稍后重试,期望:${expected},结果:${recordValues}`);
}
log(`DNS持久验证记录匹配成功(${challenge.type}/${recordName}:${expected}`);
return true;
}
/**
* Verify ACME TLS ALPN challenge
*
@@ -234,6 +252,7 @@ export function createChallengeFn(opts = {}) {
challenges: {
'http-01': verifyHttpChallenge,
'dns-01': verifyDnsChallenge,
'dns-persist-01': verifyDnsPersistChallenge,
'tls-alpn-01': verifyTlsAlpnChallenge,
},
walkTxtRecord,
+1 -1
View File
@@ -57,7 +57,7 @@ export interface ClientExternalAccountBindingOptions {
export interface ClientAutoOptions {
csr: CsrBuffer | CsrString;
challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise<string>) => Promise<{recordReq?:any,recordRes?:any,dnsProvider?:any,challenge: rfc8555.Challenge,keyAuthorization:string}>;
challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise<string>) => Promise<{recordReq?:any,recordRes?:any,dnsProvider?:any,challenge: rfc8555.Challenge,keyAuthorization:string,httpUploader?:any}>;
challengeRemoveFn: (authz: Authorization, challenge: rfc8555.Challenge, keyAuthorization: string,recordReq:any, recordRes:any,dnsProvider:any,httpUploader:any) => Promise<any>;
email?: string;
termsOfServiceAgreed?: boolean;
+5 -1
View File
@@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract {
token: string;
}
export type Challenge = HttpChallenge | DnsChallenge;
export interface DnsPersistChallenge extends ChallengeAbstract {
type: 'dns-persist-01';
}
export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge;
/**
* Certificate
+1
View File
@@ -19,6 +19,7 @@ export type AccessInputDefine = FormItemProps & {
};
export type AccessDefine = Registrable & {
icon?: string;
subtype?: string;
input?: {
[key: string]: AccessInputDefine;
};
@@ -19,6 +19,9 @@ export class AccessEntity {
@Column({ comment: '类型', length: 100 })
type: string;
@Column({ name: 'subtype', comment: '子类型', length: 100, nullable: true })
subtype: string;
@Column({ name: 'setting', comment: '设置', length: 10240, nullable: true })
setting: string;
@@ -1,4 +1,4 @@
import { IAccessService } from '@certd/pipeline';
import { IAccessService } from "@certd/pipeline";
export class AccessGetter implements IAccessService {
userId: number;
@@ -15,6 +15,6 @@ export class AccessGetter implements IAccessService {
}
async getCommonById<T = any>(id: any) {
return await this.getter<T>(id, 0,null);
return await this.getter<T>(id, 0, null);
}
}
@@ -1,14 +1,16 @@
import assert from "assert";
import esmock from "esmock";
import { AccessService } from "./access-service.js";
describe("AccessService", () => {
it("does not write id into access setting when updating selected fields", async () => {
let updateParam: any;
const service = new AccessService();
service.info = async () => ({
id: 12,
type: "eab",
} as any);
service.info = async () =>
({
id: 12,
type: "eab",
}) as any;
service.decryptAccessEntity = () => ({
kid: "kid-1",
});
@@ -27,4 +29,82 @@ describe("AccessService", () => {
accountKey: "account-key",
});
});
it("writes subtype from access define field", async () => {
const { AccessService: MockedAccessService } = await esmock("./access-service.js", {
"@certd/pipeline": {
accessRegistry: {
getDefine(type: string) {
assert.equal(type, "acmeAccount");
return {
name: "acmeAccount",
subtype: "caType",
input: {
caType: {},
account: {
encrypt: true,
},
},
};
},
},
},
});
const service = new MockedAccessService();
service.encryptService = {
encrypt(value: string) {
return `encrypted:${value}`;
},
};
const param: any = {
type: "acmeAccount",
setting: JSON.stringify({
caType: "letsencrypt",
account: JSON.stringify({
accountKey: "key",
accountUri: "https://example.com/acct/1",
caType: "letsencrypt",
}),
}),
};
service.encryptSetting(param);
assert.equal(param.subtype, "letsencrypt");
});
it("allows acme account access to be saved before account generation", async () => {
const { AccessService: MockedAccessService } = await esmock("./access-service.js", {
"@certd/pipeline": {
accessRegistry: {
getDefine() {
return {
name: "acmeAccount",
subtype: "caType",
input: {
caType: {},
account: {
encrypt: true,
},
},
};
},
},
},
});
const service = new MockedAccessService();
const param: any = {
type: "acmeAccount",
setting: JSON.stringify({
caType: "letsencrypt",
}),
};
service.encryptSetting(param);
assert.equal(param.subtype, "letsencrypt");
assert.deepEqual(JSON.parse(param.setting), {
caType: "letsencrypt",
});
});
});
@@ -1,17 +1,17 @@
import {Inject, Provide, Scope, ScopeEnum} from '@midwayjs/core';
import {InjectEntityModel} from '@midwayjs/typeorm';
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { In, Repository } from "typeorm";
import {AccessGetter, BaseService, PageReq, PermissionException, ValidateException} from '../../../index.js';
import {AccessEntity} from '../entity/access.js';
import {AccessDefine, accessRegistry, newAccess} from '@certd/pipeline';
import {EncryptService} from './encrypt-service.js';
import { logger, utils } from '@certd/basic';
import { AccessGetter, BaseService, PageReq, PermissionException, ValidateException } from "../../../index.js";
import { AccessEntity } from "../entity/access.js";
import { AccessDefine, accessRegistry, newAccess } from "@certd/pipeline";
import { EncryptService } from "./encrypt-service.js";
import { logger, utils } from "@certd/basic";
/**
* 授权
*/
@Provide()
@Scope(ScopeEnum.Request, {allowDowngrade: true})
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class AccessService extends BaseService<AccessEntity> {
@InjectEntityModel(AccessEntity)
repository: Repository<AccessEntity>;
@@ -36,16 +36,16 @@ export class AccessService extends BaseService<AccessEntity> {
async add(param) {
let oldEntity = null;
if (param._copyFrom){
if (param._copyFrom) {
oldEntity = await this.info(param._copyFrom);
if (oldEntity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
if (oldEntity.userId !== param.userId) {
throw new ValidateException('您无权查看该授权配置');
if (oldEntity.userId !== param.userId) {
throw new ValidateException("您无权查看该授权配置");
}
}
delete param._copyFrom
delete param._copyFrom;
this.encryptSetting(param, oldEntity);
param.keyId = "ac_" + utils.id.simpleNanoId();
return await super.add(param);
@@ -62,17 +62,20 @@ export class AccessService extends BaseService<AccessEntity> {
return;
}
const json = JSON.parse(setting);
if (accessDefine.subtype) {
param.subtype = json[accessDefine.subtype] || null;
}
let oldSetting = {};
let encryptSetting = {};
const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === '{}';
const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === "{}";
if (oldSettingEntity) {
oldSetting = JSON.parse(oldSettingEntity.setting || '{}');
encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || '{}');
oldSetting = JSON.parse(oldSettingEntity.setting || "{}");
encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || "{}");
}
for (const key in json) {
//加密
let value = json[key];
if (value && typeof value === 'string') {
if (value && typeof value === "string") {
//去除前后空格
value = value.trim();
json[key] = value;
@@ -81,7 +84,7 @@ export class AccessService extends BaseService<AccessEntity> {
if (!accessInputDefine) {
continue;
}
if (!accessInputDefine.encrypt || !value || typeof value !== 'string') {
if (!accessInputDefine.encrypt || !value || typeof value !== "string") {
//定义无需加密、value为空、不是字符串 这些不需要加密
encryptSetting[key] = {
value: value,
@@ -96,7 +99,7 @@ export class AccessService extends BaseService<AccessEntity> {
const subIndex = Math.min(2, length);
let starLength = length - subIndex * 2;
starLength = Math.max(2, starLength);
const starString = '*'.repeat(starLength);
const starString = "*".repeat(starLength);
json[key] = value.substring(0, subIndex) + starString + value.substring(value.length - subIndex);
encryptSetting[key] = {
value: this.encryptService.encrypt(value),
@@ -116,21 +119,21 @@ export class AccessService extends BaseService<AccessEntity> {
async update(param) {
const oldEntity = await this.info(param.id);
if (oldEntity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
this.encryptSetting(param, oldEntity);
delete param.keyId
delete param.keyId;
return await super.update(param);
}
async updateAccess(access: any) {
const oldEntity = await this.info(access.id);
if (oldEntity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
const setting = this.decryptAccessEntity(oldEntity);
for (const key of Object.keys(access)) {
if (key === 'id') {
if (key === "id") {
continue;
}
setting[key] = access[key];
@@ -145,11 +148,13 @@ export class AccessService extends BaseService<AccessEntity> {
async getSimpleInfo(id: number) {
const entity = await this.info(id);
if (entity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
return {
id: entity.id,
name: entity.name,
type: entity.type,
subtype: entity.subtype,
userId: entity.userId,
projectId: entity.projectId,
};
@@ -162,14 +167,14 @@ export class AccessService extends BaseService<AccessEntity> {
}
if (checkUserId) {
if (userId == null) {
throw new ValidateException('userId不能为空');
throw new ValidateException("userId不能为空");
}
if (userId !== entity.userId) {
throw new PermissionException('您对该Access授权无访问权限');
throw new PermissionException("您对该Access授权无访问权限");
}
}
if (projectId != null && projectId !== entity.projectId) {
throw new PermissionException('您对该Access授权无访问权限');
throw new PermissionException("您对该Access授权无访问权限");
}
// const access = accessRegistry.get(entity.type);
@@ -178,8 +183,8 @@ export class AccessService extends BaseService<AccessEntity> {
id: entity.id,
...setting,
};
const accessGetter = new AccessGetter(userId,projectId, this.getById.bind(this));
return await newAccess(entity.type, input,accessGetter);
const accessGetter = new AccessGetter(userId, projectId, this.getById.bind(this));
return await newAccess(entity.type, input, accessGetter);
}
async getById(id: any, userId: number, projectId?: number): Promise<any> {
@@ -188,7 +193,7 @@ export class AccessService extends BaseService<AccessEntity> {
decryptAccessEntity(entity: AccessEntity): any {
let setting = {};
if (entity.encryptSetting && entity.encryptSetting !== '{}') {
if (entity.encryptSetting && entity.encryptSetting !== "{}") {
setting = JSON.parse(entity.encryptSetting);
for (const key in setting) {
//解密
@@ -213,12 +218,11 @@ export class AccessService extends BaseService<AccessEntity> {
return accessRegistry.getDefine(type);
}
async getSimpleByIds(ids: number[], userId: any, projectId?: number) {
if (ids.length === 0) {
return [];
}
if (userId==null) {
if (userId == null) {
return [];
}
return await this.repository.find({
@@ -231,24 +235,24 @@ export class AccessService extends BaseService<AccessEntity> {
id: true,
name: true,
type: true,
userId:true,
projectId:true,
subtype: true,
userId: true,
projectId: true,
},
});
}
/**
* 复制授权到其他项目
* @param accessId
* @param projectId
* @param accessId
* @param projectId
*/
async copyTo(accessId: number,projectId?: number) {
async copyTo(accessId: number, projectId?: number) {
const access = await this.info(accessId);
if (access == null) {
throw new Error(`该授权配置不存在,请确认是否已被删除:id=${accessId}`);
}
const keyId = access.keyId;
//检查目标项目里是否已经有相同keyId的配置
const existAccess = await this.repository.findOne({
@@ -263,10 +267,10 @@ export class AccessService extends BaseService<AccessEntity> {
}
const newAccess = {
...access,
userId:-1,
userId: -1,
id: undefined,
projectId,
}
};
await this.repository.save(newAccess);
return newAccess.id;
}
@@ -11,14 +11,16 @@
<td class="record-value" :title="cnameRecord.recordValue">
<fs-copyable v-model="cnameRecord.recordValue"></fs-copyable>
</td>
<td class="status center flex-center">
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
</a-tooltip>
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
</a-tooltip>
<td class="status center">
<span class="status-content">
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
</a-tooltip>
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
</a-tooltip>
</span>
</td>
<td class="center">
<template v-if="cnameRecord.status !== 'valid'">
@@ -142,5 +144,10 @@ async function resetStatus() {
.fs-copyable {
width: 100%;
}
.status-content {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
</style>
@@ -2,11 +2,11 @@
<table class="cname-verify-plan">
<thead>
<tr>
<td style="width: 160px">{{ t("certd.verifyPlan.hostRecord") }}</td>
<td style="width: 100px; text-align: center">{{ t("certd.verifyPlan.recordType") }}</td>
<td style="width: 250px">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
<td style="width: 120px" class="center">{{ t("certd.status") }}</td>
<td style="width: 90px" class="center">{{ t("certd.verifyPlan.operation") }}</td>
<td class="col-host">{{ t("certd.verifyPlan.hostRecord") }}</td>
<td class="col-type center">{{ t("certd.verifyPlan.recordType") }}</td>
<td class="col-value">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
<td class="col-status center">{{ t("certd.status") }}</td>
<td class="col-action center">{{ t("certd.verifyPlan.operation") }}</td>
</tr>
</thead>
<template v-for="key in domains" :key="key">
@@ -49,6 +49,21 @@ function onRecordChange(domain: string, record: CnameRecord) {
.cname-verify-plan {
width: 100%;
table-layout: fixed;
.col-host {
width: 220px;
}
.col-type {
width: 100px;
}
.col-value {
width: 360px;
}
.col-status {
width: 120px;
}
.col-action {
width: 150px;
}
tbody tr td {
border-top: 1px solid #e8e8e8 !important;
}
@@ -0,0 +1,144 @@
<template>
<tbody class="dns-persist-record-info">
<tr v-if="dnsPersistRecord">
<td class="host-record" :title="dnsPersistRecord.hostRecord">
<fs-copyable v-model="dnsPersistRecord.hostRecord"></fs-copyable>
</td>
<td style="text-align: center">TXT</td>
<td class="record-value" :title="dnsPersistRecord.recordValue">
<fs-copyable v-model="dnsPersistRecord.recordValue"></fs-copyable>
</td>
<td class="status center">
<fs-values-format v-model="dnsPersistRecord.status" :dict="statusDict" />
</td>
<td class="center">
<template v-if="dnsPersistRecord.status !== 'valid'">
<a-space>
<a-button type="primary" size="small" @click="openSettingDialog">设置TXT</a-button>
<a-button type="primary" size="small" :loading="loading" @click="doVerify">校验</a-button>
</a-space>
</template>
<div v-else class="helper">请勿删除TXT记录</div>
</td>
</tr>
<tr v-else>
<td colspan="5" class="color-red">{{ errorMessage || "请先选择ACME账号" }}</td>
</tr>
</tbody>
</template>
<script lang="ts" setup>
import { dict } from "@fast-crud/fast-crud";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { GetByDomain, Verify } from "/@/views/certd/cert/dns-persist/api";
import { useDnsPersistSettingDialog } from "/@/views/certd/cert/dns-persist/use-setting-dialog";
import { DnsPersistRecord } from "./type";
defineOptions({
name: "DnsPersistRecordInfo",
});
const props = defineProps<{
domain: string;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
wildcard?: boolean;
persistUntil?: number;
}>();
const emit = defineEmits<{
change: [DnsPersistRecord];
}>();
const statusDict = dict({
data: [
{ value: "pending", label: "待设置", color: "warning" },
{ value: "validating", label: "校验中", color: "blue" },
{ value: "valid", label: "有效", color: "green" },
{ value: "failed", label: "请重试", color: "red" },
],
});
const dnsPersistRecord = ref<DnsPersistRecord | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const { openDnsPersistSettingDialog } = useDnsPersistSettingDialog();
function onRecordChange() {
if (dnsPersistRecord.value) {
emit("change", dnsPersistRecord.value);
} else {
emit("change", {
domain: props.domain,
status: null,
} as any);
}
}
async function loadRecord() {
errorMessage.value = "";
dnsPersistRecord.value = null;
if (!props.domain || (!props.acmeAccountAccessId && !props.commonAcmeAccountAccessId)) {
onRecordChange();
return;
}
try {
dnsPersistRecord.value = await GetByDomain({
domain: props.domain,
caType: props.caType,
acmeAccountAccessId: props.acmeAccountAccessId,
commonAcmeAccountAccessId: props.commonAcmeAccountAccessId,
wildcard: props.wildcard,
persistUntil: props.persistUntil,
createOnNotFound: true,
});
onRecordChange();
} catch (e: any) {
errorMessage.value = e.message;
}
}
watch(
() => [props.domain, props.caType, props.acmeAccountAccessId, props.commonAcmeAccountAccessId, props.wildcard, props.persistUntil],
async () => {
await loadRecord();
},
{
immediate: true,
}
);
async function doVerify() {
if (!dnsPersistRecord.value?.id) {
return;
}
loading.value = true;
try {
const ok = await Verify(dnsPersistRecord.value.id);
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
await loadRecord();
} finally {
loading.value = false;
}
}
function openSettingDialog() {
if (!dnsPersistRecord.value) {
return;
}
openDnsPersistSettingDialog({
record: dnsPersistRecord.value,
onDone: loadRecord,
});
}
</script>
<style lang="less">
.dns-persist-record-info {
.fs-copyable {
width: 100%;
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<table class="dns-persist-verify-plan">
<thead>
<tr>
<td class="col-host">TXT主机名</td>
<td class="col-type center">记录类型</td>
<td class="col-value">请设置TXT记录验证成功以后不要删除</td>
<td class="col-status center">状态</td>
<td class="col-action center">操作</td>
</tr>
</thead>
<template v-for="key in domains" :key="key">
<dns-persist-record-info
:domain="key"
:ca-type="caType"
:acme-account-access-id="acmeAccountAccessId"
:common-acme-account-access-id="commonAcmeAccountAccessId"
:wildcard="modelValue[key]?.wildcard"
:persist-until="modelValue[key]?.persistUntil"
@change="onRecordChange(key, $event)"
/>
</template>
</table>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import DnsPersistRecordInfo from "./dns-persist-record-info.vue";
import { DnsPersistRecord } from "./type";
defineOptions({
name: "DnsPersistVerifyPlan",
});
const emit = defineEmits(["update:modelValue", "change"]);
const props = defineProps<{
modelValue: Record<string, DnsPersistRecord>;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
}>();
const domains = computed(() => {
return Object.keys(props.modelValue || {});
});
function onRecordChange(domain: string, record: DnsPersistRecord) {
const value = { ...props.modelValue };
value[domain] = {
...value[domain],
...record,
};
emit("update:modelValue", value);
emit("change", value);
}
</script>
<style lang="less">
.dns-persist-verify-plan {
width: 100%;
table-layout: fixed;
.col-host {
width: 220px;
}
.col-type {
width: 100px;
}
.col-value {
width: 360px;
}
.col-status {
width: 120px;
}
.col-action {
width: 150px;
}
tbody tr td {
border-top: 1px solid #e8e8e8 !important;
}
tr {
td {
border: 0 !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.center {
text-align: center;
}
}
}
}
</style>
@@ -46,13 +46,28 @@
<div class="form-item">
<span class="label">{{ t("certd.verifyPlan.dnsAccess") }}:</span>
<span class="input">
<access-selector v-model="item.dnsProviderAccessId" size="small" :type="item.dnsProviderAccessType || item.dnsProviderType" :placeholder="t('certd.verifyPlan.pleaseSelect')" @change="onPlanChanged"></access-selector>
<access-selector
v-model="item.dnsProviderAccessId"
size="small"
:type="item.dnsProviderAccessType || item.dnsProviderType"
:placeholder="t('certd.verifyPlan.pleaseSelect')"
@change="onPlanChanged"
></access-selector>
</span>
</div>
</div>
<div v-if="item.type === 'cname'" class="plan-cname">
<cname-verify-plan v-model="item.cnameVerifyPlan" @change="onPlanChanged" />
</div>
<div v-if="item.type === 'dns-persist'" class="plan-dns-persist">
<dns-persist-verify-plan
v-model="item.dnsPersistVerifyPlan"
:ca-type="caType"
:acme-account-access-id="acmeAccountAccessId"
:common-acme-account-access-id="commonAcmeAccountAccessId"
@change="onPlanChanged"
/>
</div>
<div v-if="item.type === 'http'" class="plan-http">
<http-verify-plan v-model="item.httpVerifyPlan" @change="onPlanChanged" />
<div class="helper">{{ t("certd.verifyPlan.httpHelper") }}</div>
@@ -76,6 +91,7 @@ import { useI18n } from "vue-i18n";
import { dict, FsDictSelect } from "@fast-crud/fast-crud";
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
import CnameVerifyPlan from "./cname-verify-plan.vue";
import DnsPersistVerifyPlan from "./dns-persist-verify-plan.vue";
import HttpVerifyPlan from "./http-verify-plan.vue";
import { Form } from "ant-design-vue";
import { DomainsVerifyPlanInput } from "./type";
@@ -92,6 +108,10 @@ const challengeTypeOptions = ref<any[]>([
label: t("certd.verifyPlan.dnsChallenge"),
value: "dns",
},
{
label: "DNS持久验证",
value: "dns-persist",
},
{
label: t("certd.verifyPlan.cnameChallenge"),
value: "cname",
@@ -106,6 +126,9 @@ const props = defineProps<{
modelValue?: DomainsVerifyPlanInput;
domains?: string[];
defaultType?: string;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
}>();
const emit = defineEmits<{
@@ -189,11 +212,15 @@ async function onDomainsChanged(domains: string[]) {
const cnameOrigin = planItem.cnameVerifyPlan;
const httpOrigin = planItem.httpVerifyPlan;
const dnsPersistOrigin = planItem.dnsPersistVerifyPlan;
planItem.cnameVerifyPlan = {};
planItem.httpVerifyPlan = {};
planItem.dnsPersistVerifyPlan = {};
const cnamePlan = planItem.cnameVerifyPlan;
const httpPlan = planItem.httpVerifyPlan;
const dnsPersistPlan = planItem.dnsPersistVerifyPlan;
for (const subDomain of domainGroupItem.keySubDomains) {
const wildcard = true;
if (!cnameOrigin[subDomain]) {
//@ts-ignore
planItem.cnameVerifyPlan[subDomain] = {
@@ -225,6 +252,19 @@ async function onDomainsChanged(domains: string[]) {
domain: subDomain,
};
}
if (!dnsPersistOrigin?.[subDomain]) {
//@ts-ignore
dnsPersistPlan[subDomain] = {
domain: subDomain,
wildcard,
};
} else {
dnsPersistPlan[subDomain] = {
...dnsPersistOrigin[subDomain],
wildcard,
};
}
}
for (const subDomain of Object.keys(cnamePlan)) {
@@ -238,6 +278,12 @@ async function onDomainsChanged(domains: string[]) {
delete httpPlan[subDomain];
}
}
for (const subDomain of Object.keys(dnsPersistPlan)) {
if (!domainGroupItem.keySubDomains.includes(subDomain)) {
delete dnsPersistPlan[subDomain];
}
}
}
for (const domain of Object.keys(planRef.value)) {
const mainDomains = Object.keys(domainGroups);
@@ -268,6 +314,7 @@ watch(
overflow-x: auto;
.fullscreen-modal {
display: none;
background-color: rgba(0, 0, 0, 0.42);
}
&.fullscreen {
@@ -7,15 +7,32 @@ export type HttpRecord = {
httpUploadRootDir: string;
};
export type DnsPersistRecord = {
id?: number;
domain: string;
mainDomain?: string;
status?: string;
hostRecord?: string;
recordValue?: string;
caType?: string;
acmeAccountAccessId?: number;
accountUri?: string;
wildcard?: boolean;
persistUntil?: number;
dnsProviderType?: string;
dnsProviderAccess?: number;
};
export type DomainVerifyPlanInput = {
domain: string;
domains: string[];
type: "cname" | "dns" | "http";
type: "cname" | "dns" | "http" | "dns-persist";
dnsProviderType?: string;
dnsProviderAccessType?: string;
dnsProviderAccessId?: number;
cnameVerifyPlan?: Record<string, CnameRecord>;
httpVerifyPlan?: Record<string, HttpRecord>;
dnsPersistVerifyPlan?: Record<string, DnsPersistRecord>;
};
export type DomainsVerifyPlanInput = {
[key: string]: DomainVerifyPlanInput;
@@ -46,6 +46,14 @@ function checkDomainVerifyPlan(rule: any, value: DomainsVerifyPlanInput) {
if (!value[domain].dnsProviderType || !value[domain].dnsProviderAccessId) {
throw new Error($t("certd.verifyPlan.errors.dnsProviderRequired", { domain }));
}
} else if (type === "dns-persist") {
const subDomains = Object.keys(value[domain].dnsPersistVerifyPlan || {});
for (const subDomain of subDomains) {
const plan = value[domain].dnsPersistVerifyPlan[subDomain];
if (plan.status !== "valid") {
throw new Error(`DNS持久验证记录(${subDomain})还未校验成功`);
}
}
}
}
return true;
@@ -1,8 +1,8 @@
<template>
<div class="refresh-input">
<div class="refresh-input-line">
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" allow-clear @update:value="emit('update:value', $event)"></a-input>
<fs-button :loading="loading" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" :allow-clear="!disabled" :disabled="disabled" @update:value="emit('update:value', $event)"></a-input>
<fs-button :loading="loading" :disabled="disabled" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
</div>
<div class="helper" :class="{ error: hasError }">
{{ message }}
@@ -25,6 +25,7 @@ type RefreshInputProps = ComponentPropsType & {
icon?: string;
placeholder?: string;
successMessage?: string;
disabled?: boolean;
};
const fromType: any = inject("getFromType");
@@ -49,6 +50,9 @@ const placeholder = computed(() => props.placeholder || "");
const successMessage = computed(() => props.successMessage || "刷新成功,请保存配置");
const doRefresh = async () => {
if (props.disabled) {
return;
}
if (loading.value) {
return;
}
@@ -5,6 +5,7 @@ function createChallengeTypeDict() {
return dict({
data: [
{ value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" },
{ value: "dns-persist", label: "DNS持久验证", color: "cyan" },
{ value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" },
{ value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" },
],
@@ -39,7 +40,12 @@ export const Dicts = {
sslProviderDict: dict({
data: [
{ value: "letsencrypt", label: "Let's Encrypt" },
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境" },
{ value: "google", label: "Google" },
{ value: "zerossl", label: "ZeroSSL" },
{ value: "sslcom", label: "SSL.com" },
{ value: "litessl", label: "litessl" },
{ value: "custom", label: "自定义ACME" },
],
}),
get challengeTypeDict() {
@@ -11,6 +11,7 @@ export default {
siteMonitor: "Site Certificate Monitor",
settings: "Settings",
accessManager: "Access Management",
dnsPersistRecord: "DNS Persist Records",
subDomain: "Subdomain Delegation Settings",
pipelineGroup: "Pipeline Group Management",
openKey: "Open API Key",
@@ -22,6 +23,7 @@ export default {
mySuite: "My Suite",
suiteBuy: "Suite Purchase",
myTrade: "My Orders",
inviteCommission: "Incentive Plan",
paymentReturn: "Payment Return",
source: "Source Code",
github: "GitHub",
@@ -46,6 +48,10 @@ export default {
suiteSetting: "Suite Settings",
orderManager: "Order Management",
userSuites: "User Suites",
inviteCommissionSetting: "Incentive Plan Settings",
inviteLevel: "Promotion Levels",
inviteUserLevel: "User Promotion Levels",
inviteWithdraw: "Withdrawal Requests",
netTest: "Network Test",
enterpriseSetting: "Enterprise Settings",
@@ -11,6 +11,7 @@ export default {
siteMonitor: "站点证书监控",
settings: "设置",
accessManager: "授权管理",
dnsPersistRecord: "DNS持久验证记录",
subDomain: "子域名托管设置",
pipelineGroup: "流水线分组管理",
openKey: "开放接口密钥",
@@ -22,6 +23,8 @@ export default {
mySuite: "我的套餐",
suiteBuy: "套餐购买",
myTrade: "我的订单",
myWallet: "我的钱包",
inviteCommission: "激励计划",
paymentReturn: "支付返回",
source: "源码",
github: "github",
@@ -46,6 +49,10 @@ export default {
suiteSetting: "套餐设置",
orderManager: "订单管理",
userSuites: "用户套餐",
inviteCommissionSetting: "激励计划设置",
inviteLevel: "推广等级",
inviteUserLevel: "用户推广等级",
inviteWithdraw: "提现申请记录",
netTest: "网络测试",
enterpriseManager: "企业管理设置",
projectManager: "项目管理",
+2
View File
@@ -12,9 +12,11 @@ import plugin from "./plugin/";
import { setupVben } from "./vben";
import { util } from "/@/utils";
import { initPreferences } from "/@/vben/preferences";
import { inviteUtils } from "/@/utils/util.invite";
// import "./components/code-editor/import-works";
// @ts-ignore
async function bootstrap() {
inviteUtils.captureFromLocation();
const app = createApp(App);
// app.use(Antd);
app.use(Antd);
@@ -186,6 +186,17 @@ export const certdResources = [
keepAlive: true,
},
},
{
title: "certd.dnsPersistRecord",
name: "DnsPersistRecord",
path: "/certd/cert/dns-persist",
component: "/certd/cert/dns-persist/index.vue",
meta: {
icon: "ion:shield-half-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.subDomain",
name: "SubDomain",
@@ -324,7 +335,7 @@ export const certdResources = [
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
return settingStore.isInviteCommissionEnabled;
},
icon: "ion:gift-outline",
auth: true,
@@ -359,6 +370,36 @@ export const certdResources = [
keepAlive: true,
},
},
{
title: "certd.myWallet",
name: "MyWallet",
path: "/certd/wallet",
component: "/certd/wallet/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:wallet-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.inviteCommission",
name: "InviteCommission",
path: "/certd/invite",
component: "/certd/invite/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.paymentReturn",
name: "PaymentReturn",
@@ -286,6 +286,70 @@ export const sysResources = [
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteCommissionSetting",
name: "SysInviteCommissionSetting",
path: "/sys/suite/invite/setting",
component: "/sys/suite/invite/setting.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteLevel",
name: "SysInviteLevel",
path: "/sys/suite/invite/level",
component: "/sys/suite/invite/level.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:ribbon-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteUserLevel",
name: "SysInviteUserLevel",
path: "/sys/suite/invite/user-level",
component: "/sys/suite/invite/user-level.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:people-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteWithdraw",
name: "SysInviteWithdraw",
path: "/sys/suite/invite/withdraw",
component: "/sys/suite/invite/withdraw.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:cash-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
],
},
{
@@ -97,6 +97,9 @@ export type SysPublicSetting = {
export type SuiteSetting = {
enabled?: boolean;
};
export type InviteSetting = {
enabled?: boolean;
};
export type SysPrivateSetting = {
httpProxy?: string;
httpsProxy?: string;
@@ -142,6 +145,7 @@ export type AllSettings = {
siteEnv: SiteEnv;
headerMenus: HeaderMenus;
suiteSetting: SuiteSetting;
inviteSetting: InviteSetting;
app: AppInfo;
};
@@ -30,6 +30,9 @@ export interface SettingState {
headerMenus?: HeaderMenus;
inited?: boolean;
suiteSetting?: SuiteSetting;
inviteSetting?: {
enabled?: boolean;
};
app: {
version?: string;
time?: number;
@@ -102,6 +105,7 @@ export const useSettingStore = defineStore({
menus: [],
},
suiteSetting: { enabled: false },
inviteSetting: { enabled: false },
inited: false,
app: {
version: "",
@@ -196,6 +200,9 @@ export const useSettingStore = defineStore({
// @ts-ignore
return this.suiteSetting?.enabled === true;
},
isInviteCommissionEnabled(): boolean {
return this.isComm && this.inviteSetting?.enabled === true;
},
},
actions: {
checkPlus() {
@@ -215,6 +222,7 @@ export const useSettingStore = defineStore({
merge(this.plusInfo, allSettings.plusInfo || {});
merge(this.headerMenus, allSettings.headerMenus || {});
merge(this.suiteSetting, allSettings.suiteSetting || {});
merge(this.inviteSetting, allSettings.inviteSetting || {});
//@ts-ignore
this.initSiteInfo(allSettings.siteInfo || {});
this.initAppInfo(allSettings.app || {});
@@ -4,6 +4,7 @@ export interface RegisterReq {
username: string;
password: string;
confirmPassword: string;
inviteCode?: string;
}
/**
* @description: Login interface parameters
@@ -18,6 +19,7 @@ export interface SmsLoginReq {
phoneCode: string;
smsCode: string;
randomStr: string;
inviteCode?: string;
}
export interface ForgotPasswordReq {
@@ -0,0 +1,54 @@
const INVITE_STORAGE_KEY = "certd_invite_code";
const INVITE_TTL = 3 * 24 * 60 * 60 * 1000;
export type InviteCache = {
code: string;
expiresAt: number;
};
function normalizeInviteCode(code?: string | null) {
return code?.trim().toUpperCase();
}
export const inviteUtils = {
save(code?: string | null) {
const normalized = normalizeInviteCode(code);
if (!normalized) {
return;
}
const cache: InviteCache = {
code: normalized,
expiresAt: Date.now() + INVITE_TTL,
};
localStorage.setItem(INVITE_STORAGE_KEY, JSON.stringify(cache));
},
get() {
const text = localStorage.getItem(INVITE_STORAGE_KEY);
if (!text) {
return "";
}
try {
const cache = JSON.parse(text) as InviteCache;
if (!cache.code || !cache.expiresAt || cache.expiresAt < Date.now()) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
return cache.code;
} catch (e) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
},
captureFromLocation() {
const hashQuery = window.location.hash?.split("?")[1] || "";
const search = window.location.search?.replace(/^\?/, "") || "";
const hashParams = new URLSearchParams(hashQuery);
const searchParams = new URLSearchParams(search);
const code = hashParams.get("inviteCode") || searchParams.get("inviteCode");
if (code) {
this.save(code);
}
},
};
@@ -12,6 +12,12 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const { props, ctx, api } = context;
const lastResRef = ref();
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
query.query = query.query || {};
if (props.subtype) {
query.query.subtype = props.subtype;
} else {
delete query.query.subtype;
}
return await context.api.GetList(query);
};
const editRequest = async (req: EditReq) => {
@@ -47,7 +53,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const { myProjectDict } = useDicts();
const typeRef = ref("aliyun");
context.typeRef = typeRef;
const commonColumnsDefine = getCommonColumnDefine(crudExpose, typeRef, api);
const commonColumnsDefine = getCommonColumnDefine(crudExpose, typeRef, api, props.subtype);
commonColumnsDefine.type.form.component.disabled = true;
const projectStore = useProjectStore();
return {
@@ -21,6 +21,10 @@ export default defineComponent({
type: String, //user | sys
default: "user",
},
subtype: {
type: String,
default: "",
},
modelValue: {},
},
emits: ["update:modelValue"],
@@ -30,10 +34,17 @@ export default defineComponent({
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
// 你可以调用此方法,重新初始化crud配置
function refreshSearch() {
const form: any = { type: props.type };
if (props.subtype) {
form.subtype = props.subtype;
}
crudExpose.setSearchFormData({ form, mergeForm: true });
crudExpose.doRefresh();
}
function onTypeChanged(value: any) {
context.typeRef.value = value;
crudExpose.setSearchFormData({ form: { type: value }, mergeForm: true });
crudExpose.doRefresh();
refreshSearch();
}
watch(
() => {
@@ -44,6 +55,14 @@ export default defineComponent({
onTypeChanged(value);
}
);
watch(
() => {
return props.subtype;
},
() => {
refreshSearch();
}
);
// 页面打开后获取列表数据
onMounted(() => {
onTypeChanged(props.type);
@@ -9,7 +9,7 @@
<a-form-item-rest v-if="chooseForm.show">
<a-modal v-model:open="chooseForm.show" title="选择授权提供者" width="900px" @ok="chooseForm.ok">
<div style="height: 400px; position: relative">
<cert-access-modal v-model="selectedId" :type="type" :from="from"></cert-access-modal>
<cert-access-modal v-model="selectedId" :type="type" :subtype="subtype" :from="from"></cert-access-modal>
</div>
</a-modal>
</a-form-item-rest>
@@ -35,6 +35,10 @@ export default defineComponent({
type: String,
default: "aliyun",
},
subtype: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "请选择",
@@ -1,11 +1,11 @@
import { ColumnCompositionProps, dict } from "@fast-crud/fast-crud";
import { computed, provide, ref, toRef } from "vue";
import { provide, ref, toRef } from "vue";
import { useReference } from "/@/use/use-refrence";
import { forEach, get, merge, set } from "lodash-es";
import SecretPlainGetter from "/@/views/certd/access/access-selector/access/secret-plain-getter.vue";
import { utils } from "/@/utils";
export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any) {
export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any, fixedSubtype?: string) {
provide("getFromType", api.from);
provide("accessApi", api);
provide("get:plugin:type", () => {
@@ -34,6 +34,13 @@ export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any) {
}
}
console.log('crudBinding.value[mode + "Form"].columns', columnsRef.value);
if (mode === "add" && define.subtype && fixedSubtype) {
form.access = form.access || {};
const subtypeKey = `access.${define.subtype}`;
if (get(form, subtypeKey) == null) {
set(form, subtypeKey, fixedSubtype);
}
}
forEach(define.input, (value: any, mapKey: any) => {
const key = "access." + mapKey;
const field = {
@@ -0,0 +1,83 @@
import { request } from "/src/api/service";
const apiPrefix = "/cert/dns-persist";
export async function GetList(query: any) {
return await request({
url: apiPrefix + "/page",
method: "post",
data: query,
});
}
export async function AddObj(obj: any) {
return await request({
url: apiPrefix + "/add",
method: "post",
data: obj,
});
}
export async function UpdateObj(obj: any) {
return await request({
url: apiPrefix + "/update",
method: "post",
data: obj,
});
}
export async function DelObj(id: any) {
return await request({
url: apiPrefix + "/delete",
method: "post",
params: { id },
});
}
export async function BuildRecord(body: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
return await request({
url: apiPrefix + "/build",
method: "post",
data: body,
});
}
export async function GetByDomain(body: { domain: string; caType?: string; acmeAccountAccessId?: number; commonAcmeAccountAccessId?: number; wildcard?: boolean; persistUntil?: number; createOnNotFound?: boolean }) {
return await request({
url: apiPrefix + "/getByDomain",
method: "post",
data: body,
});
}
export async function CheckRecord(body: { hostRecord: string; recordValue: string }) {
return await request({
url: apiPrefix + "/check",
method: "post",
data: body,
});
}
export async function Verify(id: number) {
return await request({
url: apiPrefix + "/verify",
method: "post",
data: { id },
});
}
export async function TriggerVerify(id: number) {
return await request({
url: apiPrefix + "/triggerVerify",
method: "post",
data: { id },
});
}
export async function CreateTxt(body: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
return await request({
url: apiPrefix + "/createTxt",
method: "post",
data: body,
});
}
@@ -0,0 +1,349 @@
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { message, Modal, notification } from "ant-design-vue";
import * as api from "./api";
import { Dicts } from "/@/components/plugins/lib/dicts";
import { createAccessApi } from "/@/views/certd/access/api";
import { useDnsPersistSettingDialog } from "./use-setting-dialog";
function parseAccount(account: any) {
if (!account) {
return null;
}
if (typeof account === "string") {
return JSON.parse(account);
}
return account;
}
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const accessApi = createAccessApi();
const { openDnsPersistSettingDialog } = useDnsPersistSettingDialog();
const accessDict = dict({
value: "id",
label: "name",
url: "accessDict",
async getNodesByValues(ids: number[]) {
return await accessApi.GetDictByIds(ids);
},
});
const dnsProviderTypeDict = dict({
url: "pi/dnsProvider/dnsProviderTypeDict",
});
const statusDict = dict({
data: [
{ value: "pending", label: "待设置", color: "warning" },
{ value: "created", label: "已创建", color: "blue" },
{ value: "validating", label: "校验中", color: "blue" },
{ value: "valid", label: "有效", color: "green" },
{ value: "failed", label: "请重试", color: "red" },
],
});
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
const editRequest = async ({ form, row }: EditReq) => {
form.id = row.id;
return await api.UpdateObj(form);
};
const delRequest = async ({ row }: DelReq) => {
const res = await api.DelObj(row.id);
if (res?.message) {
notification.warning({
message: "请到供应商删除TXT记录",
description: res.message,
duration: 0,
});
}
return res;
};
const addRequest = async ({ form }: AddReq) => {
return await api.AddObj(form);
};
async function fillRecord(form: any) {
if (!form.domain || !form.acmeAccountAccessId) {
return;
}
const access: any = await accessApi.GetObj(form.acmeAccountAccessId);
const setting = JSON.parse(access.setting || "{}");
const account = parseAccount(setting.account);
if (!account?.accountUri) {
message.error("ACME账号授权缺少accountUri,请重新生成账号");
return;
}
const record = await api.BuildRecord({
domain: form.domain,
accountUri: account.accountUri,
wildcard: true,
persistUntil: form.persistUntil,
});
form.caType = account.caType;
form.accountUri = account.accountUri;
form.hostRecord = record.hostRecord;
form.recordValue = record.recordValue;
form.status = "pending";
}
async function verifyRecord(row: any) {
const ok = await api.Verify(row.id);
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
await crudExpose.doRefresh();
return ok;
}
function showRecordHelp(row: any) {
openDnsPersistSettingDialog({
record: row,
async onDone() {
await crudExpose.doRefresh();
},
});
}
return {
crudOptions: {
request: {
pageRequest,
addRequest,
editRequest,
delRequest,
},
actionbar: {
buttons: {
add: {
icon: "ion:add-circle-outline",
},
},
},
rowHandle: {
minWidth: 120,
fixed: "right",
},
columns: {
id: {
title: "ID",
key: "id",
type: "number",
column: { width: 80, order: -999 },
form: { show: false },
},
domain: {
title: "域名",
type: "text",
search: { show: true },
form: {
required: true,
valueChange({ form }) {
fillRecord(form);
},
},
},
mainDomain: {
title: "主域名",
type: "text",
form: {
show: false,
},
column: {
width: 160,
order: 901,
},
},
wildcard: {
title: "通配符",
type: "dict-switch",
form: {
show: false,
value: true,
},
column: { show: false },
},
acmeAccountAccessId: {
title: "ACME账号授权",
type: "dict-select",
dict: accessDict,
form: {
required: true,
order: -9,
component: {
name: "AccessSelector",
vModel: "modelValue",
type: "acmeAccount",
subtype: compute(({ form }) => {
return form.caType;
}),
},
valueChange({ form }) {
fillRecord(form);
},
},
column: {
width: 180,
},
},
caType: {
title: "颁发机构",
type: "dict-select",
dict: Dicts.sslProviderDict,
form: {
required: true,
value: "letsencrypt",
order: -10,
valueChange({ form }) {
form.acmeAccountAccessId = null;
fillRecord(form);
},
},
column: { width: 120 },
},
persistUntil: {
title: "有效期至",
type: "datetime",
form: {
helper: "可选;为空表示长期有效",
order: 20,
valueChange({ form }) {
fillRecord(form);
},
},
column: { width: 180, order: 900 },
},
hostRecord: {
title: "TXT主机名",
type: "copyable",
form: {
show: false,
},
column: {
width: 220,
cellRender({ value }) {
return (
<a-tooltip title={value}>
<fs-copyable modelValue={value}></fs-copyable>
</a-tooltip>
);
},
},
},
recordValue: {
title: "请设置TXT记录",
type: "copyable",
form: {
show: false,
},
column: {
width: 380,
cellRender({ value }) {
return (
<a-tooltip title={value}>
<fs-copyable modelValue={value}></fs-copyable>
</a-tooltip>
);
},
},
},
dnsProviderType: {
title: "DNS服务商",
type: "dict-select",
dict: dnsProviderTypeDict,
form: {
show: false,
component: {
name: "DnsProviderSelector",
},
},
column: { show: false },
},
dnsProviderAccess: {
title: "DNS授权",
type: "dict-select",
dict: accessDict,
form: {
show: false,
component: {
name: "AccessSelector",
vModel: "modelValue",
type: compute(({ form }) => {
const type = form.dnsProviderType || "aliyun";
return dnsProviderTypeDict?.dataMap[type]?.accessType || type;
}),
},
},
column: { show: false },
},
status: {
title: "状态",
type: "dict-select",
dict: statusDict,
form: {
show: false,
value: "pending",
},
column: {
width: 120,
cellRender({ value, row }) {
async function resetStatus() {
Modal.confirm({
title: "重新校验",
content: "确认将该记录状态重置为待设置,并重新校验吗?",
onOk: async () => {
await api.UpdateObj({ id: row.id, status: "pending" });
await verifyRecord(row);
},
});
}
return (
<div class={"flex flex-left"}>
<fs-values-format modelValue={value} dict={statusDict}></fs-values-format>
{row.status === "valid" && (
<a-tooltip title="撤销并重新校验">
<fs-icon class={"ml-5 pointer color-yellow"} icon="solar:undo-left-square-bold" onClick={resetStatus}></fs-icon>
</a-tooltip>
)}
</div>
);
},
},
},
triggerValidate: {
title: "校验",
type: "text",
form: {
show: false,
},
column: {
conditionalRenderDisabled: true,
width: 210,
align: "center",
cellRender({ row }) {
return (
<a-space>
{row.status === "valid" ? (
<span class="text-gray-500">TXT记录</span>
) : (
<>
<a-button type="primary" size="small" onClick={() => showRecordHelp(row)}>
TXT
</a-button>
<a-button type="primary" size="small" onClick={() => verifyRecord(row)}>
</a-button>
</>
)}
</a-space>
);
},
},
},
accountUri: {
title: "Account URI",
type: "text",
form: { show: false },
column: { show: false },
},
},
},
};
}
@@ -0,0 +1,33 @@
<template>
<fs-page class="page-cert-dns-persist">
<template #header>
<div>
<div class="title">DNS持久验证记录</div>
<div class="text-orange-500 mt-5">当前仅 Let's Encrypt 测试环境可以申请 DNS 持久验证证书</div>
</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud>
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
defineOptions({
name: "DnsPersistRecord",
});
const context: any = {
permission: { isProjectPermission: true },
};
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
onMounted(() => {
crudExpose.doRefresh();
});
onActivated(async () => {
await crudExpose.doRefresh();
});
</script>
@@ -0,0 +1,133 @@
import { message } from "ant-design-vue";
import { reactive } from "vue";
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
import DnsProviderSelector from "/@/components/plugins/cert/dns-provider-selector/index.vue";
import { useFormDialog } from "/@/use/use-dialog";
import { CreateTxt, TriggerVerify } from "./api";
export type DnsPersistSettingRecord = {
id?: number;
mainDomain?: string;
hostRecord?: string;
recordValue?: string;
dnsProviderType?: string;
dnsProviderAccess?: number;
};
export function useDnsPersistSettingDialog() {
const { openFormDialog } = useFormDialog();
function copyableRow(label: string, value?: string) {
return (
<div class="mb-10 flex items-center">
<div style={{ width: "90px", flexShrink: 0 }}>{label}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<fs-copyable class="w-full" model-value={value || ""}></fs-copyable>
</div>
</div>
);
}
async function openDnsPersistSettingDialog(req: { record: DnsPersistSettingRecord; onDone?: () => Promise<void> | void }) {
const record = req.record;
const form = reactive({
mode: "manual",
dnsProviderType: record.dnsProviderType || "",
dnsProviderAccessType: "",
dnsProviderAccess: record.dnsProviderAccess || null,
});
async function submit() {
if (!record.id) {
return;
}
if (form.mode === "manual") {
await TriggerVerify(record.id);
message.success("已提交校验");
await req.onDone?.();
return;
}
if (!form.dnsProviderType || !form.dnsProviderAccess) {
throw new Error("请选择DNS服务商和授权");
}
await CreateTxt({
id: record.id,
dnsProviderType: form.dnsProviderType,
dnsProviderAccess: form.dnsProviderAccess,
});
message.success("TXT记录已创建");
await req.onDone?.();
}
await openFormDialog({
title: "设置DNS TXT记录",
wrapper: {
width: 680,
buttons: {
reset: {
show: false,
},
ok: {
show: true,
text: "确定",
},
},
},
body: () => (
<div>
<a-radio-group value={form.mode} buttonStyle="solid" class="mb-10" onUpdate:value={(value: string) => (form.mode = value)}>
<a-radio-button value="manual"></a-radio-button>
<a-radio-button value="auto"></a-radio-button>
</a-radio-group>
{form.mode === "manual" ? (
<div>
<a-alert class="mb-10" type="info" show-icon message="请到DNS解析控制台添加以下TXT记录,添加后点击确定会立即校验。" />
{copyableRow("主域名", record.mainDomain)}
{copyableRow("TXT主机名", record.hostRecord)}
{copyableRow("TXT值", record.recordValue)}
</div>
) : (
<div>
<a-alert class="mb-10" type="info" show-icon message="请选择DNS服务商和授权,系统会创建TXT记录,后续校验由后台完成。" />
{copyableRow("主域名", record.mainDomain)}
<div class="mb-10 flex items-center">
<div style={{ width: "90px", flexShrink: 0 }}>DNS服务商</div>
<div style={{ flex: 1, minWidth: 0 }}>
<DnsProviderSelector
class="w-full"
style={{ width: "100%" }}
modelValue={form.dnsProviderType}
onUpdate:modelValue={(value: string) => {
form.dnsProviderType = value;
form.dnsProviderAccess = null;
}}
onSelectedChange={(option: any) => {
form.dnsProviderAccessType = option?.accessType || form.dnsProviderType;
}}
/>
</div>
</div>
<div class="mb-10 flex items-center">
<div style={{ width: "90px", flexShrink: 0 }}>DNS授权</div>
<div style={{ flex: 1, minWidth: 0 }}>
<AccessSelector
modelValue={form.dnsProviderAccess}
type={form.dnsProviderAccessType || form.dnsProviderType || "aliyun"}
onUpdate:modelValue={(value: number) => {
form.dnsProviderAccess = value;
}}
/>
</div>
</div>
</div>
)}
</div>
),
onSubmit: submit,
});
}
return {
openDnsPersistSettingDialog,
};
}
@@ -92,6 +92,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
if (form.challengeType === "cname") {
throw new Error(t("certd.domain.cnameManagedInCnamePage"));
}
if (form.challengeType === "dns-persist") {
throw new Error("DNS持久验证记录请在DNS持久验证记录页面管理");
}
if (form.challengeType === "dns") {
const isSubdomain = await api.IsSubdomain({ domain: form.domain });
if (isSubdomain && !subdomainConfirmed.value) {
@@ -221,6 +224,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
crudExpose.getFormWrapperRef().close();
},
});
} else if (value === "dns-persist") {
Modal.confirm({
title: "请前往DNS持久验证记录页面添加记录",
content: "DNS持久验证需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Let's Encrypt 测试环境可以申请。",
async onOk() {
router.push({
path: "/certd/cert/dns-persist",
});
crudExpose.getFormWrapperRef().close();
},
});
}
},
},
@@ -0,0 +1,17 @@
import { request } from "/@/api/service";
export async function GetMyInvite() {
return await request({ url: "/invite/my", method: "post" });
}
export async function OpenInvitePlan() {
return await request({ url: "/invite/open", method: "post" });
}
export async function GetInvitees(query: any) {
return await request({ url: "/invite/invitees/page", method: "post", data: query });
}
export async function GetCommissionLogs(query: any) {
return await request({ url: "/invite/commission/page", method: "post", data: query });
}
@@ -0,0 +1,35 @@
import { CreateCrudOptionsProps, CreateCrudOptionsRet, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetInvitees(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
inviteeUserId: {
title: "被推广用户ID",
type: "number",
column: { width: 140 },
},
inviteCode: {
title: "推广码",
type: "text",
column: { width: 160 },
},
createTime: {
title: "推广时间",
type: "datetime",
column: { width: 180 },
},
},
},
};
}
@@ -0,0 +1,67 @@
import { CreateCrudOptionsRet, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetCommissionLogs(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
amount: {
title: "收益金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
simpleUser: {
title: "被推广用户",
type: "text",
column: {
width: 170,
cellRender({ row }) {
const simpleUser = row.simpleUser;
if (!simpleUser) {
return "-";
}
return (
<div class="leading-5">
<div>
{simpleUser.username || "-"} ({simpleUser.id})
</div>
</div>
);
},
},
},
consumeAmount: {
title: "推广金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
remark: {
title: "备注",
type: "text",
column: { minWidth: 220 },
},
createTime: {
title: "时间",
type: "datetime",
column: { width: 180 },
},
},
},
};
}
@@ -0,0 +1,642 @@
<template>
<fs-page class="page-invite">
<template #header>
<div class="title">
激励计划
<span class="sub"> 邀请好友获取丰厚佣金奖励 </span>
</div>
<div class="more">
<a-button type="primary" @click="openAgreementDialog(false)">推广协议</a-button>
</div>
</template>
<div v-if="loaded && enabled && inviteInfo.enabled" class="invite-body">
<div class="invite-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-title">{{ item.title }}</div>
<div class="summary-value" :class="item.className">{{ item.value }}</div>
</div>
</div>
<div class="invite-link-panel">
<div class="invite-info-row">
<span class="info-label">邀请码</span>
<fs-copyable v-model="inviteInfo.inviteCode" />
</div>
<div class="invite-info-row">
<span class="info-label">邀请链接</span>
<fs-copyable v-model="inviteInfo.inviteLink" />
</div>
<div class="invite-info-row">
<span class="info-label">我的等级</span>
<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>{{ inviteInfo.currentLevel?.name || "未设置" }}</span>
<span v-if="inviteInfo.currentLevel" class="current-level-rate">{{ inviteInfo.currentLevel.commissionRate }}%</span>
</a-button>
</div>
</div>
<a-tabs v-model:active-key="activeTab" class="invite-tabs" @change="handleTabChange">
<a-tab-pane key="invitees" tab="推广成功用户">
<fs-crud v-if="activeTab === 'invitees'" ref="inviteesCrudRef" class="invite-crud" v-bind="inviteesCrudBinding" />
</a-tab-pane>
<a-tab-pane key="logs" tab="收益记录">
<fs-crud v-if="activeTab === 'logs'" ref="logsCrudRef" class="invite-crud" v-bind="logsCrudBinding" />
</a-tab-pane>
</a-tabs>
</div>
<div v-else-if="loaded && enabled" class="invite-disabled">
<a-empty description="请先开通激励计划">
<a-button type="primary" @click="openAgreementDialog(true)">开通激励计划</a-button>
</a-empty>
</div>
<a-empty v-else-if="loaded" description="激励计划未开启" />
<a-modal v-model:open="levelDialogOpen" title="推广等级" width="820px" wrap-class-name="invite-level-modal" :footer="null">
<div class="level-modal-subtitle">推广越多等级越高返佣比例越高</div>
<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 class="level-name">
<span class="level-medal" :class="levelMedalClass(level)">{{ levelMedal(level) }}</span>
{{ level.name }}
<a-tag v-if="level.isHidden" color="orange">专属</a-tag>
</div>
<div class="level-rate-label">佣金比例</div>
<div class="level-rate">{{ level.commissionRate }}%</div>
<div class="level-threshold">累计推广 {{ amountToYuan(level.minAmount) }} </div>
<a-tag v-if="level.id === inviteInfo.currentLevel?.id" class="current-tag" color="blue">当前等级</a-tag>
<div v-else-if="level.id === inviteInfo.nextLevel?.id" class="next-gap">还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }}</div>
</div>
</div>
<div v-if="inviteInfo.nextLevel" class="next-level">距离下一等级{{ inviteInfo.nextLevel.name }}还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }} 元推广金额</div>
<div v-else class="next-level">已达到当前可自动升级的最高等级</div>
</a-modal>
<a-modal
v-model:open="agreementDialogOpen"
:title="agreementDialogNeedOpen ? '开通激励计划' : '推广协议'"
width="760px"
:mask-closable="!agreementDialogNeedOpen"
:keyboard="!agreementDialogNeedOpen"
:confirm-loading="agreementSubmitting"
@ok="handleAgreementOk"
@cancel="closeAgreementDialog"
>
<div class="invite-agreement-content">{{ agreementText }}</div>
<div v-if="agreementDialogNeedOpen" class="invite-agreement-confirm">
<a-checkbox v-model:checked="agreementAgree">我已阅读并同意推广协议</a-checkbox>
</div>
<template #footer>
<a-button @click="closeAgreementDialog">{{ agreementDialogNeedOpen ? "暂不开通" : "关闭" }}</a-button>
<a-button v-if="agreementDialogNeedOpen" type="primary" :disabled="!agreementAgree" :loading="agreementSubmitting" @click="handleAgreementOk">同意并开通</a-button>
</template>
</a-modal>
</fs-page>
</template>
<script lang="ts" setup>
import { computed, nextTick, onActivated, onMounted, reactive, ref } from "vue";
import { FsIcon, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import * as api from "./api";
import createInviteesCrudOptions from "./crud-invitees";
import createLogsCrudOptions from "./crud-logs";
import { useSettingStore } from "/@/store/settings";
import { util } from "/@/utils";
defineOptions({ name: "InviteCommission" });
const settingStore = useSettingStore();
const enabled = ref(false);
const activeTab = ref("invitees");
const loaded = ref(false);
const levelDialogOpen = ref(false);
const agreementDialogOpen = ref(false);
const agreementDialogNeedOpen = ref(false);
const agreementAgree = ref(false);
const agreementSubmitting = ref(false);
const defaultAgreementContent = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。";
const inviteInfo = reactive<any>({
enabled: false,
inviteCode: "",
inviteLink: "",
agreementContent: "",
summary: { totalIncomeAmount: 0, monthIncomeAmount: 0, promotionAmount: 0, inviteeCount: 0 },
currentLevel: null,
nextLevel: null,
levelList: [],
});
const { crudBinding: inviteesCrudBinding, crudExpose: inviteesCrudExpose, crudRef: inviteesCrudRef } = useFs({ createCrudOptions: createInviteesCrudOptions });
const { crudBinding: logsCrudBinding, crudExpose: logsCrudExpose, crudRef: logsCrudRef } = useFs({ createCrudOptions: createLogsCrudOptions });
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`;
}
const summaryCards = computed(() => [
{
key: "totalIncome",
title: "累计收益",
value: moneyText(inviteInfo.summary.totalIncomeAmount),
className: "income",
},
{
key: "monthIncome",
title: "本月收益",
value: moneyText(inviteInfo.summary.monthIncomeAmount),
className: "income",
},
{
key: "inviteeCount",
title: "已推广人数",
value: `${inviteInfo.summary.inviteeCount || 0}`,
className: "people",
},
]);
const visibleLevels = computed(() => {
return (inviteInfo.levelList || []).filter((level: any) => !level.disabled);
});
const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent);
function levelMedal(level: any) {
const name = `${level?.name || ""}`;
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) {
agreementDialogNeedOpen.value = needOpenPlan;
agreementAgree.value = false;
agreementDialogOpen.value = true;
}
function closeAgreementDialog() {
agreementDialogOpen.value = false;
}
async function handleAgreementOk() {
if (!agreementDialogNeedOpen.value) {
closeAgreementDialog();
return;
}
if (!agreementAgree.value) {
notification.warning({ message: "请先勾选同意推广协议" });
return;
}
agreementSubmitting.value = true;
try {
await api.OpenInvitePlan();
notification.success({ message: "激励计划已开通" });
closeAgreementDialog();
await refreshInvitePage(false);
} finally {
agreementSubmitting.value = false;
}
}
async function loadMyInvite(autoOpenAgreement = false) {
const res: any = await api.GetMyInvite();
Object.assign(inviteInfo, res || {});
if (autoOpenAgreement && !inviteInfo.enabled) {
await nextTick();
openAgreementDialog(true);
}
}
async function refreshActiveList() {
if (!inviteInfo.enabled) {
return;
}
if (activeTab.value === "invitees") {
await inviteesCrudExpose.doRefresh();
} else if (activeTab.value === "logs") {
await logsCrudExpose.doRefresh();
}
}
async function handleTabChange() {
await nextTick();
await refreshActiveList();
}
async function refreshInvitePage(autoOpenAgreement = true) {
await settingStore.initOnce();
enabled.value = settingStore.isInviteCommissionEnabled;
loaded.value = true;
if (!enabled.value) {
return;
}
await loadMyInvite(autoOpenAgreement);
if (!inviteInfo.enabled) {
return;
}
await nextTick();
await refreshActiveList();
}
onMounted(async () => {
await refreshInvitePage(true);
});
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshInvitePage();
});
</script>
<style lang="less">
.page-invite {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.invite-page-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.invite-page-subtitle {
margin-top: 4px;
color: hsl(var(--muted-foreground));
font-size: 13px;
font-weight: 400;
}
.invite-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
background: hsl(var(--background-deep));
}
.invite-disabled {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
.level-subtitle,
.level-modal-subtitle {
color: hsl(var(--muted-foreground));
font-size: 14px;
}
.invite-summary-grid {
display: grid;
gap: 16px;
}
.invite-summary-grid {
flex: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 18px;
}
.summary-card,
.invite-link-panel {
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.summary-card {
min-height: 112px;
padding: 22px;
}
.summary-title {
margin-bottom: 10px;
color: hsl(var(--muted-foreground));
font-size: 15px;
}
.summary-value {
font-size: 30px;
font-weight: 700;
line-height: 36px;
}
.summary-value.income {
color: #c58a35;
}
.summary-value.people {
color: #3478f6;
}
.invite-link-panel {
flex: none;
padding: 14px 18px;
margin-bottom: 18px;
}
.invite-info-row {
display: flex;
align-items: center;
min-height: 34px;
gap: 10px;
}
.invite-info-row + .invite-info-row {
margin-top: 8px;
}
.info-label {
width: 92px;
flex: none;
color: hsl(var(--muted-foreground));
text-align: right;
white-space: nowrap;
}
.current-level-rate {
margin-left: 6px;
color: #c58a35;
font-weight: 700;
}
.level-button {
display: inline-flex;
align-items: center;
height: 28px;
padding-left: 0;
gap: 4px;
}
.level-medal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16;
font-size: 12px;
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 {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 0 12px 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
}
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane {
display: flex;
flex: 1;
min-height: 0;
}
.ant-tabs-tabpane {
flex-direction: column;
}
.invite-crud {
flex: 1;
min-height: 0;
}
.invite-tabs {
.fs-search {
display: none;
}
}
}
.invite-level-modal {
.level-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.level-card {
position: relative;
min-height: 132px;
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.active {
border-color: #3478f6;
background: hsl(var(--primary) / 10%);
}
.level-name {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: hsl(var(--foreground));
font-weight: 700;
}
.level-medal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16;
font-size: 12px;
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 {
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,
.next-gap {
margin-top: 6px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.current-tag {
display: table;
margin: 10px auto 0;
}
.next-gap {
color: #3478f6;
}
}
.modal-level-grid {
margin-top: 12px;
}
.invite-agreement-content {
max-height: 360px;
padding: 12px;
overflow: auto;
white-space: pre-wrap;
border: 1px solid #eee;
border-radius: 6px;
background: hsl(var(--card));
line-height: 1.7;
}
.invite-agreement-confirm {
margin-top: 14px;
padding: 10px 12px;
border: 1px solid #e6f4ff;
border-radius: 6px;
background: #f5fbff;
}
.level-modal-subtitle {
margin-bottom: 12px;
}
.next-level {
margin-top: 16px;
color: #3478f6;
}
@media (max-width: 900px) {
.page-invite {
.invite-summary-grid,
.level-card-grid {
grid-template-columns: 1fr;
}
.invite-info-row {
align-items: stretch;
flex-direction: column;
}
.info-label {
width: auto;
text-align: left;
}
}
.invite-level-modal {
.level-card-grid {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -213,6 +213,10 @@ function useStepForm() {
const stepOpen = (step: any, emit: any) => {
callback.value = emit;
currentStep.value = merge({ input: {}, strategy: {} }, step);
// version 1
if (mode.value === "edit" && currentStep.value.type === "CertApply" && currentStep.value.input?.version == null) {
currentStep.value.input.version = 1;
}
if (step.type) {
changeCurrentPlugin(currentStep.value);
}
@@ -46,6 +46,7 @@ export type TradeCreateReq = {
duration: number;
num: number;
payType: string;
useRebateBalance?: boolean;
};
export async function TradeCreate(form: TradeCreateReq) {
@@ -26,12 +26,21 @@
</div>
<div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.price") }}</span>
<price-input :edit="false" :model-value="durationSelected.price"></price-input>
<price-input :edit="false" :model-value="durationSelected.price" zero-text="免费"></price-input>
</div>
<div v-if="durationSelected.price > 0 && wallet.availableAmount > 0" class="flex-o mt-5">
<span class="label">返利抵扣</span>
<a-switch v-model:checked="formRef.useRebateBalance" />
<span class="ml-10">可用 {{ amountToYuan(wallet.availableAmount) }} 预计抵扣 {{ amountToYuan(expectedRebateAmount) }} </span>
</div>
<div v-if="durationSelected.price > 0 && formRef.useRebateBalance" class="flex-o mt-5">
<span class="label">还需支付</span>
<span class="color-red">{{ amountToYuan(expectedThirdPartyAmount) }} </span>
</div>
<div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.paymentMethod") }}</span>
<div v-if="durationSelected.price === 0">{{ $t("certd.order.free") }}</div>
<div v-if="durationSelected.price === 0 || expectedThirdPartyAmount === 0">{{ $t("certd.order.free") }}</div>
<fs-dict-select v-else v-model:value="formRef.payType" :dict="paymentsDictRef" style="width: 200px"> </fs-dict-select>
</div>
</div>
@@ -39,7 +48,7 @@
</template>
<script setup lang="tsx">
import { ref } from "vue";
import { computed, ref } from "vue";
import { GetPaymentTypes, OrderModalOpenReq, TradeCreate } from "/@/views/certd/suite/api";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
@@ -49,11 +58,14 @@ import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
import { useRouter } from "vue-router";
import qrcode from "qrcode";
import * as api from "/@/views/certd/suite/api";
import { GetMyInvite } from "/@/views/certd/invite/api";
import { util } from "/@/utils";
const openRef = ref(false);
const product = ref<any>(null);
const formRef = ref<any>({});
const durationSelected = ref<any>(null);
const wallet = ref<any>({ availableAmount: 0 });
async function open(opts: OrderModalOpenReq) {
openRef.value = true;
@@ -63,6 +75,13 @@ async function open(opts: OrderModalOpenReq) {
formRef.value.productId = opts.product.id;
formRef.value.duration = opts.duration;
formRef.value.num = opts.num ?? 1;
formRef.value.useRebateBalance = false;
try {
const inviteInfo: any = await GetMyInvite();
wallet.value = inviteInfo.wallet || { availableAmount: 0 };
} catch (e) {
wallet.value = { availableAmount: 0 };
}
}
const paymentsDictRef = dict({
async getData() {
@@ -77,6 +96,21 @@ const paymentsDictRef = dict({
const router = useRouter();
const expectedRebateAmount = computed(() => {
if (!formRef.value.useRebateBalance) {
return 0;
}
return Math.min(wallet.value.availableAmount || 0, durationSelected.value?.price || 0);
});
const expectedThirdPartyAmount = computed(() => {
return Math.max(0, (durationSelected.value?.price || 0) - expectedRebateAmount.value);
});
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
async function orderCreate() {
if (durationSelected.value.price === 0) {
//0
@@ -93,7 +127,7 @@ async function orderCreate() {
return;
}
if (!formRef.value.payType) {
if (expectedThirdPartyAmount.value > 0 && !formRef.value.payType) {
notification.error({
message: "请选择支付方式",
});
@@ -104,8 +138,17 @@ async function orderCreate() {
duration: formRef.value.duration,
num: formRef.value.num ?? 1,
payType: formRef.value.payType,
useRebateBalance: formRef.value.useRebateBalance,
});
if (paymentReq.paid) {
notification.success({
message: "套餐购买成功",
});
openRef.value = false;
return;
}
async function onPaid() {
openRef.value = false;
router.push({
@@ -47,7 +47,7 @@
<div class="price flex-between mt-5">
<div class="flex-o">价格</div>
<div class="flex-o price-text">
<price-input style="color: red" :font-size="20" :model-value="selected?.price" :edit="false" />
<price-input style="color: red" :font-size="20" :model-value="selected?.price" :edit="false" zero-text="免费" />
<span class="ml-5" style="font-size: 12px"> / {{ durationDict.dataMap[selected.duration]?.label }}</span>
</div>
</div>
@@ -151,6 +151,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
},
},
rebateAmount: {
title: "返利抵扣",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
thirdPartyPayAmount: {
title: "实付金额",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
status: {
title: "状态",
search: { show: true },
@@ -177,6 +201,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "支付宝", value: "alipay" },
{ label: "微信", value: "wxpay" },
{ label: "免费", value: "free" },
{ label: "返利余额", value: "rebate" },
],
}),
column: {
@@ -0,0 +1,25 @@
import { request } from "/@/api/service";
export async function GetWalletSummary() {
return await request({ url: "/wallet/summary", method: "post" });
}
export async function GetWithdrawSetting() {
return await request({ url: "/wallet/withdraw/setting/get", method: "post" });
}
export async function SaveWithdrawSetting(data: any) {
return await request({ url: "/wallet/withdraw/setting/save", method: "post", data });
}
export async function ApplyWithdraw(amount: number) {
return await request({ url: "/wallet/withdraw/apply", method: "post", data: { amount } });
}
export async function GetWithdraws(query: any) {
return await request({ url: "/wallet/withdraw/page", method: "post", data: query });
}
export async function GetWalletLogs(query: any) {
return await request({ url: "/wallet/log/page", method: "post", data: query });
}
@@ -0,0 +1,57 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWalletLogs(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: { title: "时间", type: "datetime", column: { width: 180 } },
type: {
title: "类型",
type: "dict-select",
dict: dict({
data: [
{ label: "收益入账", value: "income", color: "success" },
{ label: "余额抵扣", value: "consume", color: "default" },
{ label: "提现冻结", value: "withdraw_freeze", color: "warning" },
{ label: "提现成功", value: "withdraw_success", color: "success" },
{ label: "提现退回", value: "withdraw_reject", color: "processing" },
],
}),
column: { width: 120 },
},
amount: {
title: "变动金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
balanceAfter: {
title: "变动后余额",
type: "number",
column: {
width: 130,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
remark: {
title: "备注",
type: "text",
column: { minWidth: 220 },
},
},
},
};
}
@@ -0,0 +1,70 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWithdraws(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
status: {
title: "状态",
type: "dict-select",
dict: dict({
data: [
{ label: "待审核", value: "pending", color: "warning" },
{ label: "已通过", value: "approved", color: "success" },
{ label: "已拒绝", value: "rejected", color: "error" },
],
}),
column: { width: 110 },
},
channel: {
title: "提现渠道",
type: "dict-select",
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
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 } },
},
},
};
}
@@ -0,0 +1,367 @@
<template>
<fs-page class="page-wallet">
<template #header>
<div class="title">我的钱包</div>
</template>
<div class="wallet-body">
<div class="wallet-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-title">{{ item.title }}</div>
<div class="summary-value" :class="item.className">{{ item.value }}</div>
</div>
</div>
<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>
<a-tabs v-model:active-key="activeTab" class="wallet-tabs" @change="refreshActiveList">
<a-tab-pane key="withdraw" tab="提现记录">
<fs-crud v-if="activeTab === 'withdraw'" ref="withdrawCrudRef" class="wallet-crud" v-bind="withdrawCrudBinding" />
</a-tab-pane>
<a-tab-pane key="logs" tab="余额明细">
<fs-crud v-if="activeTab === 'logs'" ref="logsCrudRef" class="wallet-crud" v-bind="logsCrudBinding" />
</a-tab-pane>
</a-tabs>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { computed, onActivated, onMounted, reactive, ref } from "vue";
import { compute, dict, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import * as api from "./api";
import createLogsCrudOptions from "./crud-logs";
import createWithdrawCrudOptions from "./crud-withdraw";
import { util } from "/@/utils";
import { useFormDialog } from "/@/use/use-dialog";
import { useUserStore } from "/@/store/user";
defineOptions({ name: "MyWallet" });
const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 });
const withdrawAmountYuan = ref(0);
const loaded = ref(false);
const activeTab = ref("withdraw");
const { openFormDialog } = useFormDialog();
const userStore = useUserStore();
const { crudBinding: withdrawCrudBinding, crudExpose: withdrawCrudExpose, crudRef: withdrawCrudRef } = useFs({ createCrudOptions: createWithdrawCrudOptions });
const { crudBinding: logsCrudBinding, crudExpose: logsCrudExpose, crudRef: logsCrudRef } = useFs({ createCrudOptions: createLogsCrudOptions });
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`;
}
const summaryCards = computed(() => [
{
key: "availableAmount",
title: "可用余额",
value: moneyText(summary.availableAmount),
className: "available",
},
{
key: "frozenAmount",
title: "冻结余额",
value: moneyText(summary.frozenAmount),
className: "frozen",
},
{
key: "totalIncomeAmount",
title: "累计收入",
value: moneyText(summary.totalIncomeAmount),
className: "income",
},
{
key: "totalWithdrawAmount",
title: "累计提现",
value: moneyText(summary.totalWithdrawAmount),
className: "withdraw",
},
]);
async function loadWalletSummary() {
const res: any = await api.GetWalletSummary();
Object.assign(summary, res || {});
}
async function openWithdrawSetting() {
const setting: any = await api.GetWithdrawSetting();
const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {});
await openFormDialog({
title: "提现设置",
wrapper: {
width: 560,
},
initialForm,
columns: {
channel: {
title: "提现渠道",
type: "dict-radio",
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
form: {
col: { span: 24 },
rules: [{ required: true, message: "请选择提现渠道" }],
},
},
realName: {
title: "真实姓名",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入真实姓名" }],
},
},
account: {
title: "收款账号",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入收款账号" }],
},
},
qrCode: {
title: "收款二维码",
type: "cropper-uploader",
form: {
col: { span: 24 },
component: {
vModel: "modelValue",
valueType: "key",
cropper: {
aspectRatio: 1,
autoCropArea: 1,
viewMode: 0,
},
onReady: null,
uploader: {
type: "form",
action: "/basic/file/upload?token=" + userStore.getToken,
name: "file",
headers: {
Authorization: "Bearer " + userStore.getToken,
},
successHandle(res: any) {
return res;
},
},
buildUrl(key: string) {
return `/api/basic/file/download?key=` + key;
},
},
},
},
bankName: {
title: "开户银行",
type: "text",
form: {
col: { span: 24 },
show: compute(({ form }) => form.channel === "bank"),
rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }],
},
},
},
async onSubmit(form: any) {
await api.SaveWithdrawSetting(form);
notification.success({ message: "保存成功" });
},
});
}
async function applyWithdraw() {
await api.ApplyWithdraw(util.amount.toCent(withdrawAmountYuan.value || 0));
withdrawAmountYuan.value = 0;
await loadWalletSummary();
await Promise.all([withdrawCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
notification.success({ message: "提现申请已提交" });
}
async function refreshActiveList() {
if (activeTab.value === "withdraw") {
await withdrawCrudExpose.doRefresh();
} else if (activeTab.value === "logs") {
await logsCrudExpose.doRefresh();
}
}
async function refreshWalletPage() {
await loadWalletSummary();
await refreshActiveList();
loaded.value = true;
}
onMounted(refreshWalletPage);
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshWalletPage();
});
</script>
<style lang="less">
.page-wallet {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.wallet-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
background: hsl(var(--background-deep));
}
.wallet-summary-grid,
.wallet-action-panel {
flex: none;
}
.wallet-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.summary-card,
.wallet-action-panel,
.wallet-tabs {
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.summary-card {
min-height: 112px;
padding: 22px;
}
.summary-title {
margin-bottom: 10px;
color: hsl(var(--muted-foreground));
font-size: 15px;
}
.summary-value {
font-size: 30px;
font-weight: 700;
line-height: 36px;
}
.summary-value.available,
.summary-value.income {
color: #c58a35;
}
.summary-value.frozen {
color: #8b96a8;
}
.summary-value.withdraw {
color: #3478f6;
}
.wallet-action-panel {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding: 14px 18px;
margin-bottom: 18px;
}
.wallet-action-title {
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 {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 0 12px 12px;
}
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane {
display: flex;
flex: 1;
min-height: 0;
}
.ant-tabs-tabpane {
flex-direction: column;
}
.wallet-crud {
flex: 1;
min-height: 0;
}
.wallet-tabs {
.fs-search {
display: none;
}
}
}
@media (max-width: 900px) {
.page-wallet {
.wallet-summary-grid {
grid-template-columns: 1fr;
}
.wallet-action-panel {
align-items: stretch;
flex-direction: column;
}
.wallet-action-content {
justify-content: flex-start;
}
.withdraw-amount-input {
width: 100%;
}
}
}
</style>
@@ -1,3 +1,4 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div class="main login-page">
<a-form v-if="!twoFactor.loginId" ref="formRef" class="user-layout-login" name="custom-validation" :model="formState" v-bind="layout" @finish="handleFinish" @finish-failed="handleFinishFailed">
@@ -108,6 +109,7 @@ import * as oauthApi from "../oauth/api";
import { notification } from "ant-design-vue";
import { request } from "/src/api/service";
import * as UserApi from "/src/store/user/api.user";
import { inviteUtils } from "/@/utils/util.invite";
const { t } = useI18n();
const route = useRoute();
@@ -136,6 +138,7 @@ const formState = reactive({
smsCode: "",
captcha: null,
smsCaptcha: null,
inviteCode: inviteUtils.get(),
});
const rules = {
@@ -78,6 +78,14 @@
</a-tab-pane>
</a-tabs>
<a-form-item v-if="registerType !== 'mobile'" has-feedback name="inviteCode" label="邀请码">
<a-input v-model:value="formState.inviteCode" placeholder="邀请码(选填)" size="large" autocomplete="off">
<template #prefix>
<fs-icon icon="ion:gift-outline"></fs-icon>
</template>
</a-input>
</a-form-item>
<a-form-item v-if="registerType !== 'mobile'">
<a-button type="primary" size="large" html-type="submit" class="login-button">注册</a-button>
</a-form-item>
@@ -97,6 +105,7 @@ import { useSettingStore } from "/@/store/settings";
import { notification } from "ant-design-vue";
import CaptchaInput from "/@/components/captcha/captcha-input.vue";
import { useRouter } from "vue-router";
import { inviteUtils } from "/@/utils/util.invite";
export default defineComponent({
name: "RegisterPage",
components: { CaptchaInput, EmailCode },
@@ -125,6 +134,7 @@ export default defineComponent({
confirmPassword: "",
captcha: null,
captchaForEmail: null,
inviteCode: inviteUtils.get(),
});
const rules = {
@@ -213,6 +223,7 @@ export default defineComponent({
email: formState.email,
captcha: registerType.value === "email" ? formState.captchaForEmail : formState.captcha,
validateCode: formState.validateCode,
inviteCode: formState.inviteCode,
}) as any
);
} finally {
@@ -6,7 +6,7 @@
<div class="sys-plugin-config settings-form">
<a-form :model="formState" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish" @finish-failed="onFinishFailed">
<a-form-item label="公共Google EAB授权" :name="['CertApply', 'sysSetting', 'input', 'googleCommonEabAccessId']">
<a-form-item v-show="false" label="公共Google EAB授权" :name="['CertApply', 'sysSetting', 'input', 'googleCommonEabAccessId']">
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.googleCommonEabAccessId" type="eab" from="sys"></access-selector>
<div class="helper">
<div>设置公共Google EAB授权给用户使用避免用户自己去翻墙获取Google EAB授权</div>
@@ -16,7 +16,14 @@
</div>
</a-form-item>
<a-form-item label="公共ZeroSSL EAB授权" :name="['CertApply', 'sysSetting', 'input', 'zerosslCommonEabAccessId']">
<a-form-item label="公共Google ACME账号" :name="['CertApply', 'sysSetting', 'input', 'googleCommonAcmeAccountAccessId']">
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.googleCommonAcmeAccountAccessId" type="acmeAccount" subtype="google" from="sys"></access-selector>
<div class="helper">
<div>优先推荐配置公共ACME账号配置后普通用户申请Google证书时无需选择账号也不会重复消费公共EAB</div>
</div>
</a-form-item>
<a-form-item v-show="false" label="公共ZeroSSL EAB授权" :name="['CertApply', 'sysSetting', 'input', 'zerosslCommonEabAccessId']">
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.zerosslCommonEabAccessId" type="eab" from="sys"></access-selector>
<div class="helper">
<div>设置公共ZeroSSL EAB授权给用户使用避免用户自己去翻墙获取Zero EAB授权</div>
@@ -26,7 +33,11 @@
</div>
</a-form-item>
<a-form-item label="公共litessl EAB授权" :name="['CertApply', 'sysSetting', 'input', 'litesslCommonEabAccessId']">
<a-form-item label="公共ZeroSSL ACME账号" :name="['CertApply', 'sysSetting', 'input', 'zerosslCommonAcmeAccountAccessId']">
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.zerosslCommonAcmeAccountAccessId" type="acmeAccount" subtype="zerossl" from="sys"></access-selector>
</a-form-item>
<a-form-item v-show="false" label="公共litessl EAB授权" :name="['CertApply', 'sysSetting', 'input', 'litesslCommonEabAccessId']">
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.litesslCommonEabAccessId" type="eab" from="sys"></access-selector>
<div class="helper">
<div>设置公共litessl EAB授权给用户使用避免用户自己获取litessl EAB授权</div>
@@ -36,6 +47,10 @@
</div>
</a-form-item>
<a-form-item label="公共litessl ACME账号" :name="['CertApply', 'sysSetting', 'input', 'litesslCommonAcmeAccountAccessId']">
<access-selector v-model:model-value="formState.CertApply.sysSetting.input.litesslCommonAcmeAccountAccessId" type="acmeAccount" subtype="litessl" from="sys"></access-selector>
</a-form-item>
<a-form-item label="其他配置">
<a-button type="primary" @click="doPluginConfig">证书申请插件默认值设置</a-button>
<div class="helper">
@@ -0,0 +1,45 @@
import { request } from "/@/api/service";
export async function GetSettings() {
return await request({ url: "/sys/invite/settings/get", method: "post" });
}
export async function SaveSettings(data: any) {
return await request({ url: "/sys/invite/settings/save", method: "post", data });
}
export async function GetLevels(query: any) {
return await request({ url: "/sys/invite/level/page", method: "post", data: query });
}
export async function GetLevelList() {
return await request({ url: "/sys/invite/level/list", method: "post", data: {} });
}
export async function AddLevel(data: any) {
return await request({ url: "/sys/invite/level/add", method: "post", data });
}
export async function UpdateLevel(data: any) {
return await request({ url: "/sys/invite/level/update", method: "post", data });
}
export async function GetUserLevels(query: any) {
return await request({ url: "/sys/invite/user/page", method: "post", data: query });
}
export async function SetUserLevel(data: any) {
return await request({ url: "/sys/invite/user/setLevel", method: "post", data });
}
export async function GetWithdraws(query: any) {
return await request({ url: "/sys/wallet/withdraw/page", method: "post", data: query });
}
export async function ApproveWithdraw(id: number, remark?: string) {
return await request({ url: "/sys/wallet/withdraw/approve", method: "post", data: { id, remark } });
}
export async function RejectWithdraw(id: number, remark: string) {
return await request({ url: "/sys/wallet/withdraw/reject", method: "post", data: { id, remark } });
}
@@ -0,0 +1,108 @@
import { AddReq, compute, CreateCrudOptionsRet, DelReq, EditReq, UserPageQuery, UserPageRes, dict } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetLevels(query);
};
const addRequest = async ({ form }: AddReq) => {
return await api.AddLevel(form);
};
const editRequest = async ({ form, row }: EditReq) => {
form.id = row.id;
return await api.UpdateLevel(form);
};
const delRequest = async ({ row }: DelReq) => {
row.disabled = true;
return await api.UpdateLevel(row);
};
return {
crudOptions: {
request: { pageRequest, addRequest, editRequest, delRequest },
rowHandle: {
width: 180,
fixed: "right",
buttons: {
view: { show: false },
copy: { show: false },
remove: {
text: "禁用",
show: compute(({ row }) => row.disabled !== true),
},
},
},
columns: {
id: {
title: "ID",
type: "number",
form: { show: false },
column: { width: 90 },
},
name: {
title: "等级名称",
type: "text",
search: { show: true },
form: {
rules: [{ required: true, message: "请输入等级名称" }],
},
column: { width: 140 },
},
minAmount: {
title: "升级金额",
type: "number",
form: {
component: { name: PriceInput, vModel: "modelValue", edit: true },
rules: [{ required: true, message: "请输入升级金额" }],
},
column: {
width: 140,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
commissionRate: {
title: "佣金比例",
type: "number",
form: {
component: { min: 0, max: 100, addonAfter: "%" },
rules: [{ required: true, message: "请输入佣金比例" }],
},
column: { width: 110, align: "center", cellRender: ({ value }) => `${value || 0}%` },
},
isHidden: {
title: "隐藏等级",
type: "dict-switch",
dict: dict({
data: [
{ label: "普通等级", value: false, color: "success" },
{ label: "隐藏等级", value: true, color: "warning" },
],
}),
form: { value: false },
column: { width: 120, align: "center" },
},
sort: {
title: "排序",
type: "number",
form: { value: 10 },
column: { width: 90, align: "center" },
},
disabled: {
title: "状态",
type: "dict-switch",
dict: dict({
data: [
{ label: "启用", value: false, color: "success" },
{ label: "禁用", value: true, color: "error" },
],
}),
form: { value: false },
column: { width: 100, align: "center" },
},
createTime: { title: "创建时间", type: "datetime", form: { show: false }, column: { width: 170 } },
updateTime: { title: "更新时间", type: "datetime", form: { show: false }, column: { width: 170 } },
},
},
};
}
@@ -0,0 +1,136 @@
import { compute, CreateCrudOptionsProps, CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { useFormDialog } from "/@/use/use-dialog";
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog();
const levelDict = dict({
url: "/sys/invite/level/list",
value: "id",
label: "name",
});
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetUserLevels(query);
};
async function openSetLevel(row: any) {
await openFormDialog({
title: "指定推广等级",
wrapper: { width: 560 },
initialForm: {
userId: row.userId,
levelId: row.levelId,
levelLocked: row.levelLocked === true,
},
columns: {
levelId: {
title: "推广等级",
type: "dict-select",
dict: levelDict,
form: {
col: { span: 24 },
rules: [{ required: true, message: "请选择推广等级" }],
},
},
levelLocked: {
title: "锁定等级",
type: "dict-switch",
dict: dict({
data: [
{ label: "自动升级", value: false, color: "success" },
{ label: "锁定", value: true, color: "warning" },
],
}),
form: {
col: { span: 24 },
helper: "隐藏等级会自动锁定,不参与自动升级。",
},
},
},
async onSubmit(form: any) {
await api.SetUserLevel(form);
await crudExpose.doRefresh();
notification.success({ message: "推广等级已更新" });
},
});
}
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: {
width: 130,
fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
copy: { show: false },
remove: { show: false },
setLevel: {
text: "指定等级",
type: "link",
click: ({ row }) => openSetLevel(row),
},
},
},
columns: {
userId: { title: "用户ID", type: "number", search: { show: true }, column: { width: 100 } },
username: { title: "用户名", type: "text", search: { show: true }, column: { width: 160 } },
userDisplay: { title: "显示名称", type: "text", column: { width: 160 } },
enabled: {
title: "开通状态",
type: "dict-switch",
dict: dict({
data: [
{ label: "未开通", value: false, color: "default" },
{ label: "已开通", value: true, color: "success" },
],
}),
column: { width: 110, align: "center" },
},
levelId: {
title: "当前等级",
type: "dict-select",
dict: levelDict,
search: { show: true },
column: { width: 130 },
},
levelLocked: {
title: "升级方式",
type: "dict-switch",
dict: dict({
data: [
{ label: "自动升级", value: false, color: "success" },
{ label: "锁定", value: true, color: "warning" },
],
}),
column: { width: 110, align: "center" },
},
isHidden: {
title: "隐藏等级",
type: "dict-switch",
dict: dict({
data: [
{ label: "否", value: false, color: "default" },
{ label: "是", value: true, color: "warning" },
],
}),
column: { width: 100, align: "center", show: compute(({ row }) => row.levelId) },
},
commissionRate: { title: "佣金比例", type: "number", column: { width: 110, align: "center", cellRender: ({ value }) => (value == null ? "-" : `${value}%`) } },
promotionAmount: {
title: "累计推广金额",
type: "number",
column: { width: 140, component: { name: PriceInput, vModel: "modelValue", edit: false } },
},
createTime: { title: "开通时间", type: "datetime", column: { width: 170 } },
updateTime: { title: "更新时间", type: "datetime", column: { width: 170 } },
},
},
};
}
@@ -0,0 +1,169 @@
import { compute, CreateCrudOptionsProps, CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { useFormDialog } from "/@/use/use-dialog";
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog();
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWithdraws(query);
};
function renderWithdrawDetail(row: any) {
return (
<a-descriptions class={"w-full"} bordered column={2} size={"small"}>
<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.account || "-"}</a-descriptions-item>
<a-descriptions-item label="开户行名称">{row.bankName || "-"}</a-descriptions-item>
<a-descriptions-item label="收款二维码" span={2}>
{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>
);
}
async function approve(row: any) {
await openFormDialog({
title: "提现审核",
wrapper: {
width: 760,
},
body: () => renderWithdrawDetail(row),
onSubmit: async () => {
await api.ApproveWithdraw(row.id);
await crudExpose.doRefresh();
notification.success({ message: "已审核通过" });
},
});
}
async function reject(row: any) {
await openFormDialog({
title: "提现审核",
wrapper: {
width: 760,
},
initialForm: {
remark: "",
},
body: () => renderWithdrawDetail(row),
columns: {
remark: {
title: "拒绝理由",
type: "textarea",
form: {
col: {
span: 24,
},
component: {
name: "a-textarea",
vModel: "value",
rows: 4,
placeholder: "请填写拒绝理由",
},
rules: [{ required: true, message: "请填写拒绝理由" }],
},
},
},
async onSubmit(form: any) {
const remark = form.remark.trim();
if (!remark) {
notification.error({ message: "请填写拒绝理由" });
throw new Error("请填写拒绝理由");
}
await api.RejectWithdraw(row.id, remark);
await crudExpose.doRefresh();
notification.success({ message: "已拒绝并退回余额" });
},
});
}
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: {
width: 150,
fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
copy: { show: false },
remove: { show: false },
approve: {
text: "通过",
type: "link",
show: compute(({ row }) => row.status === "pending"),
click: ({ row }) => approve(row),
},
reject: {
text: "拒绝",
type: "link",
show: compute(({ row }) => row.status === "pending"),
click: ({ row }) => reject(row),
},
},
},
columns: {
userId: { title: "用户ID", type: "number", search: { show: true }, column: { width: 100 } },
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
userDisplay: { title: "用户名", type: "text", search: { show: true }, column: { width: 140 } },
status: {
title: "状态",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ label: "待审核", value: "pending", color: "warning" },
{ label: "已通过", value: "approved", color: "success" },
{ label: "已拒绝", value: "rejected", color: "error" },
],
}),
column: { width: 110 },
},
channel: {
title: "提现渠道",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
column: { width: 110 },
},
realName: { title: "真实姓名", type: "text", search: { show: true }, 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 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
},
},
};
}
@@ -0,0 +1,25 @@
<template>
<fs-page class="page-sys-invite-level">
<template #header>
<div class="title">推广等级</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-level";
defineOptions({ name: "SysInviteLevel" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
onMounted(() => {
crudExpose.doRefresh();
});
onActivated(() => {
crudExpose.doRefresh();
});
</script>
@@ -0,0 +1,75 @@
<template>
<fs-page class="page-sys-invite-setting">
<template #header>
<div class="title">激励计划设置</div>
</template>
<div class="page-body">
<a-form ref="formRef" :model="settings" :label-col="{ style: { width: '140px' } }" class="settings-form">
<a-form-item label="开启激励计划" name="enabled">
<a-switch v-model:checked="settings.enabled" />
</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-input-number v-model:value="settings.minWithdrawAmountYuan" :min="0" addon-after="" />
</a-form-item>
<a-form-item label="提现渠道" name="withdrawChannels">
<a-checkbox-group v-model:value="settings.withdrawChannels" :options="withdrawChannelOptions" />
</a-form-item>
<a-form-item label=" ">
<a-button type="primary" @click="saveSettings">保存设置</a-button>
</a-form-item>
</a-form>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { onMounted, reactive } from "vue";
import { notification } from "ant-design-vue";
import * as api from "./api";
import { util } from "/@/utils";
import { useSettingStore } from "/@/store/settings";
defineOptions({ name: "SysInviteCommissionSetting" });
const defaultAgreement = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。";
const settings = reactive<any>({ enabled: false, agreementContent: "", minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"] });
const withdrawChannelOptions = [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
];
async function loadSettings() {
const data: any = await api.GetSettings();
settings.enabled = !!data?.enabled;
settings.agreementContent = data?.agreementContent || defaultAgreement;
settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0);
settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"];
}
async function saveSettings() {
await api.SaveSettings({
enabled: settings.enabled,
agreementContent: settings.agreementContent || "",
minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0),
withdrawChannels: settings.withdrawChannels || [],
});
await useSettingStore().loadSysSettings();
notification.success({ message: "保存成功" });
}
onMounted(loadSettings);
</script>
<style lang="less">
.page-sys-invite-setting {
.page-body {
padding: 20px;
}
.settings-form {
max-width: 860px;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<fs-page class="page-sys-invite-user-level">
<template #header>
<div class="title">用户推广等级</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-user-level";
defineOptions({ name: "SysInviteUserLevel" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
onMounted(() => {
crudExpose.doRefresh();
});
onActivated(() => {
crudExpose.doRefresh();
});
</script>
@@ -0,0 +1,25 @@
<template>
<fs-page class="page-sys-invite-withdraw">
<template #header>
<div class="title">提现申请记录</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-withdraw";
defineOptions({ name: "SysInviteWithdraw" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
onMounted(() => {
crudExpose.doRefresh();
});
onActivated(() => {
crudExpose.doRefresh();
});
</script>
@@ -13,11 +13,13 @@ const props = withDefaults(
modelValue?: number;
edit?: boolean;
fontSize?: number;
zeroText?: string;
}>(),
{
modelValue: 0,
edit: false,
fontSize: 14,
zeroText: "¥0",
}
);
@@ -39,7 +41,7 @@ const priceValue = computed(() => {
const priceLabel = computed(() => {
if (priceValue.value === 0) {
return "免费";
return props.zeroText;
}
return `¥${priceValue.value}`;
});
@@ -174,6 +174,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
},
},
rebateAmount: {
title: "返利抵扣",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
thirdPartyPayAmount: {
title: "实付金额",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
status: {
title: "状态",
search: { show: true },
@@ -200,6 +224,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "支付宝", value: "alipay" },
{ label: "微信", value: "wxpay" },
{ label: "免费", value: "free" },
{ label: "返利余额", value: "rebate" },
],
}),
column: {
@@ -0,0 +1,89 @@
ALTER TABLE cd_trade ADD COLUMN rebate_amount bigint NOT NULL DEFAULT 0;
ALTER TABLE cd_trade ADD COLUMN third_party_pay_amount bigint NOT NULL DEFAULT 0;
CREATE TABLE `cd_invite_code`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`code` varchar(50),
`disabled` boolean NOT NULL DEFAULT false,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX `index_invite_code_user_id` ON `cd_invite_code` (`user_id`);
CREATE UNIQUE INDEX `index_invite_code_code` ON `cd_invite_code` (`code`);
CREATE TABLE `cd_invite_relation`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`inviter_user_id` bigint,
`invitee_user_id` bigint,
`invite_code` varchar(50),
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_invite_relation_inviter` ON `cd_invite_relation` (`inviter_user_id`);
CREATE UNIQUE INDEX `index_invite_relation_invitee` ON `cd_invite_relation` (`invitee_user_id`);
CREATE TABLE `cd_user_wallet`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`available_amount` bigint NOT NULL DEFAULT 0,
`frozen_amount` bigint NOT NULL DEFAULT 0,
`total_income_amount` bigint NOT NULL DEFAULT 0,
`total_consumed_amount` bigint NOT NULL DEFAULT 0,
`total_withdraw_amount` bigint NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX `index_user_wallet_user_id` ON `cd_user_wallet` (`user_id`);
CREATE TABLE `cd_invite_commission_log`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`amount` bigint,
`trade_id` bigint,
`invitee_user_id` bigint,
`consume_amount` bigint NOT NULL DEFAULT 0,
`remark` varchar(2048),
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_invite_log_user_id` ON `cd_invite_commission_log` (`user_id`);
CREATE TABLE `cd_user_wallet_log`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`type` varchar(50),
`amount` bigint,
`balance_after` bigint,
`trade_id` bigint,
`withdraw_id` bigint,
`remark` varchar(2048),
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_user_wallet_log_user_id` ON `cd_user_wallet_log` (`user_id`);
CREATE TABLE `cd_user_wallet_withdraw`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`amount` bigint,
`status` varchar(50),
`channel` varchar(50),
`real_name` varchar(100),
`account` varchar(200),
`bank_name` varchar(200),
`qr_code` varchar(512),
`audit_user_id` bigint,
`audit_remark` varchar(2048),
`audit_time` bigint,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_user_wallet_withdraw_user_id` ON `cd_user_wallet_withdraw` (`user_id`);
CREATE INDEX `index_user_wallet_withdraw_status` ON `cd_user_wallet_withdraw` (`status`);
@@ -0,0 +1,35 @@
ALTER TABLE cd_invite_commission_log ADD COLUMN level_id bigint NOT NULL DEFAULT 0;
ALTER TABLE cd_invite_commission_log ADD COLUMN commission_rate bigint NOT NULL DEFAULT 0;
CREATE TABLE `cd_invite_level`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`name` varchar(100),
`sort` bigint NOT NULL DEFAULT 0,
`min_amount` bigint NOT NULL DEFAULT 0,
`commission_rate` bigint NOT NULL DEFAULT 0,
`is_hidden` boolean NOT NULL DEFAULT false,
`disabled` boolean NOT NULL DEFAULT false,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_invite_level_sort` ON `cd_invite_level` (`sort`);
CREATE TABLE `cd_invite_user_plan`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`enabled` boolean NOT NULL DEFAULT false,
`level_id` bigint NOT NULL DEFAULT 0,
`level_locked` boolean NOT NULL DEFAULT false,
`agreement_time` bigint NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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`)
VALUES ('青铜', 10, 0, 10, false, false),
('白银', 20, 100000, 15, false, false),
('黄金', 30, 500000, 20, false, false),
('钻石', 40, 1000000, 30, false, false);
@@ -0,0 +1,89 @@
ALTER TABLE cd_trade ADD COLUMN rebate_amount bigint NOT NULL DEFAULT 0;
ALTER TABLE cd_trade ADD COLUMN third_party_pay_amount bigint NOT NULL DEFAULT 0;
CREATE TABLE "cd_invite_code"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"code" varchar(50),
"disabled" boolean NOT NULL DEFAULT (false),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_invite_code_user_id" ON "cd_invite_code" ("user_id");
CREATE UNIQUE INDEX "index_invite_code_code" ON "cd_invite_code" ("code");
CREATE TABLE "cd_invite_relation"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"inviter_user_id" bigint,
"invitee_user_id" bigint,
"invite_code" varchar(50),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_relation_inviter" ON "cd_invite_relation" ("inviter_user_id");
CREATE UNIQUE INDEX "index_invite_relation_invitee" ON "cd_invite_relation" ("invitee_user_id");
CREATE TABLE "cd_user_wallet"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"available_amount" bigint NOT NULL DEFAULT 0,
"frozen_amount" bigint NOT NULL DEFAULT 0,
"total_income_amount" bigint NOT NULL DEFAULT 0,
"total_consumed_amount" bigint NOT NULL DEFAULT 0,
"total_withdraw_amount" bigint NOT NULL DEFAULT 0,
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_user_wallet_user_id" ON "cd_user_wallet" ("user_id");
CREATE TABLE "cd_invite_commission_log"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"amount" bigint,
"trade_id" bigint,
"invitee_user_id" bigint,
"consume_amount" bigint NOT NULL DEFAULT 0,
"remark" varchar(2048),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_log_user_id" ON "cd_invite_commission_log" ("user_id");
CREATE TABLE "cd_user_wallet_log"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"type" varchar(50),
"amount" bigint,
"balance_after" bigint,
"trade_id" bigint,
"withdraw_id" bigint,
"remark" varchar(2048),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_log_user_id" ON "cd_user_wallet_log" ("user_id");
CREATE TABLE "cd_user_wallet_withdraw"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"amount" bigint,
"status" varchar(50),
"channel" varchar(50),
"real_name" varchar(100),
"account" varchar(200),
"bank_name" varchar(200),
"qr_code" varchar(512),
"audit_user_id" bigint,
"audit_remark" varchar(2048),
"audit_time" bigint,
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_withdraw_user_id" ON "cd_user_wallet_withdraw" ("user_id");
CREATE INDEX "index_user_wallet_withdraw_status" ON "cd_user_wallet_withdraw" ("status");
@@ -0,0 +1,35 @@
ALTER TABLE cd_invite_commission_log ADD COLUMN level_id bigint NOT NULL DEFAULT 0;
ALTER TABLE cd_invite_commission_log ADD COLUMN commission_rate bigint NOT NULL DEFAULT 0;
CREATE TABLE "cd_invite_level"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100),
"sort" bigint NOT NULL DEFAULT 0,
"min_amount" bigint NOT NULL DEFAULT 0,
"commission_rate" bigint NOT NULL DEFAULT 0,
"is_hidden" boolean NOT NULL DEFAULT (false),
"disabled" boolean NOT NULL DEFAULT (false),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_level_sort" ON "cd_invite_level" ("sort");
CREATE TABLE "cd_invite_user_plan"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"enabled" boolean NOT NULL DEFAULT (false),
"level_id" bigint NOT NULL DEFAULT 0,
"level_locked" boolean NOT NULL DEFAULT (false),
"agreement_time" bigint NOT NULL DEFAULT 0,
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
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")
VALUES ('青铜', 10, 0, 10, false, false),
('白银', 20, 100000, 15, false, false),
('黄金', 30, 500000, 20, false, false),
('钻石', 40, 1000000, 30, false, false);
@@ -0,0 +1,89 @@
ALTER TABLE cd_trade ADD COLUMN rebate_amount integer NOT NULL DEFAULT 0;
ALTER TABLE cd_trade ADD COLUMN third_party_pay_amount integer NOT NULL DEFAULT 0;
CREATE TABLE "cd_invite_code"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"code" varchar(50),
"disabled" boolean NOT NULL DEFAULT (false),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_invite_code_user_id" ON "cd_invite_code" ("user_id");
CREATE UNIQUE INDEX "index_invite_code_code" ON "cd_invite_code" ("code");
CREATE TABLE "cd_invite_relation"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"inviter_user_id" integer,
"invitee_user_id" integer,
"invite_code" varchar(50),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_relation_inviter" ON "cd_invite_relation" ("inviter_user_id");
CREATE UNIQUE INDEX "index_invite_relation_invitee" ON "cd_invite_relation" ("invitee_user_id");
CREATE TABLE "cd_user_wallet"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"available_amount" integer NOT NULL DEFAULT 0,
"frozen_amount" integer NOT NULL DEFAULT 0,
"total_income_amount" integer NOT NULL DEFAULT 0,
"total_consumed_amount" integer NOT NULL DEFAULT 0,
"total_withdraw_amount" integer NOT NULL DEFAULT 0,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_user_wallet_user_id" ON "cd_user_wallet" ("user_id");
CREATE TABLE "cd_invite_commission_log"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"amount" integer,
"trade_id" integer,
"invitee_user_id" integer,
"consume_amount" integer NOT NULL DEFAULT 0,
"remark" varchar(2048),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_log_user_id" ON "cd_invite_commission_log" ("user_id");
CREATE TABLE "cd_user_wallet_log"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"type" varchar(50),
"amount" integer,
"balance_after" integer,
"trade_id" integer,
"withdraw_id" integer,
"remark" varchar(2048),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_log_user_id" ON "cd_user_wallet_log" ("user_id");
CREATE TABLE "cd_user_wallet_withdraw"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"amount" integer,
"status" varchar(50),
"channel" varchar(50),
"real_name" varchar(100),
"account" varchar(200),
"bank_name" varchar(200),
"qr_code" varchar(512),
"audit_user_id" integer,
"audit_remark" varchar(2048),
"audit_time" integer,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_withdraw_user_id" ON "cd_user_wallet_withdraw" ("user_id");
CREATE INDEX "index_user_wallet_withdraw_status" ON "cd_user_wallet_withdraw" ("status");
@@ -0,0 +1,35 @@
ALTER TABLE cd_invite_commission_log ADD COLUMN level_id integer NOT NULL DEFAULT 0;
ALTER TABLE cd_invite_commission_log ADD COLUMN commission_rate integer NOT NULL DEFAULT 0;
CREATE TABLE "cd_invite_level"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"name" varchar(100),
"sort" integer NOT NULL DEFAULT 0,
"min_amount" integer NOT NULL DEFAULT 0,
"commission_rate" integer NOT NULL DEFAULT 0,
"is_hidden" boolean NOT NULL DEFAULT (false),
"disabled" boolean NOT NULL DEFAULT (false),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_level_sort" ON "cd_invite_level" ("sort");
CREATE TABLE "cd_invite_user_plan"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"enabled" boolean NOT NULL DEFAULT (false),
"level_id" integer NOT NULL DEFAULT 0,
"level_locked" boolean NOT NULL DEFAULT (false),
"agreement_time" integer NOT NULL DEFAULT 0,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
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")
VALUES ('青铜', 10, 0, 10, false, false),
('白银', 20, 100000, 15, false, false),
('黄金', 30, 500000, 20, false, false),
('钻石', 40, 1000000, 30, false, false);
@@ -0,0 +1,31 @@
ALTER TABLE cd_access ADD COLUMN subtype varchar(100);
CREATE INDEX "index_access_subtype" ON "cd_access" ("subtype");
CREATE TABLE "cd_dns_persist_record"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer NOT NULL,
"project_id" integer,
"domain" varchar(255) NOT NULL,
"main_domain" varchar(255) NOT NULL,
"ca_type" varchar(50) NOT NULL,
"acme_account_access_id" integer NOT NULL,
"account_uri" varchar(512) NOT NULL,
"host_record" varchar(255) NOT NULL,
"record_value" text NOT NULL,
"policy" varchar(50),
"persist_until" integer,
"status" varchar(50) NOT NULL DEFAULT 'pending',
"dns_provider_type" varchar(50),
"dns_provider_access" integer,
"record_res" text,
"disabled" integer NOT NULL DEFAULT 0,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_dns_persist_user_id" ON "cd_dns_persist_record" ("user_id");
CREATE INDEX "index_dns_persist_project_id" ON "cd_dns_persist_record" ("project_id");
CREATE INDEX "index_dns_persist_domain" ON "cd_dns_persist_record" ("domain");
CREATE INDEX "index_dns_persist_main_domain" ON "cd_dns_persist_record" ("main_domain");
CREATE INDEX "index_dns_persist_account" ON "cd_dns_persist_record" ("acme_account_access_id");
@@ -138,6 +138,5 @@ export class MainConfiguration {
logger.info('当前环境:', this.app.getEnv()); // prod
// throw new Error("address family not supported")
}
}
@@ -64,6 +64,7 @@ export class LoginController extends BaseController {
mobile: body.mobile,
smsCode: body.smsCode,
randomStr: body.randomStr,
inviteCode: body.inviteCode,
});
this.writeTokenCookie(token);
@@ -3,6 +3,7 @@ import { BaseController, Constants, SysSettingsService } from '@certd/lib-server
import { RegisterType, UserService } from '../../../modules/sys/authority/service/user-service.js';
import { CodeService } from '../../../modules/basic/service/code-service.js';
import { checkComm, checkPlus } from '@certd/plus-core';
import { InviteService } from '@certd/commercial-core';
export type RegisterReq = {
type: RegisterType;
@@ -14,6 +15,7 @@ export type RegisterReq = {
validateCode: string;
captcha:any;
inviteCode?: string;
};
/**
@@ -29,6 +31,9 @@ export class RegisterController extends BaseController {
@Inject()
sysSettingsService: SysSettingsService;
@Inject()
inviteService: InviteService;
@Post('/register', { description: Constants.per.guest })
public async register(
@Body(ALL)
@@ -53,10 +58,13 @@ export class RegisterController extends BaseController {
}
await this.codeService.checkCaptcha(body.captcha,{remoteIp});
const newUser = await this.userService.register(body.type, {
const registerUser = {
username: body.username,
password: body.password,
} as any);
} as any;
const newUser = await this.userService.register(body.type, registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, body.inviteCode, txManager);
});
return this.ok(newUser);
} else if (body.type === 'mobile') {
if (sysPublicSettings.mobileRegisterEnabled === false) {
@@ -70,12 +78,15 @@ export class RegisterController extends BaseController {
smsCode: body.validateCode,
throwError: true,
});
const newUser = await this.userService.register(body.type, {
const registerUser = {
username: body.username,
phoneCode: body.phoneCode,
mobile: body.mobile,
password: body.password,
} as any);
} as any;
const newUser = await this.userService.register(body.type, registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, body.inviteCode, txManager);
});
return this.ok(newUser);
} else if (body.type === 'email') {
if (sysPublicSettings.emailRegisterEnabled === false) {
@@ -87,11 +98,14 @@ export class RegisterController extends BaseController {
validateCode: body.validateCode,
throwError: true,
});
const newUser = await this.userService.register(body.type, {
const registerUser = {
username: body.username,
email: body.email,
password: body.password,
} as any);
} as any;
const newUser = await this.userService.register(body.type, registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, body.inviteCode, txManager);
});
return this.ok(newUser);
}
}
@@ -11,6 +11,7 @@ import {
SysSuiteSetting
} from "@certd/lib-server";
import { AppKey, getPlusInfo, isComm } from "@certd/plus-core";
import { SysInviteCommissionSetting } from "@certd/commercial-core";
import { cloneDeep } from "lodash-es";
import { getVersion } from "../../utils/version.js";
import { http } from "@certd/basic";
@@ -57,6 +58,16 @@ export class BasicSettingsController extends BaseController {
};
}
public async getInviteSetting() {
if (!isComm()) {
return { enabled: false };
}
const setting = await this.sysSettingsService.getSetting<SysInviteCommissionSetting>(SysInviteCommissionSetting);
return {
enabled: setting.enabled,
};
}
public async getSiteEnv() {
const env: SysSiteEnv = {
agent: this.agentConfig
@@ -92,6 +103,7 @@ export class BasicSettingsController extends BaseController {
const plusInfo = await this.plusInfo();
const headerMenus = await this.getHeaderMenus();
const suiteSetting = await this.getSuiteSetting();
const inviteSetting = await this.getInviteSetting();
const version = await getVersion();
return this.ok({
sysPublic,
@@ -101,6 +113,7 @@ export class BasicSettingsController extends BaseController {
plusInfo,
headerMenus,
suiteSetting,
inviteSetting,
app: {
time: new Date().getTime(),
version
@@ -0,0 +1,91 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
import { Constants, CrudController } from "@certd/lib-server";
import { ApiTags } from "@midwayjs/swagger";
import { DnsPersistRecordService } from "../../../modules/cert/service/dns-persist-record-service.js";
@Provide()
@Controller("/api/cert/dns-persist")
@ApiTags(["cert"])
export class DnsPersistRecordController extends CrudController<DnsPersistRecordService> {
@Inject()
service: DnsPersistRecordService;
getService(): DnsPersistRecordService {
return this.service;
}
@Post("/page", { description: Constants.per.authOnly, summary: "查询DNS持久验证记录分页列表" })
async page(@Body(ALL) body: any) {
const { projectId, userId } = await this.getProjectUserIdRead();
body.query = body.query ?? {};
body.query.projectId = projectId;
body.query.userId = userId;
return super.page(body);
}
@Post("/add", { description: Constants.per.authOnly, summary: "添加DNS持久验证记录" })
async add(@Body(ALL) bean: any) {
const { projectId, userId } = await this.getProjectUserIdWrite();
bean.projectId = projectId;
bean.userId = userId;
return super.add(bean);
}
@Post("/update", { description: Constants.per.authOnly, summary: "更新DNS持久验证记录" })
async update(@Body(ALL) bean: any) {
await this.checkOwner(this.getService(), bean.id, "write");
delete bean.userId;
delete bean.projectId;
return super.update(bean);
}
@Post("/info", { description: Constants.per.authOnly, summary: "查询DNS持久验证记录详情" })
async info(@Query("id") id: number) {
await this.checkOwner(this.getService(), id, "read");
return super.info(id);
}
@Post("/delete", { description: Constants.per.authOnly, summary: "删除DNS持久验证记录" })
async delete(@Query("id") id: number) {
await this.checkOwner(this.getService(), id, "write");
await this.service.delete(id as any);
return this.ok({
message: this.service.lastDeleteMessage,
});
}
@Post("/build", { description: Constants.per.authOnly, summary: "生成DNS持久验证记录值" })
async build(@Body(ALL) body: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
const { projectId, userId } = await this.getProjectUserIdRead();
return this.ok(await this.service.buildRecord({ ...body, userId, projectId }));
}
@Post("/getByDomain", { description: Constants.per.authOnly, summary: "根据域名获取或创建DNS持久验证记录" })
async getByDomain(@Body(ALL) body: { domain: string; caType?: string; acmeAccountAccessId?: number; commonAcmeAccountAccessId?: number; wildcard?: boolean; persistUntil?: number; createOnNotFound?: boolean }) {
const { projectId, userId } = await this.getProjectUserIdWrite();
return this.ok(await this.service.getByDomain({ ...body, userId, projectId }));
}
@Post("/check", { description: Constants.per.authOnly, summary: "校验DNS持久验证记录" })
async check(@Body(ALL) body: { hostRecord: string; recordValue: string }) {
return this.ok(await this.service.checkRecord(body));
}
@Post("/verify", { description: Constants.per.authOnly, summary: "验证DNS持久验证记录" })
async verify(@Body(ALL) body: { id: number }) {
await this.checkOwner(this.getService(), body.id, "write");
return this.ok(await this.service.verify(body.id));
}
@Post("/triggerVerify", { description: Constants.per.authOnly, summary: "后台验证DNS持久验证记录" })
async triggerVerify(@Body(ALL) body: { id: number }) {
await this.checkOwner(this.getService(), body.id, "write");
return this.ok(await this.service.triggerVerify(body.id));
}
@Post("/createTxt", { description: Constants.per.authOnly, summary: "一键创建DNS持久验证TXT记录" })
async createTxt(@Body(ALL) body: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
await this.checkOwner(this.getService(), body.id, "write");
return this.ok(await this.service.createDnsTxt(body));
}
}
@@ -44,14 +44,28 @@ describe("AutoFix", () => {
return true;
},
} as any;
autoFix.legacyAcmeAccountAccessFix = {
async init() {
calls.push("legacy-acme");
return true;
},
} as any;
autoFix.commonEabToAcmeAccountFix = {
async init() {
calls.push("common-eab-acme");
return true;
},
} as any;
await autoFix.init();
assert.deepEqual(calls, ["google", "cert", "suite"]);
assert.deepEqual(calls, ["google", "cert", "suite", "legacy-acme", "common-eab-acme"]);
assert.equal(savedSetting.fixed["google-common-eab-account-key"], true);
assert.equal(savedSetting.fixed["oauth-subtype-bound-type"], true);
assert.equal(savedSetting.fixed["cert-info-wildcard-domain-count"], true);
assert.equal(savedSetting.fixed["suite-content-wildcard-domain-count"], true);
assert.equal(savedSetting.fixed["legacy-acme-account-access"], true);
assert.equal(savedSetting.fixed["common-eab-to-acme-account"], true);
});
it("initializes missing fixed map", async () => {
@@ -66,6 +80,8 @@ describe("AutoFix", () => {
autoFix.oauthSubtypeBoundTypeFix = { async init() {} } as any;
autoFix.certInfoWildcardDomainCountFix = { async init() {} } as any;
autoFix.suiteContentWildcardDomainCountFix = { async init() {} } as any;
autoFix.legacyAcmeAccountAccessFix = { async init() {} } as any;
autoFix.commonEabToAcmeAccountFix = { async init() {} } as any;
await autoFix.init();
});
@@ -4,6 +4,8 @@ import { GoogleCommonEabAccountKeyFix } from "./google-common-eab-account-key-fi
import { OauthSubtypeBoundTypeFix } from "./oauth-subtype-bound-type-fix.js";
import { CertInfoWildcardDomainCountFix } from "./cert-info-wildcard-domain-count-fix.js";
import { SuiteContentWildcardDomainCountFix } from "./suite-content-wildcard-domain-count-fix.js";
import { LegacyAcmeAccountAccessFix } from "./legacy-acme-account-access-fix.js";
import { CommonEabToAcmeAccountFix } from "./common-eab-to-acme-account-fix.js";
type AutoFixTask = {
key: string;
@@ -30,6 +32,12 @@ export class AutoFix {
@Inject()
suiteContentWildcardDomainCountFix: SuiteContentWildcardDomainCountFix;
@Inject()
legacyAcmeAccountAccessFix: LegacyAcmeAccountAccessFix;
@Inject()
commonEabToAcmeAccountFix: CommonEabToAcmeAccountFix;
async init() {
const setting = await this.sysSettingsService.getSetting<SysAutoFixSetting>(SysAutoFixSetting);
setting.fixed = setting.fixed || {};
@@ -50,6 +58,14 @@ export class AutoFix {
key: "suite-content-wildcard-domain-count",
fix: this.suiteContentWildcardDomainCountFix,
},
{
key: "legacy-acme-account-access",
fix: this.legacyAcmeAccountAccessFix,
},
{
key: "common-eab-to-acme-account",
fix: this.commonEabToAcmeAccountFix,
},
];
for (const task of tasks) {
@@ -0,0 +1,135 @@
import assert from "assert";
import { buildLegacyCommonEabAccountStorageWhere, CommonEabToAcmeAccountFix, parseEabAccountKey } from "./common-eab-to-acme-account-fix.js";
import { AcmeService } from "../../../plugins/plugin-cert/plugin/cert-plugin/acme.js";
describe("CommonEabToAcmeAccountFix", () => {
it("parses legacy EAB account key payload", () => {
assert.equal(
parseEabAccountKey(
JSON.stringify({
kid: "kid-1",
privateKey: "private-key",
})
),
"private-key"
);
});
it("builds legacy common EAB account storage query", () => {
assert.deepEqual(buildLegacyCommonEabAccountStorageWhere("google", 12), {
userId: 0,
scope: "user",
namespace: "0",
key: "acme.config.google.access.12",
});
});
it("creates common acme account from common eab and legacy storage", async () => {
let addParam: any;
const fix = new CommonEabToAcmeAccountFix();
fix.accessService = {
async getAccessById(id: number) {
assert.equal(id, 12);
return {
accountKey: JSON.stringify({
privateKey: "private-key",
}),
email: "common@example.com",
};
},
async findOne() {
return null;
},
async add(param: any) {
addParam = param;
return { id: 99 };
},
} as any;
fix.storageService = {
getRepository() {
return {
async findOne(options: any) {
assert.deepEqual(options.where, buildLegacyCommonEabAccountStorageWhere("google", 12));
return {
value: JSON.stringify({
value: {
accountUrl: "https://example.com/acct/1",
},
}),
};
},
};
},
} as any;
const id = await fix.createCommonAcmeAccountFromEab("google", 12);
assert.equal(id, 99);
assert.equal(addParam.userId, 0);
assert.equal(addParam.type, "acmeAccount");
const setting = JSON.parse(addParam.setting);
const account = JSON.parse(setting.account);
assert.equal(account.accountKey, "private-key");
assert.equal(account.accountUri, "https://example.com/acct/1");
});
it("creates common acme account by resolving account uri from eab private key", async () => {
const original = AcmeService.prototype.getAcmeClient;
const calls: string[] = [];
AcmeService.prototype.getAcmeClient = async function (email: string) {
calls.push(email);
return {
getAccountUrl() {
return "https://example.com/acct/generated";
},
} as any;
};
try {
let addParam: any;
const fix = new CommonEabToAcmeAccountFix();
fix.accessService = {
async getAccessById(id: number) {
assert.equal(id, 12);
return {
id: 12,
kid: "kid-1",
hmacKey: "hmac-1",
accountKey: JSON.stringify({
kid: "kid-1",
privateKey: "private-key",
}),
email: "common@example.com",
};
},
async findOne() {
return null;
},
async add(param: any) {
addParam = param;
return { id: 100 };
},
} as any;
fix.storageService = {
getRepository() {
return {
async findOne() {
return null;
},
};
},
} as any;
const id = await fix.createCommonAcmeAccountFromEab("google", 12);
assert.equal(id, 100);
assert.deepEqual(calls, ["common@example.com"]);
const setting = JSON.parse(addParam.setting);
const account = JSON.parse(setting.account);
assert.equal(account.accountKey, "private-key");
assert.equal(account.accountUri, "https://example.com/acct/generated");
} finally {
AcmeService.prototype.getAcmeClient = original;
}
});
});
@@ -0,0 +1,188 @@
import { logger } from "@certd/basic";
import { AccessService } from "@certd/lib-server";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { PluginConfigService } from "../../plugin/service/plugin-config-service.js";
import { StorageService } from "../../pipeline/service/storage-service.js";
import { AcmeService } from "../../../plugins/plugin-cert/plugin/cert-plugin/acme.js";
import { buildAcmeAccountSetting, LegacyAcmeAccountConfig } from "./legacy-acme-account-access-fix.js";
import { parseStorageValue } from "./google-common-eab-account-key-fix.js";
const COMMON_EAB_TO_ACME_ACCOUNT_FIELDS = [
{
caType: "google",
eabField: "googleCommonEabAccessId",
acmeField: "googleCommonAcmeAccountAccessId",
},
{
caType: "zerossl",
eabField: "zerosslCommonEabAccessId",
acmeField: "zerosslCommonAcmeAccountAccessId",
},
{
caType: "sslcom",
eabField: "sslcomCommonEabAccessId",
acmeField: "sslcomCommonAcmeAccountAccessId",
},
{
caType: "litessl",
eabField: "litesslCommonEabAccessId",
acmeField: "litesslCommonAcmeAccountAccessId",
},
];
export function parseEabAccountKey(accountKey?: string) {
if (!accountKey) {
return null;
}
try {
const parsed = JSON.parse(accountKey);
return parsed?.privateKey || parsed?.accountKey || parsed?.key || accountKey;
} catch {
return accountKey;
}
}
export function buildLegacyCommonEabAccountStorageWhere(caType: string, accessId: number) {
return {
userId: 0,
scope: "user",
namespace: "0",
key: `acme.config.${caType}.access.${accessId}`,
};
}
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class CommonEabToAcmeAccountFix {
@Inject()
pluginConfigService: PluginConfigService;
@Inject()
accessService: AccessService;
@Inject()
storageService: StorageService;
async init() {
try {
const certApplyConfig = await this.pluginConfigService.getPluginConfig({
name: "CertApply",
type: "builtIn",
});
const input = certApplyConfig.sysSetting.input || {};
let changed = false;
for (const item of COMMON_EAB_TO_ACME_ACCOUNT_FIELDS) {
if (input[item.acmeField]) {
continue;
}
const eabAccessId = input[item.eabField];
if (!eabAccessId) {
continue;
}
const acmeAccessId = await this.createCommonAcmeAccountFromEab(item.caType, eabAccessId);
if (acmeAccessId) {
input[item.acmeField] = acmeAccessId;
changed = true;
}
}
if (changed) {
await this.pluginConfigService.savePluginConfig({
name: "CertApply",
disabled: certApplyConfig.disabled,
sysSetting: {
...certApplyConfig.sysSetting,
input,
},
});
}
return true;
} catch (e: any) {
logger.error("公共EAB迁移为公共ACME账号失败", e);
return false;
}
}
async createCommonAcmeAccountFromEab(caType: string, eabAccessId: number) {
const eabAccess = await this.accessService.getAccessById(eabAccessId, false);
const privateKey = parseEabAccountKey(eabAccess.accountKey);
const accountConfig = await this.getLegacyCommonEabAccountConfig(caType, eabAccessId);
const accountUri = await this.resolveAccountUriByPrivateKey(caType, eabAccess, accountConfig?.accountUri || accountConfig?.accountUrl);
if (!privateKey || !accountUri) {
logger.info(`公共${caType} EAB缺少可迁移的accountKey或无法获取accountUri,跳过生成公共ACME账号`);
return null;
}
const email = eabAccess.email || `${caType}@common.certd.local`;
const exists = await this.accessService.findOne({
where: {
userId: 0,
projectId: null,
type: "acmeAccount",
subtype: caType,
name: `公共${caType} ACME账号`,
} as any,
});
if (exists) {
return exists.id;
}
const setting = buildAcmeAccountSetting({
caType,
email,
config: {
privateKey,
accountUri,
},
});
const { id } = await this.accessService.add({
userId: 0,
projectId: null,
type: "acmeAccount",
name: `公共${caType} ACME账号`,
setting: JSON.stringify(setting),
});
logger.info(`已根据公共${caType} EAB生成公共ACME账号,accessId=${id}`);
return id;
}
async resolveAccountUriByPrivateKey(caType: string, eabAccess: any, accountUri?: string | null) {
if (accountUri) {
return accountUri;
}
const privateKey = parseEabAccountKey(eabAccess.accountKey);
if (!privateKey || !eabAccess?.kid) {
return null;
}
const acmeService = new AcmeService({
userId: 0,
userContext: {
async getObj() {
return null;
},
async setObj() {},
} as any,
logger: logger as any,
sslProvider: caType as any,
eab: {
id: eabAccess.id || eabAccess.accessId || eabAccess.eabAccessId || 0,
kid: eabAccess.kid,
hmacKey: eabAccess.hmacKey,
accountKey: JSON.stringify({
kid: eabAccess.kid,
privateKey,
}),
} as any,
domainParser: {} as any,
privateKeyType: "rsa_2048",
});
const client = await acmeService.getAcmeClient(eabAccess.email || `${caType}@common.certd.local`);
return client.getAccountUrl() || null;
}
async getLegacyCommonEabAccountConfig(caType: string, accessId: number): Promise<LegacyAcmeAccountConfig | null> {
const repository = this.storageService.getRepository();
const record = await repository.findOne({
where: buildLegacyCommonEabAccountStorageWhere(caType, accessId),
});
return parseStorageValue(record?.value) as LegacyAcmeAccountConfig;
}
}
@@ -0,0 +1,48 @@
import assert from "assert";
import { buildAcmeAccountAccessName, buildAcmeAccountSetting, maskAcmeAccountEmail, parseLegacyAcmeStorageKey } from "./legacy-acme-account-access-fix.js";
describe("LegacyAcmeAccountAccessFix", () => {
it("parses legacy storage account key", () => {
assert.deepEqual(parseLegacyAcmeStorageKey("acme.config.letsencrypt.user@example.com"), {
caType: "letsencrypt",
email: "user@example.com",
});
});
it("skips EAB access cache keys", () => {
assert.equal(parseLegacyAcmeStorageKey("acme.config.google.access.12"), null);
});
it("builds acme account access setting from legacy config", () => {
const setting = buildAcmeAccountSetting({
caType: "letsencrypt",
email: "user@example.com",
config: {
key: "private-key",
accountUrl: "https://example.com/acct/1",
},
});
assert.equal(setting.caType, "letsencrypt");
const account = JSON.parse(setting.account);
assert.equal(account.accountKey, "private-key");
assert.equal(account.accountUri, "https://example.com/acct/1");
});
it("builds masked acme account access name", () => {
assert.equal(maskAcmeAccountEmail("xiaojunnuo@qq.com"), "xi*******qq.com");
assert.equal(buildAcmeAccountAccessName("zerossl", "xiaojunnuo@qq.com"), "zerossl-acme-xi*******qq.com");
});
it("skips incomplete legacy config", () => {
const setting = buildAcmeAccountSetting({
caType: "letsencrypt",
email: "user@example.com",
config: {
key: "private-key",
},
});
assert.equal(setting, null);
});
});
@@ -0,0 +1,131 @@
import { logger } from "@certd/basic";
import { AccessService } from "@certd/lib-server";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { Like } from "typeorm";
import { StorageService } from "../../pipeline/service/storage-service.js";
import { parseStorageValue } from "./google-common-eab-account-key-fix.js";
export type LegacyAcmeAccountConfig = {
key?: string;
privateKey?: string;
accountKey?: string;
accountUrl?: string;
accountUri?: string;
};
export function parseLegacyAcmeStorageKey(key: string) {
const match = /^acme\.config\.([^.]+)\.(.+)$/.exec(key);
if (!match) {
return null;
}
if (match[2].startsWith("access.")) {
return null;
}
return {
caType: match[1],
email: match[2],
};
}
export function buildAcmeAccountSetting(req: { caType: string; email: string; config: LegacyAcmeAccountConfig }) {
const accountKey = req.config.privateKey || req.config.key || req.config.accountKey;
const accountUri = req.config.accountUri || req.config.accountUrl;
if (!accountKey || !accountUri) {
return null;
}
return {
caType: req.caType,
email: req.email,
account: JSON.stringify({
accountKey,
accountUri,
caType: req.caType,
email: req.email,
directoryUrl: "",
migratedFrom: "legacy-storage",
}),
};
}
export function maskAcmeAccountEmail(email: string) {
if (!email) {
return "unknown";
}
const atIndex = email.indexOf("@");
if (atIndex < 0) {
return email.length <= 2 ? `${email[0] || ""}*******` : `${email.substring(0, 2)}*******`;
}
const name = email.substring(0, atIndex);
const domain = email.substring(atIndex + 1);
const prefix = name.substring(0, Math.min(2, name.length));
return `${prefix}*******${domain}`;
}
export function buildAcmeAccountAccessName(caType: string, email: string) {
return `${caType}-acme-${maskAcmeAccountEmail(email)}`;
}
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class LegacyAcmeAccountAccessFix {
@Inject()
storageService: StorageService;
@Inject()
accessService: AccessService;
async init() {
try {
const repository = this.storageService.getRepository();
const records = await repository.find({
where: {
scope: "user",
key: Like("acme.config.%"),
},
});
let count = 0;
for (const record of records) {
const parsedKey = parseLegacyAcmeStorageKey(record.key);
if (!parsedKey) {
continue;
}
const config = parseStorageValue(record.value) as LegacyAcmeAccountConfig;
const setting = buildAcmeAccountSetting({
...parsedKey,
config,
});
if (!setting) {
continue;
}
const name = buildAcmeAccountAccessName(parsedKey.caType, parsedKey.email);
const exists = await this.accessService.findOne({
where: {
userId: record.userId,
projectId: record.projectId,
type: "acmeAccount",
subtype: parsedKey.caType,
name,
} as any,
});
if (exists) {
continue;
}
await this.accessService.add({
userId: record.userId,
projectId: record.projectId,
type: "acmeAccount",
subtype: parsedKey.caType,
name,
setting: JSON.stringify(setting),
});
count++;
}
logger.info(`旧ACME账号迁移完成,生成${count}个ACME账号授权`);
return true;
} catch (e: any) {
logger.error("旧ACME账号迁移失败", e);
return false;
}
}
}
@@ -0,0 +1,61 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("cd_dns_persist_record")
export class DnsPersistRecordEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: "user_id" })
userId: number;
@Column({ name: "project_id", nullable: true })
projectId: number;
@Column({ length: 255 })
domain: string;
@Column({ name: "main_domain", length: 255 })
mainDomain: string;
@Column({ name: "ca_type", length: 50 })
caType: string;
@Column({ name: "acme_account_access_id" })
acmeAccountAccessId: number;
@Column({ name: "account_uri", length: 512 })
accountUri: string;
@Column({ name: "host_record", length: 255 })
hostRecord: string;
@Column({ name: "record_value", type: "text" })
recordValue: string;
@Column({ length: 50, nullable: true })
policy: string;
@Column({ name: "persist_until", nullable: true })
persistUntil: number;
@Column({ length: 50 })
status: string;
@Column({ name: "dns_provider_type", length: 50, nullable: true })
dnsProviderType: string;
@Column({ name: "dns_provider_access", nullable: true })
dnsProviderAccess: number;
@Column({ name: "record_res", type: "text", nullable: true })
recordRes: string;
@Column()
disabled: boolean;
@Column({ name: "create_time", default: () => "CURRENT_TIMESTAMP" })
createTime: Date;
@Column({ name: "update_time", default: () => "CURRENT_TIMESTAMP" })
updateTime: Date;
}
@@ -0,0 +1,315 @@
import assert from "assert";
import { buildDnsPersistRecordValue, DnsPersistRecordService } from "./dns-persist-record-service.js";
describe("DnsPersistRecordService", () => {
it("builds dns-persist-01 record value", () => {
const value = buildDnsPersistRecordValue({
accountUri: "https://example.com/acct/1",
wildcard: true,
persistUntil: 1893456000,
});
assert.equal(value, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard; persistUntil=1893456000");
});
it("builds validation host from wildcard domain", async () => {
const service = new DnsPersistRecordService();
const record = await service.buildRecord({
domain: "*.example.com",
accountUri: "https://example.com/acct/1",
wildcard: true,
});
assert.equal(record.hostRecord, "_validation-persist");
});
it("builds relative validation host from subdomain", async () => {
const service = new DnsPersistRecordService();
const record = await service.buildRecord({
domain: "aaa.handsfree.work",
accountUri: "https://example.com/acct/1",
});
assert.equal(record.hostRecord, "_validation-persist.aaa");
assert.equal(record.mainDomain, "handsfree.work");
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
});
it("builds dns-persist record from acme account access", async () => {
const service = new DnsPersistRecordService();
(service as any).accessService = {
async getAccessById(id: number, checkUser: boolean, userId?: number) {
assert.equal(id, 12);
assert.equal(checkUser, true);
assert.equal(userId, 1);
return {
account: JSON.stringify({
accountKey: "private-key",
accountUri: "https://example.com/acct/1",
caType: "zerossl",
}),
};
},
};
const record = await service.buildRecordByAcmeAccount({
domain: "*.example.com",
caType: "zerossl",
acmeAccountAccessId: 12,
userId: 1,
projectId: 2,
});
assert.equal(record.domain, "example.com");
assert.equal(record.caType, "zerossl");
assert.equal(record.acmeAccountAccessId, 12);
assert.equal(record.accountUri, "https://example.com/acct/1");
assert.equal(record.hostRecord, "_validation-persist");
assert.equal(record.mainDomain, "example.com");
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.policy, "wildcard");
assert.equal(record.status, "pending");
});
it("rejects mismatched ca type", async () => {
const service = new DnsPersistRecordService();
(service as any).accessService = {
async getAccessById() {
return {
account: JSON.stringify({
accountKey: "private-key",
accountUri: "https://example.com/acct/1",
caType: "google",
}),
};
},
};
await assert.rejects(
() =>
service.buildRecordByAcmeAccount({
domain: "example.com",
caType: "zerossl",
acmeAccountAccessId: 12,
userId: 1,
}),
/颁发机构不匹配/
);
});
it("returns full local record after add and triggers auto create hook", async () => {
const service = new DnsPersistRecordService();
let saved: any = null;
let autoCreateId: number | null = null;
(service as any).repository = {
async save(param: any) {
param.id = 77;
saved = { ...param };
},
async findOneBy(where: any) {
return where.id === 77 ? saved : null;
},
async findOne() {
return null;
},
};
(service as any).accessService = {
async getAccessById() {
return {
account: JSON.stringify({
accountKey: "private-key",
accountUri: "https://example.com/acct/1",
caType: "letsencrypt",
}),
};
},
};
(service as any).tryAutoCreateDnsTxt = async (id: number) => {
autoCreateId = id;
};
const record: any = await service.add({
domain: "example.com",
mainDomain: "example.com",
caType: "letsencrypt",
acmeAccountAccessId: 1,
userId: 1,
projectId: 2,
});
assert.equal(autoCreateId, 77);
assert.equal(record.id, 77);
assert.equal(record.hostRecord, "_validation-persist");
assert.equal(record.mainDomain, "example.com");
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.status, "pending");
});
it("reuses existing record for the same domain and acme account", async () => {
const service = new DnsPersistRecordService();
let saveCount = 0;
const exists = {
id: 88,
domain: "example.com",
mainDomain: "example.com",
caType: "letsencrypt",
acmeAccountAccessId: 1,
userId: 1,
projectId: 2,
hostRecord: "_validation-persist",
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
policy: "wildcard",
status: "valid",
};
(service as any).repository = {
async save() {
saveCount++;
},
async findOne(options: any) {
return options.where.domain === "example.com" && options.where.acmeAccountAccessId === 1 ? exists : null;
},
};
(service as any).accessService = {
async getAccessById() {
return {
account: JSON.stringify({
accountKey: "private-key",
accountUri: "https://example.com/acct/1",
caType: "letsencrypt",
}),
};
},
};
const record: any = await service.add({
domain: "example.com",
mainDomain: "example.com",
caType: "letsencrypt",
acmeAccountAccessId: 1,
userId: 1,
projectId: 2,
});
assert.equal(saveCount, 0);
assert.equal(record.id, 88);
assert.equal(record.status, "valid");
});
it("upgrades existing non-wildcard record to wildcard and pending", async () => {
const service = new DnsPersistRecordService();
let saved: any = {
id: 89,
domain: "example.com",
mainDomain: "example.com",
caType: "letsencrypt",
acmeAccountAccessId: 1,
userId: 1,
projectId: 2,
hostRecord: "_validation-persist",
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1",
policy: null,
status: "valid",
recordRes: JSON.stringify({ old: true }),
};
(service as any).repository = {
async save(param: any) {
saved = { ...saved, ...param };
},
async findOne(options: any) {
return options.where.domain === "example.com" && options.where.acmeAccountAccessId === 1 ? saved : null;
},
async findOneBy(where: any) {
return where.id === 89 ? saved : null;
},
};
(service as any).accessService = {
async getAccessById() {
return {
account: JSON.stringify({
accountKey: "private-key",
accountUri: "https://example.com/acct/1",
caType: "letsencrypt",
}),
};
},
};
const record: any = await service.add({
domain: "example.com",
caType: "letsencrypt",
acmeAccountAccessId: 1,
userId: 1,
projectId: 2,
});
assert.equal(record.id, 89);
assert.equal(record.policy, "wildcard");
assert.equal(record.status, "pending");
assert.equal(record.mainDomain, "example.com");
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.recordRes, null);
});
it("returns manual cleanup hint when deleting record", async () => {
const service = new DnsPersistRecordService();
let deletedIds: any = null;
(service as any).repository = {
async findOneBy(where: any) {
return where.id === 90
? {
id: 90,
domain: "example.com",
mainDomain: "example.com",
hostRecord: "_validation-persist",
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
recordRes: null,
}
: null;
},
async delete(ids: any) {
deletedIds = ids;
},
};
await service.delete([90]);
assert.deepEqual(deletedIds.id._value, [90]);
assert.match(service.lastDeleteMessage, /请到域名供应商删除TXT记录/);
assert.match(service.lastDeleteMessage, /_validation-persist/);
});
it("triggers dns-persist verification asynchronously", async () => {
const service = new DnsPersistRecordService();
const savedStatuses: string[] = [];
let saved: any = {
id: 91,
domain: "example.com",
mainDomain: "example.com",
hostRecord: "_validation-persist",
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
status: "pending",
};
(service as any).repository = {
async findOneBy(where: any) {
return where.id === 91 ? saved : null;
},
async save(param: any) {
saved = { ...saved, ...param };
savedStatuses.push(saved.status);
},
};
(service as any).checkRecord = async () => true;
const triggered = await service.triggerVerify(91);
assert.equal(triggered, true);
assert.equal(saved.status, "validating");
await new Promise(resolve => setTimeout(resolve, 10));
assert.deepEqual(savedStatuses, ["validating", "valid"]);
assert.equal(saved.status, "valid");
});
});
@@ -0,0 +1,438 @@
import { BaseService } from "@certd/lib-server";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { In, Repository } from "typeorm";
import { createChallengeFn } from "@certd/acme-client";
import { AccessService } from "@certd/lib-server";
import { http, logger, utils } from "@certd/basic";
import { createDnsProvider, DomainParser } from "@certd/plugin-lib";
import { DnsPersistRecordEntity } from "../entity/dns-persist-record.js";
import { TaskServiceBuilder } from "../../pipeline/service/getter/task-service-getter.js";
import { DomainEntity } from "../entity/domain.js";
export function buildDnsPersistRecordValue(req: { issuer?: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
const parts = [req.issuer || "letsencrypt.org", `accounturi=${req.accountUri}`];
if (req.wildcard !== false) {
parts.push("policy=wildcard");
}
if (req.persistUntil) {
parts.push(`persistUntil=${req.persistUntil}`);
}
return parts.join("; ");
}
export type DnsPersistRecordBuildReq = {
domain: string;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
wildcard?: boolean;
persistUntil?: number;
};
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity> {
@InjectEntityModel(DnsPersistRecordEntity)
repository: Repository<DnsPersistRecordEntity>;
@InjectEntityModel(DomainEntity)
domainRepository: Repository<DomainEntity>;
@Inject()
accessService: AccessService;
@Inject()
taskServiceBuilder: TaskServiceBuilder;
lastDeleteMessage = "";
//@ts-ignore
getRepository() {
return this.repository;
}
normalizeDomain(domain: string) {
return domain?.replace(/^\*\./, "");
}
private async parseMainDomain(domain: string, userId?: number, projectId?: number) {
if (this.taskServiceBuilder && userId != null) {
const taskService = this.taskServiceBuilder.create({ userId, projectId });
const subDomainsGetter = await taskService.getSubDomainsGetter();
const domainParser = new DomainParser(subDomainsGetter, logger);
return await domainParser.parse(domain);
}
const parts = domain.split(".");
return parts.length > 2 ? parts.slice(-2).join(".") : domain;
}
private buildRelativeHostRecord(domain: string, mainDomain: string) {
let prefix = domain;
if (domain === mainDomain) {
prefix = "";
} else if (domain.endsWith(`.${mainDomain}`)) {
prefix = domain.substring(0, domain.length - mainDomain.length - 1);
}
return prefix ? `_validation-persist.${prefix}` : "_validation-persist";
}
private async buildFullHostRecord(record: Pick<DnsPersistRecordEntity, "domain" | "hostRecord" | "userId" | "projectId" | "mainDomain">) {
if (record.hostRecord === `_validation-persist.${record.domain}` || record.hostRecord.endsWith(`.${record.domain}`)) {
return record.hostRecord;
}
const mainDomain = record.mainDomain || (await this.parseMainDomain(record.domain, record.userId, record.projectId));
return `${record.hostRecord}.${mainDomain}`;
}
private parseAcmeAccount(account: string | any) {
if (!account) {
throw new Error("ACME账号授权缺少账号信息,请重新生成ACME账号");
}
const parsed = typeof account === "string" ? JSON.parse(account) : account;
if (!parsed.accountKey || !parsed.accountUri) {
throw new Error("ACME账号授权无效,请重新生成ACME账号");
}
return parsed;
}
async getAcmeAccount(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number }) {
const accessId = req.acmeAccountAccessId || req.commonAcmeAccountAccessId;
if (!accessId) {
throw new Error("请选择ACME账号");
}
let access: any;
if (req.commonAcmeAccountAccessId) {
const entity = await this.accessService.info(accessId);
if (!entity || entity.userId !== 0 || entity.type !== "acmeAccount") {
throw new Error("公共ACME账号不存在");
}
access = await this.accessService.getAccessById(accessId, false);
} else {
access = await this.accessService.getAccessById(accessId, true, req.userId, req.projectId);
}
const account = this.parseAcmeAccount(access.account);
const caType = req.caType || account.caType;
if (caType && account.caType && caType !== account.caType) {
throw new Error("ACME账号授权与颁发机构不匹配");
}
return {
accessId,
caType,
account,
};
}
async buildRecord(req: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number; userId?: number; projectId?: number }) {
const domain = this.normalizeDomain(req.domain);
const mainDomain = await this.parseMainDomain(domain, req.userId, req.projectId);
return {
mainDomain,
hostRecord: this.buildRelativeHostRecord(domain, mainDomain),
recordValue: buildDnsPersistRecordValue({
...req,
wildcard: true,
}),
};
}
async buildRecordByAcmeAccount(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number }) {
const { account, caType, accessId } = await this.getAcmeAccount(req);
const record = await this.buildRecord({
domain: req.domain,
accountUri: account.accountUri,
wildcard: true,
persistUntil: req.persistUntil,
userId: req.userId,
projectId: req.projectId,
});
return {
...record,
domain: this.normalizeDomain(req.domain),
mainDomain: record.mainDomain,
caType,
acmeAccountAccessId: accessId,
accountUri: account.accountUri,
policy: "wildcard",
persistUntil: req.persistUntil,
status: "pending",
disabled: false,
};
}
async add(param: any) {
const record = await this.buildRecordByAcmeAccount(param);
const exists = await this.findOne({
where: {
domain: record.domain,
caType: record.caType,
acmeAccountAccessId: record.acmeAccountAccessId,
userId: param.userId,
projectId: param.projectId,
},
});
if (exists) {
if (exists.policy !== "wildcard") {
await this.upgradeToWildcardRecord(exists, record);
return await this.info(exists.id);
}
if (exists.status !== "valid" && !exists.dnsProviderAccess) {
await this.tryAutoCreateDnsTxt(exists.id);
return await this.info(exists.id);
}
return exists;
}
const result = await super.add({
...param,
...record,
});
await this.tryAutoCreateDnsTxt(result.id);
return await this.info(result.id);
}
async update(param: any) {
const old = await this.info(param.id);
if (!old) {
throw new Error("DNS持久验证记录不存在");
}
if (param.domain || param.caType || param.acmeAccountAccessId || param.commonAcmeAccountAccessId || param.persistUntil != null || param.wildcard != null) {
const record = await this.buildRecordByAcmeAccount({
domain: param.domain || old.domain,
caType: param.caType || old.caType,
acmeAccountAccessId: param.acmeAccountAccessId || old.acmeAccountAccessId,
commonAcmeAccountAccessId: param.commonAcmeAccountAccessId,
wildcard: true,
persistUntil: param.persistUntil ?? old.persistUntil,
userId: old.userId,
projectId: old.projectId,
});
param = {
...param,
...record,
};
}
await super.update(param);
if (param.domain || param.caType || param.acmeAccountAccessId || param.commonAcmeAccountAccessId || param.persistUntil != null || param.wildcard != null) {
await this.tryAutoCreateDnsTxt(param.id);
}
}
async checkRecord(req: { hostRecord: string; recordValue: string }) {
const { walkTxtRecord } = createChallengeFn();
const values = await walkTxtRecord(req.hostRecord);
return values.includes(req.recordValue);
}
async getByDomain(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number; createOnNotFound?: boolean }) {
const account = await this.getAcmeAccount(req);
const domain = this.normalizeDomain(req.domain);
let record = await this.findOne({
where: {
domain,
caType: account.caType,
acmeAccountAccessId: account.accessId,
userId: req.userId,
projectId: req.projectId,
},
});
if (!record && req.createOnNotFound) {
record = await this.add({
...req,
domain,
caType: account.caType,
acmeAccountAccessId: account.accessId,
});
} else if (record && record.policy !== "wildcard") {
const wildcardRecord = await this.buildRecordByAcmeAccount({
...req,
domain,
caType: account.caType,
acmeAccountAccessId: account.accessId,
persistUntil: req.persistUntil ?? record.persistUntil,
});
await this.upgradeToWildcardRecord(record, wildcardRecord);
record = await this.info(record.id);
} else if (record && record.status !== "valid" && !record.dnsProviderAccess) {
await this.tryAutoCreateDnsTxt(record.id);
record = await this.info(record.id);
}
return record;
}
private async upgradeToWildcardRecord(exists: DnsPersistRecordEntity, wildcardRecord: Partial<DnsPersistRecordEntity>) {
await super.update({
id: exists.id,
hostRecord: wildcardRecord.hostRecord,
recordValue: wildcardRecord.recordValue,
mainDomain: wildcardRecord.mainDomain,
policy: "wildcard",
persistUntil: wildcardRecord.persistUntil ?? exists.persistUntil,
status: "pending",
recordRes: null,
});
}
async verify(id: number) {
const record = await this.info(id);
if (!record) {
throw new Error("DNS持久验证记录不存在");
}
const ok = await this.checkRecord({
hostRecord: await this.buildFullHostRecord(record),
recordValue: record.recordValue,
});
await this.update({
id: record.id,
status: ok ? "valid" : "failed",
});
return ok;
}
async triggerVerify(id: number) {
await super.update({
id,
status: "validating",
});
setTimeout(() => {
this.verify(id).catch(async (e: any) => {
logger.error(`DNS持久验证记录后台校验失败:${e.message || e}`);
await super.update({
id,
status: "failed",
});
});
}, 0);
return true;
}
private async findDomainDnsProvider(record: DnsPersistRecordEntity) {
const taskService = this.taskServiceBuilder.create({ userId: record.userId, projectId: record.projectId });
const subDomainsGetter = await taskService.getSubDomainsGetter();
const domainParser = new DomainParser(subDomainsGetter, logger);
const mainDomain = record.mainDomain || (await domainParser.parse(record.domain));
const domains = [...new Set([record.domain, mainDomain].filter(Boolean))];
const list = await this.domainRepository.find({
where: {
domain: In(domains),
userId: record.userId,
projectId: record.projectId,
challengeType: "dns",
disabled: false,
},
});
const matched = list.find(item => item.domain === record.domain) || list.find(item => item.domain === mainDomain);
if (!matched) {
return null;
}
return {
dnsProviderType: matched.dnsProviderType,
dnsProviderAccess: matched.dnsProviderAccess,
};
}
private async resolveDnsProvider(record: DnsPersistRecordEntity, req: { dnsProviderType?: string; dnsProviderAccess?: number }) {
if (req.dnsProviderType && req.dnsProviderAccess) {
return {
dnsProviderType: req.dnsProviderType,
dnsProviderAccess: req.dnsProviderAccess,
};
}
const provider = await this.findDomainDnsProvider(record);
if (!provider) {
throw new Error("未找到该域名在域名管理中的DNS授权配置,请手动选择DNS服务商和授权");
}
return provider;
}
private async tryAutoCreateDnsTxt(id: number) {
const record = await this.info(id);
if (!record || record.status === "valid") {
return;
}
const provider = await this.findDomainDnsProvider(record);
if (!provider) {
return;
}
try {
await this.createDnsTxt({
id,
...provider,
});
} catch (e: any) {
await super.update({
id,
status: "failed",
recordRes: JSON.stringify({
autoCreateError: e.message || `${e}`,
}),
});
}
}
async createDnsTxt(req: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
const record = await this.info(req.id);
if (!record) {
throw new Error("DNS持久验证记录不存在");
}
const provider = await this.resolveDnsProvider(record, req);
const taskService = this.taskServiceBuilder.create({ userId: record.userId, projectId: record.projectId });
const subDomainsGetter = await taskService.getSubDomainsGetter();
const domainParser = new DomainParser(subDomainsGetter, logger);
const access = await this.accessService.getAccessById(provider.dnsProviderAccess, true, record.userId, record.projectId);
const dnsProvider = await createDnsProvider({
dnsProviderType: provider.dnsProviderType,
context: {
access,
logger,
http,
utils,
domainParser,
serviceGetter: taskService,
},
});
const mainDomain = record.mainDomain || (await domainParser.parse(record.domain));
const fullRecordRaw = await this.buildFullHostRecord(record);
const fullRecord = dnsProvider.usePunyCode() ? fullRecordRaw : dnsProvider.punyCodeDecode(fullRecordRaw);
let hostRecord = fullRecord.replace(`${mainDomain}`, "");
if (hostRecord.endsWith(".")) {
hostRecord = hostRecord.substring(0, hostRecord.length - 1);
}
const recordReq = {
domain: mainDomain,
fullRecord,
hostRecord,
type: "TXT",
value: record.recordValue,
};
const recordRes = await dnsProvider.createRecord(recordReq);
const verified = await this.checkRecord({
hostRecord: await this.buildFullHostRecord(record),
recordValue: record.recordValue,
});
await this.update({
id: record.id,
dnsProviderType: provider.dnsProviderType,
dnsProviderAccess: provider.dnsProviderAccess,
recordRes: JSON.stringify({ recordReq, recordRes }),
status: verified ? "valid" : "validating",
});
return {
recordReq,
recordRes,
verified,
};
}
async delete(ids: string | any[], where?: any) {
const idList = this.resolveIdArr(ids);
const messages: string[] = [];
for (const id of idList) {
const record = await this.info(id);
if (!record) {
continue;
}
messages.push(`DNS持久验证记录已删除,请到域名供应商删除TXT记录:${record.hostRecord} => ${record.recordValue}`);
}
this.lastDeleteMessage = messages.join("\n");
return await super.delete(ids, where);
}
}
@@ -19,6 +19,7 @@ import { isPlus } from "@certd/plus-core";
import { AddonService } from "@certd/lib-server";
import { OauthBoundService } from "./oauth-bound-service.js";
import { PasskeyService } from "./passkey-service.js";
import { InviteService } from "@certd/commercial-core";
/**
*/
@@ -49,6 +50,9 @@ export class LoginService {
@Inject()
passkeyService: PasskeyService;
@Inject()
inviteService: InviteService;
checkIsBlocked(username: string) {
const blockDurationKey = `login_block_duration:${username}`;
const value = cache.get(blockDurationKey);
@@ -111,7 +115,7 @@ export class LoginService {
}
async loginBySmsCode(req: { mobile: string; phoneCode: string; smsCode: string; randomStr: string }) {
async loginBySmsCode(req: { mobile: string; phoneCode: string; smsCode: string; randomStr: string; inviteCode?: string }) {
this.checkIsBlocked(req.mobile)
@@ -129,11 +133,14 @@ export class LoginService {
let info = await this.userService.findOne({phoneCode, mobile: mobile});
if (info == null) {
//用户不存在,注册
info = await this.userService.register('mobile', {
const registerUser = {
phoneCode,
mobile,
password: '',
} as any);
} as any;
info = await this.userService.register('mobile', registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, req.inviteCode, txManager);
});
}
this.clearCacheOnSuccess(mobile);
return this.onLoginSuccess(info);
@@ -264,4 +271,3 @@ export class LoginService {
const user = await this.passkeyService.loginByPasskey(credential, challenge, ctx);
return this.generateToken(user);
}}
@@ -0,0 +1,59 @@
import assert from "assert";
import { AcmeAccountAccess } from "./acme-account-access.js";
import { AcmeService } from "../plugin/cert-plugin/acme.js";
describe("AcmeAccountAccess", () => {
it("requires generated account payload before use", () => {
const access = new AcmeAccountAccess();
assert.throws(() => access.getAccount(), /ACME账号信息无效/);
});
it("parses generated account payload", () => {
const access = new AcmeAccountAccess();
access.account = JSON.stringify({
accountKey: "private-key",
accountUri: "https://example.com/acct/1",
caType: "letsencrypt",
email: "user@example.com",
directoryUrl: "https://example.com/directory",
});
const account = access.getAccount();
assert.equal(account.accountKey, "private-key");
assert.equal(account.accountUri, "https://example.com/acct/1");
});
it("generates account payload through acme service", async () => {
const original = AcmeService.prototype.getAcmeClient;
const calls: string[] = [];
AcmeService.prototype.getAcmeClient = async function (email: string) {
calls.push(email);
await this.userContext.setObj(this.buildAccountKey(email), { key: "generated-key" });
return {
getAccountUrl() {
return "https://example.com/acct/2";
},
} as any;
};
try {
const access = new AcmeAccountAccess();
access.caType = "google";
access.email = "user@example.com";
access.eabKid = "kid-1";
access.eabHmacKey = "hmac-1";
const account = JSON.parse(await access.onGenerateAccount());
assert.equal(calls[0], "user@example.com");
assert.equal(account.accountKey, "generated-key");
assert.equal(account.accountUri, "https://example.com/acct/2");
assert.equal(account.caType, "google");
assert.equal(account.email, "user@example.com");
} finally {
AcmeService.prototype.getAcmeClient = original;
}
});
});
@@ -0,0 +1,239 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import * as acme from "@certd/acme-client";
import { AcmeService } from "../plugin/cert-plugin/acme.js";
export type AcmeAccountInfo = {
accountKey: string;
accountUri: string;
caType: string;
email: string;
directoryUrl: string;
eab?: {
kid?: string;
hmacKey?: string;
usedAt: number;
};
};
function parseAccount(account?: string | AcmeAccountInfo): AcmeAccountInfo | null {
if (!account) {
return null;
}
if (typeof account !== "string") {
return account;
}
return JSON.parse(account);
}
@IsAccess({
name: "acmeAccount",
title: "ACME账号",
desc: "用于复用ACME账号私钥和账号地址,证书申请时不再临时创建账号",
icon: "ph:certificate",
subtype: "caType",
} as any)
export class AcmeAccountAccess extends BaseAccess {
@AccessInput({
title: "颁发机构",
component: {
name: "a-select",
options: [
{ value: "letsencrypt", label: "Let's Encrypt" },
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境" },
{ value: "google", label: "Google" },
{ value: "zerossl", label: "ZeroSSL" },
{ value: "litessl", label: "litessl" },
{ value: "sslcom", label: "SSL.com" },
],
},
required: true,
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
caType = "letsencrypt";
@AccessInput({
title: "邮箱",
component: {
placeholder: "user@example.com",
},
rules: [{ type: "email", message: "请输入正确的邮箱" }],
required: true,
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
email = "";
@AccessInput({
title: "ACME Directory URL",
component: {
placeholder: "自定义ACME服务端点",
},
helper: "自定义ACME时必填,其他颁发机构默认自动使用内置端点",
required: false,
mergeScript: `
return {
show: false,
}
`,
})
directoryUrl = "";
@AccessInput({
title: "EAB KID",
component: {
placeholder: "需要EAB的颁发机构生成账号时填写",
},
helper:
"需要提供EAB授权" +
"\nZeroSSL:请前往[zerossl开发者中心](https://app.zerossl.com/developer),生成 'EAB Credentials'" +
"\nGoogle:请查看[google获取eab帮助文档](https://certd.docmirror.cn/guide/use/google/),用过一次后会绑定邮箱,后续复用EAB要用同一个邮箱" +
"\nSSL.com:[SSL.com账号页面](https://secure.ssl.com/account),然后点击api credentials链接,然后点击编辑按钮,查看Secret key和HMAC key" +
"\nlitessl:[litesslEAB页面](https://freessl.cn/automation/eab-manager),然后点击新增EAB",
required: false,
encrypt: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
const caType = form.access?.caType;
return ['google','zerossl','sslcom','litessl'].includes(caType);
}),
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
eabKid = "";
@AccessInput({
title: "EAB HMAC Key",
component: {
placeholder: "需要EAB的颁发机构生成账号时填写",
},
required: false,
encrypt: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
const caType = form.access?.caType;
return ['google','zerossl','sslcom','litessl'].includes(caType);
}),
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
eabHmacKey = "";
@AccessInput({
title: "ACME账号信息",
component: {
name: "refresh-input",
action: "GenerateAccount",
buttonText: "生成ACME账号",
successMessage: "ACME账号已生成,请保存授权配置",
},
required: true,
helper: "请生成ACME账号,账号一旦生成不允许修改",
encrypt: true,
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
}
`,
})
account = "";
getDirectoryUrl() {
if (this.caType === "custom") {
if (!this.directoryUrl) {
throw new Error("自定义ACME需要填写Directory URL");
}
return this.directoryUrl;
}
return acme.getDirectoryUrl({ sslProvider: this.caType, pkType: "rsa_2048" });
}
async onGenerateAccount() {
if (!this.caType) {
throw new Error("请先选择颁发机构");
}
if (!this.email) {
throw new Error("请先填写邮箱");
}
const needEab = ["google", "zerossl", "sslcom", "litessl"].includes(this.caType);
if (needEab && (!this.eabKid || !this.eabHmacKey)) {
throw new Error("该颁发机构需要填写EAB KID和EAB HMAC Key后才能生成账号");
}
const account = await this.createAccountInfo();
return JSON.stringify(account);
}
private async createAccountInfo(): Promise<AcmeAccountInfo> {
const directoryUrl = this.getDirectoryUrl();
const externalAccountBinding = this.eabKid && this.eabHmacKey ? { kid: this.eabKid, hmacKey: this.eabHmacKey } : undefined;
const memoryStore = new Map<string, any>();
const userContext = {
async getObj(key: string) {
return memoryStore.get(key);
},
async setObj(key: string, value: any) {
memoryStore.set(key, value);
},
};
const acmeService = new AcmeService({
userId: 0,
userContext: userContext as any,
logger: (this.ctx?.logger || console) as any,
sslProvider: this.caType as any,
eab: externalAccountBinding ? ({ ...externalAccountBinding, id: 0 } as any) : undefined,
privateKeyType: "rsa_2048",
signal: (this.ctx as any)?.signal,
maxCheckRetryCount: 20,
domainParser: {} as any,
});
const client = await acmeService.getAcmeClient(this.email);
const conf = await userContext.getObj(acmeService.buildAccountKey(this.email));
if (!conf?.key || !client.getAccountUrl()) {
throw new Error("ACME账号生成失败,请稍后重试");
}
const account: AcmeAccountInfo = {
accountKey: conf.key,
accountUri: client.getAccountUrl(),
caType: this.caType,
email: this.email,
directoryUrl,
};
if (externalAccountBinding) {
account.eab = {
...externalAccountBinding,
usedAt: Date.now(),
};
}
return account;
}
getAccount(): AcmeAccountInfo {
const account = parseAccount(this.account);
if (!account?.accountKey || !account?.accountUri) {
throw new Error("ACME账号信息无效,请重新生成ACME账号");
}
return account;
}
}
new AcmeAccountAccess();
@@ -1,2 +1,3 @@
export * from "./eab-access.js";
export * from "./google-access.js";
export * from "./acme-account-access.js";
@@ -1,4 +1,5 @@
import assert from "assert";
import { utils } from "@certd/basic";
import { AcmeService } from "./acme.js";
const logger = {
@@ -173,4 +174,28 @@ describe("AcmeService challenge", () => {
assert.deepEqual(parseCalls, ["www.example.com", "certd-key.cname.sub.example.com"]);
});
it("enables proxy mapping when acme directory request fails", async () => {
const originalRequest = utils.http.request;
utils.http.request = async () => {
throw new Error("timeout");
};
try {
const service = new AcmeService({
userId: 1,
userContext: {} as any,
logger: logger as any,
sslProvider: "google",
domainParser: {} as any,
});
const urlMapping = await service.resolveUrlMapping("https://dv.acme-v02.api.pki.goog/directory");
assert.equal(urlMapping.enabled, true);
assert.equal(urlMapping.mappings["dv.acme-v02.api.pki.goog"], "gg.px.certd.handfree.work");
} finally {
utils.http.request = originalRequest;
}
});
});
@@ -21,15 +21,30 @@ export type HttpVerifyPlan = {
export type DomainVerifyPlan = {
domain: string;
mainDomain: string;
type: "cname" | "dns" | "http";
type: "cname" | "dns" | "http" | "dns-persist";
dnsProvider?: IDnsProvider;
cnameVerifyPlan?: CnameVerifyPlan;
httpVerifyPlan?: HttpVerifyPlan;
dnsPersistVerifyPlan?: DnsPersistVerifyPlan;
};
export type DomainsVerifyPlan = {
[key: string]: DomainVerifyPlan;
};
export type AcmeAccountInfo = {
accountKey: string;
accountUri: string;
caType: SSLProvider | string;
email: string;
directoryUrl: string;
};
export type DnsPersistVerifyPlan = {
hostRecord: string;
recordValue: string;
accountUri: string;
};
export type Providers = {
dnsProvider?: IDnsProvider;
domainsVerifyPlan?: DomainsVerifyPlan;
@@ -153,8 +168,7 @@ export class AcmeService {
await this.userContext.setObj(this.buildAccountKey(email), conf);
}
async getAcmeClient(email: string): Promise<acme.Client> {
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
buildUrlMapping(directoryUrl: string): UrlMapping {
let targetUrl = directoryUrl.replace("https://", "");
targetUrl = targetUrl.substring(0, targetUrl.indexOf("/"));
@@ -174,10 +188,29 @@ export class AcmeService {
if (this.options.reverseProxy && targetUrl) {
mappings[targetUrl] = this.options.reverseProxy;
}
const urlMapping: UrlMapping = {
return {
enabled: false,
mappings,
};
}
async resolveUrlMapping(directoryUrl: string) {
const urlMapping = this.buildUrlMapping(directoryUrl);
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
return urlMapping;
}
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
return urlMapping;
}
async getAcmeClient(email: string): Promise<acme.Client> {
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
const urlMapping = await this.resolveUrlMapping(directoryUrl);
const conf = await this.getAccountConfig(email, urlMapping);
if (conf.key == null) {
conf.key = await this.createNewKey();
@@ -185,16 +218,6 @@ export class AcmeService {
this.logger.info(`创建新的Accountkey:${email}`);
}
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
} else {
//测试directory是否可以访问
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
}
const client = new acme.Client({
sslProvider: this.sslProvider,
directoryUrl: directoryUrl,
@@ -238,6 +261,26 @@ export class AcmeService {
return client;
}
async getAcmeClientByAccount(account: AcmeAccountInfo): Promise<acme.Client> {
if (!account?.accountKey || !account?.accountUri) {
throw new Error("ACME账号信息无效,请重新生成ACME账号");
}
const directoryUrl = account.directoryUrl || acme.getDirectoryUrl({ sslProvider: account.caType, pkType: this.options.privateKeyType });
const urlMapping = await this.resolveUrlMapping(directoryUrl);
return new acme.Client({
sslProvider: account.caType,
directoryUrl,
accountKey: account.accountKey,
accountUrl: account.accountUri,
backoffAttempts: this.options.maxCheckRetryCount || 20,
backoffMin: 5000,
backoffMax: 30 * 1000,
urlMapping,
signal: this.options.signal,
logger: this.logger,
});
}
async createNewKey() {
const key = await acme.crypto.createPrivateKey(2048);
return key.toString();
@@ -300,6 +343,18 @@ export class AcmeService {
};
};
const doDnsPersistVerify = async (challenge: any, plan: DnsPersistVerifyPlan) => {
if (challenge == null) {
throw new Error("该域名不支持dns-persist-01方式校验,请确认当前CA是否已开放该能力");
}
this.logger.info("DNS持久验证");
challenge.expectedRecordValue = plan.recordValue;
return {
challenge,
keyAuthorization: "",
};
};
let dnsProvider = providers.dnsProvider;
let fullRecord = `_acme-challenge.${fullDomain}`;
@@ -343,6 +398,9 @@ export class AcmeService {
} else {
throw new Error("未找到域名【" + fullDomain + "】的http校验配置");
}
} else if (domainVerifyPlan.type === "dns-persist") {
checkIpChallenge("dns-persist");
return await doDnsPersistVerify(getChallenge("dns-persist-01"), domainVerifyPlan.dnsPersistVerifyPlan);
} else {
throw new Error("不支持的校验类型", domainVerifyPlan.type);
}
@@ -394,6 +452,8 @@ export class AcmeService {
this.logger.error("删除解析记录出错:", e);
throw e;
}
} else if (challenge.type === "dns-persist-01") {
this.logger.info(`DNS持久验证无需清理:${fullDomain}`);
}
}
@@ -407,9 +467,10 @@ export class AcmeService {
privateKeyType?: string;
profile?: string;
preferredChain?: string;
acmeAccount?: AcmeAccountInfo;
}): Promise<CertInfo> {
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain } = options;
const client: acme.Client = await this.getAcmeClient(email);
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain, acmeAccount } = options;
const client: acme.Client = acmeAccount ? await this.getAcmeClientByAccount(acmeAccount) : await this.getAcmeClient(email);
let domains = options.domains;
const encodingDomains = [];
@@ -463,12 +524,13 @@ export class AcmeService {
domainsVerifyPlan,
};
/* 自动申请证书 */
const challengePriority = domainsVerifyPlan && Object.values(domainsVerifyPlan).some((item: any) => item?.type === "dns-persist") ? ["dns-persist-01"] : ["dns-01", "http-01"];
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
skipChallengeVerification: this.skipLocalVerify,
challengePriority: ["dns-01", "http-01"],
challengePriority,
challengeCreateFn: async (
authz: acme.Authorization,
keyAuthorizationGetter: (challenge: Challenge) => Promise<string>
@@ -0,0 +1,47 @@
import assert from "assert";
import { CertApplyPlugin } from "./apply.js";
describe("CertApplyPlugin dns-persist verify plan", () => {
it("keeps dns-persist entries when building mixed domain verify plans", async () => {
const plugin: any = new CertApplyPlugin();
plugin.acme = {
options: {
domainParser: {
async parse(domain: string) {
return domain;
},
},
},
};
const plan = await plugin.createDomainsVerifyPlan(
["*.handfree.work"],
{
"handfree.work": {
domain: "handfree.work",
type: "dns-persist",
dnsPersistVerifyPlan: {
"handfree.work": {
domain: "handfree.work",
status: "valid",
hostRecord: "_validation-persist",
recordValue: "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard",
accountUri: "https://acme.example/acct/1",
},
},
},
},
{
accountKey: "private-key",
accountUri: "https://acme.example/acct/1",
caType: "letsencrypt_staging",
email: "user@example.com",
directoryUrl: "https://acme-staging-v02.api.letsencrypt.org/directory",
}
);
assert.equal(plan["handfree.work"].type, "dns-persist");
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.hostRecord, "_validation-persist");
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.recordValue, "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard");
});
});
@@ -1,7 +1,7 @@
import { CancelError, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
import { AcmeAccountInfo, AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
import { createDnsProvider, DnsProviderContext, DnsVerifier, DomainVerifiers, HttpVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "@certd/plugin-lib";
import { CertReader } from "@certd/plugin-lib";
import { CertApplyBasePlugin } from "./base.js";
@@ -22,14 +22,22 @@ export type HttpRecordInput = {
httpUploaderAccess: number;
httpUploadRootDir: string;
};
export type DnsPersistRecordInput = {
domain: string;
status?: string;
hostRecord?: string;
recordValue?: string;
accountUri?: string;
};
export type DomainVerifyPlanInput = {
domain: string;
type: "cname" | "dns" | "http";
type: "cname" | "dns" | "http" | "dns-persist";
dnsProviderType?: string;
dnsProviderAccessType?: string;
dnsProviderAccessId?: number;
cnameVerifyPlan?: Record<string, CnameRecordInput>;
httpVerifyPlan?: Record<string, HttpRecordInput>;
dnsPersistVerifyPlan?: Record<string, DnsPersistRecordInput>;
};
export type DomainsVerifyPlanInput = {
[key: string]: DomainVerifyPlanInput;
@@ -99,6 +107,19 @@ const preferredChainMergeScript = (() => {
},
})
export class CertApplyPlugin extends CertApplyBasePlugin {
constructor() {
super();
this.version = 1;
}
@TaskInput({
title: "版本",
value: 2,
isSys: true,
show: false,
})
version?: number;
@TaskInput({
title: "域名验证方式",
value: "dns",
@@ -107,6 +128,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
vModel: "value",
options: [
{ value: "dns", label: "DNS直接验证" },
{ value: "dns-persist", label: "DNS持久验证" },
{ value: "cname", label: "CNAME代理验证" },
{ value: "http", label: "HTTP文件验证(IP证书只能选它)" },
{ value: "dnses", label: "多DNS提供商" },
@@ -119,12 +141,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
3. <b>HTTP文件验证</b>IP证书必须选它
4. <b>DNS提供商</b>DNS提供商
5. <b></b>[](#/certd/cert/domain)
6. <b>DNS持久验证</b>ACME账号和_validation-persist持久TXT记录DNS记录 Let's Encrypt
`,
})
challengeType!: string;
@TaskInput({
title: "DNS解析服务商",
component: {
@@ -145,7 +166,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
}
`,
required: true,
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证校验方式",
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证",
})
dnsProviderType!: string;
@@ -190,18 +211,30 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
}),
defaultType: ctx.compute(({form})=>{
return form.challengeType || 'cname'
}),
caType: ctx.compute(({form})=>{
return form.sslProvider
}),
acmeAccountAccessId: ctx.compute(({form})=>{
return form.acmeAccountAccessId
}),
commonAcmeAccountAccessId: ctx.compute(({form})=>{
const key = form.sslProvider + 'CommonAcmeAccountAccessId';
return form[key]
})
},
show: ctx.compute(({form})=>{
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses'
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses' || form.challengeType === 'dns-persist'
}),
helper: ctx.compute(({form})=>{
if(form.challengeType === 'cname' ){
return '请按照上面的提示,给要申请证书的域名添加CNAME记录,添加后,点击验证,验证成功后不要删除记录,申请和续期证书会一直用它'
}else if (form.challengeType === 'http'){
return '请按照上面的提示,给每个域名设置文件上传配置,证书申请过程中会上传校验文件到网站根目录的.well-known/acme-challenge/目录下'
}else if (form.challengeType === 'http'){
}else if (form.challengeType === 'dnses'){
return '给每个域名单独配置dns提供商'
}else if (form.challengeType === 'dns-persist'){
return '请先创建并校验_validation-persist TXT持久记录,校验成功后才能提交流水线;当前仅 Let\\'s Encrypt '
}
})
}
@@ -209,7 +242,6 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
domainsVerifyPlan!: DomainsVerifyPlanInput;
@TaskInput({
title: "证书颁发机构",
value: "letsencrypt",
@@ -237,6 +269,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
googleCommonEabAccessId!: number;
@TaskInput({
title: "Google公共ACME账号",
isSys: true,
show: false,
})
googleCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "ZeroSSL公共EAB授权",
isSys: true,
@@ -244,6 +283,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
zerosslCommonEabAccessId!: number;
@TaskInput({
title: "ZeroSSL公共ACME账号",
isSys: true,
show: false,
})
zerosslCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "SSL.com公共EAB授权",
isSys: true,
@@ -251,6 +297,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
sslcomCommonEabAccessId!: number;
@TaskInput({
title: "SSL.com公共ACME账号",
isSys: true,
show: false,
})
sslcomCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "litessl公共EAB授权",
isSys: true,
@@ -258,6 +311,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
litesslCommonEabAccessId!: number;
@TaskInput({
title: "litessl公共ACME账号",
isSys: true,
show: false,
})
litesslCommonAcmeAccountAccessId!: number;
@TaskInput({
title: "EAB授权",
component: {
@@ -275,7 +335,16 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
mergeScript: `
return {
show: ctx.compute(({form})=>{
console.log("show",form)
if (form.version === 2) {
return false
}
if(form.acmeAccountAccessId){
return false
}
const commonAcmeKey = form.sslProvider + 'CommonAcmeAccountAccessId';
if (form[commonAcmeKey]) {
return false
}
return (form.sslProvider === 'zerossl' && !form.zerosslCommonEabAccessId)
|| (form.sslProvider === 'google' && !form.googleCommonEabAccessId)
|| (form.sslProvider === 'sslcom' && !form.sslcomCommonEabAccessId)
@@ -286,6 +355,38 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
})
eabAccessId!: number;
@TaskInput({
title: "ACME账号",
component: {
name: "access-selector",
type: "acmeAccount",
},
required: false,
helper: "请选择颁发机构对应的ACME账号",
mergeScript: `
return {
show: ctx.compute(({form})=>{
const commonKey = form.sslProvider + 'CommonAcmeAccountAccessId';
if (form[commonKey]) {
return false
}
return !!form.sslProvider
}),
component:{
subtype: ctx.compute(({form})=> form.sslProvider)
},
required: ctx.compute(({form})=>{
const commonKey = form.sslProvider + 'CommonAcmeAccountAccessId';
if (form[commonKey]) {
return false
}
return form.version === 2
})
}
`,
})
acmeAccountAccessId!: number;
@TaskInput({
title: "服务账号授权",
component: {
@@ -298,6 +399,15 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
mergeScript: `
return {
show: ctx.compute(({form})=>{
if (form.version === 2) {
return false
}
if(form.acmeAccountAccessId){
return false
}
if(form.googleCommonAcmeAccountAccessId){
return false
}
return form.sslProvider === 'google' && !form.googleCommonEabAccessId
})
}
@@ -432,7 +542,8 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
async onInit() {
let eab: EabAccess = null;
if (this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
const isNewVersion = this.version === 2;
if (!isNewVersion && this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
if (this.sslProvider === "google" && this.googleAccessId) {
this.logger.info("当前正在使用 google服务账号授权获取EAB");
const googleAccess = await this.getAccess(this.googleAccessId);
@@ -499,8 +610,23 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
let dnsProvider: IDnsProvider = null;
let domainsVerifyPlan: DomainsVerifyPlan = null;
if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan);
let acmeAccount: AcmeAccountInfo = null;
if (this.acmeAccountAccessId) {
const access: any = await this.getAccess(this.acmeAccountAccessId);
acmeAccount = this.parseAcmeAccount(access.account);
} else {
acmeAccount = await this.getCommonAcmeAccount();
}
if (this.version === 2 && !acmeAccount) {
throw new Error("请选择颁发机构对应的ACME账号");
}
if (this.challengeType === "dns-persist") {
if (!acmeAccount) {
throw new Error("DNS持久验证需要先选择ACME账号");
}
domainsVerifyPlan = await this.createDnsPersistDomainsVerifyPlan(domains, acmeAccount);
} else if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan, acmeAccount);
} else if (this.challengeType === "auto") {
domainsVerifyPlan = await this.createDomainsVerifyPlanByAuto(domains);
} else {
@@ -519,6 +645,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
privateKeyType: this.privateKeyType,
profile: this.certProfile,
preferredChain: this.preferredChain,
acmeAccount,
});
const certInfo = this.formatCerts(cert);
@@ -552,7 +679,80 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
});
}
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput): Promise<DomainsVerifyPlan> {
parseAcmeAccount(account: string | AcmeAccountInfo): AcmeAccountInfo {
if (!account) {
throw new Error("ACME账号授权缺少账号信息,请重新生成ACME账号");
}
const parsed = typeof account === "string" ? JSON.parse(account) : account;
if (!parsed.accountKey || !parsed.accountUri) {
throw new Error("ACME账号无效,请重新生成ACME账号");
}
return parsed;
}
async getCommonAcmeAccount(): Promise<AcmeAccountInfo | null> {
if (!this.sslProvider || this.sslProvider === "letsencrypt" || this.sslProvider === "letsencrypt_staging") {
return null;
}
const commonAccessId = this[`${this.sslProvider}CommonAcmeAccountAccessId`];
if (!commonAccessId) {
return null;
}
const accessService: any = this.ctx.accessService;
if (!accessService?.getCommonById) {
return null;
}
const access = await accessService.getCommonById(commonAccessId);
if (!access?.account) {
return null;
}
this.logger.info(`使用系统公共${this.sslProvider} ACME账号`);
return this.parseAcmeAccount(access.account);
}
private async createDnsPersistDomainsVerifyPlan(domains: string[], acmeAccount: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
const plan: DomainsVerifyPlan = {};
const domainParser = this.acme.options.domainParser;
for (const fullDomain of domains) {
const domain = fullDomain.replaceAll("*.", "");
const mainDomain = await domainParser.parse(domain);
const persistRecord = this.domainsVerifyPlan?.[mainDomain]?.dnsPersistVerifyPlan?.[domain];
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, persistRecord);
}
return plan;
}
private createDnsPersistDomainVerifyPlan(domain: string, mainDomain: string, acmeAccount: AcmeAccountInfo, persistRecord?: DnsPersistRecordInput): DomainVerifyPlan {
if (!persistRecord) {
throw new Error(`DNS持久验证记录${domain}不存在,请先创建并校验`);
}
if (persistRecord.status !== "valid") {
throw new Error(`DNS持久验证记录${domain}还未校验成功`);
}
return {
type: "dns-persist",
mainDomain,
domain,
dnsPersistVerifyPlan: {
hostRecord: persistRecord.hostRecord || `_validation-persist.${domain}`,
recordValue: persistRecord.recordValue || this.buildDnsPersistRecordValue(acmeAccount.accountUri, true),
accountUri: persistRecord.accountUri || acmeAccount.accountUri,
},
};
}
buildDnsPersistRecordValue(accountUri: string, wildcard = false, persistUntil?: number) {
const parts = [`letsencrypt.org`, `accounturi=${accountUri}`];
if (wildcard !== false) {
parts.push("policy=wildcard");
}
if (persistUntil) {
parts.push(`persistUntil=${persistUntil}`);
}
return parts.join("; ");
}
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput, acmeAccount?: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
const plan: DomainsVerifyPlan = {};
const domainParser = this.acme.options.domainParser;
@@ -569,6 +769,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
plan[domain] = await this.createCnameDomainVerifyPlan(domain, mainDomain);
} else if (planSetting.type === "http") {
plan[domain] = await this.createHttpDomainVerifyPlan(planSetting.httpVerifyPlan[domain], domain, mainDomain);
} else if (planSetting.type === "dns-persist") {
if (!acmeAccount) {
throw new Error("DNS持久验证需要先选择ACME账号");
}
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, planSetting.dnsPersistVerifyPlan?.[domain]);
}
}
return plan;
@@ -677,9 +882,9 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
}
async onGetReverseProxyList() {
const sysSettingsService:any = await this.ctx.serviceGetter.get("sysSettingsService");
const sysSettingsService: any = await this.ctx.serviceGetter.get("sysSettingsService");
const sysSettings = await sysSettingsService.getPrivateSettings();
return sysSettings.reverseProxyList || []
return sysSettings.reverseProxyList || [];
}
}