This commit is contained in:
dlandy
2026-04-08 17:56:12 +08:00
parent 5a6328561f
commit e6c2d76238
41 changed files with 1361 additions and 335 deletions

View File

@@ -1,5 +1,5 @@
PORT=3000
DATABASE_URL="file:./dev.db"
DATABASE_URL=file:./dev.db
DOWNLOAD_TICKET_TTL_SEC=120
JWT_ACCESS_SECRET=change_this_access_secret

View File

@@ -6,6 +6,7 @@ import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module';
import { AdminOverviewModule } from './modules/admin-overview/admin-overview.module';
import { AdminTagsModule } from './modules/admin-tags/admin-tags.module';
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
import { CategoriesModule } from './modules/categories/categories.module';
@@ -32,6 +33,7 @@ import { ToolsModule } from './modules/tools/tools.module';
GitlabStorageModule,
DownloadsModule,
AdminAuthModule,
AdminOverviewModule,
AdminCategoriesModule,
AdminTagsModule,
AdminToolsModule,

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Param, Post, Req } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { LaunchToolDto } from './dto/launch-tool.dto';
import { TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
import { AccessService } from './access.service';
@ApiTags('public-launch')
@@ -18,4 +19,14 @@ export class AccessController {
) {
return this.accessService.launchTool(id, body, request);
}
@Post(':id/interaction')
@ApiOperation({ summary: 'Track tool interaction (open/download) asynchronously' })
trackInteraction(
@Param('id') id: string,
@Body() body: TrackToolInteractionDto,
@Req() request: RequestWithContext,
) {
return this.accessService.trackInteraction(id, body, request);
}
}

View File

@@ -7,6 +7,7 @@ import { AppException } from '../../common/exceptions/app.exception';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { PrismaService } from '../../prisma/prisma.service';
import { LaunchToolDto } from './dto/launch-tool.dto';
import { ToolInteractionAction, TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
@Injectable()
export class AccessService {
@@ -40,23 +41,6 @@ export class AccessService {
);
}
await this.prisma.$transaction([
this.prisma.openRecord.create({
data: {
toolId: tool.id,
channel: body.channel,
clientVersion: body.clientVersion,
clientIp: this.extractIp(request),
userAgent: request.headers['user-agent'],
referer: this.extractHeader(request, 'referer'),
},
}),
this.prisma.tool.update({
where: { id: tool.id },
data: { openCount: { increment: 1 } },
}),
]);
return {
mode: 'web' as const,
actionUrl: tool.openUrl,
@@ -64,6 +48,14 @@ export class AccessService {
};
}
if (tool.openUrl) {
return {
mode: 'download' as const,
actionUrl: tool.openUrl,
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
};
}
if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) {
throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
@@ -93,9 +85,63 @@ export class AccessService {
ticket,
expiresInSec: ttlSec,
actionUrl: `/api/v1/downloads/${ticket}`,
openIn: 'new_tab' as const,
};
}
async trackInteraction(
toolId: string,
body: TrackToolInteractionDto,
request: RequestWithContext,
) {
const tool = await this.prisma.tool.findFirst({
where: {
id: toolId,
isDeleted: false,
status: ToolStatus.published,
},
select: {
id: true,
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
if (body.action === ToolInteractionAction.open) {
await this.prisma.$transaction([
this.prisma.openRecord.create({
data: {
toolId: tool.id,
channel: body.channel,
clientVersion: body.clientVersion,
clientIp: this.extractIp(request),
userAgent: request.headers['user-agent'],
referer: this.extractHeader(request, 'referer'),
},
}),
this.prisma.tool.update({
where: { id: tool.id },
data: { openCount: { increment: 1 } },
}),
]);
return { success: true };
}
await this.prisma.tool.update({
where: { id: tool.id },
data: {
downloadCount: {
increment: 1,
},
},
});
return { success: true };
}
private extractIp(request: RequestWithContext): string | undefined {
const forwarded = request.headers['x-forwarded-for'];
if (Array.isArray(forwarded) && forwarded.length > 0) {

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export enum ToolInteractionAction {
open = 'open',
download = 'download',
}
export class TrackToolInteractionDto {
@ApiProperty({ enum: ToolInteractionAction })
@IsEnum(ToolInteractionAction)
action!: ToolInteractionAction;
@ApiPropertyOptional({ example: 'official' })
@IsOptional()
@IsString()
@MaxLength(32)
channel?: string;
@ApiPropertyOptional({ example: 'web-1.0.0' })
@IsOptional()
@IsString()
@MaxLength(64)
clientVersion?: string;
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import { AdminOverviewService } from './admin-overview.service';
@ApiTags('admin-overview')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@Controller('admin/overview')
export class AdminOverviewController {
constructor(private readonly adminOverviewService: AdminOverviewService) {}
@Get()
@ApiOperation({ summary: 'Get admin dashboard overview metrics' })
getOverview() {
return this.adminOverviewService.getOverview();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminOverviewController } from './admin-overview.controller';
import { AdminOverviewService } from './admin-overview.service';
@Module({
controllers: [AdminOverviewController],
providers: [AdminOverviewService],
})
export class AdminOverviewModule {}

View File

@@ -0,0 +1,300 @@
import { Injectable } from '@nestjs/common';
import { AccessMode, ArtifactStatus, DownloadRecordStatus, ToolStatus } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
type DailyRange = {
date: string;
start: Date;
end: Date;
};
@Injectable()
export class AdminOverviewService {
constructor(private readonly prisma: PrismaService) {}
async getOverview() {
const [
categoryTotal,
tagTotal,
artifactTotal,
activeArtifactTotal,
auditLogTotal,
downloadReadyToolTotal,
toolStatusBuckets,
accessModeBuckets,
publishedTraffic,
publishedTools,
dailyActivity,
] = await Promise.all([
this.prisma.category.count({
where: {
isDeleted: false,
},
}),
this.prisma.tag.count({
where: {
isDeleted: false,
},
}),
this.prisma.toolArtifact.count({
where: {
status: {
not: ArtifactStatus.deleted,
},
},
}),
this.prisma.toolArtifact.count({
where: {
status: ArtifactStatus.active,
},
}),
this.prisma.adminAuditLog.count(),
this.prisma.tool.count({
where: {
isDeleted: false,
accessMode: AccessMode.download,
OR: [
{
AND: [
{
openUrl: {
not: null,
},
},
{
openUrl: {
not: '',
},
},
],
},
{
latestArtifact: {
is: {
status: ArtifactStatus.active,
},
},
},
],
},
}),
this.prisma.tool.groupBy({
by: ['status'],
where: {
isDeleted: false,
},
_count: {
_all: true,
},
}),
this.prisma.tool.groupBy({
by: ['accessMode'],
where: {
isDeleted: false,
},
_count: {
_all: true,
},
}),
this.prisma.tool.aggregate({
where: {
isDeleted: false,
status: ToolStatus.published,
},
_sum: {
openCount: true,
downloadCount: true,
},
}),
this.prisma.tool.findMany({
where: {
isDeleted: false,
status: ToolStatus.published,
},
select: {
id: true,
name: true,
categoryId: true,
category: {
select: {
name: true,
},
},
accessMode: true,
openCount: true,
downloadCount: true,
modifiedAt: true,
},
}),
this.getDailyActivity(7),
]);
const statusStats = {
draft: 0,
published: 0,
archived: 0,
};
toolStatusBuckets.forEach((item) => {
statusStats[item.status] = item._count._all;
});
const modeStats = {
web: 0,
download: 0,
};
accessModeBuckets.forEach((item) => {
modeStats[item.accessMode] = item._count._all;
});
const openTotal = publishedTraffic._sum.openCount ?? 0;
const downloadTotal = publishedTraffic._sum.downloadCount ?? 0;
const interactionTotal = openTotal + downloadTotal;
const topTools = publishedTools
.map((tool) => ({
id: tool.id,
name: tool.name,
categoryName: tool.category.name,
accessMode: tool.accessMode,
openCount: tool.openCount,
downloadCount: tool.downloadCount,
interactionTotal: tool.openCount + tool.downloadCount,
updatedAt: tool.modifiedAt.toISOString(),
}))
.sort((a, b) => {
if (b.interactionTotal !== a.interactionTotal) {
return b.interactionTotal - a.interactionTotal;
}
return a.name.localeCompare(b.name);
})
.slice(0, 8);
const categoryStatMap = new Map<
string,
{
categoryId: string;
categoryName: string;
toolTotal: number;
openTotal: number;
downloadTotal: number;
}
>();
publishedTools.forEach((tool) => {
const existing = categoryStatMap.get(tool.categoryId) ?? {
categoryId: tool.categoryId,
categoryName: tool.category.name,
toolTotal: 0,
openTotal: 0,
downloadTotal: 0,
};
existing.toolTotal += 1;
existing.openTotal += tool.openCount;
existing.downloadTotal += tool.downloadCount;
categoryStatMap.set(tool.categoryId, existing);
});
const topCategories = Array.from(categoryStatMap.values())
.map((item) => ({
...item,
interactionTotal: item.openTotal + item.downloadTotal,
}))
.sort((a, b) => {
if (b.interactionTotal !== a.interactionTotal) {
return b.interactionTotal - a.interactionTotal;
}
return b.toolTotal - a.toolTotal;
})
.slice(0, 8);
const toolTotal = statusStats.draft + statusStats.published + statusStats.archived;
return {
generatedAt: new Date().toISOString(),
summary: {
toolTotal,
draftTotal: statusStats.draft,
publishedTotal: statusStats.published,
archivedTotal: statusStats.archived,
categoryTotal,
tagTotal,
webToolTotal: modeStats.web,
downloadToolTotal: modeStats.download,
downloadReadyToolTotal,
openTotal,
downloadTotal,
interactionTotal,
artifactTotal,
activeArtifactTotal,
auditLogTotal,
},
dailyActivity,
topCategories,
topTools,
};
}
private async getDailyActivity(days: number) {
const ranges = this.buildDailyRanges(days);
const queries = ranges.flatMap((range) => [
this.prisma.openRecord.count({
where: {
openedAt: {
gte: range.start,
lt: range.end,
},
},
}),
this.prisma.downloadRecord.count({
where: {
downloadedAt: {
gte: range.start,
lt: range.end,
},
status: DownloadRecordStatus.success,
},
}),
this.prisma.adminAuditLog.count({
where: {
createdAt: {
gte: range.start,
lt: range.end,
},
},
}),
]);
const counts = await this.prisma.$transaction(queries);
return ranges.map((range, index) => {
const openCount = counts[index * 3] ?? 0;
const downloadCount = counts[index * 3 + 1] ?? 0;
const auditCount = counts[index * 3 + 2] ?? 0;
return {
date: range.date,
openCount,
downloadCount,
interactionTotal: openCount + downloadCount,
auditCount,
};
});
}
private buildDailyRanges(days: number): DailyRange[] {
const safeDays = Math.max(1, days);
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
return Array.from({ length: safeDays }, (_, index) => {
const offset = safeDays - index - 1;
const start = new Date(todayStart);
start.setDate(todayStart.getDate() - offset);
const end = new Date(start);
end.setDate(start.getDate() + 1);
return {
date: start.toISOString().slice(0, 10),
start,
end,
};
});
}
}

View File

@@ -77,9 +77,10 @@ export class AdminToolsService {
async createTool(body: CreateToolDto) {
await this.assertCategoryExists(body.categoryId);
await this.assertTagsExist(body.tags ?? []);
const openUrl = this.normalizeOptionalUrl(body.openUrl);
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
this.assertPublishInput(body.accessMode, body.openUrl, undefined);
this.assertPublishInput(body.accessMode, openUrl, undefined);
}
const toolId = this.generateBusinessId('tool');
@@ -95,7 +96,7 @@ export class AdminToolsService {
description: body.description.trim(),
rating: body.rating ?? 0,
accessMode: body.accessMode,
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
openUrl,
openInNewTab: body.openInNewTab ?? true,
status: body.status ?? ToolStatus.draft,
updatedAt,
@@ -156,14 +157,12 @@ export class AdminToolsService {
async updateTool(id: string, body: UpdateToolDto) {
const existingTool = await this.getToolEntity(id);
const normalizedOpenUrl =
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : undefined;
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
const nextOpenUrl =
body.openUrl !== undefined
? body.openUrl
: nextAccessMode === AccessMode.web
? existingTool.openUrl
: null;
normalizedOpenUrl !== undefined ? normalizedOpenUrl : existingTool.openUrl ?? null;
const nextStatus = body.status ?? existingTool.status;
if (body.categoryId) {
@@ -189,7 +188,7 @@ export class AdminToolsService {
description: body.description?.trim(),
rating: body.rating,
accessMode: body.accessMode,
openUrl: body.openUrl,
openUrl: normalizedOpenUrl,
openInNewTab: body.openInNewTab,
status: body.status,
updatedAt,
@@ -245,10 +244,12 @@ 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;
this.assertModeSwitchConstraint(
tool.status,
body.accessMode,
body.openUrl,
nextOpenUrl,
tool,
tool.accessMode !== body.accessMode,
);
@@ -257,7 +258,7 @@ export class AdminToolsService {
where: { id },
data: {
accessMode: body.accessMode,
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
openUrl: nextOpenUrl,
openInNewTab: body.openInNewTab ?? tool.openInNewTab,
updatedAt: this.getDateOnlyString(),
},
@@ -354,7 +355,7 @@ export class AdminToolsService {
private assertPublishInput(
accessMode: AccessMode,
openUrl?: string,
openUrl?: string | null,
latestArtifact?: { status: ArtifactStatus } | null,
) {
if (accessMode === AccessMode.web) {
@@ -368,10 +369,14 @@ export class AdminToolsService {
return;
}
if (openUrl) {
return;
}
if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) {
throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
'download mode tool requires one active latest artifact before publish',
'download mode tool requires one active latest artifact or one download URL before publish',
HttpStatus.CONFLICT,
);
}
@@ -396,11 +401,12 @@ export class AdminToolsService {
isSwitching &&
targetMode === AccessMode.download &&
currentStatus === ToolStatus.published &&
!openUrl &&
(!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active)
) {
throw new AppException(
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
'published tool cannot switch to download mode without active artifact',
'published tool cannot switch to download mode without active artifact or download URL',
HttpStatus.CONFLICT,
);
}
@@ -481,4 +487,13 @@ export class AdminToolsService {
return slug;
}
private normalizeOptionalUrl(value: string | null | undefined): string | null | undefined {
if (value === undefined) {
return undefined;
}
const trimmed = String(value ?? '').trim();
return trimmed ? trimmed : null;
}
}

View File

@@ -15,7 +15,6 @@ import {
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateToolDto {
@@ -62,8 +61,10 @@ export class CreateToolDto {
@IsEnum(AccessMode)
accessMode!: AccessMode;
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
@ValidateIf((obj: CreateToolDto) => obj.accessMode === AccessMode.web)
@ApiPropertyOptional({
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
})
@IsOptional()
@IsString()
@IsUrl({
require_protocol: true,

View File

@@ -1,15 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AccessMode } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl } from 'class-validator';
export class UpdateAccessModeDto {
@ApiProperty({ enum: AccessMode })
@IsEnum(AccessMode)
accessMode!: AccessMode;
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
@ValidateIf((obj: UpdateAccessModeDto) => obj.accessMode === AccessMode.web)
@ApiPropertyOptional({
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
})
@IsOptional()
@IsString()
@IsUrl({
require_protocol: true,

View File

@@ -107,15 +107,6 @@ export class DownloadsService {
status: DownloadRecordStatus.success,
},
});
await tx.tool.update({
where: { id: ticketEntity.toolId },
data: {
downloadCount: {
increment: 1,
},
},
});
});
response.setHeader(

View File

@@ -3,6 +3,7 @@ import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export enum ToolSortBy {
created = 'created',
popular = 'popular',
latest = 'latest',
rating = 'rating',
@@ -21,7 +22,7 @@ export class GetToolsQueryDto {
@IsString()
category?: string;
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.latest })
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.created })
@IsOptional()
@IsEnum(ToolSortBy)
sortBy?: ToolSortBy;

View File

@@ -12,7 +12,7 @@ export class ToolsService {
async getTools(query: GetToolsQueryDto) {
const page = query.page ?? 1;
const pageSize = Math.min(query.pageSize ?? 6, 50);
const sortBy = query.sortBy ?? ToolSortBy.latest;
const sortBy = query.sortBy ?? ToolSortBy.created;
const where: Prisma.ToolWhereInput = {
isDeleted: false,
@@ -59,6 +59,8 @@ export class ToolsService {
const hasArtifact = Boolean(
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
);
const hasExternalDownloadUrl =
tool.accessMode === 'download' && Boolean(tool.openUrl);
return {
id: tool.id,
@@ -75,8 +77,10 @@ export class ToolsService {
latestVersion:
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
accessMode: tool.accessMode,
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
openUrl: tool.openUrl,
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
downloadReady:
tool.accessMode === 'download' ? hasArtifact || hasExternalDownloadUrl : false,
tags: tool.tags.map((item) => item.tag.name),
features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt,
@@ -134,7 +138,7 @@ export class ToolsService {
tags: tool.tags.map((item) => item.tag.name),
features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt,
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
openUrl: tool.openUrl,
latestVersion:
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
fileSize:
@@ -143,7 +147,10 @@ export class ToolsService {
: null,
downloadReady:
tool.accessMode === 'download'
? Boolean(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active)
? Boolean(
tool.openUrl ||
(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active),
)
: false,
};
@@ -152,6 +159,8 @@ export class ToolsService {
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
switch (sortBy) {
case ToolSortBy.created:
return [{ createdAt: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.popular:
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.rating: