zero-ai 1.0.74 → 1.0.75

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.
@@ -0,0 +1,812 @@
1
+ "use strict";
2
+
3
+ const Ec = require("../epic");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const Ut = require("../commander-shared");
7
+ const yaml = require("js-yaml");
8
+ const inquirer = require("inquirer");
9
+ const { v4: uuidv4 } = require("uuid");
10
+
11
+ const CONFIG_PATH = ".r2mo/task/command/ex-api.yaml";
12
+ const REQUIRED_ENV_DB = ["Z_DB_TYPE", "Z_DB_HOST", "Z_DB_PORT", "Z_DBS_INSTANCE", "Z_DB_APP_USER", "Z_DB_APP_PASS"];
13
+ const REQUIRED_ENV_APP = ["Z_APP_ID", "Z_TENANT", "Z_SIGMA"];
14
+ const R2_BY_UUID = "9a0d5018-33ad-4c64-80bf-8ae7947c482f";
15
+
16
+ /** 全局列(开发时按 RBAC Flyway 建表固定,执行时仅 DML,不查元数据):S_RESOURCE/S_ACTION/S_PERMISSION 写入,Excel 不写入 */
17
+ const GLOBAL_COLUMNS = [
18
+ { name: "SIGMA", value: () => process.env.Z_SIGMA || "" },
19
+ { name: "APP_ID", value: () => process.env.Z_APP_ID || "" },
20
+ { name: "TENANT_ID", value: () => process.env.Z_TENANT || "" },
21
+ { name: "CREATED_BY", value: () => R2_BY_UUID },
22
+ { name: "UPDATED_BY", value: () => R2_BY_UUID },
23
+ { name: "CREATED_AT", value: () => new Date().toISOString().slice(0, 19).replace("T", " ") },
24
+ { name: "UPDATED_AT", value: () => new Date().toISOString().slice(0, 19).replace("T", " ") }
25
+ ];
26
+
27
+ function getGlobalColsAndVals() {
28
+ const cols = GLOBAL_COLUMNS.map((c) => c.name);
29
+ const vals = GLOBAL_COLUMNS.map((c) => c.value());
30
+ return { cols, vals };
31
+ }
32
+
33
+ /**
34
+ * 扫描 sheet 中所有 {TABLE} 区域:某行首格为 "{TABLE}" 则开启一个表,下一格为表名;紧跟 2 行为表头(中文、英文),之后为数据区;下一处 {TABLE} 或 sheet 末为数据区结束。
35
+ * 返回 [{ tableName, tableStartRow, dataStartRow, dataEndRow, columnNames: [name], columnIndex: { name: colNum } }, ...]
36
+ */
37
+ function scanTableRegions(ws, maxScanRows) {
38
+ if (!ws) return [];
39
+ const regions = [];
40
+ const limit = maxScanRows || 5000;
41
+ let i = 1;
42
+ while (i <= limit) {
43
+ const row = ws.getRow(i);
44
+ const first = row.getCell(1).value;
45
+ const v = first != null ? String(first).trim() : "";
46
+ if (v === "{TABLE}") {
47
+ const tableNameCell = row.getCell(2).value;
48
+ const tableName = tableNameCell != null ? String(tableNameCell).trim() : "";
49
+ const headerRowCount = 2;
50
+ const dataStartRow = i + 1 + headerRowCount;
51
+ let dataEndRow = dataStartRow - 1;
52
+ let j = i + 1;
53
+ while (j <= limit) {
54
+ const nextRow = ws.getRow(j);
55
+ const nextFirst = nextRow.getCell(1).value;
56
+ const nv = nextFirst != null ? String(nextFirst).trim() : "";
57
+ if (nv === "{TABLE}") {
58
+ dataEndRow = j - 1;
59
+ break;
60
+ }
61
+ dataEndRow = j;
62
+ j++;
63
+ }
64
+ const enHeaderRow = ws.getRow(i + 2);
65
+ const columnNames = [];
66
+ const columnIndex = {};
67
+ enHeaderRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
68
+ const val = cell && cell.value != null ? String(cell.value).trim() : "";
69
+ if (val) {
70
+ columnNames.push(val);
71
+ columnIndex[val] = colNumber;
72
+ }
73
+ });
74
+ regions.push({
75
+ tableName,
76
+ tableStartRow: i,
77
+ dataStartRow,
78
+ dataEndRow,
79
+ columnNames,
80
+ columnIndex
81
+ });
82
+ i = dataEndRow + 1;
83
+ continue;
84
+ }
85
+ i++;
86
+ }
87
+ return regions;
88
+ }
89
+
90
+ /** 在指定 TABLE 区域内找最后一行有数据的行号(按首列非空判断),返回下一行用于追加;若无则返回 dataStartRow */
91
+ function findAppendRowInRegion(ws, dataStartRow, dataEndRow) {
92
+ let last = dataStartRow - 1;
93
+ for (let r = dataStartRow; r <= dataEndRow; r++) {
94
+ const cell = ws.getRow(r).getCell(1).value;
95
+ if (cell != null && String(cell).trim() !== "") last = r;
96
+ }
97
+ return last + 1;
98
+ }
99
+
100
+ /** 将 uri 转为文件名安全片段:/ -> -,去首尾 -,长度限制 */
101
+ function uriToFileNameSlug(uri) {
102
+ if (!uri || !String(uri).trim()) return "default";
103
+ return String(uri)
104
+ .trim()
105
+ .replace(/\//g, "-")
106
+ .replace(/-+/g, "-")
107
+ .replace(/^-|-$/g, "")
108
+ .slice(0, 120);
109
+ }
110
+
111
+ /** 无 target 时解析到 -api 项目目录(当前为父目录时取 xxx-api,已在 -api 内则用 cwd) */
112
+ function resolveExcelRoot(cwd, target) {
113
+ if (target && target.root && target.module) {
114
+ const zeroModule = process.env.ZERO_MODULE;
115
+ return path.resolve(zeroModule, `zero-exmodule-${target.module}`);
116
+ }
117
+ const artifactId = getArtifactIdFromPom(cwd);
118
+ const apiDir = artifactId ? path.resolve(cwd, artifactId + "-api") : null;
119
+ if (apiDir && fs.existsSync(apiDir)) return apiDir;
120
+ return cwd;
121
+ }
122
+
123
+ function getArtifactIdFromPom(cwd) {
124
+ const pomPath = path.resolve(cwd, "pom.xml");
125
+ if (!fs.existsSync(pomPath)) return null;
126
+ let content = fs.readFileSync(pomPath, "utf-8");
127
+ content = content.replace(/<parent>[\s\S]*?<\/parent>/i, "");
128
+ const m = content.match(/<artifactId>([^<]+)<\/artifactId>/);
129
+ return m ? m[1].trim() : null;
130
+ }
131
+
132
+ function loadAppEnv(filePath) {
133
+ if (!fs.existsSync(filePath)) return false;
134
+ const content = fs.readFileSync(filePath, "utf-8");
135
+ content.split(/\r?\n/).forEach((line) => {
136
+ const trimmed = line.trim();
137
+ if (trimmed.startsWith("#") || !trimmed.startsWith("export ")) return;
138
+ const match = trimmed.match(/^export\s+([A-Za-z0-9_]+)=["']?([^"'\n]*)["']?/);
139
+ if (match) process.env[match[1]] = match[2].trim();
140
+ });
141
+ return true;
142
+ }
143
+
144
+ function resolveAppEnvPath(cwd) {
145
+ const primary = path.resolve(cwd, ".r2mo", "app.env");
146
+ if (fs.existsSync(primary)) return primary;
147
+ let artifactId = getArtifactIdFromPom(cwd);
148
+ if (!artifactId) artifactId = path.basename(cwd);
149
+ if (artifactId && artifactId !== ".") {
150
+ const apiDir = `${artifactId}-api`;
151
+ const nested = path.resolve(cwd, apiDir, ".r2mo", "app.env");
152
+ if (fs.existsSync(nested)) return nested;
153
+ const sibling = path.resolve(cwd, "..", apiDir, ".r2mo", "app.env");
154
+ if (fs.existsSync(sibling)) return sibling;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function checkEnv(keys, label) {
160
+ const missing = keys.filter((k) => !process.env[k] || !String(process.env[k]).trim());
161
+ if (missing.length > 0) {
162
+ Ec.error(`${label}:以下环境变量必须全部已设置。`);
163
+ Ec.info("当前缺失:" + missing.join(", "));
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ function isDpaRoot(dir) {
169
+ const pom = path.join(dir, "pom.xml");
170
+ if (!fs.existsSync(pom)) return false;
171
+ const id = getArtifactIdFromPom(dir);
172
+ if (!id) return false;
173
+ const apiDir = path.join(dir, `${id}-api`);
174
+ const domainDir = path.join(dir, `${id}-domain`);
175
+ return fs.existsSync(apiDir) && fs.existsSync(domainDir);
176
+ }
177
+
178
+ module.exports = async (options) => {
179
+ const cwd = process.cwd();
180
+
181
+ // 1. 前置:配置文件必须存在;缺失时在配置路径中写入模板
182
+ const configFullPath = path.resolve(cwd, CONFIG_PATH);
183
+ if (!fs.existsSync(configFullPath)) {
184
+ const configDir = path.dirname(configFullPath);
185
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
186
+ const template = `# ai ex-api 使用此配置,请按项目修改
187
+ # 参数格式:-r "<METHOD> <uri>" uri 必须以 /api 为前缀
188
+ metadata:
189
+ identifier: "核心标识符"
190
+ brief: "接口描述"
191
+ resource: "resource.ambient"
192
+ level: 1
193
+ ptype: "权限集 S_PERM_SET 类型"
194
+ pname: "权限集 S_PERM_SET 名称"
195
+ # keyword 可选;若存在则编码为 res.\${keyword} / act.\${keyword} / perm.\${keyword},否则按规则计算
196
+ # keyword: "app.test.data"
197
+ # target 可选;存在时需配置 ZERO_MODULE 且 DPA 目录 zero-exmodule-{module} 存在
198
+ # target:
199
+ # root: "ZERO_MODULE"
200
+ # module: "ambient"
201
+ `;
202
+ fs.writeFileSync(configFullPath, template, "utf-8");
203
+ Ec.info("配置文件缺失,已在下列路径写入模板:" + configFullPath);
204
+ Ec.info("请编辑后重新执行。参数格式: -r \"<METHOD> <uri>\" (uri 必须以 /api 为前缀)");
205
+ Ec.info("示例: ai ex-api -r \"GET /api/ambient\"");
206
+ process.exit(1);
207
+ }
208
+
209
+ // 2. 环境变量:先加载 app.env 再检查
210
+ const appEnvPath = resolveAppEnvPath(cwd);
211
+ if (!appEnvPath) {
212
+ Ec.error(".r2mo/app.env 不存在;DPA 下也未找到 {id}-api/.r2mo/app.env");
213
+ process.exit(1);
214
+ }
215
+ loadAppEnv(appEnvPath);
216
+ Ec.info("已加载环境变量:" + appEnvPath);
217
+ checkEnv(REQUIRED_ENV_DB, "数据库环境变量");
218
+ checkEnv(REQUIRED_ENV_APP, "应用环境变量(Z_APP_ID / Z_TENANT / Z_SIGMA)");
219
+
220
+ // 3. 解析 ex-api.yaml(不校验格式)
221
+ let config;
222
+ try {
223
+ config = yaml.load(fs.readFileSync(configFullPath, "utf-8"));
224
+ } catch (e) {
225
+ Ec.error("ex-api.yaml 解析失败:" + e.message);
226
+ process.exit(1);
227
+ }
228
+ if (!config || !config.metadata) {
229
+ Ec.error("ex-api.yaml 需包含 metadata 节点");
230
+ process.exit(1);
231
+ }
232
+
233
+ const metadata = config.metadata;
234
+ const target = config.target;
235
+ if (target && target.root && target.module) {
236
+ const zeroModule = process.env.ZERO_MODULE;
237
+ if (!zeroModule || !zeroModule.trim()) {
238
+ Ec.error("存在 target 配置时,环境变量 ZERO_MODULE 必须已设置");
239
+ process.exit(1);
240
+ }
241
+ const dpaRoot = path.resolve(zeroModule, `zero-exmodule-${target.module}`);
242
+ if (!fs.existsSync(dpaRoot) || !isDpaRoot(dpaRoot)) {
243
+ Ec.error(`ZERO_MODULE 下 DPA 目录不是标准架构:${dpaRoot}`);
244
+ Ec.info("需存在 pom.xml 且包含 xxx-api、xxx-domain 子目录");
245
+ process.exit(1);
246
+ }
247
+ }
248
+
249
+ // 4. 参数:-r "<METHOD> <uri>" ,-s 可选
250
+ const parsed = Ut.parseArgument(options);
251
+ const skip = parsed.skip === true || process.argv.includes("-s") || process.argv.includes("--skip");
252
+ let method = "";
253
+ let uri = "";
254
+ const requestRaw = parsed.request;
255
+ if (requestRaw && String(requestRaw).trim()) {
256
+ const parts = String(requestRaw).trim().split(/\s+/);
257
+ if (parts.length >= 2) {
258
+ method = parts[0].toUpperCase();
259
+ uri = parts.slice(1).join(" ").trim();
260
+ } else if (parts.length === 1) {
261
+ Ec.error("参数 -r 格式应为 \"<METHOD> <uri>\",例如 \"GET /api/ambient\"");
262
+ process.exit(1);
263
+ }
264
+ }
265
+ if (!skip && (!method || !uri)) {
266
+ Ec.error("缺少必需参数:请求方法与 URI");
267
+ Ec.info("参数格式: -r \"<METHOD> <uri>\" 或 --request \"<METHOD> <uri>\"");
268
+ Ec.info("示例: ai ex-api -r \"GET /api/ambient\"(uri 必须以 /api 为前缀)");
269
+ process.exit(1);
270
+ }
271
+ if (!skip && uri && !uri.trim().startsWith("/api")) {
272
+ Ec.error("参数 uri 必须以 /api 为前缀。");
273
+ Ec.info("当前 uri:" + (uri || ""));
274
+ Ec.info("示例: ai ex-api -r \"GET /api/ambient\"");
275
+ process.exit(1);
276
+ }
277
+
278
+ Ec.execute("ai ex-api:配置已加载,环境与参数检查通过。");
279
+
280
+ const dbConfig = {
281
+ host: process.env.Z_DB_HOST || "localhost",
282
+ port: parseInt(process.env.Z_DB_PORT || "3306", 10),
283
+ user: process.env.Z_DB_APP_USER,
284
+ password: process.env.Z_DB_APP_PASS,
285
+ database: process.env.Z_DBS_INSTANCE
286
+ };
287
+
288
+ const mysql = require("mysql2/promise");
289
+ Ec.info("[ex-api] 配置:" + CONFIG_PATH + " | request=" + (requestRaw || "") + " | skip=" + skip);
290
+ let conn = await mysql.createConnection(dbConfig);
291
+ Ec.info("[ex-api] 数据库已连接:" + (dbConfig.database || "") + " @" + (dbConfig.host || "") + ":" + (dbConfig.port || ""));
292
+
293
+ // 约定:执行时数据表已存在且结构固定,仅执行 DML(SELECT/INSERT/INSERT IGNORE),禁止表扫描、DDL、元数据查询(如 SHOW COLUMNS)。
294
+ try {
295
+ const appId = process.env.Z_APP_ID;
296
+ const tenantId = process.env.Z_TENANT;
297
+ const sigma = process.env.Z_SIGMA;
298
+
299
+ let actionId = null;
300
+ let resourceId = null;
301
+ let insertedResource = null;
302
+ let insertedAction = null;
303
+ let insertedPermission = null;
304
+ const insertedRolePerms = [];
305
+
306
+ if (!skip && method && uri) {
307
+ const level = metadata.level != null ? metadata.level : 1;
308
+ const brief = metadata.brief || "";
309
+ const identifier = metadata.identifier || "default";
310
+ const keyword = metadata.keyword && String(metadata.keyword).trim();
311
+ const resourceCode = keyword
312
+ ? "res." + keyword
313
+ : ("res_" + (metadata.resource || "api").replace(/\./g, "_") + "_" + method + "_" + uri.replace(/\//g, "_").replace(/^\s+|\s+$/g, "").slice(0, 48) || "res_api");
314
+ const actionCode = keyword ? "act." + keyword : "act_" + (metadata.resource || "api").replace(/\./g, "_");
315
+
316
+ // 实体表按 CODE 去重:先按 METHOD+URI 查,再按 CODE+SIGMA 查,存在则不插入
317
+ let [rows] = await conn.execute(
318
+ "SELECT ID, RESOURCE_ID FROM S_ACTION WHERE METHOD = ? AND URI = ? LIMIT 2",
319
+ [method, uri]
320
+ );
321
+ if (rows && rows.length > 1) {
322
+ [rows] = await conn.execute(
323
+ "SELECT ID, RESOURCE_ID FROM S_ACTION WHERE METHOD = ? AND URI = ? AND SIGMA = ? AND APP_ID = ? AND TENANT_ID = ? LIMIT 1",
324
+ [method, uri, sigma, appId, tenantId]
325
+ );
326
+ }
327
+ if (rows && rows.length > 0) {
328
+ actionId = rows[0].ID;
329
+ resourceId = rows[0].RESOURCE_ID;
330
+ Ec.info("[ex-api] 已存在 S_ACTION(METHOD+URI),ID=" + actionId);
331
+ } else {
332
+ const [actByCode] = await conn.execute("SELECT ID, RESOURCE_ID FROM S_ACTION WHERE CODE = ? AND SIGMA = ? LIMIT 1", [actionCode, sigma]);
333
+ if (actByCode && actByCode.length > 0) {
334
+ actionId = actByCode[0].ID;
335
+ resourceId = actByCode[0].RESOURCE_ID;
336
+ Ec.info("[ex-api] 已存在 S_ACTION(CODE 去重),ID=" + actionId);
337
+ } else {
338
+ const [resRows] = await conn.execute("SELECT ID FROM S_RESOURCE WHERE CODE = ? AND SIGMA = ? LIMIT 1", [resourceCode, sigma]);
339
+ if (resRows && resRows[0]) {
340
+ resourceId = resRows[0].ID;
341
+ Ec.info("[ex-api] 已存在 S_RESOURCE(CODE 去重),ID=" + resourceId);
342
+ } else {
343
+ resourceId = uuidv4();
344
+ const resBaseCols = ["ID", "NAME", "CODE", "IDENTIFIER", "TYPE", "LEVEL", "MODE_ROLE"];
345
+ const resBaseVals = [resourceId, brief, resourceCode, identifier, metadata.resource || "resource.ambient", level, "UNION"];
346
+ const resGlobal = getGlobalColsAndVals();
347
+ const resCols = resBaseCols.concat(resGlobal.cols);
348
+ const resVals = resBaseVals.concat(resGlobal.vals);
349
+ const resPlaceholders = resCols.map(() => "?").join(", ");
350
+ await conn.execute(
351
+ "INSERT INTO S_RESOURCE (" + resCols.join(", ") + ") VALUES (" + resPlaceholders + ")",
352
+ resVals
353
+ );
354
+ insertedResource = { CODE: resourceCode, NAME: brief, IDENTIFIER: identifier, TYPE: metadata.resource || "resource.ambient", LEVEL: level, MODE_ROLE: "UNION" };
355
+ Ec.info("[ex-api] 已插入 S_RESOURCE,ID=" + resourceId);
356
+ }
357
+
358
+ actionId = uuidv4();
359
+ const actBaseCols = ["ID", "CODE", "NAME", "RESOURCE_ID", "METHOD", "URI", "LEVEL"];
360
+ const actBaseVals = [actionId, actionCode, brief, resourceId, method, uri, level];
361
+ const actGlobal = getGlobalColsAndVals();
362
+ const actCols = actBaseCols.concat(actGlobal.cols);
363
+ const actVals = actBaseVals.concat(actGlobal.vals);
364
+ const actPlaceholders = actCols.map(() => "?").join(", ");
365
+ await conn.execute(
366
+ "INSERT INTO S_ACTION (" + actCols.join(", ") + ") VALUES (" + actPlaceholders + ")",
367
+ actVals
368
+ );
369
+ insertedAction = { CODE: actionCode, NAME: brief, RESOURCE_ID: resourceId, METHOD: method, URI: uri, LEVEL: level };
370
+ Ec.info("[ex-api] 已插入 S_ACTION,ID=" + actionId);
371
+ }
372
+ }
373
+ }
374
+
375
+ let permissionId = null;
376
+ const permIdentifier = metadata.identifier || "default";
377
+ let permissionMode = "new";
378
+
379
+ if (!skip) {
380
+ const ans = await inquirer.prompt([
381
+ {
382
+ type: "list",
383
+ name: "permissionMode",
384
+ message: "追加新权限 / 使用已有权限?",
385
+ choices: [
386
+ { name: "追加新权限", value: "new" },
387
+ { name: "使用已有权限(按 identifier 选择)", value: "existing" }
388
+ ]
389
+ }
390
+ ]);
391
+ permissionMode = ans.permissionMode;
392
+
393
+ if (permissionMode === "existing") {
394
+ const [permRows] = await conn.execute("SELECT ID, CODE, NAME FROM S_PERMISSION WHERE IDENTIFIER = ?", [permIdentifier]);
395
+ if (!permRows || permRows.length === 0) {
396
+ Ec.info("当前 identifier 下无已有权限,将按新权限创建");
397
+ permissionMode = "new";
398
+ } else {
399
+ const { selectedPerm } = await inquirer.prompt([
400
+ {
401
+ type: "list",
402
+ name: "selectedPerm",
403
+ message: "选择要追加到的权限",
404
+ choices: permRows.map((r) => ({ name: `${r.NAME} (${r.CODE})`, value: r.ID }))
405
+ }
406
+ ]);
407
+ permissionId = selectedPerm;
408
+ }
409
+ }
410
+ if (permissionMode === "new" || !permissionId) {
411
+ const brief = metadata.brief || "";
412
+ const keyword = metadata.keyword && String(metadata.keyword).trim();
413
+ const permCode = keyword ? "perm." + keyword : ("perm_" + (metadata.resource || "api").replace(/\./g, "_").slice(0, 64));
414
+ const [ex] = await conn.execute("SELECT ID FROM S_PERMISSION WHERE CODE = ? AND SIGMA = ? LIMIT 1", [permCode, sigma]);
415
+ if (ex && ex[0]) {
416
+ permissionId = ex[0].ID;
417
+ Ec.info("[ex-api] 已存在 S_PERMISSION(CODE 去重),ID=" + permissionId);
418
+ } else {
419
+ permissionId = uuidv4();
420
+ const permBaseCols = ["ID", "CODE", "NAME", "IDENTIFIER", "COMMENT"];
421
+ const permBaseVals = [permissionId, permCode, brief, permIdentifier, brief];
422
+ const permGlobal = getGlobalColsAndVals();
423
+ const permCols = permBaseCols.concat(permGlobal.cols);
424
+ const permVals = permBaseVals.concat(permGlobal.vals);
425
+ const permPlaceholders = permCols.map(() => "?").join(", ");
426
+ await conn.execute(
427
+ "INSERT INTO S_PERMISSION (" + permCols.join(", ") + ") VALUES (" + permPlaceholders + ")",
428
+ permVals
429
+ );
430
+ insertedPermission = { CODE: permCode, NAME: brief, IDENTIFIER: permIdentifier, COMMENT: brief };
431
+ Ec.info("[ex-api] 已插入 S_PERMISSION,ID=" + permissionId);
432
+ }
433
+ }
434
+ }
435
+
436
+ let roleIds = [];
437
+ if (!skip) {
438
+ const [roleRows] = await conn.execute("SELECT ID, NAME, CODE FROM S_ROLE ORDER BY NAME");
439
+ if (!roleRows || roleRows.length === 0) {
440
+ Ec.info("[ex-api] S_ROLE 中无角色,跳过授权");
441
+ } else {
442
+ const answer = await inquirer.prompt([
443
+ {
444
+ type: "checkbox",
445
+ name: "selectedRoles",
446
+ message: "选择要授权当前 API 的角色(可多选)",
447
+ choices: roleRows.map((r) => ({ name: `${r.NAME || r.CODE} (${r.ID})`, value: String(r.ID != null ? r.ID : r.id) }))
448
+ }
449
+ ]);
450
+ const raw = answer.selectedRoles;
451
+ if (Array.isArray(raw)) {
452
+ roleIds = raw.map((id) => (id != null ? String(id) : id));
453
+ } else if (raw != null && raw !== "") {
454
+ roleIds = [String(raw)];
455
+ } else {
456
+ roleIds = [];
457
+ }
458
+ Ec.info("[ex-api] 已选角色数:" + roleIds.length + (roleIds.length > 0 ? ",ID=" + roleIds.slice(0, 5).join(",") + (roleIds.length > 5 ? "..." : "") : ""));
459
+ }
460
+ }
461
+
462
+ if (!skip && permissionId && roleIds.length > 0) {
463
+ for (const roleId of roleIds) {
464
+ await conn.execute(
465
+ "INSERT IGNORE INTO R_ROLE_PERM (ROLE_ID, PERM_ID) VALUES (?, ?)",
466
+ [roleId, permissionId]
467
+ );
468
+ insertedRolePerms.push({ ROLE_ID: roleId, PERM_ID: permissionId });
469
+ }
470
+ Ec.info("[ex-api] 已同步 R_ROLE_PERM,角色数:" + roleIds.length);
471
+ }
472
+
473
+ // 汇总与 Excel 写入前:查询已有记录,供 Excel 填充(本次未插入时也写出当前 resource/role-perm)
474
+ let existingResource = null;
475
+ let existingAction = null;
476
+ let existingPermission = null;
477
+ if (resourceId && !insertedResource) {
478
+ const [rows] = await conn.execute("SELECT ID, CODE, NAME, IDENTIFIER, TYPE, LEVEL, MODE_ROLE FROM S_RESOURCE WHERE ID = ? LIMIT 1", [resourceId]);
479
+ if (rows && rows[0]) existingResource = rows[0];
480
+ }
481
+ if (actionId && !insertedAction) {
482
+ const [rows] = await conn.execute("SELECT ID, CODE, NAME, RESOURCE_ID, METHOD, URI, LEVEL FROM S_ACTION WHERE ID = ? LIMIT 1", [actionId]);
483
+ if (rows && rows[0]) existingAction = rows[0];
484
+ }
485
+ if (permissionId && !insertedPermission) {
486
+ const [rows] = await conn.execute("SELECT ID, CODE, NAME, IDENTIFIER, COMMENT FROM S_PERMISSION WHERE ID = ? LIMIT 1", [permissionId]);
487
+ if (rows && rows[0]) existingPermission = rows[0];
488
+ }
489
+ let existingRolePerms = [];
490
+ if (permissionId && insertedRolePerms.length === 0 && (!roleIds || roleIds.length === 0)) {
491
+ try {
492
+ const [rpRows] = await conn.execute("SELECT ROLE_ID, PERM_ID FROM R_ROLE_PERM WHERE PERM_ID = ?", [permissionId]);
493
+ if (rpRows && rpRows.length > 0) {
494
+ Ec.info("[ex-api] R_ROLE_PERM 从库中提取 " + rpRows.length + " 条(PERM_ID=" + permissionId + ")");
495
+ existingRolePerms = rpRows.map((r) => ({
496
+ ROLE_ID: r.ROLE_ID != null ? r.ROLE_ID : r.role_id,
497
+ PERM_ID: r.PERM_ID != null ? r.PERM_ID : r.perm_id
498
+ }));
499
+ }
500
+ } catch (e) {
501
+ Ec.info("[ex-api] R_ROLE_PERM 查询失败: " + e.message);
502
+ }
503
+ }
504
+ // 四张表行数据(Excel 列名与模板英文表头一致);S_PERM_SET 的 name/type 来自配置 pname/ptype
505
+ const resRow = resourceId && (insertedResource || existingResource) ? (insertedResource || existingResource) : null;
506
+ const actRow = actionId && (insertedAction || existingAction) ? (insertedAction || existingAction) : null;
507
+ const permRow = permissionId && (insertedPermission || existingPermission) ? (insertedPermission || existingPermission) : null;
508
+ const rowS_RESOURCE = resRow
509
+ ? {
510
+ key: resourceId,
511
+ name: resRow.NAME,
512
+ modeRole: resRow.MODE_ROLE || "UNION",
513
+ code: resRow.CODE,
514
+ identifier: resRow.IDENTIFIER,
515
+ type: resRow.TYPE,
516
+ level: resRow.LEVEL,
517
+ modeGroup: "",
518
+ modeTree: ""
519
+ }
520
+ : null;
521
+ const rowS_ACTION = actRow && resourceId && permissionId
522
+ ? {
523
+ key: actionId,
524
+ resourceId,
525
+ permissionId,
526
+ code: actRow.CODE,
527
+ method: actRow.METHOD,
528
+ uri: actRow.URI,
529
+ name: actRow.NAME,
530
+ level: actRow.LEVEL,
531
+ renewalCredit: ""
532
+ }
533
+ : null;
534
+ const rowS_PERMISSION = permRow
535
+ ? {
536
+ key: permissionId,
537
+ name: permRow.NAME,
538
+ comment: permRow.COMMENT,
539
+ code: permRow.CODE,
540
+ identifier: permRow.IDENTIFIER
541
+ }
542
+ : null;
543
+ // S_PERM_SET:name/type 必须从配置 pname/ptype 提取,key/code 与资源一致
544
+ const rowS_PERM_SET = resourceId && resRow
545
+ ? {
546
+ key: resourceId,
547
+ code: resRow.CODE,
548
+ name: (metadata.pname != null && metadata.pname !== "") ? metadata.pname : resRow.NAME,
549
+ type: (metadata.ptype != null && metadata.ptype !== "") ? metadata.ptype : resRow.TYPE
550
+ }
551
+ : null;
552
+ // 写入 Excel 的数据(仅用于写 Excel,不写库):有 permissionId + 本次选中的 roleIds 则用其组合;否则用本次插入的;否则用库中已有的
553
+ let rolePermsToWrite =
554
+ permissionId && roleIds && roleIds.length > 0
555
+ ? roleIds.map((rid) => ({ ROLE_ID: rid, PERM_ID: permissionId }))
556
+ : insertedRolePerms.length > 0
557
+ ? insertedRolePerms
558
+ : existingRolePerms.length > 0
559
+ ? existingRolePerms
560
+ : [];
561
+ rolePermsToWrite = rolePermsToWrite.map((p) => ({
562
+ ROLE_ID: p.ROLE_ID != null ? p.ROLE_ID : p.role_id,
563
+ PERM_ID: p.PERM_ID != null ? p.PERM_ID : p.perm_id
564
+ }));
565
+ // 有 permissionId 但仍无一条可写时,从 S_ROLE 取一个角色,保证 Excel 至少有一行(仅写 Excel,不写库);优先超级管理员
566
+ if (permissionId && rolePermsToWrite.length === 0) {
567
+ try {
568
+ let [oneRole] = await conn.execute(
569
+ "SELECT ID FROM S_ROLE WHERE NAME = ? OR CODE = ? OR CODE = ? LIMIT 1",
570
+ ["超级管理员", "ADMIN.SUPER", "ADMIN_SUPER"]
571
+ );
572
+ if (!oneRole || !oneRole[0]) {
573
+ [oneRole] = await conn.execute("SELECT ID FROM S_ROLE ORDER BY NAME LIMIT 1", []);
574
+ }
575
+ if (oneRole && oneRole[0]) {
576
+ const rid = oneRole[0].ID != null ? String(oneRole[0].ID) : String(oneRole[0].id);
577
+ rolePermsToWrite = [{ ROLE_ID: rid, PERM_ID: permissionId }];
578
+ Ec.info("[ex-api] R_ROLE_PERM 无选中/库中记录,已用 S_ROLE 补一条写 Excel(ROLE_ID=" + rid + ")");
579
+ }
580
+ } catch (e) {
581
+ Ec.info("[ex-api] S_ROLE 取角色失败: " + e.message);
582
+ }
583
+ }
584
+
585
+ Ec.info("[ex-api] 📋 R_ROLE_PERM 写入前数据:");
586
+ Ec.info("[ex-api] insertedRolePerms.length = " + insertedRolePerms.length);
587
+ Ec.info("[ex-api] existingRolePerms.length = " + existingRolePerms.length);
588
+ Ec.info("[ex-api] permissionId = " + (permissionId || "—"));
589
+ Ec.info("[ex-api] roleIds.length = " + (roleIds ? roleIds.length : 0));
590
+ Ec.info("[ex-api] rolePermsToWrite.length = " + rolePermsToWrite.length);
591
+ if (rolePermsToWrite.length > 0) {
592
+ rolePermsToWrite.slice(0, 10).forEach((p, i) => {
593
+ Ec.info("[ex-api] rolePermsToWrite[" + i + "] = { ROLE_ID: " + (p.ROLE_ID != null ? p.ROLE_ID : "undefined") + ", PERM_ID: " + (p.PERM_ID != null ? p.PERM_ID : "undefined") + " }");
594
+ });
595
+ if (rolePermsToWrite.length > 10) Ec.info("[ex-api] ... 共 " + rolePermsToWrite.length + " 条");
596
+ }
597
+
598
+ // Excel 输出:有 target 时为 DPA zero-exmodule-{module};无 target 时输出到 -api 项目;文件名固化 identifier-method-uri
599
+ const excelRoot = resolveExcelRoot(cwd, target);
600
+ const domainName = target && target.module ? `zero-exmodule-${target.module}-domain` : null;
601
+ const pluginsBase = domainName
602
+ ? path.join(excelRoot, domainName, "src", "main", "resources", "plugins")
603
+ : path.join(excelRoot, "src", "main", "resources", "plugins");
604
+ const pluginId = domainName ? `zero-exmodule-${target.module}` : "zero-launcher-configuration";
605
+ const rbacResourceDir = path.join(pluginsBase, pluginId, "security", "RBAC_RESOURCE");
606
+ const rbacRoleDir = path.join(pluginsBase, pluginId, "security", "RBAC_ROLE", "ADMIN.SUPER");
607
+
608
+ if (!fs.existsSync(rbacResourceDir)) fs.mkdirSync(rbacResourceDir, { recursive: true });
609
+ if (!fs.existsSync(rbacRoleDir)) fs.mkdirSync(rbacRoleDir, { recursive: true });
610
+
611
+ const identifierSlug = (metadata.identifier || "api").replace(/[^a-zA-Z0-9._-]/g, "_");
612
+ const methodSlug = (method || "GET").toUpperCase();
613
+ const uriSlug = uriToFileNameSlug(uri);
614
+ const defaultFileName = `${identifierSlug}-${methodSlug}-${uriSlug}.xlsx`;
615
+
616
+ const fileName = defaultFileName;
617
+ const ExcelJS = require("exceljs");
618
+ // 模板目录取自 r2mo-init 包内(__dirname),非当前项目 cwd,保证任意项目执行 ai ex-api 都能找到模板
619
+ const templateDir = path.resolve(__dirname, "..", "_template", "EXCEL", "ex-api");
620
+ const templateDefPath = path.join(templateDir, "template-def.json");
621
+ let templateDef = {
622
+ RBAC_RESOURCE: { templateFile: "template-RBAC_RESOURCE.xlsx", sheetName: "DATA-PERM", tableName: "S_PERM_SET", columns: ["key", "code", "name", "type"] },
623
+ RBAC_ROLE: { templateFile: "template-RBAC_ROLE.xlsx", sheetName: "DATA-PERM", tableName: "R_ROLE_PERM", columns: ["roleId", "permId"] }
624
+ };
625
+ if (fs.existsSync(templateDefPath)) {
626
+ try {
627
+ const defJson = JSON.parse(fs.readFileSync(templateDefPath, "utf-8"));
628
+ if (defJson.RBAC_RESOURCE) templateDef.RBAC_RESOURCE = { ...templateDef.RBAC_RESOURCE, ...defJson.RBAC_RESOURCE };
629
+ if (defJson.RBAC_ROLE) templateDef.RBAC_ROLE = { ...templateDef.RBAC_ROLE, ...defJson.RBAC_ROLE };
630
+ } catch (_) {
631
+ Ec.info("[ex-api] 模版定义解析失败,使用内置格式");
632
+ }
633
+ }
634
+
635
+ const defRes = templateDef.RBAC_RESOURCE;
636
+ const defRole = templateDef.RBAC_ROLE;
637
+ const tableNameRole = defRole.tableName || "R_ROLE_PERM";
638
+
639
+ const templateResPath = path.join(templateDir, defRes.templateFile || "template-RBAC_RESOURCE.xlsx");
640
+ const templateRolePath = path.join(templateDir, defRole.templateFile || "template-RBAC_ROLE.xlsx");
641
+
642
+ // 四张表行数据 keyed by 模板中的 tableName(与 scanTableRegions 返回一致)
643
+ const tableRowData = {
644
+ S_RESOURCE: rowS_RESOURCE,
645
+ S_ACTION: rowS_ACTION,
646
+ S_PERMISSION: rowS_PERMISSION,
647
+ S_PERM_SET: rowS_PERM_SET
648
+ };
649
+
650
+ let workbook;
651
+ if (fs.existsSync(templateResPath)) {
652
+ workbook = await new ExcelJS.Workbook().xlsx.readFile(templateResPath);
653
+ const wsRes = workbook.getWorksheet(defRes.sheetName || "DATA-PERM") || workbook.worksheets[0];
654
+ if (wsRes) {
655
+ const regions = scanTableRegions(wsRes);
656
+ regions.forEach((region) => {
657
+ const dataRow = tableRowData[region.tableName];
658
+ if (!dataRow || !region.columnIndex) return;
659
+ const row = wsRes.getRow(region.dataStartRow);
660
+ Object.keys(dataRow).forEach((col) => {
661
+ const colNum = region.columnIndex[col];
662
+ if (colNum != null && dataRow[col] != null && dataRow[col] !== "") {
663
+ row.getCell(colNum).value = dataRow[col];
664
+ }
665
+ });
666
+ });
667
+ }
668
+ } else {
669
+ Ec.info("[ex-api] 未找到模板 " + templateResPath + ",使用固定表头格式(可被解析)");
670
+ workbook = new ExcelJS.Workbook();
671
+ const wsRes = workbook.addWorksheet(defRes.sheetName || "DATA-PERM");
672
+ wsRes.addRow([]);
673
+ wsRes.addRow([]);
674
+ ["S_PERM_SET", "S_PERMISSION", "S_ACTION", "S_RESOURCE"].forEach((tname) => {
675
+ const data = tableRowData[tname];
676
+ if (tname === "S_PERM_SET" && data) {
677
+ wsRes.addRow(["{TABLE}", tname, "", "", ""]);
678
+ wsRes.addRow(["权限集主键", "权限代码", "权限集名称", "权限集类型"]);
679
+ wsRes.addRow(["key", "code", "name", "type"]);
680
+ wsRes.addRow([data.key, data.code, data.name, data.type]);
681
+ } else if (tname === "S_RESOURCE" && data) {
682
+ wsRes.addRow(["{TABLE}", tname, "", "", ""]);
683
+ wsRes.addRow(["主键", "名称", "MODE_ROLE", "CODE", "IDENTIFIER", "TYPE", "LEVEL"]);
684
+ wsRes.addRow(["key", "name", "modeRole", "code", "identifier", "type", "level"]);
685
+ wsRes.addRow([data.key, data.name, data.modeRole, data.code, data.identifier, data.type, data.level]);
686
+ } else if (tname === "S_ACTION" && data) {
687
+ wsRes.addRow(["{TABLE}", tname, "", "", ""]);
688
+ wsRes.addRow(["主键", "RESOURCE_ID", "PERMISSION_ID", "CODE", "METHOD", "URI", "NAME", "LEVEL"]);
689
+ wsRes.addRow(["key", "resourceId", "permissionId", "code", "method", "uri", "name", "level"]);
690
+ wsRes.addRow([data.key, data.resourceId, data.permissionId, data.code, data.method, data.uri, data.name, data.level]);
691
+ } else if (tname === "S_PERMISSION" && data) {
692
+ wsRes.addRow(["{TABLE}", tname, "", "", ""]);
693
+ wsRes.addRow(["主键", "名称", "备注", "CODE", "IDENTIFIER"]);
694
+ wsRes.addRow(["key", "name", "comment", "code", "identifier"]);
695
+ wsRes.addRow([data.key, data.name, data.comment, data.code, data.identifier]);
696
+ }
697
+ });
698
+ }
699
+ const outResPath = path.join(rbacResourceDir, fileName);
700
+ await workbook.xlsx.writeFile(outResPath);
701
+ Ec.info("[ex-api] 已写入 RBAC_RESOURCE:" + outResPath);
702
+
703
+ let roleWorkbook;
704
+ if (fs.existsSync(templateRolePath)) {
705
+ roleWorkbook = await new ExcelJS.Workbook().xlsx.readFile(templateRolePath);
706
+ const wsRole = roleWorkbook.getWorksheet(defRole.sheetName || "DATA-PERM") || roleWorkbook.worksheets[0];
707
+ if (wsRole) {
708
+ const regionsRole = scanTableRegions(wsRole);
709
+ Ec.info("[ex-api] 📋 R_ROLE_PERM 模板区域:");
710
+ Ec.info("[ex-api] templateRolePath = " + templateRolePath);
711
+ Ec.info("[ex-api] sheetName = " + (defRole.sheetName || "DATA-PERM"));
712
+ Ec.info("[ex-api] tableNameRole(查找用) = " + tableNameRole);
713
+ Ec.info("[ex-api] regionsRole.length = " + regionsRole.length);
714
+ regionsRole.forEach((r, i) => {
715
+ Ec.info("[ex-api] regionsRole[" + i + "].tableName = " + JSON.stringify(r.tableName) + ", dataStartRow = " + r.dataStartRow + ", columnIndex keys = " + (r.columnIndex ? Object.keys(r.columnIndex).join(", ") : "—"));
716
+ });
717
+ const region = regionsRole.find((r) => String(r.tableName).trim() === String(tableNameRole).trim());
718
+ if (region) {
719
+ const colRole = region.columnIndex["roleId"] || region.columnIndex["ROLE_ID"] || 1;
720
+ const colPerm = region.columnIndex["permId"] || region.columnIndex["PERM_ID"] || 2;
721
+ Ec.info("[ex-api] 找到 R_ROLE_PERM 区域:dataStartRow = " + region.dataStartRow + ", colRole = " + colRole + ", colPerm = " + colPerm);
722
+ if (rolePermsToWrite.length > 0) {
723
+ Ec.info("[ex-api] 即将写入 " + rolePermsToWrite.length + " 行到 wsRole 行 " + region.dataStartRow + " 起,列 " + colRole + "(ROLE_ID)、" + colPerm + "(PERM_ID)");
724
+ rolePermsToWrite.forEach((pair, idx) => {
725
+ const row = wsRole.getRow(region.dataStartRow + idx);
726
+ const roleId = pair.ROLE_ID != null ? pair.ROLE_ID : pair.role_id;
727
+ const permId = pair.PERM_ID != null ? pair.PERM_ID : pair.perm_id;
728
+ row.getCell(colRole).value = roleId;
729
+ row.getCell(colPerm).value = permId;
730
+ if (idx < 3) Ec.info("[ex-api] row[" + (region.dataStartRow + idx) + "] 已设 列" + colRole + "=" + roleId + " 列" + colPerm + "=" + permId);
731
+ });
732
+ const checkRow = wsRole.getRow(region.dataStartRow);
733
+ Ec.info("[ex-api] 已写入 R_ROLE_PERM " + rolePermsToWrite.length + " 行(dataStartRow=" + region.dataStartRow + ");写回读首行 列" + colRole + "=" + (checkRow.getCell(colRole).value) + " 列" + colPerm + "=" + (checkRow.getCell(colPerm).value));
734
+ } else {
735
+ Ec.info("[ex-api] R_ROLE_PERM 无数据可写(rolePermsToWrite.length=0,permissionId=" + (permissionId || "—") + ")");
736
+ }
737
+ } else {
738
+ Ec.info("[ex-api] 未找到 R_ROLE_PERM 区域(tableName=" + tableNameRole + "),跳过写入");
739
+ }
740
+ }
741
+ } else {
742
+ Ec.info("[ex-api] 未找到模板 " + templateRolePath + ",使用固定表头格式(可被解析)");
743
+ roleWorkbook = new ExcelJS.Workbook();
744
+ const wsRole = roleWorkbook.addWorksheet(defRole.sheetName || "DATA-PERM");
745
+ wsRole.addRow([]);
746
+ wsRole.addRow([]);
747
+ wsRole.addRow(["{TABLE}", tableNameRole, "角色和权限关系", "", ""]);
748
+ wsRole.addRow(["角色ID", "权限ID"]);
749
+ wsRole.addRow(["roleId", "permId"]);
750
+ rolePermsToWrite.forEach((p) => wsRole.addRow([p.ROLE_ID, p.PERM_ID]));
751
+ }
752
+ const roleFileName = "falcon-" + fileName;
753
+ const outRolePath = path.join(rbacRoleDir, roleFileName);
754
+ await roleWorkbook.xlsx.writeFile(outRolePath);
755
+ Ec.info("[ex-api] 已写入 RBAC_ROLE/ADMIN.SUPER:" + outRolePath);
756
+
757
+ Ec.info("[ex-api] ✅ 执行完成(幂等)");
758
+ Ec.info("[ex-api] 📋 汇总:");
759
+ Ec.info("[ex-api] 🔑 ACTION_ID = " + (actionId || "—"));
760
+ Ec.info("[ex-api] 🔑 RESOURCE_ID = " + (resourceId || "—"));
761
+ Ec.info("[ex-api] 🔑 PERMISSION_ID = " + (permissionId || "—"));
762
+ Ec.info("[ex-api] 👥 授权角色数 = " + (roleIds ? roleIds.length : 0));
763
+ Ec.info("[ex-api] 📁 RBAC_RESOURCE = " + outResPath);
764
+ Ec.info("[ex-api] 📁 RBAC_ROLE = " + outRolePath);
765
+ if (insertedResource) {
766
+ Ec.info("[ex-api] 📦 S_RESOURCE 本次插入字段:");
767
+ Object.keys(insertedResource).forEach((k) => Ec.info("[ex-api] " + k + " = " + (insertedResource[k] != null ? insertedResource[k] : "—")));
768
+ }
769
+ if (existingResource) {
770
+ Ec.info("[ex-api] 📄 S_RESOURCE 已有记录(有值属性):");
771
+ Object.keys(existingResource).forEach((k) => {
772
+ const v = existingResource[k];
773
+ if (v != null && v !== "") Ec.info("[ex-api] " + k + " = " + v);
774
+ });
775
+ }
776
+ if (insertedAction) {
777
+ Ec.info("[ex-api] 📦 S_ACTION 本次插入字段:");
778
+ Object.keys(insertedAction).forEach((k) => Ec.info("[ex-api] " + k + " = " + (insertedAction[k] != null ? insertedAction[k] : "—")));
779
+ }
780
+ if (existingAction) {
781
+ Ec.info("[ex-api] 📄 S_ACTION 已有记录(有值属性):");
782
+ Object.keys(existingAction).forEach((k) => {
783
+ const v = existingAction[k];
784
+ if (v != null && v !== "") Ec.info("[ex-api] " + k + " = " + v);
785
+ });
786
+ }
787
+ if (insertedPermission) {
788
+ Ec.info("[ex-api] 📦 S_PERMISSION 本次插入字段:");
789
+ Object.keys(insertedPermission).forEach((k) => Ec.info("[ex-api] " + k + " = " + (insertedPermission[k] != null ? insertedPermission[k] : "—")));
790
+ }
791
+ if (existingPermission) {
792
+ Ec.info("[ex-api] 📄 S_PERMISSION 已有记录(有值属性):");
793
+ Object.keys(existingPermission).forEach((k) => {
794
+ const v = existingPermission[k];
795
+ if (v != null && v !== "") Ec.info("[ex-api] " + k + " = " + v);
796
+ });
797
+ }
798
+ if (insertedRolePerms.length > 0) {
799
+ Ec.info("[ex-api] 📦 R_ROLE_PERM 本次写入(ROLE_ID, PERM_ID):");
800
+ insertedRolePerms.forEach((r, i) => Ec.info("[ex-api] [" + (i + 1) + "] " + r.ROLE_ID + ", " + r.PERM_ID));
801
+ }
802
+ } catch (err) {
803
+ Ec.error("[ex-api] 执行失败:" + (err && err.message));
804
+ if (err && err.code) Ec.error("[ex-api] 错误码:" + err.code);
805
+ if (err && err.sqlMessage) Ec.error("[ex-api] SQL:" + err.sqlMessage);
806
+ if (err && err.sql) Ec.error("[ex-api] 语句:" + (typeof err.sql === "string" ? err.sql : err.sql.join(" ")));
807
+ if (err && err.stack) Ec.error("[ex-api] 堆栈:" + err.stack);
808
+ process.exit(1);
809
+ } finally {
810
+ if (conn) await conn.end();
811
+ }
812
+ };