feat: improve yuntu report sync flow
This commit is contained in:
parent
c8b5d27078
commit
bcc5afa291
@ -1,55 +1,11 @@
|
||||
DROP TABLE IF EXISTS public.yuntu_report_info;
|
||||
DROP TABLE IF EXISTS public.segmented_market_reports;
|
||||
|
||||
CREATE TABLE public.yuntu_report_info (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
report_id VARCHAR(64) NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL,
|
||||
source_report_id VARCHAR(64) NULL,
|
||||
aadvid VARCHAR(32) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
price JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
start_time DATE NOT NULL,
|
||||
end_time DATE NOT NULL,
|
||||
period_type VARCHAR(32) NULL,
|
||||
user_name TEXT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id),
|
||||
CONSTRAINT chk_yuntu_report_info_source_type
|
||||
CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')),
|
||||
CONSTRAINT chk_yuntu_report_info_copy_source
|
||||
CHECK (
|
||||
(source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL)
|
||||
OR
|
||||
(source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL)
|
||||
)
|
||||
CREATE TABLE public.segmented_market_reports (
|
||||
report_id TEXT NOT NULL,
|
||||
report_info JSONB NOT NULL,
|
||||
completion_notified_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
details_synced_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT segmented_market_reports_pkey PRIMARY KEY (report_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yuntu_report_info_source_report_id
|
||||
ON public.yuntu_report_info (source_report_id);
|
||||
|
||||
CREATE INDEX idx_yuntu_report_info_created_at
|
||||
ON public.yuntu_report_info (created_at DESC);
|
||||
|
||||
CREATE INDEX idx_yuntu_report_info_aadvid_created_at
|
||||
ON public.yuntu_report_info (aadvid, created_at DESC);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info;
|
||||
|
||||
CREATE TRIGGER trg_yuntu_report_info_updated_at
|
||||
BEFORE UPDATE ON public.yuntu_report_info
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_yuntu_report_info_updated_at();
|
||||
|
||||
@ -1,33 +1,18 @@
|
||||
const INSERT_REPORT_SQL = `
|
||||
INSERT INTO public.yuntu_report_info (
|
||||
INSERT INTO public.segmented_market_reports (
|
||||
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
|
||||
report_info
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
|
||||
$11, $12, $13, $14, $15::jsonb
|
||||
$1, $2::jsonb
|
||||
)
|
||||
ON CONFLICT (report_id) DO NOTHING
|
||||
RETURNING id, report_id
|
||||
RETURNING report_id
|
||||
`;
|
||||
|
||||
const FIND_REPORT_SQL = `
|
||||
SELECT id, report_id
|
||||
FROM public.yuntu_report_info
|
||||
SELECT report_id
|
||||
FROM public.segmented_market_reports
|
||||
WHERE report_id = $1
|
||||
`;
|
||||
|
||||
@ -36,20 +21,7 @@ function createRepository(pool) {
|
||||
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),
|
||||
JSON.stringify(record.report_info),
|
||||
];
|
||||
|
||||
const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues);
|
||||
@ -57,7 +29,6 @@ function createRepository(pool) {
|
||||
if (insertResult.rows.length > 0) {
|
||||
const row = insertResult.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
reportId: row.report_id,
|
||||
created: true,
|
||||
};
|
||||
@ -71,7 +42,6 @@ function createRepository(pool) {
|
||||
}
|
||||
|
||||
return {
|
||||
id: existingRow.id,
|
||||
reportId: existingRow.report_id,
|
||||
created: false,
|
||||
};
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
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);
|
||||
@ -9,30 +6,6 @@ class ValidationError extends 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`);
|
||||
@ -41,75 +14,60 @@ function ensureObject(value, fieldName) {
|
||||
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`);
|
||||
function normalizeReportId(value) {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractReportId(reportInfo) {
|
||||
const directReportId =
|
||||
normalizeReportId(reportInfo.report_id) ||
|
||||
normalizeReportId(reportInfo.reportId);
|
||||
if (directReportId) {
|
||||
return directReportId;
|
||||
}
|
||||
|
||||
if (!reportInfo.data || typeof reportInfo.data !== 'object' || Array.isArray(reportInfo.data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizeReportId(reportInfo.data.report_id) ||
|
||||
normalizeReportId(reportInfo.data.reportId)
|
||||
);
|
||||
}
|
||||
|
||||
function validateAndNormalizeReportInput(input) {
|
||||
const payload = ensureObject(input, 'body');
|
||||
const sourceType = ensureNonEmptyString(payload.sourceType, 'sourceType');
|
||||
const reportInfo = ensureObject(input, 'body');
|
||||
const reportId = extractReportId(reportInfo);
|
||||
|
||||
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');
|
||||
if (!reportId) {
|
||||
throw new ValidationError('report_id is required in response info');
|
||||
}
|
||||
|
||||
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'),
|
||||
reportId,
|
||||
reportInfo,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
report_info: normalizedReport.reportInfo,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ValidationError,
|
||||
extractReportId,
|
||||
validateAndNormalizeReportInput,
|
||||
toDatabaseRecord,
|
||||
};
|
||||
|
||||
@ -35,7 +35,6 @@ function createApp({ repository, allowedOrigin }) {
|
||||
response.status(saved.created ? 201 : 200).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: saved.id,
|
||||
reportId: saved.reportId,
|
||||
created: saved.created,
|
||||
},
|
||||
|
||||
@ -6,90 +6,71 @@ const {
|
||||
toDatabaseRecord,
|
||||
} = require('../src/report-service');
|
||||
|
||||
function createManualPayload() {
|
||||
function createResponseInfo() {
|
||||
return {
|
||||
reportId: 'report-001',
|
||||
sourceType: 'MANUAL_CAPTURE',
|
||||
sourceReportId: null,
|
||||
aadvid: '1648829117232140',
|
||||
name: '测试报告',
|
||||
price: ['1,100', '101,100000'],
|
||||
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
|
||||
analysisDims: ['MARKETOVERVIEW'],
|
||||
categories: [{ id: '20028', name: '奶粉类目' }],
|
||||
channels: ['ALL'],
|
||||
startTime: '2025-03-01',
|
||||
endTime: '2026-02-28',
|
||||
periodType: 'MONTH',
|
||||
userName: 'tester@example.com',
|
||||
payload: {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
reportId: 'report-001',
|
||||
status: 'SUCCESS',
|
||||
name: '测试报告',
|
||||
startTime: '2025-03-01',
|
||||
endTime: '2026-02-28',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('validateAndNormalizeReportInput accepts a valid manual report payload', () => {
|
||||
const input = createManualPayload();
|
||||
test('validateAndNormalizeReportInput accepts response info with data.reportId', () => {
|
||||
const input = createResponseInfo();
|
||||
|
||||
const result = validateAndNormalizeReportInput(input);
|
||||
|
||||
assert.equal(result.reportId, 'report-001');
|
||||
assert.equal(result.sourceType, 'MANUAL_CAPTURE');
|
||||
assert.equal(result.sourceReportId, null);
|
||||
assert.equal(result.aadvid, '1648829117232140');
|
||||
assert.equal(result.startTime, '2025-03-01');
|
||||
assert.equal(result.endTime, '2026-02-28');
|
||||
assert.deepEqual(result.reportInfo, input);
|
||||
});
|
||||
|
||||
test('validateAndNormalizeReportInput rejects AUTO_COPY without sourceReportId', () => {
|
||||
test('validateAndNormalizeReportInput accepts response info with data.report_id', () => {
|
||||
const input = {
|
||||
...createManualPayload(),
|
||||
reportId: 'report-002',
|
||||
sourceType: 'AUTO_COPY',
|
||||
sourceReportId: '',
|
||||
code: 0,
|
||||
data: {
|
||||
report_id: 'report-002',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateAndNormalizeReportInput(input);
|
||||
|
||||
assert.equal(result.reportId, 'report-002');
|
||||
assert.deepEqual(result.reportInfo, input);
|
||||
});
|
||||
|
||||
test('validateAndNormalizeReportInput rejects response info without a report id', () => {
|
||||
assert.throws(
|
||||
() => validateAndNormalizeReportInput(input),
|
||||
() =>
|
||||
validateAndNormalizeReportInput({
|
||||
code: 0,
|
||||
data: {},
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'VALIDATION_ERROR');
|
||||
assert.match(error.message, /sourceReportId/i);
|
||||
assert.match(error.message, /report[_ ]?id/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('toDatabaseRecord maps API payload fields into database-ready values', () => {
|
||||
const normalized = validateAndNormalizeReportInput({
|
||||
...createManualPayload(),
|
||||
reportId: 'report-003',
|
||||
sourceType: 'AUTO_COPY',
|
||||
sourceReportId: 'report-001',
|
||||
});
|
||||
test('toDatabaseRecord maps response info into segmented_market_reports columns', () => {
|
||||
const normalized = validateAndNormalizeReportInput(createResponseInfo());
|
||||
|
||||
const record = toDatabaseRecord(normalized);
|
||||
|
||||
assert.deepEqual(record, {
|
||||
report_id: 'report-003',
|
||||
source_type: 'AUTO_COPY',
|
||||
source_report_id: 'report-001',
|
||||
aadvid: '1648829117232140',
|
||||
name: '测试报告',
|
||||
price: ['1,100', '101,100000'],
|
||||
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
|
||||
analysis_dims: ['MARKETOVERVIEW'],
|
||||
categories: [{ id: '20028', name: '奶粉类目' }],
|
||||
transaction_channels: ['ALL'],
|
||||
start_time: '2025-03-01',
|
||||
end_time: '2026-02-28',
|
||||
period_type: 'MONTH',
|
||||
user_name: 'tester@example.com',
|
||||
payload: {
|
||||
name: '测试报告',
|
||||
startTime: '2025-03-01',
|
||||
endTime: '2026-02-28',
|
||||
report_id: 'report-001',
|
||||
report_info: {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
reportId: 'report-001',
|
||||
status: 'SUCCESS',
|
||||
name: '测试报告',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,26 +3,13 @@ const assert = require('node:assert/strict');
|
||||
|
||||
const { createApp } = require('../src/server');
|
||||
|
||||
function createManualPayload() {
|
||||
function createResponseInfo() {
|
||||
return {
|
||||
reportId: 'report-001',
|
||||
sourceType: 'MANUAL_CAPTURE',
|
||||
sourceReportId: null,
|
||||
aadvid: '1648829117232140',
|
||||
name: '测试报告',
|
||||
price: ['1,100', '101,100000'],
|
||||
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
|
||||
analysisDims: ['MARKETOVERVIEW'],
|
||||
categories: [{ id: '20028', name: '奶粉类目' }],
|
||||
channels: ['ALL'],
|
||||
startTime: '2025-03-01',
|
||||
endTime: '2026-02-28',
|
||||
periodType: 'MONTH',
|
||||
userName: 'tester@example.com',
|
||||
payload: {
|
||||
name: '测试报告',
|
||||
startTime: '2025-03-01',
|
||||
endTime: '2026-02-28',
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
reportId: 'report-001',
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -78,7 +65,7 @@ test('GET /health returns ok status', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('POST /api/reports returns 400 for invalid auto copy payload', async () => {
|
||||
test('POST /api/reports returns 400 when response info does not contain a report id', async () => {
|
||||
await withServer(
|
||||
{
|
||||
async save() {
|
||||
@ -92,9 +79,8 @@ test('POST /api/reports returns 400 for invalid auto copy payload', async () =>
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...createManualPayload(),
|
||||
sourceType: 'AUTO_COPY',
|
||||
sourceReportId: '',
|
||||
code: 0,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
const body = await response.json();
|
||||
@ -106,7 +92,7 @@ test('POST /api/reports returns 400 for invalid auto copy payload', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('POST /api/reports persists a valid payload and returns created response', async () => {
|
||||
test('POST /api/reports persists raw response info and returns created response', async () => {
|
||||
let savedRecord = null;
|
||||
|
||||
await withServer(
|
||||
@ -114,19 +100,19 @@ test('POST /api/reports persists a valid payload and returns created response',
|
||||
async save(record) {
|
||||
savedRecord = record;
|
||||
return {
|
||||
id: 12,
|
||||
reportId: record.report_id,
|
||||
created: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const responseInfo = createResponseInfo();
|
||||
const response = await fetch(`${baseUrl}/api/reports`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(createManualPayload()),
|
||||
body: JSON.stringify(responseInfo),
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
@ -134,13 +120,14 @@ test('POST /api/reports persists a valid payload and returns created response',
|
||||
assert.deepEqual(body, {
|
||||
success: true,
|
||||
data: {
|
||||
id: 12,
|
||||
reportId: 'report-001',
|
||||
created: true,
|
||||
},
|
||||
});
|
||||
assert.equal(savedRecord.report_id, 'report-001');
|
||||
assert.equal(savedRecord.source_type, 'MANUAL_CAPTURE');
|
||||
assert.deepEqual(savedRecord, {
|
||||
report_id: 'report-001',
|
||||
report_info: responseInfo,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -36,6 +36,13 @@ test("isSupportedPageUrl covers both creation and detail pages", () => {
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
api.isSupportedPageUrl(
|
||||
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList?aadvid=1648829117232140",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
api.isSupportedPageUrl(
|
||||
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/otherPage?aadvid=1710507483282439",
|
||||
@ -44,6 +51,105 @@ test("isSupportedPageUrl covers both creation and detail pages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("getSupportedPageType identifies list, creation, and detail pages", () => {
|
||||
assert.equal(
|
||||
api.getSupportedPageType(
|
||||
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList?aadvid=1648829117232140",
|
||||
),
|
||||
"list",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
api.getSupportedPageType(
|
||||
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140",
|
||||
),
|
||||
"creation",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
api.getSupportedPageType(
|
||||
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/marketOverview?aadvid=1710507483282439&reportId=441518",
|
||||
),
|
||||
"detail",
|
||||
);
|
||||
});
|
||||
|
||||
test("getButtonUiState disables the button on the list page and enables it on action pages with payload", () => {
|
||||
assert.deepEqual(
|
||||
api.getButtonUiState({
|
||||
pageType: "list",
|
||||
hasPayload: true,
|
||||
isSubmitting: false,
|
||||
}),
|
||||
{
|
||||
shouldRender: true,
|
||||
disabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
api.getButtonUiState({
|
||||
pageType: "detail",
|
||||
hasPayload: true,
|
||||
isSubmitting: false,
|
||||
}),
|
||||
{
|
||||
shouldRender: true,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
api.getButtonUiState({
|
||||
pageType: null,
|
||||
hasPayload: true,
|
||||
isSubmitting: false,
|
||||
}),
|
||||
{
|
||||
shouldRender: false,
|
||||
disabled: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("createRouteChangeWatcher triggers a sync callback after pushState", () => {
|
||||
const events = [];
|
||||
const listeners = new Map();
|
||||
const root = {
|
||||
history: {
|
||||
pushState() {
|
||||
events.push("pushState");
|
||||
},
|
||||
replaceState() {
|
||||
events.push("replaceState");
|
||||
},
|
||||
},
|
||||
addEventListener(name, handler) {
|
||||
listeners.set(name, handler);
|
||||
},
|
||||
removeEventListener(name) {
|
||||
listeners.delete(name);
|
||||
},
|
||||
setTimeout(callback) {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout() {},
|
||||
};
|
||||
|
||||
const dispose = api.createRouteChangeWatcher(root, () => {
|
||||
events.push("sync");
|
||||
});
|
||||
|
||||
root.history.pushState({}, "", "/next");
|
||||
assert.deepEqual(events, ["pushState", "sync"]);
|
||||
|
||||
dispose();
|
||||
events.length = 0;
|
||||
root.history.replaceState({}, "", "/again");
|
||||
assert.deepEqual(events, ["replaceState"]);
|
||||
});
|
||||
|
||||
test("getLocalApiBaseCandidates prefers localhost and falls back to 127.0.0.1", () => {
|
||||
assert.deepEqual(api.getLocalApiBaseCandidates(), [
|
||||
"http://localhost:3000",
|
||||
@ -93,42 +199,19 @@ test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appe
|
||||
assert.notEqual(copied.nested, original.nested);
|
||||
});
|
||||
|
||||
test("buildPersistRequest maps manual capture payload into backend request format", () => {
|
||||
const payload = {
|
||||
name: "测试",
|
||||
price: ["1,100", "101,100000"],
|
||||
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }],
|
||||
analysisDims: ["MARKETOVERVIEW"],
|
||||
categories: [{ id: "20028" }],
|
||||
channels: ["ALL"],
|
||||
startTime: "2025-03-01",
|
||||
endTime: "2026-02-28",
|
||||
periodType: "MONTH",
|
||||
userName: "tester@example.com",
|
||||
test("buildPersistRequest keeps the response info unchanged for backend parsing", () => {
|
||||
const responseInfo = {
|
||||
code: 0,
|
||||
message: "success",
|
||||
data: {
|
||||
reportId: "report-001",
|
||||
status: "SUCCESS",
|
||||
},
|
||||
};
|
||||
const request = api.buildPersistRequest({
|
||||
payload,
|
||||
aadvid: "1648829117232140",
|
||||
reportId: "report-001",
|
||||
sourceType: "MANUAL_CAPTURE",
|
||||
sourceReportId: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(request, {
|
||||
reportId: "report-001",
|
||||
sourceType: "MANUAL_CAPTURE",
|
||||
sourceReportId: null,
|
||||
aadvid: "1648829117232140",
|
||||
name: "测试",
|
||||
price: ["1,100", "101,100000"],
|
||||
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }],
|
||||
analysisDims: ["MARKETOVERVIEW"],
|
||||
categories: [{ id: "20028" }],
|
||||
channels: ["ALL"],
|
||||
startTime: "2025-03-01",
|
||||
endTime: "2026-02-28",
|
||||
periodType: "MONTH",
|
||||
userName: "tester@example.com",
|
||||
payload,
|
||||
});
|
||||
const request = api.buildPersistRequest(responseInfo);
|
||||
|
||||
assert.deepEqual(request, responseInfo);
|
||||
assert.notEqual(request, responseInfo);
|
||||
assert.notEqual(request.data, responseInfo.data);
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
// @version 0.1.0
|
||||
// @description 记录最近一次成功创建的云图报告,并一键复制同比报告
|
||||
// @author wangxi
|
||||
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList*
|
||||
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation*
|
||||
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/*
|
||||
// @grant GM_getValue
|
||||
@ -24,10 +25,28 @@
|
||||
api.init();
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) {
|
||||
const TARGET_PATH = "/product_node/v2/api/segmentedMarket/createSegmentedMarket";
|
||||
const PAGE_TYPES = {
|
||||
list: "list",
|
||||
creation: "creation",
|
||||
detail: "detail",
|
||||
};
|
||||
// 后续如果后端部署到服务器,只需要修改这里的基础地址。
|
||||
// 例如改成:https://your-server.example.com
|
||||
// 脚本会在这些基础地址后自动拼接 /api/reports。
|
||||
const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"];
|
||||
const SUPPORTED_PAGE_PATTERNS = [
|
||||
/^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/,
|
||||
/^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/,
|
||||
{
|
||||
type: PAGE_TYPES.list,
|
||||
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketList(?:\/.*)?$/,
|
||||
},
|
||||
{
|
||||
type: PAGE_TYPES.creation,
|
||||
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/,
|
||||
},
|
||||
{
|
||||
type: PAGE_TYPES.detail,
|
||||
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/,
|
||||
},
|
||||
];
|
||||
const STORAGE_KEYS = {
|
||||
payload: "lastReportPayload",
|
||||
@ -42,6 +61,8 @@
|
||||
isSubmitting: false,
|
||||
suppressedRequestBody: null,
|
||||
clearSuppressionTimer: null,
|
||||
captureListenerAttached: false,
|
||||
routeWatcherCleanup: null,
|
||||
};
|
||||
|
||||
function log(message, extra) {
|
||||
@ -65,7 +86,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedPageUrl(url) {
|
||||
function getSupportedPageType(url) {
|
||||
try {
|
||||
const parsed = new URL(
|
||||
url,
|
||||
@ -73,14 +94,20 @@
|
||||
? root.location.href
|
||||
: "https://yuntu.oceanengine.com",
|
||||
);
|
||||
return SUPPORTED_PAGE_PATTERNS.some((pattern) =>
|
||||
|
||||
const matched = SUPPORTED_PAGE_PATTERNS.find(({ pattern }) =>
|
||||
pattern.test(parsed.pathname),
|
||||
);
|
||||
return matched ? matched.type : null;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedPageUrl(url) {
|
||||
return getSupportedPageType(url) !== null;
|
||||
}
|
||||
|
||||
function getLocalApiBaseCandidates() {
|
||||
return [...LOCAL_API_BASES];
|
||||
}
|
||||
@ -135,30 +162,8 @@
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function buildPersistRequest({
|
||||
payload,
|
||||
aadvid,
|
||||
reportId,
|
||||
sourceType,
|
||||
sourceReportId,
|
||||
}) {
|
||||
return {
|
||||
reportId: String(reportId),
|
||||
sourceType,
|
||||
sourceReportId: sourceReportId ? String(sourceReportId) : null,
|
||||
aadvid: String(aadvid),
|
||||
name: payload.name || "",
|
||||
price: Array.isArray(payload.price) ? payload.price : [],
|
||||
rules: Array.isArray(payload.rules) ? payload.rules : [],
|
||||
analysisDims: Array.isArray(payload.analysisDims) ? payload.analysisDims : [],
|
||||
categories: Array.isArray(payload.categories) ? payload.categories : [],
|
||||
channels: Array.isArray(payload.channels) ? payload.channels : [],
|
||||
startTime: payload.startTime || "",
|
||||
endTime: payload.endTime || "",
|
||||
periodType: payload.periodType || null,
|
||||
userName: payload.userName || null,
|
||||
payload,
|
||||
};
|
||||
function buildPersistRequest(responseJson) {
|
||||
return deepCloneJson(responseJson);
|
||||
}
|
||||
|
||||
function parseJson(text) {
|
||||
@ -266,12 +271,47 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const pageType = getSupportedPageType(root.location && root.location.href ? root.location.href : "");
|
||||
const hasPayload = Boolean(readStoredPayload());
|
||||
button.disabled = runtimeState.isSubmitting || !hasPayload;
|
||||
const buttonUiState = getButtonUiState({
|
||||
pageType,
|
||||
hasPayload,
|
||||
isSubmitting: runtimeState.isSubmitting,
|
||||
});
|
||||
button.disabled = buttonUiState.disabled;
|
||||
button.textContent = runtimeState.isSubmitting
|
||||
? "创建中..."
|
||||
: "一键复制同比报告";
|
||||
button.dataset.enabled = String(hasPayload);
|
||||
button.dataset.enabled = String(!buttonUiState.disabled);
|
||||
button.dataset.pageType = String(pageType || "");
|
||||
}
|
||||
|
||||
function getButtonUiState({ pageType, hasPayload, isSubmitting }) {
|
||||
if (!pageType) {
|
||||
return {
|
||||
shouldRender: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (pageType === PAGE_TYPES.list) {
|
||||
return {
|
||||
shouldRender: true,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldRender: true,
|
||||
disabled: Boolean(isSubmitting || !hasPayload),
|
||||
};
|
||||
}
|
||||
|
||||
function removeButton() {
|
||||
const button = root.document.getElementById(BUTTON_ID);
|
||||
if (button) {
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function persistToLocalServer(payload) {
|
||||
@ -384,7 +424,6 @@
|
||||
try {
|
||||
const nextPayload = buildAutoCopyPayload(payload);
|
||||
const requestUrl = getCreateRequestUrl(meta);
|
||||
const sourceReportId = meta.reportId || null;
|
||||
const { responseJson } = await createReportThroughPage(requestUrl, nextPayload);
|
||||
const reportId = extractReportId(responseJson);
|
||||
|
||||
@ -401,15 +440,7 @@
|
||||
};
|
||||
|
||||
writeStoredData(nextPayload, nextMeta);
|
||||
await persistToLocalServer(
|
||||
buildPersistRequest({
|
||||
payload: nextPayload,
|
||||
aadvid: nextMeta.aadvid,
|
||||
reportId,
|
||||
sourceType: "AUTO_COPY",
|
||||
sourceReportId,
|
||||
}),
|
||||
);
|
||||
await persistToLocalServer(buildPersistRequest(responseJson));
|
||||
|
||||
showToast(`同比报告创建成功:${reportId}`, "success");
|
||||
} catch (error) {
|
||||
@ -455,15 +486,7 @@
|
||||
writeStoredData(payload, meta);
|
||||
updateButtonState();
|
||||
|
||||
await persistToLocalServer(
|
||||
buildPersistRequest({
|
||||
payload,
|
||||
aadvid,
|
||||
reportId,
|
||||
sourceType: "MANUAL_CAPTURE",
|
||||
sourceReportId: null,
|
||||
}),
|
||||
);
|
||||
await persistToLocalServer(buildPersistRequest(responseJson));
|
||||
|
||||
showToast(`已记录报告配置:${reportId}`, "success");
|
||||
} catch (error) {
|
||||
@ -571,6 +594,73 @@
|
||||
updateButtonState();
|
||||
}
|
||||
|
||||
function syncUiForCurrentPage() {
|
||||
if (!root.document || !root.document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageType = getSupportedPageType(root.location && root.location.href ? root.location.href : "");
|
||||
const hasPayload = Boolean(readStoredPayload());
|
||||
const buttonUiState = getButtonUiState({
|
||||
pageType,
|
||||
hasPayload,
|
||||
isSubmitting: runtimeState.isSubmitting,
|
||||
});
|
||||
|
||||
if (!buttonUiState.shouldRender) {
|
||||
removeButton();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureStyles();
|
||||
ensureButton();
|
||||
updateButtonState();
|
||||
}
|
||||
|
||||
function createRouteChangeWatcher(targetRoot, onRouteChange) {
|
||||
if (!targetRoot || !targetRoot.history || typeof onRouteChange !== "function") {
|
||||
return function noop() {};
|
||||
}
|
||||
|
||||
let timerId = null;
|
||||
const schedule = () => {
|
||||
if (timerId) {
|
||||
targetRoot.clearTimeout(timerId);
|
||||
}
|
||||
timerId = targetRoot.setTimeout(() => {
|
||||
timerId = null;
|
||||
onRouteChange();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const originalPushState = targetRoot.history.pushState;
|
||||
const originalReplaceState = targetRoot.history.replaceState;
|
||||
|
||||
targetRoot.history.pushState = function patchedPushState() {
|
||||
const result = originalPushState.apply(this, arguments);
|
||||
schedule();
|
||||
return result;
|
||||
};
|
||||
|
||||
targetRoot.history.replaceState = function patchedReplaceState() {
|
||||
const result = originalReplaceState.apply(this, arguments);
|
||||
schedule();
|
||||
return result;
|
||||
};
|
||||
|
||||
targetRoot.addEventListener("popstate", schedule);
|
||||
|
||||
return function dispose() {
|
||||
if (timerId) {
|
||||
targetRoot.clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
targetRoot.history.pushState = originalPushState;
|
||||
targetRoot.history.replaceState = originalReplaceState;
|
||||
targetRoot.removeEventListener("popstate", schedule);
|
||||
};
|
||||
}
|
||||
|
||||
function injectCaptureHook() {
|
||||
if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") {
|
||||
return;
|
||||
@ -680,34 +770,47 @@
|
||||
}
|
||||
|
||||
function attachCaptureListener() {
|
||||
if (runtimeState.captureListenerAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.addEventListener(EVENT_NAME, (event) => {
|
||||
handleCapturedRequest(event.detail);
|
||||
});
|
||||
runtimeState.captureListenerAttached = true;
|
||||
}
|
||||
|
||||
function ensureRouteWatcher() {
|
||||
if (runtimeState.routeWatcherCleanup) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeState.routeWatcherCleanup = createRouteChangeWatcher(root, () => {
|
||||
syncUiForCurrentPage();
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!isSupportedPageUrl(root.location && root.location.href ? root.location.href : "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.document || !root.document.body) {
|
||||
root.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
ensureStyles();
|
||||
ensureButton();
|
||||
ensureRouteWatcher();
|
||||
attachCaptureListener();
|
||||
injectCaptureHook();
|
||||
updateButtonState();
|
||||
syncUiForCurrentPage();
|
||||
}
|
||||
|
||||
return {
|
||||
buildAutoCopyPayload,
|
||||
buildAutoCopyName,
|
||||
buildPersistRequest,
|
||||
createRouteChangeWatcher,
|
||||
extractReportId,
|
||||
getButtonUiState,
|
||||
getLocalApiBaseCandidates,
|
||||
getSupportedPageType,
|
||||
init,
|
||||
isSupportedPageUrl,
|
||||
isTargetCreateReportRequest,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user