w-web-perm 1.1.13 → 1.1.14
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 +70 -6
- package/dist/css/{app.f09c7433.css → app.f24c5e50.css} +1 -1
- package/dist/index.tmp +1 -1
- package/dist/js/app-legacy.033deb22.js +1 -0
- package/dist/js/app.033deb22.js +1 -0
- package/dist/js/{chunk-vendors.ece5a361.js → chunk-vendors-legacy.b0bbb5ac.js} +4 -4
- package/dist/js/{chunk-vendors-legacy.ece5a361.js → chunk-vendors.b0bbb5ac.js} +4 -4
- package/dist/w-web-perm.umd.js +2 -2
- package/dist/w-web-perm.umd.js.map +1 -1
- package/docs/WWebPerm.html +11 -6
- package/docs/global.html +11316 -5113
- package/docs/index.html +2 -2
- package/docs/server_WWebPerm.mjs.html +114 -334
- package/docs/server_cascadeWrite.mjs.html +5 -26
- package/docs/server_dataChannel.mjs.html +3 -3
- package/docs/server_keyCheck.mjs.html +150 -23
- package/docs/server_lockSave.mjs.html +2 -2
- package/docs/server_maskLog.mjs.html +2 -2
- package/docs/server_procCore.mjs.html +246 -62
- package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
- package/docs/server_syncCore.mjs.html +533 -0
- package/docs/server_syncKeep.mjs.html +27 -20
- package/docs/server_tools_authRefsAudit.mjs.html +2 -2
- package/docs/server_tools_authRefsClean.mjs.html +2 -2
- package/docs/server_tools_fromTools.mjs.html +2 -2
- package/docs/server_tools_isMain.mjs.html +2 -2
- package/docs/server_tools_nameDupReport.mjs.html +40 -7
- package/docs/server_validateRows.mjs.html +2 -2
- package/docs/server_wrapInjected.mjs.html +2 -2
- package/docs/src_fetchJson.mjs.html +2 -2
- package/docs/src_getPermUserInfor.mjs.html +175 -0
- package/docs/src_js_applySaved.mjs.html +128 -0
- package/docs/src_js_cellEditing.mjs.html +163 -0
- package/docs/src_js_cvKpToArr.mjs.html +2 -2
- package/docs/src_js_cvKpToObj.mjs.html +2 -2
- package/docs/src_js_draftSync.mjs.html +83 -7
- package/docs/src_js_eachOwn.mjs.html +2 -2
- package/docs/src_js_editorRefRows.mjs.html +2 -2
- package/docs/src_js_externalRows.mjs.html +34 -10
- package/docs/src_js_externalUi.mjs.html +4 -8
- package/docs/src_js_findRefRows.mjs.html +2 -2
- package/docs/src_js_findTouchedDup.mjs.html +2 -2
- package/docs/src_js_fmtLossyMsg.mjs.html +2 -2
- package/docs/src_js_fromAudit.mjs.html +2 -2
- package/docs/src_js_getAddImpact.mjs.html +2 -2
- package/docs/src_js_getAuthWarn.mjs.html +2 -2
- package/docs/src_js_getCascadePreview.mjs.html +2 -2
- package/docs/src_js_getDelRefCount.mjs.html +2 -2
- package/docs/src_js_getEffMode.mjs.html +2 -2
- package/docs/src_js_getKpLossy.mjs.html +2 -2
- package/docs/src_js_getNameErrs.mjs.html +2 -2
- package/docs/src_js_getOwn.mjs.html +2 -2
- package/docs/src_js_getPropKey.mjs.html +2 -2
- package/docs/src_js_getRuleEnable.mjs.html +2 -2
- package/docs/src_js_isAuthMode.mjs.html +2 -2
- package/docs/src_js_joinNames.mjs.html +2 -2
- package/docs/src_js_legendFit.mjs.html +2 -2
- package/docs/src_js_pairRowsToSnapshot.mjs.html +2 -2
- package/docs/src_js_refAudit.mjs.html +2 -2
- package/docs/src_js_refCascade.mjs.html +2 -2
- package/docs/src_js_refIdentity.mjs.html +500 -0
- package/docs/src_js_relDialogSend.mjs.html +2 -2
- package/docs/src_js_rowDigest.mjs.html +75 -145
- package/docs/src_plugins_dataReloadGuard.mjs.html +2 -2
- package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
- package/docs/src_plugins_submitGuard.mjs.html +2 -2
- package/g_provideGrups.mjs +1 -1
- package/g_providePemis.mjs +1 -1
- package/g_provideTargets.mjs +1 -1
- package/g_provideUsers.mjs +1 -1
- package/package.json +2 -2
- package/server/WWebPerm.mjs +112 -332
- package/server/cascadeWrite.mjs +3 -24
- package/server/dataChannel.mjs +1 -1
- package/server/keyCheck.mjs +148 -21
- package/server/procCore.mjs +244 -60
- package/server/procLang.mjs +72 -4
- package/server/provideTabs.mjs +6 -4
- package/server/syncCore.mjs +461 -0
- package/server/syncKeep.mjs +25 -18
- package/server/tools/nameDupReport.mjs +38 -5
- package/src/components/LayoutContentGrups.vue +57 -8
- package/src/components/LayoutContentPemis.vue +57 -8
- package/src/components/LayoutContentTargets.vue +38 -7
- package/src/components/LayoutContentUsers.vue +57 -8
- package/src/components/VeGrupBlngUsers.vue +128 -20
- package/src/components/VePemiBlngGrups.vue +128 -20
- package/src/getPermUserInfor.mjs +10 -0
- package/src/js/applySaved.mjs +56 -0
- package/src/js/cellEditing.mjs +91 -0
- package/src/js/draftSync.mjs +81 -5
- package/src/js/externalRows.mjs +32 -8
- package/src/js/externalUi.mjs +2 -6
- package/src/js/refIdentity.mjs +428 -0
- package/src/js/rowDigest.mjs +73 -143
- package/src/schema/contracts/grups.mjs +8 -0
- package/src/schema/contracts/index.mjs +17 -0
- package/src/schema/contracts/pemis.mjs +8 -0
- package/src/schema/contracts/targets.mjs +8 -0
- package/src/schema/contracts/users.mjs +13 -0
- package/src/schema/tables/grups.mjs +4 -0
- package/src/schema/tables/pemis.mjs +4 -0
- package/src/schema/tables/targets.mjs +4 -0
- package/src/schema/tables/users.mjs +4 -0
- package/srv.mjs +19 -0
- package/dist/js/app-legacy.8af87052.js +0 -1
- package/dist/js/app.8af87052.js +0 -1
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
3
|
+
import isearr from 'wsemi/src/isearr.mjs'
|
|
4
|
+
import clearXSS from 'wsemi/src/clearXSS.mjs'
|
|
5
|
+
import genIDSeq from 'wsemi/src/genIDSeq.mjs'
|
|
6
|
+
import ltdtmapping from 'wsemi/src/ltdtmapping.mjs'
|
|
7
|
+
import ds from '../src/schema/index.mjs'
|
|
8
|
+
import validateRowsBeforeWrite, { maxIdBytes } from './validateRows.mjs'
|
|
9
|
+
import planRefChanges, { REF_OF, diffRefTargets } from '../src/js/refCascade.mjs'
|
|
10
|
+
import { SERVER_FIELDS } from '../src/js/externalRows.mjs'
|
|
11
|
+
import { findDupIndex, findKeyOtherFrom, withTabLocks, mapKeyOf, checkMapKeyBatch, findLoginIdClashDetail } from './keyCheck.mjs'
|
|
12
|
+
import { checkCascadePlan, writeWithCascade } from './cascadeWrite.mjs'
|
|
13
|
+
import { markHasKey, keepAssignOnMissing, keepAssignByEntity, countSrcKeyRenumber } from './syncKeep.mjs'
|
|
14
|
+
import { toOutKey, isRowObj } from './procCore.mjs'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
//外部匯入(外部同步)之核心: 依外部自訂之來源鍵 srcKey 於表鎖內做差異同步(perm-run03 範圍二; 使用者 2026-10-22 裁示原則 11、2026-10-23 裁示不提供呼叫端相容;
|
|
18
|
+
//規格(真理源) C:\opensrcOper\dspec\外部匯入之定義與機制.md 第 3~10 節; 規則帳本 R-IMPORT-01).
|
|
19
|
+
//由 WWebPerm.mjs 之 POST /syncAndReplaceTabs 呼叫; 獨立成模組以便注入 ORM 替身做單元測試(寫入中途失敗之補償、鎖、配對).
|
|
20
|
+
//
|
|
21
|
+
//語意: 本文 { from, rows }, rows=該 from 之完整清單; 每列帶外部自訂之 srcKey(同一 from 內唯一且穩定), 不得帶 id(內部主鍵由本系統產生).
|
|
22
|
+
// 於該表之表鎖內(與後台整表寫入、引用連帶之引用端寫入共鎖, R-WRITE-LOCK-01/02)新鮮讀取庫內全表, 依 srcKey 配對該 from 之庫內列:
|
|
23
|
+
// 新增: 配新 id(targets 例外: 其 id 即標的名稱〔被 pemis.crules 以名稱引用〕, 由 srcKey 衍生, id=srcKey);
|
|
24
|
+
// 修改: 保留 id, 只寫外部擁有欄位與有帶鍵之外部可選欄位(指派欄), 改名之引用跟著改(R-REF-CASCADE-01);
|
|
25
|
+
// 刪除: 庫有、批次無 → 刪除, 引用依原則 6 連帶清理;
|
|
26
|
+
// 不變: 不寫(零寫入; 冪等).
|
|
27
|
+
// 後台列(from 為空字串)與他 from 之列永不被觸及. 回 { nAdd, nUpdate, nDel, nSame }.
|
|
28
|
+
//欄位歸屬: 外部擁有(批次提供之欄位, 缺鍵視為空值)、外部可選(指派欄 users.cgrups/grups.cpemis/pemis.crules: 有帶鍵才寫、缺鍵保留庫內值, F-23, syncKeep.mjs)、
|
|
29
|
+
// 本系統擁有(伺服器管理欄: 建立者/建立時間/更新者/更新時間, 一律不採批次值). id、srcKey、from 為身分欄: 由本函式寫入, 後台不可改(procCore/externalRows 守門).
|
|
30
|
+
//一次性資料遷移(非呼叫端相容): 既有外部列(舊同步寫入、無 srcKey)於首次匯入時, 批次 srcKey 等於其 id 者視為同一列, 於鎖內補上 srcKey; 其後只認 srcKey, 對不上者依差異同步刪舊增新.
|
|
31
|
+
//寫入中途失敗: 先預檢(整批驗證、引用預檢)攔下可預見之失敗; 寫入階段失敗時補償還原——引用端由 writeWithCascade 還原, 本表由 undoMain 以鎖內讀取之舊列還原(新增者刪除、被刪者與被改者以整列覆寫寫回);
|
|
32
|
+
// 補償亦失敗 → saveWriteFail + srLog(syncAndReplaceTabs-restore-error), 由稽核工具人工處置.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
let hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
|
|
36
|
+
let isEmptyV = (v) => v === undefined || v === null || v === ''
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
//eqV: 同步比對用相等(缺欄/null/'' 視為同一空值; 純量以字串形式比, 數字 1 與字串 '1' 同一〔ORM 欄位型別轉換不致使冪等失效〕; 物件以 JSON 比)
|
|
40
|
+
let eqV = (a, b) => {
|
|
41
|
+
if (isEmptyV(a) || isEmptyV(b)) {
|
|
42
|
+
return isEmptyV(a) && isEmptyV(b)
|
|
43
|
+
}
|
|
44
|
+
if (typeof a === 'object' || typeof b === 'object') {
|
|
45
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
46
|
+
}
|
|
47
|
+
return String(a) === String(b)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
//isSrcKey: 來源鍵須為非空白字串、UTF-8 位元組數不超過 ORM 主鍵長度上限(targets 之 id 即 srcKey, 受 LMDB 主鍵上限約束; 其餘表統一同一上限)、不含 HTML 標記(會被 clearXSS 改寫者; 送入值即儲存值)
|
|
52
|
+
let isSrcKey = (s) => typeof s === 'string' && s.trim() !== '' && Buffer.byteLength(s, 'utf8') <= maxIdBytes && clearXSS(s) === s
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
//KEY_ASSIGN: 各表之授權指派欄(外部可選); targets 無
|
|
56
|
+
let KEY_ASSIGN = { users: 'cgrups', grups: 'cpemis', pemis: 'crules', targets: null }
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 建立外部匯入核心
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} p 輸入參數物件
|
|
63
|
+
* @param {Object} p.woItems 輸入 ORM 表物件集合(select/save/delAll)
|
|
64
|
+
* @param {Function} p.procOrm 輸入 procOrm(userId, 表名, 方法, 資料, opt)
|
|
65
|
+
* @param {Function} p.lockTabs 輸入 w-serv-orm 實例之 withTabLocks(tableNames, fn)
|
|
66
|
+
* @param {Object} p.srLog 輸入 srLog
|
|
67
|
+
* @param {String} [p.mappingBy='id'] 輸入使用者對應鍵('id'|'email'|'name')
|
|
68
|
+
* @param {Boolean} [p.cascadeRename=true] 輸入改名是否連帶更新引用
|
|
69
|
+
* @returns {Object} 回傳 { syncAndReplaceTabs(userId, inp, keyTable), getSyncWarns() }
|
|
70
|
+
*/
|
|
71
|
+
function syncCore({ woItems, procOrm, lockTabs, srLog, mappingBy = 'id', cascadeRename = true }) {
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
//syncWarns: 外部同步「同一 from 同名異 srcKey」(疑似來源鍵重編)之提示登錄(記憶體, 重啟即清; 主控定案: 不加開關, 記 warn log 並使頁面可提示):
|
|
75
|
+
//以「表名+來源」為鍵, 最近一次同步有此情形者登錄筆數與時間, 已無此情形者清除; 只含表名、來源名稱、筆數、時間, 不含個資.
|
|
76
|
+
//why 保留(perm-run03 範圍二 5 評估): 差異同步下身分是 srcKey, 「同名異 id」之 id 重編已不存在, 但外部來源若重編 srcKey(如目錄重建後 GUID 全換), 該列等於「刪舊增新」,
|
|
77
|
+
//引用每次被連帶清理而抹掉後台指派(原則 6: 新實體不繼承)——此風險仍在, 提示仍有價值, 故判定口徑由 id 改為 srcKey(countSrcKeyRenumber), 不移除
|
|
78
|
+
let kpSyncWarn = new Map()
|
|
79
|
+
let setSyncWarn = (keyTable, from, n) => {
|
|
80
|
+
let k = `${keyTable}\u0000${from}`
|
|
81
|
+
if (n > 0) {
|
|
82
|
+
kpSyncWarn.set(k, { keyTable, from, n, time: new Date().toISOString() })
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
kpSyncWarn.delete(k)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let getSyncWarns = () => [...kpSyncWarn.values()]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
//syncAndReplaceTabs
|
|
92
|
+
let syncAndReplaceTabs = async (userId, inp, keyTable) => {
|
|
93
|
+
|
|
94
|
+
//序列化同表之並行匯入/後台整表寫入: 防 lost update (同 keyTable 序列化, 不同表並行); 與後台整表寫入(procCore.updateTabItems)共用同一個鎖管理器(w-serv-orm 實例之 withTabLocks, R-LOCK-CORE-01);
|
|
95
|
+
//有改名或刪除而須改寫引用端時, 於內層依階層再取引用端表之鎖(R-REF-CASCADE-01、R-WRITE-LOCK-02). 外部匯入為不帶基準之寫入(以外部為主, 原則 5): 取表鎖但不比對基準
|
|
96
|
+
return await withTabLocks(lockTabs, [keyTable], async () => {
|
|
97
|
+
|
|
98
|
+
let logErr = (msg, extra = {}) => srLog.error({ event: 'syncAndReplaceTabs-error', msg, keyTable, ...extra })
|
|
99
|
+
|
|
100
|
+
//from
|
|
101
|
+
let from = get(inp, 'from', '')
|
|
102
|
+
if (!isestr(from)) {
|
|
103
|
+
logErr('invalidFrom')
|
|
104
|
+
return Promise.reject(`invalidFrom`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//rows: 空批次拒收(清空某來源須另以明確操作, 防誤清源, ADR-008)
|
|
108
|
+
let rowsIn = get(inp, 'rows', [])
|
|
109
|
+
if (!isearr(rowsIn)) {
|
|
110
|
+
logErr('invalidRows')
|
|
111
|
+
return Promise.reject(`invalidRows`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
//各列須為非空物件(規則帳本 R-ORM-05, 與後台整表寫入共用 isRowObj)
|
|
115
|
+
let kBadRow = rowsIn.findIndex((v) => !isRowObj(v))
|
|
116
|
+
if (kBadRow >= 0) {
|
|
117
|
+
logErr('invalidRows', { reason: 'rowInvalid', from, row: kBadRow })
|
|
118
|
+
return Promise.reject(`invalidRows`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
//idNotAllowed: 內部主鍵 id 由本系統產生, 外部不得指定(含空值; 規格第 9 節): 任一列帶有 id 鍵 → 整批拒收、零寫入. 先於 srcKey 檢查——
|
|
122
|
+
//仍送 id 者多為舊呼叫方式(以 id 代 srcKey), 先提示移除 id, 再提示補 srcKey
|
|
123
|
+
let kId = rowsIn.findIndex((v) => hasOwn(v, 'id'))
|
|
124
|
+
if (kId >= 0) {
|
|
125
|
+
logErr('idNotAllowed', { from, row: kId, field: 'id' })
|
|
126
|
+
return Promise.reject(`idNotAllowed`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//srcKey 必填(規格第 5 節): 缺、空白、非字串、過長、含標記 → srcKeyInvalid; 同批重複 → srcKeyDuplicate; 皆整批拒收、零寫入
|
|
130
|
+
let kSrc = rowsIn.findIndex((v) => !isSrcKey(v.srcKey))
|
|
131
|
+
if (kSrc >= 0) {
|
|
132
|
+
logErr('srcKeyInvalid', { from, row: kSrc, field: 'srcKey' })
|
|
133
|
+
return Promise.reject(`srcKeyInvalid`)
|
|
134
|
+
}
|
|
135
|
+
let kSrcDup = findDupIndex(rowsIn, 'srcKey')
|
|
136
|
+
if (kSrcDup >= 0) {
|
|
137
|
+
logErr('srcKeyDuplicate', { from, row: kSrcDup, field: 'srcKey' })
|
|
138
|
+
return Promise.reject(`srcKeyDuplicate`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//keyAssign/hasAssign: 外部可選之指派欄及各列是否自帶該鍵(須於 ltdtmapping 缺鍵補 '' 之前記錄); 缺該鍵者保留庫內值(F-23, 見 syncKeep.mjs)
|
|
142
|
+
let keyAssign = KEY_ASSIGN[keyTable]
|
|
143
|
+
let hasAssign = (keyAssign !== null) ? markHasKey(rowsIn, keyAssign) : null
|
|
144
|
+
|
|
145
|
+
//rows: 映射到 schema 欄位(缺鍵補 ''; 不在 schema 之鍵剝除), 寫入 from; targets 之 id 即標的名稱, 由 srcKey 衍生(id=srcKey)
|
|
146
|
+
let rows = ltdtmapping(rowsIn.map((v) => {
|
|
147
|
+
let o = { ...v, from }
|
|
148
|
+
if (keyTable === 'targets') {
|
|
149
|
+
o.id = o.srcKey
|
|
150
|
+
}
|
|
151
|
+
return o
|
|
152
|
+
}), ds[keyTable].keys)
|
|
153
|
+
|
|
154
|
+
//名稱須為非空字串(規則帳本 R-SYNC-NAME-01): grups/pemis 之 name 缺漏、空字串、非字串一律整批拒收(targets 之 id 即 srcKey, 已於上方保證非空字串; users 之 name 可空)
|
|
155
|
+
let keyName = (keyTable === 'grups' || keyTable === 'pemis') ? 'name' : null
|
|
156
|
+
if (keyName !== null) {
|
|
157
|
+
let kName = rows.findIndex((v) => !isestr(get(v, keyName)))
|
|
158
|
+
if (kName >= 0) {
|
|
159
|
+
logErr('invalidRows', { reason: 'nameInvalid', from, row: kName, field: keyName })
|
|
160
|
+
return Promise.reject(`invalidRows`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//群組/權限名稱唯一(F-02, 規則帳本 R-NAME-UNIQ-01): 本批內 name 精確 === 重複整批拒收(他 from/後台列已使用者見下方 nameOtherFrom)
|
|
164
|
+
let kDup = findDupIndex(rows, 'name')
|
|
165
|
+
if (kDup >= 0) {
|
|
166
|
+
logErr('invalidRows', { reason: 'nameDuplicate', from, row: kDup, field: 'name' })
|
|
167
|
+
return Promise.reject(`invalidRows`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
//users 對應鍵批內檢查(F-14, 規則帳本 R-MAPKEY-01): mappingBy 為 'email'/'name' 時, 每個 isActive==='y' 之列該欄位須非空字串且批內唯一; log 不記對應值(個資)
|
|
172
|
+
if (keyTable === 'users') {
|
|
173
|
+
let mk = checkMapKeyBatch(rows, mappingBy)
|
|
174
|
+
if (mk !== null) {
|
|
175
|
+
logErr('invalidRows', { reason: mk.reason, from, row: mk.k, field: mappingBy })
|
|
176
|
+
return Promise.reject(`invalidRows`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
//寫入前驗證(規則帳本 R-ORM-01): 主鍵(targets 之 id)長度/標記、任一字串欄位含會被 clearXSS 改寫之標記 → htmlMarkupNotAllowed; 其餘 → invalidRows
|
|
181
|
+
let ve = validateRowsBeforeWrite(rows)
|
|
182
|
+
if (ve !== null) {
|
|
183
|
+
let key = (ve.reason === 'htmlMarkup') ? 'htmlMarkupNotAllowed' : 'invalidRows'
|
|
184
|
+
logErr(key, { reason: ve.reason, from, row: ve.k, field: ve.field })
|
|
185
|
+
return Promise.reject(key)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
//rsAll: 庫內全表(表鎖內新鮮讀取, 後續配對、跨 from 檢查、引用規劃共用同一版本); 讀取失敗尚未寫入任何資料, 不回 saveWriteFail
|
|
189
|
+
let rsAll = null
|
|
190
|
+
try {
|
|
191
|
+
rsAll = await woItems[keyTable].select()
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
|
|
195
|
+
let key = toOutKey(err, 'anUnexpectedErrorOccurred')
|
|
196
|
+
logErr(key, { raw, from, step: 'select' })
|
|
197
|
+
return Promise.reject(key)
|
|
198
|
+
}
|
|
199
|
+
let objs = rsAll.filter((r) => r !== null && typeof r === 'object')
|
|
200
|
+
let sameFrom = objs.filter((r) => r.from === from)
|
|
201
|
+
let others = objs.filter((r) => r.from !== from)
|
|
202
|
+
|
|
203
|
+
//跨 from 同名/同對應鍵(F-02/F-14, 規則帳本 R-NAME-UNIQ-01/R-MAPKEY-01): grups/pemis 之 name、users 於 mappingBy 為 email/name 時之該欄位(僅 active 列),
|
|
204
|
+
//已為其他 from(含後台列)之既有列使用 → 整批拒絕(雙方不變、提示); 同 from 之既有列不算衝突(同批比對後被修改或刪除)
|
|
205
|
+
let keyOther = (keyName === 'name') ? 'name' : ((keyTable === 'users') ? mapKeyOf(mappingBy) : null)
|
|
206
|
+
if (keyOther !== null) {
|
|
207
|
+
let kOther = findKeyOtherFrom(rows, objs, from, keyOther, { onlyActive: keyTable === 'users' })
|
|
208
|
+
if (kOther >= 0) {
|
|
209
|
+
logErr('invalidRows', { reason: (keyTable === 'users') ? 'mapKeyOtherFrom' : 'nameOtherFrom', from, row: kOther, field: keyOther })
|
|
210
|
+
return Promise.reject('invalidRows')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
//users 登入識別撞值(mappingBy 為 'id' 時; perm-run04, 規則帳本 R-MAPKEY-04, 規格第 9 節末 O1): 外部列以 srcKey、後台列以 id 對應外部 token 之使用者 id.
|
|
215
|
+
//perm-run05(主控 2026-10-24 收件裁示): srcKey 須跨全表唯一, 「不論 isActive」「含他來源外部列」——本批任一列(皆視為被觸及, 同 R-MAPKEY-01: 批次全列參與)之 srcKey 等於他 from 之任一既有列
|
|
216
|
+
//(後台列之 id、他來源外部列之 srcKey、他來源舊外部列〔無 srcKey〕之 id)→ 登入將命中多筆 → 整批拒收 loginIdCollision(收嚴+提示, 提示請部署方改用可唯一對應之鍵; 零寫入); 同 from 之既有列不算(隨後由差異同步配對或刪除).
|
|
217
|
+
//固定訊息、log 不記 srcKey/id 之值
|
|
218
|
+
if (keyTable === 'users' && mapKeyOf(mappingBy) === null) {
|
|
219
|
+
let dClash = findLoginIdClashDetail([...others, ...rows], (r, k) => k >= others.length)
|
|
220
|
+
if (dClash !== null) {
|
|
221
|
+
logErr('loginIdCollision', { reason: (dClash.kind === 'otherFrom') ? 'srcKeyOtherFrom' : 'srcKeyIsAdminId', from, row: dClash.k - others.length, field: 'srcKey' })
|
|
222
|
+
return Promise.reject('loginIdCollision')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
//配對(依 srcKey): 庫內該 from 之列有 srcKey 者依其值對應; 無 srcKey 者為遷移前資料(舊同步寫入), 批次 srcKey 等於其 id 者視為同一列並補上 srcKey(一次性資料遷移, 規格第 9 節);
|
|
227
|
+
//庫內同 from 有重複 srcKey, 或同一 srcKey 同時被兩個庫內列主張(一個有 srcKey、一個以 id 主張)→ 無法唯一配對, 整批拒收(srcKeyConflict), 以稽核/清源工具人工處置
|
|
228
|
+
let oldBySrc = new Map()
|
|
229
|
+
let legacyById = new Map()
|
|
230
|
+
for (let r of sameFrom) {
|
|
231
|
+
if (isestr(r.srcKey)) {
|
|
232
|
+
if (oldBySrc.has(r.srcKey)) {
|
|
233
|
+
logErr('srcKeyConflict', { reason: 'dupInDb', from })
|
|
234
|
+
return Promise.reject('srcKeyConflict')
|
|
235
|
+
}
|
|
236
|
+
oldBySrc.set(r.srcKey, r)
|
|
237
|
+
}
|
|
238
|
+
else if (isestr(r.id)) {
|
|
239
|
+
legacyById.set(r.id, r)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (let k of oldBySrc.keys()) {
|
|
243
|
+
if (legacyById.has(k)) { //同一 srcKey 被兩個庫內列主張(一個已有 srcKey、一個以 id 主張): 與庫內重複 srcKey 同屬庫內資料異常, 不論本批是否帶該鍵一律拒收(外審 sol#2)
|
|
244
|
+
logErr('srcKeyConflict', { reason: 'claimTwice', from })
|
|
245
|
+
return Promise.reject('srcKeyConflict')
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
let matched = rows.map((v) => {
|
|
249
|
+
let o = oldBySrc.get(v.srcKey)
|
|
250
|
+
if (o === undefined && legacyById.has(v.srcKey)) {
|
|
251
|
+
return { old: legacyById.get(v.srcKey), migrate: true }
|
|
252
|
+
}
|
|
253
|
+
return o === undefined ? { old: null, migrate: false } : { old: o, migrate: false }
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
//各列之身分 id: 配對者沿用庫內 id(不變); 新增者配新 id(targets 為 srcKey 衍生之標的名稱——主鍵已被庫內任何其他列占用者整批拒收, 免 insert 對既有主鍵靜默略過; 其餘表產生不撞既有之循序 id)
|
|
257
|
+
let usedIds = new Set(objs.map((r) => r.id))
|
|
258
|
+
let idOf = []
|
|
259
|
+
for (let k = 0; k < rows.length; k++) {
|
|
260
|
+
let m = matched[k]
|
|
261
|
+
if (m.old !== null) {
|
|
262
|
+
idOf.push(m.old.id)
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
if (keyTable === 'targets') {
|
|
266
|
+
if (usedIds.has(rows[k].id)) {
|
|
267
|
+
logErr('invalidRows', { reason: 'idTaken', from, row: k, field: 'srcKey' }) //標的名稱(id)已被其他來源或後台列使用: 與他 from 同鍵(R-SYNC-01 精神)
|
|
268
|
+
return Promise.reject('invalidRows')
|
|
269
|
+
}
|
|
270
|
+
idOf.push(rows[k].id)
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
let nid = `${genIDSeq()}`
|
|
274
|
+
while (usedIds.has(nid)) {
|
|
275
|
+
nid = `${genIDSeq()}`
|
|
276
|
+
}
|
|
277
|
+
idOf.push(nid)
|
|
278
|
+
}
|
|
279
|
+
usedIds.add(idOf[k])
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
//外部可選之指派欄: payload 缺該鍵之列保留庫內值(F-23; 指派欄屬後台, 同步不得清掉後台指派; 含該鍵則以外部為準). 身分已由 srcKey 配對(rowsKeep 之 id 即配對到之庫內 id, 新增者為新配之 id),
|
|
283
|
+
//故 syncKeep 以 id 認列; users 另依實際 mappingBy 做登入對應鍵矛盾檢查(外審 sol#1): mappingBy 為 email/name 時登入以該欄位找使用者, 同 srcKey 之對應鍵值被改成別值、或新值為庫內別列持有,
|
|
284
|
+
//保留指派會使該登入值取得原擁有者之授權(換主移轉, 增權), 一律不保留並記 warn(收嚴+提示, 同 perm-run03 之前的行為); 改名而 cascadeRename 為假者不保留(keepAssignByEntity, 引用不跟著改時名稱即授權身分)
|
|
285
|
+
let rowsKeep = rows.map((v, k) => ({ ...v, id: idOf[k] }))
|
|
286
|
+
if (keyAssign !== null && hasAssign.some((b) => b === false)) {
|
|
287
|
+
//isKeepable: 保留值須能通過與外部寫入同一套寫入前驗證(外審 sol R4): 不通過者不保留(視為空, 收嚴), 只記筆數
|
|
288
|
+
let isKeepable = (val) => validateRowsBeforeWrite([{ id: 'keep', [keyAssign]: val }]) === null
|
|
289
|
+
let kr = (keyTable === 'users') ? keepAssignOnMissing({ rows: rowsKeep, hasKey: hasAssign, rowsOldSameFrom: sameFrom, mappingBy, isKeepable }) : keepAssignByEntity({ rows: rowsKeep, hasKey: hasAssign, rowsOldSameFrom: sameFrom, key: keyAssign, cascadeRename, isKeepable })
|
|
290
|
+
rowsKeep = kr.rows
|
|
291
|
+
if (kr.nKeep > 0) {
|
|
292
|
+
srLog.info({ event: 'syncAndReplaceTabs-keepAssign', keyTable, from, field: keyAssign, n: kr.nKeep })
|
|
293
|
+
}
|
|
294
|
+
if (kr.nSkip > 0) {
|
|
295
|
+
srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nSkip, reason: 'notKeepable' })
|
|
296
|
+
}
|
|
297
|
+
if (kr.nConflict > 0) {
|
|
298
|
+
srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nConflict, reason: 'identityConflict' }) //改名而引用不跟著改、或 users 登入對應鍵矛盾(email/name 被改或為別列持有): 指派不移轉而清空(收嚴須附提示; 不含個資值)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//差異分類: 外部擁有欄位(schema 欄位扣除 id/srcKey/from/伺服器管理欄/指派欄; 含 order——外部列之順序由外部擁有)與指派欄(缺鍵者已於上方換成庫內值)任一不同、或需補 srcKey(遷移)即修改; 皆同即不變
|
|
303
|
+
let keysExt = ds[keyTable].keys.filter((k) => k !== 'id' && k !== 'srcKey' && k !== 'from' && k !== keyAssign && SERVER_FIELDS.indexOf(k) < 0)
|
|
304
|
+
let keysCmp = keyAssign !== null ? [...keysExt, keyAssign] : keysExt
|
|
305
|
+
let adds = []
|
|
306
|
+
let upds = []
|
|
307
|
+
let updPre = []
|
|
308
|
+
let nSame = 0
|
|
309
|
+
let nMigrate = 0
|
|
310
|
+
let postById = new Map() //修改後之列(供規劃改名), 以庫內 id 為鍵
|
|
311
|
+
rowsKeep.forEach((v, k) => {
|
|
312
|
+
let m = matched[k]
|
|
313
|
+
if (m.old === null) {
|
|
314
|
+
adds.push({ ...v, id: idOf[k], srcKey: v.srcKey, from })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
let o = m.old
|
|
318
|
+
let isDiff = m.migrate || keysCmp.some((f) => !eqV(v[f], o[f]))
|
|
319
|
+
if (!isDiff) {
|
|
320
|
+
nSame++
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (m.migrate) {
|
|
324
|
+
nMigrate++
|
|
325
|
+
}
|
|
326
|
+
let w = { ...o }
|
|
327
|
+
keysCmp.forEach((f) => {
|
|
328
|
+
w[f] = v[f]
|
|
329
|
+
})
|
|
330
|
+
w.srcKey = v.srcKey
|
|
331
|
+
w.from = from
|
|
332
|
+
upds.push(w)
|
|
333
|
+
updPre.push({ ...o })
|
|
334
|
+
postById.set(o.id, w)
|
|
335
|
+
})
|
|
336
|
+
let matchedIds = new Set(matched.filter((m) => m.old !== null).map((m) => m.old.id))
|
|
337
|
+
let dels = sameFrom.filter((r) => !matchedIds.has(r.id))
|
|
338
|
+
|
|
339
|
+
//引用連帶(適用表 m/n, R-REF-CASCADE-01): 以「寫入後整表」=他 from 之列+同 from 存續者(含修改後)+新增者, 與寫入前整表比對——同 id 異名=改名級聯(srcKey 同、名稱變)、
|
|
340
|
+
//舊 id 不在寫入後整表=刪除而連帶清理引用(寫入後仍存續之同名者不清理). 預檢(新名撞懸空名、歷史同名組改名、容器內相撞、改寫後欄位驗證)於任何寫入之前, 被拒整批零變更.
|
|
341
|
+
//是否須改寫引用端只依本表新舊列判定(diffRefTargets), 持有本表鎖之下即可決定是否再取引用端表之鎖(R-WRITE-LOCK-02); 取齊鎖之後才讀取引用端、規劃與預檢
|
|
342
|
+
let rowsPlan = null
|
|
343
|
+
let kind = null
|
|
344
|
+
if (hasOwn(REF_OF, keyTable)) {
|
|
345
|
+
let survivors = sameFrom.filter((r) => matchedIds.has(r.id)).map((r) => postById.get(r.id) || r)
|
|
346
|
+
rowsPlan = { rowsOld: objs, rowsNew: [...others, ...survivors, ...adds] }
|
|
347
|
+
kind = diffRefTargets({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, opt: { cascadeRename } })
|
|
348
|
+
|
|
349
|
+
//外部來源鍵重編之偵測(見檔頭 syncWarns): 只適用有 name 之 grups/pemis(users 之 name 可重複, targets 之 id 即 srcKey)
|
|
350
|
+
if (keyTable === 'grups' || keyTable === 'pemis') {
|
|
351
|
+
let nRenum = countSrcKeyRenumber(sameFrom, rows)
|
|
352
|
+
setSyncWarn(keyTable, from, nRenum)
|
|
353
|
+
if (nRenum > 0) {
|
|
354
|
+
srLog.warn({ event: 'syncAndReplaceTabs-srcKeyRenumber', keyTable, from, n: nRenum })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let result = { nAdd: adds.length, nUpdate: upds.length, nDel: dels.length, nSame }
|
|
360
|
+
let info = () => srLog.info({ event: 'syncAndReplaceTabs-diff', keyTable, from, userId, ...result, nMigrate })
|
|
361
|
+
|
|
362
|
+
//無任何變更: 零寫入(冪等; 表之基準不變)
|
|
363
|
+
if (adds.length === 0 && upds.length === 0 && dels.length === 0) {
|
|
364
|
+
info()
|
|
365
|
+
return result
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
//writeMain: 刪除 → 新增 → 修改(同後台整表寫入之次序); 任一步失敗即以鎖內讀取之舊列補償還原本表(逐步冪等, 不論做到哪一步皆可無條件全做): 新增者以 id 刪除、被刪者與被改者以整列覆寫寫回(upsert, merge:false);
|
|
369
|
+
//還原失敗 → saveWriteFail + srLog; 還原成功後重新拋出原錯誤(外層轉 saveWriteFail 並記 raw)
|
|
370
|
+
let writeMain = async () => {
|
|
371
|
+
try {
|
|
372
|
+
if (dels.length > 0) {
|
|
373
|
+
await procOrm(userId, keyTable, 'del', dels, { mustExist: true })
|
|
374
|
+
}
|
|
375
|
+
if (adds.length > 0) {
|
|
376
|
+
await procOrm(userId, keyTable, 'insert', adds)
|
|
377
|
+
}
|
|
378
|
+
if (upds.length > 0) {
|
|
379
|
+
await procOrm(userId, keyTable, 'save', upds, { mustExist: true })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
let okUndo = true
|
|
384
|
+
try {
|
|
385
|
+
if (adds.length > 0) {
|
|
386
|
+
await woItems[keyTable].delAll({ id: { $in: adds.map((a) => a.id) } })
|
|
387
|
+
}
|
|
388
|
+
let back = [...dels, ...updPre].map((r) => ({ ...r }))
|
|
389
|
+
if (back.length > 0) {
|
|
390
|
+
let rs = await woItems[keyTable].save(back, { merge: false })
|
|
391
|
+
if (Array.isArray(rs) ? rs.some((v) => v && v.ok === 0) : (rs !== null && typeof rs === 'object' && rs.ok === 0)) { //逐筆結果陣列(或單一結果物件)有 ok:0 即還原失敗(外審 beam#1)
|
|
392
|
+
okUndo = false
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch (errUndo) {
|
|
397
|
+
okUndo = false
|
|
398
|
+
}
|
|
399
|
+
if (!okUndo) {
|
|
400
|
+
srLog.error({ event: 'syncAndReplaceTabs-restore-error', msg: 'saveWriteFail', keyTable, from, userId, nAdd: adds.length, nUpdate: upds.length, nDel: dels.length })
|
|
401
|
+
return Promise.reject('saveWriteFail')
|
|
402
|
+
}
|
|
403
|
+
srLog.warn({ event: 'syncAndReplaceTabs-restored', keyTable, from, userId, nAdd: adds.length, nUpdate: upds.length, nDel: dels.length })
|
|
404
|
+
return Promise.reject(err)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
//doWrite: ORM/I/O 失敗之原文只入 srLog, 外部應用收 saveWriteFail(規則帳本 R-ORM-02); 引用端先寫、失敗補償還原(cascadeWrite.mjs)
|
|
409
|
+
let doWrite = async (plan, holdersOld) => {
|
|
410
|
+
try {
|
|
411
|
+
await writeWithCascade({ plan, holdersOld, woItems, procOrm, userId, srLog, writeMain })
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
|
|
415
|
+
let key = toOutKey(err)
|
|
416
|
+
if (key === 'saveCascadePartial') {
|
|
417
|
+
key = 'saveWriteFail' //引用端補償失敗(雙重故障): 同步端點之固定錯誤契約為 saveWriteFail(規格 §5.6); 引用端懸空由 cascadeWrite 之 refCascade-restore-error 供稽核(外審 sol#3)
|
|
418
|
+
}
|
|
419
|
+
logErr(key, { raw, from })
|
|
420
|
+
return Promise.reject(key)
|
|
421
|
+
}
|
|
422
|
+
info()
|
|
423
|
+
return result
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
//有改名或刪除: 再取引用端表之鎖, 讀取引用端、規劃、預檢後寫入
|
|
427
|
+
if (kind !== null && (kind.renames.length > 0 || kind.delNames.length > 0)) {
|
|
428
|
+
let holder = REF_OF[keyTable].holder
|
|
429
|
+
return await withTabLocks(lockTabs, [holder], async () => {
|
|
430
|
+
let holdersOld = null
|
|
431
|
+
try {
|
|
432
|
+
holdersOld = await woItems[holder].select()
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
|
|
436
|
+
let key = toOutKey(err, 'anUnexpectedErrorOccurred')
|
|
437
|
+
logErr(key, { raw, from, step: 'select' })
|
|
438
|
+
return Promise.reject(key)
|
|
439
|
+
}
|
|
440
|
+
let plan = planRefChanges({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, holdersOld, opt: { cascadeRename } })
|
|
441
|
+
let errPlan = plan.err !== null ? plan.err : checkCascadePlan(plan)
|
|
442
|
+
if (errPlan !== null) {
|
|
443
|
+
let key = (errPlan === 'htmlMarkupNotAllowed') ? 'htmlMarkupNotAllowed' : 'invalidRows'
|
|
444
|
+
logErr(key, { reason: errPlan, from })
|
|
445
|
+
return Promise.reject(key)
|
|
446
|
+
}
|
|
447
|
+
return await doWrite(plan, holdersOld)
|
|
448
|
+
}, [keyTable])
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return await doWrite(null, [])
|
|
452
|
+
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
return { syncAndReplaceTabs, getSyncWarns }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
export default syncCore
|
package/server/syncKeep.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
//外部同步(
|
|
1
|
+
//外部同步(server/syncCore.mjs:syncAndReplaceTabs; perm-run03 起依 srcKey 差異同步, 本檔函數由其以「配對到之庫內 id/新配之 id」呼叫)之三個純函數(F-23; R-PRINCIPLE-00 原則 3、5; 規則帳本 R-EXT-OWN-01; ADR-037), 不依賴 ORM/Vue, 供單元測試:
|
|
2
2
|
// keepAssignOnMissing: users 列「payload 缺 cgrups 鍵」時保留庫內指派(指派欄屬後台, Lv1; 含該鍵則以外部為準)——修正前 ltdtmapping 缺鍵補 ''、delAll+insert 即清掉後台已指派之群組(run06 已知限制④);
|
|
3
3
|
// keepAssignByEntity: grups 列缺 cpemis 鍵、pemis 列缺 crules 鍵時同樣保留(run08; 同為後台可寫之指派欄)——群組/權限無登入對應鍵, 實體身分依 id(無 id 者依名稱), 見該函數說明;
|
|
4
|
-
//
|
|
4
|
+
// countSrcKeyRenumber: 同一 from 同一批內出現「同名異 srcKey」(疑似外部來源鍵重編)之筆數, 供 warn log 與頁面提示(外部來源鍵不穩定時, 來源鍵消失=刪除會每次清理引用而抹掉後台指派, 不加開關, 以提示處理; 原 countIdRenumber 之 id 口徑隨差異同步改為 srcKey)
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
let isStr = (v) => typeof v === 'string'
|
|
@@ -202,33 +202,40 @@ function keepAssignByEntity({ rows, hasKey, rowsOldSameFrom, key, cascadeRename
|
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* 同一 from 同一批內「同名異
|
|
205
|
+
* 同一 from 同一批內「同名異 srcKey」之筆數(疑似外部來源鍵重編; perm-run03 範圍二: 差異同步下身分是 srcKey, 原「同名異 id」之判定口徑改為 srcKey)
|
|
206
|
+
*
|
|
207
|
+
* 本批列之 srcKey 不屬庫內同一 from 任何列(新來源鍵), 而庫內同一 from 有另一列同名、其來源鍵不在本批(將被刪除)——外部來源若重編來源鍵(如目錄重建後 GUID 全換),
|
|
208
|
+
* 該列等於「刪舊增新」, 引用每次被連帶清理而抹掉後台指派(原則 6: 新實體不繼承). 庫內尚無 srcKey 之列(遷移前資料)以其 id 為身分候選(批次 srcKey 等於其 id 者視為同一列, 見 syncCore).
|
|
206
209
|
*
|
|
207
210
|
* @param {Array} rowsOldSameFrom 輸入庫內同一 from 之既有列(grups/pemis; users 之 name 可重複故不適用)
|
|
208
|
-
* @param {Array} rows 輸入本批列(
|
|
211
|
+
* @param {Array} rows 輸入本批列(須帶 srcKey 與 name)
|
|
209
212
|
* @returns {Number} 回傳筆數
|
|
210
213
|
*/
|
|
211
|
-
function
|
|
212
|
-
let
|
|
214
|
+
function countSrcKeyRenumber(rowsOldSameFrom, rows) {
|
|
215
|
+
let idOf = (o) => (isStr(o.srcKey) && o.srcKey !== '') ? o.srcKey : (isStr(o.id) ? o.id : null) //庫內列之身分候選: 有 srcKey 用 srcKey, 遷移前資料用 id
|
|
216
|
+
let keysBatch = new Set()
|
|
213
217
|
rows.forEach((v) => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
idsBatch.add(id)
|
|
218
|
+
if (isObj(v) && isStr(v.srcKey)) {
|
|
219
|
+
keysBatch.add(v.srcKey)
|
|
217
220
|
}
|
|
218
221
|
})
|
|
219
|
-
let
|
|
222
|
+
let keysOld = new Set()
|
|
223
|
+
let goneByName = new Set() //將被刪除之庫內列之名稱(其身分不在本批)
|
|
220
224
|
rowsOldSameFrom.forEach((o) => {
|
|
221
|
-
if (isObj(o)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
225
|
+
if (!isObj(o)) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
let k = idOf(o)
|
|
229
|
+
if (k !== null) {
|
|
230
|
+
keysOld.add(k)
|
|
231
|
+
}
|
|
232
|
+
if (k !== null && !keysBatch.has(k) && isStr(o.name)) {
|
|
233
|
+
goneByName.add(o.name)
|
|
226
234
|
}
|
|
227
235
|
})
|
|
228
236
|
let n = 0
|
|
229
237
|
rows.forEach((v) => {
|
|
230
|
-
|
|
231
|
-
if (id !== null && isObj(v) && isStr(v.name) && oldByName.has(v.name)) {
|
|
238
|
+
if (isObj(v) && isStr(v.srcKey) && !keysOld.has(v.srcKey) && isStr(v.name) && goneByName.has(v.name)) {
|
|
232
239
|
n++
|
|
233
240
|
}
|
|
234
241
|
})
|
|
@@ -236,4 +243,4 @@ function countIdRenumber(rowsOldSameFrom, rows) {
|
|
|
236
243
|
}
|
|
237
244
|
|
|
238
245
|
|
|
239
|
-
export { markHasKey, keepAssignOnMissing, keepAssignByEntity,
|
|
246
|
+
export { markHasKey, keepAssignOnMissing, keepAssignByEntity, countSrcKeyRenumber }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// node server/tools/nameDupReport.mjs --audit <匯出檔.json> [--mapping-by id|email|name] [--json] (同上; 與 authMergeReport.mjs --audit 寫法一致)
|
|
6
6
|
//
|
|
7
7
|
// 匯出檔內容 { users, grups, pemis, targets }(各為列陣列, 如以資料通道 select 之結果或資料庫匯出)。
|
|
8
|
-
// --mapping-by 省略時 email 與 name 皆報告(部署之 mappingBy 設定不在資料庫內); 給 id
|
|
8
|
+
// --mapping-by 省略時 email 與 name 皆報告(部署之 mappingBy 設定不在資料庫內); 給 id 則查 id 缺漏與「登入識別撞值」(perm-run05; 見下方報告內容 2).
|
|
9
9
|
// --json 輸出結構化結果(供其他工具), 省略則輸出文字報告.
|
|
10
10
|
//
|
|
11
11
|
//報告內容:
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// 已知邊界(增量外審 mmd-N8, 未處理): 陣列形式之引用元素缺 name 時, 伺服端 arrFinder 以 get(vv,'name','') 取得 ''(可對應名稱為 '' 之列), 本工具解碼後名稱為索引數字, 不計入該引用;
|
|
15
15
|
// 須同時存在「名稱為空字串之群組/權限」與「陣列形式且缺 name 之引用」, 空名稱者已另由 authMergeReport --audit 列出, 請一併人工檢視.
|
|
16
16
|
// 2. users 對應鍵: isActive==='y' 之列, 對應鍵(email/name/id)為空/空白/非字串者, 以及值重複者(精確 ===; 與 getTokenUser 之 select 一致), 附各列 id/name/from.
|
|
17
|
+
// mappingBy 為 id 時(perm-run05, 規則帳本 R-MAPKEY-05): 登入識別值=後台列(from 空)之 id、外部列(from 非空)之 srcKey(遷移前無 srcKey 之舊外部列為其 id);
|
|
18
|
+
// 列出每一組「同一登入識別值有 ≥ 2 列」者(登入時命中多筆即一律拒絕 ambiguousUserFromPerm), **不論 isActive、含他來源與同一來源**; 每組標 scope:
|
|
19
|
+
// crossSource=匯入/後台存檔會擋之撞值組合(後台列↔外部列、他來源外部列↔外部列、同來源一有 srcKey 一為舊形之 claimTwice 形; 判準 keyCheck.findLoginIdClashDetail),
|
|
20
|
+
// sameSource=同一來源之重複(皆有 srcKey 之 srcKeyDuplicate/srcKeyConflict 形, 或同主鍵), 同步端本就拒絕, 需於來源端或以 g_purgeFrom.mjs 清理.
|
|
21
|
+
// 各組附各列 id/name/from/isActive. 升版後這些歷史撞值不阻擋後台存檔(才可修復), 但登入一律被拒——請依本報告在後台刪除或改用可唯一對應之鍵、並請外部來源改用不撞值之 srcKey
|
|
22
|
+
// (同時須讓部署方之 getUserByToken 回報值隨之改變, 見 README Upgrade Notes 第 4 節), 處理後重跑確認為 0 組.
|
|
17
23
|
//
|
|
18
24
|
//【清理須知】引用以「名稱」比對, 改名級聯(F-07)尚未實作: 同名組中改名或刪除其一, 引用該名稱者會全部指向剩下的那一筆或失效.
|
|
19
25
|
// 請先以本報告列出引用者, 清理後逐一確認授權(getPermUserInfor/getPerm 之結果), 再處理下一組.
|
|
@@ -23,6 +29,7 @@ import fs from 'fs'
|
|
|
23
29
|
import isMain from './isMain.mjs'
|
|
24
30
|
import JSON5 from 'json5'
|
|
25
31
|
import cvKpToArr from '../../src/js/cvKpToArr.mjs'
|
|
32
|
+
import { loginIdOf, findLoginIdClashDetail } from '../keyCheck.mjs'
|
|
26
33
|
|
|
27
34
|
|
|
28
35
|
let isStr = (v) => typeof v === 'string'
|
|
@@ -104,6 +111,27 @@ function mapKeyIssues(users, key) {
|
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
|
|
114
|
+
//loginIdDups: mappingBy='id' 之登入識別撞值組(perm-run05): 依 loginIdOf 分組, 凡同一登入識別值有 ≥ 2 列者皆列出(不論 isActive、含他來源與同一來源——登入時命中多筆即一律拒絕, 故稽核須與登入同口徑,
|
|
115
|
+
// 外審 sol 第一輪#5: 同來源重複〔其一停用〕亦使該使用者不能登入、來源同步回 srcKeyConflict); scope 標示: 'crossSource'=匯入/後台存檔會擋之撞值組合(findLoginIdClashDetail, 判準單一擁有者在 keyCheck.mjs),
|
|
116
|
+
// 'sameSource'=同一來源之重複(皆有 srcKey 之 srcKeyDuplicate/srcKeyConflict 形,或同主鍵), 需在來源端或以 g_purgeFrom.mjs 清理
|
|
117
|
+
function loginIdDups(users) {
|
|
118
|
+
let kp = new Map()
|
|
119
|
+
for (let u of arr(users)) {
|
|
120
|
+
let v = loginIdOf(u)
|
|
121
|
+
if (v === '') {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (!kp.has(v)) {
|
|
125
|
+
kp.set(v, [])
|
|
126
|
+
}
|
|
127
|
+
kp.get(v).push(u)
|
|
128
|
+
}
|
|
129
|
+
return [...kp]
|
|
130
|
+
.filter(([, rs]) => rs.length >= 2)
|
|
131
|
+
.map(([value, rs]) => ({ value, scope: (findLoginIdClashDetail(rs, () => true) !== null) ? 'crossSource' : 'sameSource', rows: rs.map((u) => ({ id: u.id, name: u.name, from: u.from, isActive: u.isActive })) }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
107
135
|
/**
|
|
108
136
|
* 建立稽核結果(純函數, 不讀寫檔案)
|
|
109
137
|
*
|
|
@@ -138,7 +166,7 @@ function buildReport(data, opt = {}) {
|
|
|
138
166
|
let mapKey = {}
|
|
139
167
|
for (let key of keys) {
|
|
140
168
|
mapKey[key] = (key === 'id')
|
|
141
|
-
? { empty: arr(d.users).filter((u) => u && u.isActive === 'y' && !(isStr(u.id) && u.id !== '')).map((u) => ({ id: u.id, name: u.name, from: u.from })), dup:
|
|
169
|
+
? { empty: arr(d.users).filter((u) => u && u.isActive === 'y' && !(isStr(u.id) && u.id !== '')).map((u) => ({ id: u.id, name: u.name, from: u.from })), dup: loginIdDups(d.users) }
|
|
142
170
|
: mapKeyIssues(d.users, key)
|
|
143
171
|
}
|
|
144
172
|
//壞 JSON 去重(同一列被多個重複名稱之解碼重複記錄)
|
|
@@ -174,11 +202,16 @@ function formatReport(rp) {
|
|
|
174
202
|
}
|
|
175
203
|
for (let [key, v] of Object.entries(rp.mapKey)) {
|
|
176
204
|
L.push('')
|
|
177
|
-
|
|
205
|
+
if (key === 'id') {
|
|
206
|
+
L.push(`== users 對應鍵 id(後台列=id、外部列=srcKey; 登入識別撞值不論 isActive、含他來源): 無 id 之有效列 ${v.empty.length} 筆, 撞值 ${v.dup.length} 組 ==`)
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
L.push(`== users 對應鍵 ${key}(僅 isActive='y'): 空/空白/非字串 ${v.empty.length} 筆, 值重複 ${v.dup.length} 組 ==`)
|
|
210
|
+
}
|
|
178
211
|
v.empty.forEach((u) => L.push(`- 空: id=${u.id} name=${JSON.stringify(u.name)} from=${JSON.stringify(u.from)}`))
|
|
179
212
|
for (let g of v.dup) {
|
|
180
|
-
L.push(`-
|
|
181
|
-
g.rows.forEach((u) => L.push(` id=${u.id} name=${JSON.stringify(u.name)} from=${JSON.stringify(u.from)}`))
|
|
213
|
+
L.push(`- ${key === 'id' ? (g.scope === 'sameSource' ? '同來源重複' : '跨來源撞值') : '重複'}「${g.value}」${g.rows.length} 筆`)
|
|
214
|
+
g.rows.forEach((u) => L.push(` id=${u.id} name=${JSON.stringify(u.name)} from=${JSON.stringify(u.from)}${('isActive' in u) ? ` isActive=${JSON.stringify(u.isActive)}` : ''}`))
|
|
182
215
|
}
|
|
183
216
|
}
|
|
184
217
|
L.push('')
|