= {
+ SUCCESS: 'success',
+ FAILED: 'error',
+ PENDING: 'processing',
+};
+
+function formatFileSize(size: number | null | undefined) {
+ if (size == null || size <= 0) return '—';
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+ return `${(size / 1024 / 1024).toFixed(2)} MB`;
+}
+
+function JsonBlock({ value }: { value: unknown }) {
+ if (value == null) return —;
+ return (
+
+ {JSON.stringify(value, null, 2)}
+
+ );
+}
+
+export default function OssUploadLogsPage() {
+ const [filters, setFilters] = useState>({});
+ const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList(
+ '/admin/logs/oss',
+ () => {
+ const qs = new URLSearchParams();
+ if (filters.scene) qs.set('scene', filters.scene);
+ if (filters.status) qs.set('status', filters.status);
+ if (filters.bizType) qs.set('bizType', filters.bizType);
+ if (filters.clientApp) qs.set('clientApp', filters.clientApp);
+ if (filters.refId) qs.set('refId', filters.refId);
+ return qs;
+ },
+ [filters],
+ );
+ const [detail, setDetail] = useState | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ const columns: ColumnsType = [
+ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
+ {
+ title: '场景',
+ dataIndex: 'scene',
+ width: 120,
+ render: (v: string) => SCENE_OPTIONS.find((o) => o.value === v)?.label ?? v,
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 90,
+ render: (v: string) => {v},
+ },
+ { title: '端', dataIndex: 'clientApp', width: 110, render: (v) => v || '—' },
+ {
+ title: '操作者',
+ width: 120,
+ render: (_, r) => (r.actorType && r.actorId ? `${r.actorType}#${r.actorId}` : '—'),
+ },
+ { title: '业务类型', dataIndex: 'bizType', width: 120, render: (v) => v || '—' },
+ { title: '文件名', dataIndex: 'fileName', ellipsis: true, render: (v) => v || '—' },
+ {
+ title: '大小',
+ dataIndex: 'fileSize',
+ width: 90,
+ render: (v: number | null) => formatFileSize(v),
+ },
+ { title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160, render: (v) => v || '—' },
+ {
+ title: 'Mock',
+ dataIndex: 'mock',
+ width: 70,
+ render: (v: boolean | null) => (v == null ? '—' : v ? '是' : '否'),
+ },
+ {
+ title: '错误',
+ dataIndex: 'errorMessage',
+ ellipsis: true,
+ render: (v: string | null | undefined) => v || '—',
+ },
+ {
+ title: '操作',
+ width: 80,
+ fixed: 'right',
+ render: (_, row) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
OSS 上传日志
+
+ 记录各端经 /common/resources/upload 的文件上传与直传凭证申请。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+ setDrawerOpen(false)}>
+ {detail && (
+
+ {String(detail.id)}
+ {String(detail.scene)}
+
+ {String(detail.status)}
+
+ {String(detail.clientApp ?? '—')}
+
+ {detail.actorType ? `${String(detail.actorType)}#${String(detail.actorId)}` : '—'}
+
+ {String(detail.bizType ?? '—')}
+ {String(detail.mediaType ?? '—')}
+ {String(detail.fileName ?? '—')}
+ {formatFileSize(detail.fileSize as number | null)}
+ {String(detail.mimeType ?? '—')}
+ {String(detail.bucket ?? '—')}
+ {String(detail.ossKey ?? '—')}
+
+ {detail.url ? (
+
+ {String(detail.url)}
+
+ ) : (
+ '—'
+ )}
+
+ {detail.mock == null ? '—' : detail.mock ? '是' : '否'}
+
+ {detail.errorMessage ? (
+
+ {String(detail.errorMessage)}
+
+ ) : (
+ '—'
+ )}
+
+ {fmtTime(String(detail.createdAt))}
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx
index f158c3e..e5b9c0c 100644
--- a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx
+++ b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx
@@ -25,6 +25,7 @@ const PROVIDER_OPTIONS = [
{ value: 'WECHAT_AUTH', label: 'WECHAT_AUTH' },
{ value: 'WECHAT_PAY', label: 'WECHAT_PAY' },
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
+ { value: 'ALIYUN_OSS', label: 'ALIYUN_OSS' },
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
{ value: 'XFX', label: '小飞侠 (XFX)' },
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 3e49d08..d69e349 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -243,6 +243,7 @@ enum ThirdPartyProvider {
WECHAT_REFUND
WECHAT_AUTH
WECHAT_MAP
+ ALIYUN_OSS
XFX
SMS
LOGISTICS
diff --git a/server/dukang-api/src/integrations/oss/oss-log.util.ts b/server/dukang-api/src/integrations/oss/oss-log.util.ts
new file mode 100644
index 0000000..c018d44
--- /dev/null
+++ b/server/dukang-api/src/integrations/oss/oss-log.util.ts
@@ -0,0 +1,41 @@
+import type { PrismaService } from '../../common/prisma/prisma.module';
+
+export type OssActorRef = {
+ refType: string;
+ refId: bigint;
+};
+
+export function ossActorRefFromAuth(
+ actorType?: string,
+ actorId?: bigint,
+): OssActorRef | undefined {
+ if (!actorType || actorId == null) return undefined;
+ return { refType: actorType, refId: actorId };
+}
+
+type LogOssUploadInput = {
+ scene: 'UPLOAD_PUT_OBJECT' | 'UPLOAD_TOKEN';
+ requestBody?: Record;
+ responseBody?: Record;
+ externalNo?: string;
+ status: 'SUCCESS' | 'FAILED';
+ errorMessage?: string;
+ actorRef?: OssActorRef;
+};
+
+export async function logOssUpload(prisma: PrismaService, input: LogOssUploadInput) {
+ const row = await prisma.logThirdParty.create({
+ data: {
+ provider: 'ALIYUN_OSS',
+ scene: input.scene,
+ refType: input.actorRef?.refType,
+ refId: input.actorRef?.refId,
+ requestBody: input.requestBody as never,
+ responseBody: input.responseBody as never,
+ externalNo: input.externalNo?.slice(0, 128),
+ status: input.status,
+ errorMessage: input.errorMessage?.slice(0, 512),
+ },
+ });
+ return row.id;
+}
diff --git a/server/dukang-api/src/modules/common/resource.controller.ts b/server/dukang-api/src/modules/common/resource.controller.ts
index b541f7c..d22ebca 100644
--- a/server/dukang-api/src/modules/common/resource.controller.ts
+++ b/server/dukang-api/src/modules/common/resource.controller.ts
@@ -12,21 +12,31 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
-import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
-import { ResourceService } from './resource.service';
+import { JwtAuthGuard, type AuthUser } from '../../common/guards/jwt-auth.guard';
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+import { ResourceService, type OssUploadActor } from './resource.service';
import { ResourceListQueryDto } from './dto/common-query.dto';
import { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
+function resolveUploadActor(user?: AuthUser): OssUploadActor | undefined {
+ if (!user) return undefined;
+ return {
+ refType: user.actorType,
+ refId: user.actorId,
+ clientApp: user.clientApp,
+ };
+}
+
@Controller('common/resources')
@UseGuards(JwtAuthGuard)
export class ResourceController {
constructor(private readonly service: ResourceService) {}
@Post('upload-token')
- uploadToken(@Body() dto: UploadTokenDto) {
- return this.service.getUploadToken(dto);
+ uploadToken(@CurrentUser() user: AuthUser, @Body() dto: UploadTokenDto) {
+ return this.service.getUploadToken(dto, resolveUploadActor(user));
}
@Post('upload')
@@ -35,8 +45,12 @@ export class ResourceController {
limits: { fileSize: Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES) },
}),
)
- upload(@UploadedFile() file: Express.Multer.File, @Body() dto: UploadFileDto) {
- return this.service.uploadFile(file, dto);
+ upload(
+ @CurrentUser() user: AuthUser,
+ @UploadedFile() file: Express.Multer.File,
+ @Body() dto: UploadFileDto,
+ ) {
+ return this.service.uploadFile(file, dto, resolveUploadActor(user));
}
@Post()
diff --git a/server/dukang-api/src/modules/common/resource.service.ts b/server/dukang-api/src/modules/common/resource.service.ts
index 8b304bc..b56e3a1 100644
--- a/server/dukang-api/src/modules/common/resource.service.ts
+++ b/server/dukang-api/src/modules/common/resource.service.ts
@@ -5,11 +5,16 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
+import { logOssUpload, type OssActorRef } from '../../integrations/oss/oss-log.util';
import type { ResourceListQueryDto } from './dto/common-query.dto';
import type { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
+export type OssUploadActor = OssActorRef & {
+ clientApp?: string;
+};
+
@Injectable()
export class ResourceService {
constructor(
@@ -17,25 +22,120 @@ export class ResourceService {
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
) {}
- getUploadToken(dto: UploadTokenDto) {
- return this.oss.getUploadToken(dto);
+ getUploadToken(dto: UploadTokenDto, actor?: OssUploadActor) {
+ try {
+ const result = this.oss.getUploadToken(dto);
+ void logOssUpload(this.prisma, {
+ scene: 'UPLOAD_TOKEN',
+ actorRef: actor,
+ requestBody: {
+ bizType: dto.bizType,
+ mediaType: dto.mediaType,
+ fileName: dto.fileName,
+ clientApp: actor?.clientApp,
+ },
+ responseBody: {
+ bucket: result.bucket,
+ ossKey: result.ossKey,
+ url: result.url,
+ mock: result.mock ?? false,
+ },
+ externalNo: result.ossKey,
+ status: 'SUCCESS',
+ });
+ return result;
+ } catch (err) {
+ void logOssUpload(this.prisma, {
+ scene: 'UPLOAD_TOKEN',
+ actorRef: actor,
+ requestBody: {
+ bizType: dto.bizType,
+ mediaType: dto.mediaType,
+ fileName: dto.fileName,
+ clientApp: actor?.clientApp,
+ },
+ status: 'FAILED',
+ errorMessage: err instanceof Error ? err.message : String(err),
+ });
+ throw err;
+ }
}
- async uploadFile(file: Express.Multer.File | undefined, dto: UploadFileDto) {
- if (!file) {
- throw new BadRequestException('请选择要上传的文件');
- }
- const maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
- if (file.size > maxUploadBytes) {
- throw new BadRequestException(`文件不能超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB`);
- }
- return this.oss.putObject({
+ async uploadFile(
+ file: Express.Multer.File | undefined,
+ dto: UploadFileDto,
+ actor?: OssUploadActor,
+ ) {
+ const baseRequest = {
bizType: dto.bizType,
mediaType: dto.mediaType,
+ clientApp: actor?.clientApp,
+ };
+
+ if (!file) {
+ await logOssUpload(this.prisma, {
+ scene: 'UPLOAD_PUT_OBJECT',
+ actorRef: actor,
+ requestBody: baseRequest,
+ status: 'FAILED',
+ errorMessage: '请选择要上传的文件',
+ });
+ throw new BadRequestException('请选择要上传的文件');
+ }
+
+ const maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
+ const requestBody = {
+ ...baseRequest,
fileName: file.originalname || 'upload.bin',
- buffer: file.buffer,
+ fileSize: file.size,
mimeType: file.mimetype,
- });
+ };
+
+ if (file.size > maxUploadBytes) {
+ const message = `文件不能超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB`;
+ await logOssUpload(this.prisma, {
+ scene: 'UPLOAD_PUT_OBJECT',
+ actorRef: actor,
+ requestBody,
+ status: 'FAILED',
+ errorMessage: message,
+ });
+ throw new BadRequestException(message);
+ }
+
+ try {
+ const result = await this.oss.putObject({
+ bizType: dto.bizType,
+ mediaType: dto.mediaType,
+ fileName: file.originalname || 'upload.bin',
+ buffer: file.buffer,
+ mimeType: file.mimetype,
+ });
+ await logOssUpload(this.prisma, {
+ scene: 'UPLOAD_PUT_OBJECT',
+ actorRef: actor,
+ requestBody,
+ responseBody: {
+ bucket: result.bucket,
+ region: result.region,
+ ossKey: result.ossKey,
+ url: result.url,
+ mock: result.mock ?? false,
+ },
+ externalNo: result.ossKey,
+ status: 'SUCCESS',
+ });
+ return result;
+ } catch (err) {
+ await logOssUpload(this.prisma, {
+ scene: 'UPLOAD_PUT_OBJECT',
+ actorRef: actor,
+ requestBody,
+ status: 'FAILED',
+ errorMessage: err instanceof Error ? err.message : String(err),
+ });
+ throw err;
+ }
}
async register(dto: RegisterResourceDto) {
diff --git a/server/dukang-api/src/modules/ops/admin-oss-logs.controller.ts b/server/dukang-api/src/modules/ops/admin-oss-logs.controller.ts
new file mode 100644
index 0000000..fe3d912
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-oss-logs.controller.ts
@@ -0,0 +1,20 @@
+import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { AdminOssLogsService } from './admin-oss-logs.service';
+import { AdminOssLogsQueryDto } from './dto/admin-query.dto';
+
+@Controller('admin/logs/oss')
+@UseGuards(HqAuthGuard)
+export class AdminOssLogsController {
+ constructor(private readonly service: AdminOssLogsService) {}
+
+ @Get()
+ list(@Query() query: AdminOssLogsQueryDto) {
+ return this.service.list(query);
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-oss-logs.service.ts b/server/dukang-api/src/modules/ops/admin-oss-logs.service.ts
new file mode 100644
index 0000000..15f2c01
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-oss-logs.service.ts
@@ -0,0 +1,99 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import type { AdminOssLogsQueryDto } from './dto/admin-query.dto';
+
+function mapOssLogRow(row: {
+ id: bigint;
+ scene: string;
+ refType: string | null;
+ refId: bigint | null;
+ requestBody: unknown;
+ responseBody: unknown;
+ externalNo: string | null;
+ status: string;
+ errorMessage: string | null;
+ createdAt: Date;
+}) {
+ const req = (row.requestBody ?? {}) as Record;
+ const res = (row.responseBody ?? {}) as Record;
+ return {
+ id: row.id,
+ scene: row.scene,
+ status: row.status,
+ actorType: row.refType,
+ actorId: row.refId,
+ clientApp: (req.clientApp as string | undefined) ?? null,
+ bizType: (req.bizType as string | undefined) ?? null,
+ mediaType: (req.mediaType as string | undefined) ?? null,
+ fileName: (req.fileName as string | undefined) ?? null,
+ fileSize: (req.fileSize as number | undefined) ?? null,
+ mimeType: (req.mimeType as string | undefined) ?? null,
+ ossKey: (res.ossKey as string | undefined) ?? row.externalNo ?? null,
+ url: (res.url as string | undefined) ?? null,
+ bucket: (res.bucket as string | undefined) ?? null,
+ mock: (res.mock as boolean | undefined) ?? null,
+ errorMessage: row.errorMessage,
+ createdAt: row.createdAt,
+ requestBody: row.requestBody,
+ responseBody: row.responseBody,
+ };
+}
+
+@Injectable()
+export class AdminOssLogsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async list(query: AdminOssLogsQueryDto) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: Prisma.LogThirdPartyWhereInput = {
+ provider: 'ALIYUN_OSS',
+ };
+ if (query.scene) where.scene = query.scene;
+ if (query.status) where.status = query.status as Prisma.EnumThirdPartyLogStatusFilter['equals'];
+ if (query.refType) where.refType = query.refType;
+ if (query.refId) where.refId = BigInt(query.refId);
+
+ const andFilters: Prisma.LogThirdPartyWhereInput[] = [];
+ if (query.bizType) {
+ andFilters.push({
+ requestBody: { string_contains: `"bizType":"${query.bizType}"` },
+ });
+ }
+ if (query.clientApp) {
+ andFilters.push({
+ requestBody: { string_contains: `"clientApp":"${query.clientApp}"` },
+ });
+ }
+ if (andFilters.length) {
+ where.AND = andFilters;
+ }
+
+ const [rows, total] = await Promise.all([
+ this.prisma.logThirdParty.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.logThirdParty.count({ where }),
+ ]);
+
+ return serializeBigInt({
+ items: rows.map(mapOssLogRow),
+ total,
+ page,
+ pageSize,
+ });
+ }
+
+ async detail(id: bigint) {
+ const row = await this.prisma.logThirdParty.findFirst({
+ where: { id, provider: 'ALIYUN_OSS' },
+ });
+ if (!row) throw new NotFoundException('OSS 上传日志不存在');
+ return serializeBigInt(mapOssLogRow(row));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
index aa3b78d..709220d 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
@@ -399,6 +399,32 @@ export class AdminHqLogsQueryDto extends PaginationQueryDto {
to?: string;
}
+export class AdminOssLogsQueryDto extends PaginationQueryDto {
+ @IsOptional()
+ @IsString()
+ scene?: string;
+
+ @IsOptional()
+ @IsIn(['SUCCESS', 'FAILED', 'PENDING'])
+ status?: string;
+
+ @IsOptional()
+ @IsString()
+ bizType?: string;
+
+ @IsOptional()
+ @IsString()
+ clientApp?: string;
+
+ @IsOptional()
+ @IsString()
+ refType?: string;
+
+ @IsOptional()
+ @IsString()
+ refId?: string;
+}
+
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index f45df89..07ed17f 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -31,6 +31,8 @@ import { AdminPartnerLogsController } from './admin-partner-logs.controller';
import { AdminPartnerLogsService } from './admin-partner-logs.service';
import { AdminHqLogsController } from './admin-hq-logs.controller';
import { AdminHqLogsService } from './admin-hq-logs.service';
+import { AdminOssLogsController } from './admin-oss-logs.controller';
+import { AdminOssLogsService } from './admin-oss-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@@ -77,6 +79,7 @@ import { AdminDeployService } from './admin-deploy.service';
AdminStoreLogsController,
AdminPartnerLogsController,
AdminHqLogsController,
+ AdminOssLogsController,
AdminTicketsController,
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
@@ -101,6 +104,7 @@ import { AdminDeployService } from './admin-deploy.service';
AdminStoreLogsService,
AdminPartnerLogsService,
AdminHqLogsService,
+ AdminOssLogsService,
AdminTicketsService,
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,