diff --git a/AGENTS.md b/AGENTS.md
index 43cc830b9..cd4f1545c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -177,6 +177,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- 优先沿用现有模块、插件、服务模式,再考虑新增抽象。
- `packages/ui/certd-server/data/`、`logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。
- 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。
+- 使用 `/basic/file/upload` 上传文件后,接口返回的是临时缓存 key。业务保存表单或设置时,后端必须调用 `FileService.saveFile(userId, key, "public" | "private")` 转成永久文件 key 后再入库/入设置;不要直接保存 `tmpfile_key_...`,否则后续回显或下载会失效。
- 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。
- 代码可读性优先于短写法。遇到包含业务分支的复杂三元表达式、内联对象、链式调用或条件组合时,优先拆成命名清晰的中间变量、独立分支或小函数,让读代码的人能一眼看出业务意图;不要为了少写几行把逻辑压成难读的一坨。
@@ -232,4 +233,3 @@ Get-ChildItem packages\ui\certd-client\src\views\certd
- 优先对改动包运行聚焦的测试;后端可按包运行单元测试,前端优先使用 Prettier/ESLint 做改动文件验证。只有跨包影响明显时再考虑全 monorepo 构建。
- 不要主动运行 `pnpm install` 安装依赖:用户会事先准备好 `node_modules`。如果 `pnpm install` 或 `test:unit` 因缺少依赖、TTY 或网络问题失败,立即停止尝试,告知用户解决环境问题。
-
diff --git a/packages/libs/lib-server/src/basic/constants.ts b/packages/libs/lib-server/src/basic/constants.ts
index 407683b8c..73de33205 100644
--- a/packages/libs/lib-server/src/basic/constants.ts
+++ b/packages/libs/lib-server/src/basic/constants.ts
@@ -8,6 +8,8 @@ export const Constants = {
guest: '_guest_',
//无需登录
anonymous: '_guest_',
+ //无需登录,有 token 时解析当前用户
+ guestOptionalAuth: '_guestOptionalAuth_',
//仅需要登录
authOnly: '_authOnly_',
//仅需要登录
diff --git a/packages/libs/lib-server/src/system/basic/service/file-service.test.ts b/packages/libs/lib-server/src/system/basic/service/file-service.test.ts
new file mode 100644
index 000000000..b5dfca3aa
--- /dev/null
+++ b/packages/libs/lib-server/src/system/basic/service/file-service.test.ts
@@ -0,0 +1,43 @@
+///
+///
+
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { FileService } from "./file-service.js";
+
+function createUploadFile(key: string) {
+ const uploadRootDir = "./data/upload";
+ const filePath = path.join(uploadRootDir, key);
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, "test");
+ return filePath;
+}
+
+describe("FileService.getFile", () => {
+ let cwd: string;
+ let oldCwd: string;
+
+ beforeEach(() => {
+ oldCwd = process.cwd();
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), "certd-file-service-"));
+ process.chdir(cwd);
+ });
+
+ afterEach(() => {
+ process.chdir(oldCwd);
+ fs.rmSync(cwd, { recursive: true, force: true });
+ });
+
+ it("allows admin to read another user's private file", () => {
+ const service = new FileService();
+ const userIdMd5 = Buffer.from(Buffer.from("2").toString("base64")).toString("hex");
+ const key = `/private/${userIdMd5}/2026_05_25/qr.png`;
+ const expectedPath = createUploadFile(key);
+
+ const filePath = service.getFile(key, 1, true);
+
+ assert.equal(filePath, expectedPath);
+ });
+});
diff --git a/packages/libs/lib-server/src/system/basic/service/file-service.ts b/packages/libs/lib-server/src/system/basic/service/file-service.ts
index deb1a3012..4dc782f8e 100644
--- a/packages/libs/lib-server/src/system/basic/service/file-service.ts
+++ b/packages/libs/lib-server/src/system/basic/service/file-service.ts
@@ -56,7 +56,7 @@ export class FileService {
return key;
}
- getFile(key: string, userId?: number) {
+ getFile(key: string, userId?: number, allowAnyPrivateUser = false) {
if (!key) {
throw new ParamException('参数错误');
}
@@ -70,7 +70,7 @@ export class FileService {
const keyArr = key.split('/');
const permission = keyArr[1];
const userIdMd5 = keyArr[2];
- if (permission !== 'public') {
+ if (permission !== 'public' && !allowAnyPrivateUser) {
//非公开文件需要验证用户
const userIdStr = Buffer.from(Buffer.from(userIdMd5, 'hex').toString('base64')).toString();
const userIdInt: number = parseInt(userIdStr, 10);
diff --git a/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx b/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx
index ae90e3328..77d0bbe8c 100644
--- a/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx
+++ b/packages/ui/certd-client/src/views/certd/wallet/crud-withdraw.tsx
@@ -1,6 +1,12 @@
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";
+import { useUserStore } from "/@/store/user";
+
+function buildPrivateFileUrl(key: string) {
+ const userStore = useUserStore();
+ return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`;
+}
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise => {
@@ -59,7 +65,7 @@ export default function (): CreateCrudOptionsRet {
if (!value) {
return "-";
}
- return ;
+ return ;
},
},
},
diff --git a/packages/ui/certd-client/src/views/certd/wallet/index.vue b/packages/ui/certd-client/src/views/certd/wallet/index.vue
index 03dfb90a0..26e3dc082 100644
--- a/packages/ui/certd-client/src/views/certd/wallet/index.vue
+++ b/packages/ui/certd-client/src/views/certd/wallet/index.vue
@@ -14,9 +14,11 @@
提现操作
-
提现设置
-
-
申请提现
+
提现设置
+
+
申请提现
@@ -62,6 +64,10 @@ function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`;
}
+function buildPrivateFileUrl(key: string) {
+ return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`;
+}
+
const summaryCards = computed(() => [
{
key: "availableAmount",
@@ -139,6 +145,7 @@ async function openWithdrawSetting() {
type: "cropper-uploader",
form: {
col: { span: 24 },
+ show: compute(({ form }) => form.channel !== "bank"),
component: {
vModel: "modelValue",
valueType: "key",
@@ -160,7 +167,7 @@ async function openWithdrawSetting() {
},
},
buildUrl(key: string) {
- return `/api/basic/file/download?key=` + key;
+ return buildPrivateFileUrl(key);
},
},
},
@@ -176,6 +183,9 @@ async function openWithdrawSetting() {
},
},
async onSubmit(form: any) {
+ if (form.channel === "bank") {
+ form.qrCode = "";
+ }
await api.SaveWithdrawSetting(form);
notification.success({ message: "保存成功" });
},
@@ -303,13 +313,26 @@ onActivated(async () => {
.wallet-action-content {
display: flex;
align-items: center;
+ flex: 1;
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
+ min-width: 0;
}
- .withdraw-amount-input {
- width: 240px;
+ .wallet-action-button {
+ flex: none;
+ }
+
+ .withdraw-amount-field {
+ flex: 0 1 280px;
+ min-width: 240px;
+ }
+
+ .withdraw-amount-field,
+ .withdraw-amount-input,
+ .withdraw-amount-input.ant-input-number-group-wrapper {
+ width: 100%;
}
.wallet-tabs {
@@ -359,9 +382,15 @@ onActivated(async () => {
justify-content: flex-start;
}
- .withdraw-amount-input {
+ .wallet-action-button,
+ .withdraw-amount-field {
width: 100%;
}
+
+ .withdraw-amount-field {
+ flex-basis: auto;
+ min-width: 0;
+ }
}
}
diff --git a/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx b/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx
index 5152c925e..3ad104717 100644
--- a/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx
+++ b/packages/ui/certd-client/src/views/sys/suite/invite/crud-withdraw.tsx
@@ -3,6 +3,12 @@ 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";
+import { useUserStore } from "/@/store/user";
+
+function buildPrivateFileUrl(key: string) {
+ const userStore = useUserStore();
+ return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`;
+}
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog();
@@ -19,7 +25,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
{row.account || "-"}
{row.bankName || "-"}
- {row.qrCode ? : -}
+ {row.qrCode ? : -}
{row.amount / 100} 元
@@ -157,7 +163,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
if (!value) {
return "-";
}
- return ;
+ return ;
},
},
},
diff --git a/packages/ui/certd-server/src/controller/basic/file-controller.ts b/packages/ui/certd-server/src/controller/basic/file-controller.ts
index 1fb6bd203..c96747f47 100644
--- a/packages/ui/certd-server/src/controller/basic/file-controller.ts
+++ b/packages/ui/certd-server/src/controller/basic/file-controller.ts
@@ -1,9 +1,10 @@
import { Controller, Fields, Files, Get, Inject, Post, Provide, Query } from '@midwayjs/core';
-import { BaseController, Constants, FileService, UploadFileItem, uploadTmpFileCacheKey } from '@certd/lib-server';
+import { BaseController, Constants, FileService, PermissionException, UploadFileItem, uploadTmpFileCacheKey } from '@certd/lib-server';
import send from 'koa-send';
import { nanoid } from 'nanoid';
import { cache } from '@certd/basic';
import { UploadFileInfo } from '@midwayjs/upload';
+import { AuthService } from '../../modules/sys/authority/service/auth-service.js';
const imageExtSet = new Set(['.apng', '.avif', '.bmp', '.gif', '.ico', '.jpeg', '.jpg', '.png', '.svg', '.webp']);
const imageCacheSeconds = 3 * 24 * 60 * 60;
@@ -32,6 +33,9 @@ export class FileController extends BaseController {
@Inject()
fileService: FileService;
+ @Inject()
+ authService: AuthService;
+
@Post('/upload', { description: Constants.per.authOnly })
async upload(@Files() files: UploadFileInfo[], @Fields() fields: any) {
console.log('files', files, fields);
@@ -52,17 +56,30 @@ export class FileController extends BaseController {
});
}
- @Get('/download', { description: Constants.per.guest })
+ @Get('/download', { description: Constants.per.guestOptionalAuth })
async download(@Query('key') key: string) {
- let userId: any = null;
- if (!key.startsWith('/public')) {
- userId = this.getUserId();
- }
- const filePath = this.fileService.getFile(key, userId);
+ const filePath = this.getDownloadFilePath(key);
const sendOptions = getImageDownloadOptions(filePath);
if (!sendOptions) {
this.ctx.response.attachment(filePath);
}
await send(this.ctx, filePath, sendOptions);
}
+
+ private getDownloadFilePath(key: string) {
+ const isPrivateFile = !key.startsWith('/public');
+ const userId = isPrivateFile ? this.getUserId() : null;
+ try {
+ return this.fileService.getFile(key, userId);
+ } catch (e) {
+ if (!(e instanceof PermissionException) || !isPrivateFile || !this.authService.isAdmin(this.ctx)) {
+ throw e;
+ }
+ const adminFilePath = this.fileService.getFile(key, userId, true);
+ if (!isImageFile(adminFilePath)) {
+ throw e;
+ }
+ return adminFilePath;
+ }
+ }
}
diff --git a/packages/ui/certd-server/src/middleware/authority.test.ts b/packages/ui/certd-server/src/middleware/authority.test.ts
new file mode 100644
index 000000000..67a7a4f67
--- /dev/null
+++ b/packages/ui/certd-server/src/middleware/authority.test.ts
@@ -0,0 +1,56 @@
+///
+///
+
+import assert from "node:assert/strict";
+import jwt from "jsonwebtoken";
+import { Constants } from "@certd/lib-server";
+import { AuthorityMiddleware } from "./authority.js";
+
+function createMiddleware(permission: string) {
+ const middleware = new AuthorityMiddleware();
+ middleware.secret = "test-secret";
+ middleware.webRouterService = {
+ async getMatchedRouterInfo() {
+ return { description: permission };
+ },
+ } as any;
+ return middleware;
+}
+
+function createCtx(token?: string) {
+ return {
+ path: "/api/basic/file/download",
+ method: "GET",
+ query: token ? { token } : {},
+ headers: {},
+ get() {
+ return "";
+ },
+ } as any;
+}
+
+describe("AuthorityMiddleware guestOptionalAuth", () => {
+ it("continues without user when token is not provided", async () => {
+ const middleware = createMiddleware(Constants.per.guestOptionalAuth);
+ const ctx = createCtx();
+ let called = false;
+
+ await middleware.resolve()(ctx, async () => {
+ called = true;
+ });
+
+ assert.equal(called, true);
+ assert.equal(ctx.user, undefined);
+ });
+
+ it("sets user when token is provided", async () => {
+ const middleware = createMiddleware(Constants.per.guestOptionalAuth);
+ const token = jwt.sign({ id: 1, roles: [1] }, middleware.secret);
+ const ctx = createCtx(token);
+
+ await middleware.resolve()(ctx, async () => {});
+
+ assert.equal(ctx.user.id, 1);
+ assert.deepEqual(ctx.user.roles, [1]);
+ });
+});
diff --git a/packages/ui/certd-server/src/middleware/authority.ts b/packages/ui/certd-server/src/middleware/authority.ts
index 6452aa1dc..78d189487 100644
--- a/packages/ui/certd-server/src/middleware/authority.ts
+++ b/packages/ui/certd-server/src/middleware/authority.ts
@@ -52,29 +52,7 @@ export class AuthorityMiddleware implements IWebMiddleware {
return;
}
- let token = ctx.get('Authorization') || '';
- token = token.replace('Bearer ', '').trim();
- if (!token) {
- //尝试从cookie中获取token
- const cookie = ctx.headers.cookie;
- if (cookie) {
- const items = cookie.split(';');
- for (const item of items) {
- if (!item || !item.trim()) {
- continue;
- }
- const [key, value] = item.split('=');
- if (key.trim() === 'certd_token') {
- token = value.trim();
- break;
- }
- }
- }
- }
- if (!token) {
- //尝试从query中获取token
- token = (ctx.query.token as string) || '';
- }
+ const token = this.getTokenFromRequest(ctx);
if (token) {
try {
@@ -84,6 +62,10 @@ export class AuthorityMiddleware implements IWebMiddleware {
return this.notAuth(ctx);
}
} else {
+ if (permission === Constants.per.guestOptionalAuth) {
+ await next();
+ return;
+ }
//找找openKey
const openKey = await this.doOpenHandler(ctx);
if (!openKey) {
@@ -101,6 +83,10 @@ export class AuthorityMiddleware implements IWebMiddleware {
await next();
return;
}
+ if (permission === Constants.per.guestOptionalAuth) {
+ await next();
+ return;
+ }
const pass = await this.authService.checkPermission(ctx, permission);
if (!pass) {
@@ -123,6 +109,30 @@ export class AuthorityMiddleware implements IWebMiddleware {
return;
}
+ private getTokenFromRequest(ctx: IMidwayKoaContext) {
+ let token = ctx.get('Authorization') || '';
+ token = token.replace('Bearer ', '').trim();
+ if (token) {
+ return token;
+ }
+
+ const cookie = ctx.headers.cookie;
+ if (cookie) {
+ const items = cookie.split(';');
+ for (const item of items) {
+ if (!item || !item.trim()) {
+ continue;
+ }
+ const [key, value] = item.split('=');
+ if (key.trim() === 'certd_token') {
+ return value.trim();
+ }
+ }
+ }
+
+ return (ctx.query.token as string) || '';
+ }
+
async doOpenHandler(ctx: IMidwayKoaContext) {
//开放接口
const openKey = ctx.get('x-certd-token') || '';