@microi.net/cli 5.3.9 → 5.4.1

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.
@@ -7,7 +7,7 @@
7
7
  | 源码 | 协议/安全原语测试,静态扫描无 Secret/Token 日志 |
8
8
  | 后端构建 | 隔离输出 `dotnet build` 成功,不覆盖共享运行目录 |
9
9
  | 前端构建 | 现代包和项目要求的兼容包完成 |
10
- | 应用包 | 可重复生成;Name/AppId/Version、DDL、字段数、菜单、空数据集、11 个接口引擎源码同源与 ResourcePolicies 校验 |
10
+ | 应用包 | 可重复生成;Name/AppId/Version、DDL、字段数、菜单、空数据集、35 个接口引擎源码同源与 ResourcePolicies 校验 |
11
11
  | 官方商城 | `microi_itdos` 发布后回读 `sys_microistore` 与官方资源 API;状态、版本、包哈希一致 |
12
12
  | 目标租户 | 安装/升级任务终态 `Succeeded`,再读真实表、字段、菜单、安装版本 |
13
13
  | 运行时 | Capabilities、Discovery/Metadata/JWKS 和实际使用端点命中已部署程序集 |
@@ -19,9 +19,9 @@
19
19
 
20
20
  1. 使用官方 `microi_itdos`,核对绑定 `https://api.itdos.com` 与 `OsClient=iTdos`。
21
21
  2. 在发布源更新真实 `diy_sso`、字段、表单 Tabs 和菜单视图并回读。
22
- 3. 导出精确菜单/表和 11 个接口引擎,不带连接数据、用户绑定、Secret、证书或示例账号;接口源码必须与 `Microi-V8-Engine/.../SSO身份联邦` 逐字同源。
23
- 4. AppId 固定 `app.microi.sso`,PublisherType 为官方应用;10 个核心 Key 为 `Managed/Application`,`sso_event_hook` 为 `CreateIfMissing/Tenant`。
24
- 5. 发布后从官方资源 API 读取 `app.microi.sso.json`,校验 SHA-256、版本、1 菜单、1 表、64 字段、11 个接口引擎及策略。
22
+ 3. 导出精确菜单/表和 35 个接口引擎,不带连接数据、用户绑定、Secret、证书或示例账号;接口源码必须与 `Microi-V8-Engine/.../SSO身份联邦` 逐字同源。
23
+ 4. AppId 固定 `app.microi.sso`,PublisherType 为官方应用;34 个核心 Key 为 `Managed/Platform`,`sso_event_hook` 为 `CreateIfMissing/Tenant`。
24
+ 5. 发布后从官方资源 API 读取 `app.microi.sso.json`,校验 SHA-256、版本、1 菜单、1 表、64 字段、35 个接口引擎及策略。
25
25
  6. 官方 iTdos 是发布源,安装任务被“发布源不允许安装”拒绝是保护性终态;不要绕过或伪装成成功。
26
26
  7. 普通目标租户才执行安装/更新,并轮询后台任务到终态。Pending/Running/入队不算成功。
27
27
 
@@ -45,5 +45,5 @@
45
45
 
46
46
  - 客户端能力发现和登录完成只允许调用 `/apiengine/{ApiEngineKey}?OsClient=`;固定业务不得新增 `/api/ApiEngine/Run` 依赖。
47
47
  - 在未安装 `app.microi.sso` 的租户调用通用入口,应返回结构化“接口引擎不存在”;不能返回 `/api/Sso/Capabilities` 路由 404。
48
- - 安装后回读 11 个 `sys_apiengine` 行并刷新缓存,再验证 `sso_capabilities` 为 `Code=1`。
49
- - `/api/Sso/Capabilities`、`LegacyCapabilities`、`CompleteLogin`、`RotateClientSecret` 与 `/api/SysUser/SsoPengrui` 必须保持删除,防止业务逻辑重新漂回 Controller。
48
+ - 安装后回读 35 个 `sys_apiengine` 行并刷新缓存,再验证 `sso_capabilities` 为 `Code=1`,并验证 24 个 `sso_http_*` 的 `ApiAddress`、`ResponseType=HTTP` 与 Managed 策略。
49
+ - 所有 `Sso*Controller.cs`、`/api/Sso/Capabilities`、`LegacyCapabilities`、`CompleteLogin`、`RotateClientSecret` 与 `/api/SysUser/SsoPengrui` 必须保持删除;`/api/Sso/Begin`、`CompleteAuthorization` 与回调地址只允许作为接口引擎 `ApiAddress` 存在。
@@ -12,7 +12,7 @@
12
12
 
13
13
  `diy_sso` 是管理员专用平台表。匿名能力接口只投影 ConnectionKey、名称、协议、图标、说明和发起地址;旧兼容投影只允许同源 `/api/` 路径与安全 Token 参数名。
14
14
 
15
- 匿名投影由 `sso_capabilities` / `sso_legacy_capabilities` 接口引擎提供;Controller 不得直接查询并返回 `diy_sso`。内部协议网关通过 StopHttp 的 `sso_connection_runtime` 取得最小运行投影。客户端统一调用 `/apiengine/{ApiEngineKey}?OsClient=`,让观测数据保留真实 Key;新版宿主对未安装引擎返回结构化缺失错误,不再因动态路由尚未注册而直接 404。
15
+ 匿名投影由 `sso_capabilities` / `sso_legacy_capabilities` 接口引擎提供;任何 Controller 都不得直接查询并返回 `diy_sso`。公开协议地址由 `sso_http_*` Managed 接口引擎拥有,可信原子再通过 StopHttp 的 `sso_connection_runtime` 取得最小运行投影。客户端能力类接口统一调用 `/apiengine/{ApiEngineKey}?OsClient=`;标准伙伴协议使用应用声明的 `ApiAddress`,其中 `{OsClient}`、`{ConnectionKey}` 由动态路由安全提取并覆盖同名外部参数。
16
16
 
17
17
  ## Secret 与证书
18
18
 
@@ -46,7 +46,7 @@
46
46
  5. 重新读取 `sys_user`,确认未删除、未停用。
47
47
  6. 签发 DiyToken,写不含凭据的 SSO 审计。
48
48
 
49
- 步骤 2–5 固定由 `sso_resolve_federated_identity` 执行;协议 Controller 只把已经验签、归一化且不含原始 Token/断言的 Claim 交给该引擎。步骤 6 由 `sso_complete_login` 编排并调用一次性票据/DiyToken 原子。不要在 OIDC、SAML、CAS 三个 Controller 中各复制一套用户查询、JIT 和角色映射。
49
+ 步骤 2–5 固定由 `sso_resolve_federated_identity` 执行;`sso_http_oidc_callback`、`sso_http_saml_acs`、`sso_http_cas_callback` 只通过 `RunSsoProtocol` 取得已经验签、归一化且不含原始 Token/断言的 Claim。步骤 6 由 `sso_complete_login` 编排并调用一次性票据/DiyToken 原子。禁止为 OIDC、SAML、CAS 恢复 Controller 或各复制一套用户查询、JIT 和角色映射。
50
50
 
51
51
  ## 浏览器回调
52
52
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  Client Secret 轮换动作只显示一次明文,持久化 PBKDF2-SHA256 哈希。签名 Key 必须有 `kid`,轮换时保留验证重叠窗口。
20
20
 
21
- 对外 Claim 固定由 `sso_outbound_claims` 从启用用户与连接白名单生成;OIDC/SAML/CAS 协议网关只负责把同一投影编码成对应协议。Client Secret 轮换由 `sso_rotate_client_secret` 编排,并调用精确受限的可信原子生成一次性明文与持久哈希,不能恢复为 Controller 定制动作。
21
+ 对外 Claim 固定由 `sso_outbound_claims` 从启用用户与连接白名单生成;24 个 `sso_http_*` Managed 端点通过 `RunSsoProtocol` 把同一投影编码成 OIDC/SAML/CAS 响应。Client Secret 轮换由 `sso_rotate_client_secret` 编排,并调用精确受限的可信原子生成一次性明文与持久哈希,不能恢复为 Controller 定制动作。
22
22
 
23
23
  ## SAML IdP
24
24
 
@@ -151,42 +151,59 @@ function deserializeUser(value) {
151
151
  return parseMaybeJson(value, null);
152
152
  }
153
153
 
154
- // 吾码文件字段可能来自上传控件、HDFS 接口、字符串或 JSON 字符串,这里统一抽取可用路径。
155
- function extractUploadPath(value) {
156
- if (!value) return '';
157
- if (typeof value === 'object') {
158
- const raw = Array.isArray(value) ? (value[0] || {}) : value;
159
- if (typeof raw === 'string') return extractUploadPath(raw);
160
- return raw.Url || raw.FileUrl || raw.FileURL || raw.PreviewUrl || raw.PreviewURL ||
161
- raw.FullUrl || raw.Path || raw.FilePathName || raw.FilePath || raw.FullPath || '';
162
- }
163
-
164
- const text = String(value || '').trim();
165
- if (!text) return '';
166
- if ((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']'))) {
167
- return extractUploadPath(parseMaybeJson(text, text));
168
- }
169
- return text;
170
- }
171
-
172
- function normalizeUploadValue(value) {
173
- if (!value) return [];
174
- if (Array.isArray(value)) return value.map(extractUploadPath).filter(Boolean);
175
- if (typeof value === 'object') {
176
- const path = extractUploadPath(value);
177
- return path ? [path] : [];
178
- }
154
+ const uploadPathKeys = [
155
+ 'Url', 'FileUrl', 'FileURL', 'PreviewUrl', 'PreviewURL', 'FullUrl',
156
+ 'Path', 'FilePathName', 'FilePath', 'FullPath', 'Src', 'Href',
157
+ 'url', 'fileUrl', 'previewUrl', 'fullUrl',
158
+ 'path', 'filePathName', 'filePath', 'fullPath', 'src', 'href'
159
+ ];
160
+
161
+ // 吾码文件字段可能是旧版绝对/相对字符串、新版单图对象、多图数组,
162
+ // 也可能是上述结构序列化后的 JSON 字符串;这里统一抽取第一个有效路径。
163
+ function extractUploadPath(value, depth = 0) {
164
+ if (depth > 6 || value === null || value === undefined) return '';
165
+ if (Array.isArray(value)) {
166
+ for (const item of value) {
167
+ const path = extractUploadPath(item, depth + 1);
168
+ if (path) return path;
169
+ }
170
+ return '';
171
+ }
172
+ if (typeof value === 'object') {
173
+ for (const key of uploadPathKeys) {
174
+ const path = extractUploadPath(value[key], depth + 1);
175
+ if (path) return path;
176
+ }
177
+ return '';
178
+ }
179
+
180
+ const text = String(value || '').trim();
181
+ if (!text) return '';
182
+ if ((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']'))) {
183
+ return extractUploadPath(parseMaybeJson(text, text), depth + 1);
184
+ }
185
+ return text;
186
+ }
179
187
 
180
- const text = String(value || '').trim();
181
- if (!text) return [];
182
- if ((text.startsWith('[') && text.endsWith(']')) || (text.startsWith('{') && text.endsWith('}'))) {
183
- const parsed = parseMaybeJson(text, null);
184
- if (Array.isArray(parsed)) return parsed.map(extractUploadPath).filter(Boolean);
185
- const path = extractUploadPath(parsed);
186
- return path ? [path] : [];
187
- }
188
- return [text];
189
- }
188
+ function normalizeUploadEntries(value) {
189
+ if (!value) return [];
190
+ let parsed = value;
191
+ if (typeof parsed === 'string') {
192
+ const text = parsed.trim();
193
+ if (!text) return [];
194
+ if ((text.startsWith('[') && text.endsWith(']')) || (text.startsWith('{') && text.endsWith('}'))) {
195
+ parsed = parseMaybeJson(text, text);
196
+ }
197
+ }
198
+ const items = Array.isArray(parsed) ? parsed : [parsed];
199
+ return items.map((item) => ({ item, path: extractUploadPath(item) })).filter((entry) => entry.path);
200
+ }
201
+
202
+ // 旧调用方依赖路径数组,继续保持返回类型;需要 Name/Size/Id 等元数据时
203
+ // 使用 normalizeUploadEntries,禁止把上传对象 String(...) 后再解析。
204
+ function normalizeUploadValue(value) {
205
+ return normalizeUploadEntries(value).map((entry) => entry.path);
206
+ }
190
207
 
191
208
  function normalizeUploadData(body) {
192
209
  const raw = Array.isArray(body && body.Data) ? (body.Data[0] || {}) : ((body && body.Data) || {});
@@ -940,7 +957,9 @@ export function createMicroiV8(options = {}) {
940
957
  if (!picked || isBlockedAsset(picked)) return '';
941
958
  if (/^(https?:|data:|blob:|file:)/i.test(picked)) return picked;
942
959
  if (isLocalPackagedAsset(picked)) return picked;
943
- if (/^\/?file\//i.test(picked)) return joinUrl(config.apiBase, picked);
960
+ if (/^\/?file\//i.test(picked) || /^\/?micro-app\/v3(?:\/|$)/i.test(picked)) {
961
+ return joinUrl(config.apiBase, picked);
962
+ }
944
963
  if (/^\//.test(picked) || /^[a-z0-9_-]+\//i.test(picked)) return joinUrl(config.fileServer || config.apiBase, picked);
945
964
  return picked;
946
965
  }
@@ -1509,9 +1528,10 @@ export function createMicroiV8(options = {}) {
1509
1528
  resolveAvatarUrl: resolveFileUrl,
1510
1529
  resolveFileUrl,
1511
1530
  isBlockedAsset,
1512
- extractUploadPath,
1513
- normalizeUploadValue,
1514
- uploadFile,
1531
+ extractUploadPath,
1532
+ normalizeUploadValue,
1533
+ normalizeUploadEntries,
1534
+ uploadFile,
1515
1535
  getSafeArea,
1516
1536
  formatDate,
1517
1537
  toNumber,
@@ -11,13 +11,25 @@ description: Microi UniApp、H5、商城与 PC 页面中的上传图片、附件
11
11
 
12
12
  ## 先识别资源类型
13
13
 
14
- Microi 上传字段通常保存对象存储 Key 或相对路径,例如 `/demo/product/20260101/p1.jpg`。页面不能把相对路径直接交给 `<image>` / `<img>`,也不能一律拼接 API Base。
14
+ Microi 上传字段存在四种必须长期兼容的存储形态,不能假设字段一定是字符串:
15
+
16
+ | 存储形态 | 示例 | 兼容要求 |
17
+ |---|---|---|
18
+ | 旧版绝对地址字符串 | `https://cdn.example.com/a.png` | 校验协议和允许域名后原样使用,禁止再次拼接 `FileServer` |
19
+ | 旧版相对路径字符串 | `/tenant/a.png` | 仅此类纯相对对象 Key 拼接当前租户 `FileServer` |
20
+ | 新版单图对象 | `{ FilePathName:'/tenant/a.png', Name:'a.png' }` | 提取路径字段;保留对象的名称、大小、Id 等元数据,不得把整个对象转成字符串 |
21
+ | 新版多图数组 | `[{ FilePathName:'/tenant/a.png' }, ...]` | 逐项解析并保持原顺序;单图展示取第一个有效路径,多图展示返回全部有效项 |
22
+
23
+ 数据库或接口还可能返回上述对象/数组的 JSON 字符串,解析器也必须兼容。标准路径键按以下顺序识别:`Url`、`FileUrl`、`FileURL`、`PreviewUrl`、`PreviewURL`、`FullUrl`、`Path`、`FilePathName`、`FilePath`、`FullPath`、`Src`、`Href`,并兼容相应 camelCase 键。
24
+
25
+ 页面不能把相对路径直接交给 `<image>` / `<img>`,也不能一律拼接 API Base。项目已使用 `microi.v8.js` 时,优先直接复用 `V8.extractUploadPath`、返回路径数组的 `V8.normalizeUploadValue`、保留 `{ item, path }` 元数据的 `V8.normalizeUploadEntries`、`V8.assetUrl` / `V8.resolveAssetUrl` 和 `V8.resolveFileUrl`,不得在业务页面另造一套不完整的解析规则。
15
26
 
16
27
  | 类型 | 处理方式 |
17
28
  |---|---|
18
29
  | 已允许的 `https://` 绝对地址 | 校验协议和允许域名后使用 |
19
30
  | 公有对象存储相对路径 | 拼接当前租户 `FileServer` |
20
31
  | `/file/...` 平台本地文件路由 | 拼接当前 API Base |
32
+ | `/micro-app/v3/...` 应用稳定解析路由 | 拼接当前 API Base;这是动态指针路由,不是 HDFS 对象 Key,禁止拼接 `FileServer` |
21
33
  | 私有对象 | 后端鉴权后签发短期 URL,前端只使用临时 URL |
22
34
  | `blob:` | 仅用于本页创建且能及时 `revokeObjectURL` 的预览 |
23
35
  | `data:` | 仅允许经过大小和 MIME 校验的图片预览;禁止用于富文本任意 HTML |
@@ -26,15 +38,54 @@ Microi 上传字段通常保存对象存储 Key 或相对路径,例如 `/demo/
26
38
 
27
39
  ## 统一资源解析函数
28
40
 
29
- 项目应在共享请求/资源模块中实现一个 `resolveAssetUrl`,所有页面复用同一逻辑:
41
+ 未使用 `microi.v8.js` 的项目,应在共享请求/资源模块中实现等价的“提取上传路径 + 解析资源 URL”两层函数,所有页面复用同一逻辑:
30
42
 
31
43
  ```js
44
+ const uploadPathKeys = [
45
+ 'Url', 'FileUrl', 'FileURL', 'PreviewUrl', 'PreviewURL', 'FullUrl',
46
+ 'Path', 'FilePathName', 'FilePath', 'FullPath', 'Src', 'Href',
47
+ 'url', 'fileUrl', 'previewUrl', 'fullUrl', 'path', 'filePathName', 'filePath', 'fullPath', 'src', 'href'
48
+ ];
49
+
50
+ export function extractUploadPath(raw, depth = 0) {
51
+ if (depth > 4 || raw === null || raw === undefined) return '';
52
+ if (Array.isArray(raw)) {
53
+ for (const item of raw) {
54
+ const path = extractUploadPath(item, depth + 1);
55
+ if (path) return path;
56
+ }
57
+ return '';
58
+ }
59
+ if (typeof raw === 'object') {
60
+ for (const key of uploadPathKeys) {
61
+ const path = extractUploadPath(raw[key], depth + 1);
62
+ if (path) return path;
63
+ }
64
+ return '';
65
+ }
66
+ const value = String(raw).trim();
67
+ if (!value) return '';
68
+ if (/^[{[]/.test(value)) {
69
+ try { return extractUploadPath(JSON.parse(value), depth + 1); } catch (_) {}
70
+ }
71
+ return value;
72
+ }
73
+
74
+ export function normalizeUploadValue(raw) {
75
+ let value = raw;
76
+ if (typeof value === 'string' && /^[{[]/.test(value.trim())) {
77
+ try { value = JSON.parse(value); } catch (_) {}
78
+ }
79
+ const items = Array.isArray(value) ? value : [value];
80
+ return items.map(item => ({ item, path: extractUploadPath(item) })).filter(entry => entry.path);
81
+ }
82
+
32
83
  export function resolveAssetUrl(raw, {
33
84
  apiBase,
34
85
  fileServer,
35
86
  allowedHosts = []
36
87
  }) {
37
- const value = String(raw || '').trim();
88
+ const value = extractUploadPath(raw);
38
89
  if (!value) return '';
39
90
 
40
91
  if (/^https:\/\//i.test(value)) {
@@ -44,6 +95,9 @@ export function resolveAssetUrl(raw, {
44
95
  if (/^blob:/i.test(value)) return value;
45
96
  if (/^data:image\/(png|jpeg|gif|webp);base64,/i.test(value)) return value;
46
97
  if (value.startsWith('/file/')) return `${apiBase.replace(/\/$/, '')}${value}`;
98
+ if (/^\/?micro-app\/v3(?:\/|$)/i.test(value)) {
99
+ return `${apiBase.replace(/\/$/, '')}/${value.replace(/^\/+/, '')}`;
100
+ }
47
101
 
48
102
  const relative = value.replace(/^\/+/, '');
49
103
  return `${fileServer.replace(/\/$/, '')}/${relative}`;
@@ -103,7 +157,9 @@ function imageUrl(path) {
103
157
 
104
158
  ## 验收清单
105
159
 
106
- - [ ] 公有对象、本地文件、私有对象、绝对 URL 的路径分支均有测试
160
+ - [ ] 公有对象、本地文件、`/micro-app/v3` 动态路由、私有对象、绝对 URL 的路径分支均有测试
161
+ - [ ] 绝对地址字符串、相对路径字符串、单图对象、多图数组及其 JSON 字符串均有测试
162
+ - [ ] 单图从数组取第一个有效路径,多图保持原顺序;解析 URL 时不丢失或改写上传对象元数据
107
163
  - [ ] 切换两个 `OsClient` 后使用各自 `FileServer`,无跨租户路径或缓存复用
108
164
  - [ ] 私有 URL 越权、过期、篡改签名和复制到其它账号均失败
109
165
  - [ ] 网络面板中没有硬编码客户域名、真实租户或永久签名 URL
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: v8-api-config
3
- description: Microi V8 接口引擎配置指南。用于设置 ApiEngineKey、ApiAddress、StopHttp、AllowAnonymous、ResponseFile、锁、日志、超时和 HTTP 暴露。
3
+ description: Microi V8 接口引擎配置指南。用于设置 ApiEngineKey、ApiAddress、StopHttp、AllowAnonymous、ResponseFile、ResponseType=HTTP、锁、日志、超时和 HTTP 暴露。
4
4
  ---
5
5
 
6
6
  > **Codex 非阻塞自动更新:** 当前宿主为 Codex 时,吾码 CLI、Codex 插件与工作区 AI/MCP 由后台自动更新;需要诊断时读取 `../microi-codex-installer/SKILL.md`。更新失败、等待空闲或尚未重载均不得阻断当前、正在进行或新建任务。非 Codex 宿主跳过此项。
@@ -13,20 +13,36 @@ description: Microi V8 接口引擎配置指南。用于设置 ApiEngineKey、Ap
13
13
 
14
14
  | 字段 | 说明 | 默认 |
15
15
  |------|------|------|
16
- | `ApiEngineKey` | 接口唯一标识(URL 路径) | 必填 |
17
- | `ApiAddress` | 自定义接口地址(覆盖默认 `/apiengine/{Key}`) | 空 |
16
+ | `ApiEngineKey` | 接口唯一标识(URL 路径) | 必填 |
17
+ | `ApiAddress` | 自定义接口地址(覆盖默认 `/apiengine/{Key}`) | 空 |
18
+ | `ApiRoutes`(多路由) | 同一接口的兼容地址,多个绝对路径用英文分号分隔 | 空 |
18
19
  | `RequestType` | `Get` / `Post` / `Both` | `Both` |
19
20
  | `ParamType` | `form` / `json` / `url` —— 但 V8.Param 都能统一接收 | `Both` |
20
21
  | `IsAnonymous` | 允许匿名调用(无 Token) | `false` |
21
22
  | `StopHttp` | 禁止外部 HTTP 调用(仅允许 V8.ApiEngine.Run 内部调用) | `false` |
22
23
  | `IsResponseFile` | 是否响应文件(开启后 Data 必须是文件结构) | `false` |
23
- | `ResponseType` | `JSON/String/File/HTML/Stream`;`Stream` 开启 SSE/NDJSON | 自动识别 |
24
+ | `ResponseType` | `JSON/String/File/HTML/Stream/HTTP`;`HTTP` 返回受控状态码、响应头和正文 | 自动识别 |
24
25
  | `LockKey` | 分布式锁 Key(同一时刻全集群只能执行一次) | 空 |
25
26
  | `LockTimeout` | 锁超时秒数 | `30` |
26
27
  | `LockMsg` | 加锁失败时返回提示 | `操作过于频繁` |
27
28
  | `RateLimit` | 频率限制(如 `60/m` 每分钟60次) | 空 |
28
29
  | `LogParam` | 是否记录请求参数到 `sys_log` | `false` |
29
- | `LogResult` | 是否记录返回值到 `sys_log` | `false` |
30
+ | `LogResult` | 是否记录返回值到 `sys_log` | `false` |
31
+
32
+ ### 多路由(ApiRoutes)
33
+
34
+ `ApiAddress` 是唯一主路由;`ApiRoutes` 只用于让同一接口引擎继续接收多个历史地址,例如:
35
+
36
+ ```text
37
+ ApiAddress: /apiengine/platform-sys-menu
38
+ ApiRoutes: /api/SysMenu/GetSysMenuModel;/api/SysMenu/GetSysMenuStep
39
+ ```
40
+
41
+ - 多个地址必须用英文分号 `;` 分隔;每项都必须是 `/` 开头的绝对路径,保存时按不区分大小写去重,最多 128 项。
42
+ - `Id`、`ApiEngineKey`、`ApiAddress` 与每一项 `ApiRoutes` 都会成为同一接口的缓存别名。路由只做完整路径精确匹配,不把 Query 计入地址。
43
+ - 主路由与多路由不得重复,也不得与其它启用接口的主路由/多路由冲突;保存、启动闭包和缓存初始化都必须失败关闭并指出冲突 Key,禁止后写覆盖先写。
44
+ - 新客户端仍使用 `/apiengine/{ApiEngineKey}` 或主 `ApiAddress`。多路由用于 Controller 迁移、旧移动端和第三方已登记回调的兼容,不得拿它复制多份相同接口代码。
45
+ - MCP 创建接口时传 `apiRoutes: ['/api/Old/A', '/api/Old/B']`;更新时省略表示保留,传空字符串/空数组表示清空。官方应用包必须同时携带 `ApiRoutes`、醒目 Managed 提示与 `ResourcePolicies.ApiEngines`。
30
46
 
31
47
  ### 资源预算与嵌套调用(强制理解)
32
48
 
@@ -54,6 +70,30 @@ return { Code: 1, Data: { Count: rows.length } };
54
70
  - 当前租户在 `sys_osclients` 配置单分片、累计响应和心跳:`ApiEngineStreamMaxChunkKB` 默认 256(4–1024)、`ApiEngineStreamMaxTotalMB` 默认 16(1–256)、`ApiEngineStreamHeartbeatSeconds` 默认 15(5–60)。
55
71
  - 流式传输用于在线增量反馈;大型文件走 HDFS/文件响应,可靠长任务走后台任务 + Checkpoint,广播状态走提交后 SignalR。禁止用流式响应绕过这些边界。
56
72
 
73
+ ### 受控原始 HTTP 响应(ResponseType=HTTP)
74
+
75
+ 标准协议需要非 200 状态、重定向、XML/纯文本或指定 Content-Type 时,不要新建 Controller。设置 `ResponseType=HTTP`,并返回统一契约:
76
+
77
+ ```javascript
78
+ return {
79
+ Code: 1,
80
+ DataAppend: { HttpResponse: {
81
+ StatusCode: 302,
82
+ ContentType: 'text/plain; charset=utf-8',
83
+ Body: '',
84
+ Headers: {
85
+ Location: 'https://identity.example.com/login',
86
+ 'Cache-Control': 'no-store'
87
+ }
88
+ } }
89
+ };
90
+ ```
91
+
92
+ - 普通接口引擎可设置 `Cache-Control`、`Pragma`、`Location`、`WWW-Authenticate`、下载/语言/CSP 等安全白名单响应头;禁止 Host、Content-Length、Transfer-Encoding、Connection 等逐跳或宿主管理头。
93
+ - `Location` 只允许站内绝对路径、HTTPS 地址或本机开发地址,禁止 CRLF、协议相对地址、非本机 HTTP 和带用户信息 URL。
94
+ - `Set-Cookie` 等高风险头只允许由受限 `V8.Method` 可信原子生成并签名,租户 V8 无法自行伪造签名。SSO 使用 `V8.Method.RunSsoProtocol`;不要把 Secret、Cookie 值或签名密钥暴露给 V8。
95
+ - `204/304` 不得带正文;状态码限制为 100–599,正文和响应头有大小/数量限制。HTTP 契约校验失败时宿主返回标准错误,不写出半截协议响应。
96
+
57
97
  ### 通用实时事件(SignalR)
58
98
 
59
99
  订单、协作、设备、审批或多人房间需要实时刷新时,业务写命令仍由接口引擎执行并提交事务;成功结果通过 `DataAppend.RealtimeEvent` 声明提交后事件。新业务统一使用通用 v2 Hub `/api-engine-realtime`,不要再新建业务专用 Hub 或把权威状态放进 C# 进程内字典。
@@ -154,13 +194,22 @@ return { Code: 1 };
154
194
 
155
195
  分布式锁不是“业务只执行一次”的最终保证。扣款、库存、积分、流水、对账等副作用还必须使用稳定幂等键、数据库唯一约束/条件更新、状态机或 outbox/inbox;锁 Key 至少包含 `OsClient + 业务唯一标识`,超时必须大于正常执行时间。所需唯一索引必须写入 Manifest `tables[].indexes` 并通过 `microi_create_table_index` 创建、`microi_get_table_indexes` 回读,接口引擎本身禁止执行索引 DDL。
156
196
 
157
- ## 4. 自定义路径(ApiAddress)
197
+ ## 4. 自定义路径(ApiAddress)
158
198
 
159
199
  让接口暴露为 `/wechat/notify` 而非 `/apiengine/wechat_notify`,对接第三方时常用:
160
200
 
161
201
  ```
162
- ApiAddress: /wechat/notify
163
- ```
202
+ ApiAddress: /wechat/notify
203
+ ```
204
+
205
+ 标准协议还可声明逐段模板,例如:
206
+
207
+ ```text
208
+ ApiAddress: /sso/{OsClient}/.well-known/openid-configuration
209
+ ApiAddress: /saml/{OsClient}/sp/{ConnectionKey}/metadata
210
+ ```
211
+
212
+ 模板只匹配完整路径段,不支持贪婪通配符。命中的路由值会写入 `V8.Param._RouteValues`,并以权威路径值覆盖同名 Query/Form/JSON 参数,防止调用者伪造另一租户或连接。包含 `{OsClient}` 时租户由该路径段解析;同一路径匹配多个模板视为配置冲突并拒绝执行。
164
213
 
165
214
  ## 5. 响应文件(IsResponseFile)
166
215
 
@@ -241,7 +290,7 @@ Body: {"ApiEngineKey":"your_key","Action":"Bootstrap"}
241
290
  - `ApiAddress` 不能为空字符串;空字符串可能导致 404。
242
291
  - 响应不能是空 body、字符串 `null`、非 JSON;业务接口必须返回标准 DosResult。
243
292
  - 普通 `POST/PUT/PATCH/DELETE` 必须使用稳定路径 `/apiengine/{ApiEngineKey}`,租户放在唯一的 `osclient` Header,并可在 JSON/Form Body 中冗余传入;禁止无脑给路径追加 `--OsClient--...--`。普通 GET 优先 Header 或 `?OsClient=`。只有微信/支付等第三方回调(包括 POST)、浏览器直接下载等调用方确实无法设置 Header 或 Query 的场景,才使用 `--OsClient--{OsClient}--` 特殊路径;Query 参数名固定为 `OsClient`,禁止 `o` 等缩写。
244
- - 需要 C# 验签/AES 解密或隐藏 SaaS 密钥的回调,使用“最小协议网关 + `Managed` 核心接口 + `CreateIfMissing` 租户 Hook”。网关不得承载日志、写表、通知等业务逻辑;传给 V8 的事件必须脱敏,并包含稳定 `EventId` 供 Hook 幂等。
293
+ - 需要 C# 验签/AES 解密、协议编解码或隐藏 SaaS 密钥的回调,使用“`Managed` HTTP 接口引擎 + 精确 Key 可调用的最小 `V8.Method` 可信原子 + `CreateIfMissing` 租户 Hook”。公开地址仍归接口引擎,禁止为此恢复 Controller;传给 Hook 的事件必须脱敏,并包含稳定 `EventId` 供幂等。
245
294
  - 更新接口代码时保留 HTTP 元数据,避免只覆盖 JS 代码却把匿名、启用、自定义地址等配置冲掉。
246
295
 
247
296
  ### 路由冷缓存与客户端直达头(强制)
@@ -65,7 +65,7 @@ Secret 只通过租户管理员专用端点写入租户绑定的认证密文。
65
65
  - 多节点保存连接使用按 `OsClient + DbKey` 隔离的分布式锁,并由数据库唯一索引兜底;同步数据和附件仍必须使用业务幂等键,锁不能替代唯一约束、状态机或 inbox/outbox。
66
66
 
67
67
  <!-- /microi-progressive:chunk -->
68
- <!-- microi-progressive:chunk id=v8-security-001 sha256=aad35b2c3e1876d98ead7e604307bcb7d39fe8c480d72f102ae956972580a35b -->
68
+ <!-- microi-progressive:chunk id=v8-security-001 sha256=bcb95a6c33b3abb6bfce13589e21ce56ff2aea8578d05180f2f2538cc5117fb8 -->
69
69
  ## 0.5 接口引擎配置安全
70
70
 
71
71
  代码以外,接口本身的配置项也是安全防线(详见 `v8-api-config/SKILL.md`):
@@ -75,8 +75,13 @@ Secret 只通过租户管理员专用端点写入租户绑定的认证密文。
75
75
  | `IsAnonymous = false` | 非公开接口默认关闭,防止匿名调用越权 |
76
76
  | `StopHttp = true` | 内部接口(核心扣款、内部计算)防止外部直接 HTTP 调用 |
77
77
  | `LockKey = ...` | 写操作类接口(对账、补单)防止并发执行 |
78
- | `RateLimit = 60/m` | 公开接口(验证码、登录)防爬虫 |
78
+ | `RateLimit = 60/m` | 公开接口(验证码、登录)防爬虫 |
79
79
  | `LogParam = true` | 支付/审计类接口记录请求 |
80
+ | `ResponseType = HTTP` | 标准协议需要状态码、重定向、XML/纯文本或特定响应头 |
81
+
82
+ `ResponseType=HTTP` 不是任意响应头旁路。普通 V8 只能返回安全白名单头,`Location` 经过站内/HTTPS 校验;Host、Content-Length、逐跳头始终禁止。`Set-Cookie` 等高风险头只接受平台可信原子的进程内签名结果,V8 不能获得或伪造签名密钥。需要协议编解码、签名验签或密钥隔离时,公开地址仍由 Managed 接口引擎拥有,C# 只实现精确 Key 可调用的最小 `V8.Method` 原子,禁止恢复业务 Controller。
83
+
84
+ `ApiAddress` 使用 `{OsClient}`、`{ConnectionKey}` 路径模板时,模板值是权威值并覆盖同名 Query/Form/JSON 参数;多模板歧义必须失败关闭,防止跨租户或跨连接参数混淆。
80
85
 
81
86
  ### 表单上传的防篡改边界
82
87
 
@@ -7,7 +7,15 @@ description: Microi V8 工作流事件指南。用于编写审批流条件、节
7
7
 
8
8
  # Microi V8 工作流事件开发
9
9
 
10
- 你正在开发 Microi 吾码平台的工作流(审批流程)V8 事件。流程引擎基于表单引擎,通过 V8 事件控制审批逻辑。
10
+ 你正在开发 Microi 吾码平台的工作流(审批流程)V8 事件。流程引擎基于表单引擎,通过 V8 事件控制审批逻辑。
11
+
12
+ ## 后端类库与接口边界
13
+
14
+ - 工作流运行时固定属于独立 `Microi.WorkFlow` 类库;`Microi.net.Api` 只注册 `AddMicroiWorkFlow()`,不得恢复 `WorkFlowController` 或把流程业务写回 `Program.cs`。
15
+ - 对外与旧移动端路由统一由官方 Managed `platform-workflow` 接口引擎承载,历史 `/api/WorkFlow/*` 地址写入同一记录的 `ApiRoutes`。租户个性化逻辑写在 CreateIfMissing `platform-workflow-custom-hook`,默认只返回 `{ Code: 1 }`。
16
+ - `Microi.Core` 只保留避免循环引用所需的 `IWFEngine`、模型和最小运行时合同;发起、审批、撤回、移交、退回及审批人计算实现在 `Microi.WorkFlow`,并按职责拆分 partial 文件。
17
+ - 新业务规则仍优先写工作流 V8/接口引擎。只有事务、流程状态机、可信当前用户和运行时内核缺少不可伪造的底层原子时,才扩展 `Microi.WorkFlow`。
18
+ - 官方发布时 `Microi.WorkFlow` 必须生成 NuGet 包,并与 `Microi.AI` 使用同一 Obfuscar 配置加密后替换包内 DLL;不得推送未加密的 WorkFlow 包。开源安装只消费 NuGet,不要求存在私有源码。
11
19
 
12
20
  <!-- microi-progressive:begin -->
13
21
  <!-- microi-progressive:chunk id=v8-workflow-000 sha256=9030cd2de9f1febfb9a749c82cf83e97ccb8fb972a3f8f0ff11067488e14cd8e -->