chore(plugin): 完善插件管理功能,优化作者校验与异常处理

1.  新增插件作者必填校验,修复本地插件命名逻辑
2.  优化在线插件安装的异常处理,支持隐藏错误通知
3.  更新文档规范与单元测试用例
4.  重构插件作者字段组件,支持手动输入作者信息
This commit is contained in:
xiaojunnuo
2026-08-12 00:43:32 +08:00
parent 7138243c63
commit cacd108802
9 changed files with 186 additions and 33 deletions
+8 -2
View File
@@ -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;只有跨包影响明显时再考虑更大范围构建。
## 规范回顾
- 每次任务收尾时,回顾用户提出的要求,识别其中可跨任务复用、稳定且不与既有规则冲突的约束。
- 只将经过确认的共性要求精炼后写入本文;一次性任务细节、临时偏好和未经确认的推断不要写入长期规范。
- 更新本文后检查规则是否清晰、可执行,并删除重复或互相矛盾的表述。
## 后端规则
@@ -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,
});
}
@@ -1,7 +1,7 @@
<template>
<div class="plugin-author-field">
<span v-if="authorName" class="plugin-author-field__name">{{ authorName }}</span>
<a-button v-else type="link" size="small" :loading="loading" @click="registerAuthor">注册作者</a-button>
<a-input :value="modelValue" placeholder="请输入作者名称" @update:value="emit('update:modelValue', $event)" />
<a-button type="link" size="small" :loading="loading" @click="registerAuthor">注册作者</a-button>
</div>
</template>
@@ -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<api.OnlinePluginAuthorBean>();
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();
});
</script>
<style lang="less">
@@ -51,9 +59,11 @@ onMounted(loadAuthor);
display: flex;
min-height: 32px;
align-items: center;
gap: 6px;
&__name {
color: #1f2937;
.ant-input {
min-width: 0;
flex: 1;
}
}
</style>
@@ -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 || "";
});
@@ -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("插件记录已删除");
});
},
});
}
@@ -407,6 +407,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
name: PluginAuthorField,
vModel: "modelValue",
},
rules: [{ required: true, message: "请先注册并选择插件作者" }],
},
column: {
width: 200,
@@ -44,7 +44,6 @@ process.on("uncaughtException", error => {
// log()
// }
// startHeapLog();
@Configuration({
detectorOptions: {
@@ -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 () => {
@@ -599,12 +599,27 @@ export class PluginService extends BaseService<PluginEntity> {
return this.builtInPluginService.getByType(type);
}
private normalizeStorePluginAuthor(plugin: Record<string, any>) {
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<PluginEntity> {
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<string, any>;
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<PluginEntity> {
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<string, any>;
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<PluginEntity> {
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<PluginEntity> {
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<PluginEntity> {
}
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") {