@ats-cx/cx-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/apm/client.d.ts +59 -0
- package/dist/apm/client.js +247 -0
- package/dist/apm/config-state.d.ts +19 -0
- package/dist/apm/config-state.js +18 -0
- package/dist/apm/config.d.ts +93 -0
- package/dist/apm/config.js +72 -0
- package/dist/apm/errors.d.ts +39 -0
- package/dist/apm/errors.js +39 -0
- package/dist/apm/portal.d.ts +20 -0
- package/dist/apm/portal.js +89 -0
- package/dist/apm/values.d.ts +13 -0
- package/dist/apm/values.js +58 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +168 -0
- package/dist/db/client.d.ts +23 -0
- package/dist/db/client.js +21 -0
- package/dist/diff/project-diff.d.ts +125 -0
- package/dist/diff/project-diff.js +531 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +23 -0
- package/dist/normalize/log.d.ts +12 -0
- package/dist/normalize/log.js +143 -0
- package/dist/normalize/project.d.ts +18 -0
- package/dist/normalize/project.js +254 -0
- package/dist/providers/log-provider.d.ts +27 -0
- package/dist/providers/log-provider.js +63 -0
- package/dist/providers/project-provider.d.ts +10 -0
- package/dist/providers/project-provider.js +22 -0
- package/dist/providers/remote-project-provider.d.ts +28 -0
- package/dist/providers/remote-project-provider.js +167 -0
- package/dist/providers/source-provider.d.ts +11 -0
- package/dist/providers/source-provider.js +33 -0
- package/dist/rules/engine.d.ts +13 -0
- package/dist/rules/engine.js +213 -0
- package/dist/run/result-schema.d.ts +98 -0
- package/dist/run/result-schema.js +50 -0
- package/dist/run/store.d.ts +23 -0
- package/dist/run/store.js +49 -0
- package/dist/semantics/resolver.d.ts +37 -0
- package/dist/semantics/resolver.js +220 -0
- package/dist/semantics/table-loader.d.ts +11 -0
- package/dist/semantics/table-loader.js +44 -0
- package/dist/semantics/table-schema.d.ts +30 -0
- package/dist/semantics/table-schema.js +186 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +2 -0
- package/package.json +33 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* 统一把各种日志返回结构整理成一个标准格式。
|
|
4
|
+
*
|
|
5
|
+
* 当前兼容三种输入:
|
|
6
|
+
* - 直接是数组
|
|
7
|
+
* - `{ data: { result: [...] } }`
|
|
8
|
+
* - `{ result: [...] }`
|
|
9
|
+
*
|
|
10
|
+
* 这样无论是线上 APM 原始返回,还是离线导出的局部样本,都能直接喂进分析器。
|
|
11
|
+
*/
|
|
12
|
+
export function normalizeLogEnvelope(raw, sourcePath = "") {
|
|
13
|
+
const rawRecord = raw;
|
|
14
|
+
const dataResult = rawRecord?.data?.result;
|
|
15
|
+
const items = Array.isArray(raw)
|
|
16
|
+
? raw
|
|
17
|
+
: Array.isArray(dataResult)
|
|
18
|
+
? dataResult
|
|
19
|
+
: Array.isArray(rawRecord?.result)
|
|
20
|
+
? rawRecord.result
|
|
21
|
+
: [];
|
|
22
|
+
const events = items
|
|
23
|
+
.map((item, index) => normalizeEvent(item, index))
|
|
24
|
+
.sort((left, right) => {
|
|
25
|
+
if (left.sessionId !== right.sessionId) {
|
|
26
|
+
return left.sessionId.localeCompare(right.sessionId);
|
|
27
|
+
}
|
|
28
|
+
if (left.logTimestamp !== right.logTimestamp) {
|
|
29
|
+
return left.logTimestamp - right.logTimestamp;
|
|
30
|
+
}
|
|
31
|
+
return left.originalIndex - right.originalIndex;
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
generatedAt: new Date().toISOString(),
|
|
35
|
+
sourcePath: sourcePath ? resolve(sourcePath) : "",
|
|
36
|
+
eventCount: events.length,
|
|
37
|
+
sessions: groupEventsBySession(events),
|
|
38
|
+
events,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 规范化单条日志事件。
|
|
43
|
+
* 这里会做三件事:
|
|
44
|
+
* - 解析 data_extra / extra_data
|
|
45
|
+
* - 统一 eventName / sessionId / projectId 等字段
|
|
46
|
+
* - 从 page_url 中提取 route 和 query
|
|
47
|
+
*/
|
|
48
|
+
function normalizeEvent(item, originalIndex) {
|
|
49
|
+
const payload = parseJsonField(item.data_extra) || parseJsonField(item.extra_data) || {};
|
|
50
|
+
const routeInfo = parseRoute(String(item.page_url || item.pageUrl || ""));
|
|
51
|
+
return {
|
|
52
|
+
originalIndex,
|
|
53
|
+
eventName: String(item.data_name || payload.name || item.name || "unknown"),
|
|
54
|
+
logTime: String(item.log_time || item.logTime || ""),
|
|
55
|
+
logTimestamp: parseTimestamp(item.log_time || item.logTime),
|
|
56
|
+
receivedTime: String(item.received_time || item.receivedTime || ""),
|
|
57
|
+
receivedTimestamp: parseTimestamp(item.received_time || item.receivedTime),
|
|
58
|
+
sessionId: String(item.session_id || item.sessionId || "unknown-session"),
|
|
59
|
+
projectId: String(item.project_id || item.projectId || ""),
|
|
60
|
+
userId: String(item.user_id || item.userId || ""),
|
|
61
|
+
type: String(item.type || ""),
|
|
62
|
+
subType: String(item.sub_type || item.subType || ""),
|
|
63
|
+
pageUrl: String(item.page_url || item.pageUrl || ""),
|
|
64
|
+
route: routeInfo.route,
|
|
65
|
+
query: routeInfo.query,
|
|
66
|
+
payload,
|
|
67
|
+
device: {
|
|
68
|
+
osType: String(item.os_type || item.osType || ""),
|
|
69
|
+
deviceType: String(item.device_type || item.deviceType || ""),
|
|
70
|
+
host: String(item.host || ""),
|
|
71
|
+
agentVersion: String(item.agent_version || item.agentVersion || ""),
|
|
72
|
+
terminalId: String(item.terminal_id || item.terminalId || ""),
|
|
73
|
+
},
|
|
74
|
+
raw: item,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 安全解析 JSON 字符串,失败则返回 null。
|
|
79
|
+
*/
|
|
80
|
+
function parseJsonField(value) {
|
|
81
|
+
if (!value || typeof value !== "string") {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(value);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 把日志时间字符串转成时间戳,后续用于排序和计算间隔秒数。
|
|
93
|
+
*/
|
|
94
|
+
function parseTimestamp(value) {
|
|
95
|
+
const parsed = Date.parse(String(value ?? ""));
|
|
96
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 从 page_url 中拆出路由和查询参数。
|
|
100
|
+
* 例如:
|
|
101
|
+
* `/cxeditor/index.html?virtualProjectGuid=1#/photobook`
|
|
102
|
+
* 会变成:
|
|
103
|
+
* - route = /photobook
|
|
104
|
+
* - query.virtualProjectGuid = 1
|
|
105
|
+
*/
|
|
106
|
+
function parseRoute(pageUrl) {
|
|
107
|
+
if (!pageUrl) {
|
|
108
|
+
return { route: "", query: {} };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(pageUrl, "https://diagnostic.local");
|
|
112
|
+
return {
|
|
113
|
+
route: url.hash ? url.hash.slice(1) : url.pathname,
|
|
114
|
+
query: Object.fromEntries(url.searchParams.entries()),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { route: "", query: {} };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 按 session 聚合事件。
|
|
123
|
+
* 诊断系统最终不是分析单条日志,而是分析一次“用户操作会话”。
|
|
124
|
+
*/
|
|
125
|
+
function groupEventsBySession(events) {
|
|
126
|
+
const sessionMap = {};
|
|
127
|
+
for (const event of events) {
|
|
128
|
+
if (!sessionMap[event.sessionId]) {
|
|
129
|
+
sessionMap[event.sessionId] = [];
|
|
130
|
+
}
|
|
131
|
+
sessionMap[event.sessionId].push(event);
|
|
132
|
+
}
|
|
133
|
+
return Object.entries(sessionMap).map(([sessionId, sessionEvents]) => ({
|
|
134
|
+
sessionId,
|
|
135
|
+
projectId: sessionEvents[0]?.projectId || "",
|
|
136
|
+
userId: sessionEvents[0]?.userId || "",
|
|
137
|
+
startedAt: sessionEvents[0]?.logTime || "",
|
|
138
|
+
endedAt: sessionEvents[sessionEvents.length - 1]?.logTime || "",
|
|
139
|
+
eventCount: sessionEvents.length,
|
|
140
|
+
events: sessionEvents,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=log.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { NormalizedProject } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 把 project.json 规范化成适合诊断与检索的结构。
|
|
4
|
+
*
|
|
5
|
+
* 原始 project.json 更像编辑器运行态数据,不适合直接分析:
|
|
6
|
+
* - 页面分散在多个集合里,例如 pages / cover / frontFlysheet
|
|
7
|
+
* - 元素结构深、字段不统一
|
|
8
|
+
* - 日志里经常只给 elementId 或 sheetIndex
|
|
9
|
+
*
|
|
10
|
+
* 这个函数会把它压平为:
|
|
11
|
+
* - projectMeta
|
|
12
|
+
* - pages
|
|
13
|
+
* - elements
|
|
14
|
+
* - elementIndex
|
|
15
|
+
*
|
|
16
|
+
* 这样日志分析阶段就能快速把 event payload 反查到具体页面和元素。
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalizeProjectSnapshot(rawSnapshot: unknown, sourcePath?: string): NormalizedProject;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* 把 project.json 规范化成适合诊断与检索的结构。
|
|
4
|
+
*
|
|
5
|
+
* 原始 project.json 更像编辑器运行态数据,不适合直接分析:
|
|
6
|
+
* - 页面分散在多个集合里,例如 pages / cover / frontFlysheet
|
|
7
|
+
* - 元素结构深、字段不统一
|
|
8
|
+
* - 日志里经常只给 elementId 或 sheetIndex
|
|
9
|
+
*
|
|
10
|
+
* 这个函数会把它压平为:
|
|
11
|
+
* - projectMeta
|
|
12
|
+
* - pages
|
|
13
|
+
* - elements
|
|
14
|
+
* - elementIndex
|
|
15
|
+
*
|
|
16
|
+
* 这样日志分析阶段就能快速把 event payload 反查到具体页面和元素。
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeProjectSnapshot(rawSnapshot, sourcePath = "") {
|
|
19
|
+
const root = rawSnapshot.project ? rawSnapshot : { project: rawSnapshot };
|
|
20
|
+
const project = (root.project || {});
|
|
21
|
+
const images = normalizeImages(root.images || []);
|
|
22
|
+
const imageIndex = Object.fromEntries(images.flatMap((image) => buildImageKeys(image).map((key) => [key, image])));
|
|
23
|
+
const pageNodes = collectPageNodes(project);
|
|
24
|
+
const pages = [];
|
|
25
|
+
const elements = [];
|
|
26
|
+
const elementIndex = {};
|
|
27
|
+
const elementTypeCounts = {};
|
|
28
|
+
for (let pageOrder = 0; pageOrder < pageNodes.length; pageOrder += 1) {
|
|
29
|
+
const pageNode = pageNodes[pageOrder];
|
|
30
|
+
const page = normalizePage(pageNode, pageOrder);
|
|
31
|
+
pages.push(page);
|
|
32
|
+
for (let elementOrder = 0; elementOrder < pageNode.node.elements.length; elementOrder += 1) {
|
|
33
|
+
const rawElement = pageNode.node.elements[elementOrder];
|
|
34
|
+
const element = normalizeElement(rawElement, page, elementOrder, imageIndex);
|
|
35
|
+
elements.push(element);
|
|
36
|
+
elementIndex[element.lookupKey] = element;
|
|
37
|
+
if (element.id) {
|
|
38
|
+
elementIndex[element.id] = element;
|
|
39
|
+
}
|
|
40
|
+
if (element.oriElementId) {
|
|
41
|
+
elementIndex[element.oriElementId] = element;
|
|
42
|
+
}
|
|
43
|
+
elementTypeCounts[element.type] = (elementTypeCounts[element.type] || 0) + 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
generatedAt: new Date().toISOString(),
|
|
48
|
+
sourcePath: sourcePath ? resolve(sourcePath) : "",
|
|
49
|
+
projectMeta: buildProjectMeta(project),
|
|
50
|
+
imageCount: images.length,
|
|
51
|
+
pageCount: pages.length,
|
|
52
|
+
elementCount: elements.length,
|
|
53
|
+
pageCollectionCounts: countBy(pages, (page) => page.collectionName),
|
|
54
|
+
elementTypeCounts,
|
|
55
|
+
pages,
|
|
56
|
+
elements,
|
|
57
|
+
elementIndex,
|
|
58
|
+
images,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 组装项目元信息。
|
|
63
|
+
*
|
|
64
|
+
* 除了保留 guid/version 等稳定字段,也把 `project.spec` 保留到
|
|
65
|
+
* `projectMeta.spec`,这样报告里既能看到完整规格信息,也不会污染顶层元字段。
|
|
66
|
+
*/
|
|
67
|
+
function buildProjectMeta(project) {
|
|
68
|
+
return {
|
|
69
|
+
guid: project.guid || null,
|
|
70
|
+
version: project.version || null,
|
|
71
|
+
clientId: project.clientId || null,
|
|
72
|
+
createdDate: project.createdDate || null,
|
|
73
|
+
updatedDate: project.updatedDate || null,
|
|
74
|
+
spuVersion: project.spuVersion || null,
|
|
75
|
+
applyBookThemeId: project.applyBookThemeId || null,
|
|
76
|
+
isFlexProject: Boolean(project.isFlexProject),
|
|
77
|
+
spec: normalizeProjectSpec(project.spec),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 复制 project.spec,避免后续调用方意外持有原始引用。
|
|
82
|
+
*/
|
|
83
|
+
function normalizeProjectSpec(spec) {
|
|
84
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
return { ...spec };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 提取图片基础信息。
|
|
91
|
+
* 当前只保留诊断里最常用的字段,后续如果需要做图像尺寸异常或上传链路分析,再继续扩展。
|
|
92
|
+
*/
|
|
93
|
+
function normalizeImages(rawImages) {
|
|
94
|
+
return rawImages.map((image) => ({
|
|
95
|
+
guid: image.guid || null,
|
|
96
|
+
encImgId: image.encImgId || null,
|
|
97
|
+
name: image.name || null,
|
|
98
|
+
width: numberOrNull(image.width),
|
|
99
|
+
height: numberOrNull(image.height),
|
|
100
|
+
uploadTime: image.uploadTime || null,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 图片通常既可以通过 guid,也可以通过 encImgId 被元素引用,因此这里同时建立两套索引键。
|
|
105
|
+
*/
|
|
106
|
+
function buildImageKeys(image) {
|
|
107
|
+
return [image.guid, image.encImgId].filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 遍历整个 project 对象,找到所有含有 `elements` 数组的节点,把它们都视作“页面”。
|
|
111
|
+
*
|
|
112
|
+
* 这里故意不只盯着 `project.pages`:
|
|
113
|
+
* - cover
|
|
114
|
+
* - frontFlysheet
|
|
115
|
+
* - backFlysheet
|
|
116
|
+
* - 其他特殊 page-like 结构
|
|
117
|
+
*
|
|
118
|
+
* 都可能携带 elements。
|
|
119
|
+
*/
|
|
120
|
+
function collectPageNodes(project) {
|
|
121
|
+
const pages = [];
|
|
122
|
+
function walk(node, pathName) {
|
|
123
|
+
if (!node) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(node)) {
|
|
127
|
+
node.forEach((item, index) => walk(item, `${pathName}[${index}]`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (typeof node !== "object") {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const record = node;
|
|
134
|
+
if (Array.isArray(record.elements)) {
|
|
135
|
+
pages.push({ node: record, path: pathName });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const [key, value] of Object.entries(record)) {
|
|
139
|
+
walk(value, pathName ? `${pathName}.${key}` : key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
walk(project, "project");
|
|
143
|
+
return pages;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 规范化页面节点,补出 collectionName / collectionIndex 这种诊断场景很常用的信息。
|
|
147
|
+
* 例如 `project.pages[4]` 会拆成:
|
|
148
|
+
* - collectionName = pages
|
|
149
|
+
* - collectionIndex = 4
|
|
150
|
+
*/
|
|
151
|
+
function normalizePage(pageNode, pageOrder) {
|
|
152
|
+
const match = /\.([A-Za-z_][\w$]*)\[(\d+)\]$/.exec(pageNode.path);
|
|
153
|
+
return {
|
|
154
|
+
id: pageNode.node.id || null,
|
|
155
|
+
type: pageNode.node.type || null,
|
|
156
|
+
path: pageNode.path,
|
|
157
|
+
order: pageOrder,
|
|
158
|
+
collectionName: match ? match[1] : inferCollectionName(pageNode.path),
|
|
159
|
+
collectionIndex: match ? Number(match[2]) : pageOrder,
|
|
160
|
+
width: numberOrNull(pageNode.node.width),
|
|
161
|
+
height: numberOrNull(pageNode.node.height),
|
|
162
|
+
elementCount: Array.isArray(pageNode.node.elements) ? pageNode.node.elements.length : 0,
|
|
163
|
+
elementTypes: countBy(pageNode.node.elements || [], (element) => element.type || "Unknown"),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 在路径无法直接命中数组下标时,尽量给一个兜底的集合名。
|
|
168
|
+
*/
|
|
169
|
+
function inferCollectionName(pagePath) {
|
|
170
|
+
const parts = pagePath.split(".");
|
|
171
|
+
return parts.length >= 2 ? parts[parts.length - 1] : "unknown";
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 把元素压平为诊断友好的结构。
|
|
175
|
+
*
|
|
176
|
+
* 重点保留三类信息:
|
|
177
|
+
* - 定位信息:元素在哪一页、元素顺序、查找键
|
|
178
|
+
* - 几何信息:x/y/width/height/rot 等
|
|
179
|
+
* - 裁剪和图片引用:crop / imageRef / imageName
|
|
180
|
+
*
|
|
181
|
+
* 这些字段足以支撑:
|
|
182
|
+
* - 日志事件反查元素
|
|
183
|
+
* - 历史版本 diff
|
|
184
|
+
* - LLM 描述“哪个元素发生了什么变化”
|
|
185
|
+
*/
|
|
186
|
+
function normalizeElement(rawElement, page, elementOrder, imageIndex) {
|
|
187
|
+
const imageRef = rawElement.encImgId ||
|
|
188
|
+
rawElement.imageid ||
|
|
189
|
+
rawElement.imageId ||
|
|
190
|
+
rawElement.backgroundId ||
|
|
191
|
+
rawElement.decorationResourceId ||
|
|
192
|
+
null;
|
|
193
|
+
const image = imageRef ? imageIndex[imageRef] || null : null;
|
|
194
|
+
const lookupKey = rawElement.id ||
|
|
195
|
+
rawElement.oriElementId ||
|
|
196
|
+
`${page.path}.elements[${elementOrder}]`;
|
|
197
|
+
return {
|
|
198
|
+
lookupKey,
|
|
199
|
+
id: rawElement.id || null,
|
|
200
|
+
oriElementId: rawElement.oriElementId || null,
|
|
201
|
+
type: rawElement.type || "Unknown",
|
|
202
|
+
pageId: page.id,
|
|
203
|
+
pagePath: page.path,
|
|
204
|
+
pageOrder: page.order,
|
|
205
|
+
pageCollection: page.collectionName,
|
|
206
|
+
pageCollectionIndex: page.collectionIndex,
|
|
207
|
+
elementOrder,
|
|
208
|
+
imageRef,
|
|
209
|
+
imageName: image ? image.name : null,
|
|
210
|
+
geometry: pickDefined(rawElement, ["x", "y", "width", "height", "rot", "imgRot", "pw", "ph", "px", "py"]),
|
|
211
|
+
crop: pickDefined(rawElement, ["cropLUX", "cropLUY", "cropRLX", "cropRLY"]),
|
|
212
|
+
meta: pickDefined(rawElement, [
|
|
213
|
+
"border",
|
|
214
|
+
"dep",
|
|
215
|
+
"style",
|
|
216
|
+
"graphicObjType",
|
|
217
|
+
"designObjType",
|
|
218
|
+
"backgroundId",
|
|
219
|
+
"decorationResourceType",
|
|
220
|
+
"decorationResourceId",
|
|
221
|
+
"craftType",
|
|
222
|
+
]),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 从原始对象里只挑出“有值”的字段,避免规范化后的 JSON 充满 null。
|
|
227
|
+
*/
|
|
228
|
+
function pickDefined(source, keys) {
|
|
229
|
+
const result = {};
|
|
230
|
+
for (const key of keys) {
|
|
231
|
+
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
|
|
232
|
+
result[key] = source[key];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 通用计数器,用于页面类型、元素类型等基础统计。
|
|
239
|
+
*/
|
|
240
|
+
function countBy(items, iteratee) {
|
|
241
|
+
const counts = {};
|
|
242
|
+
for (const item of items) {
|
|
243
|
+
const key = iteratee(item) || "unknown";
|
|
244
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
245
|
+
}
|
|
246
|
+
return counts;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 只保留 number,否则返回 null,避免把字符串数字误认为可信几何字段。
|
|
250
|
+
*/
|
|
251
|
+
function numberOrNull(value) {
|
|
252
|
+
return typeof value === "number" ? value : null;
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=project.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ApmClientDeps, FlushRecord } from "../apm/client.js";
|
|
2
|
+
import type { ApmProviderState } from "../apm/config-state.js";
|
|
3
|
+
import type { LogEnvelope, ResolvedConfig } from "../types.js";
|
|
4
|
+
export interface LogProvenance {
|
|
5
|
+
source: "apm" | "file";
|
|
6
|
+
fetchedAt: string;
|
|
7
|
+
request?: {
|
|
8
|
+
url: string;
|
|
9
|
+
body: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
file?: string;
|
|
12
|
+
meta?: unknown;
|
|
13
|
+
total?: number;
|
|
14
|
+
fetched?: number;
|
|
15
|
+
pagesFetched?: number;
|
|
16
|
+
warnings: string[];
|
|
17
|
+
flush: FlushRecord;
|
|
18
|
+
}
|
|
19
|
+
export interface LogFetchResult {
|
|
20
|
+
normalized: LogEnvelope;
|
|
21
|
+
provenance: LogProvenance;
|
|
22
|
+
}
|
|
23
|
+
export interface LogProvider {
|
|
24
|
+
fetch(projectId: string): Promise<LogFetchResult>;
|
|
25
|
+
}
|
|
26
|
+
export declare function createLogProvider(logSource: ResolvedConfig["logSource"], apmProvider: ResolvedConfig["apmProvider"], deps?: ApmClientDeps, apmProviderState?: ApmProviderState | null): LogProvider;
|
|
27
|
+
export declare function createFileLogProvider(path: string): LogProvider;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { createApmClient } from "../apm/client.js";
|
|
4
|
+
import { createApmConfigError, isApmConfigured } from "../apm/config-state.js";
|
|
5
|
+
import { ApmRequestError } from "../apm/errors.js";
|
|
6
|
+
import { normalizeLogEnvelope } from "../normalize/log.js";
|
|
7
|
+
export function createLogProvider(logSource, apmProvider, deps = {}, apmProviderState = null) {
|
|
8
|
+
if (logSource.type === "file") {
|
|
9
|
+
return createFileLogProvider(logSource.path);
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
async fetch(projectId) {
|
|
13
|
+
if (!apmProvider) {
|
|
14
|
+
if (!isApmConfigured(apmProviderState)) {
|
|
15
|
+
throw createApmConfigError(apmProviderState);
|
|
16
|
+
}
|
|
17
|
+
throw new ApmRequestError("invalid_config", { message: "apm 配置状态与 provider 不一致" });
|
|
18
|
+
}
|
|
19
|
+
const result = await createApmClient(apmProvider, deps).query({
|
|
20
|
+
type: apmProvider.runPreset.type,
|
|
21
|
+
project: projectId,
|
|
22
|
+
from: apmProvider.runPreset.log_time_start || undefined,
|
|
23
|
+
app: apmProvider.defaults.app_id,
|
|
24
|
+
bizline: apmProvider.defaults.bizline_id,
|
|
25
|
+
order: "ASC",
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
normalized: normalizeLogEnvelope({ result: result.rows }),
|
|
29
|
+
provenance: {
|
|
30
|
+
source: "apm",
|
|
31
|
+
fetchedAt: new Date().toISOString(),
|
|
32
|
+
request: result.request,
|
|
33
|
+
total: result.total,
|
|
34
|
+
fetched: result.rows.length,
|
|
35
|
+
pagesFetched: result.pagesFetched,
|
|
36
|
+
warnings: result.warnings,
|
|
37
|
+
flush: result.flush,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function createFileLogProvider(path) {
|
|
44
|
+
const file = resolve(path);
|
|
45
|
+
return {
|
|
46
|
+
async fetch() {
|
|
47
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
48
|
+
const hasMeta = typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.prototype.hasOwnProperty.call(raw, "meta");
|
|
49
|
+
return {
|
|
50
|
+
normalized: normalizeLogEnvelope(raw, file),
|
|
51
|
+
provenance: {
|
|
52
|
+
source: "file",
|
|
53
|
+
fetchedAt: new Date().toISOString(),
|
|
54
|
+
file,
|
|
55
|
+
...(hasMeta ? { meta: raw.meta } : {}),
|
|
56
|
+
warnings: [],
|
|
57
|
+
flush: { attempted: false },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=log-provider.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ProjectSnapshotVersion {
|
|
2
|
+
version: string;
|
|
3
|
+
filePath: string;
|
|
4
|
+
data: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface ProjectProvider {
|
|
7
|
+
loadHistory(projectId: string): Promise<ProjectSnapshotVersion[]>;
|
|
8
|
+
}
|
|
9
|
+
/** 本地目录实现:`<projectHistoryDir>/<projectId>/*.json`,按文件名排序即版本序。 */
|
|
10
|
+
export declare function createProjectProvider(projectHistoryDir: string): ProjectProvider;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** 本地目录实现:`<projectHistoryDir>/<projectId>/*.json`,按文件名排序即版本序。 */
|
|
4
|
+
export function createProjectProvider(projectHistoryDir) {
|
|
5
|
+
return {
|
|
6
|
+
async loadHistory(projectId) {
|
|
7
|
+
const dir = join(projectHistoryDir, projectId);
|
|
8
|
+
if (!existsSync(dir)) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
return readdirSync(dir)
|
|
12
|
+
.filter(name => name.endsWith(".json"))
|
|
13
|
+
.sort()
|
|
14
|
+
.map(name => ({
|
|
15
|
+
version: name.replace(/\.json$/, ""),
|
|
16
|
+
filePath: join(dir, name),
|
|
17
|
+
data: JSON.parse(readFileSync(join(dir, name), "utf8")),
|
|
18
|
+
}));
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=project-provider.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DbClient } from "../db/client.js";
|
|
2
|
+
export interface ProjectPullEntry {
|
|
3
|
+
projectId: string;
|
|
4
|
+
/** 本次实际落盘的文件数,恒等于 `files.length`(`files` 内无重复路径)。 */
|
|
5
|
+
snapshotCount: number;
|
|
6
|
+
files: string[];
|
|
7
|
+
/** 单条记录落盘失败的原因(数据坏、CREATE_TIME 无效等),其余记录照常写入。 */
|
|
8
|
+
failures: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface RemoteProjectPullResult {
|
|
11
|
+
projects: ProjectPullEntry[];
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface RemoteProjectProvider {
|
|
15
|
+
/** 每个 id 拉最新一条(最新一秒里 UIDPK 最大、即最后写入的那条)。 */
|
|
16
|
+
pullLatest(projectIds: string[]): Promise<RemoteProjectPullResult>;
|
|
17
|
+
/** 每个 id 拉全量历史,按 CREATE_TIME 升序。 */
|
|
18
|
+
pullHistory(projectIds: string[]): Promise<RemoteProjectPullResult>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 远端快照拉取:查 DB → 转 JSON → 落盘 `<projectHistoryDir>/<projectId>/<projectId>_<时间>.json`
|
|
22
|
+
* (同秒的第 2 条起带 `_02` 序号后缀,见 planSnapshots)。
|
|
23
|
+
*
|
|
24
|
+
* 逐 projectId 循环执行单项目 SQL(不用 CTE + ROW_NUMBER):不依赖 MySQL 8,错误定位到单 id;
|
|
25
|
+
* pull 的 id 数量极小,N 次往返可忽略。
|
|
26
|
+
* 连接 / SQL 错误一律向上抛(由调用方转 exit 1),只有「查到 0 条」与「单条记录坏数据」降级为警告 / 失败记录。
|
|
27
|
+
*/
|
|
28
|
+
export declare function createRemoteProjectProvider(client: DbClient, projectHistoryDir: string): RemoteProjectProvider;
|