From cacd1088024fbe5bab60413b2e4ec91ac9850d7d Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Wed, 12 Aug 2026 00:43:32 +0800 Subject: [PATCH] =?UTF-8?q?chore(plugin):=20=E5=AE=8C=E5=96=84=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BD=9C=E8=80=85=E6=A0=A1=E9=AA=8C=E4=B8=8E=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增插件作者必填校验,修复本地插件命名逻辑 2. 优化在线插件安装的异常处理,支持隐藏错误通知 3. 更新文档规范与单元测试用例 4. 重构插件作者字段组件,支持手动输入作者信息 --- AGENTS.md | 10 ++- .../certd-client/src/views/sys/plugin/api.ts | 3 +- .../plugin/components/plugin-author-field.vue | 30 +++++--- .../components/plugin-edit-dialog-body.vue | 5 +- .../plugin/components/plugin-item-card.vue | 46 +++++++++++-- .../src/views/sys/plugin/crud.tsx | 1 + packages/ui/certd-server/src/configuration.ts | 1 - .../plugin/service/plugin-service.test.ts | 54 ++++++++++++--- .../modules/plugin/service/plugin-service.ts | 69 +++++++++++++++++-- 9 files changed, 186 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf6b3e696..bbd2cfe57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,7 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web ## 通用工作规则 - 先读本文,再按任务读取具体代码或技能文件。 +- 与用户沟通、代码注释、文档、日志和面向用户的界面文案,尽量使用中文;技术专有名词、代码标识符、第三方库名称和命令可保留英文。 - PowerShell 读取中文、Markdown、locale、文档类文件时使用 `Get-Content -Raw -Encoding UTF8`;仍乱码时先执行 `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()`。 - PowerShell 中用 `rg` 搜索含引号、括号、反斜杠的 pattern 时,优先用单引号包裹整个 pattern,例如 `rg 'await import\("tencentcloud-sdk-nodejs' packages/ui/certd-server/src -g '*.ts'`。 - 手工编辑或创建文件时优先使用 `apply_patch`。单个文件内有多处不连续改动时,拆成多个独立的 `*** Update File` 块,每块只改一处附近上下文;不要在同一个 update hunk 里强塞多个 `@@`。 @@ -83,8 +84,7 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web ## 测试与验证 -- 务必写单元测试,覆盖主要业务逻辑。 -- 实现新功能或修复行为缺陷前,优先补单元测试并先确认红灯,再实现并跑聚焦验证。 +- 新功能、缺陷修复和重构遵循 TDD:先编写或更新测试,覆盖正常路径、边界条件和明确的错误处理;先运行测试确认 RED,再实现最小改动,最后运行聚焦测试确认 GREEN。 - 单元测试应优先直接测试原始业务方法,不要为了方便测试而抽取没有业务价值的 helper;数据库、RPC 和其他外部依赖可以使用 mock 隔离。 - 确实不适合先写测试时,在回复中说明原因和替代验证方式。 - 后补单元测试时,按正确行为写预期;若红灯需要修改既有实现,先向用户确认这是 bug 还是既有需求,避免未经确认改变行为。 @@ -94,6 +94,12 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web - 单包单测优先用 `cd <包目录> && npm run test:unit`,例如 `cd packages\ui\certd-server && npm run test:unit`。 - 优先对改动包运行聚焦测试或格式化/ESLint;只有跨包影响明显时再考虑更大范围构建。 +## 规范回顾 + +- 每次任务收尾时,回顾用户提出的要求,识别其中可跨任务复用、稳定且不与既有规则冲突的约束。 +- 只将经过确认的共性要求精炼后写入本文;一次性任务细节、临时偏好和未经确认的推断不要写入长期规范。 +- 更新本文后检查规则是否清晰、可执行,并删除重复或互相矛盾的表述。 + ## 后端规则 diff --git a/packages/ui/certd-client/src/views/sys/plugin/api.ts b/packages/ui/certd-client/src/views/sys/plugin/api.ts index 99360b871..a59e42404 100644 --- a/packages/ui/certd-client/src/views/sys/plugin/api.ts +++ b/packages/ui/certd-client/src/views/sys/plugin/api.ts @@ -159,11 +159,12 @@ export async function OnlinePluginSetting(): Promise<{ lastSyncTime?: number }> }); } -export async function OnlinePluginInstall(body: { fullName: string; version?: string }) { +export async function OnlinePluginInstall(body: { fullName: string; version?: string }, options?: { showErrorNotify?: boolean }) { return await request({ url: apiPrefix + "/online/install", method: "post", data: body, + showErrorNotify: options?.showErrorNotify, }); } diff --git a/packages/ui/certd-client/src/views/sys/plugin/components/plugin-author-field.vue b/packages/ui/certd-client/src/views/sys/plugin/components/plugin-author-field.vue index d17843d5e..575698032 100644 --- a/packages/ui/certd-client/src/views/sys/plugin/components/plugin-author-field.vue +++ b/packages/ui/certd-client/src/views/sys/plugin/components/plugin-author-field.vue @@ -1,7 +1,7 @@ @@ -15,16 +15,20 @@ defineOptions({ name: "PluginAuthorField" }); const props = defineProps<{ modelValue?: string }>(); const emit = defineEmits<{ (event: "update:modelValue", value: string): void }>(); const loading = ref(false); -const registeredAuthor = ref(); const { registerPluginAuthor } = usePluginPublish(); -const authorName = computed(() => props.modelValue || registeredAuthor.value?.name || ""); +const isLocalAuthor = computed(() => { + return ( + String(props.modelValue || "") + .trim() + .toLowerCase() === "local" + ); +}); async function loadAuthor() { loading.value = true; try { const result = await api.OnlinePluginAuthorGet(); - registeredAuthor.value = result.author; - if (!props.modelValue && result.author?.name) { + if ((!props.modelValue || isLocalAuthor.value) && result.author?.name) { emit("update:modelValue", result.author.name); } } catch { @@ -39,11 +43,15 @@ async function registerAuthor() { if (!author?.name) { return; } - registeredAuthor.value = author; emit("update:modelValue", author.name); } -onMounted(loadAuthor); +onMounted(async () => { + if (isLocalAuthor.value) { + emit("update:modelValue", ""); + } + await loadAuthor(); +}); diff --git a/packages/ui/certd-client/src/views/sys/plugin/components/plugin-edit-dialog-body.vue b/packages/ui/certd-client/src/views/sys/plugin/components/plugin-edit-dialog-body.vue index 145288c7b..9075129bd 100644 --- a/packages/ui/certd-client/src/views/sys/plugin/components/plugin-edit-dialog-body.vue +++ b/packages/ui/certd-client/src/views/sys/plugin/components/plugin-edit-dialog-body.vue @@ -83,8 +83,9 @@ function initFormOptions() { initFormOptions(); const pluginName = computed(() => { - if (plugin.value.author) { - return `${plugin.value.author}/${plugin.value.name}`; + const author = String(plugin.value.author || "").trim(); + if (author && author.toLowerCase() !== "local") { + return `${author}/${plugin.value.name}`; } return plugin.value.name || ""; }); diff --git a/packages/ui/certd-client/src/views/sys/plugin/components/plugin-item-card.vue b/packages/ui/certd-client/src/views/sys/plugin/components/plugin-item-card.vue index f69a248e4..929504b6e 100644 --- a/packages/ui/certd-client/src/views/sys/plugin/components/plugin-item-card.vue +++ b/packages/ui/certd-client/src/views/sys/plugin/components/plugin-item-card.vue @@ -442,12 +442,48 @@ async function installPlugin() { if (!fullName) { return; } - await runAction("install", async () => { - await api.OnlinePluginInstall({ - fullName, - version: props.plugin.latest, + try { + await runAction("install", async () => { + await api.OnlinePluginInstall( + { + fullName, + version: props.plugin.latest, + }, + { + showErrorNotify: false, + } + ); + message.success(t("certd.onlinePluginInstallSuccess")); }); - message.success(t("certd.onlinePluginInstallSuccess")); + } catch (error: any) { + if (!isOnlinePluginMissing(error)) { + throw error; + } + confirmRemoveMissingOnlinePlugin(); + } +} + +function isOnlinePluginMissing(error: unknown) { + return error instanceof Error && error.message.includes("插件不存在"); +} + +function confirmRemoveMissingOnlinePlugin() { + if (!props.plugin.id) { + return; + } + Modal.confirm({ + title: "插件不存在", + content: `在线市场已找不到插件「${fullNameValue() || props.plugin.title || props.plugin.name}」,是否删除本地同步的插件记录?`, + okText: "删除插件", + cancelText: "保留记录", + okButtonProps: { danger: true }, + async onOk() { + await runAction("remove", async () => { + await api.DelObj(props.plugin.id); + await pluginStore.reload(); + message.success("插件记录已删除"); + }); + }, }); } diff --git a/packages/ui/certd-client/src/views/sys/plugin/crud.tsx b/packages/ui/certd-client/src/views/sys/plugin/crud.tsx index 96dab1951..e5a2208cc 100644 --- a/packages/ui/certd-client/src/views/sys/plugin/crud.tsx +++ b/packages/ui/certd-client/src/views/sys/plugin/crud.tsx @@ -407,6 +407,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat name: PluginAuthorField, vModel: "modelValue", }, + rules: [{ required: true, message: "请先注册并选择插件作者" }], }, column: { width: 200, diff --git a/packages/ui/certd-server/src/configuration.ts b/packages/ui/certd-server/src/configuration.ts index 7cf2a36c3..34aa57541 100644 --- a/packages/ui/certd-server/src/configuration.ts +++ b/packages/ui/certd-server/src/configuration.ts @@ -44,7 +44,6 @@ process.on("uncaughtException", error => { // log() // } - // startHeapLog(); @Configuration({ detectorOptions: { diff --git a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.test.ts b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.test.ts index 6778ec3a6..a42f40333 100644 --- a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.test.ts +++ b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.test.ts @@ -236,6 +236,13 @@ describe("PluginService online plugins", () => { assert.equal(getPluginFullName({ name: "developer/plugin", author: "developer" }), "developer/plugin"); }); + it("requires a user-provided author for local store plugin edits", async () => { + const service = new PluginService(); + + await assert.rejects(() => service.add({ type: "store", author: "local", name: "demo", pluginType: "deploy" }), /请先填写插件作者/); + await assert.rejects(() => service.update({ id: 1, type: "store", author: "", name: "demo" }), /请先填写插件作者/); + }); + it("marks online plugins with local installation state", async () => { const service = new PluginService(); service.repository = createPluginRepositoryMock({ @@ -563,9 +570,12 @@ describe("PluginService online plugins", () => { assert.deepEqual(config.data, { fullName: "greper/DeployToDemo", version: "1.0.1" }); return { fullName: "greper/DeployToDemo", - content: "name: DeployToDemo\npluginType: deploy\nauthor: greper\ncontent: |\n return class Demo {}\n", + content: "name: DeployToDemo\npluginType: deploy\nauthor: local\ncontent: |\n return class Demo {}\n", plugin: { fullName: "greper/DeployToDemo", + author: "greper", + appId: 3, + developerId: 12, downloadCount: 88, }, version: { @@ -592,6 +602,8 @@ describe("PluginService online plugins", () => { assert.equal(importReq.override, true); assert.equal(importReq.type, "store"); assert.match(importReq.content, /DeployToDemo/); + assert.match(importReq.content, /^author: greper$/m); + assert.match(importReq.content, /^fullName: greper\/DeployToDemo$/m); assert.deepEqual(downloadUpdates[0].where, { type: "store", fullName: "greper/DeployToDemo", @@ -770,6 +782,7 @@ describe("PluginService online plugins", () => { it("publishes a local store plugin through activation with exported content", async () => { const service = new PluginService(); let requestConfig: any; + let updatedPlugin: any; let registered = false; service.sysSettingsService = { @@ -783,9 +796,28 @@ describe("PluginService online plugins", () => { }, async request(config: any) { requestConfig = config; - return { plugin: { fullName: "developer/custom-one" }, version: { version: "1.2.3" } }; + return { + plugin: { + appId: 3, + developerId: 12, + author: "developer", + fullName: "developer/custom-one", + latest: "1.2.3", + status: "reviewing", + }, + version: { version: "1.2.3" }, + }; }, } as any; + service.getOnlinePluginAuthor = async () => { + return { + registered: true, + author: { + id: 2, + name: "developer", + }, + }; + }; service.info = async () => { return { id: 7, @@ -797,6 +829,9 @@ describe("PluginService online plugins", () => { service.exportPlugin = async () => { return "name: custom-one\nversion: 1.2.3\n"; }; + service.update = async plugin => { + updatedPlugin = plugin; + }; const res = await service.publishLocalPlugin({ id: 7 }); @@ -804,12 +839,15 @@ describe("PluginService online plugins", () => { assert.equal(res.plugin.fullName, "developer/custom-one"); assert.equal(requestConfig.url, "/activation/plugin/publish"); assert.equal(requestConfig.method, "post"); - assert.deepEqual(requestConfig.data, { - content: "name: custom-one\nversion: 1.2.3\n", - version: "1.2.3", - minAppVersion: "", - maxAppVersion: "", - }); + assert.match(requestConfig.data.content, /^author: developer$/m); + assert.match(requestConfig.data.content, /^fullName: developer\/custom-one$/m); + assert.equal(requestConfig.data.version, "1.2.3"); + assert.equal(requestConfig.data.minAppVersion, ""); + assert.equal(requestConfig.data.maxAppVersion, ""); + assert.equal(updatedPlugin.appId, 3); + assert.equal(updatedPlugin.developerId, 12); + assert.equal(updatedPlugin.author, "developer"); + assert.equal(updatedPlugin.fullName, "developer/custom-one"); }); it("gets and creates online plugin author for the bound user", async () => { diff --git a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts index 6c6372fb4..dd892fa64 100644 --- a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts +++ b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts @@ -599,12 +599,27 @@ export class PluginService extends BaseService { return this.builtInPluginService.getByType(type); } + private normalizeStorePluginAuthor(plugin: Record) { + const author = `${plugin.author || ""}`.trim(); + if (!author) { + throw new Error("请先填写插件作者"); + } + plugin.author = author; + const name = `${plugin.name || ""}`.trim(); + if (name) { + plugin.fullName = `${author}/${name}`; + } + } + /** * 新增 * @param param 数据 */ async add(param: any) { param.type = normalizePluginSourceType(param.type); + if (param.type === "store") { + this.normalizeStorePluginAuthor(param); + } const old = await this.findOne({ where: { name: param.name, @@ -924,7 +939,18 @@ export class PluginService extends BaseService { throw new Error("插件内容为空"); } - const content = fillOnlinePluginYamlVersion(res.content, res.version?.version || req.version); + const downloadedFullName = (res.fullName || res.plugin?.fullName || fullName).trim(); + const downloadedIdentity = parseOnlinePluginFullName(downloadedFullName); + const downloadedContent = fillOnlinePluginYamlVersion(res.content, res.version?.version || req.version); + const contentPlugin = yaml.load(downloadedContent) as Record; + if (!contentPlugin || typeof contentPlugin !== "object") { + throw new Error("插件内容格式错误"); + } + contentPlugin.author = res.plugin?.author || downloadedIdentity.author; + contentPlugin.fullName = downloadedFullName; + contentPlugin.appId = res.plugin?.appId || contentPlugin.appId; + contentPlugin.developerId = res.plugin?.developerId || contentPlugin.developerId; + const content = yaml.dump(contentPlugin); const importRes = await this.importPlugin({ content, override: true, @@ -995,9 +1021,23 @@ export class PluginService extends BaseService { if (!canEditStorePlugin(plugin.developerId, installInfo.bindUserId)) { throw new Error("当前绑定账号无权编辑该插件"); } - const content = await this.exportPlugin(req.id); + const authorReply = await this.getOnlinePluginAuthor(); + const author = authorReply.author; + if (!authorReply.registered || !author?.name) { + throw new Error("请先注册插件作者后再发布插件"); + } + + const exportedContent = await this.exportPlugin(req.id); + const publishContent = yaml.load(exportedContent) as Record; + if (!publishContent || typeof publishContent !== "object") { + throw new Error("插件内容格式错误"); + } + publishContent.author = author.name; + publishContent.fullName = `${author.name}/${plugin.name}`; + const content = yaml.dump(publishContent); + await this.plusService.register(); - return await this.plusService.request({ + const publishReply = await this.plusService.request({ url: "/activation/plugin/publish", method: "post", data: { @@ -1007,6 +1047,21 @@ export class PluginService extends BaseService { maxAppVersion: req.maxAppVersion || "", }, }); + + const marketPlugin = publishReply?.plugin as OnlinePluginBean | undefined; + const publishedAuthor = marketPlugin?.author || author.name; + const publishedFullName = marketPlugin?.fullName || `${publishedAuthor}/${plugin.name}`; + await this.update({ + ...plugin, + appId: marketPlugin?.appId || plugin.appId, + developerId: marketPlugin?.developerId || installInfo.bindUserId, + author: publishedAuthor, + fullName: publishedFullName, + latest: marketPlugin?.latest || plugin.latest, + status: marketPlugin?.status || plugin.status, + vip: marketPlugin?.vip || plugin.vip, + }); + return publishReply; } async ensurePluginEditable(id: number) { @@ -1170,6 +1225,9 @@ export class PluginService extends BaseService { async update(param: any) { param.type = normalizePluginSourceType(param.type); + if (param.type === "store") { + this.normalizeStorePluginAuthor(param); + } let old: PluginEntity | null; if (param.type === "store" && param.fullName) { old = await this.findOne({ @@ -1388,8 +1446,11 @@ export class PluginService extends BaseService { } delete loaded.id; - const fullName = loaded.fullName || (loaded.author && loaded.name ? `${loaded.author}/${loaded.name}` : ""); const entityType = normalizePluginSourceType(req.type || loaded.type); + if (entityType === "store") { + this.normalizeStorePluginAuthor(loaded); + } + const fullName = loaded.fullName || (loaded.author && loaded.name ? `${loaded.author}/${loaded.name}` : ""); let old: PluginEntity | null; if (entityType === "store") {