feat: add yuntu report filling automation

This commit is contained in:
wxs
2026-03-31 18:52:34 +08:00
parent c0a531fd1d
commit c8b5d27078
17 changed files with 2996 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
const { Pool } = require('pg');
function createPool(env = process.env) {
if (!env.DATABASE_URL) {
throw new Error('DATABASE_URL is required');
}
return new Pool({
connectionString: env.DATABASE_URL,
});
}
module.exports = {
createPool,
};
@@ -0,0 +1,36 @@
const dotenv = require('dotenv');
const { createPool } = require('./db');
const { createRepository } = require('./report-repository');
const { createApp } = require('./server');
dotenv.config();
const port = Number.parseInt(process.env.PORT || '3000', 10);
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'https://yuntu.oceanengine.com';
async function start() {
const pool = createPool(process.env);
const repository = createRepository(pool);
const app = createApp({ repository, allowedOrigin });
const server = app.listen(port, () => {
console.log(`Yuntu report server listening on http://localhost:${port}`);
});
const shutdown = async () => {
server.close(async () => {
await pool.end();
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
start().catch((error) => {
console.error('Failed to start Yuntu report server');
console.error(error);
process.exit(1);
});
@@ -0,0 +1,86 @@
const INSERT_REPORT_SQL = `
INSERT INTO public.yuntu_report_info (
report_id,
source_type,
source_report_id,
aadvid,
name,
price,
rules,
analysis_dims,
categories,
transaction_channels,
start_time,
end_time,
period_type,
user_name,
payload
)
VALUES (
$1, $2, $3, $4, $5,
$6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
$11, $12, $13, $14, $15::jsonb
)
ON CONFLICT (report_id) DO NOTHING
RETURNING id, report_id
`;
const FIND_REPORT_SQL = `
SELECT id, report_id
FROM public.yuntu_report_info
WHERE report_id = $1
`;
function createRepository(pool) {
return {
async save(record) {
const insertValues = [
record.report_id,
record.source_type,
record.source_report_id,
record.aadvid,
record.name,
JSON.stringify(record.price),
JSON.stringify(record.rules),
JSON.stringify(record.analysis_dims),
JSON.stringify(record.categories),
JSON.stringify(record.transaction_channels),
record.start_time,
record.end_time,
record.period_type,
record.user_name,
JSON.stringify(record.payload),
];
const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues);
if (insertResult.rows.length > 0) {
const row = insertResult.rows[0];
return {
id: row.id,
reportId: row.report_id,
created: true,
};
}
const existingResult = await pool.query(FIND_REPORT_SQL, [record.report_id]);
const existingRow = existingResult.rows[0];
if (!existingRow) {
throw new Error(`report_id ${record.report_id} was not found after conflict`);
}
return {
id: existingRow.id,
reportId: existingRow.report_id,
created: false,
};
},
};
}
module.exports = {
createRepository,
FIND_REPORT_SQL,
INSERT_REPORT_SQL,
};
@@ -0,0 +1,115 @@
const ALLOWED_SOURCE_TYPES = new Set(['MANUAL_CAPTURE', 'AUTO_COPY']);
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
this.code = 'VALIDATION_ERROR';
}
}
function ensureNonEmptyString(value, fieldName) {
if (typeof value !== 'string' || value.trim() === '') {
throw new ValidationError(`${fieldName} is required`);
}
return value.trim();
}
function ensureOptionalString(value, fieldName) {
if (value == null || value === '') {
return null;
}
return ensureNonEmptyString(value, fieldName);
}
function ensureArray(value, fieldName) {
if (!Array.isArray(value)) {
throw new ValidationError(`${fieldName} must be an array`);
}
return value;
}
function ensureObject(value, fieldName) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ValidationError(`${fieldName} must be an object`);
}
return value;
}
function ensureDateString(value, fieldName) {
const normalized = ensureNonEmptyString(value, fieldName);
if (!DATE_PATTERN.test(normalized)) {
throw new ValidationError(`${fieldName} must match YYYY-MM-DD`);
}
return normalized;
}
function validateAndNormalizeReportInput(input) {
const payload = ensureObject(input, 'body');
const sourceType = ensureNonEmptyString(payload.sourceType, 'sourceType');
if (!ALLOWED_SOURCE_TYPES.has(sourceType)) {
throw new ValidationError('sourceType must be MANUAL_CAPTURE or AUTO_COPY');
}
const sourceReportId = ensureOptionalString(payload.sourceReportId, 'sourceReportId');
if (sourceType === 'MANUAL_CAPTURE' && sourceReportId !== null) {
throw new ValidationError('sourceReportId must be empty for MANUAL_CAPTURE');
}
if (sourceType === 'AUTO_COPY' && sourceReportId === null) {
throw new ValidationError('sourceReportId is required when sourceType is AUTO_COPY');
}
return {
reportId: ensureNonEmptyString(payload.reportId, 'reportId'),
sourceType,
sourceReportId,
aadvid: ensureNonEmptyString(payload.aadvid, 'aadvid'),
name: ensureNonEmptyString(payload.name, 'name'),
price: ensureArray(payload.price, 'price'),
rules: ensureArray(payload.rules, 'rules'),
analysisDims: ensureArray(payload.analysisDims, 'analysisDims'),
categories: ensureArray(payload.categories, 'categories'),
channels: ensureArray(payload.channels, 'channels'),
startTime: ensureDateString(payload.startTime, 'startTime'),
endTime: ensureDateString(payload.endTime, 'endTime'),
periodType: ensureOptionalString(payload.periodType, 'periodType'),
userName: ensureOptionalString(payload.userName, 'userName'),
payload: ensureObject(payload.payload, 'payload'),
};
}
function toDatabaseRecord(normalizedReport) {
return {
report_id: normalizedReport.reportId,
source_type: normalizedReport.sourceType,
source_report_id: normalizedReport.sourceReportId,
aadvid: normalizedReport.aadvid,
name: normalizedReport.name,
price: normalizedReport.price,
rules: normalizedReport.rules,
analysis_dims: normalizedReport.analysisDims,
categories: normalizedReport.categories,
transaction_channels: normalizedReport.channels,
start_time: normalizedReport.startTime,
end_time: normalizedReport.endTime,
period_type: normalizedReport.periodType,
user_name: normalizedReport.userName,
payload: normalizedReport.payload,
};
}
module.exports = {
ValidationError,
validateAndNormalizeReportInput,
toDatabaseRecord,
};
@@ -0,0 +1,70 @@
const cors = require('cors');
const express = require('express');
const {
ValidationError,
toDatabaseRecord,
validateAndNormalizeReportInput,
} = require('./report-service');
function createApp({ repository, allowedOrigin }) {
const app = express();
app.use(
cors({
origin: allowedOrigin,
}),
);
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_request, response) => {
response.json({
success: true,
data: {
status: 'ok',
},
});
});
app.post('/api/reports', async (request, response) => {
try {
const normalizedReport = validateAndNormalizeReportInput(request.body);
const record = toDatabaseRecord(normalizedReport);
const saved = await repository.save(record);
response.status(saved.created ? 201 : 200).json({
success: true,
data: {
id: saved.id,
reportId: saved.reportId,
created: saved.created,
},
});
} catch (error) {
if (error instanceof ValidationError) {
response.status(400).json({
success: false,
error: {
code: error.code,
message: error.message,
},
});
return;
}
response.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: error.message || 'Unexpected server error',
},
});
}
});
return app;
}
module.exports = {
createApp,
};