feat: 完成现有小程序接口的全部迁移至新后端
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/** 仅接收成功上传并校验的预览对象;对象身份不包含 CDN 域名。 */
|
||||
export function buildAssetMappings(report, assets, config) {
|
||||
if (
|
||||
report.schemaVersion !== 1 ||
|
||||
report.mode !== 'apply' ||
|
||||
report.ok !== true ||
|
||||
!Array.isArray(report.files)
|
||||
)
|
||||
throw new Error('INVALID_SYNC_REPORT');
|
||||
const files = new Map();
|
||||
for (const file of report.files) {
|
||||
if (!file.relativePath?.startsWith('assets/previews/')) continue;
|
||||
const parts = file.relativePath.split('/');
|
||||
if (
|
||||
parts.some((part) => !part || part === '.' || part === '..') ||
|
||||
file.relativePath.includes('\\') ||
|
||||
!['uploaded', 'skipped'].includes(file.status) ||
|
||||
file.verifiedBy !== 'qiniu-etag-and-size' ||
|
||||
file.bucket !== config.publicBucket ||
|
||||
file.key !== `${config.prefix}${file.relativePath}` ||
|
||||
file.visibility !== 'public' ||
|
||||
!file.mimeType?.startsWith('image/') ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 0 ||
|
||||
!/^[a-f0-9]{64}$/.test(file.sha256) ||
|
||||
typeof file.etag !== 'string' ||
|
||||
files.has(file.relativePath)
|
||||
)
|
||||
throw new Error('INVALID_REPORT_FILE');
|
||||
files.set(file.relativePath, file);
|
||||
}
|
||||
const mappings = [];
|
||||
for (const asset of assets) {
|
||||
if (
|
||||
asset.purpose !== 'preview' ||
|
||||
!asset.sourceRef?.startsWith('cloud://')
|
||||
)
|
||||
continue;
|
||||
const url = new URL(asset.sourceRef);
|
||||
if (
|
||||
!url.hostname ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
asset.sourceRefHash !==
|
||||
createHash('sha256').update(asset.sourceRef).digest('hex')
|
||||
)
|
||||
throw new Error('INVALID_SOURCE_REFERENCE');
|
||||
const path = decodeURIComponent(url.pathname.slice(1));
|
||||
const file = files.get(path);
|
||||
if (
|
||||
!file ||
|
||||
asset.visibility !== 'public' ||
|
||||
asset.ownerUserId !== null ||
|
||||
!['pending', 'ready'].includes(asset.status)
|
||||
)
|
||||
throw new Error('ASSET_MAPPING_MISSING_OR_INELIGIBLE');
|
||||
const data = {
|
||||
provider: 'qiniu',
|
||||
bucket: file.bucket,
|
||||
objectKey: file.key,
|
||||
objectRefHash: createHash('sha256')
|
||||
.update(JSON.stringify(['qiniu', file.bucket, file.key]))
|
||||
.digest('hex'),
|
||||
externalUrl: null,
|
||||
mimeType: file.mimeType,
|
||||
sizeBytes: BigInt(file.size),
|
||||
checksum: file.sha256,
|
||||
status: 'ready',
|
||||
lastError: null,
|
||||
};
|
||||
const unchanged = Object.entries(data).every(
|
||||
([key, value]) => asset[key] === value,
|
||||
);
|
||||
// 已就绪对象只能重复确认同一映射,禁止工具悄悄覆盖之后的编辑。
|
||||
if (asset.status === 'ready' && !unchanged)
|
||||
throw new Error('READY_ASSET_CONFLICT');
|
||||
mappings.push({ asset, file, data, unchanged });
|
||||
}
|
||||
if (!mappings.length) throw new Error('NO_MATCHING_ASSETS');
|
||||
return mappings;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'reflect-metadata';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { parseArgs, parseEnv } from 'node:util';
|
||||
import {
|
||||
loadConfig,
|
||||
validateApplyConfig,
|
||||
} from '../../../packages/asset-sync/src/config.mjs';
|
||||
import { readSource } from '../../../packages/asset-sync/src/files.mjs';
|
||||
import { createQiniuClient } from '../../../packages/asset-sync/src/qiniu-client.mjs';
|
||||
import { buildAssetMappings } from './asset-mapping.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { PrismaService } = require('../dist/database/prisma.service.js');
|
||||
const { getServerConfig } = require('../dist/config/server-config.js');
|
||||
let database;
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
report: { type: 'string' },
|
||||
apply: { type: 'boolean', default: false },
|
||||
},
|
||||
});
|
||||
if (!values.report) throw new Error('REPORT_REQUIRED');
|
||||
const local = parseEnv(
|
||||
await readFile(new URL('../.env', import.meta.url), 'utf8'),
|
||||
);
|
||||
const server = getServerConfig({ ...local, ...process.env });
|
||||
const url = new URL(server.databaseUrl);
|
||||
// 本次数据迁移仅授权本机开发库;部署环境需独立迁移流程。
|
||||
if (
|
||||
!['localhost', '127.0.0.1'].includes(url.hostname) ||
|
||||
url.pathname !== '/doodle_dev'
|
||||
)
|
||||
throw new Error('LOCAL_DATABASE_REQUIRED');
|
||||
const config = await loadConfig();
|
||||
if (
|
||||
config.publicBucket !== server.qiniuBucket ||
|
||||
config.prefix !== server.qiniuKeyPrefix ||
|
||||
config.publicDomain !== server.qiniuPublicDomain
|
||||
)
|
||||
throw new Error('SERVER_SYNC_CONFIG_MISMATCH');
|
||||
const raw = await readFile(values.report, 'utf8');
|
||||
database = new PrismaService(server);
|
||||
const assets = await database.client.asset.findMany();
|
||||
const mappings = buildAssetMappings(JSON.parse(raw), assets, config);
|
||||
validateApplyConfig(
|
||||
config,
|
||||
mappings.map((item) => item.file),
|
||||
);
|
||||
const client = createQiniuClient(config);
|
||||
if ((await client.bucketInfo(config.publicBucket)).private !== 0)
|
||||
throw new Error('BUCKET_VISIBILITY_MISMATCH');
|
||||
// 数据库事务外完成全部本地和远端核对,不将网络等待放进事务。
|
||||
for (const { file } of mappings) {
|
||||
const source = await readSource(config, file.relativePath);
|
||||
const remote = await client.stat(file.bucket, file.key);
|
||||
if (
|
||||
source.sha256 !== file.sha256 ||
|
||||
source.etag !== file.etag ||
|
||||
source.size !== file.size ||
|
||||
remote?.hash !== file.etag ||
|
||||
remote.fsize !== file.size ||
|
||||
remote.mimeType !== file.mimeType
|
||||
)
|
||||
throw new Error('ASSET_VERIFICATION_FAILED');
|
||||
}
|
||||
const requestId = `asset-mapping-${randomUUID()}`;
|
||||
if (values.apply) {
|
||||
await database.client.$transaction(
|
||||
async (tx) => {
|
||||
for (const { asset, data, unchanged } of mappings) {
|
||||
if (unchanged) continue;
|
||||
// 乐观并发检查与整批事务保证不会覆盖回填期间的其他编辑。
|
||||
const result = await tx.asset.updateMany({
|
||||
where: {
|
||||
id: asset.id,
|
||||
updatedAt: asset.updatedAt,
|
||||
status: asset.status,
|
||||
},
|
||||
data,
|
||||
});
|
||||
if (result.count !== 1)
|
||||
throw new Error('CONCURRENT_ASSET_CHANGE');
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorType: 'migration',
|
||||
action: 'asset.map-qiniu',
|
||||
resourceType: 'assets',
|
||||
resourceId: asset.id,
|
||||
requestId,
|
||||
beforeData: {
|
||||
status: asset.status,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
},
|
||||
afterData: {
|
||||
status: data.status,
|
||||
bucket: data.bucket,
|
||||
objectKey: data.objectKey,
|
||||
checksum: data.checksum,
|
||||
},
|
||||
reason: `sync-report sha256=${createHash('sha256').update(raw).digest('hex')}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
dryRun: !values.apply,
|
||||
requestId,
|
||||
matched: mappings.length,
|
||||
updated: mappings.filter((item) => !item.unchanged).length,
|
||||
unchanged: mappings.filter((item) => item.unchanged).length,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// 不输出数据库连接、SDK 原始异常或密钥;错误保留有限自定义代码。
|
||||
console.error(
|
||||
/^[A-Z_]+$/.test(error.message ?? '')
|
||||
? error.message
|
||||
: 'ASSET_MAPPING_FAILED',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await database?.onModuleDestroy();
|
||||
}
|
||||
@@ -75,6 +75,12 @@ try {
|
||||
'--test',
|
||||
'dist-test/test/content-publication-database.test.js',
|
||||
]);
|
||||
run([
|
||||
'exec',
|
||||
'node',
|
||||
'--test',
|
||||
'dist-test/test/member-site-database.test.js',
|
||||
]);
|
||||
} finally {
|
||||
if (created) {
|
||||
await admin.query(`DROP DATABASE "${database}"`);
|
||||
|
||||
Reference in New Issue
Block a user