feat: implement third-party order sync
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { catchError, from, mergeMap, Observable, throwError } from 'rxjs';
|
||||
import { AuditService } from './audit.service';
|
||||
import {
|
||||
OPERATION_LOG_METADATA,
|
||||
type OperationLogOptions,
|
||||
} from './operation-log.decorator';
|
||||
|
||||
const sensitiveKeys = [
|
||||
'password',
|
||||
'oldPassword',
|
||||
'newPassword',
|
||||
'passwordHash',
|
||||
'token',
|
||||
'key',
|
||||
'secret',
|
||||
'studentPassword',
|
||||
];
|
||||
|
||||
type RequestWithUser = Request & {
|
||||
user?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OperationLogInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly auditService: AuditService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const options = this.reflector.getAllAndOverride<OperationLogOptions>(
|
||||
OPERATION_LOG_METADATA,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!options) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
|
||||
return next.handle().pipe(
|
||||
mergeMap((result) =>
|
||||
from(
|
||||
this.writeLog({
|
||||
options,
|
||||
request,
|
||||
result,
|
||||
status: 'success',
|
||||
durationMs: Date.now() - startedAt,
|
||||
}),
|
||||
).pipe(mergeMap(() => [result])),
|
||||
),
|
||||
catchError((error: unknown) =>
|
||||
from(
|
||||
this.writeLog({
|
||||
options,
|
||||
request,
|
||||
status: 'failed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
}),
|
||||
).pipe(mergeMap(() => throwError(() => error))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async writeLog(input: {
|
||||
options: OperationLogOptions;
|
||||
request: RequestWithUser;
|
||||
result?: unknown;
|
||||
status: 'success' | 'failed';
|
||||
durationMs: number;
|
||||
error?: unknown;
|
||||
}) {
|
||||
const { options, request, result, status, durationMs, error } = input;
|
||||
const actor = request.user;
|
||||
|
||||
await this.auditService.write({
|
||||
actorId: actor?.id ?? null,
|
||||
actorUsername: actor?.name || actor?.username || null,
|
||||
action: options.action,
|
||||
resourceType: options.resourceType ?? null,
|
||||
resourceId: this.getResourceId(options, request, result),
|
||||
metadata: {
|
||||
status,
|
||||
description: options.description,
|
||||
durationMs,
|
||||
request: this.pickRequestMetadata(request, options),
|
||||
error: error ? this.toErrorMetadata(error) : undefined,
|
||||
},
|
||||
ipAddress: this.getIp(request),
|
||||
});
|
||||
}
|
||||
|
||||
private getResourceId(
|
||||
options: OperationLogOptions,
|
||||
request: RequestWithUser,
|
||||
result?: unknown,
|
||||
) {
|
||||
if (options.resourceIdParam) {
|
||||
return String(request.params?.[options.resourceIdParam] ?? '') || null;
|
||||
}
|
||||
|
||||
if (options.resourceIdFromResult) {
|
||||
const value = this.readPath(result, options.resourceIdFromResult);
|
||||
return value == null ? null : String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private pickRequestMetadata(
|
||||
request: RequestWithUser,
|
||||
options: OperationLogOptions,
|
||||
) {
|
||||
return {
|
||||
params: this.maskValue(request.params || {}),
|
||||
query: this.pickAndMask(request.query, options.metadataFromQuery),
|
||||
body: this.pickAndMask(request.body, options.metadataFromBody),
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private pickAndMask(value: unknown, keys?: string[]) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
if (!keys?.length) {
|
||||
return this.maskValue(source);
|
||||
}
|
||||
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
picked[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return this.maskValue(picked);
|
||||
}
|
||||
|
||||
private maskValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.maskValue(item));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const masked: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (this.isSensitiveKey(key)) {
|
||||
masked[key] = '******';
|
||||
} else {
|
||||
masked[key] = this.maskValue(item);
|
||||
}
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
private isSensitiveKey(key: string) {
|
||||
const normalized = key.toLowerCase();
|
||||
return sensitiveKeys.some((item) => normalized.includes(item.toLowerCase()));
|
||||
}
|
||||
|
||||
private toErrorMetadata(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
private readPath(value: unknown, path: string) {
|
||||
return path.split('.').reduce<unknown>((current, key) => {
|
||||
if (!current || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}, value);
|
||||
}
|
||||
|
||||
private getIp(request: Request) {
|
||||
const forwardedFor = request.headers['x-forwarded-for'];
|
||||
if (typeof forwardedFor === 'string') {
|
||||
return forwardedFor.split(',')[0]?.trim() || null;
|
||||
}
|
||||
|
||||
return request.ip || request.socket.remoteAddress || null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user