mirror of
https://github.com/certd/certd.git
synced 2026-10-13 15:49:25 +08:00
refactor: 整理代码优化与功能完善
主要变更: 1. 审计日志新增匿名请求过滤逻辑 2. 移除多余的antd message导入,统一使用notification 3. 优化受限令牌时效计算逻辑与注释 4. 插件作者接口新增错误提示开关 5. 完善证书服务逻辑:优先复用空证书占位记录 6. 优化单测用例与文档说明
This commit is contained in:
@@ -89,7 +89,7 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
- 确实不适合先写测试时,在回复中说明原因和替代验证方式。
|
||||
- 后补单元测试时,按正确行为写预期;若红灯需要修改既有实现,先向用户确认这是 bug 还是既有需求,避免未经确认改变行为。
|
||||
- 后端纯单测放在 `src/**/*.test.ts`,尽量与被测文件相邻;`test:unit` 只跑这些文件,构建/打包应排除 `*.test.ts`。
|
||||
- 单测需要 mock ESM 静态 import 时,优先使用 `esmock`,不要为了测试改业务代码结构。
|
||||
- 单测需要 mock ESM 静态 import(如 `@certd/plus-core` 的 `isPlus`、第三方包)时,**优先使用 `esmock`**,通过 `esmock(被测模块路径, { 依赖模块: { 导出: mock实现 } })` 加载被测模块并替换依赖;**不要为了测试给业务代码加注入点/改业务结构**(如不需要给类加 `isPlusFn` 之类的可注入属性)。
|
||||
- 各包 `test:unit` 脚本应显式设置 `NODE_ENV=unittest`。
|
||||
- 单包单测优先用 `cd <包目录> && npm run test:unit`,例如 `cd packages\ui\certd-server && npm run test:unit`。
|
||||
- 优先对改动包运行聚焦测试或格式化/ESLint;只有跨包影响明显时再考虑更大范围构建。
|
||||
@@ -150,6 +150,7 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
|
||||
- 前端主包是 `packages/ui/certd-client`,使用 Vue 3、Vite、TypeScript、Ant Design Vue、Fast Crud、Pinia、vue-router、vue-i18n。
|
||||
- 做前端任务时,先定位 `packages/ui/certd-client/src/views/certd` 下的页面,再找对应 `src/api`。
|
||||
- 前端用户提示**统一使用 `notification`**(ant-design-vue 的 `notification.success({ message: X })` / `.error` / `.warning` / `.info`),**不要使用 `message` API**;`src/vben/` 目录(vben 框架内置布局/组件)保持原样不动。
|
||||
- 不要运行前端 `pnpm tsc` / `vue-tsc`;当前 `vue-tsc@1.8.27` 会抛无效内部错误。前端 `test:unit` 只是占位脚本,也不要跑。
|
||||
- 前端 TS/Vue/locale 改动后,只对本次改动文件运行现有 Prettier / ESLint:`packages\ui\certd-client\node_modules\.bin\prettier.cmd --write <files>` 和 `packages\ui\certd-client\node_modules\.bin\eslint.cmd --fix <files>`。
|
||||
- Vue 模板中需要用 JSX 动态渲染时,使用 `<script setup lang="tsx">` 编写;不要使用 `h()` 创建 VNode。
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { dict } from "@fast-crud/fast-crud";
|
||||
import { message } from "ant-design-vue";
|
||||
import { notification } 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";
|
||||
@@ -117,7 +117,7 @@ async function doVerify() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const ok = await Verify(dnsPersistRecord.value.id);
|
||||
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
|
||||
notification[ok ? "success" : "error"]({ message: ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试" });
|
||||
await loadRecord();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@@ -62,7 +62,7 @@ import { computed, defineComponent, onMounted, ref, Ref, useAttrs } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { message as antdMessage } from "ant-design-vue";
|
||||
import { notification } from "ant-design-vue";
|
||||
import { Dicts } from "../lib/dicts";
|
||||
import { request } from "/@/api/service";
|
||||
import { openRouteInNewWindow } from "/@/vben/utils";
|
||||
@@ -251,7 +251,7 @@ function tagRender(tag: { value: string; label: string; closable: boolean; onClo
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await copy(value);
|
||||
antdMessage.success("复制成功");
|
||||
notification.success({ message: "复制成功" });
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
@@ -267,7 +267,7 @@ async function copySelectedDomains(event: MouseEvent) {
|
||||
return;
|
||||
}
|
||||
await copy(domains);
|
||||
antdMessage.success("复制成功");
|
||||
notification.success({ message: "复制成功" });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as UserApi from "./api.user";
|
||||
import { ForgotPasswordReq, RegisterReq, SmsLoginReq } from "./api.user";
|
||||
// @ts-ignore
|
||||
import { LoginReq, UserInfoRes } from "/@/store/user/api.user";
|
||||
import { message, Modal, notification } from "ant-design-vue";
|
||||
import { Modal, notification } from "ant-design-vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { mitter } from "/src/utils/util.mitt";
|
||||
|
||||
@@ -218,10 +218,11 @@ export type OnlinePluginAuthorBean = {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export async function OnlinePluginAuthorGet(): Promise<{ registered?: boolean; author?: OnlinePluginAuthorBean }> {
|
||||
export async function OnlinePluginAuthorGet(options?: { showErrorNotify?: boolean }): Promise<{ registered?: boolean; author?: OnlinePluginAuthorBean }> {
|
||||
return await request({
|
||||
url: apiPrefix + "/online/author/get",
|
||||
method: "post",
|
||||
showErrorNotify: options?.showErrorNotify,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const isLocalAuthor = computed(() => {
|
||||
async function loadAuthor() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.OnlinePluginAuthorGet();
|
||||
const result = await api.OnlinePluginAuthorGet({ showErrorNotify: false });
|
||||
if ((!props.modelValue || isLocalAuthor.value) && result.author?.name) {
|
||||
emit("update:modelValue", result.author.name);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import { SysSettings } from "/@/views/sys/settings/api";
|
||||
import * as api from "/@/views/sys/settings/api";
|
||||
import { merge } from "lodash-es";
|
||||
import { useSettingStore } from "/@/store/settings";
|
||||
import { message, notification } from "ant-design-vue";
|
||||
import { notification } from "ant-design-vue";
|
||||
import { useI18n } from "/src/locales";
|
||||
const { t } = useI18n();
|
||||
import { util } from "/@/utils";
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Constants } from "@certd/lib-server";
|
||||
import { AuditLogMiddleware } from "./audit-log.js";
|
||||
import esmock from "esmock";
|
||||
|
||||
// 通过 esmock 加载被测模块并 mock 专业版判断(isPlus),无需在实现里加注入点
|
||||
const { AuditLogMiddleware } = await esmock("./audit-log.js", {
|
||||
"@certd/plus-core": {
|
||||
isPlus: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
class TestController {
|
||||
import() {}
|
||||
@@ -22,7 +29,6 @@ function createMiddleware() {
|
||||
records.push(record);
|
||||
},
|
||||
} as any;
|
||||
(middleware as any).isPlusFn = async () => true;
|
||||
return { middleware, records };
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ export class AuditLogMiddleware implements IWebMiddleware {
|
||||
if (!isPlus()) {
|
||||
return;
|
||||
}
|
||||
// 匿名请求不记录操作日志
|
||||
if (!ctx.user?.id) {
|
||||
return;
|
||||
}
|
||||
let isSuccess = this.isSuccessResponse(ctx);
|
||||
|
||||
if (err) {
|
||||
|
||||
@@ -81,7 +81,8 @@ describe("LoginService.register", () => {
|
||||
describe("LoginService.generateScopedAccessToken", () => {
|
||||
it("adds the requested scopes to a short-lived JWT", async () => {
|
||||
const service = new LoginService();
|
||||
(service as any).jwt = { expire: 7200 };
|
||||
// 全局 jwt 过期时间大于受限令牌上限(6小时),验证受限令牌被截断到 6 小时
|
||||
(service as any).jwt = { expire: 8 * 60 * 60 };
|
||||
service.sysSettingsService = {
|
||||
async getSetting() {
|
||||
return { jwtKey: "test-secret" };
|
||||
@@ -99,6 +100,7 @@ describe("LoginService.generateScopedAccessToken", () => {
|
||||
const payload = jwt.verify(result.token, "test-secret") as jwt.JwtPayload;
|
||||
|
||||
assert.deepEqual(payload.scoped, ["sys/ai"]);
|
||||
assert.equal(result.expire, 3600);
|
||||
// 受限令牌短时效:截断到 6 小时上限
|
||||
assert.equal(result.expire, 6 * 60 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,6 +262,7 @@ export class LoginService {
|
||||
throw new CommonException("scoped不能为空");
|
||||
}
|
||||
const setting = await this.sysSettingsService.getSetting<SysPrivateSettings>(SysPrivateSettings);
|
||||
// 受限令牌短时效:不超过全局 jwt 过期时间,且上限 6 小时
|
||||
const expire = Math.min(this.jwt.expire, 6 * 60 * 60);
|
||||
const token = jwt.sign(
|
||||
{
|
||||
|
||||
@@ -33,10 +33,15 @@ describe("CertInfoService", () => {
|
||||
describe("updateCertByPipelineId 多条模式", () => {
|
||||
it("新建激活证书并绑定流水线id、继承已有记录来源", async () => {
|
||||
const service = new CertInfoService();
|
||||
// 该流水线已有旧证书记录(upload 来源)
|
||||
// 该流水线已有旧证书记录(upload 来源),但没有空证书记录
|
||||
const existing = { id: 10, userId: 2, projectId: 3, pipelineId: 1, fromType: "upload", status: CertStatus.inactive };
|
||||
service.repository = {
|
||||
async findOne() {
|
||||
async findOne(args: any) {
|
||||
const where = args?.where ?? args;
|
||||
if (where.certInfo !== undefined) {
|
||||
// 空证书记录(占位)查询:该流水线没有占位记录
|
||||
return null;
|
||||
}
|
||||
return existing;
|
||||
},
|
||||
async delete() {},
|
||||
@@ -176,12 +181,13 @@ describe("CertInfoService", () => {
|
||||
assert.equal(updateSet.status, CertStatus.inactive);
|
||||
});
|
||||
|
||||
it("空证书记录(带任务id)在申请成功后标记为未激活,避免同流水线多条 active 记录", async () => {
|
||||
it("存在空证书记录时,申请成功后直接更新占位记录,不新建记录", async () => {
|
||||
const service = new CertInfoService();
|
||||
// 保存流水线时创建的空证书记录(占位,certInfo 为空,已记录申请任务id)
|
||||
const emptyRecord = { id: 184, userId: 2, projectId: 1, pipelineId: 209, fromType: "pipeline", status: CertStatus.active, taskId: "PgaUhdeO1DWB_BLbIlzqV", certInfo: null };
|
||||
service.repository = {
|
||||
async findOne() {
|
||||
// 既有记录查询与空证书记录查询都命中同一条占位记录
|
||||
return emptyRecord;
|
||||
},
|
||||
} as any;
|
||||
@@ -190,8 +196,10 @@ describe("CertInfoService", () => {
|
||||
return { id: 209, userId: 2, projectId: 1, type: "cert_auto" };
|
||||
},
|
||||
} as any;
|
||||
let newBean: any = null;
|
||||
service.addOrUpdate = async (bean: any) => {
|
||||
bean.id = 185;
|
||||
// 更新占位记录:保留原 id,不分配新 id
|
||||
newBean = bean;
|
||||
return bean;
|
||||
};
|
||||
let updateWhere: any = null;
|
||||
@@ -203,10 +211,18 @@ describe("CertInfoService", () => {
|
||||
};
|
||||
|
||||
const cert = createSelfSignedCert();
|
||||
await service.updateCertByPipelineId(209, cert, "pipeline", "PgaUhdeO1DWB_BLbIlzqV");
|
||||
const bean = await service.updateCertByPipelineId(209, cert, "pipeline", "PgaUhdeO1DWB_BLbIlzqV");
|
||||
|
||||
// 空证书记录因 taskId 相同被标记为未激活,只剩新证书保持 active
|
||||
// 直接更新占位记录(id 不变),写入证书内容并保持激活,不再新建一条
|
||||
assert.equal(newBean.id, 184);
|
||||
assert.equal(newBean.taskId, "PgaUhdeO1DWB_BLbIlzqV");
|
||||
assert.equal(newBean.status, CertStatus.active);
|
||||
assert.ok(newBean.certInfo.includes("BEGIN CERTIFICATE"));
|
||||
assert.equal(bean.id, 184);
|
||||
// 标记未激活时排除占位记录自己(id: Not(184)),其他同任务 active 记录被标记
|
||||
assert.equal(updateWhere.pipelineId, 209);
|
||||
assert.equal(updateWhere.status, CertStatus.active);
|
||||
assert.equal(updateWhere.id._value, 184);
|
||||
assert.equal(updateWhere.taskId, "PgaUhdeO1DWB_BLbIlzqV");
|
||||
assert.equal(updateSet.status, CertStatus.inactive);
|
||||
});
|
||||
|
||||
@@ -149,12 +149,12 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
|
||||
* 流水线申请证书成功后写入证书仓库
|
||||
*
|
||||
* 每条流水线可保留多条证书记录:
|
||||
* 1. 新证书总是新建一条激活状态(active)的记录,并绑定流水线id与申请任务id;
|
||||
* 2. 证书来源(fromType)保持与该流水线原有记录一致(如 upload/auto);
|
||||
* 3. 同流水线、同申请任务的其他激活状态的证书记录被标记为未激活(inactive);
|
||||
* 4. 【重要】不得删除该流水线的空证书记录(certInfo 为空、由保存流水线时 updateDomains 维护的占位记录)。
|
||||
* 开放接口(OpenAPI autoApply)正是根据证书仓库中是否有该流水线的记录来判断“是否已申请过”:
|
||||
* 若删掉这条空记录,证书申请成功前开放接口会反复触发创建新流水线。空证书记录必须保留。
|
||||
* 1. 【重要】若该流水线存在空证书记录(certInfo 为空、由保存流水线时 updateDomains 创建的占位记录),
|
||||
* 直接更新这条占位记录写入证书内容,不要新建一条——占位记录是开放接口(OpenAPI autoApply)
|
||||
* 判断“是否已申请过”的依据,必须保留,更新它既能复用记录又保持记录数不变;
|
||||
* 2. 没有占位记录时,新证书新建一条激活状态(active)的记录,并绑定流水线id与申请任务id;
|
||||
* 3. 证书来源(fromType)保持与该流水线原有记录一致(如 upload/auto);
|
||||
* 4. 同流水线、同申请任务的其他激活状态的证书记录被标记为未激活(inactive)。
|
||||
*/
|
||||
async updateCertByPipelineId(pipelineId: number, cert: CertInfo, fromType?: string, taskId?: string) {
|
||||
const pipeline = await this.pipelineRepository.findOne({
|
||||
@@ -176,6 +176,17 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
|
||||
const certUserId = pipeline?.userId;
|
||||
const certProjectId = pipeline?.projectId;
|
||||
|
||||
// 查找该流水线的空证书记录(占位记录,certInfo 为空):存在则直接更新它,不新建记录
|
||||
const placeholderRecord = await this.repository.findOne({
|
||||
where: {
|
||||
pipelineId,
|
||||
taskId: taskId ?? IsNull(),
|
||||
certInfo: IsNull(),
|
||||
...this.buildUserProjectQuery(certUserId, certProjectId),
|
||||
},
|
||||
order: { id: "DESC" },
|
||||
});
|
||||
|
||||
const bean = await this.updateCert({
|
||||
certReader: new CertReader(cert),
|
||||
fromType: certFromType,
|
||||
@@ -183,6 +194,7 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
|
||||
projectId: certProjectId,
|
||||
pipelineId,
|
||||
taskId,
|
||||
id: placeholderRecord?.id,
|
||||
});
|
||||
// 旧证书标记为未激活(仅同申请任务产出的旧证书,避免误伤其他任务仍在使用的证书)
|
||||
await this.repository.update(
|
||||
@@ -289,7 +301,9 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
|
||||
const bean = new CertInfoEntity();
|
||||
const { id, fromType, userId, certReader } = req;
|
||||
if (id) {
|
||||
// 更新已有记录(如空证书记录占位被申请成功复用):写入新证书内容后该记录应为激活状态
|
||||
bean.id = id;
|
||||
bean.status = CertStatus.active;
|
||||
} else {
|
||||
bean.fromType = fromType;
|
||||
// 新证书默认为激活状态
|
||||
|
||||
+2
@@ -25,6 +25,8 @@ function createPlugin(opts: { revokeOld?: boolean; list?: any[]; pipeline?: any;
|
||||
const { revokeOld = true, list = [], pipeline = createApplyPipeline(), filterByTaskId = true } = opts;
|
||||
const plugin = new CertRevokeOldPlugin();
|
||||
plugin.revokeOld = revokeOld;
|
||||
// 默认不等待(避免每个用例都走 sleep);等待场景由专门用例单独配置 delay 与 utils
|
||||
plugin.delay = 0;
|
||||
plugin.logger = { info: () => {}, warn: () => {}, error: () => {} } as any;
|
||||
plugin.ctx = {
|
||||
pipeline,
|
||||
|
||||
Reference in New Issue
Block a user