gis-view-webgl2 0.1.0 → 0.1.2

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.
Files changed (87) hide show
  1. package/README.md +40 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1036 -249
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
  7. package/docs/GisView.md +9 -8
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +172 -0
  10. package/docs/api/index.html +545 -59
  11. package/docs/demos/BackendSyncDemo.vue +10 -8
  12. package/docs/demos/BasicDemo.vue +101 -101
  13. package/docs/demos/BatchAddDemo.vue +5 -5
  14. package/docs/demos/ChannelDemo.vue +617 -10
  15. package/docs/demos/ConnectDemo.vue +392 -0
  16. package/docs/demos/CopyMergeDemo.vue +980 -929
  17. package/docs/demos/DecorationDraftDemo.vue +1300 -0
  18. package/docs/demos/DemoShell.vue +569 -240
  19. package/docs/demos/DragDropDemo.vue +11 -11
  20. package/docs/demos/EffectsDemo.vue +6 -6
  21. package/docs/demos/EventDemo.vue +327 -327
  22. package/docs/demos/ExtensionDemo.vue +458 -0
  23. package/docs/demos/GisViewDemo.vue +61 -48
  24. package/docs/demos/GraphicDemo.vue +338 -0
  25. package/docs/demos/LabelDemo.vue +553 -497
  26. package/docs/demos/MinimapDemo.vue +358 -0
  27. package/docs/demos/PerfDemo.vue +29 -0
  28. package/docs/demos/SearchFilterDemo.vue +412 -0
  29. package/docs/demos/StyleDemo.vue +121 -121
  30. package/docs/demos/api-index.js +98 -77
  31. package/docs/demos/demos.js +159 -135
  32. package/docs/demos/shared/demoData.js +107 -107
  33. package/docs/demos/shared/demoStyle.css +249 -249
  34. package/docs/demos/shared/fakeBackend.js +93 -6
  35. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +28 -8
  36. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  37. package/docs/format/06-/345/257/274/345/205/245/345/257/274/345/207/272/344/270/216/346/240/207/345/207/206/346/226/207/346/241/243.md +8 -6
  38. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +6 -6
  39. package/docs/guide/sample-document.html +4 -4
  40. package/docs/index.html +180 -9
  41. package/docs/internals/11-/350/246/206/347/233/226/345/261/202/346/240/207/347/255/276/344/270/216/346/217/220/347/244/272.md +1 -1
  42. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
  43. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +16 -0
  44. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +11 -0
  45. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  46. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
  47. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
  48. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
  49. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +111 -10
  50. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +47 -9
  51. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
  52. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +61 -11
  53. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +74 -7
  54. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
  55. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
  56. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
  57. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
  58. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
  59. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +31 -29
  60. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
  61. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
  62. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +4 -4
  63. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +42 -2
  64. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +22 -6
  65. package/docs/playground/index.html +5 -5
  66. package/docs/steps/README.md +14 -10
  67. package/docs/steps/check-doc-truth.mjs +1 -1
  68. package/docs/steps/selftest-actions.html +10 -10
  69. package/docs/steps/selftest-data.html +96 -14
  70. package/docs/steps/selftest-decoration.html +577 -0
  71. package/docs/steps/selftest-demo.html +6 -5
  72. package/docs/steps/selftest-demos.html +2201 -1612
  73. package/docs/steps/selftest-docs.html +89 -16
  74. package/docs/steps/selftest-effects.html +5 -5
  75. package/docs/steps/selftest-features.html +757 -86
  76. package/docs/steps/selftest-full.html +583 -583
  77. package/docs/steps/selftest-labels.html +1034 -652
  78. package/docs/steps/selftest-perf.html +1899 -1844
  79. package/docs/steps/selftest-scale.html +117 -7
  80. package/docs/steps/selftest-select.html +24 -17
  81. package/docs/steps/step-10-full.js +1 -1
  82. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
  83. package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
  84. package/package.json +43 -43
  85. package/docs/demos/HistoryDemo.vue +0 -230
  86. package/docs/demos/InterfaceDemo.vue +0 -309
  87. package/docs/demos/SampleDemo.vue +0 -283
@@ -1,250 +1,250 @@
1
- /**
2
- * 几个功能演示共用的外观:浅色卡片 + 深色画布 + 右侧读数栏。
3
- * 类名统一 dv- 前缀,和 GisViewDemo.vue 里那套 demo-* 互不干扰
4
- * (综合示例保持原样,功能演示各自短小,共用这一份样式不必各写一遍)。
5
- */
6
- .dv-demo {
7
- display: grid;
8
- grid-template-columns: minmax(0, 1fr) clamp(272px, 23vw, 380px);
9
- gap: 14px;
10
- align-items: start;
11
- }
12
-
13
- .dv-stage {
14
- position: relative;
15
- height: clamp(460px, calc(100vh - 320px), 860px);
16
- border: 1px solid #e2e9f2;
17
- border-radius: 10px;
18
- overflow: hidden;
19
- background: #f7fafd;
20
- }
21
-
22
- .dv-canvas {
23
- height: 100%;
24
- }
25
-
26
- /* 画布右上角的悬浮工具条:全屏 / 适合全部这类「不动数据」的操作 */
27
- .dv-toolbar {
28
- position: absolute;
29
- top: 10px;
30
- right: 10px;
31
- z-index: 3;
32
- display: flex;
33
- gap: 6px;
34
- padding: 5px;
35
- border-radius: 8px;
36
- background: rgba(255, 255, 255, 0.86);
37
- box-shadow: 0 4px 16px rgba(31, 62, 105, 0.14);
38
- backdrop-filter: blur(6px);
39
- }
40
-
41
- .dv-toolbar button {
42
- padding: 5px 10px;
43
- font-size: 12px;
44
- }
45
-
46
- .dv-panel {
47
- display: flex;
48
- flex-direction: column;
49
- gap: 10px;
50
- max-height: clamp(460px, calc(100vh - 320px), 860px);
51
- overflow: auto;
52
- padding-right: 4px;
53
- }
54
-
55
- .dv-group {
56
- padding: 12px;
57
- border: 1px solid #e2e9f2;
58
- border-radius: 10px;
59
- background: #fff;
60
- box-shadow: 0 8px 24px rgba(31, 62, 105, 0.05);
61
- }
62
-
63
- .dv-group > h3 {
64
- display: flex;
65
- align-items: center;
66
- justify-content: space-between;
67
- gap: 8px;
68
- margin: 0 0 10px;
69
- color: #17233d;
70
- font-size: 14px;
71
- }
72
-
73
- .dv-row {
74
- display: flex;
75
- align-items: center;
76
- flex-wrap: wrap;
77
- gap: 8px;
78
- }
79
-
80
- .dv-row + .dv-row {
81
- margin-top: 8px;
82
- }
83
-
84
- .dv-row label {
85
- display: inline-flex;
86
- align-items: center;
87
- gap: 5px;
88
- color: #526b8d;
89
- font-size: 12px;
90
- cursor: pointer;
91
- }
92
-
93
- .dv-row select {
94
- padding: 4px 6px;
95
- border: 1px solid #d6e0ec;
96
- border-radius: 6px;
97
- background: #fff;
98
- color: #1f2a44;
99
- font-size: 12px;
100
- }
101
-
102
- .dv-row input[type='range'] {
103
- width: 118px;
104
- }
105
-
106
- .dv-button {
107
- padding: 6px 10px;
108
- border: 1px solid #cfdcec;
109
- border-radius: 6px;
110
- background: #f6f9fd;
111
- color: #2f5e94;
112
- font-size: 12px;
113
- cursor: pointer;
114
- }
115
-
116
- .dv-button:hover {
117
- background: #e9f1fb;
118
- }
119
-
120
- .dv-button--primary {
121
- border-color: #3678bf;
122
- background: #3678bf;
123
- color: #fff;
124
- }
125
-
126
- .dv-button--primary:hover {
127
- background: #28629e;
128
- }
129
-
130
- .dv-button--danger {
131
- border-color: #e7bcbc;
132
- background: #fdf4f4;
133
- color: #b93b3b;
134
- }
135
-
136
- .dv-button:disabled {
137
- opacity: 0.55;
138
- cursor: not-allowed;
139
- }
140
-
141
- .dv-readout {
142
- margin: 0;
143
- padding: 8px 10px;
144
- border-radius: 8px;
145
- background: #f3f6fa;
146
- color: #526b8d;
147
- font-size: 12px;
148
- line-height: 1.6;
149
- word-break: break-all;
150
- }
151
-
152
- .dv-hint {
153
- margin: 6px 0 0;
154
- color: #8191a8;
155
- font-size: 11px;
156
- line-height: 1.6;
157
- }
158
-
159
- .dv-log {
160
- max-height: 220px;
161
- margin: 0;
162
- padding: 0;
163
- overflow: auto;
164
- list-style: none;
165
- }
166
-
167
- .dv-log li {
168
- padding: 5px 8px;
169
- border-bottom: 1px dashed #e6edf6;
170
- color: #526b8d;
171
- font-size: 12px;
172
- line-height: 1.5;
173
- }
174
-
175
- .dv-log li:last-child {
176
- border-bottom: 0;
177
- }
178
-
179
- .dv-log strong {
180
- color: #2f5e94;
181
- font-weight: 600;
182
- }
183
-
184
- .dv-chips {
185
- display: flex;
186
- flex-wrap: wrap;
187
- gap: 5px;
188
- }
189
-
190
- .dv-chip {
191
- padding: 3px 8px;
192
- border: 1px solid #d6e0ec;
193
- border-radius: 999px;
194
- background: #fff;
195
- color: #526b8d;
196
- font-size: 11px;
197
- cursor: pointer;
198
- }
199
-
200
- .dv-chip--active {
201
- border-color: #3678bf;
202
- background: #eaf2fc;
203
- color: #2f5e94;
204
- font-weight: 600;
205
- }
206
-
207
- /* 标签右键菜单:贴在指针位置的自绘菜单(演示 labelContextMenu 的用法) */
208
- .dv-menu {
209
- position: fixed;
210
- z-index: 60;
211
- min-width: 168px;
212
- padding: 5px;
213
- border: 1px solid #d6e0ec;
214
- border-radius: 8px;
215
- background: #fff;
216
- box-shadow: 0 12px 32px rgba(31, 62, 105, 0.18);
217
- }
218
-
219
- .dv-menu button {
220
- display: block;
221
- width: 100%;
222
- padding: 7px 10px;
223
- border: 0;
224
- border-radius: 6px;
225
- background: transparent;
226
- color: #1f2a44;
227
- font-size: 12px;
228
- text-align: left;
229
- cursor: pointer;
230
- }
231
-
232
- .dv-menu button:hover {
233
- background: #eef4fc;
234
- }
235
-
236
- .dv-menu__title {
237
- padding: 6px 10px 4px;
238
- color: #8191a8;
239
- font-size: 11px;
240
- }
241
-
242
- @media (max-width: 1080px) {
243
- .dv-demo {
244
- grid-template-columns: minmax(0, 1fr);
245
- }
246
-
247
- .dv-panel {
248
- max-height: none;
249
- }
1
+ /**
2
+ * 几个功能演示共用的外观:浅色卡片 + 深色画布 + 右侧读数栏。
3
+ * 类名统一 dv- 前缀,和 GisViewDemo.vue 里那套 demo-* 互不干扰
4
+ * (综合示例保持原样,功能演示各自短小,共用这一份样式不必各写一遍)。
5
+ */
6
+ .dv-demo {
7
+ display: grid;
8
+ grid-template-columns: minmax(0, 1fr) clamp(272px, 23vw, 380px);
9
+ gap: 14px;
10
+ align-items: start;
11
+ }
12
+
13
+ .dv-stage {
14
+ position: relative;
15
+ height: clamp(460px, calc(100vh - 320px), 860px);
16
+ border: 1px solid #e2e9f2;
17
+ border-radius: 10px;
18
+ overflow: hidden;
19
+ background: #f7fafd;
20
+ }
21
+
22
+ .dv-canvas {
23
+ height: 100%;
24
+ }
25
+
26
+ /* 画布右上角的悬浮工具条:全屏 / 适合全部这类「不动数据」的操作 */
27
+ .dv-toolbar {
28
+ position: absolute;
29
+ top: 10px;
30
+ right: 10px;
31
+ z-index: 3;
32
+ display: flex;
33
+ gap: 6px;
34
+ padding: 5px;
35
+ border-radius: 8px;
36
+ background: rgba(255, 255, 255, 0.86);
37
+ box-shadow: 0 4px 16px rgba(31, 62, 105, 0.14);
38
+ backdrop-filter: blur(6px);
39
+ }
40
+
41
+ .dv-toolbar button {
42
+ padding: 5px 10px;
43
+ font-size: 12px;
44
+ }
45
+
46
+ .dv-panel {
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: 10px;
50
+ max-height: clamp(460px, calc(100vh - 320px), 860px);
51
+ overflow: auto;
52
+ padding-right: 4px;
53
+ }
54
+
55
+ .dv-group {
56
+ padding: 12px;
57
+ border: 1px solid #e2e9f2;
58
+ border-radius: 10px;
59
+ background: #fff;
60
+ box-shadow: 0 8px 24px rgba(31, 62, 105, 0.05);
61
+ }
62
+
63
+ .dv-group > h3 {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ gap: 8px;
68
+ margin: 0 0 10px;
69
+ color: #17233d;
70
+ font-size: 14px;
71
+ }
72
+
73
+ .dv-row {
74
+ display: flex;
75
+ align-items: center;
76
+ flex-wrap: wrap;
77
+ gap: 8px;
78
+ }
79
+
80
+ .dv-row + .dv-row {
81
+ margin-top: 8px;
82
+ }
83
+
84
+ .dv-row label {
85
+ display: inline-flex;
86
+ align-items: center;
87
+ gap: 5px;
88
+ color: #526b8d;
89
+ font-size: 12px;
90
+ cursor: pointer;
91
+ }
92
+
93
+ .dv-row select {
94
+ padding: 4px 6px;
95
+ border: 1px solid #d6e0ec;
96
+ border-radius: 6px;
97
+ background: #fff;
98
+ color: #1f2a44;
99
+ font-size: 12px;
100
+ }
101
+
102
+ .dv-row input[type='range'] {
103
+ width: 118px;
104
+ }
105
+
106
+ .dv-button {
107
+ padding: 6px 10px;
108
+ border: 1px solid #cfdcec;
109
+ border-radius: 6px;
110
+ background: #f6f9fd;
111
+ color: #2f5e94;
112
+ font-size: 12px;
113
+ cursor: pointer;
114
+ }
115
+
116
+ .dv-button:hover {
117
+ background: #e9f1fb;
118
+ }
119
+
120
+ .dv-button--primary {
121
+ border-color: #3678bf;
122
+ background: #3678bf;
123
+ color: #fff;
124
+ }
125
+
126
+ .dv-button--primary:hover {
127
+ background: #28629e;
128
+ }
129
+
130
+ .dv-button--danger {
131
+ border-color: #e7bcbc;
132
+ background: #fdf4f4;
133
+ color: #b93b3b;
134
+ }
135
+
136
+ .dv-button:disabled {
137
+ opacity: 0.55;
138
+ cursor: not-allowed;
139
+ }
140
+
141
+ .dv-readout {
142
+ margin: 0;
143
+ padding: 8px 10px;
144
+ border-radius: 8px;
145
+ background: #f3f6fa;
146
+ color: #526b8d;
147
+ font-size: 12px;
148
+ line-height: 1.6;
149
+ word-break: break-all;
150
+ }
151
+
152
+ .dv-hint {
153
+ margin: 6px 0 0;
154
+ color: #8191a8;
155
+ font-size: 11px;
156
+ line-height: 1.6;
157
+ }
158
+
159
+ .dv-log {
160
+ max-height: 220px;
161
+ margin: 0;
162
+ padding: 0;
163
+ overflow: auto;
164
+ list-style: none;
165
+ }
166
+
167
+ .dv-log li {
168
+ padding: 5px 8px;
169
+ border-bottom: 1px dashed #e6edf6;
170
+ color: #526b8d;
171
+ font-size: 12px;
172
+ line-height: 1.5;
173
+ }
174
+
175
+ .dv-log li:last-child {
176
+ border-bottom: 0;
177
+ }
178
+
179
+ .dv-log strong {
180
+ color: #2f5e94;
181
+ font-weight: 600;
182
+ }
183
+
184
+ .dv-chips {
185
+ display: flex;
186
+ flex-wrap: wrap;
187
+ gap: 5px;
188
+ }
189
+
190
+ .dv-chip {
191
+ padding: 3px 8px;
192
+ border: 1px solid #d6e0ec;
193
+ border-radius: 999px;
194
+ background: #fff;
195
+ color: #526b8d;
196
+ font-size: 11px;
197
+ cursor: pointer;
198
+ }
199
+
200
+ .dv-chip--active {
201
+ border-color: #3678bf;
202
+ background: #eaf2fc;
203
+ color: #2f5e94;
204
+ font-weight: 600;
205
+ }
206
+
207
+ /* 标签右键菜单:贴在指针位置的自绘菜单(演示 labelContextMenu 的用法) */
208
+ .dv-menu {
209
+ position: fixed;
210
+ z-index: 60;
211
+ min-width: 168px;
212
+ padding: 5px;
213
+ border: 1px solid #d6e0ec;
214
+ border-radius: 8px;
215
+ background: #fff;
216
+ box-shadow: 0 12px 32px rgba(31, 62, 105, 0.18);
217
+ }
218
+
219
+ .dv-menu button {
220
+ display: block;
221
+ width: 100%;
222
+ padding: 7px 10px;
223
+ border: 0;
224
+ border-radius: 6px;
225
+ background: transparent;
226
+ color: #1f2a44;
227
+ font-size: 12px;
228
+ text-align: left;
229
+ cursor: pointer;
230
+ }
231
+
232
+ .dv-menu button:hover {
233
+ background: #eef4fc;
234
+ }
235
+
236
+ .dv-menu__title {
237
+ padding: 6px 10px 4px;
238
+ color: #8191a8;
239
+ font-size: 11px;
240
+ }
241
+
242
+ @media (max-width: 1080px) {
243
+ .dv-demo {
244
+ grid-template-columns: minmax(0, 1fr);
245
+ }
246
+
247
+ .dv-panel {
248
+ max-height: none;
249
+ }
250
250
  }
@@ -49,6 +49,21 @@ const uniqueIds = values => [...new Set((values ?? [])
49
49
  .map(value => String(value)))]
50
50
  const idsOf = values => (values ?? []).map(item => item?.id).filter(id => id !== undefined && id !== null)
51
51
  const groupLineIds = groups => (groups ?? []).flatMap(group => (group?.lineIds ?? []).map(id => String(id)))
52
+ /** 与字段顺序无关的 JSON:同一个 opId 换一个 body 要能被服务端识别成 IDEMPOTENCY_CONFLICT */
53
+ const canonicalJson = (value) => {
54
+ if (Array.isArray(value)) {
55
+ return `[${value.map(entry => canonicalJson(entry)).join(',')}]`
56
+ }
57
+ if (value && typeof value === 'object') {
58
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
59
+ }
60
+ return JSON.stringify(value ?? null)
61
+ }
62
+ const requestFingerprint = request => canonicalJson({
63
+ method: request?.method ?? 'POST',
64
+ url: request?.url ?? '',
65
+ body: request?.body ?? {}
66
+ })
52
67
 
53
68
  /** 从 URL 判断业务操作;URL 是接口边界,body.op 只作为展示 / 审计字段 */
54
69
  const operationOf = (request) => {
@@ -83,7 +98,7 @@ export function createFakeBackend(options = {}) {
83
98
  let requestSeq = 0
84
99
  let attemptSeq = 0
85
100
  let replayCount = 0
86
- /** opId → 第一次成功响应:重复提交直接复用,不再改版本 */
101
+ /** opId → { response, fingerprint }:重复提交复用第一次响应,但换了 body 要报幂等冲突 */
87
102
  const completed = new Map()
88
103
  /** 最近一次成功写入的资源快照:409 时可以随错误包一起回给前端 */
89
104
  const resources = new Map()
@@ -319,7 +334,13 @@ export function createFakeBackend(options = {}) {
319
334
  scope: 'document',
320
335
  resolution: 'refetch-latest-or-rebase'
321
336
  }
322
- : null,
337
+ : code === 'IDEMPOTENCY_CONFLICT'
338
+ ? {
339
+ type: 'idempotency-key',
340
+ scope: 'opId',
341
+ resolution: 'use-new-opid-for-new-request'
342
+ }
343
+ : null,
323
344
  current: currentResourceOf(request, operation)
324
345
  }
325
346
  })
@@ -337,11 +358,77 @@ export function createFakeBackend(options = {}) {
337
358
  const requestId = request?.requestId ?? request?.id ?? `req-${prefix}-${pad(requestSeq)}`
338
359
  const serverTime = new Date().toISOString()
339
360
  const operation = operationOf(request)
340
- const opId = request?.opId ?? request?.body?.opId ?? request?.headers?.['Idempotency-Key']
361
+ const headerOpId = request?.headers?.['Idempotency-Key']
362
+ const bodyOpId = request?.body?.opId ?? request?.opId
363
+ const ifMatch = request?.headers?.['If-Match']
364
+ const opId = bodyOpId ?? headerOpId
341
365
  const baseVersion = Number(request?.body?.baseVersion ?? request?.baseVersion)
342
- const cached = opId ? completed.get(opId) : null
343
- if (cached) {
366
+ const fingerprint = requestFingerprint(request)
367
+ /** 统一组一笔校验失败响应:400 / 409 都走同一个错误包 */
368
+ const fail = ({ code, status, message, retryable = false }) => {
369
+ const body = errorBody({
370
+ request,
371
+ operation,
372
+ code,
373
+ message,
374
+ requestId,
375
+ traceId,
376
+ serverTime,
377
+ currentVersion: version,
378
+ expectedVersion: baseVersion,
379
+ retryable
380
+ })
381
+ return {
382
+ ok: false,
383
+ status,
384
+ statusText: statusText(status),
385
+ code,
386
+ reason: message,
387
+ retryable,
388
+ idempotent: false,
389
+ serverVersion: version,
390
+ expectedVersion: Number.isFinite(baseVersion) ? baseVersion : null,
391
+ currentVersion: version,
392
+ requestId,
393
+ traceId,
394
+ request,
395
+ body,
396
+ data: body.data
397
+ }
398
+ }
399
+ // 真实网关会先校验头与 body 一致性;服务端不能猜以哪一个为准。
400
+ if (bodyOpId === undefined || bodyOpId === null || bodyOpId === '' || String(headerOpId ?? '') !== String(bodyOpId)) {
401
+ return fail({
402
+ code: 'BAD_REQUEST',
403
+ status: 400,
404
+ message: 'Idempotency-Key 必须存在且与 body.opId 完全一致'
405
+ })
406
+ }
407
+ if (!Number.isFinite(baseVersion)) {
408
+ return fail({
409
+ code: 'BAD_REQUEST',
410
+ status: 400,
411
+ message: 'body.baseVersion 必须是有限数字'
412
+ })
413
+ }
414
+ if (String(ifMatch ?? '') !== `"v${baseVersion}"`) {
415
+ return fail({
416
+ code: 'BAD_REQUEST',
417
+ status: 400,
418
+ message: `If-Match 必须写成 "v${baseVersion}"(与 body.baseVersion 一致)`
419
+ })
420
+ }
421
+ const cachedEntry = opId ? completed.get(opId) : null
422
+ if (cachedEntry && cachedEntry.fingerprint !== fingerprint) {
423
+ return fail({
424
+ code: 'IDEMPOTENCY_CONFLICT',
425
+ status: 409,
426
+ message: '同一个 opId 被用于不同请求体:新动作请生成新的 opId'
427
+ })
428
+ }
429
+ if (cachedEntry) {
344
430
  replayCount += 1
431
+ const cached = cachedEntry.response
345
432
  const body = cloneFakeData(cached.body)
346
433
  body.requestId = requestId
347
434
  body.traceId = traceId
@@ -486,7 +573,7 @@ export function createFakeBackend(options = {}) {
486
573
  data: body.data
487
574
  }
488
575
  if (opId) {
489
- completed.set(opId, response)
576
+ completed.set(opId, { response, fingerprint })
490
577
  }
491
578
  return response
492
579
  }