update:增加文档模式

This commit is contained in:
dlandy
2026-04-15 13:47:39 +08:00
parent e04405d0bc
commit 8973d46a42
38 changed files with 2193 additions and 83 deletions

View File

@@ -0,0 +1,4 @@
-- SQLite stores Prisma enums as TEXT, so AccessMode.none does not require a
-- physical enum migration here. The only schema change needed is the display
-- version override column on tools.
ALTER TABLE "tools" ADD COLUMN "version_override" TEXT;

BIN
server/prisma/prisma.zip Normal file

Binary file not shown.

View File

@@ -10,6 +10,7 @@ datasource db {
enum AccessMode {
web
download
none
}
enum ToolStatus {
@@ -45,6 +46,7 @@ model Tool {
openCount Int @default(0) @map("open_count")
accessMode AccessMode @default(download) @map("access_mode")
openUrl String? @map("open_url")
versionOverride String? @map("version_override")
openInNewTab Boolean @default(true) @map("open_in_new_tab")
latestArtifactId String? @map("latest_artifact_id")
status ToolStatus @default(draft)

View File

@@ -38,6 +38,7 @@ type ToolSeed = {
openCount: number;
accessMode: AccessMode;
openUrl?: string;
versionOverride?: string;
openInNewTab?: boolean;
status: ToolStatus;
createdAt: Date;
@@ -132,6 +133,7 @@ async function createToolWithRelations(tool: ToolSeed) {
openCount: tool.openCount,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
versionOverride: tool.versionOverride,
openInNewTab: tool.openInNewTab ?? true,
status: tool.status,
createdAt: tool.createdAt,
@@ -251,6 +253,7 @@ async function main() {
openCount: 642,
accessMode: AccessMode.web,
openUrl: 'https://platform.openai.com/playground',
versionOverride: '2026.04',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(45, 10, 0),
@@ -282,6 +285,7 @@ async function main() {
openCount: 438,
accessMode: AccessMode.web,
openUrl: 'https://claude.ai',
versionOverride: '2026.03',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(31, 11, 0),
@@ -344,6 +348,7 @@ async function main() {
openCount: 196,
accessMode: AccessMode.web,
openUrl: 'https://atlas.example.internal/runbooks',
versionOverride: 'atlas-3.2',
openInNewTab: false,
status: ToolStatus.published,
createdAt: daysAgo(19, 9, 0),
@@ -379,6 +384,7 @@ async function main() {
openCount: 153,
accessMode: AccessMode.web,
openUrl: 'https://www.notion.so',
versionOverride: 'workflow-1.8',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(12, 13, 30),
@@ -413,6 +419,7 @@ async function main() {
downloadCount: 182,
openCount: 0,
accessMode: AccessMode.download,
versionOverride: 'stable-1.1',
status: ToolStatus.published,
createdAt: daysAgo(40, 10, 15),
updatedAt: dateOnly(daysAgo(0)),
@@ -515,6 +522,7 @@ async function main() {
downloadCount: 89,
openCount: 0,
accessMode: AccessMode.download,
versionOverride: 'stable-2.1',
status: ToolStatus.published,
createdAt: daysAgo(26, 9, 40),
updatedAt: dateOnly(daysAgo(4)),
@@ -569,6 +577,40 @@ async function main() {
],
latestArtifactId: 'art_202',
},
{
id: 'tool_none_001',
name: 'Prompt Catalog Preview',
slug: 'prompt-catalog-preview',
categoryId: 'cat_ai',
description:
'Preview-only catalog for upcoming internal prompt packs, curation notes, and launch plans.',
rating: 4.1,
downloadCount: 0,
openCount: 0,
accessMode: AccessMode.none,
versionOverride: 'preview-2026.04',
status: ToolStatus.published,
createdAt: daysAgo(7, 15, 20),
updatedAt: dateOnly(daysAgo(0)),
features: [
{
id: 'feat_none_001',
featureText: 'Upcoming prompt pack roadmap',
sortOrder: 10,
},
{
id: 'feat_none_002',
featureText: 'Preview-only release notes',
sortOrder: 20,
},
{
id: 'feat_none_003',
featureText: 'Owner and rollout visibility',
sortOrder: 30,
},
],
tagIds: ['tag_new', 'tag_team'],
},
{
id: 'tool_draft_001',
name: 'Local Agent Kit',

View File

@@ -1,5 +1,26 @@
const { spawn, spawnSync } = require('child_process');
const IS_WINDOWS = process.platform === 'win32';
function quoteWindowsArg(value) {
const text = String(value ?? '');
if (text.length === 0) {
return '""';
}
if (!/[\s"&()^<>|]/.test(text)) {
return text;
}
return `"${text
.replace(/(\\*)"/g, '$1$1\\"')
.replace(/(\\+)$/g, '$1$1')}"`;
}
function buildWindowsCommand(command, args) {
return [command, ...args].map(quoteWindowsArg).join(' ');
}
function normalizeWrappedEnv(name) {
const value = process.env[name];
if (!value || value.length < 2) {
@@ -14,11 +35,17 @@ function normalizeWrappedEnv(name) {
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
const result = IS_WINDOWS
? spawnSync('cmd.exe', ['/d', '/s', '/c', buildWindowsCommand(command, args)], {
stdio: 'inherit',
env: process.env,
...options,
})
: spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
if (result.error) {
throw result.error;
@@ -29,23 +56,34 @@ function run(command, args, options = {}) {
}
}
function launch(command, args, options = {}) {
return IS_WINDOWS
? spawn('cmd.exe', ['/d', '/s', '/c', buildWindowsCommand(command, args)], {
stdio: 'inherit',
env: process.env,
...options,
})
: spawn(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
}
function main() {
if (!process.env.DATABASE_URL) {
process.env.DATABASE_URL = 'file:./dev.db';
}
normalizeWrappedEnv('DATABASE_URL');
run(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['prisma', 'migrate', 'deploy']);
run('npx', ['prisma', 'migrate', 'deploy']);
const args = process.argv.slice(2);
if (args.length === 0) {
return;
}
const child = spawn(args[0], args.slice(1), {
stdio: 'inherit',
env: process.env,
});
const child = launch(args[0], args.slice(1));
child.on('exit', (code, signal) => {
if (signal) {

View File

@@ -0,0 +1,128 @@
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { AccessService } from './access.service';
function createRequest(): RequestWithContext {
return {
headers: {
'user-agent': 'Vitest',
},
ip: '127.0.0.1',
} as RequestWithContext;
}
function createTool(overrides = {}) {
return {
id: 'tool_demo',
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
openInNewTab: true,
isDeleted: false,
status: ToolStatus.published,
latestArtifact: null,
...overrides,
};
}
describe('AccessService', () => {
function createService() {
const findFirst = jest.fn();
const create = jest.fn();
const prisma = {
tool: {
findFirst,
},
downloadTicket: {
create,
},
};
const configService = {
get: jest.fn((key: string, fallback: number) => fallback),
};
return {
prisma,
configService,
service: new AccessService(prisma as never, configService as never),
};
}
it('rejects launch requests for none mode tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createTool({
accessMode: AccessMode.none,
openUrl: null,
}),
);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'web-1.0.0' },
createRequest(),
),
).rejects.toMatchObject({
errorCode: ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
} satisfies Partial<AppException>);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'web-1.0.0' },
createRequest(),
),
).rejects.toHaveProperty('status', 409);
});
it('keeps web launch behavior unchanged for launchable tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createTool({
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
openInNewTab: false,
}),
);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'web-1.0.0' },
createRequest(),
),
).resolves.toMatchObject({
mode: 'web',
actionUrl: 'https://example.com/tool',
openIn: 'same_tab',
});
});
it('keeps download ticket launches available for download tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createTool({
accessMode: AccessMode.download,
openUrl: null,
latestArtifact: {
id: 'art_demo',
status: ArtifactStatus.active,
},
}),
);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'desktop-1.0.0' },
createRequest(),
),
).resolves.toMatchObject({
mode: 'download',
actionUrl: expect.stringContaining('/api/v1/downloads/'),
openIn: 'new_tab',
});
});
});

View File

@@ -1,6 +1,6 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ArtifactStatus, ToolStatus } from '@prisma/client';
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
import { randomUUID } from 'crypto';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
@@ -32,6 +32,14 @@ export class AccessService {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
if (tool.accessMode === AccessMode.none) {
throw new AppException(
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
'tool is not launchable in none mode',
HttpStatus.CONFLICT,
);
}
if (tool.accessMode === 'web') {
if (!tool.openUrl) {
throw new AppException(
@@ -52,7 +60,7 @@ export class AccessService {
return {
mode: 'download' as const,
actionUrl: tool.openUrl,
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
openIn: 'new_tab' as const,
};
}

View File

@@ -0,0 +1,111 @@
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
import { AdminOverviewService } from './admin-overview.service';
describe('AdminOverviewService', () => {
function createService() {
const prisma = {
category: {
count: jest.fn().mockResolvedValue(4),
},
tag: {
count: jest.fn().mockResolvedValue(8),
},
toolArtifact: {
count: jest
.fn()
.mockResolvedValueOnce(5)
.mockResolvedValueOnce(3),
},
adminAuditLog: {
count: jest.fn().mockResolvedValue(12),
},
tool: {
count: jest.fn().mockResolvedValue(2),
groupBy: jest
.fn()
.mockResolvedValueOnce([
{ status: ToolStatus.draft, _count: { _all: 1 } },
{ status: ToolStatus.published, _count: { _all: 2 } },
{ status: ToolStatus.archived, _count: { _all: 1 } },
])
.mockResolvedValueOnce([
{ accessMode: AccessMode.download, _count: { _all: 2 } },
{ accessMode: AccessMode.web, _count: { _all: 3 } },
{ accessMode: AccessMode.none, _count: { _all: 1 } },
]),
aggregate: jest.fn().mockResolvedValue({
_sum: {
openCount: 23,
downloadCount: 7,
},
}),
findMany: jest.fn().mockResolvedValue([
{
id: 'tool_none',
name: 'Prompt Catalog Preview',
categoryId: 'cat_ai',
category: {
name: 'AI',
},
accessMode: AccessMode.none,
openCount: 0,
downloadCount: 0,
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
},
{
id: 'tool_web',
name: 'OpenAI Playground',
categoryId: 'cat_ai',
category: {
name: 'AI',
},
accessMode: AccessMode.web,
openCount: 23,
downloadCount: 0,
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
},
]),
},
openRecord: {
count: jest.fn(),
},
downloadRecord: {
count: jest.fn(),
},
$transaction: jest
.fn()
.mockImplementation(async (operations: Array<Promise<number>>) => Promise.all(operations)),
};
prisma.openRecord.count.mockResolvedValue(0);
prisma.downloadRecord.count.mockResolvedValue(0);
prisma.adminAuditLog.count
.mockResolvedValueOnce(12)
.mockResolvedValue(0);
return {
prisma,
service: new AdminOverviewService(prisma as never),
};
}
it('returns noneToolTotal in summary and keeps none mode separate from downloads', async () => {
const { service } = createService();
await expect(service.getOverview()).resolves.toMatchObject({
summary: expect.objectContaining({
toolTotal: 4,
webToolTotal: 3,
downloadToolTotal: 2,
noneToolTotal: 1,
downloadReadyToolTotal: 2,
}),
topTools: expect.arrayContaining([
expect.objectContaining({
id: 'tool_none',
accessMode: AccessMode.none,
}),
]),
});
});
});

View File

@@ -141,6 +141,7 @@ export class AdminOverviewService {
const modeStats = {
web: 0,
download: 0,
none: 0,
};
accessModeBuckets.forEach((item) => {
modeStats[item.accessMode] = item._count._all;
@@ -219,6 +220,7 @@ export class AdminOverviewService {
tagTotal,
webToolTotal: modeStats.web,
downloadToolTotal: modeStats.download,
noneToolTotal: modeStats.none,
downloadReadyToolTotal,
openTotal,
downloadTotal,

View File

@@ -0,0 +1,232 @@
import { AccessMode, ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
import { AdminToolsService } from './admin-tools.service';
function createToolEntity(overrides = {}) {
return {
id: 'tool_demo',
name: 'Demo Tool',
slug: 'demo-tool',
categoryId: 'cat_dev',
description: 'Demo description',
rating: 4.2,
status: ToolStatus.published,
accessMode: AccessMode.download,
openUrl: 'https://example.com/download',
versionOverride: null,
openInNewTab: true,
downloadCount: 12,
openCount: 34,
updatedAt: '2026-04-14',
createdAt: new Date('2026-04-14T00:00:00.000Z'),
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
category: {
id: 'cat_dev',
name: 'Developer Tools',
},
tags: [{ tag: { id: 'tag_cli', name: 'cli' } }],
features: [{ featureText: 'Fast setup', sortOrder: 10 }],
latestArtifact: {
id: 'art_demo',
version: '2.0.0',
status: ArtifactStatus.active,
fileName: 'demo.zip',
fileSizeBytes: 4096,
},
...overrides,
};
}
describe('AdminToolsService', () => {
type ToolEntity = ReturnType<typeof createToolEntity>;
type CountArgs = {
where?: Prisma.ToolWhereInput;
};
type FindManyArgs = {
where?: Prisma.ToolWhereInput;
include?: Prisma.ToolInclude;
orderBy?: Prisma.ToolOrderByWithRelationInput;
skip?: number;
take?: number;
};
type FindFirstArgs = {
where?: Prisma.ToolWhereInput;
include?: Prisma.ToolInclude;
};
type UpdateArgs = {
where: { id: string };
data: Prisma.ToolUpdateInput;
};
function createService() {
const count = jest.fn<Promise<number>, [CountArgs]>();
const findMany = jest.fn<Promise<ToolEntity[]>, [FindManyArgs]>();
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
const update = jest.fn<Promise<ToolEntity>, [UpdateArgs]>();
const $transaction = jest
.fn()
.mockImplementation(async (operations: Array<Promise<unknown>>) => Promise.all(operations));
const prisma = {
$transaction,
tool: {
count,
findMany,
findFirst,
update,
},
};
return {
prisma,
service: new AdminToolsService(prisma as never),
};
}
it('returns versionOverride and displayVersion for admin detail responses', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
versionOverride: '2026.04',
latestArtifact: null,
}),
);
await expect(service.getToolById('tool_demo')).resolves.toMatchObject({
id: 'tool_demo',
accessMode: AccessMode.web,
versionOverride: '2026.04',
displayVersion: '2026.04',
latestArtifact: null,
});
});
it('prefers versionOverride over artifact version in admin list responses', async () => {
const { prisma, service } = createService();
prisma.tool.count.mockResolvedValue(1);
prisma.tool.findMany.mockResolvedValue([
createToolEntity({
accessMode: AccessMode.download,
versionOverride: 'hotfix-7',
latestArtifact: {
id: 'art_demo',
version: '2.0.0',
status: ArtifactStatus.active,
fileName: 'demo.zip',
fileSizeBytes: 4096,
},
}),
]);
await expect(
service.getTools({
page: 1,
pageSize: 10,
} as never),
).resolves.toMatchObject({
list: [
expect.objectContaining({
accessMode: AccessMode.download,
versionOverride: 'hotfix-7',
displayVersion: 'hotfix-7',
latestArtifact: expect.objectContaining({
version: '2.0.0',
}),
}),
],
});
});
it('allows switching to none mode with an empty openUrl', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst
.mockResolvedValueOnce(
createToolEntity({
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
versionOverride: 'preview-2',
latestArtifact: null,
}),
)
.mockResolvedValueOnce(
createToolEntity({
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
prisma.tool.update.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
await expect(
service.updateAccessMode('tool_demo', {
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
}),
).resolves.toMatchObject({
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
displayVersion: 'preview-2',
});
expect(prisma.tool.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'tool_demo' },
data: expect.objectContaining({
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
}),
}),
);
});
it('allows publishing a none mode tool without openUrl or artifacts', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst
.mockResolvedValueOnce(
createToolEntity({
status: ToolStatus.draft,
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
latestArtifact: null,
}),
)
.mockResolvedValueOnce(
createToolEntity({
status: ToolStatus.published,
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
prisma.tool.update.mockResolvedValue(
createToolEntity({
status: ToolStatus.published,
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
await expect(service.updateToolStatus('tool_demo', ToolStatus.published)).resolves.toMatchObject({
status: ToolStatus.published,
accessMode: AccessMode.none,
displayVersion: 'preview-2',
});
});
});

View File

@@ -77,7 +77,9 @@ export class AdminToolsService {
async createTool(body: CreateToolDto) {
await this.assertCategoryExists(body.categoryId);
await this.assertTagsExist(body.tags ?? []);
const openUrl = this.normalizeOptionalUrl(body.openUrl);
const openUrl =
body.accessMode === AccessMode.none ? null : this.normalizeOptionalUrl(body.openUrl);
const versionOverride = this.normalizeOptionalText(body.versionOverride);
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
this.assertPublishInput(body.accessMode, openUrl, undefined);
@@ -97,6 +99,7 @@ export class AdminToolsService {
rating: body.rating ?? 0,
accessMode: body.accessMode,
openUrl,
versionOverride,
openInNewTab: body.openInNewTab ?? true,
status: body.status ?? ToolStatus.draft,
updatedAt,
@@ -159,10 +162,18 @@ export class AdminToolsService {
const existingTool = await this.getToolEntity(id);
const normalizedOpenUrl =
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : undefined;
const normalizedVersionOverride =
body.versionOverride !== undefined
? this.normalizeOptionalText(body.versionOverride)
: undefined;
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
const nextOpenUrl =
normalizedOpenUrl !== undefined ? normalizedOpenUrl : existingTool.openUrl ?? null;
nextAccessMode === AccessMode.none
? null
: normalizedOpenUrl !== undefined
? normalizedOpenUrl
: existingTool.openUrl ?? null;
const nextStatus = body.status ?? existingTool.status;
if (body.categoryId) {
@@ -188,7 +199,8 @@ export class AdminToolsService {
description: body.description?.trim(),
rating: body.rating,
accessMode: body.accessMode,
openUrl: normalizedOpenUrl,
openUrl: nextAccessMode === AccessMode.none ? null : normalizedOpenUrl,
versionOverride: normalizedVersionOverride,
openInNewTab: body.openInNewTab,
status: body.status,
updatedAt,
@@ -245,7 +257,11 @@ export class AdminToolsService {
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
const tool = await this.getToolEntity(id);
const nextOpenUrl =
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : tool.openUrl ?? null;
body.accessMode === AccessMode.none
? null
: body.openUrl !== undefined
? this.normalizeOptionalUrl(body.openUrl)
: tool.openUrl ?? null;
this.assertModeSwitchConstraint(
tool.status,
body.accessMode,
@@ -369,6 +385,10 @@ export class AdminToolsService {
return;
}
if (accessMode === AccessMode.none) {
return;
}
if (openUrl) {
return;
}
@@ -389,6 +409,10 @@ export class AdminToolsService {
tool: { latestArtifact?: { status: ArtifactStatus } | null },
isSwitching = false,
) {
if (targetMode === AccessMode.none) {
return;
}
if (targetMode === AccessMode.web && !openUrl) {
throw new AppException(
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
@@ -422,6 +446,8 @@ export class AdminToolsService {
};
}>,
) {
const latestVersion = this.resolveLatestVersion(tool);
return {
id: tool.id,
name: tool.name,
@@ -435,6 +461,8 @@ export class AdminToolsService {
status: tool.status,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
versionOverride: tool.versionOverride,
displayVersion: this.resolveDisplayVersion(tool),
openInNewTab: tool.openInNewTab,
downloadCount: tool.downloadCount,
openCount: tool.openCount,
@@ -453,6 +481,7 @@ export class AdminToolsService {
})),
features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt,
latestVersion,
createdAt: tool.createdAt,
modifiedAt: tool.modifiedAt,
};
@@ -496,4 +525,47 @@ export class AdminToolsService {
const trimmed = String(value ?? '').trim();
return trimmed ? trimmed : null;
}
private normalizeOptionalText(value: string | null | undefined): string | null | undefined {
if (value === undefined) {
return undefined;
}
const trimmed = String(value ?? '').trim();
return trimmed ? trimmed : null;
}
private resolveDisplayVersion(
tool: Prisma.ToolGetPayload<{
include: {
category: true;
tags: { include: { tag: true } };
features: true;
latestArtifact: true;
};
}>,
): string | null {
if (tool.versionOverride) {
return tool.versionOverride;
}
return this.resolveLatestVersion(tool);
}
private resolveLatestVersion(
tool: Prisma.ToolGetPayload<{
include: {
category: true;
tags: { include: { tag: true } };
features: true;
latestArtifact: true;
};
}>,
): string | null {
if (tool.accessMode !== AccessMode.download || !tool.latestArtifact) {
return null;
}
return tool.latestArtifact.version;
}
}

View File

@@ -70,6 +70,15 @@ export class CreateToolDto {
})
openUrl?: string;
@ApiPropertyOptional({
description: 'Optional display version override shown in admin and public pages',
maxLength: 80,
})
@IsOptional()
@IsString()
@MaxLength(80)
versionOverride?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Type(() => Boolean)

View File

@@ -9,7 +9,8 @@ export class UpdateAccessModeDto {
accessMode!: AccessMode;
@ApiPropertyOptional({
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
description:
'Required when accessMode=web; optional external download URL when accessMode=download; ignored when accessMode=none',
})
@IsOptional()
@IsString()

View File

@@ -14,6 +14,7 @@ function createToolEntity(overrides = {}) {
openCount: 42,
accessMode: AccessMode.download,
openUrl: 'https://example.com/download',
versionOverride: null,
updatedAt: '2026-04-11',
isDeleted: false,
status: 'published',
@@ -37,11 +38,29 @@ describe('ToolsService', () => {
type FindFirstArgs = {
where?: Prisma.ToolWhereInput;
};
type CountArgs = {
where?: Prisma.ToolWhereInput;
};
type FindManyArgs = {
where?: Prisma.ToolWhereInput;
include?: Prisma.ToolInclude;
skip?: number;
take?: number;
orderBy?: Prisma.ToolOrderByWithRelationInput | Prisma.ToolOrderByWithRelationInput[];
};
function createService() {
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
const count = jest.fn<Promise<number>, [CountArgs]>();
const findMany = jest.fn<Promise<ToolEntity[]>, [FindManyArgs]>();
const $transaction = jest
.fn()
.mockImplementation(async (operations: Array<Promise<unknown>>) => Promise.all(operations));
const prisma = {
$transaction,
tool: {
count,
findMany,
findFirst,
},
};
@@ -62,6 +81,7 @@ describe('ToolsService', () => {
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
displayVersion: '2.0.0',
latestVersion: '2.0.0',
fileSize: 4096,
downloadReady: true,
@@ -111,9 +131,78 @@ describe('ToolsService', () => {
await expect(
service.getToolDetailBySlug('demo-tool'),
).resolves.toMatchObject({
displayVersion: null,
downloadReady: false,
latestVersion: null,
fileSize: null,
});
});
it('returns displayVersion from versionOverride for web tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.web,
versionOverride: '2026.04',
latestArtifact: null,
}),
);
await expect(service.getToolDetailBySlug('demo-tool')).resolves.toMatchObject({
accessMode: AccessMode.web,
displayVersion: '2026.04',
latestVersion: null,
downloadReady: false,
});
});
it('returns displayVersion and none mode for non-launchable tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: 'none' as AccessMode,
openUrl: null,
versionOverride: 'alpha-preview',
latestArtifact: null,
}),
);
await expect(service.getToolDetailBySlug('demo-tool')).resolves.toMatchObject({
accessMode: 'none',
displayVersion: 'alpha-preview',
latestVersion: null,
downloadReady: false,
});
});
it('returns displayVersion for list items and keeps latestVersion for download tools', async () => {
const { prisma, service } = createService();
prisma.tool.count.mockResolvedValue(1);
prisma.tool.findMany.mockResolvedValue([
createToolEntity({
accessMode: AccessMode.download,
versionOverride: null,
latestArtifact: {
version: '3.1.4',
fileSizeBytes: 2048,
status: ArtifactStatus.active,
},
}),
]);
await expect(
service.getTools({
page: 1,
pageSize: 6,
} as never),
).resolves.toMatchObject({
list: [
expect.objectContaining({
accessMode: AccessMode.download,
displayVersion: '3.1.4',
latestVersion: '3.1.4',
}),
],
});
});
});

View File

@@ -129,6 +129,7 @@ export class ToolsService {
);
const hasExternalDownloadUrl =
tool.accessMode === 'download' && Boolean(tool.openUrl);
const latestVersion = this.resolveLatestVersion(tool);
return {
id: tool.id,
@@ -142,10 +143,8 @@ export class ToolsService {
rating: tool.rating,
downloadCount: tool.downloadCount,
openCount: tool.openCount,
latestVersion:
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.version
: null,
displayVersion: this.resolveDisplayVersion(tool),
latestVersion,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
@@ -160,6 +159,8 @@ export class ToolsService {
}
private mapToolDetail(tool: ToolDetailEntity) {
const latestVersion = this.resolveLatestVersion(tool);
return {
id: tool.id,
name: tool.name,
@@ -177,10 +178,8 @@ export class ToolsService {
features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt,
openUrl: tool.openUrl,
latestVersion:
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.version
: null,
displayVersion: this.resolveDisplayVersion(tool),
latestVersion,
fileSize:
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.fileSizeBytes
@@ -195,4 +194,22 @@ export class ToolsService {
: false,
};
}
// Keep version selection centralized so public list/detail responses always
// expose the same display value regardless of tool access mode.
private resolveDisplayVersion(tool: ToolDetailEntity): string | null {
if (tool.versionOverride) {
return tool.versionOverride;
}
return this.resolveLatestVersion(tool);
}
private resolveLatestVersion(tool: ToolDetailEntity): string | null {
if (tool.accessMode !== 'download' || !tool.latestArtifact) {
return null;
}
return tool.latestArtifact.version;
}
}

View File

@@ -0,0 +1,81 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AccessMode, ToolStatus } from '@prisma/client';
import { randomUUID } from 'crypto';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
describe('Tool launch none mode (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let categoryId = '';
let toolId = '';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
});
beforeEach(async () => {
categoryId = `cat_${randomUUID().replace(/-/g, '')}`;
toolId = `tool_${randomUUID().replace(/-/g, '')}`;
await prisma.category.create({
data: {
id: categoryId,
name: `Category ${toolId}`,
},
});
await prisma.tool.create({
data: {
id: toolId,
name: 'Preview Tool',
slug: `preview-${randomUUID().slice(0, 8)}`,
categoryId,
description: 'Preview only tool',
accessMode: AccessMode.none,
versionOverride: 'preview-1',
status: ToolStatus.published,
updatedAt: '2026-04-14',
},
});
});
afterEach(async () => {
await prisma.tool.deleteMany({
where: {
id: toolId,
},
});
await prisma.category.deleteMany({
where: {
id: categoryId,
},
});
});
afterAll(async () => {
await app.close();
});
function getHttpServer(): Parameters<typeof request>[0] {
return app.getHttpServer() as Parameters<typeof request>[0];
}
it('returns 409 when launching a none mode tool', async () => {
await request(getHttpServer())
.post(`/tools/${toolId}/launch`)
.send({
channel: 'official',
clientVersion: 'web-1.0.0',
})
.expect(409);
});
});

View File

@@ -11,6 +11,8 @@ interface ToolDetailResponse {
slug: string;
name: string;
description: string;
accessMode: string;
displayVersion: string | null;
}
describe('Tools detail by slug (e2e)', () => {
@@ -51,6 +53,7 @@ describe('Tools detail by slug (e2e)', () => {
description: '# Manual\n\n## Install',
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
versionOverride: '2026.04',
status: ToolStatus.published,
updatedAt: '2026-04-11',
},
@@ -87,10 +90,46 @@ describe('Tools detail by slug (e2e)', () => {
expect(body.slug).toBe(toolSlug);
expect(body.name).toBe('Slug Detail Tool');
expect(body.description).toContain('# Manual');
expect(body.accessMode).toBe(AccessMode.web);
expect(body.displayVersion).toBe('2026.04');
});
});
it('returns 404 for an unknown slug', async () => {
await request(getHttpServer()).get('/tools/slug/missing-tool').expect(404);
});
it('returns none mode tool detail with displayVersion', async () => {
const noneToolId = `tool_${randomUUID().replace(/-/g, '')}`;
const noneToolSlug = `none-tool-${randomUUID().slice(0, 8)}`;
await prisma.tool.create({
data: {
id: noneToolId,
name: 'Preview Tool',
slug: noneToolSlug,
categoryId,
description: '# Preview\n\n即将上线',
accessMode: 'none' as AccessMode,
versionOverride: 'preview-1',
status: ToolStatus.published,
updatedAt: '2026-04-11',
},
});
await request(getHttpServer())
.get(`/tools/slug/${noneToolSlug}`)
.expect(200)
.expect(({ body }: { body: ToolDetailResponse }) => {
expect(body.id).toBe(noneToolId);
expect(body.accessMode).toBe('none');
expect(body.displayVersion).toBe('preview-1');
});
await prisma.tool.deleteMany({
where: {
id: noneToolId,
},
});
});
});