mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-12 06:40:58 +04:30
Ship legacy migrate APIs, portfolio/blog old-id schema, and admin migrate/purge flows.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7244b70e90
commit
4598add88c
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { LegacyMysqlService } from './legacy-mysql.service';
|
||||
import { LegacySourceS3Service } from './legacy-source-s3.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [LegacyMysqlService, LegacySourceS3Service],
|
||||
exports: [LegacyMysqlService, LegacySourceS3Service],
|
||||
})
|
||||
export class LegacyMysqlModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import mysql, { Pool, PoolOptions, RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
@Injectable()
|
||||
export class LegacyMysqlService implements OnModuleDestroy {
|
||||
private pool: Pool | null = null;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.pool = null;
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(
|
||||
this.config.get<string>('OLD_MYSQL_HOST') &&
|
||||
this.config.get<string>('OLD_MYSQL_USER') &&
|
||||
this.config.get<string>('OLD_MYSQL_DATABASE'),
|
||||
);
|
||||
}
|
||||
|
||||
async query<T extends RowDataPacket[]>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T> {
|
||||
const pool = this.getPool();
|
||||
try {
|
||||
const [rows] = await pool.query<T>(sql, params);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown MySQL error';
|
||||
throw new ServiceUnavailableException(
|
||||
`Old CMS MySQL query failed: ${message}. Ensure the SSH tunnel is up and OLD_MYSQL_* is set.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getPool(): Pool {
|
||||
if (this.pool) {
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Old CMS MySQL is not configured. Set OLD_MYSQL_HOST, OLD_MYSQL_USER, OLD_MYSQL_DATABASE (and password/port).',
|
||||
);
|
||||
}
|
||||
|
||||
const options: PoolOptions = {
|
||||
host: this.config.getOrThrow<string>('OLD_MYSQL_HOST'),
|
||||
port: Number(this.config.get<string>('OLD_MYSQL_PORT', '3307')),
|
||||
user: this.config.getOrThrow<string>('OLD_MYSQL_USER'),
|
||||
password: this.config.get<string>('OLD_MYSQL_PASSWORD', ''),
|
||||
database: this.config.getOrThrow<string>('OLD_MYSQL_DATABASE'),
|
||||
waitForConnections: true,
|
||||
connectionLimit: 4,
|
||||
namedPlaceholders: false,
|
||||
};
|
||||
|
||||
this.pool = mysql.createPool(options);
|
||||
return this.pool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
private client: S3Client | null = null;
|
||||
private bucket = '';
|
||||
private publicUrlBase = '';
|
||||
private readonly prefixCache = new Map<number, string>();
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.client?.destroy();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(
|
||||
this.config.get<string>('OLD_S3_ENDPOINT') &&
|
||||
this.config.get<string>('OLD_S3_BUCKET') &&
|
||||
this.config.get<string>('OLD_S3_ACCESS_KEY_ID') &&
|
||||
this.config.get<string>('OLD_S3_SECRET_ACCESS_KEY'),
|
||||
);
|
||||
}
|
||||
|
||||
spatieObjectKey(prefix: string, mediaId: number, fileName: string): string {
|
||||
const hash = createHash('md5').update(String(mediaId)).digest('hex');
|
||||
return `${prefix}/${hash}/${fileName}`;
|
||||
}
|
||||
|
||||
async resolveBusinessPrefix(
|
||||
oldBusinessId: number,
|
||||
slugHint: string | null,
|
||||
probes: Array<{ id: number; fileName: string }>,
|
||||
): Promise<string> {
|
||||
const cached = this.prefixCache.get(oldBusinessId);
|
||||
if (cached) return cached;
|
||||
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (prefix: string) => {
|
||||
if (!prefix || seen.has(prefix)) return;
|
||||
seen.add(prefix);
|
||||
candidates.push(prefix);
|
||||
};
|
||||
|
||||
if (slugHint?.trim()) {
|
||||
const slug = slugHint.trim();
|
||||
add(`${slug}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
|
||||
}
|
||||
|
||||
const listed = await this.listTopLevelPrefixes();
|
||||
for (const prefix of listed) {
|
||||
if (prefix.endsWith(`_${oldBusinessId}`)) {
|
||||
add(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Could not resolve old S3 path prefix for business ${oldBusinessId}. Check OLD_S3_* and that files exist.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sample = probes.slice(0, 12);
|
||||
let bestPrefix = candidates[0];
|
||||
let bestHits = -1;
|
||||
|
||||
for (const prefix of candidates) {
|
||||
let hits = 0;
|
||||
for (const probe of sample) {
|
||||
const key = this.spatieObjectKey(prefix, probe.id, probe.fileName);
|
||||
if (await this.objectExists(key)) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
if (hits > bestHits) {
|
||||
bestHits = hits;
|
||||
bestPrefix = prefix;
|
||||
}
|
||||
if (bestHits === sample.length && sample.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestHits <= 0 && sample.length > 0) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
|
||||
);
|
||||
}
|
||||
|
||||
this.prefixCache.set(oldBusinessId, bestPrefix);
|
||||
return bestPrefix;
|
||||
}
|
||||
|
||||
async getObjectBuffer(key: string): Promise<Buffer> {
|
||||
const { client, bucket } = this.getClient();
|
||||
try {
|
||||
const result = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
);
|
||||
if (!result.Body) {
|
||||
throw new Error(`Empty body for ${key}`);
|
||||
}
|
||||
return Buffer.from(await result.Body.transformToByteArray());
|
||||
} catch (err) {
|
||||
// Public URL fallback (path-style bucket public reads)
|
||||
if (this.publicUrlBase) {
|
||||
const res = await fetch(`${this.publicUrlBase}/${key}`);
|
||||
if (res.ok) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown S3 error';
|
||||
throw new ServiceUnavailableException(
|
||||
`Old CMS S3 get failed for ${key}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async objectExists(key: string): Promise<boolean> {
|
||||
const { client, bucket } = this.getClient();
|
||||
try {
|
||||
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
||||
return true;
|
||||
} catch {
|
||||
if (this.publicUrlBase) {
|
||||
try {
|
||||
const res = await fetch(`${this.publicUrlBase}/${key}`, {
|
||||
method: 'HEAD',
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async listTopLevelPrefixes(): Promise<string[]> {
|
||||
const { client, bucket } = this.getClient();
|
||||
const prefixes: string[] = [];
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const res = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Delimiter: '/',
|
||||
ContinuationToken: token,
|
||||
MaxKeys: 1000,
|
||||
}),
|
||||
);
|
||||
for (const p of res.CommonPrefixes ?? []) {
|
||||
const raw = p.Prefix?.replace(/\/$/, '');
|
||||
if (raw) prefixes.push(raw);
|
||||
}
|
||||
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (token);
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
private getClient(): { client: S3Client; bucket: string } {
|
||||
if (this.client) {
|
||||
return { client: this.client, bucket: this.bucket };
|
||||
}
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Old CMS S3 is not configured. Set OLD_S3_ENDPOINT, OLD_S3_BUCKET, OLD_S3_ACCESS_KEY_ID, OLD_S3_SECRET_ACCESS_KEY.',
|
||||
);
|
||||
}
|
||||
|
||||
this.bucket = this.config.getOrThrow<string>('OLD_S3_BUCKET');
|
||||
this.publicUrlBase = (
|
||||
this.config.get<string>('OLD_S3_PUBLIC_URL') ?? ''
|
||||
).replace(/\/$/, '');
|
||||
|
||||
this.client = new S3Client({
|
||||
endpoint: this.config.getOrThrow<string>('OLD_S3_ENDPOINT'),
|
||||
region: this.config.get<string>('OLD_S3_REGION', 'us-east-1'),
|
||||
forcePathStyle:
|
||||
this.config.get<string>('OLD_S3_FORCE_PATH_STYLE', 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: this.config.getOrThrow<string>('OLD_S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: this.config.getOrThrow<string>(
|
||||
'OLD_S3_SECRET_ACCESS_KEY',
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return { client: this.client, bucket: this.bucket };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user