diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index e345e66..e5d61e2 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -29,6 +29,7 @@ import ResourcesPage from './pages/ResourcesPage'; import StoreBillsPage from './pages/StoreBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import TicketsPage from './pages/TicketsPage'; +import OssUploadLogsPage from './pages/OssUploadLogsPage'; import UserLogsPage from './pages/UserLogsPage'; import HqLogsPage from './pages/HqLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; @@ -86,6 +87,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index b841023..823bfa3 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -90,6 +90,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/logs/stores', label: '商户日志' }, { key: '/logs/partners', label: '合伙人日志' }, { key: '/logs/hq', label: 'HQ 操作日志' }, + { key: '/logs/oss', label: 'OSS 上传日志' }, { key: '/logs/third-party', label: '第三方日志' }, ], }, diff --git a/apps/admin-web/src/pages/OssUploadLogsPage.tsx b/apps/admin-web/src/pages/OssUploadLogsPage.tsx new file mode 100644 index 0000000..6be92a1 --- /dev/null +++ b/apps/admin-web/src/pages/OssUploadLogsPage.tsx @@ -0,0 +1,247 @@ +import { useState } from 'react'; +import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + scene: string; + status: string; + actorType: string | null; + actorId: string | null; + clientApp: string | null; + bizType: string | null; + mediaType: string | null; + fileName: string | null; + fileSize: number | null; + mimeType: string | null; + ossKey: string | null; + url: string | null; + bucket: string | null; + mock: boolean | null; + errorMessage?: string | null; + createdAt: string; +}; + +const SCENE_OPTIONS = [ + { value: 'UPLOAD_PUT_OBJECT', label: '服务端上传' }, + { value: 'UPLOAD_TOKEN', label: '直传凭证' }, +]; + +const STATUS_OPTIONS = [ + { value: 'SUCCESS', label: 'SUCCESS' }, + { value: 'FAILED', label: 'FAILED' }, + { value: 'PENDING', label: 'PENDING' }, +]; + +const CLIENT_APP_OPTIONS = [ + { value: 'HQ_WEB', label: 'HQ WebAdmin' }, + { value: 'USER_H5', label: 'C 端 H5' }, + { value: 'USER_MINI', label: 'C 端小程序' }, + { value: 'SHOP_H5', label: '门店 H5' }, + { value: 'PARTNER_H5', label: '合伙人 H5' }, +]; + +const STATUS_COLOR: Record = { + 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 的文件上传与直传凭证申请。 + +
{ + setFilters(v); + setPage(1); + }} + > + + + + + + + + + + +
+ { + 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,