feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
@@ -0,0 +1,163 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import ts from '../../api/node_modules/typescript/lib/typescript.js';
const root = process.cwd();
const manifest = JSON.parse(
readFileSync(resolve(root, 'docs/contracts/report-materials-r4-methods.json'), 'utf8'),
);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
function loadSource(relativePath) {
const filePath = resolve(root, relativePath);
const source = readFileSync(filePath, 'utf8');
return {
source,
file: ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
};
}
function loadClass(relativePath, className) {
const loaded = loadSource(relativePath);
const declaration = loaded.file.statements.find(
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === className,
);
if (!declaration || !ts.isClassDeclaration(declaration)) {
throw new Error(`${className} not found in ${relativePath}`);
}
return {
...loaded,
methods: new Map(
declaration.members.filter(ts.isMethodDeclaration).map((method) => [
method.name.getText(loaded.file),
method,
]),
),
};
}
const reverseReplacements = {
officialExport: {
'this.pending.findPendingItems': 'this.findPendingItems',
},
importReview: {
'this.importParser.saveImportProfile': 'this.saveImportProfile',
},
batchGeneration: {
'this.operations.claimBatchOperation': 'this.claimBatchOperation',
'this.operations.completeBatchOperation': 'this.completeBatchOperation',
'this.operations.failBatchOperation': 'this.failBatchOperation',
'this.channelExport.exportChannelBatch': 'this.exportChannelBatch',
},
};
function normalizedBody(domain, method, file) {
let printed = printer.printNode(ts.EmitHint.Unspecified, method.body, file);
for (const [current, original] of Object.entries(reverseReplacements[domain] ?? {})) {
printed = printed.split(current).join(original);
}
printed = printed
.split("ReportBatchGenerationService['preflightBatch']")
.join("ReportMaterialsService['preflightBatch']")
.split("ReportBatchGenerationService['prepareBatchItem']")
.join("ReportMaterialsService['prepareBatchItem']");
return printed;
}
const domainMethods = new Map();
for (const [domain, definition] of Object.entries(manifest.domains)) {
const loaded = loadClass(definition.file, definition.className);
for (const name of definition.methods) {
const method = loaded.methods.get(name);
if (!method?.body) throw new Error(`Missing R4 domain method: ${domain}.${name}`);
if (domainMethods.has(name)) throw new Error(`Duplicate R4 domain method: ${name}`);
domainMethods.set(name, { domain, method, file: loaded.file });
}
if (loaded.methods.size !== definition.methods.length) {
throw new Error(`Unexpected method found in R4 domain: ${domain}`);
}
}
const expectedMethods = [...manifest.publicMethods, ...manifest.internalMethods];
for (const expected of expectedMethods) {
const actual = domainMethods.get(expected.name);
if (!actual) throw new Error(`R4 implementation missing: ${expected.name}`);
if (actual.domain !== expected.domain) throw new Error(`${expected.name} moved to unexpected R4 domain`);
if (sha256(normalizedBody(actual.domain, actual.method, actual.file)) !== expected.canonicalBodySha256) {
throw new Error(`${expected.name} implementation changed during R4 split`);
}
}
if (domainMethods.size !== expectedMethods.length) {
throw new Error('Unexpected R4 implementation method found');
}
const facade = loadClass('api/src/report-materials/report-materials.service.ts', 'ReportMaterialsService');
for (const expected of manifest.publicMethods) {
const method = facade.methods.get(expected.name);
if (!method?.body) throw new Error(`ReportMaterialsService facade method missing: ${expected.name}`);
const signature = facade.source.slice(method.getStart(facade.file), method.body.getStart(facade.file)).trim();
if (signature !== expected.signature) throw new Error(`${expected.name} facade signature changed`);
}
if (facade.methods.size !== manifest.publicMethods.length) {
throw new Error('Unexpected ReportMaterialsService facade method found');
}
const contracts = loadSource('api/src/report-materials/report-materials.contracts.ts');
const actualContracts = new Map(
contracts.file.statements
.filter((statement) => ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement))
.map((statement) => [
statement.name.text,
sha256(statement.getText(contracts.file).replace(/^export\s+/, '')),
]),
);
for (const expected of manifest.contracts) {
if (actualContracts.get(expected.name) !== expected.sha256) {
throw new Error(`Report-materials contract changed during R4 split: ${expected.name}`);
}
}
if (actualContracts.size !== manifest.contracts.length) {
throw new Error('Unexpected report-materials contract found');
}
const helpers = loadSource('api/src/report-materials/report-materials.helpers.ts');
const actualHelpers = new Map();
for (const statement of helpers.file.statements) {
if (ts.isFunctionDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
if (statement.name) {
actualHelpers.set(statement.name.text, sha256(statement.getText(helpers.file).replace(/^export\s+/, '')));
}
} else if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
actualHelpers.set(
declaration.name.getText(helpers.file),
sha256(statement.getText(helpers.file).replace(/^export\s+/, '')),
);
}
}
}
for (const expected of manifest.helpers) {
if (actualHelpers.get(expected.name) !== expected.sha256) {
throw new Error(`Report-materials helper changed during R4 split: ${expected.name}`);
}
}
if (actualHelpers.size !== manifest.helpers.length) {
throw new Error('Unexpected report-materials helper found');
}
const controller = loadSource('api/src/report-materials/report-materials.controller.ts');
for (const statement of controller.file.statements.filter(ts.isImportDeclaration)) {
if (!statement.moduleSpecifier.getText(controller.file).includes('report-materials.service')) continue;
const names = statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)
? statement.importClause.namedBindings.elements.map((element) => element.name.text)
: [];
if (names.some((name) => name.endsWith('Dto') || name.endsWith('Mapping'))) {
throw new Error('ReportMaterialsController still imports contracts from the implementation facade');
}
}
console.log(
`R4 report-materials facade verified: ${manifest.publicMethods.length} public methods, ${manifest.internalMethods.length} internal methods, ${manifest.contracts.length} contracts and ${manifest.helpers.length} helpers; implementations unchanged.`,
);