mirror of
https://github.com/certd/certd.git
synced 2026-10-13 07:38:56 +08:00
chore(plugin): 完善插件管理功能,优化作者校验与异常处理
1. 新增插件作者必填校验,修复本地插件命名逻辑 2. 优化在线插件安装的异常处理,支持隐藏错误通知 3. 更新文档规范与单元测试用例 4. 重构插件作者字段组件,支持手动输入作者信息
This commit is contained in:
@@ -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>
|
||||
|
||||
+3
-2
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user