Oak框架简介
Oak是一个现代的应用系统快速开发框架,它实现了对业务系统相对抽象层次上的功能抽象,使应用开发者可以将自己的注意力完全集中于实现业务逻辑本身,而无须关注众多构建应用系统需要考虑的(共性)问题,例如:
- 我该选用什么样的数据库,如何建立索引?
- 我该怎样设计并实现全局一致的用户权限?(几乎没有多少应用系统能完美解决这一问题)
- 我该怎样设计前后端接口才能保证低耦合且可重用?(传统MVC设计模式下controller如果不加以规范的组织设计/定期重构,对于长期开发而言是一场灾难)
- 我该如何在各层次上保证数据的一致性?(前端->后端->分布式,每一个层面上都有各种细节问题需要考虑)
- 我该如何保持前后端常量和逻辑的一致性?(有很多库被设计专门用来解决这一问题,使得整个项目变得更加复杂)
- 我的应用如果有多个前端,如何在代码复用和保证高可持续开发性之间取得平衡?
- 如何发现并杜绝我的项目在各个层次上的安全漏洞?
Oak的背景与目标
作为软件开发者,在当今这个年代要开发业务软件既是幸福的也是痛苦的。一方面,众多成熟的技术方案及SaaS类服务几乎能解决应用开发中遇到的各种问题(对比20年前,光是一个图片上传预览就要手写解决无数的问题),另一方面,开发软件需要不断学习和使用的新技术/开源库也越来越多,甚至可以说,几乎没有人的学习速度能赶得上新技术的出现速度。技术栈的爆炸既造成了应用开发者之间的鸿沟(我的项目代码只有我能维护的动😭),也让绝大多数小团队或者个人开发者难以赋予项目一个健壮、可持续的基础架构。当一个项目持续开发一年以上,代码往往就成为了著名的“屎山”。
Oak框架的设计目标就是解决软件开发过程中的这些问题,让现代典型的应用系统开发达到一致、可持续、高可用的目标。
Oak的技术选择
Oak框架使用Typescript作为开发语言。Oak设计之初就希望以一种语言统一编写前后端(前后端一致性),让团队中编写应用的程序员变成“完全对等”的。在此前提下,Javascript语言几乎是唯一的选择。同时为了提高代码的规范性,当前最流行的Typescript成为了我们的选择。
Typescript语言在开发过程中非常消耗计算机资源,请确保您的开发机器拥有Intel 12代以上的Cpu以及16GB以上的内存空间,以获得较好的开发体验😁
Oak框架主要针对项目的整体架构及公共功能抽象,其本身并没有限制过多的技术栈。当前 oak-db 已经提供 MySQL 和 PostgreSQL 的 store / translator,后端启动时会按配置文件选择数据库;如果要接 MongoDB 这类非 SQL 存储,仍需要额外适配。在前端,目前我们选择使用 React,但这并不意味着您不能使用 vue(当然,这需要您自己实现一套 vue 上对等的逻辑转换)。在未来,我们将积极拥抱开源,希望集各方之力,服务于数量广大的应用开发工程师。
Oak的定位
纵观计算机软硬件技术的发展历史,都可以用“抽象”两个字来概括,例如,操作系统就是对各种硬件的抽象,数据库就是对于数据存储查询的抽象。从这个角度看,Oak框架也是一种抽象,它是最接近业务层次的抽象,尽可能的将业务需要遇到的各种共性问题统一进行了处理,并制订了业务逻辑编写的开发规范,以致力于使业务开发者不写一行多余的代码这一目标。

需要强调的是:正如操作系统和数据库一样,Oak也并非能解决所有的应用开发问题,它的设计目标仅仅是为了提高应用软件开发的效率和规范。一名优秀的工程师仍然应当掌握更多基础技术的实现,以应付项目中的更多问题。
Oak仍然在不断开发完善中,如有问题,也欢迎加入讨论小组,给出您的宝贵建议。
基础知识
- Oak使用Typescript语言,因此您需要提前掌握以下知识:
- 在前端,Oak目前使用React作为网页端框架(尽管这不是必须,但由于团队技术力量等原因,短期内并没有计划去适配vue等其它框架),因此您也需要掌握React的一些基本概念。如果您需要开发App或者小程序,也需要去了解一些Oak所采用的技术本的相关技术。
对于其它更多的前端环境,Oak也将在未来进行适配。Oak的前端技术路线介绍请参见:目录文件结构。
对于新手开发者,可能对上述这么多的前置知识学习感到望而生畏。没关系,理论上只要了解并掌握基础概念即可进行开发,更多的技术细节可以在开发过程中再不断学习补充。
开发环境
当前 oak-cli 的 package.json 已经要求 Node.js >=20.0.0,新项目建议直接使用 Node.js 20 以上版本。推荐使用 Microsoft VS Code 作为开发 IDE。
新手入门:从 Web CRUD 到完整 Oak 组件树
本教程面向第一次接触 Oak 的开发者。我们从一个不安装 oak-general-business、oak-pay-business 的最小项目开始,先在 Web 中完成真正的增删改查,再引入任务分组和一对多关系,最后把同一棵组件树带到微信小程序、Desktop 和 Native。
完成后,项目具有以下能力:
- Group 分组的新增、编辑、删除;
- Todo 任务的新增、编辑、完成切换、删除;
- 页面顶部用 tabs 切换分组;
- 新增和编辑都在 Modal 中打开 Upsert 子组件;
- Todo 列表通过生成关系
todo$group挂在 Group 节点下; - Web、小程序、Electron Desktop、React Native 共用实体和 Oak 节点逻辑。
本章对应真实参考项目
oak-tutorial-todo。示例不是只通过类型检查:Web CRUD 已连接 SQLite 验证持久化,小程序 XML 检查为 0 diagnostics,Electron 已生成 NSIS 安装包,Native 已生成 Android production Metro bundle。
1. 先建立正确的学习顺序
第一次学习请按顺序完成:
- 安装 Oak Assistant (oak-team)。
- 创建无公共业务依赖的 Oak 项目。
- 定义 Todo 实体并生成领域代码。
- 理解 ListNode、SingleNode 和
oakPath。 - 用 Todo 列表 + Modal Upsert 完成基础 CRUD。
- 真实初始化数据库并逐项验证 CRUD。
- 新增 Group,与 Todo 建立一对多关系。
- 把 Web 页面升级为完整关系组件树。
- 再进入微信小程序、Desktop和Native。
- 最后阅读知识归纳。
不要一上来背 Oak 名词。本章每引入一个概念,都会先说明它解决什么问题,再给出代码。
2. 安装编辑器能力
在 VS Code 扩展市场安装:
Oak Assistant (oak-team)
Oak render 的 props 由编译器根据同目录 index.ts 生成,小程序 XML 也会被转换成虚拟 TypeScript 检查。没有插件时,很多问题只能等到 npm run build 才出现;安装插件后,错误可以直接显示在 TSX、XML 和 Less 文件中。
当前规范:
- render 中写
function Render(props),不要手写宽泛的WebComponentProps; - 不使用
any、never或@ts-nocheck绕过错误; - XML 使用到的字段必须由
data、properties或编译器可识别的formData合同提供; - 可见文本放进 locale;
- 修改实体后重新运行领域和 locale 生成命令。
详细安装和配置见Oak Assistant。
3. 创建最小项目
oak-cli create oak-tutorial-todo
cd oak-tutorial-todo
npm install
git init
git add .
git commit -m "chore: scaffold standalone Oak application"
创建时选择 SQLite,Oak 公共业务依赖全部取消。此类项目默认只保留 frontend namespace 和一个展示页,不需要用户、登录、console、token 或支付模块。
先运行欢迎页:
npm run start:web
看到页面后停止服务。此时只需要认识三个目录:
src/entities 你编写的业务数据模型
src/oak-app-domain make:domain 生成的类型和关系
src/pages/frontend/home 当前 Web 首页
src/oak-app-domain 是生成产物,不要手改。
4. 定义 Todo 实体
创建 src/entities/Todo.ts:
import { Boolean, String } from '@oak-domain/types/DataType';
import { EntityShape } from '@oak-domain/types/Entity';
import { EntityDesc } from '@oak-domain/types';
export interface Schema extends EntityShape {
title: String<120>;
completed: Boolean;
}
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: {
name: '任务',
attr: {
title: '标题',
completed: '是否完成',
},
},
en_US: {
name: 'Todo',
attr: {
title: 'Title',
completed: 'Completed',
},
},
},
};
这里可以先套用数据库知识理解:Todo 类似一张表,title 和 completed 是业务字段,EntityShape 提供 id、创建时间、更新时间和软删除字段。
生成并检查:
npm run make:domain
npm run make:locale
npm run build
git add .
git commit -m "feat: define Todo domain entity"
在 src/oak-app-domain/EntityDict.ts 中应当能找到 todo。
5. 写页面前先理解 Oak 组件树
普通 React 组件树主要描述“谁渲染谁”。Oak 组件树还描述“数据操作属于哪个节点”。
第一版 CRUD 使用下面的树:
frontend/home Todo ListNode
└── frontend/home.<todoId> Todo SingleNode,Upsert 表单
三个概念先这样理解:
5.1 ListNode
entity: 'todo' 且 isList: true 的组件是 Todo 列表节点。它负责:
- 查询多条 Todo;
addItem创建列表草稿;updateItem、removeItem修改某一项;- 执行整条列表分支。
5.2 SingleNode
entity: 'todo' 且 isList: false 的 Upsert 是单条 Todo 节点。路径末尾是 Todo id,例如:
$todo/tutorial-list.9f...2a
同一个 Upsert 既能编辑数据库已有记录,也能编辑 addItem 新建的草稿,所以叫 Upsert。
5.3 oakPath
oakPath 不是普通 React key。它告诉 runningTree:这个子组件对应组件树中的哪一个数据节点。
<TodoUpsert oakPath={`${oakFullpath}.${editingTodoId}`} />
oakFullpath 是当前列表节点完整路径,拼上 id 后得到 Todo SingleNode 路径。
6. 创建 Todo Upsert 组件
创建目录:
src/components/todo/upsert/
├── index.ts
├── web.tsx
└── locales/
index.ts:
export default OakComponent({
entity: 'todo',
isList: false,
projection: {
id: 1,
title: 1,
completed: 1,
},
});
web.tsx 使用框架抽象 Upsert:
import React from 'react';
import { Upsert } from '@project/components/AbstractComponents';
export default function Render(props) {
const { oakFullpath } = props.data;
return oakFullpath ? (
<Upsert
entity="todo"
oakPath={oakFullpath}
attributes={['title', 'completed']}
layout="vertical"
/>
) : null;
}
注意:Upsert 组件只负责字段编辑。保存、取消和 Modal 状态放在父列表中,因为新建操作属于父 ListNode。
7. 用 Modal 完成第一版 Web CRUD
首页 index.ts 先作为 Todo ListNode:
export type TodoView = {
id: string;
title: string;
completed: boolean;
};
export default OakComponent({
entity: 'todo',
isList: true,
projection: {
id: 1,
title: 1,
completed: 1,
$$createAt$$: 1,
},
sorters: [{
sorter: {
$attr: { $$createAt$$: 1 },
$direction: 'desc',
},
}],
data: {
todos: [] as TodoView[],
},
formData({ data }) {
const todos: TodoView[] = (data || []).flatMap((item) => {
if (typeof item.id !== 'string') {
return [];
}
return [{
id: item.id,
title: item.title || '',
completed: item.completed === true,
}];
});
return { todos };
},
methods: {
async toggleTodo(id: string, completed: boolean) {
this.updateItem({ completed: !completed }, id);
await this.execute();
},
async removeTodo(id: string) {
this.removeItem(id);
await this.execute();
},
},
});
web.tsx 的关键结构:
import React, { useState } from 'react';
import { Button, Checkbox, Modal, Table } from 'antd';
import TodoUpsert from '@project/components/todo/upsert';
import type { TodoView } from './index';
export default function Render(props) {
const { todos = [], oakFullpath, oakExecuting } = props.data;
const { addItem, toggleTodo, removeTodo, execute, clean } = props.methods;
const [editingTodoId, setEditingTodoId] = useState('');
return (
<>
<Button
type="primary"
onClick={() => {
setEditingTodoId(addItem({
title: '',
completed: false,
}));
}}
>
新增任务
</Button>
<Table<TodoView>
rowKey="id"
dataSource={todos}
pagination={false}
columns={[
{
title: '完成',
render: (_, row) => (
<Checkbox
checked={row.completed}
onChange={() => void toggleTodo(row.id, row.completed)}
/>
),
},
{ title: '标题', dataIndex: 'title' },
{
title: '操作',
render: (_, row) => (
<>
<Button onClick={() => setEditingTodoId(row.id)}>
编辑
</Button>
<Button danger onClick={() => void removeTodo(row.id)}>
删除
</Button>
</>
),
},
]}
/>
<Modal
open={Boolean(editingTodoId)}
confirmLoading={oakExecuting}
onOk={async () => {
await execute();
setEditingTodoId('');
}}
onCancel={() => {
clean();
setEditingTodoId('');
}}
>
{editingTodoId && oakFullpath ? (
<TodoUpsert oakPath={`${oakFullpath}.${editingTodoId}`} />
) : null}
</Modal>
</>
);
}
为什么保存必须由列表执行
addItem 把 create 操作放在 Todo ListNode。Upsert 中输入标题时,字段更新发生在 Todo SingleNode。如果在 Upsert 子组件中直接 execute(),只会执行子节点,可能得到一条针对新 id 的 update,而父节点的 create 没有提交。
因此 Modal 由父列表持有,保存时父列表调用 execute()。runningTree 会组合父级 create 和子级字段修改。
这是本教程最重要的第一个 Oak 规则:在哪个节点创建操作,就从能覆盖该节点的祖先执行。
8. 初始化数据库并亲手验证 CRUD
另开终端:
npm run server:init
npm run server:start
这里能执行 server:init,是因为教程使用刚创建的空 SQLite 数据库。当前模板的初始化模式会重建非 static 表;数据库一旦有需要保留的数据,后续就只能使用审核后的结构升级和数据迁移,不能重复把 server:init 当作“同步实体”命令。
再启动 Web:
npm run start:web
按顺序验证:
- 新增任务并刷新页面,记录仍存在;
- 编辑标题并刷新,标题已更新;
- 切换完成状态并刷新,状态已保存;
- 删除任务并刷新,记录不再出现;
- 新增任务后点击取消,刷新后没有该记录。
不要只看“操作成功”提示。Oak 前端会先反映乐观状态,真正的验证必须包含刷新或数据库查询。
npm run build
git add .
git commit -m "feat: build Todo web CRUD with modal upsert"
9. 第二阶段:引入任务分组
现在需求升级:任务属于某个分组,用户先选择分组,再管理该组任务。
创建 src/entities/Group.ts:
import { String } from '@oak-domain/types/DataType';
import { EntityShape } from '@oak-domain/types/Entity';
import { EntityDesc } from '@oak-domain/types';
export interface Schema extends EntityShape {
name: String<64>;
}
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: { name: '任务分组', attr: { name: '分组名称' } },
en_US: { name: 'Task group', attr: { name: 'Group name' } },
},
};
修改 Todo.ts:
import { Schema as Group } from './Group';
export interface Schema extends EntityShape {
title: String<120>;
completed: Boolean;
group: Group;
}
Oak 会把对象关系生成成存储外键和关系路径。重新生成:
npm run make:domain
npm run make:locale
npm run build
然后在生成的 EntityDict.ts 中搜索:
todo$group
不要凭经验猜关系名。以生成代码为准。
数据库结构已变化。学习项目可以重新初始化一个空 SQLite 文件;已有业务数据的项目必须走正式 schema upgrade,不能直接删除数据库。
10. 把页面升级为完整关系组件树
最终结构:
frontend/home Group ListNode
└── frontend/home.<groupId> Group SingleNode
└── frontend/home.<groupId>.todo$group Todo ListNode
└── ...todo$group.<todoId> Todo SingleNode Upsert
目录建议:
src/components/
├── group/
│ ├── panel/
│ └── upsert/
└── todo/
├── list/
└── upsert/
10.1 首页改为 Group ListNode
首页查询 Group,顶部 tabs 的数据来自该 ListNode。新增分组时:
const id = addItem({ name: '' });
setSelectedGroupId(id);
setEditingGroupId(id);
Modal 中挂载:
<GroupUpsert oakPath={`${oakFullpath}.${editingGroupId}`} />
保存由首页 Group ListNode 执行:
await execute();
setEditingGroupId('');
10.2 GroupPanel 只负责向下挂关系
src/components/group/panel/index.ts:
export default OakComponent({
entity: 'group',
isList: false,
projection: { id: 1, name: 1 },
});
web.tsx:
import React from 'react';
import TodoList from '@project/components/todo/list';
export default function Render(props) {
const { oakFullpath } = props.data;
return oakFullpath ? (
<TodoList oakPath={`${oakFullpath}.todo$group`} />
) : null;
}
todo$group 表示“当前 Group 作为 group 被哪些 Todo 引用”。TodoList 不需要再手写 { groupId } 过滤器,关系节点已经携带父上下文。
10.3 TodoList 仍使用 Upsert Modal
TodoList 的新增、编辑 UI 与第一版相同,但它现在位于 Group 下方。新增 Todo 时,create data 只写自身字段:
this.addItem({
title: '',
completed: false,
});
不要手工填写 groupId。框架在执行 todo$group 级联操作时绑定父键。
10.4 保存 Todo 要执行 Group 节点
关系树比第一版多一层。Todo create 位于 todo$group ListNode,但它必须在 Group SingleNode 的级联操作中提交,后端才能得到父 Group 并写入 groupId。
TodoList 中的保存方法:
async saveTodo() {
const todoListPath = this.state.oakFullpath;
if (!todoListPath) {
throw new Error('Todo list node is not mounted');
}
const separatorIndex = todoListPath.lastIndexOf('.');
const groupPath = todoListPath.slice(0, separatorIndex);
await this.execute(undefined, undefined, groupPath);
}
如果只在 TodoList 自己的路径执行,create operation 没有父关系上下文,后端会报 Todo 的 groupId 不能为空。这个错误不是让你手填外键,而是在提醒你执行层级不对。
11. 完整验证分组关系
重新初始化或升级数据库后验证:
- 新增 Group,刷新后仍存在;
- 新增两个 Group,tabs 可切换;
- 在 A 组新增 Todo,切到 B 组看不到它;
- 切回 A 组能看到该 Todo;
- 编辑 Todo、切换完成状态、删除并刷新;
- 取消新增 Group 或 Todo,数据库不产生记录;
- 构建无 TypeScript、XML 或样式 diagnostics。
npm run make:locale
npm run build
git add .
git commit -m "feat: group Todo tasks through component tree"
12. 再增加其它端
现在才进入多端,因为此时业务模型和组件树已经稳定:
- 微信小程序:复用节点逻辑,补 XML render
- Desktop:Electron/Tauri 壳与 Fluent UI render
- Native:React Native render 与 Android 构建
多端不是复制业务逻辑。index.ts、实体、projection、关系路径和 execute 层级保持一致;不同端主要替换 render 和交互控件。
13. 最终命令清单
# 实体变化
npm run make:domain
npm run make:locale
# Oak 严格检查
npm run build
# Web
npm run start:web
npm run build:web
# 微信小程序,添加 workspace 后
npm run build:mp:wechatMp
# Electron Desktop,添加 workspace 后
npm run build:desktop:desktop-electron
# Native,添加 workspace 后
npm run build:native:native:android
Android production build要求本机配置 ANDROID_HOME 或 native/android/local.properties -> sdk.dir。没有 Android SDK 时,仍可单独执行 Metro production bundle 验证 JS/render 链路,详见 Native 专题。
14. 你现在真正学会了什么
- 数据库:实体、字段、外键和一对多关系;
- 前端:列表、Modal、表单、tabs 和多端 render;
- 后端:operation 最终由服务端校验并持久化;
- Oak:ListNode、SingleNode、
oakPath、关系路径、草稿 operation、祖先执行与级联外键绑定。
下一步阅读知识归纳,把这些经验整理成以后开发其它业务对象时可以复用的方法。
开发前必装:Oak Assistant
Oak 项目不能只依靠普通 TypeScript、React 和 XML 插件完成编辑期检查。Oak 编译器会根据实体、同目录 index.ts、平台 render、XML/WXML、Less 和 locale 生成额外的类型合同;普通编辑器并不知道这些合同。
因此,在第一次编写实体或页面之前,请安装:
Oak Assistant (oak-team)
VS Code Marketplace ID:
oak-team.oak-assistant-new
当前审计版本是 3.5.0。旧的 oak-assistant 仓库和相似名称扩展不是当前语言服务入口;排错时先在扩展详情页确认 Marketplace ID 和版本。
如果没有安装它,代码可能在编辑器中看起来没有问题,直到执行 npm run build 才集中出现实体定义、render props、XML 事件、组件属性、样式类名或翻译键错误。这会把本来应该边写边修的小问题拖到最后一起处理。
1. 它和编译器分别负责什么
可以把两者理解为同一套规则的两个入口:
| 工具 | 发生时间 | 主要用途 |
|---|---|---|
| Oak Assistant | 保存和编辑文件时 | 尽早显示红线、补全、悬浮说明和跳转 |
npm run build | 完整构建时 | 对整个项目做最终、可重复的严格检查 |
插件不能代替构建。它让反馈更早,构建则是提交代码前的最终结论。
2. 安装插件
方法一:在 VS Code 中安装
- 打开 VS Code。
- 打开左侧“扩展”。
- 搜索
Oak Assistant (oak-team)。 - 确认发布者是
oak-team。 - 点击安装。
- 安装或升级完成后执行
Developer: Reload Window。
不要只按名字安装历史版本或相似名称插件。当前 Marketplace ID 是 oak-team.oak-assistant-new。
方法二:通过命令安装
如果本机的 code 命令已经加入 PATH,可以执行:
code --install-extension oak-team.oak-assistant-new
然后重新加载 VS Code 窗口。
为团队推荐插件
在项目中创建 .vscode/extensions.json:
{
"recommendations": [
"oak-team.oak-assistant-new"
]
}
这个文件不会替同事静默安装插件,但在他们打开项目时会显示推荐提示,避免团队中只有部分人拥有 Oak 编辑期检查。
3. 选择工作区 TypeScript
Oak Assistant 的完整实体语义检查和虚拟 TSX 能力依赖 VS Code 的 TypeScript Server 插件机制。当前 TypeScript 7/tsgo 不能加载这类插件,所以初学者应使用项目安装的 TypeScript 6 或更低版本。
操作步骤:
- 先在项目根目录执行
npm install,确保node_modules/typescript存在。 - 在 VS Code 中打开任意
.ts或.tsx文件。 - 打开命令面板。
- 执行
TypeScript: Select TypeScript Version。 - 选择“Use Workspace Version”。
- 执行
Developer: Reload Window。
推荐在 .vscode/settings.json 中写明:
{
"typescript.tsdk": "node_modules/typescript/lib",
"js/ts.experimental.useTsgo": false,
"oak-assistant.metadataPath": "node_modules/.cache/oak-cli-wechat-mp-props/wechat-mp-component-props.json",
"oak-assistant.hoverComponentTags": true,
"oak-assistant.rainbowTags.enabled": true,
"oak-assistant.format.maxLineLength": 120,
"oak-assistant.debug.enabled": false
}
为什么关闭 tsgo?不是因为 Oak 不支持新 TypeScript 语法,而是 TypeScript 7 当前没有向 VS Code 扩展开放同样的 tsserver 插件加载能力。未切换时,原生组件 metadata、基础 WXML 诊断和格式化仍可工作,但完整实体语义、虚拟 render TSX 和精确类型映射会缺失。
4. 确认插件真正生效
安装成功不等于当前工作区已经正确加载。请执行:
oak-assistant: Show Status
状态页应能识别当前 Oak 项目、metadata 路径和已发现的组件。然后依次确认:
- VS Code 状态栏使用的是工作区 TypeScript。
- 项目已经执行过
npm install。 - 新增小程序 workspace 后已经执行过一次对应构建或 metadata 生成流程。
- 修改设置或插件版本后已经重新加载窗口。
如果仍然没有诊断,打开“输出”面板并选择 oak-assistant 通道查看原因。
5. 它会检查哪些文件
实体文件
对于 src/entities/*.ts,插件会检查:
Schema是否正确继承EntityShape;- 字段、反向指针和继承关系是否合法;
- Action、State、Relation、索引和 locale 是否匹配;
- 是否使用系统保留名称;
- 多重继承或循环关系是否冲突。
这对应传统后端或 ORM 开发中的“模型定义检查”。区别是 Oak 还会由实体继续生成前后端共享类型,所以实体错误会传播到页面、权限和数据库定义。
当前实体诊断会把同一文件的独立问题分别显示为 TS9300 - TS9327,范围落在实体名、属性、继承、ActionDef、locale 或 index 的真实节点。插件只读源码,不会替你运行或修改 make:domain 产物。
TSX render
插件会分析 web.tsx、web.pc.tsx、web.mobile.tsx、render.desktop.tsx、render.native.tsx 等 render 文件,并根据同目录 index.ts 生成 props 合同。
当前规范是:
export default function Render(props) {
return <div>{props.data.title}</div>;
}
不要再手写一个宽泛的 WebComponentProps。如果 title 没有声明,应该判断它到底来自:
properties:由上层组件传入;data:组件自己的状态;formData:查询结果整理后的渲染数据;methods:组件方法;- Oak 框架注入字段。
修复真实来源后,编译器和插件生成的合同才会一致。
复用已有 Oak 组件时
有时你只想复用公共包或另一个目录里的 Oak 逻辑,并在本地换一份平台 render。当前 CLI 和 Oak Assistant 支持下面这种精确的转发写法:
import OakComponent from '@oak-general-business/components/example';
export default OakComponent;
本地同目录可以直接写未标注 props 的 render:
export default function Render(props) {
return (
<button onClick={() => props.methods.submit()}>
{props.data.title}
</button>
);
}
插件会把原组件对应平台的 render props 继承到本地文件,因此 title、submit 等成员仍有补全、hover、跳转和错误诊断。不要为了让这个 wrapper 通过检查再复制一份 WebComponentProps,否则公共组件升级后,本地手写类型很容易与真实合同分叉。
这不是任意导出的自动猜测。当前必须同时满足:默认导入名是 OakComponent,并且直接写 export default OakComponent。如果本地 index.ts 自己定义了 OakComponent({...}),本地定义优先;如果你需要增加新的 properties 或改动业务行为,就应建立本地真实组件合同,而不是继续把它当作透明转发。
Less Module
插件会检查 Styles.page 对应的类名是否真实存在,支持补全、悬浮和跳转,也理解嵌套选择器的作用域。
这和普通 CSS 的区别是:类名不再只是运行时字符串,而是 render 合同的一部分。拼错 Styles.compoesr 应当在编辑时被发现,而不是上线后才看到样式丢失。
XML/WXML
插件会检查:
- 标签和原生属性;
usingComponents和 Oak 自定义组件 properties;data、properties、methods、formData;bindtap、bindinput等事件方法;wx:for循环变量作用域;wx:key;- XML 使用的 class 是否存在于 Less;
- 图片、模板等资源路径;
- Mustache 表达式的 TypeScript 类型。
传统小程序开发中,模板经常被当成字符串文件;Oak 会把模板转换为虚拟 TSX 做类型检查,因此 item.completed、事件 dataset 和组件属性都能与 index.ts 对齐。
国际化
render 和 XML/WXML 中的 t('key'),以及 OakComponent 入口中的 this.t('key'),会检查对应 locale key 是否存在。相对 key、common::key 和实体 locale 都有各自的查找规则。
鼠标悬浮静态 key 会逐行显示现有语言文本,Ctrl+点击 key 可跳到 locale JSON 的真实字段。动态 key、缺失 key 或 placeholder 参数不匹配会给出 warning,但不会生成误导性的定义链接。
6. 看懂一个典型错误
假设 render 写了:
<span>{props.data.ownerName}</span>
但 index.ts 没有 ownerName。不要先增加类型断言,而是先问:“这个值是谁提供的?”
如果由父组件传入:
export default OakComponent({
properties: {
ownerName: '',
},
});
如果由查询结果整理得到:
export default OakComponent({
data: {
ownerName: '',
},
formData({ data }) {
return {
ownerName: data?.owner?.name || '',
};
},
});
这一步训练的是“组件合同”意识:渲染层不能凭空使用字段。Oak Assistant 只是把原来隐藏在运行时的问题提前展示出来。
7. 调试模式只在排错时开启
当诊断映射难以理解时,可以临时设置:
{
"oak-assistant.debug.enabled": true
}
插件会输出虚拟文件:
node_modules/.cache/oak-mp-debug
node_modules/.cache/oak-render-debug
这些文件用于查看插件如何把 XML 或 render 转换成类型检查输入,不是项目源码,不要提交到 Git。排错结束后关闭调试模式。
常用命令:
| 命令 | 用途 |
|---|---|
oak-assistant: Reload Metadata | 重新读取小程序组件 metadata 并刷新诊断 |
oak-assistant: Show Status | 查看项目、TypeScript、metadata 和组件发现状态 |
oak-assistant: Show Current Props | 查看当前组件属性来自 properties、data、formData 还是框架注入 |
oak-assistant: Pick Current Prop | 打开当前 XML/WXML 标签的属性选择 |
oak-assistant: Toggle Component Tag Hover | 切换组件标签完整 props hover |
8. 本节知识归纳
| 已有知识 | 在本节中的对应关系 |
|---|---|
| 数据库 | 实体诊断类似在建表前检查模型字段、关系和索引 |
| 前端 | render props、Less class、WXML 变量共同构成组件输入合同 |
| 后端 | 实体和动作类型会继续约束后端查询与写入 |
| Oak | Oak Assistant 把编译器生成的领域和渲染合同提前带入编辑器 |
最终结论:Oak Assistant 负责早发现,npm run build 负责最终确认。两者都要使用。
下一步回到新手入门主线,从创建项目开始完成任务清单。
微信小程序:把完整组件树接到 XML
开始本章前,Web 端应当已经完成 Group + Todo + Upsert。小程序不是重新写 CRUD,而是为同一组 OakComponent 增加 index.xml、index.less 和 index.config.ts。
1. 添加 workspace
npx oak-cli add mp wechatMp
npm install
生成脚本后可以运行:
npm run start:mp:wechatMp
npm run build:mp:wechatMp
2. 保持同一棵节点树
frontend/home Group ListNode
└── <group-panel oakPath="...groupId">
└── <todo-list oakPath="...todo$group">
└── <todo-upsert oakPath="...todoId">
小程序页面仍然复用 Web 已写好的 index.ts。只为事件对象增加窄类型适配,例如:
type MpDatasetEvent = {
currentTarget: {
dataset: {
id?: string;
};
};
};
不要使用 any 接收小程序事件。
3. 配置页面组件
在 frontend/home/index.config.ts 的 mp 中声明:
mp: {
navigationBarTitleText: '任务清单',
enablePullDownRefresh: false,
usingComponents: {
'group-panel': '../../../components/group/panel/index',
'group-upsert': '../../../components/group/upsert/index',
},
},
组件自己的配置使用 CreateComponentConfig:
import { CreateComponentConfig } from '@oak-frontend-base/config';
export default CreateComponentConfig({
mp: {
usingComponents: {
'todo-list': '../../todo/list/index',
},
},
});
不要手改生成后的页面 JSON。
4. 首页 tabs 与 Group Modal
核心 XML:
<scroll-view scroll-x="{{true}}">
<button
wx:for="{{groups}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="selectGroupFromMp"
>
{{item.name || t('unnamedGroup')}}
</button>
</scroll-view>
<group-panel
wx:if="{{selectedGroupId && !editingGroupId}}"
oakPath="{{oakFullpath + '.' + selectedGroupId}}"
/>
<view wx:if="{{editingGroupId}}" class="modalMask">
<group-upsert oakPath="{{oakFullpath + '.' + editingGroupId}}" />
<button bindtap="cancelGroupFromMp">{{t('cancel')}}</button>
<button bindtap="saveGroupFromMp">{{t('save')}}</button>
</view>
selectedGroupId、editingGroupId 必须在 data 中预声明。XML 检查不会接受模板中凭空出现的字段。
5. Upsert XML
Group Upsert 的 index.ts 同时声明初始 data 和 formData:
data: {
name: '',
},
formData({ data }) {
return { name: data?.name || '' };
},
XML:
<input
value="{{name}}"
placeholder="请输入分组名称"
bindinput="setNameFromMp"
/>
Todo Upsert 同理声明 title 和 completed,再使用 <input> 与 <switch>。
6. Todo 关系列表
GroupPanel:
<todo-list oakPath="{{oakFullpath + '.todo$group'}}" />
TodoList 新增后挂 Upsert:
<todo-upsert
wx:if="{{editingTodoId}}"
oakPath="{{oakFullpath + '.' + editingTodoId}}"
/>
保存仍调用共享的 saveTodo(),由它执行父 Group path。小程序端不要另外手工拼 groupId。
7. 构建检查
npm run build
npm run build:mp:wechatMp
目标输出应包含:
[oak-xml-check] ... 0 XML diagnostics, 0 TypeScript diagnostics
0 个提示
重点处理:
OAK_MP_XML_UNDECLARED_DATA:字段没有在data/properties/formData合同中声明;- 组件属性不存在:检查
usingComponents指向的真实 OakComponent 及其properties; - 事件不存在:检查 XML 的
bind*名称是否与methods一致; oakPath拼错:检查最终树路径,尤其是todo$group。
8. 验证清单
在微信开发者工具中逐项验证:
- 新增 Group 并重进页面;
- tabs 切换后 Todo 相互隔离;
- 新增 Todo 后数据库得到正确
groupId; - 编辑、完成切换、删除均持久化;
- 取消 Modal 不产生记录。
完成后提交:
git add .
git commit -m "feat: add grouped Todo mini-program UI"
Desktop:在原生窗口中复用 Oak 组件树
Desktop 使用 Web renderer,但运行在 Tauri 或 Electron 原生壳中。业务节点仍然是 Group/Todo 组件树,Desktop 只增加 workspace、桌面 namespace 和 render.desktop.tsx。
1. 添加 Electron 或 Tauri
Electron:
npx oak-cli add desktop desktop-electron --runtime electron
npm install
Tauri:
npx oak-cli add desktop desktop-tauri --runtime tauri
npm install
生成的脚本示例:
npm run start:desktop:desktop-electron
npm run build:desktop:desktop-electron
2. 认识生成内容
desktop-electron/ Electron main、preload、renderer workspace
src/pages/desktop/home/ Desktop 首页 OakComponent
src/pages/desktop/settings/ 主题、语言和窗口材质设置
src/configuration/access.desktop.ts
后端地址仍由 Oak access 配置统一管理,不要在 Electron main 或 Rust 中复制端口。
build/、dist-electron/、Tauri target/ 是可重复生成的机器产物,应由 workspace .gitignore 忽略。
3. Desktop 首页使用 Group ListNode
src/pages/desktop/home/index.ts 与 Web 首页保持同样的 Group projection、sorter、formData 和 remove 方法。它是 Desktop namespace 下独立路由,但数据模型完全一致。
render 中使用 Fluent UI:
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
} from '@fluentui/react-components';
tabs 可以用一组稳定宽度的 Button 表示。选择 Group 后:
<GroupPanel oakPath={`${oakFullpath}.${selectedGroupId}`} />
Dialog 中新增或编辑:
<GroupUpsert oakPath={`${oakFullpath}.${editingGroupId}`} />
保存仍由 Group ListNode 调用 execute()。
4. 为共享组件增加 Desktop render
共享目录增加:
group/panel/render.desktop.tsx
group/upsert/render.desktop.tsx
todo/list/render.desktop.tsx
todo/upsert/render.desktop.tsx
GroupPanel 仍只挂关系:
return oakFullpath ? (
<TodoList oakPath={`${oakFullpath}.todo$group`} />
) : null;
Upsert 可使用 Fluent Field/Input/Switch,调用共享 index.ts 中的 setName、setTitle、setCompleted。不要把 Oak 数据操作搬进 React hook。
Todo Dialog 保存仍调用 saveTodo(),由它执行父 Group path。
5. 不要保留旧 props 绕过写法
Desktop 模板和业务 render 都使用编译器注入:
export default function Render(props) {
不要写:
WebComponentProps<EntityDict, 'user', false>
也不要用 //@ts-nocheck。无用户业务依赖的项目甚至没有理由把 Desktop 设置页伪装成 user SingleNode。
6. 构建与验证
npm run build
npm run build:desktop:desktop-electron
Electron production build会先构建 renderer,再执行 electron-builder。成功后生成 NSIS 安装包。
验证:
- 原生窗口和 Desktop 导航正常;
- Group tabs、Modal Upsert、Todo 关系列表正常;
- 刷新或重启应用后数据仍存在;
- 主题和语言设置页没有类型绕过;
dist-electron未进入 Git 状态。
git add .
git commit -m "feat: add grouped Todo Electron workspace"
Native:用 React Native 渲染同一棵组件树
Native 不使用 DOM、AntD 或 Fluent UI。它使用 React Native 控件,但实体、OakComponent、projection、oakPath、关系路径和 execute 层级全部复用。
1. 添加 Native workspace
npx oak-cli add rn native
npm install
当前 CLI 会生成:
npm run start:native:native
npm run build:native:native:android
npm run build:native:native:ios
npm run run:android:native
npm run run:ios:native
工作区包含 Android、iOS、Metro、路由、polyfill 和 Oak 初始化代码。
2. 开发环境
Android 需要:
- JDK;
- Android Studio 与 SDK;
ANDROID_HOME,或native/android/local.properties中的sdk.dir;- 模拟器或开启 USB 调试的设备。
local.properties、签名 keystore、token 和密码不能提交。教程项目也不提交 debug.keystore。
3. Native render 文件
为页面和共享组件增加:
render.native.tsx
render.native.scss
例如 GroupPanel:
import React from 'react';
import TodoList from '@project/components/todo/list';
export default function Render(props) {
const { oakFullpath } = props.data;
return oakFullpath ? (
<TodoList oakPath={`${oakFullpath}.todo$group`} />
) : null;
}
这与 Web/Desktop 的关系挂载完全相同。
4. Group tabs 与 Modal
使用 React Native 控件:
import {
Modal,
Pressable,
SafeAreaView,
ScrollView,
Text,
View,
} from 'react-native';
横向 ScrollView 渲染 Group tabs。选择后挂载:
<GroupPanel oakPath={`${oakFullpath}.${selectedGroupId}`} />
Modal 中挂载 GroupUpsert。取消调用父节点 clean(),保存调用父 Group ListNode 的 execute()。
5. Native Upsert
Group 使用 TextInput:
<TextInput
value={name}
onChangeText={setName}
/>
Todo 使用 TextInput 和 Switch:
<TextInput value={title} onChangeText={setTitle} />
<Switch value={completed} onValueChange={setCompleted} />
这些 setter 定义在共享 index.ts,内部调用 this.update(...)。render 只负责平台交互。
6. TodoList 的关系提交
TodoList Native render 仍通过:
<TodoUpsert oakPath={`${oakFullpath}.${editingTodoId}`} />
保存调用共享 saveTodo(),执行父 Group path,从而让 todo$group 级联绑定 groupId。不要在 Native render 中单独构造 operation。
7. 为什么不能用裸 tsc 判断 render props
Oak render 的 props 由 Oak 编译器注入。直接执行:
npx tsc -p native/tsconfig.json --noEmit
可能把 function Render(props) 报成隐式类型,因为这条命令绕过了 Oak render transform。项目级检查使用:
npm run build
Native 打包使用 Oak CLI 或 Metro。
8. 构建
完整 Android production build:
npm run build:native:native:android
它会生成路由、执行原生依赖 codegen、Metro bundle 和 Gradle release 构建。
如果当前机器没有 Android SDK,可先验证 JS/render 链路:
cd native
npx cross-env NODE_ENV=production OAK_PLATFORM=native react-native bundle \
--entry-file=index.js \
--bundle-output=../node_modules/.cache/oak-tutorial/native/index.android.bundle \
--assets-dest=../node_modules/.cache/oak-tutorial/native/assets \
--dev=false \
--platform=android
看到 Done writing bundle output 和 Done copying assets,说明路由、Oak transform、Native render、Sass 与 JS 依赖已成功打包。它不能代替最终 APK 构建,但能把“代码问题”和“本机 SDK 问题”分开。
9. 验证
- Group tabs 可以横向滚动和切换;
- Group/Todo Modal Upsert 可新增和编辑;
- Todo 保存后关联正确 Group;
- 重启 App 后数据仍存在;
- Android cleartext/network 配置与 Oak access 地址一致;
- Git 中没有
local.properties、keystore 或 bundle 产物。
git add .
git commit -m "feat: add grouped Todo Native workspace"
知识归纳:从全栈 CRUD 到 Oak 组件树
前面的主教程不是只做了一个任务列表。我们先用 Todo 完成 Web CRUD,再增加 Group 与 Todo 的一对多关系,最后把同一套业务节点带到小程序、Desktop 和 Native。
现在回头整理这些代码,可以把 Oak 理解为:用实体统一数据事实,用组件树统一前端数据节点与待提交操作,再用不同 render 适配不同平台。
1. 先看最终业务模型
数据库关系是:
Group 1 -------- N Todo
|
+-- groupId
每个 Todo 必须属于一个 Group。它在 Oak 中由两份实体源文件表达:
src/entities/Group.ts:分组名称;src/entities/Todo.ts:任务标题、完成状态,以及指向 Group 的引用。
执行 npm run make:domain 后,不要凭经验猜反向关系名,要到生成领域中确认。这个项目生成的是:
todo$group
它表示“通过 Todo.group 这条关系,反向取得当前 Group 下的 Todo 列表”。
2. 数据库知识如何映射到 Oak
| 数据库概念 | 教程项目 | Oak 对应 |
|---|---|---|
| 表 | group、todo | 实体 Storage |
| 一行 | 一个分组、一条任务 | 实体 Schema |
| 主键 | id | EntityShape 提供的 id |
| 外键 | todo.groupId | Todo.Schema.group 引用 |
| 一对多 | 一个 Group 有多个 Todo | 生成关系 todo$group |
| SELECT 列 | id、name、title | projection |
| WHERE | 只查某组任务 | 组件树关系节点与 filter |
| ORDER BY | 新任务优先 | sorters |
| INSERT | 新增 Group/Todo | addItem 形成 create |
| UPDATE | 改名、改标题、完成任务 | update / updateItem |
| DELETE | 删除记录 | removeItem |
| migration | 给已有表加字段或关系 | 数据库升级计划 |
实体与数据库迁移是两件事
修改实体后运行:
npm run make:domain
npm run make:locale
这会更新代码、类型、Storage 与多语言数据,但不会擅自修改一个已有生产数据库。已有数据库仍要生成、审核并执行升级计划。这与传统 ORM 中“改 model”和“执行 migration”是两件事完全一致。
projection 是明确的数据合同
例如 TodoList 只需要:
projection: {
id: 1,
title: 1,
completed: 1,
$$createAt$$: 1,
},
它类似明确写出 SQL 的 SELECT 列。projection 还会被生成类型检查,因此字段拼错、关系不存在或结果使用方式错误,都能更早暴露。
3. 普通 React 组件树与 Oak 组件树的区别
普通 React 组件树主要说明“谁渲染谁”。Oak 组件树还要说明:
- 当前组件对应哪一个实体数据节点;
- 查询结果缓存在哪个节点;
- create、update、remove 操作属于哪个节点;
execute()从树的哪一层收集并提交操作。
教程项目最终的树是:
frontend/home Group ListNode
└── frontend/home.<groupId> Group SingleNode
└── frontend/home.<groupId>.todo$group Todo ListNode
└── ...todo$group.<todoId> Todo SingleNode Upsert
Group ListNode
首页是 entity: 'group'、isList: true,负责查询所有 Group、渲染 tabs、创建 Group 草稿,并持有 Group Modal 的保存和取消。
Group SingleNode
选中一个 tab 后,页面用:
<GroupPanel oakPath={`${oakFullpath}.${selectedGroupId}`} />
把 GroupPanel 挂到具体 Group 节点。GroupPanel 不重复查询 todo,也不自己保存 todo;它只负责继续挂载关系节点。
todo$group ListNode
GroupPanel 使用:
<TodoList oakPath={`${oakFullpath}.todo$group`} />
这里不是把 groupId 当普通 props 传给 TodoList,而是把 TodoList 挂到 Group 的反向关系节点。runningTree 因此知道这些 Todo 属于当前 Group。
Todo SingleNode Upsert
新增或编辑 Todo 时,TodoList 在 Modal 中挂载:
<TodoUpsert oakPath={`${oakFullpath}.${editingTodoId}`} />
Upsert 只负责字段编辑:标题输入调用 this.update({ title }),完成状态调用 this.update({ completed })。Modal 的打开、保存和取消由拥有列表操作的父组件负责。
4. oakPath 不是普通 props
oakPath 是组件在 runningTree 中的地址。它决定子组件连接哪个数据节点,也决定操作如何沿组件树组织。
可以把它类比为 React key、路由 path、ORM relation path 和表单字段 path 的结合,但它同时承担这些职责,所以不能随意拼一个“看起来唯一”的字符串。关系段必须使用生成领域中的真实名字,例如 todo$group。
5. Upsert 为什么同时支持新增和更新
TodoList 新增时先调用:
const id = this.addItem({
title: '',
completed: false,
});
addItem() 在 Todo ListNode 中建立 create 操作并返回新 id。随后 TodoUpsert 挂到这个 id 的 SingleNode,输入字段时继续修改这条待创建记录。
编辑已有 Todo 时,挂载方式完全相同,只是 id 指向数据库已有记录,this.update(...) 形成 update 操作。因此同一个表单既能 insert,也能 update,这就是本教程中 Upsert 的含义。
6. 最容易误解的规则:操作属于哪个节点
新增 Group
Group ListNode.addItem() 创建 Group,因此保存 Group 时由首页 Group ListNode 执行:
await this.execute();
如果只在 GroupUpsert SingleNode 中执行,父列表持有的 create 可能没有被一起提交。
新增关系 Todo
Todo ListNode.addItem() 在 group.todo$group 节点创建 Todo,但这条 create 还依赖父 Group 提供关系上下文。只执行 Todo SingleNode 不够;只执行关系 ListNode,也可能缺少把新 Todo 绑定到父 Group 所需的级联上下文。
教程项目取得 TodoList 的父路径并执行 Group SingleNode:
async saveTodo() {
const todoListPath = this.state.oakFullpath;
if (!todoListPath) {
throw new Error('Todo list node is not mounted');
}
const separatorIndex = todoListPath.lastIndexOf('.');
const groupPath = todoListPath.slice(0, separatorIndex);
await this.execute(undefined, undefined, groupPath);
}
这样 runningTree 会从 Group 节点收集关系子树中的操作,级联提交时为 Todo 绑定 groupId。
这条规则可以归纳为:
先判断 create/update/remove 建立在哪个节点
-> 再判断关系外键依赖哪一层祖先上下文
-> 从能覆盖完整操作子树的节点 execute
不要把“离保存按钮最近的组件”当成默认执行节点。
7. data、formData、properties 与 render
| 合同 | 用途 | 教程例子 |
|---|---|---|
data | 组件本地状态和稳定初值 | selectedGroupId、editingTodoId |
formData | 把实体结果整理成 render 需要的形状 | groups、todos |
properties | 父组件传入的业务参数 | 只有确实需要普通输入时声明 |
methods | render 可调用的业务交互 | saveTodo、setTitle |
| 框架注入 | Oak 节点状态与标准方法 | oakFullpath、oakLoading |
当前 Oak 编译器会根据同目录 index.ts 生成 render props。标准 TSX render 写:
export default function Render(props) {
不要手写宽泛 props 类型。render 使用了未声明字段时,应回到真实来源修复:父组件输入放进 properties,查询派生结果放进 formData,本地状态放进 data,交互函数放进 methods。
小程序 XML 也使用同一合同检查字段、事件、组件属性和 class。any、never、@ts-nocheck 或虚假的 props 声明都只是隐藏问题,不会建立真实运行时合同。
8. 前端、后端和 Oak 的职责边界
| 问题 | 应放的位置 |
|---|---|
| Modal 是否打开、当前 tab | 页面或组件 data |
| Group/Todo 查询与待提交操作 | Oak component node |
| 标题字段编辑 | Upsert this.update(...) |
| 标题不能为空 | checker |
| 完成任务后写审计记录 | trigger |
| 一次处理多个实体的复合业务 | aspect |
| 第三方 HTTP 回调 | endpoint |
| 定期处理过期任务 | timer / watcher |
| 数据库存取 | backend context + oak-db |
普通 CRUD 不需要为每个实体重复写 Controller、DTO 和 API client,因为实体、operation、context 与 runningTree 已经形成统一合同。但跨入口都必须成立的业务规则,仍必须放到 checker、trigger 或其它后端扩展点,不能只写在四个平台的 render 中。
9. 四个平台共享什么
| 层 | Web | 小程序 | Desktop | Native |
|---|---|---|---|---|
| Group/Todo 实体 | 共享 | 共享 | 共享 | 共享 |
关系名 todo$group | 共享 | 共享 | 共享 | 共享 |
| Group/Todo 组件逻辑 | 共享 | 共享 | 共享 | 共享 |
| 节点执行层级 | 共享 | 共享 | 共享 | 共享 |
| 渲染技术 | React/AntD | XML/WXML | React/Fluent UI | React Native |
| 平台事件适配 | DOM | 小程序 event | DOM/Fluent | Native event |
跨端复用不是强迫四端使用同一套标签,而是共享业务事实、组件节点和操作语义。render 只负责把这些能力翻译成平台 UI。
因此正确顺序是:先在 Web 把实体关系和组件树验证稳定,再增加小程序、Desktop、Native render。否则每个平台都会重复放大同一个模型错误。
10. 完整数据链
flowchart TD
A["Group.ts / Todo.ts"] --> B["make:domain"]
B --> C["EntityDict / Storage / todo$group"]
C --> D["Group ListNode"]
D --> E["Group SingleNode"]
E --> F["todo$group ListNode"]
F --> G["Todo Upsert SingleNode"]
G --> H["runningTree pending operations"]
H --> I["execute correct ancestor"]
I --> J["backend context"]
J --> K["checker / trigger / oak-db"]
K --> L["SQLite / MySQL / PostgreSQL"]
L --> M["opRecords and cache refresh"]
M --> N["Web / MP / Desktop / Native render"]
11. 新增需求时的固定步骤
假设要给 Todo 增加截止日期:
- 从数据库角度判断字段类型、是否可空、旧数据和索引。
- 修改 Todo 实体与 locale。
- 运行
make:domain、make:locale,检查生成类型。 - 把日期加入 TodoList 和 TodoUpsert projection。
- 在 Upsert
index.ts增加统一 setter。 - Web 先实现日期控件并完成真实 CRUD。
- 若日期规则必须全端成立,写 checker,不在 render 中复制。
- 再为小程序、Desktop、Native 增加平台日期控件。
- 对已有数据库生成并审核升级计划。
12. 提交前检查
- 实体变化后运行
make:domain、make:locale、build和项目要求的升级命令。 - 真实验证 Group 与 Todo 的新增、查询、更新、删除。
- 确认新增 Todo 的
groupId正确,不只是界面显示在某个 tab 下。 - 运行目标平台构建,处理 XML、render props 和 Less Module 检查。
- 搜索并清除
any、never、@ts-nocheck和手写宽泛 props。 - 不提交数据库密码、token、
local.properties、keystore 或本地数据库文件。
回到新手入门主线,或继续查看微信小程序、Desktop与Native的完整实现。
创建项目
Oak 项目的推荐创建方式,是直接使用 oak-cli 生成一个完整的工程骨架。这样生成出来的目录结构、脚本、依赖配置和多端入口都是符合 Oak 规范的,后续再去阅读本书中的其它章节时,也更容易一一对应起来。
如果你是第一次接触 Oak,建议先从新手入门:从零完成一个任务清单应用开始。那一章会从本命令开始,连续完成安装依赖、定义实体、生成领域代码、编写页面 CRUD、初始化数据库和添加其它端;本页适合在完成第一次实践后回来查阅创建选项。
创建命令
在你准备放置项目的目录下执行:
oak-cli create my-first-oak-app
oak-cli 在源码中的入口定义位于 oak-cli/src/index.ts,创建项目的实现位于 oak-cli/src/create,其中核心命令是:
program
.command('create <name>')
.option('-d, --dev', 'dev')
.option('-m, --module', 'module')
.action(create);
其中:
create <name>:创建一个完整的 Oak 应用工程;--dev:按 Oak 家族仓库的本地联调方式生成依赖,适合你当前这种直接维护框架源码的开发环境;--module:只创建一个 Oak 模块,而不是完整应用。模块模式下不会生成web、wechatMp等前端工程骨架,适合编写一个可复用的业务包。
创建过程中会询问什么
执行创建命令后,oak-cli/src/create/index.ts 会进一步询问一些信息:
- 项目显示名
title - 版本号
version - 项目描述
description - 应用需要的数据库驱动:MySQL、PostgreSQL、SQLite,至少选择一个,默认 MySQL
- 是否默认依赖
oak-general-business - 是否还依赖其它 Oak 家族模块
- 是否加载一个示例工程
其中最值得新手注意的是 oak-general-business。它是 Oak 官方抽象出来的一套通用业务逻辑,包含了用户、应用、会话、文件、微信、OAuth 等大量基础能力。绝大多数业务项目在一开始都应该勾选它,后面本书最后一章也会专门介绍它。
创建命令不会再询问平台多选。完整应用默认只生成 Web workspace;微信小程序、React Native、Desktop 在项目创建后按需通过 oak-cli add 添加。数据库驱动选择只出现在应用模式,模块模式不会生成数据库配置。
--dev 和普通模式的区别
如果使用 --dev,生成出来的 package.json 会把 Oak 家族依赖指向本地相邻目录,例如:
"oak-domain": "file:../oak-domain",
"oak-frontend-base": "file:../oak-frontend-base",
"oak-cli": "file:../oak-cli"
这正是当前 Oak 仓库的常见开发方式。也就是说,如果你要在本地同时修改 oak-domain、oak-frontend-base、oak-general-business 等库,那么项目目录最好和这些仓库处在同一级目录下,否则这些本地依赖路径就无法正确解析。
如果不使用 --dev,oak-cli 会从 npm 上读取 Oak 家族包的最新版本,并写入 semver 版本号。此时后续 make:domain 读取的是发布包实体产物,优先级为 es/entities -> lib/entities -> src/entities。
新发布的 Oak 模块不应该靠发布 src 解决编译问题。发布包应带上 es/entities/*.d.ts 和同名 .js,编译器会自动合并类型声明和运行时值。
创建命令实际做了什么
oak-cli/src/create/index.ts 会完成下面这些事情:
- 检查目标目录是否已经存在;
- 复制模板目录;
- 在完整应用模式下生成默认
webworkspace;小程序、React Native 和 Desktop workspace 留给后续oak-cli add; - 生成
oak.config.json和oak.config.ts,前者保存项目运行/检查配置,后者保存当前 Vite / webpack / alias / CDN 等编译配置; - 生成
tsconfig.lib.json、tsconfig.es.json、src/tsconfig.json、tsconfig/paths.*.json和web/tsconfig.json;新增平台时再生成对应 workspace 配置; - 生成
package.json、Oak 默认脚本、推荐 Web CDN 配置、可选build:bundle和升级脚本入口; - 把你选择的依赖写入
src/configuration/dependency.ts; - 在页面、组件和 Web 命名空间目录中放置
index.config.ts,作为新项目的结构化配置入口; - 将模板中的默认项目名统一重命名为你输入的名字;
- 根据数据库选择只保留对应驱动依赖和
configuration/mysql.json、postgres.json、sqlite.json; - 如果勾选示例,额外复制一套示例业务代码。
因此,Oak 的“创建项目”并不是单纯地拷贝几份空文件,而是顺手把后续最关键的开发基础设施一起准备好了。
新项目里的配置入口
当前模板已经不再建议把页面、组件和命名空间配置继续散落在 index.json 里。新项目应优先使用同目录下的 index.config.ts:
import { CreatePageConfig } from '@oak-frontend-base/config';
import { CreateComponentConfig } from '@oak-frontend-base/config';
import { CreateNamespaceConfig } from '@oak-frontend-base/config';
三类配置入口的职责不同:
src/pages/**/index.config.ts:页面配置,使用CreatePageConfig。这里声明 Web 路由、菜单、访问控制以及小程序页面 JSON 能力。src/components/**/index.config.ts:组件配置,使用CreateComponentConfig。这里声明小程序组件 JSON 能力,例如usingComponents、componentGenerics、styleIsolation。web/src/app/namespaces/<namespace>/index.config.ts:Web 命名空间配置,使用CreateNamespaceConfig。这里声明命名空间路由入口、菜单分组、默认访问策略和控制台上下文实体范围。
index.json 仍然是兼容入口,旧项目可以继续读取;但新页面、新组件和新命名空间都应该把配置写进 index.config.ts。小程序相关 JSON 字段放在 mp 字段下,由 oak-cli 在构建时生成最终的页面或组件 JSON。
命名空间目录还会配合一个生成文件:
web/src/app/namespaces/
├── console/
│ └── index.config.ts
├── frontend/
│ └── index.config.ts
├── allNamespaceConfigs.ts
└── index.ts
其中 allNamespaceConfigs.ts 由 oak-cli 根据命名空间配置和页面菜单配置生成,不应该手工维护;index.ts 负责重新导出聚合结果;web/src/index.tsx 会把它传给 oak-frontend-base/platforms/web/initialize 的 namespaceConfigs 参数。
创建完成后的第一步
项目创建完成后,建议按下面的顺序继续:
npm install
npm run dev
这三步分别对应:
- 安装依赖,并由
postinstall顺序执行project:init、make:domain、make:dep; - 同时启动后端
server:start和 web 端start:web进行联调。
当前模板的普通应用会把 postinstall 配成 npm run project:init && npm run make:domain && npm run make:dep。因此首次 npm install 后通常已经完成依赖模板初始化、领域代码和依赖装配生成。后续如果增删 Oak 依赖模块,仍应显式按这个顺序重新执行,保证依赖模板先落盘、领域类型包含最终实体、装配代码最后生成。
创建应用和创建模块
很多初学者一开始会分不清 Oak 应用和 Oak 模块。
二者的区别可以先简单理解为:
- Oak 应用:最终可以独立运行,有自己的前端入口、后端入口、数据库配置和部署流程;
- Oak 模块:只提供实体、逻辑、特性、页面或组件,供别的 Oak 应用依赖。模块保留跨端源码和类型基线,但不生成可独立运行的平台 workspace。
例如 oak-general-business 就是一个典型的模块,而 bm-smart 是一个完整应用。Oak 的依赖编译能力,正是为了把这两种代码组织方式统一起来。
所以如果你的目标是“做一个业务系统”,请优先创建完整应用;如果你的目标是“沉淀一套可复用业务包”,再考虑使用 --module。
一个推荐的最小起步方式
如果你是第一次接触 Oak,最推荐的起步方式其实非常简单:
- 在 Oak 家族仓库同级目录下执行
oak-cli create xxx --dev; - 依赖
oak-general-business; - 执行
npm install;当前模板会在postinstall中完成project:init、make:domain和make:dep; - 用
npm run dev同时启动后端和 web; - 编写一个最小的
Entity和一个最小的页面。
这样学习曲线是最平缓的,也符合当前 Oak “开发时就使用真实后端运行态”的默认模板。旧的纯前台模式已经移除,不要再按 start:web 单独替代后端来理解。
需要其它平台时,再按多平台工作区添加,不必在创建当天把所有原生工具链一并安装。
多平台工作区
当前 Oak 应用把业务源码和平台壳分开组织:src 保存实体、业务逻辑、页面和组件,Web、微信小程序、React Native、Desktop 各自拥有独立 workspace。新应用默认只创建 Web;其它平台在真正需要时通过 oak-cli add 加入。
平台模型
| 平台 | 添加命令 | 构建目标 | 说明 |
|---|---|---|---|
| Web | 默认生成,或 oak-cli add web web2 | web | 浏览器 renderer |
| 微信小程序 | oak-cli add mp wechatMp | mp / wechatMp | Vite 小程序编译与微信产物 |
| React Native | oak-cli add rn native | rn / native | iOS、Android 原生应用 |
| Desktop | oak-cli add desktop desktop | desktop | 默认 Tauri,也支持 Electron |
mp、rn 是命令别名,内部会规范为 wechatMp、native。workspace 名可以自定义,CLI 会把 --subDir 写入对应 npm script;不要在业务代码里假设目录一定叫 wechatMp、native 或 desktop。
添加 workspace 后重新执行:
npm install
npm run make:locale
oak-cli add 会同步依赖、平台 TypeScript 配置和 npm scripts,但不会替你安装刚写入 package.json 的依赖。
Desktop:Tauri 与 Electron
创建 Tauri workspace:
oak-cli add desktop desktop
创建 Electron workspace:
oak-cli add desktop desktop-electron --runtime electron
不传 --runtime 时默认使用 Tauri。生成后会出现类似脚本:
{
"start:desktop:desktop": "oak-cli start --target desktop --render tauri --subDir desktop",
"build:desktop:desktop": "oak-cli build --target desktop --render tauri --subDir desktop"
}
Desktop 是 Web renderer 的一种运行形态:编译时 OAK_PLATFORM 仍为 web,再通过 OAK_RENDER=tauri 或 OAK_RENDER=electron 区分原生壳。oak-general-business 中的应用仍使用 Web Application 类型,并通过 config.render 和 identifyId 区分浏览器、Tauri、Electron。不要创建自定义 desktop AppType。
生成内容
每个 Desktop workspace 都包含 .oak-desktop.json:
{
"schemaVersion": 1,
"runtime": "tauri",
"identifyId": "00000000-0000-4000-8000-000000000000"
}
实际 identifyId 由 CLI 生成并保持稳定。它用于后端匹配对应 Desktop Application,不是用户登录凭证,也不应交给 renderer 业务代码自行修改或伪造。
Tauri workspace 还包含 src-tauri Rust 工程;Electron workspace 包含薄的 main/preload 入口和 electron-builder.yml。两种运行时共用 Desktop 页面、Fluent UI 壳、路由、权限菜单、系统主题和离线 locale 装载方式。
启动与构建
npm run server:start
npm run start:desktop:desktop
Desktop 命令会先启动 Vite renderer,等待端口可访问,再启动 Tauri 或 Electron;任一子进程退出时会清理另一侧。Tauri 使用 tauri.conf.json 的固定 devUrl 端口,默认是 1420;Electron 从指定端口或 1420 开始寻找空闲端口。
生产构建:
npm run build:desktop:desktop
Tauri 构建 renderer 后调用 Tauri CLI;Electron 构建 renderer 后调用 electron-builder。Desktop 产物必须离线可启动,所以生产 renderer 不使用 Web CDN external,React 等运行依赖会进入本地 bundle。
后端访问与离线启动
Web 和 Desktop 都从 src/configuration/access.ts 解析 Oak 后端 URL。不要再从 common.ts 单独派生 Desktop 端口,也不要把第二份后端地址写进 Rust、Electron main 或 CSP。
Desktop 对配置命中的 localhost / 127.0.0.1 Oak 请求使用原生桥接,并注入 renderer 与 application 识别元数据;其它外部 URL 继续使用浏览器 fetch。identifyId 只应在原生边界注入。
Desktop 启动和构建会先生成并复制 src/data/i18n.json。Tauri 把它嵌入资源,Electron 把它打进应用包;Locales feature 会把离线数据同步进正常 Cache。缺少该文件时,先执行:
npm run make:locale
应用解析还可以保存并回放已验证的 Application 查询快照,在真实网络异常时支持离线启动;普通业务查询不会因此自动变成离线数据库。
Desktop render 选择
普通模块选择顺序以 Desktop 和操作系统入口优先,然后回退到 Web:
.desktop.* -> .windows|.macos|.linux.* -> .web.* -> 普通入口
Oak 页面和组件 render 的回退顺序是:
render.desktop.tsx
render.windows.tsx / render.macos.tsx / render.linux.tsx
web.pc.tsx
web.tsx
共享组件通常不需要专门写 Desktop render;只有桌面交互确实不同才增加 render.desktop.tsx 或操作系统入口。
微信小程序 workspace
oak-cli add mp wechatMp
npm install
npm run start:mp:wechatMp
当前 Vite 小程序构建会根据页面配置生成 canonical route map。业务导航应使用 Oak navigator 的 /namespace/page 逻辑路径;静态的 wx.navigateTo、redirectTo、reLaunch、switchTab URL 也会尝试在构建时改写。不要手写 /pages/.../index 或分包产物路径。
switchTab 不能携带 query 或 state。动态拼接且无法静态识别的路径会产生 warning,应改为 Oak navigator 或明确的静态路由。
小程序还支持:
index.config.ts生成页面/组件 JSON;- XML/WXML 表达式、事件、properties 和 class 类型检查;
- 分包与独立分包 chunk、asset、WXS/i18n 输出;
- 构建产物图片压缩和 Node polyfill 白名单;
- bundle treemap、分包统计和反向依赖图。
这些编译选项见编译与构建配置。
React Native workspace
oak-cli add rn native
npm install
npm run start:native:native
npm run run:android:native
iOS 使用 run:ios:<workspace>。Metro 只负责 JavaScript 开发服务,原生 SDK、签名、证书和渠道包仍由 React Native、Android Studio 和 Xcode 工程管理。
当前 workspace 同时生成生产构建脚本:
npm run build:native:native:android
npm run build:native:native:ios
Android 命令会继续进入原生依赖 codegen、Metro bundle 与 Gradle release 构建;没有配置 ANDROID_HOME 或 native/android/local.properties 时会在 SDK 检查处失败。不要把 local.properties、keystore、签名密码或渠道凭证提交到仓库。
页面/组件优先使用 render.native.tsx 与 render.native.scss,需要平台差异时再增加 render.ios.tsx 或 render.android.tsx。
多 workspace 注意事项
- locale、router、alias 和 TypeScript 配置根据 npm scripts 中的
--target、--subDir发现 workspace,不应硬编码默认目录。 - 新 workspace 使用自己的
tsconfig.json;根配置主要负责共享源码和编辑器聚合。 oak-cli add --from <dir>会复制一个完整 workspace,不再覆盖内置平台 scaffold;传入目录必须自己满足目标运行时合同。- 页面、菜单、route access 和 namespace config 仍来自共享
src/pages/**/index.config.ts,平台 workspace 消费生成结果,不应在运行时重新扫描源码。
运行项目
Oak 项目高频使用的脚本,已经从“前台/前后台两套模式”收敛成“目标端开发进程 + 真实后端进程 + 生成类脚本”三类。当前模板里最值得先记住的是下面这些。
1. 生成类脚本
make:domain
npm run make:domain
把当前项目和依赖模块的实体定义编译成 src/oak-app-domain。
当前项目模板应让它读取 ES 构建配置:
oak-cli make:domain --configFile ./tsconfig.es.json
旧项目如果仍缺少这个参数,可先执行 oak-cli migrate:tsconfig --dry-run 检查迁移计划,再执行正式迁移。不要让领域生成和 build:es 分别读取两套 aliases。
依赖模块实体读取顺序是:
es/entitieslib/entitiessrc/entities
其中 lib/entities 只是历史兼容回退。生产发布包应优先提供 es/entities/*.d.ts 和同名 .js,不要再依赖发布 src。
make:locale
npm run make:locale
把项目和依赖模块中的多语言资源编译成 src/data/i18n.json,并生成兼容入口 src/data/i18n.ts。
当前 i18n.json 承载真实 i18n 行数据,i18n.ts 只是从同目录 JSON 读取并导出。后续 build:lib / build 会把这个相对 JSON 运行时资产复制到输出目录,因此不要只提交或发布其中一个文件。
make:dep
npm run make:dep
根据 src/configuration/dependency.ts 生成初始化、特性装配和运行时类型文件。默认只创建缺失的标准目标,不覆盖已有定制文件;只有显式使用底层 oak-cli make:dependency --rebuild 才会重建。当前普通应用的标准目标主要包括:
src/initialize.server.tssrc/initializeFeatures.tssrc/features/index.tssrc/types/RuntimeCxt.tssrc/types/DependentExceptions.ts
旧模板里的 src/initialize.frontend.ts 不再是当前纯前台调试入口;新项目不要围绕它做初始化拼接。
project:init
npm run project:init
根据已安装的 oak.business.module 依赖刷新 src/configuration/dependency.ts,并按项目初始化状态新增或增量补齐依赖模板。首次初始化、存在 pending 状态或使用 --force 时会覆盖模板;依赖集合变化后的普通增量模式会保留已有文件,但仍会补回缺失的模板文件。成熟项目如果有意删除或替换过模板文件,应先检查 .oak-project-init.json 状态再执行。
普通应用模板的 postinstall 顺序是:
npm run project:init
npm run make:domain
npm run make:dep
这个顺序保证模板实体先创建,领域代码再根据最终实体生成,最后才生成依赖装配文件。
2. 开发类脚本
dev
npm run dev
当前模板最常用的开发入口。它同时启动:
npm run server:start
npm run start:web
也就是说,dev 不是旧意义上的纯前台模式,而是后端和 web 开发服务一起启动。
start:web
npm run start:web
只启动 web 开发服务。它不再代表“把后端逻辑全部跑在浏览器里”的旧前台模式。
start:mp:<workspace>
npm run start:mp:wechatMp
启动微信小程序开发服务。当前新应用默认只生成 Web;先通过 oak-cli add mp wechatMp 添加默认小程序 workspace,才会获得这组脚本。需要后端时,另开终端运行 server:start。
start:native:<workspace>
npm run start:native:native
启动 React Native 开发服务。当前新应用需要先执行 oak-cli add rn native。实际生成的脚本带 workspace 后缀:
npm run start:native:native
npm run run:android:native
npm run run:ios:native
当前 workspace 还会生成生产构建命令:
npm run build:native:native:android
npm run build:native:native:ios
最后一个 native 是 workspace 名;如果创建时使用 oak-cli add rn mobile,对应后缀就是 mobile。Android 生产构建仍要求本机配置 JDK、Android SDK、ANDROID_HOME 或 native/android/local.properties;iOS 构建要求 macOS 与 Xcode。Metro bundle 成功只能验证 JavaScript、路由和 render 链路,不能代替 APK/IPA 构建。
旧文档里的无 workspace 后缀 run:android、run:ios 和 run:bundle 不是当前模板命令。
Desktop workspace scripts
Desktop 通过 oak-cli add 生成带 workspace 名的脚本。例如:
oak-cli add desktop desktop
npm install
npm run start:desktop:desktop
npm run build:desktop:desktop
Electron workspace:
oak-cli add desktop desktop-electron --runtime electron
npm install
npm run start:desktop:desktop-electron
这些脚本内部调用 oak-cli start/build --target desktop --render tauri|electron --subDir ...。CLI 同时管理 Vite renderer 和原生宿主,不需要另外手工启动 tauri dev 或 Electron main。
clean:cache
npm run clean:cache
清理本地构建缓存,适合在切换目标端或遇到缓存异常时使用。
3. 后端类脚本
build
npm run build
当前模板先通过 prebuild 自动执行 make:locale,随后 build 等价于 build:lib && build:es && copy-config-json。build:lib 生成后端运行需要的 lib 产物;build:es 使用 tsconfig.es.json 做 noEmit 检查,并启用 XML 类型检查、render 注入声明和严格 Less Module 检查。web 和小程序产物使用独立目标命令:
npm run build:web
npm run build:mp
build:bundle
npm run build:bundle
这是新应用模板提供的 opt-in server runtime bundle。它并行执行 TypeScript 检查与 esbuild 后端打包,输出 dist/server.js、dist/package.json、dist/target、dist/configuration、dist/oak-packages 等部署内容。
该能力目前仍是可选发布路径,普通 npm run build / build:lib 不会自动切换到 bundle。使用前应阅读部署,确认数据库驱动、第三方运行依赖和根配置文件都已进入预期位置。
server:init
npm run server:init
仅在首次部署到空数据库时初始化结构与 seed。当前模板通常使用 dropIfNotStatic,会删除并重建所有非 static 表;它不是刷新初始化数据的增量命令。已有开发库或生产库应生成并审核结构升级计划,并用明确的数据迁移更新 seed。
db:upgrade:plan
npm run db:upgrade:plan
对比当前编译后的 lib/oak-app-domain/Storage 和目标数据库,生成结构升级计划。默认输出到 .oak-upgrade/<时间戳>,只写 migration.sql、rollback.sql、summary.json、table-changes.json、warnings.json 和 rename-candidates.json,不会修改数据库。
常用参数是:
npm run db:upgrade:plan -- -o .oak-upgrade/release-20260508
npm run db:upgrade:plan -- --largeTableRowThreshold 500000
npm run db:upgrade:plan -- --execute
--execute 会执行排序后的结构升级 SQL,且不会自动跳过 manualSql。如果计划里有 manualSql、warnings 或 renameCandidates,先人工确认再进入发布执行。
server:start
npm run server:start
启动真正的 Oak 后端运行态。
4. 最常见顺序
日常开发最常见的是:
npm run make:domain
npm run make:dep
npm run dev
需要先起后端再单独起 web 时,可以拆成:
npm run server:start
npm run start:web
需要联调多端时,则通常是:
npm run server:start
npm run start:mp:wechatMp
npm run start:native:native
其中 MP、Native、Desktop 脚本只在对应 workspace 已添加时存在。这里的 wechatMp 和 native 是示例 workspace 名;自定义名称会生成对应后缀,例如 start:mp:mp-admin、start:native:mobile、start:desktop:ops-desktop。
不要再把 start:web、start:mp 理解成旧文档里的“前台模式”。
开发
当前 Oak 模板已经移除了旧的“纯前台模式”。开发时应把后端运行态视为默认组成部分:web / 小程序 / App 负责目标端渲染,server:start 负责真实的 Oak 后端运行、数据库访问、aspect、endpoint、watcher、timer 和 routine。
1. 生成领域代码
修改项目实体,或升级依赖模块实体产物后,先执行:
npm run make:domain
依赖模块实体会优先从 es/entities 读取,其次才是 lib/entities 和 src/entities。生产发布包不应要求发布 src。
如果增删 Oak 依赖模块,还应执行:
npm run project:init
npm run make:dep
普通应用模板的 postinstall 已经会自动执行 project:init && make:domain && make:dep,但依赖发生变化时仍建议显式按这个顺序执行一次,避免领域类型仍引用旧的依赖实体。
2. Web 开发
最常用命令是:
npm run dev
它等价于同时运行:
npm run server:start
npm run start:web
start:web 只启动 web 目标端的 Vite 开发服务,默认端口是 3000。真正的后端服务由 server:start 启动,通常监听 3001。不要再把 start:web 理解成“前端自己跑完全部 Oak 逻辑”的旧前台模式。
3. 小程序开发
新应用先添加小程序 workspace:
oak-cli add mp wechatMp
npm install
小程序端启动命令是:
npm run start:mp
运行后用微信开发者工具打开 wechatMp/dist。如果页面依赖数据库、aspect、endpoint、watcher 或第三方接口,需要另开终端运行:
npm run server:start
当前模板不再提供 start:mp:server。小程序端和后端是两个独立进程。
4. App 开发
新应用先添加 React Native workspace:
oak-cli add rn native
npm install
React Native 端先启动 Metro:
npm run start:native:native
再按目标平台安装运行:
npm run run:android:native
npm run run:ios:native
生产构建使用:
npm run build:native:native:android
npm run build:native:native:ios
旧文档里的无 workspace 后缀 run:android、run:ios 和 run:bundle 不是当前模板命令。原生 SDK、签名和渠道包仍由 React Native、Android Studio 与 Xcode 工程管理。
5. Desktop 开发
Tauri:
oak-cli add desktop desktop
npm install
npm run start:desktop:desktop
Electron:
oak-cli add desktop desktop-electron --runtime electron
npm install
npm run start:desktop:desktop-electron
Desktop 复用 Web 平台运行时,但通过 Tauri/Electron renderer metadata 区分应用。它仍需真实 Oak 后端;通常另开终端运行 npm run server:start。详细结构见多平台工作区。
6. 后端开发
后端运行命令是:
npm run server:start
首次接入真实数据库前,先准备数据库配置并执行:
npm run build
npm run server:init
server:start 会启动真正的 Oak 后端运行态,包括 trigger、checker、aspect、endpoint、port、watcher、timer 和 routines/start。
7. 清理缓存
如果 Vite、Babel 或依赖缓存导致页面表现异常,可以先执行:
npm run clean:cache
它只清理本地构建缓存,不替代 make:domain、project:init 或 make:dep。
部署
Oak 的开发模式非常灵活,但真正部署到线上时,建议把它理解成两部分:
- 前端产物的构建与发布;
- 后端服务进程的编译、初始化与运行。
本地开发也应默认使用真实后端运行态。当前模板的 npm run dev 会同时启动 server:start 和 start:web,线上部署时则应把后端服务进程和各目标端产物分开发布。
部署前要先明确的事实
一个真正启动起来的 Oak 后端,不只是提供查询和保存数据这么简单。根据 oak-backend-base/src/AppLoader.ts 的实现,服务启动后还会继续负责:
- 装配所有
trigger、checker和内置逻辑; - 暴露
aspect和endpoint; - 注册导入导出
port; - 每 120 秒轮询一次
watcher; - 通过
node-schedule运行timer; - 在启动和停止时执行
routines/start、routines/stop。
所以,线上环境中的 Oak 后端,本质上是整个业务系统的运行核心,而不只是一个“给前端提供接口”的薄服务。
一、部署后端
1. 编译项目
先在项目根目录执行:
npm run make:domain
npm run make:locale
npm run make:dep
npm run build
如果这次发布增删了 Oak 依赖模块,先执行 npm run project:init,再执行 npm run make:domain 和 npm run make:dep。这一步完成后,项目中的 TypeScript 后端代码会被编译到 lib 目录。真正上线运行的 Node.js 进程,就是基于这里的代码。
已有数据库的结构变更不要直接混在 server:init 里处理。编译后先生成升级计划:
npm run db:upgrade:plan
它会在 .oak-upgrade/<时间戳> 下输出 migration.sql、rollback.sql、summary.json、table-changes.json、warnings.json 和 rename-candidates.json。审核无误后,再由 DBA 或发布脚本执行结构升级。只有明确接受风险时,才使用:
npm run db:upgrade:plan -- --execute
如果计划里有 manualSql、warnings 或 renameCandidates,不要直接执行;先确认是否是列/索引重命名、大表索引调整、枚举变更或需要人工拆分的 DDL。
2. 准备数据库
Oak 后端当前按数据库类型 mysql -> postgres -> sqlite 查找配置;每种类型都先检查环境文件,再检查普通文件:
configuration/mysql.${NODE_ENV}.json
configuration/mysql.json
configuration/postgres.${NODE_ENV}.json
configuration/postgres.json
configuration/sqlite.${NODE_ENV}.json
configuration/sqlite.json
找到第一份有效配置后就确定 store。项目还必须安装对应驱动:MySQL 使用 mysql2,PostgreSQL 使用 pg,SQLite 使用 better-sqlite3。
本书前面已经提到过,常见做法是在项目配置中写好数据库参数,并先创建空库:
create database xxx default character set utf8mb4;
然后执行:
npm run server:init
它会根据当前项目编译后的 Storage 定义和 src/data 中的静态数据去初始化数据库。
::: danger 只能用于首次初始化
当前普通模板的 initServer.js 通常调用 AppLoader.initialize('dropIfNotStatic')。该模式会删除并重建所有非 static 实体表,再插入 seed 数据。它不是“补一条初始化数据”的增量命令,已有开发库和生产库都不能为了刷新 src/data 随手执行。
已有数据库应使用审核后的 schema migration 和明确的数据迁移;执行任何初始化脚本前,先读取项目自己的 scripts/initServer.js,确认实际 ifExists 参数。
:::
SQLite 适合本地单机工具、Desktop 配套服务和较轻量部署。最小配置示例:
{
"filename": "data/app.sqlite",
"poolSize": 4
}
SQLite 已支持 schema inspection 和 migration plan,但仍有方言边界,例如不支持任意列定义原地修改和数据库 view。升级时同样先审核 db:upgrade:plan,不要把它当作无结构数据库。
3. 启动服务
npm run server:start
此命令启动的就是正式的 Oak 后端运行进程。通常线上环境还会再用 pm2、systemd、容器编排系统等方式去守护这个进程,这一层属于通用 Node.js 运维能力,并不是 Oak 特有的部分。
4. 可选的 server bundle
新项目还提供:
npm run build:bundle
它使用 esbuild 生成独立的后端部署目录,主要包含:
dist/
├── server.js
├── package.json
├── server-metafile.json
├── target/
├── configuration/
└── oak-packages/
项目根的 pm2.*.json 会按原名复制;构建器不会生成固定的 pm2.prod.config.json。启动、初始化和升级命令都由 dist/package.json 指向本地入口:
node server.js
node server.js initialize
node server.js db:upgrade:plan
Server bundle 当前是 opt-in 路径,不替代普通 build:lib。部署前检查:
configuration/*.json是否已按环境安全提供,且没有把密码配置提交到仓库;- 目标数据库驱动是否进入部署环境;
server.nodeModules.bundle与第三方依赖安装策略是否一致;- Oak 包是否按
package.json.oak.package输出到dist/oak-packages; - 动态 require、原生模块和项目根资源是否能在干净机器上解析。
建议在空目录或容器中只使用 dist 和计划中的外部依赖做一次真实启动验证,不能仅以构建成功作为部署成功。
二、部署 web 前端
web 端一般通过 Oak CLI 构建:
npm run build:web
或者在预发环境使用:
npm run build:web:staging
这些脚本本质上都来自 oak-cli build --target web --mode ...。构建完成后,再把生成的静态资源发布到你的 web 服务器或 CDN 即可。
需要注意的是,线上 web 前端必须请求真实后端服务。start:web 只是本地 Vite 开发服务,不是生产运行方式。
三、部署小程序、App 和 Desktop
微信小程序和 App 的部署思路与 web 一样,都是“先构建目标端产物,再发布目标端”:
npm run build:mp
React Native 端则还会涉及原生包的打包与签名,这部分更多取决于 React Native 自身的工程流程。
Desktop 先通过 oak-cli add desktop 创建 workspace,再执行生成的构建脚本:
npm run build:desktop:desktop
Tauri 会生成原生 Rust 应用产物,Electron 会交给 electron-builder。Desktop renderer 使用相对 public path,并把 Web 依赖打进本地 bundle,不应依赖在线 CDN 才能启动。发布前还要验证:
src/data/i18n.json已生成并进入原生资源;.oak-desktop.json的identifyId与后端 Desktop Application 配置一致;src/configuration/access.ts指向真实后端;- 安装包签名、自动更新和操作系统权限符合项目发布策略。
但无论是哪一个前端目标端,只要业务逻辑依赖真实数据库、第三方服务、消息推送、定时任务或导入导出能力,线上都必须配套部署 Oak 后端。
四、推荐的上线顺序
一个比较稳妥的上线顺序如下:
- 在构建机上执行生成与编译脚本;
- 发布后端代码和配置;
- 对已有数据库执行并审核
db:upgrade:plan; - 首次部署执行
server:init,已有库执行审核后的结构升级 SQL; - 执行权限、i18n、静态数据升级脚本;
- 启动新的后端进程;
- 再发布前端静态资源或多端包。
如果你的项目已经进入持续发布阶段,还应把数据库变更、静态数据更新和前端发布进一步拆成明确的流水线步骤。Oak 在框架层面已经把“对象定义”“依赖装配”“国际化编译”“服务端运行”这些关键阶段分清楚了,部署脚本只需要老老实实顺着这个顺序来。
五、不要把开发命令直接当成生产命令
最后强调一个很容易被新手忽略的问题:
start:web、start:mp这类命令只负责目标端开发服务,不是为了替代正式部署。
真正的业务系统仍然需要一个完整运行的 Oak 后端进程来承担一致性、权限、定时、导入导出、外部集成等职责。前端目标端产物只是用户入口,不是业务运行核心。
项目目录结构
一个典型的Oak项目的主要目录结构如下:
- src
- aspects
- assets
- checkers
- components
- configuration
- context
- data
- endpoints
- entities
- features
- locales
- pages
- ports
- routines
- timers
- triggers
- watchers
- lib
- web
- wechatMp(按需添加)
- native(按需添加)
- desktop / desktop-electron(按需添加)
- oak.config.json
- oak.config.ts
- tsconfig.lib.json
- tsconfig.es.json
- tsconfig
- paths.lib.json
- paths.es.json
- src目录:存放项目主要的业务逻辑代码。目录按Oak的各种概念又分成多个子目录,关于src下面各种概念的介绍,在本章节将逐一对之进行介绍
- lib目录:存放项目编译后的js文件
- web目录:存放项目在web端的入口文件和路由文件等
- wechatMp目录:存放项目小程序端的入口文件和各种配置文件
- native目录:存放项目 App 端的入口文件和路由文件等,以及 iOS / Android 层的项目代码
- desktop目录:存放 Tauri 或 Electron 的 Web renderer、原生宿主配置和 Desktop namespace。目录名可自定义
当前应用创建默认只有 web workspace。其它平台使用 oak-cli add mp|rn|desktop 添加,因此真实项目不一定同时存在上述所有目录。每个平台 workspace 有自己的 tsconfig.json,共享业务源码使用根 tsconfig.lib.json、tsconfig.es.json 和 src/tsconfig.json。
项目开发
开发一个应用系统,主要编写的代码是在src的以下的目录当中。每个目录的含义和如何编写,我们将在本章的各节按照先后顺序分别介绍。
如果是开发 web 应用,在 web 目录下您还需要编写:
web/src/app/namespaces下面的各 namespace 配置及布局web/src/app/components下面被 namespace 引用的公共组件(如整个网站的 header/footer 等)web/src/app/routers下面的 web 路由汇总
如果是开发小程序,在 wechatMp 目录下您还需要编写:
wechatMp/src/app.ts、wechatMp/src/app.less全局事件处理和样式wechatMp/mp.config.ts小程序基础配置wechatMp/package.config.ts页面和分包配置wechatMp/src/sitemap.json小程序 sitemap 配置
如果是开发 App,在 native 目录下您还需要编写:
native/App.tsx、native/index.tsx全局事件处理和插件加载native/router/index.tsApp 路由配置
如果是开发 Desktop,在对应 workspace 下主要维护:
src/index.tsx和src/app/namespaces/desktop的桌面壳与 namespace- Tauri 的
src-tauri,或 Electron 的electron、electron-builder.yml .oak-desktop.json中由 CLI 生成的 runtime 与稳定identifyId
共享业务页面仍放在根 src/pages,通过 render.desktop.tsx、操作系统 render 或 web.pc.tsx / web.tsx 回退参与 Desktop 路由。
编写对象
在 Oak 中,src/entities 不是单纯的“建表目录”,而是整个应用的业务字典。一个对象定义同时决定了:
如果你还没有创建过 Oak 应用,建议先完成新手入门任务清单,再把本页当作实体设计的深入参考。
- 数据有哪些字段;
- 对象之间有哪些关系;
- 允许执行哪些业务动作;
- 状态如何流转;
- 组件、checker、trigger、aspect、endpoint 在后续开发时可以拿到哪些类型和元数据。
因此,编写对象这件事,建议从一开始就写规范。对象一旦定义清楚,后续页面、权限、查询、业务逻辑的编写都会顺很多。
一个对象文件通常包含什么
在 Oak 项目中,一个对象通常写在 src/entities/<Entity>.ts 中。一个完整的对象文件,通常包含下面几部分:
| 部分 | 是否常见 | 作用 |
|---|---|---|
Schema | 必有 | 定义对象字段和对象关系 |
Action / State / ActionDef | 按需 | 定义动作和状态机 |
Relation | 按需 | 定义对象与用户之间的关系 |
entityDesc | 强烈建议始终提供 | 定义命名、索引、风格、配置等元数据 |
如果这是一个要发布给其它项目依赖的 Oak 模块,最终消费方通常不会读取你的 src/entities。当前编译器会优先读取发布包的 es/entities,并把 .d.ts 声明和同名 .js 运行时值合并解析。
这意味着模块发布时要保证:
Schema、Action、State、枚举类型等声明出现在.d.ts中;entityDesc、ActionDef等运行时值出现在同名.js中;- 不需要、也不应该为了实体编译而发布
src。
一个最小可用对象通常长这样:
import { String, Boolean, Text } from '@oak-domain/types/DataType';
import { EntityShape } from '@oak-domain/types/Entity';
import { EntityDesc } from '@oak-domain/types/EntityDesc';
export interface Schema extends EntityShape {
name: String<32>;
default?: Boolean;
remark?: Text;
}
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: {
name: '标签',
attr: {
name: '名称',
default: '是否默认',
remark: '备注',
},
},
},
};
如果对象还有状态、动作、关系等语义,就在这个骨架上继续往下加。
1. Schema:定义对象和字段
Oak 约定使用 Schema 来描述对象本身。最常见的写法有两种:
export interface Schema extends EntityShape {
...
}
或者继承一个已经存在的对象:
import { Schema as User } from '@oak-domain/entities/User';
export interface Schema extends User {
...
}
第二种写法在公共业务包里很常见,例如 oak-general-business 中的 User 就是在 Oak 内置 User 的基础上继续扩展业务字段。
EntityShape 自带哪些字段
所有正常对象都会继承 EntityShape,它已经定义了下面这些通用字段:
| 字段 | 含义 |
|---|---|
id | 主键 |
$$seq$$ | 自增序列 |
$$createAt$$ | 创建时间 |
$$updateAt$$ | 更新时间 |
$$deleteAt$$? | 删除时间,软删除时会用到 |
这些字段不要自己重复定义。除此之外,运行时还会使用 $$triggerData$$、$$triggerUuid$$ 之类的内部字段,它们也不是业务对象作者需要手写的内容。
2. 框架支持哪些字段写法
Oak 支持的对象字段写法,实际上比“几个基础类型”要丰富得多。日常开发里最常见的是下面这几类。
2.1 基础数据类型
基础字段类型来自 @oak-domain/types/DataType。当前源码中已经定义了这些类型:
| 类别 | 可用类型 | 说明 |
|---|---|---|
| 整数 | Int<L>、Uint<L> | 有符号/无符号整数 |
| 小数 | Decimal<P, S>、Price | 金额和精确小数优先用这组 |
| 浮点 | Float<P, S>、Double<P, S> | 仍然存在,但新项目不推荐,优先 Decimal |
| 字符串 | String<L>、Text | String<L> 适合短文本,Text 适合长文本 |
| 文件类 | Image、File | 图片、文件资源标识 |
| 时间类 | Datetime、Day、Time | 日期时间相关 |
| 布尔 | Boolean | 布尔值 |
| 地理位置 | Geo、SingleGeo | 地图和坐标相关 |
例如 Address 中就有这样一组非常典型的字段:
export interface Schema extends EntityShape {
detail: String<32>;
phone: String<12>;
name: String<32>;
default: Boolean;
remark?: Text;
}
这里还有两个很实用的经验:
- 是否可选,直接用
?表达,例如remark?: Text; Text、Image、File、Object在框架内部被视为不适合建立普通索引的类型,设计索引时要慎用。
2.2 枚举、字面量联合与可选字段
Oak 不要求所有字段都必须来自 DataType。像枚举、字面量联合这类纯 TypeScript 写法,框架也是支持的。
例如 oak-general-business 的 User:
export interface Schema extends User {
gender?: 'male' | 'female';
idCardType?: 'ID-Card' | 'passport' | 'Mainland-passport';
}
这种写法非常适合:
- 性别、状态等级、来源类型这类有限枚举值;
- 需要在
entityDesc.locales.v中统一做显示文案映射的字段; - 后续还要配合颜色、图标做统一展示的字段。
2.3 对象字段与 JSON 风格字段
如果某个字段本身就是一个复杂对象,Oak 也允许你直接使用 TypeScript 对象类型,而不必强行拆成大量基础字段。
例如 oak-general-business 的 Application:
export type AppType = 'web' | 'wechatMp' | 'wechatPublic' | 'native';
export type NativeConfig = {
type: 'native';
wechatNative?: {
appId: string;
appSecret: string;
domain?: string;
};
location: Location;
};
export interface Schema extends EntityShape {
type: AppType;
config: WebConfig | WechatMpConfig | WechatPublicConfig | NativeConfig;
style?: Style;
}
这说明 Oak 支持:
- 字段直接写成对象类型;
- 字段写成多个对象类型的联合;
- 对象内部继续嵌套更深的结构。
这种写法很适合:
- 应用配置;
- 第三方平台参数;
- UI 样式配置;
- 结构稳定、但不值得单独拆成一个实体的配置对象。
2.4 普通多对一关系
如果当前对象需要“指向”另一个对象,直接把该字段写成另一个对象的 Schema 即可。这表示一条普通的多对一关系。
例如 Address 指向 Area:
import { Schema as Area } from './Area';
export interface Schema extends EntityShape {
area: Area;
}
这类写法的含义是:
- 在对象定义层,关系按对象来写,可读性最好;
- 编译后的
OpSchema会落成外键字段,例如areaId; - 但在查询、过滤、级联操作时,依然可以沿着
area这个关系名继续写。
因此,定义层和查询层的心智模型会比较统一。
2.5 自关联
对象字段也可以指向它自己,这在树结构、行政区划、分类体系里非常常见。
例如 Area:
export interface Schema extends EntityShape {
name: String<32>;
parent?: Schema;
}
这里的 parent?: Schema 就表示“地区的上级地区”。这种写法常用于:
- 地区树;
- 分类树;
- 组织架构;
- 文章目录树。
2.6 反向一对多关系
如果一个对象需要声明“它有哪些子对象”,可以直接在父对象上写数组字段。T[] 和 Array<T> 两种写法都可以。
例如 oak-general-business 的 User:
export interface Schema extends User {
files: Array<ExtraFile>;
codes: Array<WechatQrCode>;
addresses?: Address[];
}
这类数组字段的含义是:
- 当前对象和这些子对象存在一对多关系;
- 真正的外键仍然通常落在子对象侧;
- 这组定义会进入编译后的数据字典,后续查询、过滤、级联更新都能沿着这条关系使用。
什么时候应该写这种字段?通常是当你希望下面这些能力成立时:
- 可以从父对象直接查询子对象;
- 可以从父对象过滤“满足某条件的子对象”;
- 可以在级联操作里一次操作父子对象。
2.7 动态多对一关系
有些对象并不固定指向某一个父对象,而是“可能指向若干种对象中的一种”。Oak 为这种场景保留了一组专门写法:
export interface Schema extends EntityShape {
entity?: String<32>;
entityId?: String<64>;
}
这两个字段是有特殊含义的保留写法:
entity表示父对象的实体名;entityId表示父对象的主键;- 它们不是普通业务字段,不要拿这两个名字表达别的含义;
- 类型也不要自行改动。
Address、Session、AccountOper、OperEntity 都使用了这种模式。
如果你希望某个对象可以成为这类动态关联的“父对象”,通常还要在父对象上补出对应的一对多数组关系。例如 User 中的:
addresses?: Address[];
这样编译后的关系路径、级联查询和后续业务表达才会完整。
需要特别注意的一点是:一个对象内,Oak 实际上只支持一组这样的动态多对一保留字段。 如果你发现自己想在同一个对象里再造第二组“多态父对象”关系,通常就说明对象设计已经过于复杂,应该重新拆分。
2.8 一张速查表
如果你只想快速确认“某种对象字段写法支不支持”,可以直接看下表:
| 需求 | 写法示例 | 是否支持 |
|---|---|---|
| 继承基础对象 | interface Schema extends EntityShape | 支持 |
| 继承已有对象 | interface Schema extends User | 支持 |
| 基础字段 | name: String<32> | 支持 |
| 可选字段 | remark?: Text | 支持 |
| 枚举字段 | `gender?: 'male' | 'female'` |
| 对象字段 | style?: Style | 支持 |
| 联合对象字段 | `config: WebConfig | NativeConfig` |
| 普通多对一 | area: Area | 支持 |
| 自关联 | parent?: Schema | 支持 |
| 反向一对多 | addresses?: Address[] | 支持 |
| 动态多对一 | entity?: String<32>; entityId?: String<64> | 支持 |
3. Action 和 State 怎么写
当一个对象有明确的业务动作和状态流转时,建议把它们直接定义在对象文件里。这样后续权限、日志、页面动作、业务逻辑都会更清晰。
Oak 中描述状态机的核心类型是 ActionDef<A, S>,它的实际结构很简单:
type ActionDef<A extends string, S extends string> = {
stm: {
[action in A]: [prevState: S | S[], nextState: S];
};
is?: S;
};
例如 User 中的一组状态机:
export type UserAction = 'activate' | 'disable' | 'enable' | 'mergeTo' | 'mergeFrom';
export type UserState = 'shadow' | 'normal' | 'disabled' | 'merged';
export const UserActionDef: ActionDef<UserAction, UserState> = {
stm: {
activate: ['shadow', 'normal'],
disable: [['normal', 'shadow'], 'disabled'],
enable: ['disabled', 'normal'],
mergeTo: [['normal', 'shadow'], 'merged'],
mergeFrom: ['normal', 'normal'],
},
};
这里每一项都表示:
- 某个动作允许从哪些旧状态出发;
- 执行后会落到哪个新状态。
其中:
stm是状态转换矩阵;is是初始状态,可选;- 旧状态可以写成单值,也可以写成数组。
发布包场景下,ActionDef 可以来自运行时 JS,也可以通过 import alias 引入。编译器会结合声明文件里的 Action / State 类型和 JS 里的 initializer 生成 ActionDefDict。
一个对象可以有多组状态机
可以。User 就同时定义了用户状态和认证状态两组状态机:
UserAction/UserState/UserActionDefIdAction/IdState/IdActionDef
最后再把所有动作汇总成当前对象的 Action:
export type Action = UserAction | IdAction | 'play';
这也是比较推荐的写法:不同语义的状态机分开写,最后统一汇总动作。
自定义 Action 不一定非得改状态
不一定。Oak 支持你定义“不改变状态,但有明确业务语义”的动作。
这类动作依然很有价值,因为它们会:
- 让操作日志更清楚;
- 让权限控制更细;
- 让 trigger、checker、aspect 中的业务判断更明确。
通用 Action 关键字
框架本身还保留了一组通用动作名:
createupdateremoveselectcountdownloadaggregatestat
因此你定义自己的 Action 时,应避免与这些关键字重名。
4. Relation 怎么写
Relation 表达的不是对象之间的普通外键关系,而是“对象与用户之间的业务关系”。它常用于权限和业务身份表达。
例如 Session:
export type Relation = 'partner';
这里的 partner 表示“参与会话的人”。如果某个用户和某条 Session 存在 partner 关系,就表示这个用户参与了这场会话。
对应的 entityDesc.locales 中,还可以给关系补展示名称:
export const entityDesc: EntityDesc<Schema, '', Relation> = {
locales: {
zh_CN: {
name: '会话',
attr: {
entity: '关联对象',
entityId: '关联对象id',
},
r: {
partner: '所有者',
},
},
},
};
如果你理解了这里的 Relation,再去看 Oak 内置的 UserRelation 对象就很自然了。它本质上就是在表达:
- 哪个用户;
- 对哪个对象;
- 拥有什么关系。
并不是每个对象都必须定义 Relation。只有当对象真的要和“用户身份/权限关系”挂钩时,才需要这一层。
5. EntityDesc 怎么写
entityDesc 是对象的描述信息。它不是装饰性的补充,而是对象定义的重要组成部分。当前编译器会从这里读取命名、索引、配置、风格等元数据。
最常用的部分有下面几项:
| 字段 | 用途 |
|---|---|
locales | 定义对象、字段、动作、关系、枚举值的显示文案 |
indexes | 定义数据库索引 |
style | 定义动作图标、枚举/状态颜色 |
configuration | 定义对象的行为模式,如只读、只追加等 |
recursiveDepth | 递归对象的编译配置 |
5.1 locales
这是最重要的一项。它至少应该把对象名和字段名补齐。
locales: {
zh_CN: {
name: '用户',
attr: {
name: '姓名',
nickname: '昵称',
addresses: '收货地址',
},
action: {
activate: '激活',
disable: '禁用',
},
r: {
partner: '参与者',
},
v: {
gender: {
male: '男',
female: '女',
},
},
},
}
这里几组键的含义很固定:
| 键 | 含义 |
|---|---|
name | 对象名 |
attr | 字段名 |
action | 动作名 |
r | Relation 名 |
v | 枚举值/状态值名 |
如果对象定义了动作、关系、枚举或状态,建议这里一并补全,不要只写一半。后续组件和管理端展示会明显受益。
entityDesc 的泛型参数也会参与枚举字段推导。像 LocalizedContent.language: Language 这种 Language = keyof typeof LANGUAGE_LABELS 的写法,只要类型声明和必要常量声明在发布包中存在,就可以被编译器识别为字符串枚举。
5.2 indexes
索引直接定义在 entityDesc.indexes 中。User 里的写法就是一个很好的例子:
indexes: [
{
name: 'index_birth',
attributes: [
{
name: 'birth',
direction: 'ASC',
},
],
},
{
name: 'index_fulltext',
attributes: [
{ name: 'name' },
{ name: 'nickname' },
],
config: {
type: 'fulltext',
parser: 'ngram',
},
},
]
其中 config 当前支持这些能力:
| 配置项 | 说明 |
|---|---|
unique | 唯一索引 |
type | fulltext、btree、hash、spatial |
parser | MySQL 全文索引分词器 |
tsConfig | PostgreSQL 文本检索配置 |
chineseParser | PostgreSQL 中文分词配置 |
索引设计时有两点要牢记:
- 对象查询能力会受到索引声明影响,例如全文搜索必须先有全文索引;
Text、Image、File、Object这类字段不适合作为普通索引候选。
5.3 style
style 用来统一描述动作图标和枚举/状态颜色。
以 User 为例:
style: {
icon: {
activate: '',
disable: '',
enable: '',
},
color: {
userState: {
normal: '#0000FF',
disabled: '#FF0000',
shadow: '#D3D3D3',
merged: '#9A9A9A',
},
},
}
源码里的 StyleDesc 说明了两件事:
- 如果对象定义了
Action,通常就会有icon; - 如果对象定义了枚举/状态字典,通常就会有
color。
虽然不同项目对这部分元数据的消费深度不完全一样,但它的设计目的很明确:让组件和前端展示层可以统一读取风格信息,而不是在各个页面里到处写死颜色和图标。
5.4 configuration
configuration 用于声明对象的整体行为模式。当前最常用的是 actionType 和 static。
actionType 支持的值如下:
| 值 | 含义 |
|---|---|
readOnly | 只读对象 |
appendOnly | 只能新增,不能改删 |
excludeUpdate | 不允许更新 |
excludeRemove | 不允许删除 |
crud | 标准增删改查 |
公共业务包里有两个很好的例子:
Area使用configuration: { actionType: 'readOnly', static: true },表示它是只读维表;AccountOper、OperEntity使用configuration: { actionType: 'appendOnly' },表示它们更像流水,只能追加。
其中 static: true 很适合地区、字典、配置类维表。
5.5 recursiveDepth
框架类型层面对 recursiveDepth 是支持的,它主要用于递归对象的编译配置。但在当前公共业务包里,并没有特别典型的现成实体示例。
因此这里给出的建议很简单:
- 普通对象先不用它;
- 只有当你确实在做递归结构,并且已经结合生成结果验证过时,再启用它。
6. 编译结果和后续影响
对象定义写完或修改完之后,都要重新编译数据字典:
npm run make:domain
编译后,Oak 会在 src/oak-app-domain 下生成完整的数据字典与存储描述。这份产物会被下面这些部分共同使用:
- 组件和页面;
- checker、trigger、watcher、timer、routine;
- aspect、endpoint;
- 查询、聚合、级联更新等运行时能力。
所以要记住两件事:
- 只要改了
src/entities,就应重新执行npm run make:domain。 - 只要依赖模块的实体产物更新,也应重新执行
npm run make:domain。 - 如果项目已经上线,修改对象定义时还要同时维护升级脚本,保证数据库结构和线上数据能正确迁移。
下一小节会继续介绍:对象编译完成后,如何查询和操作这些对象。
7. 编写对象时最常见的坑
- 只写了
Schema,却没有把entityDesc.locales补全,导致后续显示名称到处不统一。 - 把
entity、entityId当成普通字段使用。它们在 Oak 中是动态多对一关系的保留写法。 - 在一个对象里试图定义两组不同含义的动态多对一关系。Oak 并不支持这种设计。
- 在父对象上忘记声明反向数组关系,结果后续查询和级联语义不完整。
- 继续使用
Float、Double设计业务金额,新项目应优先使用Decimal。 - 给
Text、Image、File、对象字段随意建普通索引,后续查询效果和存储成本都可能出问题。 - 修改对象后忘记重新执行
npm run make:domain。 - 升级依赖模块后忘记重新执行
npm run make:domain,导致oak-app-domain仍然是旧类型。 - 项目已上线后,只改了对象定义,却没有补数据库升级脚本。
- 先把
entityDesc组装到别的变量里再赋值。当前编译器是直接解析entityDesc字面量的,这种写法不稳妥。 - 继续沿用旧式的
locales、indexes、configuration分散定义方式。当前推荐的规范写法是统一收进entityDesc。 - 覆盖默认实体时只考虑新增字段,没有保持旧动作、状态、关系和常量兼容。
如果你第一次写对象,建议按下面这个顺序来:
- 先把
Schema写清楚; - 再补普通关系、自关联、动态关系;
- 如果对象有业务状态,再补
Action/State/ActionDef; - 最后把
entityDesc的locales、indexes、style、configuration补完整。
按这个顺序写,对象定义通常就不会乱。
查询和操作对象
编译
在编写或更新了Entity定义后,都需要执行命令来编译完整的对象数据字典:
npm run make:domain
编译出来的数据字典声明在src/oak-app-domain目录下,同时也会编译出来一个数据的存储格式供框架引用,可以在代码中像这样去引用它们:
// EntityDict是数据字典声明,StorageSchema是存储格式定义
import { EntityDict, StorageSchema } from '@project/oak-app-domain';
数据字典和存储格式是整个Oak框架最核心的内容,贯穿于使用框架的各个层面,因此需要深刻理解。本章节将使用上小节的Address和Area对象,介绍一些查询和操作的核心概念。
编译后的对象结构
编译后的对象原生结构称为OpSchema,其结构仅仅在用户定义的属性上增加了一些通用的属性类型,以及将引用对象转化成了外键。
每个Entity的OpSchema可以在编译后的oak-app-domain/${Entity}/Schema.ts中查看,本章下面的大多数数据结构都是如此
对象上增加的通用属性包括:
| 属性 | 类型 | 含义 |
|---|---|---|
| id | string<36> | 主键,uuid |
| $$createAt$$ | number | 创建时间戳(Date.now()) |
| $$updateAt$$ | number | 更新时间戳 |
| $$deleteAt$$ | number | 删除时间戳 |
| $$seq$$ | int | 递增序列 |
查询(Select)
Oak 里常用的查询入口其实有三类:
context.select(...)/store.select(...):查行数据;context.count(...)/store.count(...):只查数量;context.aggregate(...)/store.aggregate(...):分组聚合。
其中 select 使用的核心结构是 Selection<'select', ...>,定义在 oak-domain/src/types/Entity.ts 中:
{
data: Projection;
filter?: Filter;
sorter?: Sorter;
indexFrom?: number;
count?: number;
randomRange?: number;
total?: number;
distinct?: true;
}
这些字段的作用可以先记成下面这几类:
| 字段 | 含义 | 说明 |
|---|---|---|
data | 要取哪些字段 | 必填 |
filter | 过滤条件 | 可级联到父对象、子对象、JSON 字段 |
sorter | 排序条件 | 支持多个排序项,也支持按父对象字段排序 |
indexFrom + count | 分页 | oak-db/test/testcase/base.ts 已覆盖 |
randomRange | 随机取样范围 | 由 oak-domain/src/store/CascadeStore.ts 在框架层先取一批行再随机筛出 count 条 |
total | 额外附带总数上限 | 列表页常用;如果只想要数量,更推荐直接用 context.count |
distinct | 去重 | 类型和 SQL translator 已支持,当前主要有 translator 级测试 |
如果你是在后端手写查询,推荐先把这三种接口的职责分开:
const rows = await context.select('house', {
data: { id: 1, district: 1, size: 1 },
filter: { district: '杭州' },
sorter: [{ $attr: { size: 1 }, $direction: 'desc' }],
}, {});
const total = await context.count('house', {
filter: { district: '杭州' },
count: 1000,
}, {});
const aggr = await context.aggregate('house', {
data: {
'#aggr': { district: 1 },
'#count-1': { id: 1 },
},
filter: { district: { $in: ['杭州', '上海'] } },
}, {});
如果你要做去重查询,也是直接在 select 上声明 distinct:
const rows = await context.select('token', {
data: {
id: 1,
userId: 1,
$$createAt$$: 1,
},
distinct: true,
}, {});
这里也要按源码现状理解:distinct 在 Selection 类型和 SQL translator 里已经通了,oak-db/test/testSqlTranslator.ts 里也有 translator 级用例;但它目前还不是 oak-db/test/testcase 那种覆盖完整运行链路的“重回归项”,所以项目接入前最好自己补一条数据库实测。
Projection
当查询对象时,通过 Projection 可以定义要查询对象的哪些属性(projection 的命名本身就借鉴了数据库中的“投影”概念)。例如,对上一节中所定义的 Address 对象,查询时可以指定投影为:
{
detail: 1,
name: 1,
phone: 1,
}
可以根据对象之间的关系将 projection 扩展到多对一的父对象上,实现级联查询:
{
detail: 1,
name: 1,
phone: 1,
area: {
id: 1,
name: 1,
parent: {
id: 1,
name: 1,
},
},
}
也可以扩展到一对多的子对象上。例如我们查询 Area 对象时,可以把它关联的 Address 一并查出来:
{
id: 1,
name: 1,
address$area: {
$entity: 'address',
data: {
id: 1,
name: 1,
phone: 1,
},
},
}
Oak 还允许在 projection 里直接挂表达式字段。oak-domain/src/types/Demand.ts 中定义了 $expr 到 $expr20 共 21 个表达式列名,oak-db/test/testcase/projection.ts 已覆盖了 $expr、$expr1、$expr2、$expr3 的实际查询。
例如,如果想返回一个 name + phone 拼出来的展示字段,可以这样写:
{
id: 1,
name: 1,
phone: 1,
$expr: {
$concat: [
'姓名:',
{ '#attr': 'name' },
' 手机号:',
{ '#attr': 'phone' },
],
},
}
Projection 中可用的表达式
下面这些表达式已经能从类型定义和 oak-db/test/testcase 里对应上:
| 类别 | 已确认语法 | 参考来源 |
|---|---|---|
| 比较 | $gt、$gte、$lt、$lte、$eq、$ne | compare.ts |
| 字符串 | $startsWith、$endsWith、$includes、$concat | string.ts |
| 布尔/逻辑 | $true、$false、$and、$or、$not | bool.ts |
| 数学 | $add、$subtract、$multiply、$divide、$abs、$round、$floor、$ceil、$pow、$mod | math.ts、complax.ts |
| 日期 | $year、$month、$weekday、$weekOfYear、$day、$dayOfMonth、$dayOfWeek、$dayOfYear、$dateDiff、$dateFloor、$dateCeil | date.ts |
| 引用节点 | #attr、#id、#refId、#refAttr | Demand.ts、base.ts |
其中:
#attr表示“当前结点上的某个属性”;#id用来给当前 filter/projection 结点命名;#refId + #refAttr用来在一个表达式里引用另一个已命名结点上的字段。
例如,在父子结点之间做比较时,oak-db/test/testcase/base.ts 已经覆盖了这种跨结点写法:
{
'#id': 'node-1',
application$system: {
'#id': 'node-2',
$expr: {
$eq: [
{ '#attr': 'name' },
{ '#refId': 'node-1', '#refAttr': 'name' },
],
},
},
}
Schema
Schema 是对对象进行 Select 查询后得到的数据结果格式。此时返回的对象除了自身属性之外,还可能级联了父对象与子对象的数据。
例如,上面的 Address 查询结果中可能包含其父对象 Area:
{
id: 'xxx',
name: 'xxxxx',
phone: '139xxxxxxxx',
areaId: 'xxxx',
area: {
id: '310100',
name: '杭州市',
parentId: '330000',
parent: {
id: '330000',
name: '浙江省',
},
},
}
而查询到的 Area 数据结果则会包含其子对象 Address 的数组:
{
id: '310100',
name: '杭州市',
address$area: [
{
id: 'xxx',
name: 'xxxxx',
phone: '139xxxxxxxx',
},
{
id: 'yyy',
name: 'zzzzz',
phone: '138xxxxxxxx',
}
]
}
Filter
Filter 代表查询某个对象的条件。例如,我们要查询 Area 为杭州市、手机号以 139 开头的 Address,就可以这样写:
{
areaId: '310100',
phone: {
$startsWith: '139',
},
}
如果不知道杭州市的 areaId,也可以把 filter 扩展到父对象上:
{
area: {
name: '杭州市',
},
phone: {
$startsWith: '139',
},
}
同样的,Filter 也可以扩展到子对象上。比方说我们查询 Area,条件是“该 Area 上至少有一条相关的 Address,其手机号以 139 开头”:
{
address$area: {
phone: {
$startsWith: '139',
},
},
}
常用过滤算子
oak-domain/src/types/Demand.ts 中定义的常用过滤语法如下:
| 算子 | 参数类型 | 作用 |
|---|---|---|
$gt | number | string | 大于 |
$gte | number | string | 大于等于 |
$lt | number | string | 小于 |
$lte | number | string | 小于等于 |
$eq | number | string | boolean | 等于 |
$ne | number | string | boolean | 不等于 |
$in | (number | string)[] | 在……中 |
$nin | (number | string)[] | 不在……中 |
$between | [number, number] | 在……之间(含边界) |
$mod | [number, number] | 取模 |
$startsWith | string | 以……开头 |
$endsWith | string | 以……结尾 |
$includes | string | 包含…… |
$exists | boolean | 字段是否存在/是否为空 |
$and | Filter[] | 与 |
$or | Filter[] | 或 |
$not | Filter | 非 |
$text | { $search, $language?, $ts? } | 全文检索 |
此外还要注意几种“不是算子,但很常用”的写法:
- 数字、字符串、布尔、枚举都可以直接写字面量,表示等值比较;
- 枚举还支持
$in、$nin、$ne; - 日期比较沿用数字比较语法;
- 过滤条件里同样可以使用
$expr表达式。
其中 $gt、$gte、$lt、$lte、$eq、$ne、$in、$nin、$startsWith、$endsWith、$includes、$exists、$and、$or、$not 这些是 oak-db/test/testcase 已经反复覆盖的主路径;$between 则当前主要能从 Demand.ts 和 oak-db/src/sqlTranslator.ts 对上,项目里如果要大量使用,建议补一条自己的数据库回归用例。
例如,oak-db/test/testcase/base.ts 已覆盖了“在 filter 中直接写表达式”的形式:
{
id: {
$in: [id1, id2],
},
$expr: {
$eq: [
{ '#attr': 'name' },
{ '#attr': 'nickname' },
],
},
}
子查询与 #sqp
当 filter 被扩展到子对象时,Oak 默认的语义是“存在至少一条关联记录满足条件”。这个行为可以用 #sqp 改写。oak-db/test/testcase/base.ts 已经把四种语义测得很清楚:
#sqp 取值 | 含义 | 子表为空时的结果 |
|---|---|---|
不写 / 'in' | 存在至少一条满足条件 | false |
'not in' | 不存在任何满足条件的记录 | true |
'all' | 所有关联记录都满足条件 | true |
'not all' | 不是所有关联记录都满足条件 | false |
例如,我们查询“不能有任何一条手机号以 139 开头的 Address”的 Area:
{
address$area: {
phone: {
$startsWith: '139',
},
'#sqp': 'not in',
},
}
如果你要判断“所有子对象都满足某条件”,就把 #sqp 换成 'all':
{
application$system: {
type: 'web',
'#sqp': 'all',
},
}
JSON 字段过滤
oak-domain/src/types/Demand.ts 中还定义了 JsonFilter。oak-db/test/testcase/json.ts 已覆盖的能力包括:
- 嵌套对象按层级继续写 filter;
- JSON 数组支持
$contains、$overlaps; - JSON 内部的字符串字段仍然可以用
$includes、$startsWith等; - JSON 字段同样支持
$exists。
例如:
{
config: {
tags: {
$contains: ['oak'],
},
scores: {
$overlaps: [200, 500],
},
profile: {
nickname: {
$includes: 'xc',
},
},
age: {
$exists: true,
},
},
}
$or、$and 与关联查询
以前很多同学会担心:$or 里一旦混进父对象/子对象条件,SQL 会不会变坏。oak-db/test/testcase/or_search.ts 这部分已经专门补了大量用例:
$or中可以混合普通字段条件、父对象条件、多态关联条件;$and与$or可以嵌套;$not也可以和关联条件一起使用;- 空
$or: []会匹配不到任何记录; - 单项
$or也可以正常工作。
所以在真实项目里,下面这种写法是框架已经覆盖过的:
{
$or: [
{ player: { name: 'Alice' } },
{ value: 'token2' },
],
}
全文检索
全文检索的类型定义在 oak-domain/src/types/Demand.ts 中,MySQL 和 PostgreSQL translator 也都已经实现了翻译。但这里有两个前提必须同时满足:
- 对象上必须声明全文索引;
- 当前数据库实现要真的支持对应的全文检索语法。
一个典型写法如下:
{
$text: {
$search: '张三 杭州',
$language: 'zh_CN',
$ts: 'simple',
},
}
这里要特别说明:当前 oak-db/test/testcase 里还没有单独的全文检索回归用例。也就是说,这项能力在“类型定义 + translator 实现”这一层已经打通,但项目接入前最好自己补一条数据库实测。
Geo 查询
oak-domain/src/types/Expression.ts 与 MySQL/PostgreSQL translator 中已经定义了 $distance、$contains 这类 Geo 表达式;但当前:
oak-db/test/testcase没有对应回归测试;Expression.ts的本地执行分支里,$contains仍直接抛出“未实现”。
因此 Geo 能力更适合写成“按当前数据库项目验证后使用”,不建议在新手项目里把它当成已经稳定可依赖的通用语法。
Sorter
Sorter 表示查询时的排序。Oak 的 Sorter 结构定义在 oak-domain/src/types/Entity.ts 中:
[
{
$attr: {
phone: 1,
},
$direction: 'asc',
},
]
例如,我们查询 Address 时要求结果按手机号升序排序:
[
{
$attr: {
phone: 1,
},
$direction: 'asc',
},
]
写成数组意味着我们可以按序支持多个 sort 条件,同时也支持将排序属性扩展到多对一的父对象上:
[
{
$attr: {
phone: 1,
},
$direction: 'asc',
},
{
$attr: {
area: {
name: 1,
},
},
$direction: 'desc',
}
]
oak-db/test/testcase/base.ts 已覆盖:
- 按当前对象普通字段排序;
- 配合
indexFrom + count做分页; - 在更复杂的 filter 环境里使用排序。
如果查询中不指定任何排序条件,框架通常会自动补一个 $$createAt$$ 的降序排序条件。因此列表页如果要稳定分页,最好明确把 sorter 写出来。
聚合(Aggregate)
聚合查询使用的是 context.aggregate(...) / store.aggregate(...),其结构本质上和 Selection 很像,只是 data 变成了聚合数据结构:
{
data: {
'#aggr': {
district: 1,
},
'#count-1': {
id: 1,
},
},
filter: {
areaId,
},
}
这里有三个核心概念:
| 字段 | 作用 |
|---|---|
#aggr | 按哪些字段分组 |
#count-n | 聚合计数结果 |
#data | 聚合结果里真正的分组键值 |
例如,oak-db/test/testcase/aggr.ts 已覆盖了“按 district 分组并计数”的结果:
const result = await context.aggregate('house', {
data: {
'#aggr': {
district: 1,
},
'#count-1': {
id: 1,
},
},
filter: {
areaId,
},
}, {});
返回结果中的每一行大致会长成:
{
'#data': {
district: '杭州',
},
'#count-1': 3,
}
Oak 还支持把聚合挂到普通 select 的子查询 projection 上。oak-db/test/testcase/base.ts 已覆盖了这种关系聚合写法:
{
id: 1,
name: 1,
application$system$$aggr: {
$entity: 'application',
data: {
'#aggr': {
systemId: 1,
},
'#count-1': {
id: 1,
},
},
},
}
当前聚合能力的真实状态
这里必须按源码现状写,不要想当然:
#count-n:oak-db/test/testcase/aggr.ts已有实际运行用例,可以放心按当前语法使用;#sum-n、#max-n、#min-n、#avg-n:类型里已经定义,translator 命名也已预留,但aggr.ts里相关用例当前仍然被注释并标了“暂不支持”;distinct:类型与 translator 已支持,适合在你自己补过数据库测试后使用。
因此,对新手项目来说,当前最稳妥的聚合写法是:先以 #count-n 为主,其它聚合函数在项目仓库里补过实测后再上线。
不过为了避免你看源码时对不上,这几类聚合函数在类型里的真实写法大致如下:
{
data: {
'#aggr': {
district: 1,
},
'#sum-1': {
$$sum: {
'#attr': 'size',
},
},
'#max-1': {
$$max: {
'#attr': 'size',
},
},
'#min-1': {
$$min: {
'#attr': 'size',
},
},
'#avg-1': {
$$avg: {
'#attr': 'size',
},
},
},
}
这段写法可以在 oak-domain/src/types/Expression.ts 和 oak-db/test/testcase/aggr.ts 的注释用例里对上,但请注意:目前文档给出它,是为了让你读源码时能认出来,不是为了把它描述成“已经完整稳定支持”。
计数(Count)
如果你只是想知道“满足当前条件的有多少条”,不要总是先 select 再自己数。Oak 已经单独提供了 count 接口:
const count = await context.count('house', {
filter: {
district: '杭州',
},
count: 1000,
}, {});
这里的 count 参数不是“再查几条数据”,而是“数量上限”。它的用途和列表页里的 total 很接近,都是为了避免满足条件的数据特别多时,把数据库拖进一次很重的精确计数。
查询选项(SelectOption)
除了 Selection 本身,oak-domain/src/types/Entity.ts 还定义了 SelectOption:
{
dontCollect?: boolean;
blockTrigger?: true;
forUpdate?: true | 'skip locked' | 'nowait';
includedDeleted?: true;
ignoreAttrMiss?: true;
}
这些选项里最常用的是下面几个:
| 选项 | 作用 | 当前状态 |
|---|---|---|
forUpdate | 查询时加锁 | oak-db/test/testcase/base.ts 已覆盖 true |
includedDeleted | 查询时包含软删除行 | base.ts 已覆盖 |
dontCollect | 不把查询结果收集进 opRecords / 前端 cache 同步链路 | 更偏框架层能力 |
blockTrigger | 跳过 select trigger / checker 链路 | 更偏框架层能力 |
ignoreAttrMiss | cache 场景下允许属性缺失 | 前端缓存相关 |
例如:
const rows = await context.select('house', {
data: { id: 1, size: 1 },
filter: { id: houseId },
}, {
forUpdate: true,
});
forUpdate 在类型上还支持 'skip locked' 和 'nowait':
await context.select('house', {
data: { id: 1, size: 1 },
filter: { district: '杭州' },
}, {
forUpdate: 'skip locked',
});
MySQL / PostgreSQL translator 当前都会把这类字符串直接拼到 FOR UPDATE 后面;但 oak-db/test/testcase/base.ts 目前真正跑过回归的还是 forUpdate: true,因此如果项目要依赖 skip locked、nowait 这类数据库方言,仍建议自己补集成测试。
软删除数据如果要重新查出来,则要显式带上:
const rows = await context.select('house', {
data: { id: 1 },
filter: { id: houseId },
}, {
includedDeleted: true,
});
一个总的建议
查询语法这块,新同学最容易犯的错,就是把 Oak 当成“只有 Mongo 风格 filter 的 ORM”。实际上 Oak 当前已经同时支持:
- 关系级联 projection;
- 子查询谓词
#sqp; $expr表达式;- JSON 嵌套过滤;
- 聚合查询;
- 软删除可见性控制;
- 加锁查询;
- 列表页额外 total 与随机抽样。
因此遇到“这个语法到底能不能写”的问题,正确的核对顺序应该是:
- 先看生成后的 Entity 类型;
- 再看
oak-domain/src/types/Demand.ts和Expression.ts; - 最后看
oak-db/test/testcase有没有对应实测。
操作(Operate)
当要操作一个对象时,传入的数据结构如下:
{
id: Uuid;
action: Action;
data: Data;
filter: Filter;
}
Uuid
Operate的操作需要唯一编号,其作用是为了记录日志和在分布式环境下实现操作同步。可以使用下面两个函数来产生Uuid。
import { generateNewId, generateNewIdAsync } from '@oak-domain/utils/uuid';
一般来说,如果不是在有些前端环境中受到同步的限制,应优先使用异步产生函数。
Action
声明Operate的类型。有三种Action是公共的:create/update/remove,除此之外,用户在定义Entity时,所声明的Action也是有效的Action。
所有用户定义的Action从广义上来说都是update。如果用户在定义Action时,也定义了相应的状态转换矩阵(见编写对象),则在执行该动作时,会自动进行对象相应属性的状态检查以及更新其状态。
一般来说,对一个对象的操作如果有业务层面上的语义,推荐尽量细化成不同的Action,而不直接使用update。这样一来可以使对对象的操作历史更加清晰,二来也便于后续进行细粒度的权限控制。
Data
声明更新的数据。更新的数据可以是两种:
- 自身的数据属性
例如要更新地址的phone和name:
{
phone: '138xxxxxxxx',
name: '张小三',
}
create操作必须传入id以及有效的所有声明非空属性,update只需要传入至少一个属性,而remove操作无需传入自身的属性。
update 还支持表达式更新。需要用表达式计算字段值时,应显式写成 $expr:
await context.operate('account', {
id: await generateNewIdAsync(),
action: 'update',
data: {
balance: {
$expr: {
$add: [
{ '#attr': 'balance' },
100,
],
},
},
},
filter: {
id: accountId,
},
}, {});
MySQL / PostgreSQL translator 都已经支持这类 update expression。需要注意的是,JSON / JSONB 字段里的普通对象会按字面量处理,不会被自动当成表达式;如果要表达式语义,必须显式使用 $expr。
- 级联数据属性 同Filter/Projection一样,更新的Data也支持级联更新,可以通过一次Operate请求,更新当前对象以及其级联对象上的属性。 例如,我们可以在更新Address时,同时去更新其相关联的父对象Area的数据(不考虑这个请求是否有意义):
{
phone: '138xxxxxxxx',
name: '张小三',
area: {
id: '{uuid}',
action: 'update',
data: {
name: '苏杭市',
}
},
}
同样的,我们也可以在更新父对象Area时,更新其相关联的子对象Address的数据:
{
name: '苏杭市',
address$area: {
id: '{uuid}',
action: 'update',
data: {
phone: '138xxxxxxxx',
},
filter: {
name: '张小三',
}
}
}
这条Operate在更新Area数据的同时,还会将“指向它的且『name为张小三』的所有的Address”数据的phone属性更新成138xxxxxxxx。
我们还可以在更新父对象的同时,插入一条子对象,像下面这样:
{
name: '苏杭市',
address$area: {
id: '{uuid}',
action: 'create',
data: {
id: '{uuid}',
phone: '138xxxxxxxx',
name: '张小四',
....
},
}
}
新插入的Address会自动和当前Area关联。
下面列出了框架所支持的级联更新的情况:
- 子对象级联父对象
| 子对象 | 父对象 | 效果 |
|---|---|---|
| create | create | 父子对象和关联关系一起创建 |
| update | create | 更新子对象、创建父对象及关联关系(如果原来子对象上有关联的父对象关系会丢失) |
| update | update | 更新子对象,同时更新关联的父对象 |
| update | remove | 更新子对象,同时删除关联的父对象及关联关系 |
| remove | update | 移除子对象及关联关系,同时更新关联的父对象 |
| remove | remove | 同时移除父子对象,以及关联关系 |
- 父对象级联子对象
| 父对象 | 子对象 | 效果 |
|---|---|---|
| create | create | 创建父子对象,并创建关联关系 |
| update | update | 更新父对象,并更新关联的子对象 |
| update | remove | 更新父对象,同时删除关联的子对象 |
编写组件/页面
设计完Entity后,您可立即进入应用页面的编写(Oak框架会自动处理发请求、取数据、缓存等一系列工作,不需要你再编写任何一行相关代码了😁)。
编写组件/页面可以认为是应用编写最复杂的部分,因此这部分内容也较为繁重。直接编写完组件/页面,您就已经拥有一个可以演示的应用了。
三层架构
在Oak框架中,前端部分的结构可以由高到低分为三个层次,如下图所示:

namespace
namespace是指应用在最顶层被划分成几个命名空间,每个命名空间中包含若干页面,命名空间一般按顶层路由来划分,同一个命名空间中的整体布局是相同的。例如,一个典型的网站会分为frontend和console两个命名空间,分别代表普通用户访问的前端和管理人员访问的控制台。前者有统一的header(页头)和footer(页脚),而后者还会有统一的menu(菜单)。这些跨页面级别的组件摆放是在命名空间里处理的。
一般而言一个应用不会有过多的命名空间,命名空间被放置在web/src/app/namespaces目录下。目录名称是命名空间的key;web端实际路由前缀默认由目录名称决定,也可以通过命名空间配置中的route.path覆盖。
当前版本中,命名空间目录下使用index.config.ts配置此命名空间。例如web/src/app/namespaces/frontend/index.config.ts中,往往有如下配置:
import { CreateNamespaceConfig } from '@oak-frontend-base/config';
export default CreateNamespaceConfig({
route: {
path: '/',
first: 'home',
notFound: '/result/notFound',
},
access: {
unconfiguredAccess: 'allow',
},
});
这代表此命名空间的路由前缀是根目录,首页指向home页面,也就是*/home*;当应用出现无法识别的路由时,会自动指向*/result/notFound*。access.unconfiguredAccess用于声明未单独配置访问规则的页面默认如何处理,常见取值为allow、deny或login。
管理端命名空间还可以在index.config.ts里声明菜单分组和运行时feature配置,例如:
import { CreateNamespaceConfig } from '@oak-frontend-base/config';
export default CreateNamespaceConfig({
menu: {
groups: [
{
name: 'System',
icon: 'setup_fill',
order: 0,
},
],
},
access: {
unconfiguredAccess: 'deny',
},
features: {
console: {
contextEntities: ['system'],
},
},
});
页面可以在自己的index.config.ts中声明menu和route.access,Oak CLI会把页面配置和命名空间配置合并生成web/src/app/namespaces/allNamespaceConfigs.ts。这个文件是生成物,不应手工修改;应用入口应通过web/src/app/namespaces/index.ts导出它,并在web初始化时把namespaceConfigs传给oak-frontend-base/platforms/web/initialize。
一个命名空间下有哪些页面?在Oak框架中这是由src/pages目录下的目录结构自动决定的,在pages目录下,第一层目录是和命名空间名称对应的,其下的每个目录中的page就会被自动编译到对应的namespace下。
命名空间之间的跳转建议在全局统一且唯一(例如在frontend命名空间可以通过点击页上的“控制台”进入console命名空间),不要在普通页面之间跳转的时候跨越命名空间,这会容易让用户思维陷入混乱。
在大部分情况下,命名空间应该只存在于web端,对于小程序和App,一般不宜设计namespace,免得应用过于复杂。
page
一个page是前端的一个页面。page编写在src/pages/${namespace}目录下,每一个页面对应目录下的一个子目录。其路由和目录名保持相同。例如,在src/pages/frontend/home目录下的页面就会匹配到/home或/(参见上一小节中frontend命名空间的配置)。
虽然并非强制,但推荐在src/pages/${namespace}下的page目录的第一层用该页面相关的entity名称命名,第二层可以用list/detail/upsert之一,或者该entity的某个Action来加以命名(如果页面的功能就是对此entity进行此action的话),这样此页面的功能看上去就一目了然。当然,对于像首页这样的复合性页面(并不是限定在某个entity上),您可以自由对之进行命名,只要这个命名让用户看上去很容易理解。
component
一个component代表一个固定功能的组件。组件编写在src/components目录下,同样是一个子目录代表一个组件。编写组件的目的是:
- 为了在多个page之间复用代码
- 避免page过于复杂
在Oak中,一个page可以包含多个component,一个component也可以引用其它component,但在component之间不能出现循环引用。
page之间不要相互引用,尽管这样做也不会错,但会让整个项目变得更加混乱,难以维护。
目录文件结构
在src/pages和src/components目录下,您可以编写应用页面和组件了。每个页面/组件都占据着唯一的子目录,在子目录下可能有如下若干文件:
| 文件名 | 作用 |
|---|---|
| index.ts | 定义页面/组件的逻辑(必需) |
| index.config.ts | 页面/组件的结构化配置。页面使用CreatePageConfig,组件使用CreateComponentConfig |
| index.json | 历史兼容的小程序页面/组件JSON配置。新代码优先使用index.config.ts |
| locales/zh-CN.json | 页面/组件的i18n内容 |
| web.pc.tsx | 宽屏的html渲染 |
| web.tsx | web通用渲染,在没有更具体入口时回退 |
| web.mobile.tsx | 移动宽度下优先使用的html渲染 |
| web.pc.module.less | 宽屏样式 |
| web.module.less | 窄屏样式 |
| index.xml | 小程序渲染 |
| index.less | 小程序样式 |
| render.native.tsx | App渲染 |
| render.native.scss | App渲染样式 |
| render.ios.tsx | ios渲染 |
| render.android.tsx | android渲染 |
看上去有些复杂,但是实际上绝大多数项目都只会实现其中的部分文件。Oak框架在前端采取了一个较为保守的方案,以应对跨前端的一致性问题。其中心思想是:对页面的逻辑统一抽象(index.ts),而对页面的具体渲染则分别处理。
将前端各平台的渲染语法强行统一到一个框架之下(如taro, uniapp)的前景固然美好,但这会带来兼容性和扩展性的严重问题,同时也会造成与开源社区的割裂。这显然与Oak框架的设计目标背道而驰。因此Oak框架借鉴了微信小程序和React的设计思想,将页面的各种与渲染无关的逻辑部分抽象到index.ts当中,而将各个平台的渲染代码分割到各个文件之中,在对应平台下运行的时候进行加载,从而达到一致性和兼容性的较好平衡。
index.ts
在index.ts中,定义了本页面/组件的逻辑代码,此文件中应当避免引用任何平台相关的特性内容,而只关注于页面的逻辑能力。关于index.ts如何编写,请参见编写组件;而组件逻辑上的方法和数据项,可以参见定义组件对象。
如果确实需要引用平台相关的特性内容,可以通过process.env.OAK_PLATFORM环境变量加以区别。例如,在支付时如果需要唤起平台的支付接口,可以像下面这样编写(代码引用自oak-pay-business/src/components/pay/detail/index.ts):
if (process.env.OAK_PLATFORM === 'wechatMp') {
const { prepayMeta } = meta as { prepayMeta: WechatMiniprogram.RequestPaymentOption };
if (prepayMeta) {
const result = await wx.requestPayment(prepayMeta);
process.env.NODE_ENV === 'development' && console.log(result);
return result;
}
else {
features.message.setMessage({
type: 'error',
content: features.locales.t('startPayError.illegaPayData'),
});
}
}
else {
features.message.setMessage({
type: 'error',
content: features.locales.t('startPayError.falseEnv', { env: 'wechatMp' }),
});
}
在这里,当页面调用了支付事件时,如果判断当前环境是小程序,则调用wx.requestPayment方法唤起支付。
index.config.ts
index.config.ts用于声明页面或组件的结构化配置,它会被Oak CLI读取并参与web路由、小程序虚拟JSON、菜单和访问控制等生成流程。页面目录中通常这样写:
import { CreatePageConfig } from '@oak-frontend-base/config';
export default CreatePageConfig({
route: {
titleI18nKey: 'pageTitle',
access: { type: 'login' },
},
menu: {
name: 'Order',
icon: 'list',
},
mp: {
navigationBarTitleText: '订单',
enablePullDownRefresh: true,
usingComponents: {
price: '@project/components/price',
},
},
});
组件目录中通常这样写:
import { CreateComponentConfig } from '@oak-frontend-base/config';
export default CreateComponentConfig({
mp: {
component: true,
usingComponents: {
avatar: '@project/components/avatar',
},
},
});
其中mp字段对应原来小程序index.json中的配置项,例如usingComponents、componentGenerics、componentPlaceholder、styleIsolation、navigationBarTitleText等。保留index.json仍然可以兼容旧项目,但新页面和新组件建议优先写index.config.ts。
web端
如果您的应用基于 web,可以按需要提供 web.tsx、web.pc.tsx 和 web.mobile.tsx。当前选择顺序是:
- 移动宽度:
web.mobile.tsx -> web.tsx -> web.pc.tsx; - 宽屏:
web.pc.tsx -> web.tsx -> web.mobile.tsx。
只提供其中一个入口时,宽屏和移动端都会回退使用它。当前 Vite render-entry invalidation 会监听这些兄弟入口的新增和删除,开发中补充另一个入口后通常不再需要手工执行 clean:cache。在 TSX 中仍可使用普通 React 组件和 render-local hooks,但 Oak 数据树状态、projection、filter 和跨端业务方法应继续放在 index.ts。
微信小程序端
微信小程序端的模板写在index.xml中,其语法和wxml完全一致。页面/组件JSON配置优先写在index.config.ts的mp字段中,例如usingComponents、navigationBarTitleText、componentGenerics等;Oak CLI会在构建时生成对应的小程序JSON。旧项目中的index.json仍然兼容,但不建议在新代码里继续新增。
App端
Oak框架的App端基于react-native,您只需要编写render.native.tsx,则可生成在IOs和Android下通用的页面。当然,与react-native的编译规则类似,您也可以编写render.ios.tsx或者render.android.tsx,分别在两个操作系统之下进行渲染。同样的,您可以在tsx文件中引用任何您想使用的第三方组件。
编写组件/页面
定义完 Entity 后,就可以开始编写组件和页面了。Oak 的前端开发和常见 React 项目最大的不同在于:组件往往不是独立地“自己发请求”,而是挂在一棵数据树上,围绕对象路径来组织。
因此,写 Oak 组件时,建议先分清三层角色:
- 页面 / panel 容器:负责组织路径、tab、弹窗、子组件编排;
- 业务组件:负责某个对象的详情、编辑、列表、动作;
- 抽象通用组件:只负责展示或编辑,不负责具体业务语义。
这一章后面的几个小节,会分别讲单个组件怎么写、组件之间怎么组织、OakComponent 有哪些参数和运行时能力。这里先给你一个整体视角。
先记住三类最常见的业务组件
对于关联到 Entity 的组件,最常见的还是这三类:
List:列表组件,显示当前对象的多条数据;Detail:详情组件,显示当前对象的一条数据;Upsert:编辑组件,编辑当前对象的一条数据,或为一条新数据准备 create。
如果一个组件不关联任何 Entity,那么它就是 Virtual 组件。Virtual 组件通常用来做:
- 首页、看板、工作台;
- 多个 Entity 混排的页面壳;
- 纯布局、纯交互容器;
- 只负责承载子组件的 panel / wrapper。
组件不是只分“页面”和“子组件”
在真实 Oak 项目里,组件通常还会再按职责分成下面几类:
| 类型 | 常见命名 | 作用 |
|---|---|---|
| 页面入口 | pages/... | 路由入口、挂页面根路径 |
| panel 容器 | panel | 组织 tab、弹窗、多个子组件 |
| 业务详情组件 | detail | 展示一条对象数据 |
| 业务编辑组件 | upsert | 编辑一条对象数据 |
| 业务列表组件 | list / 具体名字 | 管理一组对象 |
| 通用抽象组件 | oak-frontend-base/components/... | 纯展示/编辑壳,不带具体业务语义 |
例如在 oak-general-business 中:
system/panel是System的容器组件;system/detail展示并承接提交入口;system/upsert负责编辑System;system/application展示并管理属于某个System的Application列表。
application/panel 也采用了同样的模式:自己先取当前 Application 的核心数据,再把配置、样式、公众号能力、模板能力等拆成多个 tab 子组件。
这类 panel 组件,是 Oak 项目里非常值得掌握的一种组织方式。
优先复用的顺序
在 Oak 项目里,建议按下面这个顺序考虑复用,而不是一上来就自己写一套新组件。
1. 先看 oak-frontend-base 有没有抽象组件
oak-frontend-base/src/components 里有一批通用抽象组件,比如:
detailupsertlistfilterfilterPanelpaginationactionBtnrefAttr
这类组件的特点是:
- 它们通常不代表某个具体业务;
- 更像“对象展示器”“对象编辑器”“列表渲染器”;
- 更适合拿来快速搭一个管理页、详情块、编辑块。
2. 再看 oak-general-business / oak-pay-business 有没有完整业务组件
如果你的需求已经落在公共业务包的职责范围里,优先复用它们现成的业务组件。例如:
SystemPanelApplicationPanel- 用户、登录、授权、消息、文件类组件;
- 支付、账户、提现、物流类组件。
这些组件不仅有 UI,还通常已经把对象结构、路径组织、交互流程、动作提交一并处理好了。
3. 只有当公共组件不匹配时,再写项目私有组件
通常在下面几种情况下,才值得自己新写:
- 现有公共组件的对象结构与你项目不一致;
- 页面交互或展示方式有明显差异;
- 你需要新增项目私有字段、动作或子组件关系;
- 你需要一个专门的 panel 来组织项目内多个公共能力。
oak-frontend-base 抽象组件怎么用
这部分文档以前讲得还不够。这里直接按源码整理一份速查表。
抽象 detail 组件
源码入口:
oak-frontend-base/src/components/detail/index.ts
这类组件更像“详情展示壳”。它本身不负责对象取数,而是渲染父组件已经拿到的数据。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前展示的是哪个对象 |
attributes | 要展示哪些字段 |
data | 当前行数据 |
title | 标题 |
bordered | 是否带边框 |
layout | horizontal 或 vertical |
column | 列数,可按断点配置 |
适合场景:
- 某个 panel 已经取到了当前对象数据;
- 你只想快速把一组字段渲染成标准详情块;
- 不想每次都手写
Descriptions/ label 映射 / 枚举颜色逻辑。
抽象 upsert 组件
源码入口:
oak-frontend-base/src/components/upsert/index.ts
这类组件更像“表单编辑壳”,主要负责把字段定义转成输入控件,并通过 update(...) 写回当前结点。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前编辑的是哪个对象 |
attributes | 要编辑哪些字段 |
data | 当前行数据 |
layout | 表单布局 |
mode | default 或 card |
helps | 字段帮助文案 |
适合场景:
- 你已经有当前对象上下文;
- 只想快速把若干字段做成编辑表单;
- 想复用字段类型推导、标签文案、枚举处理。
抽象 list 组件
源码入口:
oak-frontend-base/src/components/list/index.ts
它本质上是一个列表渲染器,而不是“自动帮你查询对象”的业务列表页。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前列表对应哪个对象 |
attributes | 列定义 |
data | 行数据数组 |
loading | 是否加载中 |
extraActions | 额外操作按钮 |
onAction | 行动作回调 |
rowSelection | 多选配置 |
hideHeader | 是否隐藏表头 |
disableSerialNumber | 是否禁用序号列 |
size | 表格尺寸 |
scroll | 滚动设置 |
empty | 空状态内容 |
opWidth | 操作列宽度 |
ListPro 不接受 tablePagination。它使用 Oak 自有的 Pagination 组件并从当前 list 结点读取分页状态;需要调整共享分页布局时,应修改或覆盖 Oak components/pagination 的 .pagination 根样式,而不是配置 Ant Design Table 的分页参数。
适合场景:
- 父组件已经通过 Oak 数据树拿到了行数组;
- 你只想把这组行数据渲染成一个统一样式的列表;
- 行上还需要配合
#oakLegalActions、#oakLegalCascadeActions展示动作按钮。
抽象 actionBtn 组件
源码入口:
oak-frontend-base/src/components/actionBtn/index.ts
这个组件不是“提交按钮”,而是动作按钮渲染器。它的职责是把当前行可执行动作、级联动作和额外动作,整理成一组按钮或下拉项。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前动作所属对象 |
actions | 当前行允许展示的动作 |
cascadeActions | 当前行允许展示的级联动作 |
extraActions | 额外自定义动作 |
onAction | 点击动作后的回调 |
适合场景:
- 列表行操作区;
- 详情页右上角动作区;
- 需要同时展示普通动作和子对象动作的地方。
源码里还有两个值得注意的行为:
- 普通动作和额外动作会一起参与排序和裁剪,超出的会进“更多”;
- 级联动作的文案会沿路径解析目标 entity,再做 i18n 翻译。
如果你的页面已经通过 actions / cascadeActions 拿到了合法动作集合,actionBtn 往往是最省事的渲染方式。
抽象 filterPanel 组件
源码入口:
oak-frontend-base/src/components/filterPanel/index.ts
这个组件本质上是列表筛选条件面板,它本身不定义对象取数,而是帮你把筛选列转换成一组 named filters,并挂到当前 list 结点上。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前筛选面板对应的对象 |
columns | 要渲染哪些筛选项 |
适合场景:
- 后台列表页;
- 管理台侧边筛选区;
- 需要把多个筛选条件统一折叠、展开、重置的列表页面。
这里有两个源码层面的真实约束:
- 当前只支持一项全文检索列,也就是
columns里最多一个$text; - 小程序端默认只展示前三个筛选项,其余会折叠进“更多筛选”。
另外,filterPanel 的重置和确认,最终调用的是当前结点上的:
removeNamedFilterByName(...)refresh()
所以它最适合挂在已经建立好的 list 路径上,而不是脱离列表独立使用。
抽象 refAttr 组件
源码入口:
oak-frontend-base/src/components/refAttr/index.ts
这个组件主要用于外键字段展示和选择。如果某个字段本质上是“从别的对象里挑一条或多条”,而你不想每次都手写一套选择逻辑,它非常有用。
常用参数如下:
| 参数 | 作用 |
|---|---|
placeholder | 占位文案 |
multiple | 是否多选 |
entityId | 单选当前值 |
entityIds | 多选当前值 |
pickerRender | 如何查询候选项、如何展示标题 |
onChange | 选择变化回调 |
pickerRender / pickerDef 实际会决定:
- 候选对象是哪个 entity;
- 用什么 projection 去取候选行;
- 用什么 filter 限定候选范围;
- 标题如何渲染;
- 选择模式是
radio、select还是别的形态。
源码里还有两个很实用的约束:
radio模式要求候选总数count <= 5;select模式要求候选总数count <= 20。
这意味着它更适合:
- 候选范围不大的外键选择;
- 单选/多选对象引用;
- 表单里的“选用户”“选系统”“选上级对象”。
如果候选集很大,通常应该改成专门的列表挑选页,而不是继续塞进 refAttr。
抽象 pagination 组件
源码入口:
oak-frontend-base/src/components/pagination/index.ts
这个组件是列表分页状态的展示层,它依赖当前结点里的 oakPagination,并不会自己定义分页逻辑。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前分页对应的对象 |
showQuickJumper | 是否允许快速跳页 |
size | 分页器尺寸 |
showSizeChanger | 是否允许切换每页条数 |
showTotal | 自定义总数展示 |
适合场景:
- 已经用 Oak list 组件建立了分页结点;
- 你只需要一个分页器 UI 与当前结点联动。
需要注意的是:
- 它依赖当前 list 结点上的
oakPagination; - 因此应放在同一条 list 路径上下文中使用;
- 如果列表根本没有配置分页或总数,这个组件也不会凭空帮你算出来。
抽象 search 组件
源码入口:
oak-frontend-base/src/components/search/index.ts
这个组件是列表关键字搜索辅助组件。它的核心能力不是渲染一个输入框本身,而是把关键字转换成名为 search 的 named filter,挂到当前 list 结点上。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前搜索针对哪个对象 |
attributes | 指定在哪些字段上搜索 |
placeholder | 搜索框占位文案 |
它的实际行为有两种:
- 如果不传搜索字段,则默认生成全文检索 filter,即
$text.$search; - 如果传了字段数组,则会生成一组
$or条件,按字段路径分别构造搜索 filter。
适合场景:
- 后台列表页顶部快速搜索;
- 配合
filterPanel形成“关键字 + 条件筛选”的组合检索; - 某些对象只希望暴露少数字段给用户搜索。
一个实践建议是:
- 如果你的对象已经建立了全文索引,优先让
search走全文检索; - 如果你只想允许用户搜少数字段,再显式传
attributes。
抽象 filter 组件
源码入口:
oak-frontend-base/src/components/filter/index.ts
和 filterPanel 不同,filter 是单个筛选项组件。它负责把某一列的筛选定义,转换成具体控件和具体 filter。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前筛选项对应哪个对象 |
column | 这一项的筛选定义 |
从源码看,它会根据字段类型和操作符自动决定渲染方式,例如:
- 数值、文本字段:输入框;
datetime:日期选择器;boolean、enum:选择器;ref:引用对象选择器。
同时它会根据配置自动生成 named filter 名称,并在用户确认后调用:
addNamedFilter(...)removeNamedFilterByName(...)
适合场景:
- 你不想用整块
filterPanel,而是只想在页面某处插入一个单独筛选器; - 你想自定义筛选布局,但仍希望复用 Oak 的字段类型推导和 filter 构造逻辑。
抽象 picker 组件
源码入口:
oak-frontend-base/src/components/picker/index.ts
picker 是基于 list 结点的对象选择器。和 refAttr 相比,它更适合“打开一个候选列表让用户选”的场景,而不是直接把小范围候选塞进单个表单控件。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 候选对象属于哪个 entity |
multiple | 是否多选 |
onSelect | 用户确认选择后的回调 |
title | 每行如何显示标题 |
titleLabel | 选择框标题 |
filter | 候选数据过滤条件 |
sorter | 候选数据排序 |
projection | 候选行需要取哪些字段 |
这个组件本身会建立一个 list 结点:
entity()直接来自外部 props;projection()直接复用外部传入的 projection;filters直接把外部传入的 filter 接入当前结点。
适合场景:
- 候选对象很多,
refAttr不够用; - 需要带筛选条件地选一条或多条对象;
- 需要在弹窗、抽屉、独立 tab 中做“从对象列表里挑选”。
可以把它理解成:比 refAttr 更偏列表式选择,比自己手写一个 list + 选择逻辑更省事。
抽象 actionBtnPanel / actionTabPanel 组件
源码入口:
oak-frontend-base/src/components/actionBtnPanel/index.tsoak-frontend-base/src/components/actionTabPanel/index.ts
这两个组件都属于动作集合展示壳,区别主要在布局:
actionBtnPanel更像按钮面板 / 操作工具条;actionTabPanel更像分页签 / 卡片式动作网格。
actionBtnPanel 的常用参数:
| 参数 | 作用 |
|---|---|
entity | 动作所属对象 |
items | 要展示的动作项 |
mode | 展示模式,如 default、cell、table-cell |
column | 一行显示多少项 |
fixed | 是否固定布局 |
actionTabPanel 的常用参数:
| 参数 | 作用 |
|---|---|
entity | 动作所属对象 |
items | 要展示的动作项 |
rows | 每页几行 |
column | 每行几列 |
mode | 文本或其它展示模式 |
适合场景:
- 首页或详情页上的统一动作区;
- 小程序中的多动作入口面板;
- 一组动作很多,需要分页或折叠展示。
这两个组件内部都会对动作项做一层统一处理:
- 如果动作项有
label,优先使用自定义文案; - 否则优先翻译 entity 自身动作文案;
- 再不行就退回
common::action.xxx。
因此,当你已经整理好动作项数组时,这两个组件可以帮你省掉一层动作按钮布局代码。
抽象 listPro 组件
源码入口:
oak-frontend-base/src/components/listPro/index.tsx
如果说 list 更像“纯列表渲染器”,那么 listPro 更像 Oak 后台里最常见的标准列表页壳。它内部实际上是:
- 上方
ToolBar; - 中间
list; - 配合
TableContext管理列显示状态; - 默认把刷新动作绑定到当前
oakPath对应结点上。
常用参数如下:
| 参数 | 作用 |
|---|---|
entity | 当前列表对应哪个对象 |
attributes | 列定义 |
data | 行数据数组 |
title | 列表标题 |
extraContent | 工具条右侧附加内容 |
buttonGroup | 工具条按钮组 |
extraActions | 行额外动作 |
onAction | 行动作回调 |
oakPath | 当前列表路径,默认刷新会用到 |
rowSelection | 多选配置 |
disableSerialNumber | 是否关闭序号列 |
size / scroll / empty / opWidth | 表格显示控制 |
hideDefaultButtons | 是否隐藏默认工具条 |
onReload | 自定义刷新逻辑 |
这里有一个很实用的源码细节:
- 如果你传了
oakPath,但没有自定义onReload,listPro工具条上的刷新会直接调用features.runningTree.refresh(oakPath); - 因此它最适合和 Oak list 结点放在同一路径上下文里使用。
适合场景:
- 后台标准列表页;
- 列表上方带标题、按钮、刷新入口;
- 想复用
List + ToolBar的统一外观,而不是每次手拼。
在真实项目中,这种用法非常常见。例如:
oak-pay-business/src/components/order/list/web.pc.tsxtaicang/src/pages/console/order/list/web.pc.tsx
都采用了 FilterPanel + ListPro 的组合。
抽象 pageHeader 组件
源码入口:
oak-frontend-base/src/components/pageHeader/index.ts
这个组件属于页面壳组件,主要负责:
- 页头标题;
- 返回按钮;
- 页头右侧操作区;
- 页面内容容器。
pageHeader 的常用参数:
| 参数 | 作用 |
|---|---|
title | 页标题 |
subTitle | 副标题 |
extra | 页头右侧内容 |
tags | 标题旁标签 |
showBack / onBack / delta | 返回按钮控制 |
contentMargin | 内容区是否保留默认边距 |
contentStyle / contentClassName | 内容区样式 |
children | 页面主体内容 |
适合场景:
- 后台管理页的统一页头;
- 列表页、详情页、配置页的内容容器;
- 你希望把“页面标题 + 筛选区 + 列表区”包在一个稳定的视觉外壳里。
在 taicang 和 haina-busi 里,这类页面壳的使用都非常多,尤其是:
- 列表页最外层包
PageHeader; - 页头内部先放筛选区;
- 下方再放
ListPro或详情内容。
关系权限管理相关组件
源码入口:
oak-frontend-base/src/components/relation/path/listoak-frontend-base/src/components/relation/path/detailoak-frontend-base/src/components/relation/path/upsertoak-frontend-base/src/components/relation/actionAuthoak-frontend-base/src/components/relation/relationAuth
这组组件不是通用页面壳,而是围绕系统实体 path、actionAuth、relationAuth 的专用管理组件。它们主要用于:
- 配置对象路径;
- 配置动作授权矩阵;
- 配置关系授权矩阵。
对新手来说,先把它们理解成“系统管理后台专用组件”就够了。平时业务开发里,不建议把它们当成通用抽象组件到处复用;真正需要对象关系授权配置时,再顺着这几个目录去读源码会更合适。
再补一个实用建议:通用组件放哪一层
这些抽象通用组件,最常见的放置位置通常是:
| 组件 | 推荐放置层 |
|---|---|
detail | detail / panel 子块 |
upsert | upsert / 弹窗 / 步骤块 |
list | 业务 list 外壳内部 |
actionBtn | 列表操作列、详情页动作区 |
actionBtnPanel / actionTabPanel | 首页动作区、页头动作区、操作面板 |
search | list 页顶部快速搜索区 |
filter | 自定义单筛选项区域 |
filterPanel | list 页顶部或侧边筛选区 |
refAttr | 表单字段内部 |
picker | 弹窗/抽屉中的对象选择区 |
pagination | list 页底部 |
listPro | 后台标准列表页主体 |
pageHeader | 页面最外层壳 |
在项目里通常会再包一层 AbstractComponents
这也是实际项目里非常常见的一步。
像 taicang、oak-pay-business、haina-busi 这类项目,都会在自己的:
src/components/AbstractComponents.ts
里,把 oak-frontend-base 的抽象组件按本项目的 EntityDict 重新声明一遍,例如:
import AbsListPro from '@oak-frontend-base/components/listPro';
import { EntityDict } from '@project/oak-app-domain';
const ListPro = AbsListPro as <T extends keyof EntityDict>(
...props: Parameters<typeof AbsListPro<EntityDict, T>>
) => React.ReactElement;
这样做有三个直接好处:
- 组件使用时会自动带上项目自己的 Entity 类型;
- 页面里不用每次都手写一长串泛型;
- 后续如果要统一替换或二次封装抽象组件,也有一个稳定入口。
如果你的项目已经进入“组件越来越多”的阶段,很建议尽早建立这层封装。
一个后台列表页的推荐拼法
如果你现在正在写一个后台列表页,最稳妥、也最接近真实项目的组合通常是:
- 最外层用
pageHeader做页面壳; - 页头或顶部区域放
filterPanel; - 主体区域放
listPro; - 行内新增、编辑再用弹窗挂
upsert; - 详情跳转或局部编辑再决定用共享路径、行路径或绝对路径。
一个非常典型的渲染结构大概像这样:
<PageHeader title={t('pageTitle')}>
<FilterPanel
entity="order"
oakPath={oakFullpath}
columns={filterColumns}
/>
<ListPro
entity="order"
oakPath={oakFullpath}
data={orders}
attributes={attributes}
extraActions={extraActions}
onAction={handleAction}
/>
</PageHeader>
这里要特别注意一件事:
FilterPanel和ListPro最好放在同一条 list 路径上;- 这样筛选条件、刷新动作、分页状态才会自然落在同一个 runningTree 结点里。
什么时候用抽象组件,什么时候自己写 OakComponent
可以用下面这个标准判断:
优先用抽象组件
当你只是需要:
- 展示一条当前对象数据;
- 编辑一条当前对象数据;
- 渲染一组当前对象数据;
- 做标准字段展示、标准字段编辑、标准表格列表。
优先自己写 OakComponent
当你需要:
- 自己定义
projection、filters、sorters、pagination; - 自己组织
oakPath和父子结点; - 在
formData中组合多段数据; - 自己决定
execute、clean、弹窗、tab、步骤条等交互结构; - 把多个业务组件组装成一个 panel。
实际上,Oak 项目里最常见的写法不是“二选一”,而是:
- 外层自己写一个业务
OakComponent; - 内层在合适的位置复用
oak-frontend-base抽象组件。
一个很典型的 panel 模式
如果你打开下面两个组件,会看到非常相似的结构:
oak-general-business/src/components/system/paneloak-general-business/src/components/application/panel
它们的共同特点是:
- panel 自己先拿到当前对象的核心数据;
- 再通过 tab 或子区域,挂多个子组件;
- 子组件有的共享路径,有的走关联路径,有的走绝对路径;
- panel 自己不一定负责每个 tab 的细节,但负责整体编排。
这种模式非常适合:
- 管理后台详情页;
- 配置中心;
- 一个对象下挂很多子能力的业务场景;
- 支付、系统配置、公众号能力这种“一个对象,多块配置”的页面。
阅读顺序建议
为了避免一开始被路径和组件树绕晕,建议按下面顺序阅读后面的章节:
这样先理解“单个组件怎么工作”,再理解“多个组件怎么组成页面”,会更顺。
编写详情组件
本节示例代码可参看
oak-general-business/src/components/system/detail,其父组件是oak-general-business/src/components/system/panel
现在我们需要做一个用于查看 System 信息的详情组件。这里要先说明一个真实的公共组件写法:system/detail 并不是自己单独负责首轮取数,而是复用 system/panel 已经建立好的结点和 projection。
也就是说:
system/panel负责声明entity: 'system'、完整projection和根路径;system/detail通过复用同一条oakPath,在这个结点上继续做展示和提交入口;system/upsert也会挂到同一条路径上,负责编辑。
这个模式在 Oak 组件里很常见:一个父容器组件负责稳定取数,多个子组件共享同一条对象上下文。
逻辑层(index.ts)
system/detail/index.ts 的真实代码大致如下:
export default OakComponent({
isList: false,
entity: 'system',
formData({ data }) {
return {
...data,
oakExecutable: this.tryExecute(),
};
},
});
这里有两个关键信息:
- 这是一个单行组件,所以
isList为false; - 它没有自己声明
projection,因为当前组件通常挂在system/panel的同一路径上,父组件已经把需要的字段取好了。
同时它在 formData 中额外返回了:
oakExecutable: this.tryExecute()
这也是 Oak 项目里非常常见的写法。它表示:
- 当前结点上如果存在待提交修改;
- 并且这些修改通过了 checker / 权限检查;
- 那么渲染层就可以据此决定“确认按钮是否可点”。
这也再次说明:oakExecutable 并不是所有组件自动拥有的内置数据项,很多时候是组件作者自己在 formData 中计算并返回的。
渲染层(web.pc.tsx)
在 system/detail/web.pc.tsx 中,组件一方面展示详情,一方面提供一个“打开编辑弹窗并提交”的入口。核心代码大致如下:
export default function Render(props) {
const {
oakId,
name,
description,
oakFullpath,
oakExecutable,
oakExecuting,
} = props.data;
const { t, execute, clean } = props.methods;
return (
<>
<Modal
open={open}
onCancel={() => {
clean();
setOpen(false);
}}
footer={
<Space>
<Button
onClick={() => {
clean();
setOpen(false);
}}
disabled={oakExecuting}
>
{t('common::action.cancel')}
</Button>
<Button
type="primary"
onClick={async () => {
await execute();
setOpen(false);
}}
disabled={oakExecutable !== true || oakExecuting}
>
{t('common::action.confirm')}
</Button>
</Space>
}
>
<SystemUpsert oakId={oakId} oakPath={oakFullpath} />
</Modal>
...
</>
);
}
当前 Oak 编译器会根据同目录 index.ts 中的 entity、isList、properties、formData 和 methods 为传统 TSX render 注入 props.data / props.methods 的精确类型。因此这里不再手写 WebComponentProps,也不应保留仅为旧签名服务的 WebComponentProps、EntityDict 类型导入。业务输入仍必须在 index.ts 的 properties 中声明,不能因为 render 能取到某个字段就省略组件契约。
这里最值得学的不是具体 UI,而是这三个运行时动作:
clean():取消时清理当前路径上的未提交修改;execute():提交当前路径上的修改;<SystemUpsert oakId={oakId} oakPath={oakFullpath} />:把编辑组件挂到和详情组件相同的结点上。
也就是说,在这个例子里:
- 详情组件负责打开弹窗、显示按钮状态、发起提交;
- 更新组件负责写入待提交的数据;
- 两者通过相同的
oakPath共享同一个对象结点。
这个组件是如何被使用的
真正负责首轮取数的是 system/panel。在它的 web.pc.tsx 中,可以看到类似下面的写法:
<SystemDetail
oakId={id}
oakPath={oakFullpath}
/>
这里:
oakId指向当前System的主键;oakPath复用system/panel已建立好的那条路径。
这也是为什么 system/detail 自己可以不再声明 projection。
这个例子应该记住什么
- 详情组件不一定要自己负责 projection,完全可以复用父组件的对象结点。
- 详情组件在 Oak 中经常不只是“展示”,还会兼任“提交入口”。
- 如果详情组件和更新组件共享同一条
oakPath,就可以把编辑结果直接写到同一个 runningTree 结点上,再由详情组件统一execute()。
这一模式在后台管理类页面里非常常见。下一节的更新组件,就是这个例子里被弹窗打开的 system/upsert。
编写更新组件
上一节的详情组件里,真正负责编辑 System 数据的是 oak-general-business/src/components/system/upsert。这一节就用它来说明 Oak 中单行更新组件的典型写法。
逻辑层(index.ts)
system/upsert/index.ts 的真实代码大致如下:
export default OakComponent({
isList: false,
entity: 'system',
projection: {
id: 1,
name: 1,
config: 1,
description: 1,
oldestVersion: 1,
super: 1,
},
formData({ data }) {
return data || {};
},
});
这里有三个要点:
- 它仍然是单行组件,所以
isList: false; - 它显式声明了
projection,说明这个 Upsert 组件本身可以独立工作,而不完全依赖父组件兜底取数; formData只是把当前行数据原样展开给渲染层。
这点和上一节刚好形成对照:
system/detail倾向于复用父组件已有结点,并额外返回oakExecutable;system/upsert更像一个可独立复用的编辑表单,因此把自己需要的字段写在projection里。
所以在实际项目里,不要硬记“详情一定有 projection、upsert 一定没有 projection”这种结论。正确规则是:谁需要独立承担取数责任,谁就应该声明 projection。
渲染层如何更新数据
在 system/upsert/web.pc.tsx 中,主要工作是把输入控件和 update(...) 绑在一起:
export default function Render(props) {
const {
name,
description,
super: super2,
oldestVersion,
} = props.data;
const { t, update } = props.methods;
return (
<Form>
<Form.Item label={t('system:attr.name')}>
<Input
value={name}
onChange={(e) => {
update({
name: e.target.value,
});
}}
/>
</Form.Item>
<Form.Item label={t('system:attr.description')}>
<Input.TextArea
value={description}
onChange={(e) => {
update({
description: e.target.value,
});
}}
/>
</Form.Item>
<Form.Item label={t('system:attr.oldestVersion')}>
<Input
value={oldestVersion}
onChange={(e) => {
update({
oldestVersion: e.target.value,
});
}}
/>
</Form.Item>
<Form.Item label={t('system:attr.super')}>
<Switch
checked={super2}
onChange={(checked) => {
update({
super: checked,
});
}}
/>
</Form.Item>
</Form>
);
}
这里的 props 类型由 Oak 编译器从 index.ts 自动推导并注入,不需要手写 WebComponentProps。如果表单还需要父组件传入额外业务字段,应把字段加入 index.ts -> properties;编译器会把它们合并进 render 合同。不要只在 TSX 中补一个手写类型,因为那不会建立 Oak 组件的真实运行时属性声明。
这里的 update(...) 并不会立刻向后端提交请求,它做的是:
- 把当前修改记录到 runningTree 对应结点上;
- 让当前路径上的“新值”立即反映到
formData和渲染层中; - 等待之后统一
execute()。
因此,Oak 的 Upsert 组件通常天然适合:
- 表单分块编辑;
- 多个子组件协同编辑同一对象;
- 在确认前统一校验并提交。
提交为什么通常不放在 Upsert 组件里
以 system/upsert 这个例子来说,提交按钮并不在 Upsert 组件内部,而是放在上一节的 system/detail 组件里,由详情组件统一调用:
await execute();
这是一种很值得借鉴的组织方式。因为很多时候:
- Upsert 只是某个详情页里的一个弹窗或一个 tab;
- 页面上可能还有别的子组件也在改同一条数据;
- 提交、取消、按钮可用性判断,往往更适合由父组件统一控制。
换句话说:
- Upsert 组件负责“怎么改”;
- 父组件负责“什么时候提交、什么时候回滚、按钮怎么展示”。
新建数据时要特别注意的地方
Oak 中,单行组件是否处于“创建态”,和 oakId 是否已经稳定,非常相关。
1. 没有 oakId 时,单行组件会进入 create 语义
如果一个单行组件初始化时没有 oakId,框架会把它当成创建流程来处理。此时:
- 可以通过
this.isCreation()判断当前是否是 create; - 也可以在
formData中通过data.$$createAt$$ === 1判断当前行是否是新建态。
2. 不要让 oakId 在组件初始化后“从无到有”
例如:
<SystemUpsert oakId={application?.systemId} oakPath={`${oakFullpath}.system`} />
这种写法就有风险。因为很可能第一次渲染时 application 还没取到,oakId 是 undefined,组件会按 create 初始化;等数据回来后 oakId 又变成了已有主键,运行树会认为这是一种异常状态切换。
更稳妥的写法是:
{!!application && (
<SystemUpsert
oakId={application.systemId}
oakPath={`${oakFullpath}.system`}
/>
)}
也就是:等主键真的确定后,再渲染这个单行组件。
3. 如果要连续创建,需要在提交后显式再 create 一次
Oak 不会在一次创建提交完成后自动帮你进入下一轮创建。如果你需要“连续创建”,要在 execute() 成功后手动再准备下一条:
await this.execute();
this.create({
...
});
这一节最重要的结论
- Upsert 组件的核心职责是调用
update/create把待提交修改写进 runningTree。 - 是否在 Upsert 自己身上声明
projection,取决于它是否需要独立承担取数责任。 - 提交按钮不一定要放在 Upsert 组件里,很多场景下交给父组件统一
execute更合理。 - 单行组件一旦涉及创建流程,
oakId的时序一定要特别小心。
编写列表组件
接下来看看列表组件的例子。还是沿用 oak-general-business 中的 System 和 Application:在 system/panel 里,我们不仅想展示当前 System 的详情,还想管理属于这个 System 的所有 Application。
对应的公共组件在:
oak-general-business/src/components/system/application
逻辑层(index.ts)
这个组件的 index.ts 大致如下:
export default OakComponent({
entity: 'application',
isList: true,
projection: {
id: 1,
name: 1,
config: 1,
description: 1,
type: 1,
systemId: 1,
domainId: 1,
style: 1,
},
properties: {
systemId: '',
},
formData({ data }) {
return {
applications: data || [],
oakExecutable: this.tryExecute(),
};
},
});
这里比单行组件多了几个典型特征:
isList: true,说明这是列表结点;formData中的data不再是一行,而是一个数组;- 组件接受一个额外的
systemId参数; - 组件同样在
formData中返回了oakExecutable,用于控制列表内新建弹窗的确认按钮状态。
渲染层不只是“展示列表”
Oak 的列表组件,往往同时还承担列表内的增删改入口。system/application/web.pc.tsx 就是一个非常典型的例子:
export default function render(props) {
const {
oakFullpath,
applications,
oakExecutable,
oakExecuting,
systemId,
} = props.data;
const { addItem, removeItem, clean, execute, t } = props.methods;
...
}
列表 render 同样由编译器注入类型:applications 和 oakExecutable 来自 formData,systemId 来自 properties,Oak 运行状态与方法由框架合同补齐。传统 TSX 不再手写 WebComponentProps。如果这里使用了未在 formData、properties 或框架内置合同中出现的字段,严格构建应当直接报错,正确修复位置通常是 index.ts,而不是扩大 render 参数类型。
这个组件除了渲染 applications 列表,还会:
- 调用
addItem(...)先在列表结点上添加一条待创建数据; - 调用
removeItem(...)标记删除某一项; - 调用
clean()取消弹窗中的未提交修改; - 调用
execute()统一提交列表上的修改。
所以 Oak 里的 list 组件,常常不是“纯展示表格”,而是一个完整的列表工作台。
列表内新建的一个真实模式
在这个例子里,当用户点击“新增应用”时,组件会先这样做:
const id = addItem({
systemId,
config: {} as EntityDict['application']['Schema']['config'],
warningVersions: [],
dangerousVersions: [],
});
setCreateId(id);
然后把一个 ApplicationUpsert 挂到:
<ApplicationUpsert
oakId={createId}
oakPath={`${oakFullpath}.${createId}`}
/>
这段代码很值得仔细理解:
addItem(...)只是先在当前列表结点下准备了一条待创建数据,并返回一个临时 id;ApplicationUpsert再通过这个 id,挂到当前列表项的子路径上;- 用户在弹窗里编辑这条待创建数据;
- 最后统一
execute()提交。
这就是 Oak 列表组件里最常见的“列表内创建一条子项”的模式。
列表内删除也是延迟提交
删除流程也不是立刻发请求,而是先在列表结点上记录操作,再统一提交。例如:
removeItem(removeId);
await execute();
这说明列表组件和单行 Upsert 组件在数据流上是一致的:
- 先把修改写入 runningTree;
- 再统一执行;
- 中间始终可以
clean()回滚未提交内容。
列表组件的过滤条件来自哪里
列表组件本身可以通过 filters 定义过滤条件,也可以像这个例子一样,更多依赖相对路径来表达对象关系。
在 system/panel/web.pc.tsx 中,这个列表组件是这样被挂载的:
<ApplicationList
oakPath={`${oakFullpath}.application$system`}
systemId={id}
/>
这个 oakPath 很关键,它表达的是:
- 当前列表组件展示的不是任意
Application; - 而是“属于当前
System的那些Application”。
也就是说,在 Oak 里,组件之间的相对路径通常就对应着对象之间的真实关系。
什么时候还要额外声明 filters
如果组件不依赖父结点路径,或者你希望它在多种场景下独立复用,那么就可以在组件里显式写 filters:
filters: [
{
filter() {
return {
systemId: this.props.systemId,
};
},
},
]
但如果父子组件的对象关系本来就很明确,那么优先使用符合对象关系的 oakPath,通常会让整个页面结构更自然。
列表组件不一定非得渲染成表格
system/application 这个例子其实已经说明了一点:Oak 的 list 组件本质上是“一个列表结点”,而不是“一个 table 组件”。
在这个组件里,列表最终渲染成的是:
Tabs;editable-card的增删入口;- 每个 tab 里再挂一个
ApplicationPanel。
也就是说,只要你的组件满足下面这些条件,它就是一个标准的 Oak 列表组件:
isList: true;formData拿到的是一组行数据;- 通过
addItem、removeItem、updateItem、execute等方法管理这组数据; - 每一行仍然能通过子路径继续往下挂组件。
所以在 Oak 里,列表完全可以长成:
- 表格;
- Tabs;
- 卡片网格;
- 时间线;
- 左侧列表 + 右侧详情。
真正的关键不是 UI 形态,而是你有没有把它挂成一个正确的 list 结点。
另一类最常见的列表页:PageHeader + FilterPanel + ListPro
除了 Tabs 型列表,在真实项目里更常见的其实是标准后台列表页。这个模式在:
oak-pay-business/src/components/order/listtaicang/src/pages/console/order/list/web.pc.tsx
里都很典型。
这类页面通常长成这样:
<PageHeader title={t('pageTitle')}>
<FilterPanel
entity="order"
oakPath={oakFullpath}
columns={filterColumns}
/>
<ListPro
entity="order"
oakPath={oakFullpath}
data={orders}
attributes={attributes}
extraActions={extraActions}
onAction={handleAction}
/>
</PageHeader>
这个结构里最重要的不是视觉层,而是运行时关系:
FilterPanel和ListPro共用同一条oakFullpath;- 筛选条件、刷新、分页、行动作,都落在同一个 list 结点上;
- 页面壳只负责标题和布局,不再自己重复管理另一套列表状态。
如果你在项目里写后台列表页,优先采用这个模式,通常会比“自己拼一堆状态”稳定得多。
formData 往往要先把行数据整理一遍
很多新手第一次写列表时,会直接把原始对象字段扔给渲染层。但真实项目里,更常见的写法是:在 formData 里先把行数据整理成更适合展示的结构。
例如 oak-pay-business/src/components/order/list/index.ts 中,就会先做一轮加工:
formData({ data }) {
return {
orders: data?.map((order) => {
const { creator, price, paid, ...rest } = order;
return {
...rest,
price: ThousandCont(ToYuan(price!), 2),
paid: ThousandCont(ToYuan(paid!), 2),
creatorName: creator?.name || creator?.nickname || '-',
creatorMobile: creator?.mobile$user?.[0]?.mobile || '-',
};
}),
};
}
这里做的事情包括:
- 金额字段格式化;
- 关联对象字段摊平成列表列更容易消费的结构;
- 缺省值兜底;
- 把复杂 projection 转换成更扁平的展示数据。
这是一种非常推荐的习惯。因为这样做之后:
- TSX 渲染层会明显更干净;
- 列定义里的
render逻辑会更短; - 你更容易在多个列表页之间复用同一种展示形态。
行动作通常分成两类
在真实项目里,列表页上的动作大致会分成两类:
1. 跳转类动作
最典型的是:
- 查看详情;
- 跳转到某个子页面;
- 打开另一个 panel。
像 taicang/src/pages/console/order/list/web.pc.tsx 就会通过 extraActions + onAction 来做:
<ListPro
...
extraActions={[
{
action: 'detail',
label: t('common::action.detail'),
show: true,
},
]}
onAction={async (row, action) => {
if (action === 'detail') {
navigateTo({
url: '/order/detail',
oakId: row.id,
});
}
}}
/>
这类动作的特点是:
- 不直接修改当前 list 结点的数据;
- 更像“从当前行跳到另一个上下文”。
2. 局部更新类动作
另一类动作是在当前列表页里直接改某一行。例如同一个页面里,还会用:
updateItem({
receivingMethod: value,
}, rmId);
await execute();
这种模式表示:
- 先用
updateItem(...)把某一行的修改写进当前 list 结点; - 再统一
execute()提交; - 中间如果用户取消,可以直接
clean()回滚。
这也是为什么 Oak 列表组件很适合配合:
- 行内编辑;
- 局部弹窗;
- 批量修改;
- 列表内创建和删除。
写列表页时的一个顺手检查清单
每次写完列表组件,建议顺手检查下面几项:
- 这是不是一个真正的 list 结点,也就是
isList: true? oakPath有没有表达清楚它和父组件的关系?- 如果页面用了
FilterPanel、Search、Pagination,它们是不是挂在同一条 list 路径上? - 行数据有没有必要先在
formData里整理成更易展示的结构? - 新增、删除、局部更新,是不是都走 runningTree 的方法,而不是在 TSX 里自己额外维护一套假状态?
这一节最重要的结论
- 列表组件的
formData中拿到的是行数组,而不是单条对象。 - 列表组件通常不只负责展示,还会负责新增、删除、分页、过滤、排序等交互入口。
addItem(...) + 子路径 Upsert + execute()是 Oak 列表内创建子项的典型模式。- 如果父子对象关系明确,优先用相对
oakPath表达关系,再考虑额外写filters。 - 列表页最常见的两种形态,是
Tabs型工作台和PageHeader + FilterPanel + ListPro型后台列表页。
组织组件
前面几节已经说明了单个组件怎么写,这一节继续往前走一步:在 Oak 中,页面通常不是靠一个超大组件完成,而是靠多个组件围绕同一棵数据树协同工作。
因此,“怎么组织组件”在 Oak 里不是纯前端工程问题,而是和对象关系、查询路径、提交范围直接相关。
1. 先理解组件树
Oak 会把页面上的组件映射到 runningTree 上的结点。你可以把它理解成:
- 每个 Page 对应一棵树;
- 页面内每个 Oak 组件都是树上的一个结点;
- 这些结点之间的关系,由
oakPath决定。
根页面通常有自己的页面级根路径;子组件则通过:
oakPath={`${oakFullpath}.${relative}`}
挂到父组件的某个子路径下。
这里有两个运行时名称需要分清:
| 名称 | 含义 |
|---|---|
oakPath | 组件被挂载时传入的路径 |
oakFullpath | 框架最终确定的完整路径 |
一般来说:
- 父组件负责把
oakPath传给子组件; - 子组件自己拿
oakFullpath继续往下挂孙组件。
2. 路径不是随便写的
oakPath 当然可以写任意字符串,但在 Oak 项目里,最推荐的不是“随便起一个唯一名字”,而是让路径尽量对应对象关系。
2.1 共享路径
如果父子组件其实处理的是同一行对象,最常见的做法是共享同一条路径:
oakPath={oakFullpath}
典型场景:
- 详情组件和编辑组件操作的是同一条
System; - 一个大表单被拆成多个编辑块;
- 一个 panel 下面有多个子块都在编辑同一条对象数据。
例如在 oak-general-business 中:
system/detailsystem/upsert
就会通过共享路径的方式协同工作。详情组件负责打开弹窗和执行提交,编辑组件负责把改动写到同一个结点上。
2.2 关联路径
如果子组件处理的是父对象关联出去的另一个对象,最推荐的做法是沿着真实对象关系写路径。
例如 System 下挂 Application 列表:
<ApplicationList
oakPath={`${oakFullpath}.application$system`}
systemId={id}
/>
这里的 application$system 就不是随便命名,而是 Oak 编译出来的真实关系路径。
这类写法的好处是:
- 路径本身就表达了对象关系;
- 查询和级联更新更容易推导;
- 页面拆分更自然;
- 后续别人读代码时,一眼就知道这个子组件的数据是从哪里来的。
2.3 列表行路径
如果父组件是 list,子组件处理的是列表中的某一行,通常会把该行 id 拼到当前路径下面:
oakPath={`${oakFullpath}.${item.id}`}
这类路径最常用于:
- 列表项详情块;
- 列表行内编辑弹窗;
- 列表行动作面板;
- 列表项对应的子组件。
3. 三种最常见的父子组件关系
如果父组件和子组件都是 Entity 组件,最常见的是下面三类关系。
3.1 单行父组件 + 单行子组件
这是“当前对象详情里再展示一个父对象或同对象编辑块”的场景。
常见例子:
Application详情里展示它所属的System;System详情里打开一个共享路径的SystemUpsert;- 一个对象的配置块、样式块、基本信息块都拆成单独组件。
这种场景要先判断子组件到底属于哪一种:
- 如果是同一条对象:共享路径;
- 如果是父对象/关联对象:走关系路径,例如
.system。
3.2 单行父组件 + 列表子组件
这是最常见的“一对多管理页”模式。
例如:
System下挂Application列表;Application下挂某种模板、菜单、标签列表;- 订单详情页下挂退款记录、物流记录、支付记录。
这时父组件一般负责:
- 当前对象主键;
- 子列表入口位置;
- 某些共用上下文。
子列表组件则负责:
- 列表 projection;
- 列表交互;
- 新建/删除/分页/筛选等具体行为。
3.3 列表父组件 + 单行子组件
这是列表行内再挂一个详情或编辑组件的模式。最常见的路径形式就是:
oakPath={`${oakFullpath}.${row.id}`}
这一类最需要注意性能问题:如果子组件是无条件批量渲染的,父列表的 projection 最好能覆盖子组件需要的数据。
否则就会出现:
- 父组件先查列表;
- 每个子组件再补一次自己的字段;
- 最终形成一屏几十个附加请求。
所以对这类页面,一个很重要的优化习惯是:先把“行内子组件需要哪些字段”想清楚,再回头补父列表的 projection。
3.4 Virtual 父组件 + Entity 子组件
前面三类都默认父子双方本身就是 Entity 组件。但在真实项目里,还有一类非常常见的组织方式:
- 父组件本身不绑定任何实体;
- 父组件只负责根据当前模式、feature、cache 推导上下文;
- 真正的 Entity 组件挂在它的某个子路径下。
taicang/src/pages/console/account/detail 就很典型:
- 页面自己不声明
entity; - 先通过
features.application、features.console、features.cache算出accountId; - 然后在渲染层里挂:
<AccountDetail
oakId={accountId}
oakPath={`${oakFullpath}.account`}
/>
这种组织方式特别适合:
- 页面只是业务控制器;
- 页面要复用
oak-general-business、oak-pay-business里的现成组件; - 页面要根据当前用户、当前应用、当前控制台模式,决定真正展示哪个实体。
可以把它理解成:Virtual 父组件负责“判上下文”,Entity 子组件负责“跑 Oak 数据树”。
4. panel 组件怎么组织
Oak 项目里很多复杂页面,最终都会演化成一个 panel 容器组件。
4.1 panel 的职责
一个好的 panel 通常负责:
- 取当前对象的核心数据;
- 提供 tab、步骤条、弹窗、左右布局等页面骨架;
- 决定子组件是共享路径、关系路径还是绝对路径;
- 决定哪些能力要在当前页统一
execute/clean。
4.2 真实例子:SystemPanel
oak-general-business/src/components/system/panel 的写法很典型:
- panel 自己先取
system的核心字段; SystemDetail共享当前路径;ApplicationList走application$system关联路径;DomainList走domain$system;- 部分 tab 使用独立绝对路径,因为它们不希望和当前结点绑定得太死。
4.3 真实例子:ApplicationPanel
oak-general-business/src/components/application/panel 也用了相同模式:
ApplicationDetail共享当前路径;- 配置、样式、COS 等能力挂在各自 tab 中;
- 微信公众号/小程序专属能力按
type条件拼接不同 tab; - 某些 tab 使用独立绝对路径,避免与当前主对象结点混在一起。
这说明 panel 的价值不只是“做个 Tabs”,而是把对象页面的结构和数据树结构一并组织起来。
5. 共享路径什么时候最好用
共享路径适合下面几类场景:
- 多个子组件编辑同一条对象;
- 一个详情组件打开同对象的编辑弹窗;
- 父组件本身只是 page wrapper,真正逻辑写在子组件里;
- 你要复用
oak-frontend-base的抽象detail/upsert组件。
但要注意一条真实规则:先创建这条路径结点的组件,决定了这个结点的基础取数方式。 后续共享同一路径的组件,应尽量和它对齐。
更直接地说:
- 如果第一个组件负责取数,后面的共享路径组件通常就不应再假设自己有完全独立的一套 projection;
- 如果后面的组件确实需要更多字段,最好回到“第一个组件”那里统一补 projection。
5.1 共享路径并不等于所有逻辑都写在一个组件里
很多新手会把“共享路径”误解成“那就做一个超大组件”。实际项目里更推荐的反而是:
- 父组件负责稳定 projection;
- 一个子组件负责展示;
- 一个子组件负责编辑;
- 必要时再有一个子组件负责动作按钮或局部配置。
也就是说,共享路径更多是在共享对象上下文,而不是要求共享组件职责。
6. 绝对路径什么时候用
有时子组件和父组件并没有直接对象关系,或者你明确不希望它参与父组件的级联刷新 / 级联提交,这时可以给它一条独立的绝对路径,例如:
oakPath={`$system-passport-${id}`}
或者:
oakPath={`#application-panel-cos-${id}`}
这种写法在公共业务组件里是实际存在的。
绝对路径适合:
- 与当前对象没有直接级联关系的工具块;
- 某个 tab 需要独立管理自己的结点状态;
- 不希望父组件的
execute()把它也一起提交; - 页面里存在多个相似子组件,但它们应彼此独立。
一旦使用绝对路径,就要清楚它意味着:
- 它不再是父结点的真正子树;
- 父组件的刷新、提交、清理,不会天然覆盖它;
- 你需要自己决定它的刷新与提交入口。
6.1 Tab 里的独立结点怎么选
很多复杂 panel 都是按 Tab 组织的,这时最容易犯的错,就是把所有 tab 都硬塞进同一条共享路径里。
更稳妥的判断方法是:
- tab 如果展示或编辑的是当前主对象本身,就共享路径;
- tab 如果展示的是主对象的真实子关系,就走关系路径;
- tab 如果只是挂一个相对独立的工具块、配置块、管理块,就给它独立绝对路径。
oak-general-business 里的两个 panel 都很典型:
system/panel中,SystemDetail共享oakFullpath;ApplicationList、DomainList分别走application$system、domain$system;Passport、OAuthManagement这类 tab 则直接使用$system-passport-${id}、$system-oauth-${id}这样的绝对路径。
application/panel 也是同样的组织方式:
ApplicationDetail共享当前路径;Cos这类配置块使用#application-panel-cos-${id};- 微信菜单、自动回复、标签、模板等 tab,则各自持有独立的
$application-panel-xxx-${id}路径。
所以,Tab 组织的关键不是“看起来都在一个页面里”,而是看它们是否应该共享同一个 runningTree 结点。
6.2 Tab 卸载与 runningTree 清理
Tabs 的卸载选项和 Oak 结点清理是两件事:Tabs 或条件渲染决定 React 子树是否卸载;当前 Oak React 运行时会在组件卸载时自动调用 runningTree 的结点销毁逻辑。oakAutoUnmount 已废弃,新代码不要再传。需要切换 Tab 时保留还是销毁 UI,应使用当前组件库提供的卸载选项,并让每个 Tab 使用稳定、清晰的 oakPath。
6.3 条件渲染通常比“晚一点再补路径”更稳妥
oak-frontend-base/src/page.react.tsx 里确实对 oakPath、oakId 后到做了兼容处理,但从源码行为和项目经验看,更推荐的组织方式仍然是条件渲染。
也就是说,像下面这样:
{!!accountId && (
<AccountDetail
oakId={accountId}
oakPath={`${oakFullpath}.account`}
/>
)}
通常比“先挂组件,等数据回来后再把 oakId / oakPath 补进去”更稳妥。原因是:
- 组件首次挂载时路径和主键就稳定;
- 不容易出现 create / update 语义错位;
- 子组件生命周期更清晰;
- 也更符合 Oak 数据树“结点先定,再刷新”的节奏。
7. 相同关系下渲染多个子组件怎么办
有时你会遇到这样的需求:
- 都是
application$system这条关系; - 但你想拆成两个不同列表,例如“Web 应用”和“小程序应用”。
这时它们虽然都来自同一条对象关系,但又不能简单共享结点,因为:
- 过滤条件不同;
- 渲染目的不同;
- 交互状态也不同。
这类场景可以在相对路径后面追加区分后缀,例如:
`${oakFullpath}.application$system:1`
和:
`${oakFullpath}.application$system:2`
这样它们仍然语义上挂在同一条关系下,但在组件树中是两个不同结点。
8. Virtual 组件适合放在哪里
Virtual 组件最适合拿来做:
- 页面壳;
- tab 容器;
- dashboard;
- 多实体混排容器;
- 只组织子组件,不直接关联 Entity 的 wrapper。
它们虽然没有 entity,但依然可以:
- 使用
oakPath; - 承载子组件;
- 调用
refresh()、execute()覆盖子树; - 使用公共方法和生命周期。
所以不要把 Virtual 组件理解成“没有 Oak 能力的普通 React 组件”。它更像是“不绑定具体对象的数据树容器”。
8.1 Virtual 组件很适合做跨业务包的拼装层
在 haina-busi、taicang 这类项目里,经常会看到这样的结构:
- 页面自己是 Virtual;
- 页面内部组合
oak-general-business、oak-pay-business或项目私有组件; - 页面先根据业务模式判断,再决定挂哪些 Entity 子组件。
这类页面的价值不是自己直接取一条数据,而是:
- 组织页面壳;
- 组织标题、筛选、Tab、弹窗;
- 统一处理跳转、环境判断、权限判断;
- 决定子组件各自应该挂在哪条路径上。
如果你发现一个页面“业务编排很多,但单个实体逻辑不集中”,通常就很适合做成 Virtual 控制页。
8.2 后台页面的推荐装配顺序
如果你在项目里要拼一个典型后台页,可以优先按下面这个顺序来组织:
PageHeader/PageHeader2作为页面最外层壳;FilterPanel、Search这类筛选组件挂到当前 list 路径上;ListPro或List负责主体列表;- 行内新增、编辑通过 Modal +
Upsert完成; - 详情页或复杂配置页再拆成
panel + detail + upsert + list的组合。
像 taicang/src/pages/console/order/list/web.pc.tsx 这种页面,基本就是这个思路:
- 页头壳负责标题和内容容器;
FilterPanel和ListPro共享同一条oakFullpath;- 行动作里再决定跳详情、开弹窗还是更新某一行。
这也是 Oak 后台页面里最稳定、最容易维护的一种组织方式。
8.3 stale 子组件在组织层面怎么理解
从组织角度看,stale 组件通常表示:
- 这个结点虽然存在;
- 但它不负责在挂载瞬间主动刷新;
- 真实刷新时机由外层页面、feature 回调或其它显式操作决定。
像 haina-busi/src/pages/business/machine/list/index.ts 这类页面里,就会把某些列表片段定义成 stale: true。这种设计适合:
- 大页面中嵌套多个列表片段;
- 希望把刷新责任集中在外层;
- 当前子组件更多承担展示和局部交互,而不是数据入口。
但如果你还不确定页面的数据刷新链路,优先不要急着上 stale。普通路径组织先写清楚,往往更安全。
8.4 公共业务包里的“注册槽位”怎么组织
除了直接把子组件写死在页面里,Oak 公共业务包里还有一种很值得借鉴的组织方式:预留注册槽位,让项目侧把自己的组件挂进来。
oak-pay-business 里有两个很典型的例子:
payConfig/system/web.pc.tsx暴露了registerPayChannelComponent(...);ship/system/web.pc.tsx暴露了registerShipSettingComponent(...)。
它们的思路基本一致:
- 公共业务包先定义一个注册表;
- 项目侧在初始化阶段注册自己的渠道组件或物流设置组件;
- 公共页面在渲染时遍历注册表;
- 再把这些组件挂到
${oakFullpath}.${entity}$system这样的关系路径下。
这种模式特别适合:
- 公共业务包知道“这里应该出现一类组件”,但不知道项目最终会接哪几个具体实现;
- 各项目会接入不同的支付渠道、物流实体、配置实体;
- 希望公共页面结构稳定,但把具体扩展点开放给项目层。
组织时要注意两点:
- 注册进来的组件最好仍然遵守当前页面的数据树规则,优先使用公共页面传下来的
oakPath、systemId等上下文; - 如果注册组件实际上对应某个真实关系,路径也应继续沿关系命名,而不是重新发明一套和页面脱节的绝对路径。
项目里通常会在初始化代码或业务入口处完成注册,思路大致像这样:
import { registerShipSettingComponent } from '@oak-pay-business/registry.frontend';
import WechatMpShipSetting from '@oak-pay-business/components/ship/wechatMpShip';
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
然后公共页面继续负责组织路径:
<Comp
systemId={oakId}
oakPath={`${oakFullpath}.${entity}$system`}
/>
这样项目侧只决定“接入哪个组件”,而公共页面仍然掌握“组件应该挂到哪条 Oak 路径上”。
8.5 registry 适合作为项目整合入口
在 oak-pay-business/src/registry.backend.ts、registry.frontend.ts 里,还能看到另一层更完整的组织方式:按运行端把可注册能力集中导出。
例如这里统一导出了:
registerPayChannelComponentregisterFrontendPayRoutineregisterShipSettingComponentregisterSysAccountCardTopComponentregisterSysAccountDetailComponent
这种做法的价值在于:
- 项目侧只需要记住一个整合入口;
- 公共业务包可以把“哪些位置允许扩展”集中暴露出来;
- 初始化代码更清楚,不用到处找具体组件内部的注册函数。
如果你自己的公共包也有很多可插拔组件、流程或页面片段,推荐按运行端把注册函数统一汇总到:
registry.backend.tsregistry.frontend.ts
再由项目侧在初始化阶段统一接入。
8.6 注册槽位不只用来挂页面,还能注入流程和局部渲染
继续看 oak-pay-business 会发现,注册式组织不只用于“在某个 tab 下挂一个组件”。
至少还有两类很典型的扩展点:
第一类,前端支付流程注入。
components/pay/detail/index.ts 暴露了 registerFrontendPayRoutine(...),项目侧可以按支付实体注册:
- 如何补充
pay页面额外需要的projection - 如何判断当前前端是否能发起支付
- 真正的前端拉起支付流程怎么执行
这类扩展点说明:有些公共页面的主体结构是稳定的,但核心业务流程会按项目实体而变化。这时就不该把逻辑写死在一个页面里,而应让项目侧按实体注册进去。
第二类,局部卡片/详情渲染注入。
components/sysAccount/survey/web.pc.tsx 暴露了:
registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
它不是把一个整页替换掉,而是把“卡片顶部如何画”“详情弹窗如何画”这类局部渲染开放给项目层。
所以可以把注册槽位再细分成三种:
- 页面级槽位:给 panel / tab / setting 页面挂完整子组件;
- 流程级槽位:给支付、确认、跳转等前端流程注入逻辑;
- 局部渲染槽位:给卡片、详情、头部、局部块注入 UI。
这样在设计公共业务组件时,就不必只想着“要么全写死,要么全开放”。更常见也更稳妥的做法,是只把真正需要项目差异化的那一层开放出来。
8.7 registry.backend.ts 和 registry.frontend.ts 怎么分工
oak-pay-business 里同时存在:
registry.backend.tsregistry.frontend.ts
它们按运行端严格分工:
registry.backend.ts只导出registerPayClazz,用于后端渠道实现注册;registry.frontend.ts导出配置组件、前端支付流程、物流设置和系统资金展示注册函数。
不要从前端入口导入后端渠道类,也不要让后端入口聚合 React 组件。需要增加新的注册能力时,先判断它属于哪个运行端,再放入对应入口。
从组织角度看,这样分层的价值是:
- 项目初始化代码更清楚;
- 前后端边界更清楚;
- 注册能力不会散落在各个组件内部,被项目层到处直接引用。
9. 目录层面的推荐拆分
前面这些讨论主要解决的是“运行时怎么组织组件树”。但在真实项目里,组件还涉及一个非常实际的问题:目录怎么拆,页面和组件怎么分层。
结合 oak-general-business、oak-pay-business、haina-busi、taicang 的写法,可以优先按下面这套方式拆:
- 路由入口页放在
src/pages/...,它负责页面级path、页面级zombie、环境判断、标题和路由参数处理。 - 可复用的实体业务块放在
src/components/...,例如detail、list、upsert、panel、modal、tab。 - 同一实体相关的组件尽量就近放在同一棵目录下,不要把
detail、list、upsert散落到完全不同的业务目录里。 - 如果某个页面主要是在组合
oak-general-business、oak-pay-business和项目私有组件,它通常更适合做成 Virtual 页面壳,而不是再塞一堆实体逻辑进去。 - 如果某个子块根本不需要
entity、oakPath、lifetimes、listeners,那它就继续做普通 React 展示组件,不要强行 Oak 化。
最常见的目录分层,大致可以理解成下面这样:
src/pages/console/account/detail/ # 路由入口 / 控制页
src/components/account/detail/ # 单行详情
src/components/account/list/ # 列表
src/components/account/upsert/ # 单行编辑
src/components/account/panel/ # 详情页容器
src/components/spBid/modal/ # 强交互弹窗片段
这种拆法的好处是:
- 路由层和实体层职责清楚;
- 一个实体相关的 detail / list / upsert / panel 很容易互相复用;
- 业务包组件和项目私有组件更容易组合;
- 页面在后期演化成 panel、tab、多片段结构时,不需要推翻重写目录。
再结合前面的路径组织规则,可以把职责简单记成:
pages负责“页面从哪里进来、当前业务上下文是什么”;components负责“这个 Oak 结点怎么查、怎么改、怎么展示”;- 纯展示子组件负责“某块 UI 怎么画”,但不直接承担数据树职责。
这里还可以再往下细分一层:
components/.../detail、list、upsert、panel这类目录,通常仍然是 Oak 组件目录;components/.../pure、components/common/.../*.tsx这类目录,更适合放纯展示组件或平台组件;AbstractComponents.ts、registry.frontend.ts或registry.backend.ts这类文件,则更像业务包的基础设施层,不直接承载某个实体页面,而是服务整个组件体系。
再补一个在真实项目里非常常见、但容易忽略的层次:复杂业务组件目录本身也可以继续包含子组件树。
像:
oak-general-business/src/components/wechatMenuoak-general-business/src/components/oauth/management
都属于“一个大业务组件目录,下面继续挂多个局部子目录”的结构。它说明组件组织不一定只有两层:
pagescomponents
很多时候还会出现第三层:
components/某业务根组件/局部子组件/...
这类目录适合:
- 同一业务块下有多个 tab、选择器、预览块、编辑块;
- 这些子块共享同一业务上下文,但各自又值得独立维护;
- 外层根组件更像一个局部 panel / controller。
10. 一个实用的组织原则
如果你不确定一个页面该怎么拆,可以直接按下面的顺序思考:
- 先找出页面主对象是谁;
- 再决定页面根 panel 是否应该先把主对象取出来;
- 判断每个子块是在处理同一条对象,还是在处理关联对象;
- 同一条对象就优先共享路径;
- 关联对象就优先沿对象关系写相对路径;
- 只有在确实不想级联时,才考虑绝对路径。
按这个顺序来拆,绝大多数 Oak 页面都会比较清晰,也更容易维护。
定义组件
Oak 前端组件的入口是 OakComponent(...)。它的真实类型定义在 oak-frontend-base/src/types/Page.ts 中,运行时主要由 oak-frontend-base/src/page.react.tsx、page.mp.ts、page.common.ts 和 features/runningTree.ts 驱动。
如果只从使用层面记忆,Oak 组件可以理解成四件事的组合:
- 定义这个组件在页面数据树中的结点;
- 定义这个结点如何取数、改数、分页和校验动作;
- 把取到的数据整理成渲染层更容易消费的形态;
- 把运行时方法和状态注入到组件中。
因此,写 Oak 组件时,最重要的不是先写 TSX,而是先把 OakComponent 的定义写清楚。
先看一个最小骨架
如果只看 oak-frontend-base/src/types/Page.ts,OakComponent(...) 最常见的骨架大致就是这样:
export default OakComponent({
entity: 'xxx',
isList: false,
projection: {
id: 1,
},
properties: {
someId: '',
},
data: {
open: false,
},
formData({ data, props, features, dirty, modified }) {
return {
row: data,
};
},
features: ['token'],
lifetimes: {
ready() {},
},
listeners: {
someId(prev, next) {},
},
methods: {
doSomething() {},
},
});
真正写业务时,不一定每个字段都要写,但你可以把它理解成六层:
- 结点定义:
entity、isList、path、stale、zombie; - 查询定义:
projection、filters、sorters、pagination、getTotal; - 入参定义:
properties; - 组件内部状态:
data; - 运行时整形:
formData; - 联动与行为:
features、lifetimes、listeners、methods。
先建立一个运行模型
一个典型的 Entity 组件,运行顺序大致是这样的:
- 页面或父组件传入
oakPath,单行组件通常还会传入oakId; - 框架根据
entity、projection、filters、sorters、pagination等配置,在 runningTree 中创建结点; - 结点刷新后拿到对象数据;
- 框架调用
formData(...); formData返回的数据和组件自己的data、外部props一起进入渲染层;- 在渲染层中,通过
props.data和props.methods使用这些数据与方法。
所以 Oak 组件真正的“输入”通常不是一个,而是这三类:
- 页面或父组件传入的参数,如
oakPath、oakId、systemId; OakComponent配置项中声明的查询/行为定义;- 框架在运行时注入的状态和方法。
组件文件通常怎么落地
在 Oak 项目里,OakComponent(...) 的定义通常只放在组件目录下的 index.ts。而真正的渲染文件、样式文件、locale 文件,会和它并排放在同一个目录里。
在 taicang、haina-busi、oak-general-business 里,最常见的目录形态大致是这样:
detail/
├── index.ts
├── index.config.ts
├── web.pc.tsx
├── web.tsx
├── index.xml
├── web.pc.module.less
├── web.module.less
└── locales/
└── zh-CN.json
可以把这几个文件的职责记成下面这样:
index.ts:唯一的 Oak 逻辑入口,负责OakComponent(...)定义、projection、formData、listeners、methods。index.config.ts:页面/组件的结构化配置。页面通常放route、menu、mp,组件通常放mp,用于替代新代码中的小程序index.json。web.pc.tsx/web.tsx:各端渲染层,只消费props.data和props.methods,尽量不要把数据树逻辑再塞回渲染文件。index.xml/index.less:小程序端模板和样式;小程序组件声明、usingComponents等配置优先写在index.config.ts的mp字段中。*.module.less/*.less:平台样式文件。locales/*.json:当前组件自己的文案。
真实项目里常见三种落地方式:
- 只有 PC 端的业务组件:例如
oak-general-business/src/components/system/panel、haina-busi/src/components/business/daemon/config,通常只有index.ts + web.pc.tsx + less + locales。 - 同时覆盖 web / 小程序的页面或复杂组件:例如
taicang/src/pages/console/account/detail、taicang/src/components/spBid/modal、taicang/src/pages/frontend/spAuctionCollection/detail,通常会同时存在web.pc.tsx、web.tsx、index.xml、index.config.ts。旧项目里也可能还保留index.json。 - 纯 Oak 逻辑 + 薄渲染层:最推荐的方式是把查询、监听、订阅、提交、权限判断都放在
index.ts,让各端渲染文件只负责布局和交互。
也就是说,定义 Oak 组件时最重要的分层不是“先写页面再补逻辑”,而是:
- 先在
index.ts把数据树结点和行为定义清楚; - 再让
web.pc.tsx/web.tsx/index.xml去消费这些定义好的数据和方法; - 不要把
projection、订阅、refresh触发条件分散到多个渲染文件里。
大型组件目录本身也可以是一棵局部组件树
Oak 项目里还有一种非常常见的情况:一个“业务组件”本身并不是一个单目录单文件,而是一个根组件目录,下面继续挂很多局部子组件目录。
例如:
oak-general-business/src/components/wechatMenuoak-general-business/src/components/oauth/management
这两类目录都不是“只有一个 index.ts + web.pc.tsx 就结束”,而是会继续拆出:
menuconditionalMenutagListoauthProvideroauthAppsupsert
这类拆法适合:
- 一个业务块本身就有多个 tab / 面板 / 弹窗 / 选择器;
- 子块之间属于同一个业务域,拆太散反而不好维护;
- 你希望外层组件做总编排,内层子目录做局部 Oak 结点或局部展示逻辑。
从工程角度看,可以把它理解成:页面有一棵组件树,复杂业务组件目录内部也可以再有一棵局部组件树。
但即便这样拆,原则还是一样:
- 外层根组件负责整体上下文;
- 子目录组件负责局部路径、局部状态和局部展示;
- 不要因为目录层级变深,就把路径设计和职责分层搞乱。
同一个 index.ts 通常会复用到多个渲染端
在 Oak 项目里,一个组件目录下同时出现:
web.pc.tsxweb.tsxindex.xmlindex.config.ts
是很常见的。这通常不表示“这里有四套不同逻辑”,而是表示:同一套 Oak 逻辑,分别接到多个端的渲染壳上,并通过 index.config.ts 补充平台配置。
例如:
taicang/src/components/spBid/modaltaicang/src/pages/frontend/spAuctionCollection/detailtaicang/src/pages/console/account/detail
它们的共同特点是:
index.ts仍然只有一份,负责 Oak 逻辑;- web PC、web mobile、小程序模板各自只处理平台渲染;
- 多端之间共享同一份
projection、formData、listeners、methods。
这也是 Oak 组件很重要的一条工程纪律:
- 数据树逻辑尽量只写一份;
- 平台差异尽量收敛在渲染文件里;
- 不要把“某端专属的布局差异”升级成“某端专属的一套 Oak 逻辑”,除非业务真的不同。
复用组件逻辑并覆写本地 render
跨项目或跨平台开发中,还有一种更薄的组件目录:逻辑完全沿用现有 Oak 组件,本地只提供自己的 render。例如应用要复用公共业务包的查询、formData 和 methods,但 Web 页面布局要由当前应用定制。
本地 index.ts 应使用 CLI 能明确识别的直接转发形式:
import OakComponent from '@oak-general-business/components/example';
export default OakComponent;
然后在同目录编写本地 web.tsx、web.pc.tsx、render.desktop.tsx 或 Native render,props 参数仍然保持未标注:
export default function Render(props) {
const { title, disabled } = props.data;
return (
<button
disabled={disabled}
onClick={() => props.methods.submit()}
>
{title}
</button>
);
}
这里发生了两件事:
- 运行时复用原组件的 Oak 逻辑合同;
- CLI 和 Oak Assistant 为本地 render 继承原组件对应平台的 props 合同。
如果目标还是工作区源码,编译器会沿转发链找到真正的 OakComponent({...})。如果目标是已安装的发布包,编译器不会用组件 index.d.ts 猜内部 render 数据,因为该文件通常只描述父组件可传入的外部 props;它会读取原组件的平台 render 声明。
平台声明的选择顺序如下:
| 本地 render | 依赖声明查找顺序 |
|---|---|
web.tsx | web.d.ts |
web.pc.tsx | web.pc.d.ts -> web.d.ts |
web.mobile.tsx | web.mobile.d.ts -> web.d.ts |
render.ios.tsx | render.ios.d.ts -> render.native.d.ts |
render.android.tsx | render.android.d.ts -> render.native.d.ts |
render.desktop.tsx | render.desktop.d.ts -> web.pc.d.ts -> web.d.ts |
render.windows/macos/linux.tsx | 对应系统声明 -> render.desktop.d.ts -> web.pc.d.ts -> web.d.ts |
因此,可复用 Oak 业务包应在声明构建中启用 --emit-injection-types。这样生成的 render .d.ts 会保留编译器推导出的精确 props;当另一个项目再覆写 render 时,仍能继续继承,而不需要访问依赖包源码。
这项能力有意只识别下面的稳定语法:
import OakComponent from 'component-module';
export default OakComponent;
默认导入改名、先赋给另一个变量、动态包装或其他间接导出都不会被当成复用合同。识别失败时,编译器不会编造一个宽泛类型来掩盖问题。
最后要区分“透明覆写 render”和“创建新组件合同”:
- 只改变布局和平台交互时,可以直接复用;
- 需要新增
properties、改变查询、增加状态或方法时,应在本地写真正的OakComponent({...}); - 本地存在直接
OakComponent({...})定义时,本地合同优先于复用声明; - 不要手写宽泛
WebComponentProps来假装新增字段已经成为运行时 properties。
新建组件时的推荐起手顺序
如果你现在要从零开始写一个 Oak 组件,最稳妥的顺序通常不是先写页面样式,而是先把下面四个问题答清楚:
- 这个组件是 Virtual,还是 Entity?
- 如果是 Entity,它是单行还是列表?
- 它最终会挂在哪条
oakPath上? - 它的业务参数里,哪些是运行时参数,哪些是业务参数,哪些是 UI 参数?
在真实项目里,一个更实用的起手模板通常是:
- 先写
entity、isList、projection、filters、properties; - 再写
formData,先把渲染层真正需要的数据整理出来; - 然后补
methods和listeners; - 最后再写
web.pc.tsx/web.tsx/index.xml。
这样做的好处是:
- 你会先把结点和数据关系想清楚;
- 渲染层拿到的是已经整理好的字段,而不是一堆原始查询结果;
- 后面拆分组件时,更容易判断哪些逻辑该留在 Oak 层,哪些只属于展示层。
不是所有 Oak 生态组件都由 OakComponent(...) 定义
这一点对新手非常重要:在 Oak 项目里,大家常说“组件”,但它不一定都指 OakComponent(...) 生成的组件。
除了自己写的 Oak 组件之外,项目里还大量使用这几类抽象组件:
FilterPanelListListProDetailUpsert
它们通常来自:
@oak-frontend-base/components/...- 或公共业务包里的
AbstractComponents.ts
例如:
oak-pay-business/src/components/withdrawTransfer/list/web.pc.tsxoak-pay-business/src/components/pay/list/web.pc.tsxoak-general-business/src/components/user/manage/web.pc.tsx
都会直接在渲染层中使用 FilterPanel、ListPro。
你可以这样理解它们的角色:
OakComponent(...)定义“数据树结点、查询、状态、行为”;FilterPanel/ListPro/Detail/Upsert定义“通用的展示与交互骨架”;- 业务页面则把两者拼起来。
所以在真实工程里,一个完整页面经常不是“全都写成 OakComponent”,而是:
- 先用
index.ts定义当前 Oak 结点; - 再在
web.pc.tsx/web.tsx里组合FilterPanel、ListPro、Detail、Upsert; - 最后把局部纯展示块继续拆成普通 React 组件。
这也是为什么文档里要把“定义组件”和“组织组件”分开讲。前者是在讲 Oak 结点怎么定义,后者是在讲这些 Oak 结点和抽象展示组件怎么拼成真正页面。
1. 先分清组件类型
Oak 中常见的前端组件可以先分成两大类:
1.1 Virtual 组件
如果没有声明 entity,这个组件就是 Virtual 组件。它仍然可以:
- 使用生命周期;
- 监听
features; - 使用
t、navigateTo、setMessage等公共方法; - 挂在某个
oakPath上作为纯容器组件。
但它不会自动取某个 Entity 的数据。
1.2 Entity 组件
声明了 entity 的组件,就是 Entity 组件。它又可以继续分成三种最常见形态:
| 类型 | 典型配置 | 适用场景 |
|---|---|---|
| 单行详情组件 | entity + isList: false | 展示一条对象数据 |
| 单行更新组件 | entity + isList: false | 编辑或创建一条对象数据 |
| 列表组件 | entity + isList: true | 展示并操作多条对象数据 |
严格来说,“详情”和“更新”在框架层都属于 isList: false 的单行组件,差别主要在你使用哪些方法:
- 只读展示时,通常只是取数和渲染;
- 更新组件会调用
update/create/remove; - 提交动作通常通过
execute统一完成。
2. OakComponent 常用配置项
最常用的配置项可以先记成下面这张表:
| 配置项 | 用途 | 备注 |
|---|---|---|
entity | 组件关联哪个 Entity | 不写就是 Virtual 组件 |
isList | 是否列表组件 | true 为列表,false 为单行 |
path | 页面级根路径 | 只应在顶层 Page 上声明 |
projection | 取哪些字段 | 语法和查询章节里的 Projection 完全一致 |
filters | 列表过滤条件 | 仅 list 组件有效 |
sorters | 列表排序条件 | 仅 list 组件有效 |
pagination | 分页设置 | 仅 list 组件有效 |
getTotal | 是否额外取总数 | 可按设备宽度差异配置 |
properties | 组件接受的外部参数 | 相当于 props 声明 |
data | 组件自身状态初值 | 相当于 state 初值 |
formData | 将 Oak 数据整理成渲染数据 | 最常用的配置项之一 |
actions | 需要判定合法性的动作 | 行权限结果会进入数据中 |
cascadeActions | 需要判定的级联子对象动作 | 结果会进入 #oakLegalCascadeActions |
cacheInsensativeActions | 某些动作的 checker 校验走 cache-insensitive 模式 | 少见但存在 |
append | 追加模式查询控制 | 仅在特定场景使用 |
features | 要监听哪些 feature | 可指定 reRender / refresh / callback |
stale | 标记为不主动刷新结点 | 常用于完全依赖父结点或外部控制的组件 |
zombie | 页面析构后是否保留结点状态 | 仅顶层 Page 配置项可直接声明 |
ns | i18n 命名空间补充 | 可写单个或多个 |
lifetimes | 生命周期方法 | 如 created、ready、mature |
listeners | 监听 props / state 变化 | 适合联动逻辑 |
methods | 组件自定义方法 | 会注入到 this 和 props.methods |
wechatMp | 小程序额外配置 | 如 externalClasses、组件 options |
下面只展开那些最容易写错的配置项。
2.1 entity、isList、path
entity 决定组件关联哪个对象。支持两种写法:
entity: 'system'
或者:
entity() {
return this.props.entityName as 'system';
}
isList 决定组件是列表结点还是单行结点:
isList: true
或:
isList: false
path 比较特殊。源码里明确限制了:只有页面级根组件才应该直接声明 path。 子组件不要在配置项中写 path,而应通过外部传入 oakPath。
可以这样理解:
- 顶层 page:用
path定义页面根结点; - 普通子组件:由父组件传入
oakPath; - 单行子组件:通常同时传
oakPath和oakId。
另外,运行时还有一个细节值得知道:在 page.common.ts 的 onPathSet(...) 里,如果这是页面根组件并且同时传了 oakId,框架会把它拼到根路径上,最终形成类似:
${path}-${oakId}
的页面根结点。
2.1.1 动态 entity 在项目里是存在的
entity 不一定是一个写死的字符串。框架类型定义允许你写成函数,而业务项目里也确实这样用。
例如 haina-busi/src/components/business/daemon/config/index.ts:
entity() {
return this.props.entity as 'system' | 'room';
}
这种写法适合:
- 同一套组件逻辑服务多个实体;
- 两个实体字段结构足够接近;
- 你想复用同一套渲染和编辑逻辑。
不过要注意,entity 虽然可以动态返回,但一个已经创建好的 runningTree 结点不会在生命周期中随意切换实体。如果你真的需要在不同实体之间切换,更稳妥的做法通常是让父组件条件渲染不同子组件,而不是让同一个已挂载结点反复变实体。
2.2 projection
projection 就是查询章节里的 Projection,本质上不是“组件专用语法”,而是 Oak 查询语法本身。
它支持:
- 查询当前对象字段;
- 级联查询父对象字段;
- 级联查询子对象数组;
$expr到$expr20表达式列;- 关系聚合字段。
例如:
projection: {
id: 1,
name: 1,
system: {
id: 1,
name: 1,
},
domain$system: {
$entity: 'domain',
data: {
id: 1,
url: 1,
},
},
}
这里还有一个非常重要的真实规则:同一条 runningTree 路径上的组件,应共享同一份投影结构。 如果多个组件复用同一个 oakPath,你要保证它们对数据的理解是一致的。
2.2.1 projection 也经常按用户态或 props 动态生成
框架允许把 projection 写成函数,业务项目里这也非常常见。
例如 taicang/src/pages/frontend/spAuctionCollection/detail/index.ts 就会根据当前是否登录,决定是否把:
userRelation$entityspAgent$auctionCollection- 与当前用户投标板相关的数据
一起查出来。
这种写法适合:
- 登录前后看到的字段结构不同;
- 某些字段只在特定模式下需要;
- 某些关联查询代价较高,希望按条件裁剪。
但要记住和上一条规则配套的结论:动态 projection 也要对当前路径上的其它共享组件负责。 如果某条路径上挂了多个子组件,不能一个组件想查一套,另一个组件又假设另一套。
2.3 filters、sorters
它们只对 list 组件有效,语法分别对应查询章节里的 Filter 和 Sorter。
真实结构不是单个对象,而是数组:
filters: [
{
filter() {
return {
systemId: this.props.systemId,
};
},
'#name': 'bySystem',
},
]
sorters: [
{
sorter: {
$attr: {
name: 1,
},
$direction: 'asc',
},
'#name': 'nameAsc',
},
]
这里的几个细节很值得记住:
filter、sorter都可以直接写对象,也可以写函数;'#name'可用于后续按名称替换、删除;filter还支持hot: true,表示前台取数时也持续参与判断。
后续你可以通过组件方法动态调整这些条件,比如:
addNamedFiltersetNamedFiltersremoveNamedFilterByNameaddNamedSorterremoveNamedSorterByName
2.4 pagination、getTotal
分页配置的真实结构是:
pagination: {
currentPage: 0,
pageSize: 20,
}
也可以按设备宽度分开配置:
pagination: [
{ deviceWidth: 'pc', currentPage: 0, pageSize: 20 },
{ deviceWidth: 'mobile', currentPage: 0, pageSize: 10 },
]
getTotal 不是布尔值,而是“最多精确统计多少条”:
getTotal: 500
或者:
getTotal: {
max: 500,
deviceWidth: 'pc',
}
源码里的真实行为还有两点:
- 如果你不显式配置
getTotal,宽屏默认会取100,窄屏默认不取; - runningTree 不会每次刷新都重复查总数,只有必要时才会重算。
2.5 properties、data、formData
properties 用于声明外部传入参数,相当于组件 props 的声明:
properties: {
systemId: '',
}
data 用于声明组件自身状态初值,相当于 state 初值:
data: {
keyword: '',
open: false,
}
它也可以写成函数:
data() {
return {
keyword: '',
};
}
例如 taicang/src/pages/console/news/list/index.ts 就保留了这种写法。源码中,data() 会在组件构造阶段以 this 为上下文执行一次。
这里还有一个非常值得建立的习惯:把组件参数按“运行时参数 / 业务参数 / 交互参数”分开理解。
- 运行时参数:
oakPath、oakId、oakZombie、oakStale、width。这些是 Oak 运行时已经内置的 props,不需要再在properties里重复声明。 - 业务参数:
systemId、applicationId、entity、entityId、tabKey、agentOnly这类业务输入,应明确写在properties里。 - 交互参数:
visible、disabled、onClose、showRecharge这类 UI 或回调型参数,也应该写在properties里,并给出稳定默认值。
例如 taicang/src/components/spBid/modal/index.ts 就很典型:
oakId不是它自己声明的业务参数,而是运行时给它的当前拍品主键;agentOnly、visible、disabled、isLive、onClose、showRecharge才是它自己真正关心的业务/UI 参数。
再比如 oak-general-business/src/components/system/panel/web.pc.tsx 中的 SystemDetail:
oakId、oakPath决定它挂在哪个 Oak 结点上;- 但像
ConfigUpsert、StyleUpsert这类普通业务组件,则更多接收entity、entityId、name、config这种业务参数。
这两个层次不要混在一起。否则新手最容易写出这样的代码:
- 一边把组件当 Oak Entity 组件使用;
- 一边又把
oakPath、oakId当成自己手写的普通业务 props; - 最后把页面级路径、业务参数和 UI 状态耦合在一起。
还有一个很少被文档提到、但在源码中真实存在的行为:如果 data 里的某个值本身是函数,框架会在构造阶段把它绑定到当前组件实例上。
像 taicang/src/pages/frontend/my/password/verify/index.ts 里就有这种历史写法:
data: {
path: '$$password-verify',
onVerified() {
this.navigateBack();
}
}
这里的 data.path 只是组件自己的普通状态字段,和 OakComponent 顶层配置里的 path 不是一回事。这类写法是能工作的,但从可维护性来说,更推荐把真正的行为放进 methods,把 data 留给状态值本身。
formData 是 Oak 组件里最重要的桥接层。它负责把 Oak 数据树里的原始行数据,整理成渲染层更容易消费的结构。
真实入参结构来自 Page.ts,最常用的是这些字段:
| 字段 | 含义 |
|---|---|
data | 当前行或当前行数组 |
origin | 修改前的数据 |
props | 当前组件 props |
features | 所有 features |
legalActions | 当前结点允许的动作 |
originLegalActions | 原始数据上的动作 |
dirty | 当前结点是否有未提交变更 |
modified | 是否真的和原值不同 |
例如:
formData({ data }) {
return {
applications: data || [],
oakExecutable: this.tryExecute(),
};
}
这里顺便说明一个经常被误解的点:oakExecutable 不是所有组件都会自动注入的内置状态。 在很多公共组件里,它都是开发者自己在 formData 中通过 this.tryExecute() 算出来的,例如:
oak-general-business/src/components/system/detail/index.tsoak-general-business/src/components/system/application/index.ts
所以如果你的渲染层需要“当前是否可以提交”这个值,最稳妥的做法就是显式在 formData 里返回它。
再补一个运行时细节:在 React / React Native 渲染层里,框架最终传给渲染函数的 props.data,实际是这样合并出来的:
{
...defaultProperties,
...state,
...props,
}
也就是说:
properties中声明的默认值会进入渲染层;- 组件自己的
state/formData结果会进入渲染层; - 父组件真实传入的 props 也会覆盖进去。
因此在 React 渲染函数中,你经常会在 props.data 里同时拿到:
- 组件计算出来的数据;
- 默认属性值;
- 外部传入参数。
2.6 actions 与 cascadeActions
actions 表示:当前组件希望框架帮你检查哪些动作是否合法。
例如:
actions: ['update', 'remove']
也可以写成带额外约束的动作定义对象。
经过检查后:
- 单行组件上的合法动作会进入
legalActions/oakLegalActions; - 列表组件里,每一行上会带
#oakLegalActions。
cascadeActions 则是对子对象动作的检查。源码中会把它们写回每一行的 #oakLegalCascadeActions,这类数据通常会被 actionBtn、列表操作列等通用组件消费。
这项能力适合这样的场景:
- 当前页面展示的是父对象;
- 但你还想在父对象行上展示“创建某种子对象”“修改某个子对象”等动作按钮;
- 并且希望这些动作同样经过 Oak 权限与 checker 判定。
2.7 features
features 用于声明组件需要监听哪些 feature 的变化。
最简单的写法:
features: ['token']
更完整的写法:
features: [
{
feature: 'token',
behavior: 'refresh',
},
{
feature: 'notification',
behavior: 'reRender',
},
]
行为有三种常见模式:
reRender:重新调用formData并重渲染;refresh:重新发起一次当前结点的数据刷新;callback:自己写处理函数。
另外还有两个默认行为要知道:
- 所有组件都会监听
locales; - 非 Virtual 的 Entity 组件默认会监听
cache。
2.7.1 业务项目里最常见的三种 feature 写法
第一种,最简单:
features: ['token']
这表示该 feature 更新时,组件会默认 reRender()。
第二种,明确声明刷新行为:
features: [
{
feature: 'application',
behavior: 'refresh',
}
]
像 taicang/src/pages/console/news/list/index.ts 就是这种写法。它的含义是:当 application feature 变化时,不只是重跑 formData,而是重新刷新当前 Oak 结点的数据。
第三种,自定义 callback:
features: [
{
feature: 'console',
callback() {
this.refreshAccountInfo();
}
}
]
taicang/src/pages/console/account/detail/index.ts 就采用了这种模式。适合:
- feature 变化后不一定立刻刷新当前结点;
- 需要先查本地 cache,再决定要不要补一次 refresh;
- 想把副作用收拢到自定义方法中。
2.8 stale 与 zombie
stale 用于告诉框架:这个结点在挂载时不需要像普通结点一样主动刷新。源码中,stale 结点会被当成“特殊结点”处理,通常适合:
- 数据完全依赖父结点;
- 数据刷新由外部显式控制;
- 临时性挂载,但不希望一挂载就发请求。
zombie 用于控制页面析构后是否保留 runningTree 上的状态,例如:
- 已增加的 filters / sorters;
- 尚未提交的修改;
- 当前分页位置。
但要注意真实限制:在配置项里直接写 zombie,只能用于顶层 Page。 子组件如果要保留状态,应通过 props 传 oakZombie,而不是在自己的 OakComponent 配置中直接声明。
项目里的真实使用也很能说明它们的差别:
taicang/src/pages/frontend/home/index.ts这种首页型 Virtual 页面,会直接写zombie: true,希望页面离开后再回来时还能保留状态;haina-busi/src/components/business/system/order/list/index.ts、haina-busi/src/pages/business/machine/list/index.ts这类列表片段,会写stale: true,把首轮主动刷新责任交给外层页面或特定 feature 联动。
因此可以这样记:
zombie更像“页面退出后,结点别急着销毁”;stale更像“组件挂上来时,先别自动刷新”。
stale 很有用,但也不要滥用。只有当你明确知道:
- 当前数据已经由父层准备好;
- 或者稍后会通过
features/ 自定义逻辑主动 refresh;
时,才适合这么做。
2.9 lifetimes 与 listeners
Oak 组件支持的核心生命周期包括:
createdattachedmaturereadydetached
以及一些平台相关生命周期:
movederrorshowhideresize
其中新手最需要分清的是这几个:
| 生命周期 | 真实含义 |
|---|---|
created | 组件实例构造阶段,不能依赖数据树已准备好 |
attached | 已挂载,但 oakFullpath 和初始数据不一定完成 |
mature | 当前 Entity 结点初始 refresh 完成 |
ready | 组件真正可安全使用运行树方法的阶段 |
detached | 组件准备销毁 |
listeners 用于监听 props / state 的变化,例如:
listeners: {
'keyword, status'(prev, next) {
if (prev.keyword !== next.keyword) {
this.refresh();
}
},
}
它特别适合:
- 某个 props 改了就刷新列表;
- 某个 state 改了就联动更新别的状态;
- 对输入条件做防抖/联动处理。
这里再补几个源码层面的真实规则:
listeners是在componentDidUpdate阶段跑的;- 每个监听键都可以写成逗号分隔的多个字段,例如
'showPopup,bidPrice'; - web 端当前不支持带
*的通配监听,源码里会直接报错; - 监听时比较的是
prevProps/prevState与当前值,不做深比较。
业务项目里很常见的写法有两类:
第一类,props 变化就刷新:
listeners: {
entity(prev, next) {
if (prev.entity !== next.entity) {
this.refresh();
}
},
entityId(prev, next) {
if (prev.entityId !== next.entityId) {
this.refresh();
}
},
}
例如 haina-busi/src/components/trade/detailList/index.ts。
第二类,state 变化触发额外副作用或订阅管理:
listeners: {
async accountId(prev, next) {
if (prev.accountId === next.accountId) {
return;
}
...
},
}
例如 taicang/src/components/spBid/modal/index.ts 会根据 plate、accountId 的变化去增删数据订阅。
3. 页面如何把组件挂到数据树上
组件能不能正常工作,关键不只是 entity,还取决于它是不是被正确挂到了数据树路径上。
最常见的三个运行时入口参数是:
| 参数 | 用途 |
|---|---|
oakPath | 当前组件所在的数据树路径 |
oakId | 单行结点关联的主键 |
oakZombie / oakStale | 以 props 形式覆盖结点行为 |
一个典型的详情组件嵌套写法如下:
<SystemDetail
oakId={id}
oakPath={oakFullpath}
/>
一个典型的子列表组件嵌套写法如下:
<ApplicationList
oakPath={`${oakFullpath}.application$system`}
systemId={id}
/>
这里要注意两件事:
oakPath不只是“一个字符串”,它表达的是组件之间的数据关系;- 子组件最好尽量沿着真实对象关系去组织路径,这样查询、级联更新和重用都会自然很多。
还有一条实践里很重要的规则:oakPath 和 oakId 最好在组件首次渲染时就稳定下来。
虽然 page.react.tsx 确实对“oakPath 晚一点才传进来”做了兼容处理,但源码里也明确会对“先创建结点、后补路径”的情况发出警告。实际业务里,更稳妥的方式通常是:
- 等主键准备好再渲染子组件;
- 等父组件拿到
oakFullpath再继续往下挂子结点; - 不要让同一个组件在
undefined -> 有值之间反复切换路径和主键。
4. 组件中的数据从哪里取
Oak 组件里常见的数据来源有三层:
props:外部传入;state:组件自己的状态;formData返回的数据:Oak 框架整理后的运行时数据。
4.1 在逻辑层里怎么取
在 formData、lifetimes、methods 中,通常通过 this.props 和 this.state 访问:
const { oakId } = this.props;
const { oakFullpath, oakDirty } = this.state;
4.2 在 React 渲染层里怎么取
在 web / native 渲染函数里,统一通过 props.data 和 props.methods 取:
export default function Render(props) {
const { name, description, oakFullpath, oakExecuting } = props.data;
const { t, execute, clean } = props.methods;
}
传统 TSX render 的 props 类型由 Oak 编译器结合 index.ts 自动注入,不需要再手写 WebComponentProps。properties 是组件对外业务输入的真实声明,formData 决定额外渲染数据,methods 决定自定义方法;三者不能靠 TSX 中的类型标注替代。启用 --emit-injection-types 时,这份推导合同还会写入 web.d.ts、web.pc.d.ts 或 render.native.d.ts,供下游包消费。
4.3 常见内置 props
这些值通常由页面或父组件传入:
| 名称 | 含义 |
|---|---|
oakPath | 当前组件路径 |
oakId | 单行组件主键 |
oakZombie | 子组件是否保留结点状态 |
oakStale | 子组件是否作为 stale 结点 |
oakFilters | 以 props 形式追加过滤条件 |
oakActions | 以 props 形式覆盖动作定义 |
oakCascadeActions | 以 props 形式覆盖级联动作定义 |
width | 当前宽度标识,如 xs、sm、md 等 |
其中 oakActions、oakCascadeActions 在真实类型里是字符串通道,通常由框架或通用组件透传,不建议业务页面手动乱拼。
oakAutoUnmount 是已废弃的历史参数,不再列入新组件可用参数。当前 React 运行时会在组件卸载时自动销毁对应 runningTree 结点;需要控制 UI 是否卸载时,使用条件渲染或 Tabs 自身的卸载策略。
4.4 Render、XML 与样式检查
当前严格构建通常同时启用 render 类型注入、XML 类型检查和 Less Module 检查:
- render 中的
props.data/props.methods必须来自框架合同、properties、formData或methods; - 小程序
index.xml中的数据、方法、组件属性和事件绑定会按同一组件合同检查; Styles.xxx必须在导入的 Less Module 中真实存在,嵌套选择器还要满足 JSX 祖先作用域;- 下拉菜单、弹层等 portal 内容如果脱离原父级 DOM,所用样式应放到实际可达的模块级作用域。
这些错误应通过补齐真实 properties、修正数据合同或调整真实 CSS 作用域解决,不要用 any、never、空样式规则或扩大手写 props 类型绕过。
4.5 常见内置 state / data
框架会把一批运行时状态放进组件数据中:
| 名称 | 含义 |
|---|---|
oakFullpath | 当前组件在 runningTree 中的完整路径 |
oakEntity | 当前组件关联的 entity |
oakLoading | 是否正在加载数据 |
oakLoadingMore | 列表是否正在加载更多 |
oakExecuting | 是否正在提交更新 |
oakDirty | 是否存在未提交修改 |
oakModified | 是否和原始值真实不同 |
oakLegalActions | 当前结点合法动作 |
oakPagination | 列表分页信息 |
oakLocales | 当前语言数据集 |
oakLng / oakDefaultLng | 当前语言与默认语言 |
对于单行组件,在 React 渲染层里还会拿到 oakId。
再次强调:oakExecutable 并不是所有组件自动拥有的内置字段,如果页面要用,建议像公共组件一样,在 formData 中显式返回:
formData({ data }) {
return {
...data,
oakExecutable: this.tryExecute(),
};
}
5. 组件上的方法
在逻辑层里,可以通过 this 使用组件方法;在 React 渲染层里,通过 props.methods 使用。
最常用的方法可以按三组来记。
5.1 通用方法
| 方法 | 作用 |
|---|---|
reRender() | 重新执行 formData |
refresh() | 刷新当前结点数据 |
execute() | 提交当前结点及子孙结点上的修改 |
clean() | 清理未提交修改 |
tryExecute() | 试算当前是否可提交 |
checkOperation() | 检查某个操作是否合法 |
t() | 国际化翻译 |
navigateTo() / redirectTo() / navigateBack() | 页面跳转 |
setMessage() / setNotification() | 发消息或通知 |
select() / aggregate() | 直接在前端缓存/运行态里做选择或聚合 |
5.2 单行组件方法
| 方法 | 作用 |
|---|---|
update(data) | 修改当前单行结点 |
create(data) | 当前单行结点按 create 方式准备数据 |
remove() | 删除当前单行结点 |
getId() / setId() / unsetId() | 管理当前单行结点主键 |
isCreation() | 判断当前单行结点是否处于创建态 |
5.3 列表组件方法
| 方法 | 作用 |
|---|---|
loadMore() | 加载下一页 |
addItem() / addItems() | 在列表结点上新增一条或多条待创建数据 |
updateItem() / updateItems() | 更新列表中某条或某批数据 |
removeItem() / removeItems() | 删除列表中某条或某批数据 |
recoverItem() / resetItem() | 恢复或重置列表项 |
setNamedFilters() / addNamedFilter() | 动态调整过滤条件 |
setNamedSorters() / addNamedSorter() | 动态调整排序条件 |
setPageSize() / setCurrentPage() | 调整分页 |
这些方法还有一个共同点:很多都支持额外传一个 path 参数,表示在当前组件结点下,继续对子路径对应的结点操作。
例如:
update(data, action, path)execute(action, messageProps, path, opers)clean(lsn, dontPublish, path)addItem(data, path)
因此在复杂页面里,父组件完全可以不把所有按钮都下沉到子组件,而是在父组件里直接对某个子路径进行提交、清理或增删改。
如果你看 oak-general-business/src/components/system/application/web.pc.tsx,就会看到一个非常典型的列表组件组合:
- 用
addItem(...)先在列表结点上插入一条待创建数据; - 弹出
ApplicationUpsert,并把它挂到${oakFullpath}.${createId}; - 编辑完成后统一调用
execute()提交。
这是 Oak 里非常常见的一种列表内新增模式。
5.4 列表 + Modal + Upsert 的标准模式
如果你在 Oak 项目里要做“列表里新增一条,再弹窗编辑”的能力,最推荐的不是另起一条完全独立的绝对路径,而是优先使用当前列表结点下的子路径。
oak-pay-business/src/components/apAccount/config/web.pc.tsx、oak-pay-business/src/components/withdrawAccount/list/web.pc.tsx 都是很标准的例子:
- 列表组件自己持有当前 list 结点;
- 点击新增时,先调用
addItem(...)在当前 list 结点上插入一条待创建数据,拿到新 id; - 再把 upsert 组件挂到
${oakFullpath}.${upsertId}; - 如果是编辑已有行,就继续使用同一条子路径,并补上
oakId={upsertId}; - 点击确认后,由列表组件统一
execute(); - 点击取消时,统一
clean()/resetAll()。
这类模式的优点是:
- 创建态和编辑态都挂在同一棵 list 子树里;
- 不需要额外维护一套平行结点;
- 列表组件可以统一决定提交和回滚入口;
- modal 只是 UI 壳,真正的数据仍然留在 runningTree 里。
还有一个容易漏掉的前提:addItem(...) 的第一份数据必须让草稿满足当前列表的归属和过滤条件。假设列表按 accountId 过滤,并且子 Upsert 挂在 ${oakFullpath}.${upsertId},不要只写:
const id = addItem({});
应在创建草稿时写入归属字段和不可空默认值:
const id = addItem({
accountId,
enabled: true,
needReceiving: false,
});
否则草稿可能因为不满足当前 ListNode filter 而从列表视图中消失,子 SingleNode 随后读不到稳定数据,Upsert 会空白或在保存时才暴露外键/非空错误。归属字段由列表 create 操作建立;子 Upsert 只继续编辑其它字段。
如果这个 upsert 本来就是在编辑列表行本身,优先按这个模式来写。只有当它明显不是当前列表子树的一部分时,才考虑独立绝对路径。
6. 生命周期到底该怎么用
如果严格按照 page.react.tsx 的真实执行顺序来看,一个常规 Entity 组件的关键阶段通常是:
- 构造函数中初始化
data、默认properties、自定义methods; - 同步执行
created; componentDidMount中订阅locales、cache和用户声明的features;- 执行
attached; onPathSet(...)创建 runningTree 结点;- 对非
list child、非stale的结点执行首轮refresh(); - 首轮数据回来后执行
mature; - 执行
ready; - 页面显示时执行
show; - 组件销毁时先
destroyNode(...),再执行detached。
Virtual 组件的路径会更简单一些,但也同样遵循“先建立结点,再进入 ready”的总体顺序。
一个简单的使用建议是:
created:只做最轻量的同步初始化;attached:可以做订阅、埋点、非数据树依赖逻辑;mature:适合“首次取数完成后的处理”;ready:最适合依赖oakFullpath、运行树方法、初始数据的逻辑;detached:做清理。
特别注意:不要在 created / attached 中假定 runningTree 结点已经完全就绪。 真实源码里,路径创建和首轮 refresh 是在更后面的阶段完成的。凡是依赖数据树的方法,比如:
refreshgetIdupdatesetNamedFiltersloadMore
都更适合放在 ready 之后使用。
7. 真实项目里的几种定义方式
只看类型定义很容易抽象过头。下面几种写法,都是 haina-busi 和 taicang 里真实存在、而且很值得借鉴的模式。
7.1 Virtual 控制页
例如 taicang/src/pages/console/account/detail/index.ts,它自己不声明 entity,而是:
- 通过
features.application、features.console、features.cache算出当前accountId; - 在渲染层中,再把
oak-pay-business的AccountDetail挂到${oakFullpath}.account。
这种模式很适合:
- 页面本身更像控制器,而不是单一实体页;
- 页面要组合公共业务包组件;
- 需要先根据当前模式、当前用户、当前系统环境推导真正要展示的实体。
7.2 动态实体组件
例如 haina-busi/src/components/business/daemon/config/index.ts,同一组件同时服务 system 和 room:
entity()根据this.props.entity决定当前实体;projection()与formData()也随之复用。
这种模式适合多个实体结构高度相似的场景,但要注意不要在一个已经稳定挂载的结点上频繁切实体。
7.3 用户态驱动的动态 projection
例如 taicang/src/pages/frontend/spAuctionCollection/detail/index.ts:
- 登录时查询投标板、关注关系、代理出价等用户相关数据;
- 未登录时只查询公开详情所需字段。
这种模式很适合详情页,因为详情页经常同时面对:
- 公开访问;
- 登录后增强;
- 某些字段查询成本较高。
7.4 listeners 驱动的复杂联动组件
像 taicang/src/components/spBid/modal/index.ts 这类组件,会在 listeners 中:
- 监听
showPopup,bidPrice; - 监听
offerPrice; - 监听
plate/accountId的变化来建立或释放数据订阅。
这类组件通常已经不只是“查数据然后展示”,而是一个真正的前端状态机。写这类组件时,建议把“哪个字段变化会触发什么副作用”明确集中到 listeners,不要把逻辑散在多个渲染事件里。
7.5 stale 列表片段
例如 haina-busi/src/pages/business/machine/list/index.ts、haina-busi/src/components/business/system/order/list/index.ts:
- 组件本身是 Entity list;
- 但声明
stale: true; - 再通过外层页面或 feature 联动控制真正的 refresh 时机。
这类写法适合大页面里的内嵌列表片段,但只有在你对数据刷新链路非常清楚时才建议使用。
7.6 没有 entity 的业务工具组件
并不是所有 Oak 组件都应该绑定一个实体。oak-pay-business/src/components/withdraw/create/index.ts、oak-pay-business/src/components/withdraw/display/index.ts 就很典型:
- 它们都没有声明
entity; - 主要依赖
properties、formData、features、methods; - 通过
features.cache、features.application、features.token去取业务上下文; - 最终服务的是“提现向导”“提现结果展示”这类业务流程,而不是某个单一实体详情页。
这类组件适合:
- 向导页;
- 纯业务流程表单;
- 结果展示块;
- 选择器、支付器、汇总器这类强交互工具组件。
也就是说,不写 entity 不等于它只能是普通 React 组件。只要你还需要:
- Oak 生命周期;
features;formData;props.methods;
那它依然很适合写成 Virtual Oak 组件。
7.7 单行 Upsert 中继续挂关联子实体
oak-pay-business/src/components/wpAccount/upsert/web.pc.tsx 提供了一个很好的例子:当前组件本身是 wpAccount 的单行 upsert,但它内部还继续挂了一个 WechatPayUpsert:
<WechatPayUpsert
oakPath={`${oakFullpath}.wechatPay`}
systemId={systemId}
/>
如果当前 wpAccount 已经有关联的 wechatPayId,就继续补:
oakId={wpAccount.wechatPayId}
这种模式特别适合:
- 当前对象里内嵌一个强关联的配置对象;
- 父对象和子对象的编辑希望放在同一张表单里;
- 子对象本身仍然值得保留独立 upsert 组件。
可以把它理解成:父单行组件负责主对象,子单行组件负责一个关系明确的子对象。 路径上仍然优先沿关系组织,而不是额外发明新名字。
7.8 业务包里的 AbstractComponents 适配层
在 oak-pay-business/src/components/AbstractComponents.ts 里,可以看到另一种很常见、但不容易被初学者注意到的模式:先把 oak-frontend-base 的抽象组件按当前业务包的 EntityDict 再包一层。
例如里面会把:
FilterPanelListListProDetailUpsert
重新导出成适配当前业务包类型的组件。
这层适配的价值主要有两点:
- 业务包内部直接使用时,不用每次都手工补完整的泛型;
- 项目代码和公共包代码里,
entity、列定义、RowWithActions的类型会更稳定。
如果你在自己的公共业务包里也准备封一组常用抽象组件,推荐沿用这种思路:
- 先以框架抽象组件为基础;
- 再用当前业务包的
EntityDict做一次类型收口; - 最后让业务页面统一从这一层导入。
这样做不会改变运行时逻辑,但会明显改善项目里的组件书写体验和类型一致性。
7.9 Oak 组件和 pure 展示组件怎么分工
oak-pay-business/src/components/account/detail/web.pc.tsx 里有一个非常典型的分层:
- 外层
AccountDetail仍然是 Oak 组件渲染层,负责拿account、权限、弹窗状态和NewDeposit; - 其中账户流水这块,并没有继续写成一个需要
oakPath的 Oak 子组件,而是直接交给accountOper/pure/List.pc.tsx这样的纯展示组件。
pure/List.pc.tsx 只接收:
- 已经整理好的
accountOpers t
然后专心把列表画出来。
这类拆法很适合下面这些场景:
- 子块只负责展示一段已经查好的数据;
- 子块不需要
refresh()、execute()、listeners、features; - 你希望这个子块在多个 Oak 页面里被反复复用;
- 你不想让页面里每个小块都额外再长出一棵子结点。
可以把判断标准记成一句话:如果子块需要“数据树能力”,写 Oak 组件;如果子块只需要“展示能力”,就让它退回普通 React 组件。
7.10 FilterPanel / ListPro / Detail / Upsert 的标准装配方式
再往前走一步,Oak 页面里最常见的渲染层装配,其实就是下面这几种骨架组合:
第一种,FilterPanel + ListPro 的标准列表页。
像:
oak-general-business/src/components/user/manage/web.pc.tsxoak-pay-business/src/components/pay/list/web.pc.tsxoak-pay-business/src/components/withdrawTransfer/list/web.pc.tsx
都在用这个模式。它的关键点是:
FilterPanel和ListPro共享同一条oakFullpath;FilterPanel负责筛选条件;ListPro负责表格、按钮组、行操作;- 外层 Oak 组件负责把列表数据先整理成渲染层需要的结构。
第二种,Detail + Upsert 的配置/详情页。
像:
oak-pay-business/src/components/ship/wechatMpShip/web.pc.tsxoak-pay-business/src/components/wpProduct/config/web.pc.tsx
会先用 Detail 展示当前行,再用 Upsert 放进 Modal 里做编辑。它适合:
- 配置项结构比较标准;
- 展示和编辑字段基本对应;
- 想复用一套统一的详情/编辑骨架。
第三种,List/卡片 + Upsert Modal 的片段管理页。
这类页面通常不是标准大表格,而是卡片、列表项或配置块,但编辑仍然通过 ${oakFullpath}.${upsertId} 这套子路径模式完成。
所以可以把这些抽象组件的职责简单记成:
FilterPanel:负责筛选输入;ListPro/List:负责列表骨架和行动作;Detail:负责把一行对象按字段定义展示出来;Upsert:负责把一行对象按字段定义编辑出来。
它们本身不替代 Oak 数据树,而是和 Oak 组件互相配合。最常见的组合就是:外层 Oak 组件产数据,渲染层抽象组件消费数据。
8. 实战建议
- 页面级根组件负责稳定的
projection和根路径,子组件尽量复用这条路径,不要重复造一套平行节点。 - 单行详情组件和更新组件如果共享同一条
oakPath,就要有意识地让它们共享同一份对象上下文。 - 列表组件里,过滤、排序、分页优先定义成命名条件,后续更容易动态替换。
- 需要“是否可提交”时,不要想当然依赖某个框架字段,最稳妥的是在
formData中显式返回this.tryExecute()的结果。 path和zombie的配置项只在页面级直接声明;子组件请改用oakPath、oakZombie。- 如果组件只是父组件某个对象片段的展示壳,而不需要自己主动刷新,可以认真考虑是否应该设为
stale或直接做成 Virtual 组件。 - 如果组件依赖
oakId、oakPath、上游异步结果,优先条件渲染,避免让路径和主键在挂载后才补上。 - 如果你的页面只是做环境判断、权限判断、业务包拼装,完全可以把它写成 Virtual 控制页,再把真正的 Entity 组件挂到子路径上。
- 如果你做的是“列表新增/编辑弹窗”,优先用
addItem + ${oakFullpath}.${id} + execute/clean这套标准模式,不要一上来就新开绝对路径。 - 如果你做的是向导、结果页、选择器这类业务流程组件,可以先问自己:是否真的需要
entity,还是写成 Virtual Oak 组件更合适。 - 如果当前单行组件里还要编辑一个强关联子对象,优先把子 upsert 挂到
${oakFullpath}.关系名这样的相对路径上。 - 如果一个子块只是消费已经整理好的数据,不需要 Oak 生命周期和运行树能力,就把它拆成
pure展示组件,而不是继续往下挂 Oak 子结点。 - 如果你的业务包会大量复用
FilterPanel、ListPro、Detail、Upsert,可以考虑先做一层AbstractComponents类型适配,再统一对外使用。 - 如果你在渲染层使用
FilterPanel、ListPro、Detail、Upsert,先想清楚它们消费的是哪条 Oak 路径、哪份结构化数据,不要把筛选、列表、编辑挂到三条互不相干的路径上。 - 遇到“这个配置项到底能不能这样写”的问题,先对
oak-frontend-base/src/types/Page.ts,再对page.react.tsx和page.common.ts,不要只凭旧文档猜。
编写业务逻辑
在 Oak 中,Entity 只负责把“对象长什么样、能做什么动作”定义清楚;而一个真正可运行的业务系统,还必须再补上一层“对象如何联动、什么情况下允许操作、系统启动后要持续做什么”的运行时逻辑。
这些运行时逻辑,主要就写在 src 下面的这些目录中:
triggerscheckerswatchersaspectstimersroutinesportsfeatures
它们虽然都属于“业务逻辑”,但解决的问题完全不同。
这一组概念分别是做什么的
trigger
trigger 负责数据联动。当某个对象发生 create/update/remove/select 等行为时,框架会在既定时机触发对应逻辑。它最适合表达“当 A 变化时,B 也必须同步变化”这一类约束。
checker
checker 负责合法性检查。它定义“某个动作在什么条件下允许发生”。和 trigger 最大的不同在于,checker 不只在后端执行,前端也可以利用它提前判断一个操作是否允许,从而获得前后端一致的行为。
常见的属性级更新限制,不一定要手写 checker;可以先看 src/configuration/attrUpdateMatrix.ts 是否能表达,框架会据此生成内置 checker。
watcher
watcher 负责轮询型后台任务。在 Oak 后端中,它会每 120 秒执行一轮,适合处理“不断检查数据库里有哪些待处理数据”的场景,例如重试失败消息、补偿异步状态、扫尾清理等。
aspect
aspect 可以理解为 Oak 中的命名业务服务。当一段逻辑不适合直接表达成某个单一实体上的 CRUD,或者需要把多次 select/operate 封装为一个明确的业务入口时,就适合写成 aspect。
timer
timer 是按 cron 调度的任务。和 watcher 的固定 120 秒轮询不同,timer 的执行时机由 node-schedule 的 cron 表达式控制,适合明确的周期任务。
routine
routine 是应用启动或停止时执行一次的例程。它不解决周期问题,而是解决“应用刚启动时需要初始化什么、应用关闭前需要释放什么”。
port
port 是 Oak 对导入导出能力的抽象。它主要服务于 Excel 之类的结构化批量导入导出场景,让导入模板、解析逻辑、批量创建和批量导出都进入 Oak 的业务体系。
feature
feature 是前端侧的可复用状态与服务对象。它不属于后端一致性逻辑,而是 Oak 前端运行时的一部分,用来封装缓存、消息、导航、令牌、文件上传、业务工具类等横跨多个组件的能力。
它们之间最容易混淆的边界
Oak 新手最容易犯的错误,不是“不会写”,而是“写错地方”。
下面这几个判断非常重要:
- 涉及数据一致性的约束,优先考虑
trigger或checker; - 涉及显式业务服务入口,优先考虑
aspect; - 涉及后台持续轮询,使用
watcher; - 涉及 cron 调度,使用
timer; - 涉及应用启动初始化,使用
routine; - 涉及前端共享状态或工具封装,使用
feature。
尤其不要把所有复杂逻辑都堆进 aspect。aspect 很方便,但它本质上只是一个入口,不会自动替你解决对象间联动、权限推导、前后端一致检查这些更底层的问题。
一个建议的编写顺序
实际开发时,比较推荐的顺序通常是:
- 先定义
Entity; - 再用
checker明确哪些操作允许发生; - 用
trigger补齐对象联动; - 如果需要后台扫描,再补
watcher或timer; - 最后才去写
aspect、feature、port这类更偏“入口”和“交互”的逻辑。
这样写出来的代码,会更贴近 Oak 本身的设计思路,也更容易维护。
定义trigger
在对您的业务数据进行各种增删改查操作(即对某对象的Operate行为)时,可以通过定义trigger来设置数据之间的联动。Trigger的作用是在于“当某种符合规则的Operation发生时,可能会触发其它数据的联动行为”。(可以将之理解为传统Database中的触发器,用来保持数据之间的一致性)。
当然Trigger本身也支持由Select触发,我们在本节的最后小节会加以描述
trigger的编写规范
trigger编写在src/triggers目录下,可以根据trigger的entity来分文件存放。例如:对system对象的trigger就可以写在src/triggers/system.ts文件中:
import { Trigger } from '@oak-domain/types/Trigger';
import { EntityDict } from '../oak-app-domain/EntityDict';
import { BackendRuntimeContext } from '../context/BackendRuntimeContext';
const triggers: Trigger<EntityDict, 'system', BackendRuntimeContext>[] = [
....
];
export default triggers;
再导出集成到src/triggers/index.ts中
import systemTriggers from './system';
export default [
...systemTriggers,
];
trigger的定义
trigger的定义可以参见oak-domain/types/Trigger.ts
一个trigger有以下属性需要定义:
| 属性 | 取值范围 | 是否必填 | 含义 |
|---|---|---|---|
| entity | EntityDict中的对象 | 是 | 触发的对象 |
| action | 该entity的action | 是 | 触发的操作 |
| name | 字符串 | 是 | 给trigger命名,便于后续跟踪调试(命名需要唯一) |
| priority | 1-99 | 否 | 触发器执行的优先级(只有当entity和action完全相同时才有意义),数字越小优先级越高 |
| when | 'before'/'after'/'commit' | 是 | 执行时机,在操作前/后/提交时 |
| strict | 'takeEasy'/'makeSure' | 否 | 是否需要严格执行(只有当when为commit时才有意义),见下文解释 |
| attributes | entity的属性 | 否 | 更新的属性(只有当action为update时才有意义),如果更新的属性和定义的attributes没有交集,则此trigger不会被触发 |
| check | function | 否 | 更新的数据检查(只有当action为update/remove时才有意义),如果更新/删除的操作不满足检查,则此trigger不会被触发 |
| filter | 该entity的Filter/function | 否 | 更新的条件检查(只有当action为update/remove时才有意义),如果更新/删除的数据条件不满足filter,则此trigger不会被触发 |
| mt | 'create'/'apply'/'both' | 否 | 当存在延时更新Modi时的行为控制 |
| fn | function | 是 | 触发器的行为 |
以下代码定义了一个system对象相关的trigger(代码来自oak-general-business/src/triggers/system.ts)
{
name: '当system删除前,删除相关的passports',
entity: 'system',
action: 'remove',
when: 'before',
fn: async ({ operation }, context, option) => {
const { filter } = operation;
await context.operate('passport', {
id: await generateNewIdAsync(),
action: 'remove',
data: {},
filter: {
system: filter,
},
}, option);
return 1;
},
}
这个trigger所定义的行为就是:当system对象被删除之前,先将其关联的passport对象全部删除(可以类似于Database中外键删除的处理)。
注意两个额外的细节,一是删除外键的trigger一般用before在动作之前触发,二是fn中如何将本operation对system的filter快速移植到对passport对象的filter上
关键概念解释
action
一个entity的action包括了在编写此对象时显式定义的Action,也包括通用的Action,相关描述可见编写对象。
在定义trigger时,action项可以是单个Action,也可以是Action的数组类型。当定义为数组时,数组中的任一Action发生时,均会触发此trigger。
priority
定义触发器的优先级。当entity与action相同时,按照priority定义的由小到大的顺序进行执行。
一般而言,对同一个entity的相同action,我们推荐将需要触发的行为定义在同一个entity当中,这样更利于代码的可维护性。但因为action可以支持数组,在这种情况下也需要使用priority来规范相关顺序。
当前实现中的默认trigger优先级是50。如果确定需要显式定义优先级,请优先围绕这一默认值进行调整,并结合checker的优先级表一起考虑执行顺序。
when
"before"和"after"的行为是比较容易从字面上理解的,这里要注意的是,当when定义为after时,此operation已经发生,此时使用operation的filter再去查询数据不一定成立(如果operation的data中更新了相关属性)。
因此,如果要进一步修改此operation的对象或相关联的对象,一种推荐的写法是在before的trigger中,在data中增加相应的属性(包括cascade属性),见查询和操作对象。
在before类型的trigger中,可以通过修改operation.data来影响后续持久化的数据:
{
name: '创建订单时自动填充默认值',
entity: 'order',
action: 'create',
when: 'before',
fn: async ({ operation }, context, option) => {
// 直接修改operation.data,会被带入后续的持久化过程
if (!operation.data.status) {
operation.data.status = 'pending';
}
return 1;
},
}
"commit"的意义和其字面上完全相同,就是“当此operation的事务实际提交时”(再触发)。这里就隐含了一个概念,所有when被声明为的before/after的trigger,其定义的相应的行为都会和operation发生在同一事务中,得到一致性的保护。而一旦trigger被设置为"commit",则只有当操作实际成功时才会触发(也不会得到事务的保护)。commit类型的trigger往往发生在一些需要和外部发生逻辑的地方,例如:当用户上传了100个电话号码(100次Create),需要向这100个号码发短信时。如果这时候trigger写成after,可能会发生什么?
strict
当when定义为"commit"时,strict域的定义尤其重要,它相当于一种“跨系统的一致性保护”能力。当:
- strict定义为"takeEasy"时,此trigger无论成功或失败,只会尝试执行一次(默认行为)
- strict定义为"makeSure"时,此trigger必须执行成功,否则会反复执行直到成功
在上面的例子中,如果这次短信发送必须成功,则可以把strict设置为makeSure。此时的100次Create会触发调用外部短信发送接口100次,如果其中有某次发送失败,Oak会反复调用直到发送成功(但外部短信系统应如何正确处理这种行为,使接口具有幂等性并不是Oak可以控制的)。
filter
当action为更新时(所有自定义的Action也会被视作更新)且when为"before"时,如果有定义filter,则意味着只有当该Operation的filter条件和此filter条件不“冲突”时,此trigger才会被触发。所谓“不冲突”是指这两个filter所定义的查询范围可能存在交集。
例如:如果一个trigger的定义的filter如下(对象为编写对象章节所定义的Address):
{
entity: 'address',
action: 'update',
filter: {
phone: '12345',
},
}
而当一个Address对象上的Operation为:
{
action: 'update',
data: {...},
filter: {
phone: '54321',
},
}
此时Oak会判定这个Operation的目标行一定和trigger定义的数据范围相冲突,所以trigger不会执行。
但如果另一个Operation为:
{
action: 'update',
data: {...},
filter: {
name: 'xc',
},
}
则Oak无法确定这两个filter定义的数据范围是否有交集(和实际数据相关),所以这个trigger会被执行。
由上述例子可见,filter的作用是:当某个filter只针对一个非常小的范围有效时,可以有效降低trigger不必要的调用次数。
mt
mt 和对象的 modi(延时更新)机制有关,用来声明一个 trigger 在 modi 场景中的执行时机,可取:
create:只在创建modi时执行;apply:只在真正应用modi到目标对象时执行;both:两个阶段都执行。
如果不显式声明,Oak 的默认行为是:
when: 'commit'的 trigger 默认只在apply阶段执行;- 非
commit的 trigger 默认只在create阶段执行。
因此,只有当你的业务真的接入了 modi 审批/延时更新流程时,才需要认真设置 mt;普通对象上的 trigger 往往不需要关心它。
fn
fn是定义trigger的行为,它是一个异步函数,有三个调用参数:
- object,其中存放本次触发的数据上下文
- operation:导致本次触发的operation本身
- result:如果是select动作的filter且when为after时,这个属性会包含即将返回的数据本身
- context,本次执行的上环境上下文
- option,本次执行的配置
要注意,在fn中的所有异步行为(操作其它数据)都需要用async关键字保护,否则会发生不一致行为。
fn的返回值可以是一个整数,代表在这个fn中影响(或操作)的数据行数,用于调试输出。如果trigger执行过程中抛出异常:
- before/after类型的trigger会回滚整个事务
- commit类型的trigger(跨事务trigger)的行为见下文"跨事务trigger的错误处理"
Trigger的执行机制
当Oak系统中执行某个Operation时,会检查与这个operation有关连的trigger(根据entity/action/data/filter),并将它们根据when的定义分成三类,接下来系统会:
- 按照priority从小到大的顺序执行before类型的trigger
- 执行operation
- 按照priority从小到大的顺序执行after类型的trigger
而commit类型的trigger会延时到operation提交(类似关系数据库的事务提交)之后,这种类型的trigger的行为和普通trigger区别较大,更多的细节请参看下一小节。
trigger的这种执行机制意味着可能产生递归调用,例如:在一个对A的operation触发了对B对象的另一个operation,然后后一个operation又触发了对C对象的一个operation……,因此在设计系统的时候,对象之间有关联逻辑的先后顺序应加以仔细制定,一个原则是:
应尽量减少trigger的数量,相同entity相同action的trigger尽量进行合并。
另外,trigger只在后台执行,这点是和checker的重要区别。
跨事务trigger
前面在when和strict配置项中已经初步诠释了跨事务trigger的基本含义和使用方法,跨事务trigger是业务系统和外部系统达成数据一致性的重要手段。在Oak的实现中,before/after的trigger会和触发Operation处于同一事务当中,从而保证行为的一致性。而对于跨业务系统的行为一致性较难实现,下面先简要介绍跨事务trigger的实现过程:
- 在 Oak 执行某 Operation 之前,commit trigger 都会注册事务提交回调;只有
strict: 'makeSure'还会在更新数据中加入两个持久化属性:
| 属性 | 类型 |
|---|---|
$$triggerUuid$$ | uuid |
$$triggerData$$ | object |
这是 Oak 框架自动管理的内置属性。
$$triggerData$$记录 trigger 名称、序列化上下文和 option,业务代码通常不应直接读写。
-
事务提交后,回调函数以
{ ids }作为第一个参数调用 trigger。makeSure成功后,框架会清除上述两个属性;若设置了cleanTriggerDataBySelf,则由 trigger 自行清理。 -
若
makeSuretrigger 执行失败,这两个属性不会被清除,checkpoint 会在后续 watcher 周期继续发现并重试;当前AppLoader的周期是 2 分钟。标记已物化到数据行,因此应用重启后仍可继续收敛。takeEasy不写入这些标记,也不会进入 checkpoint 重试。
跨事务trigger的错误处理
当跨事务trigger执行失败时:
- 如果
strict为takeEasy(默认),只在事务提交后尽力执行一次,失败不会重试; - 如果
strict为makeSure,失败状态会保留在数据行上,由 checkpoint 反复执行直到成功。
只有 makeSure 会通过 $$triggerUuid$$ 和 $$triggerData$$ 追踪失败状态,即使应用重启后也会继续重试。
跨事务trigger的fn
跨事务trigger的执行函数和普通trigger有两个不同:
- fn的第一个调用参数里增加了一个参数ids,以数组形式传入本次trigger涉及的id;
- fn可以返回一个更新数据对象或回调函数,如果返回的是更新数据对象,框架会在消除triggerUuid和triggerData属性的同时将这部分数据更新到数据行上,如果返回的是一个回调函数,框架会在消除triggerUuid和triggerData后执行此函数(同一事务保护)。
跨事务trigger的配置
对于跨事务trigger,除了strict之外还有一些额外的配置,简要介绍如下:
| 属性 | 取值范围 | 是否必填 | 含义 |
|---|---|---|---|
| cs | true | 否 | 在集群环境下,这个trigger涉及的数据将被分配到固定的结点上执行 |
| singleton | true | 否 | 在集群环境下,这个trigger将只会在唯一的实例上执行 |
| cleanTriggerDataBySelf | true | 否 | triggerUuid和triggerData不会自动清除 |
| grouped | true | 否 | 被同一trigger所涉及的不同批次的行,在重复执行时将被合并执行 |
当Oak框架(所编写的应用)运行在集群环境中时,会存在一个以上的应用实例。此时对trigger的处理非常微妙,会有更复杂的需求出现,我们用一个例子来加以说明:
有一个拍卖应用,当有人对某一拍品出价时,就开启一个倒计时,在10分钟之后落锤。如果在10分钟之内有人出更高的价格,就重新开始计时。
在实现上,我们需要在“拍品”这一对象(Collection)上设计一个属性“落锤时间”(confirmedAt),当这一属性被更新时,为之创建一个定时器:
{
name: '当落锤时间确定时,更新其定时器',
entity: 'collection',
action: 'confirm',
when: 'commit',
cs: true, // 标识这个trigger是cluster sensative
fn: async ({ ids }, context) => {
const collections = await context.select('collection', {
data: {
id: 1,
confirmedAt: 1,
},
filter: {
id: {
$in: ids,
},
},
});
for (const col of collections) {
const { id, confirmedAt } = col;
if (Timers[id]) {
clearTimeout(Timer[id]); // 清除上一次落锤的定时器
}
Timers[id] = setTimeout(() => {
.... // 落锤的逻辑
}, confirmedAt - Date.now());
}
}
}
这种实现方案有一个潜在的问题:即在集群环境下,每次处理同一个拍品的trigger可能在不同进程中被触发,而Timers是一个内存化的定时器集合,如果对同一条拍品的两次上述处理落在两个不同的进程中,则会出现两次落锤的逻辑,这显然是不正确的。
因此,将trigger标识为cs(cluster sensative)的意义,就使得对于同一行数据的处理一定落在同一个进程当中。另外一个属性singleton限制更加严格,一旦一个trigger被定义为singleton,则所有触发都会在全局的同一个进程中进行(当某个行为需要全局统一处理时)。
跨事务trigger的限制
目前,同一行上只能同时存在一个跨事务trigger。这意味着:首先,同一个entity的同一个action上,只能定义一个跨事务trigger;其次,当某行上有一个跨事务trigger一起不能完成,对此行新的跨事务trigger无法执行。这两种情况发生时,框架都会报错。
事实上,如果某行数据上有跨事务trigger,意味着这行的更新并未“完全完成”,在某个外部系统中还有需要更新成为一致性的数据。此时对行上的其它更新需要非常小心,应该在设计上就阻止可能产生不可预料后果的行为。无论什么情况下,如果发现跨事务trigger执行失败了,最好的处理方式都是尽快让其执行完成,达到整体一致性状态。在此之前,对系统的任何操作都要非常小心。
我们举一个例子来说明这一问题,假设当一个名为photo的对象创建时,要去某OSS上上传一张图片:
{
entity: 'photo',
action: 'create',
when: 'commit',
strict: 'makeSure',
fn: async ({ ids }, context) => {
// ...去根据ids中行的信息上传图片到OSS
}
}
那么也应该存在一个当photo对象被删除时,要去某OSS上删除这张图片:
{
entity: 'photo',
action: 'remove',
when: 'commit',
strict: 'makeSure',
fn: async ({ ids }, context) => {
// ...去根据ids中行的信息删除OSS中的文件
}
}
现在我们看看会发生什么危险的情况,假设一条photo数据被创建了,但它上传OSS的行为一直没成功(因为某种不可知原因),那么Oak框架会反复执行第一个trigger去尝试上传,但是在它成功前,用户就把这条photo数据删除了,此时第二个trigger也被激活,它会去尝试删除一张根本没有上传成功的图片!后面的行为就会变得不可预料(两个trigger究竟谁后成功?第一个trigger可能还会发生取不到数据的奇怪异常)。
所以在设计跨事务trigger时,要从根本上来杜绝这种情况。像上面这种情况,我们的解决方案可以是:
- 在photo对象中增加一个状态status,当create时,其状态设为'uploading'(可以通过下一节介绍的logicalData类型的checker来赋初值).
- 在跨事务上传动作完成后,将这个状态更新成'uploaded',
{
entity: 'photo',
action: 'create',
when: 'commit',
strict: 'makeSure',
fn: async ({ ids }, context) => {
// ...去根据ids中行的信息上传图片到OSS
return {
status: 'uploaded',
}
}
}
- 对photo的remove动作加一个row类型的checker,限定只有status为uploaded状态的图片才允许删除。
Select型trigger
我们同样允许在select行为的前后增加trigger行为。例如,如果我们规定在查询用户对象(User)时,如果当前用户(查询者)不是root,就不允许直接查询其密码(password)属性。则可以像下面这样编写trigger:
{
entity: 'user',
action: 'select',
when: 'before',
fn: async ({ operation }, context) => {
if (!context.isRoot()) {
const { data } = operation;
delete data.password;
}
}
}
trigger也可以注入到查询完成后(返回前),例如,我们想对于非root用户,对User的姓名信息打码:
{
entity: 'user',
action: 'select',
when: 'after',
fn: async ({ result }, context) => {
if (!context.isRoot()) {
for (const user of result) {
if (user.name) {
user.name = user.name.slice(0, 1) + '**';
}
}
}
}
}
上述例子表明了,当select类型的trigger的when定义为“after”时,在fn的第一个object参数时包含了查询的结果集信息(result)。
定义checker
Checker是Oak框架中另一种强大的机制,用于检查对某个对象操作的合法性。在实际应用中,对业务对象的各种业务型操作会有非常复杂的限制条件,而这些限制条件如果依靠程序员手写检验代码,不仅会使得代码迅速膨胀到难以维护,而且还会存在非常麻烦的前后端逻辑不一致问题。
和从字面上的理解一样,checker就是让程序员来定义:当对某个entity进行某项action时,必须满足的条件。这个条件可能落在更新的数据上,也可能落在现有的数据上。我们在后面将展开阐述。
checker的编写规范
Checker编写在src/checkers目录下,可以根据checker所关联的entity来分文件存放。例如:对system对象的checker就可以写在src/checkers/system.ts文件中:
import { Checker } from '@oak-domain/types/Trigger';
import { EntityDict } from '../oak-app-domain/EntityDict';
import { BackendRuntimeContext } from '../context/BackendRuntimeContext';
const checkers: Checker<EntityDict, 'system', BackendRuntimeContext>[] = [
....
];
export default checkers;
再导出集成到src/checkers/index.ts中
import systemCheckers from './system';
export default [
...systemCheckers,
];
checker的定义
checker的前四个属性定义和trigger是完全一致的,见trigger章节的解释:
| 属性 | 取值范围 | 是否必填 | 含义 |
|---|---|---|---|
| entity | EntityDict中的对象 | 是 | 检查的对象 |
| action | 该entity的action | 是 | 检查的操作 |
| priority | 1-99 | 否 | 检查器执行的优先级(只有当entity和action完全相同时才有意义),数字越小优先级越高 |
| mt | 'create'/'apply'/'both' | 否 | 当存在延时更新Modi时的行为控制 |
第五个属性type定义了checker的类型,目前checker一共分为五种类型:
- data:对此次Operation数据的检查
- row:对Operation当前行(也包括相关行)的检查
- logical:对此次Operation行为有前置的补充操作
- logicalData:对此次Operation行为有前置的、修改Operation中的data的操作
- relation:判定用户是否有Operation的权限,这是一种虚拟的类型,一般不需要用户手动编写
data类型
当type定义成data时,在checker属性上可以声明一个函数,其传入的参数为本次action的data。在函数内可以对data进行检查,若不合法可以抛出异常。
例如,如果业务逻辑限制Address对象(见编写对象章节)的name属性不能为空,则可以像下面这样编写:
import { OakAttrNotNullException, Checker } from '@oak-domain/types';
...
{
entity: 'address',
action: 'create',
type: 'data',
checker: (data) => {
if (!data.name) {
throw new OakAttrNotNullException('address', ['name'], '地址命名不能为空');
}
}
}
这里使用到了一个通用的异常类型OakAttrNotNullException,表示某个非空属性被置空。当然您也可以定义自己的异常类型,见异常定义。
不过在实际使用中,上面这个checker并不需要用户显式定义,只要我们在定义address时,其name属性没有加"?"修饰符,框架会自动生成检查本属性非空的checker。另外系统会根据entity定义一系列的数据型检查,例如参见Address对象的定义,每当发生创建或更新时,系统都会自动去检查:
- detail的长度是否小于等于32
- default是否是有效的boolean类型
等等。但是有些复杂的检查还是得依赖用户自己编写,例如检查phone是否是一个合法的电话号码格式(代码参照oak-general-business/src/checkers/address.ts):
import { OakInputIllegalException, Checker } from '@oak-domain/types';
import { isMobile } from '@oak-domain/utils/validator';
{
type: 'data',
action: 'update',
entity: 'address',
checker: (data) => {
if (data.hasOwnProperty('phone') && !isMobile((data as EntityDict['address']['Update']['data']).phone!)) {
throw new OakInputIllegalException('address', ['phone'], '手机号非法');
}
return;
},
}
框架为 data 类型的 checker 定义了三种通用异常,可以参见 @oak-domain/types/Exception 中的定义:
| 异常 | 描述 |
|---|---|
| OakInputIllegalException | 泛指输入非法 |
| OakAttrNotNullException | 非空属性为空 |
| OakAttrCantUpdateException | 属性不允许更新 |
您也可以定义更加精细的输入数据异常,遵照规范请继承OakInputIllegalException这一类型。
row类型
当type定义为row时,在filter中可以声明检查当前被更新的数据必须满足的条件。
例如,我们如果要限制只能更新收货人名称为“张三”的收货地址(这当然并不是一个有实际意义的限制),那可以像下面这样写:
{
type: 'row',
entity: 'address',
action: 'update',
filter: {
name: '张三',
},
}
当然,filter也可以写成函数形式,比如我们要限制address只能由本人修改:
{
type: 'row',
entity: 'address',
action: 'update',
filter(operation, context) {
return {
entity: 'user',
entityId: context.getCurrentUserId(),
};
},
}
对当前数据的限制不仅仅可以落在当前entity上,也可以落在行所关联的其它entity上。例如,我们限制只有浙江省杭州市的地址可以被更新:
{
type: 'row',
entity: 'address',
action: 'update',
filter: {
area: {
name: '杭州市',
},
},
}
如果action是create,则row类型的限制必须落在行所关联的其它entity上,像下面这种写法:
{
type: 'row',
entity: 'address',
action: 'create',
filter: {
area: {
level: 3, // level 3意味着这是一个行政区规划
},
},
}
这个checker限制了创建地址时,其关联的area必须指向区这一级别。
如果action是select,则对应的filter会应用到查询数据时,例如如果我们定义:
{
type: 'row',
entity: 'address',
action: 'select',
filter: {
detail: {
$exists: true,
},
},
}
则当应用查询address时,所有detail为空的行都不会被返回。
当row类型的checker不通过,框架默认会抛出OakRowInconsistencyException类型的异常,这个异常是通知前台数据缓存过期(因为checker在前台也应该起检查作用,所以后台会认为是前台数据的状态和后台不一致导致请求被通过)。在定义checker时,可以通过以下参数注入一些发生异常时的处理:
- errMsg: string类型,出错时的提示;
- err: Exception,可以取代默认的OakRowInconsistencyException异常类型(但最好是继承这个异常类型的新异常);
- inconsistentRows: 当发生异常时,通过此定义可以返回更多的数据,去更新前台缓存。
row类型的filter还有一个很有用的参数:conditionalFilter,其类型可以是一个本entity上的filter,也可以是一个返回filter的函数。这个参数可以用来限制checker作用的行的条件,即只有当action作用在满足conditionalFilter限制的行上时,此checker才起作用,相当于在Trigger章节中介绍的filter参数。
logical类型
logical类型的checker可以用来补充用data和row类型无法表达的复杂逻辑。例如,我们规定在系统中,除了root超级管理员,其余用户都只能创建自己用户的Address信息,这个checker涉及的条件无法用单一的row来表达,此时我们用logical类型来写:
{
type: 'logical',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { action, data } = operation as EntityDict['address']['CreateSingle'];
const { id, entity, entityId } = data as EntityDict['address']['CreateOperationData'];
if (entity === 'user' && !context.isRoot()) {
const userId = context.getCurrentUserId();
if (userId !== entityId) {
throw new OakOperationUnpermittedException('address', operation);
}
}
}
}
Logical checker的第二种用途可以类比before类型的trigger,在operation发生之前进行一些操作。比如像下面这样,当创建一个默认地址时,将同一用户下的其它默认地址更新为非默认:
{
type: 'logical',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { action, data } = operation as EntityDict['address']['CreateSingle'];
if (data.default) {
const { id, entity, entityId } = data as EntityDict['address']['CreateOperationData'];
return context.operate('address', {
id: generateNewId(),
action: 'update',
data: {
default: false,
},
filter: {
id: {
$ne: id,
},
entity: entity!,
entityId: entityId!,
}
}, option);
}
}
}
当然上面这个checker可以(也应该)被优先写成trigger的形式,checker更多时候起到检查操作的作用。但有一种情况例外,当某个checker的存在导致操作无法继续进行时,需要在这个checker之前来做一些操作。例如,根据上面这个例子,假如还有一个checker来检查,每个用户只能有一个默认的default地址,则会导致当已经有一个default地址时,无法再插入一个新的default地址,这时候就需要再用一个logical类型的checker来提前处理(虽然从理论上来说,有了这个负责default唯一的checker之后,不需要再做额外的检查了)。
logicalData类型
logicalData类型和logical类型的第二种作用完全一致,只不过它被用来标识“这个checker会修改operation中的data”。这类checker的意义就是赋予data一些默认的初始值。例如,如果我们规定:若用户没有设置address中的地区,就默认将它设置为杭州市:
{
type: 'logicalData',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { data } = operation;
if (!data.areaId) {
data.areaId = 330100;
}
}
}
同步和异步混写
checker 不要因为其中某个分支需要 context.select(...),就把整个 checker 函数直接写成 async。
原因是 checker 会同时服务前端同步检查和后端异步检查。Oak 在同步上下文里会直接调用 checkerFn(...),不会等待一个 Promise;而 async function 无论内部有没有真的执行到 await,都会返回 Promise,连同步抛出的异常也会变成 rejected Promise。这样一来,本来应该立即失败的前端同步检查可能失效。
如果一个 checker 里既有同步分支,又有可能异步的分支,应该使用 @oak-domain/utils/executor 里的 pipeline(...):
import { pipeline } from '@oak-domain/utils/executor';
import { OakOperationUnpermittedException } from '@oak-domain/types';
{
type: 'logical',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { data } = operation as EntityDict['address']['CreateSingle'];
const { entity, entityId } = data as EntityDict['address']['CreateOperationData'];
if (entity !== 'user') {
return;
}
if (!entityId) {
throw new OakOperationUnpermittedException('address', operation);
}
return pipeline(
() => context.select('user', {
data: {
id: 1,
},
filter: {
id: entityId,
},
indexFrom: 0,
count: 1,
}, {
...option,
dontCollect: true,
blockTrigger: true,
}),
(users) => {
if (users.length === 0) {
throw new OakOperationUnpermittedException('address', operation);
}
}
);
},
}
pipeline(...) 的特点是:每一步可以返回普通值,也可以返回 Promise;如果前面的步骤都是同步结果,它就保持同步返回;只有真正遇到 Promise 时才进入异步链。这样同一个 checker 才能兼容前端同步路径和后端异步路径。
因此,下面这种写法应该避免:
checker: async (operation, context, option) => {
if (!needQuery(operation)) {
return;
}
const rows = await context.select(...);
if (rows.length === 0) {
throw new OakOperationUnpermittedException('address', operation);
}
}
简单规则是:checker 默认写成普通函数;只有某个分支确实返回了异步结果时,才通过 pipeline(...) 把后续步骤接起来。
属性更新矩阵 attrUpdateMatrix
有一类 checker 非常常见:限制“某个对象的哪些属性,在什么 action 下、什么行状态下可以更新”。如果每个属性都手写 checker,代码会很快变得零散。Oak 为这类规则提供了一个配置化能力:src/configuration/attrUpdateMatrix.ts。
它的含义可以直接从名字理解:属性更新矩阵。矩阵的第一层是 entity,第二层是属性,每个属性可以声明:
actions:哪些 action 可以更新这个属性;filter:被更新的当前行必须满足什么条件。
例如支付账户里常见的限制可以写成这样:
import { AttrUpdateMatrix } from '@oak-domain/types/EntityDesc';
import { EntityDict } from '../oak-app-domain';
const attrUpdateMatrix: AttrUpdateMatrix<EntityDict> = {
offlineAccount: {
name: {
actions: ['update'],
filter: {
sysAccountOper$entity: {
'#sqp': 'not in',
},
},
},
allowPay: {
actions: ['update'],
filter: {
enabled: true,
},
},
price: {
actions: ['pay', 'refund', 'deposit', 'withdrawTransfer'],
},
},
};
export default attrUpdateMatrix;
上面的配置表示:
offlineAccount.name只能通过update修改,并且当前账户不能已经有关联的sysAccountOper;offlineAccount.allowPay只能通过update修改,并且当前行必须满足enabled: true;offlineAccount.price只能由pay、refund、deposit、withdrawTransfer这些业务 action 修改。
这个文件不是只给后端看的配置。项目启动时,src/configuration/index.ts 会把它作为 CommonConfiguration 的 attrUpdateMatrix 导出,前后端都会读取这份配置。后端会把它注册成内置 checker;前端也会用它参与操作合法性检查,并在声明 action attrs 时推导当前 action 可以更新哪些属性。
最重要的一点是:只要某个 entity 出现在 attrUpdateMatrix 里,这个 entity 的更新属性就进入严格白名单模式。没有写在矩阵里的属性,不允许被更新。框架内部的 $$updateAt$$、$$triggerData$$、$$triggerUuid$$ 这类字段除外。
所以给某个对象增加矩阵时,不要只写当前正在关心的一个属性;要同时检查这个对象上所有仍然允许被业务更新的属性是否都已经列出来。否则,一个原本合法的普通更新可能会因为属性没有进入白名单而抛出 OakAttrCantUpdateException。
filter 既可以是静态 filter,也可以是函数:
filter: ({ action, data, filter }, context) => {
return {
enabled: true,
};
}
函数形式适合需要根据 action、提交数据或当前上下文动态生成限制条件的场景。但它依然属于前后端共享 checker 逻辑,写的时候要注意:如果规则需要依赖后端才能查询到的数据,前端同步检查可能无法完整判断。能用静态 filter 表达时,优先使用静态 filter;复杂到难以用属性、action、filter 表达时,再考虑手写 checker。
attrUpdateMatrix 适合表达属性级的更新权限、状态约束和动作约束,不适合表达副作用。比如“账户启用后才允许打开支付开关”适合放在这里;“支付成功后同步创建流水、通知外部系统”应该放在 trigger 或 aspect 里。
relation
这种类型的语义是用于判定用户是否有进行此项操作的权限。在Oak框架中,对权限的判定设计可以参见权限章节。为了对权限进行更灵活的管理,权限被抽象成为了数据表示,不再需要专门编写固定逻辑的checker。系统保留了这一关键字,只是为了在有的接口中表示“要进行权限类型的checker检查”。
checker的内部实现
在Oak框架中,checker的实现原理和trigger一致,都发生在满足所定义条件的某次operation执行之前。
五种类型的checker中,只有logicalData类型的可能改变operation中的data值,它具有最高的执行优先级。logical可能会修改库中的其它相关数据,它的优先级次高,而剩下三种checker都不应当对operation或者现有数据做任何改变。在Oak框架中它们的优先级定义如下:
| 类型 | 优先级 |
|---|---|
| logicalData | 31 |
| logical | 33 |
| row | 51 |
| data | 61 |
而默认的trigger优先级定为50,所以如果不手动定义priority,logicalData和logical类型的checker会在before类型的trigger之前执行,而另外三种checker会在trigger之后执行。
checker和trigger最重要的区别在于:trigger只会在后台(服务器端)执行,而checker在前端也一样可以执行。这就意味着,在Oak框架下,可以将对数据操作的检查进行唯一清晰化的表达,从而获得全局一致性的目标。
在前端快速判定操作权限
利用actions属性定义检查操作
在定义组件一章中,我们提到在 OakComponent 的配置项中,有一个名为 actions 的项,在其中可以定义并检查当前用户对当前数据的操作权限。
export default OakComponent({
entity: 'address',
isList: true, // 列表组件
actions: ['create', 'update'],
});
上面代码表示,要检查当前用户是否具有:
- 执行创建(满足filter约束条件的)address操作的权限
- 执行更新(查询出来的)address行的权限
检查结果会在formData的参数中以如下格式返回:
- 如果有对create动作的检查,在formData的参数中会有一个legalActions项,如果create动作通过检查会出现在其中;
- 如果有对其它动作的检查,在formData的参数data中的每一行返回数据中,会有一个#oakLegalActions项,其中包含了通过检查的动作。
所以在上面的例子中,可以像下面的代码一样来检查action是否可以执行:
formData({ data, legalActions }) {
const creatable = legalActions.includes('create');
const rows = data.map(
(row) => {
const { '#oakLegalActions': actions, ...rest } = row;
return {
...rest,
updatable: actions.includes('update');
}
}
);
return {
creatable,
rows,
};
}
如果是详情组件(参数中的isList声明为false),写法和上面类似,而在详情组件时,legalActions中就包含了对这行数据的判定结果了:
formData({ data, legalActions }) {
const updatable = legalActions.includes('update');
return {
updatable,
row: data,
};
}
actions检查逻辑
列表组件和详情组件中对actions的检查逻辑如下:
对查询出来的每一行数据,尝试当前用户去对之进行create以外的动作检查。检查的范围包括logical、relation和row三类checker。
对于create动作,系统会把List上的固定filter条件当成create的数据。例如,如果某个List上定义了如下的filter:
export default OakComponent({
entity: 'address',
projection: ...,
filters: [
{
filter() {
const userId = this.features.token.getCurrentUserId();
return {
entity: 'user',
entityId: userId,
};
}
}
],
isList: true, // 列表组件
actions: ['create', 'update'],
})
则框架知道在这个address的List上,有一个原生的过滤条件(地址指向当前用户),在测试是否可以create新地址时,就会把{entity: 'user', entityId: 'myUserId'}作为创建的数据进行判定。
List上哪些filter是原生过滤条件?目前只有在OakComponent中被声明的filter才被认为是列表的原生过滤条件,而在页面生命周期中通过addNameFilter接口动态添加上去的filter都会被忽略。
详情组件一般不对create动作加以判定,因为无法界定create动作的限制条件。
给actions增加默认数据
也可以对要检查的action赋予默认的data,如果确定在这个页面上执行此动作一定会赋予这些属性某些值的话。例如,假如我们创建的address一定会关联某一用户,就可以这样写(无需考虑这个需求是否合理):
export default OakComponent({
entity: 'address',
isList: true;
action: [{ action: 'create', data: { entity: 'user' }}],
formData({ data, oakLegalActions }) {
const creatable = oakLegalActions.find(
ele => ele.action === 'create',
);
....
}
});
也可以通过函数调用动态赋值,例如我们要创建的address一定会关联在当前用户上:
export default OakComponent({
entity: 'address',
isList: true;
action: () => {
return [
{
action: 'create',
data: {
entity: 'user',
entityId: this.features.token.getCurrentUserId(),
},
}
]
},
formData({ data, oakLegalActions }) {
const creatable = oakLegalActions.find(
ele => ele.action === 'create',
);
....
}
});
检查更新组件的操作权限
如果当前组件是更新组件,可以在formData中调用this.tryExecute接口来判定当前组件上的operation是否可以执行。
{
entity: 'address',
isList: false,
...
formData() {
const executable = this.tryExecute(); // true/false/Exception
return {
executable,
};
}
}
定义watcher
watcher 是 Oak 后端中的轮询执行器。它和 timer 最大的区别在于:watcher 的调度周期是框架固定的,AppLoader.startWatchers() 会在每一轮执行结束后,用 setTimeout(..., 120000) 再次调度下一轮,也就是每 120 秒执行一次。
因此,watcher 适合处理这类问题:
- 扫描数据库中“待补偿”的数据;
- 重试失败的异步任务;
- 对某些状态做持续收敛;
- 周期性处理“只要符合条件就应被处理”的数据。
watcher 的三种类型
在 oak-domain/src/types/Watcher.ts 中,Oak 定义了三种 watcher:
BBWatcher
最简单的一类 watcher。它不把查询结果交给自定义函数,而是按 filter 直接对目标实体执行固定的 action + actionData。
WBWatcher
最常见的一类 watcher。它会先按 filter + projection 查出数据,再把结果数组交给你写的 fn(context, data) 进行处理。
WBFreeWatcher
它和 WBWatcher 很像,也需要 entity、filter、projection,但执行函数拿到的不是现成 context,而是 builder: () => Promise<context>。这适合某些需要自行控制上下文生命周期的复杂逻辑。
编写位置
一般把 watcher 写在 src/watchers 目录中,并在 src/watchers/index.ts 中统一导出。例如 bm-smart 中:
const watchers = [
...shellMessageWatchers,
] as Watcher<EntityDict, keyof EntityDict, BackendRuntimeContext>[];
export default watchers;
一个典型的 WBWatcher
下面这个例子来自 bm-smart/src/watchers/shellMessage.ts,它会周期性重试发送超时的 Shell 消息:
{
name: '定期重试发送失败的Shell消息',
entity: 'shellMessage',
projection: {
id: 1,
data: 1,
action: 1,
shellId: 1,
error: 1,
},
filter: {
status: 'timeout',
retryOnTimeout: true,
ackAt: {
$exists: false,
}
},
lazy: true,
async fn(context, data) {
for (const msg of data) {
try {
await retryShellMessage(msg);
} catch (err) {
console.error(err);
}
}
return context.opResult;
},
}
这个例子几乎把 watcher 的典型写法都展示出来了:
- 用
filter限定待处理数据; - 用
projection只取处理所需字段; - 用
lazy: true避免应用刚启动时立刻跑一次; - 在
fn里显式处理每一条数据。
这里按行捕获异常,是因为该任务明确选择“一条失败不阻塞同批其他行”的部分进度策略;异常仍应记录并让失败行继续满足下轮 filter。普通 watcher 如果要求整批事务一致,不要 catch 后吞掉异常,应该让异常抛出,由 AppLoader 回滚并统一记录内部错误。
配置项说明
所有 watcher 都有的属性
| 属性 | 是否必填 | 说明 |
|---|---|---|
name | 是 | watcher 的唯一名字 |
entity | 是 | 要扫描的实体 |
filter | 是 | 查询或操作条件;WB 类型可写成异步函数,BB 类型只接受对象或同步函数 |
singleton | 否 | 集群环境下只允许一个实例执行 |
lazy | 否 | 启动后的第一轮跳过执行 |
WBWatcher / WBFreeWatcher 额外属性
| 属性 | 是否必填 | 说明 |
|---|---|---|
projection | 是 | 查询出的字段 |
fn | 是 | 处理函数 |
forUpdate | 否 | 查询时是否加锁,适合需要串行修改的场景 |
exclusive | 否 | 同一实例中跳过仍在处理的同一行;只支持 WB 类型 |
这里的 filter、projection、forUpdate 并不是 watcher 自己发明的新语法,而是直接复用 Oak 查询语法:
filter/projection的写法,参见查询和操作对象;forUpdate对应的就是SelectOption.forUpdate;- 如果 watcher 要筛一批待处理数据,再逐条更新,这里通常就应该考虑是否需要加锁。
BBWatcher 额外属性
| 属性 | 是否必填 | 说明 |
|---|---|---|
action | 是 | 对目标实体执行的动作 |
actionData | 是 | 固定写入的数据,也可以写成异步函数 |
BBWatcher 不支持 exclusive;即使配置,当前 AppLoader 也只会输出警告并忽略。需要按行排他处理时,应改用 WBWatcher 或 WBFreeWatcher。
执行函数长什么样
WBWatcher
fn: async (context, data) => {
// data 是查出来的多行结果
return context.opResult;
}
WBFreeWatcher
fn: async (builder, data) => {
const context = await builder();
// 自行控制 context 的使用
return context.opResult;
}
Oak 在执行 WBWatcher 时,会先用 filter 和 projection 做一次查询,然后把查询结果数组传给 fn。所以 watcher 的思考方式不是“监听事件”,而是“定期扫描待处理行”。
lazy、singleton、exclusive 的区别
这三个参数很容易混淆,但它们解决的是完全不同的问题:
lazy:应用刚启动后的第一轮先跳过;singleton:在多实例部署时,只让一个实例执行;exclusive:在同一个实例中,如果某条数据上一次还没处理完,本次不要并发重复处理。
例如 bm-smart/src/timers/index.ts 中的示例 timer 就使用了 exclusive: true,这类控制同样适用于 watcher。
什么时候该用 watcher
下面这些情况,通常就该优先想到 watcher:
- “数据库里只要有这种状态的数据,就要持续处理”
- “上一次失败了,后面要自动重试”
- “这个逻辑不要求精确到某个秒级 cron 点,只要求不断收敛”
如果你的需求是“每天凌晨 1 点一定执行一次”,那应该用 timer;如果你的需求是“某个事务提交后立即补偿”,那应该优先考虑 commit trigger。
使用 watcher 时的一个原则
watcher 最适合处理幂等、可重复扫描、允许延迟收敛的后台任务。
因为它本质上是轮询。如果你把一个必须“立刻且只执行一次”的动作塞进 watcher,最后往往会把系统设计得更复杂,而不是更简单。
定义aspect
如果说 trigger、checker 更像 Oak 的“底层规则”,那么 aspect 更像 Oak 的“命名业务接口”。
很多业务逻辑并不适合直接表达成某个实体上的一次 create/update/remove/select,例如:
- 需要跨多个实体聚合统计;
- 需要上传文件、下载流;
- 需要调用外部服务;
- 需要把若干次
select/operate封装成一个明确的业务动作。
这类逻辑最适合写成 aspect。
aspect 的类型定义
oak-domain/src/types/Aspect.ts 的底层接口为了容纳任意业务字典而保持宽泛,但应用层不应照搬这个宽泛签名。项目应在 AspectDict.ts 中为每个方法声明具体参数和返回值,例如:
export type AspectDict = {
getLicenseSecretKey: (
params: { licenseId: string },
context: BackendRuntimeContext
) => Promise<string>;
};
也就是说,一个 aspect 本质上就是:
- 一个名字;
- 一个具有明确参数、context 和返回结果的异步函数。
编写位置
通常你会在 src/aspects 下面写两类文件:
AspectDict.ts:定义 aspect 的类型签名,方便前后端获得类型提示;index.ts:真正导出具体实现。
AspectDict.ts 中的声明只提供 TypeScript 调用合同,并不会注册运行时实现。每个需要从前端调用的名称,都必须同时出现在 src/aspects/index.ts 的默认导出对象中;否则代码可以通过类型检查,但服务端的 aspect 分发找不到该实现。
一个实现例子
const aspectDict: AspectDict = {
getLicenseSecretKey: async (params: { licenseId: string }, context) => {
const licenseRows = await context.select('license', {
data: {
secretKey: 1,
},
filter: {
id: params.licenseId,
}
}, { dontCollect: true });
const license = licenseRows?.[0];
if (!license?.secretKey) {
throw new OakUserException(
'error::license.secretKeyNotFound',
'project-name',
{ licenseId: params.licenseId }
);
}
return license.secretKey;
},
};
这个 aspect 做的事情非常典型:
- 输入是一个业务参数对象;
- 在后台上下文中执行若干次查询;
- 根据业务条件抛出 Oak 异常;
- 返回最终业务结果。
aspect 如何被执行
在服务端,oak-backend-base/src/AppLoader.ts 中的 execAspect() 会负责:
- 创建并初始化后台
context; - 找到对应名称的 aspect 函数;
- 执行 aspect;
- 调用
context.refineOpRecords(); - 提交事务;
- 返回
{ result, opRecords, message }。
因此,aspect 并不是一个“裸函数调用”,而是 Oak 事务体系中的一个标准入口。
aspect 和 endpoint 的区别
这两个概念经常被新手混在一起。
可以先这样理解:
aspect:Oak 内部命名业务接口,通常由 Oak 的 connector / cache / feature 体系去调用;endpoint:更贴近 HTTP 层,直接暴露为路由入口。
如果一个能力主要服务于 Oak 自己的前端运行时,那么优先写成 aspect;如果一个能力本来就是开放 HTTP 接口,或者需要处理下载流、第三方回调等更底层的请求细节,再考虑 endpoint。
什么时候该写 aspect
适合写成 aspect 的情况通常有:
- 一次业务动作涉及多个实体;
- 逻辑不适合挂在单一实体的
action上; - 需要返回聚合结果,而不是单纯增删改查;
- 需要把一段复杂服务逻辑封装成前端可直接调用的方法。
例如,一个管理后台总览 aspect 可以一次性统计多个实体并返回一个明确的汇总结构,而不必让前端分别发起多次底层查询。
在前端如何使用 aspect
Oak 前端通常通过两种方式间接使用 aspect:
- 直接使用
cache.exec('aspectName', params); - 在
feature中通过createService(...)再包一层,更方便组件调用。
当前 oak-general-business/template/featuresIndex.ts 也使用这一模式:
const aspect = createService<EntityDict, MergeAspectDict>(cache);
这样一来,前端就可以把 aspect 当成一个有类型的服务对象来使用。
使用 aspect 时的一条经验
aspect 很适合表达“一个完整业务动作”,但不适合承载系统底层一致性规则。
换句话说,aspect 可以组织流程,但不要拿它替代 checker 和 trigger。否则一旦这个流程之外还有别的入口触发同样的数据变化,规则就会失效。
定义timer
timer 是 Oak 中按 cron 调度执行的后台任务。它和 watcher 的区别非常明确:
watcher:固定每 120 秒轮询一次;timer:由你自己定义 cron 表达式,交给node-schedule调度。
所以,只要你对执行时间点有明确要求,例如“每小时整点”“每天凌晨”“每 5 分钟”,就应该优先使用 timer。
timer 的类型
oak-domain/src/types/Timer.ts 中把 timer 定义成了三种可能:
type Timer =
| BaseTimer
| FreeTimer
| (Watcher & { cron: ... });
这意味着 Oak 中的 timer 其实有三种写法。
第一种:BaseTimer
最直接的写法。你提供一个 timer(context) 函数,Oak 在 cron 到点时创建 context 并执行它。
第二种:FreeTimer
如果你希望自己决定何时创建 context,可以使用 type: 'free' 的形式,此时执行函数会收到 builder: () => Promise<context>。
第三种:Watcher 风格的 timer
这也是实际项目里非常常见的一种写法。它直接复用完整的 Watcher 联合类型,再额外增加一个 cron,因此可以是:
BBWatcher:按entity/filter/action/actionData直接执行一次 operate;WBWatcher:按entity/filter/projection查询后,把结果交给fn(context, data);WBFreeWatcher:设置type: 'free',查询后把 context builder 和数据交给fn(builder, data)。
不能只根据是否存在 projection/fn 判断 timer 是否为 watcher 风格;运行时以是否存在 entity 为准,并交给和普通 watcher 相同的 execWatcher() 执行路径。
编写位置
一般把 timer 写在 src/timers 目录下,并在 src/timers/index.ts 里统一导出。
bm-smart/src/timers/index.ts 中就导出了一个 timer 数组,其中既有示例 timer,也合并了 shellException、postApplyment 等分模块 timer。
一个真实例子
下面这个例子来自 bm-smart/src/timers/license.ts:
const timers: Array<Timer<EntityDict, 'license', BackendRuntimeContext>> = [
{
name: '定期过期license',
cron: cronMap[process.env.NODE_ENV || 'development'],
entity: 'license',
filter: async () => {
return {
expired: false,
expiredAt: {
$lte: Date.now()
}
}
},
projection: {
id: 1,
},
fn: async (context, data) => {
const ids = data.map(item => item.id!);
if (ids.length === 0) {
return context.opResult;
}
await context.operate('license', {
id: await generateNewIdAsync(),
action: 'expire',
filter: {
id: {
$in: ids
}
},
data: {}
}, {});
return context.opResult;
},
},
];
这个 timer 的意思非常清楚:按照不同环境的 cron 周期,定期扫描已经到期但尚未标记过期的 license,再统一执行 expire 动作。
AppLoader 是如何执行 timer 的
在 oak-backend-base/src/AppLoader.ts 的 startTimers() 中,框架会:
- 读取
lib/timers/index; - 对每个 timer 调用
scheduleJob(name, cron, ...); - 到点后根据 timer 类型执行:
BaseTimer走timer(context);FreeTimer走timer(builder);- 如果 timer 具备
entity,就按 watcher 的方式执行。
因此,timer 只是“调度层”不同,真正的数据处理方式仍然沿用了 Oak 已有的上下文和 watcher 能力。
三种 timer 执行失败时,AppLoader 都会记录错误并调用 publishInternalError('timer', ...)。Watcher 风格 timer 复用 watcher 的事务处理;BaseTimer 由 loader 创建 context,成功时提交,失败时回滚(OakPartialSuccess 除外);FreeTimer 的 context 生命周期由实现通过 builder 自行管理。不要为了让调度继续运行而在 fn 中笼统捕获并吞掉异常,否则 loader 会把本次 watcher context 当作成功提交。
常用属性
| 属性 | 是否必填 | 说明 |
|---|---|---|
name | 是 | timer 名称,必须唯一 |
cron | 是 | cron 表达式,也可以是 Date、number、RecurrenceRule |
singleton | 否 | 集群环境下只允许一个实例执行 |
如果你用的是 watcher 风格 timer,还可以继续使用:
- 三种 watcher 都有:
entity、filter、singleton、lazy; BBWatcher使用:action、actionData;WBWatcher/WBFreeWatcher使用:projection、fn、forUpdate、exclusive;WBFreeWatcher还必须使用type: 'free'。
虽然当前底层 BBWatcher 类型仍带有 exclusive 字段,但 AppLoader 会警告并忽略它,因此不要在 BB 形态中配置 exclusive。Watcher 类型也没有 sorter、count、indexFrom 等字段;需要排序或截断时,应在 fn 中明确处理查询结果。
这里的 filter、projection、forUpdate 也不是 timer 自己的一套新语法,而是直接复用 watcher / 查询章节里的同一套定义:
filter/projection的写法,参见查询和操作对象;forUpdate的含义,和 watcher 里一样,适合“先扫出来,再逐条改”的串行处理场景。
timer 和 watcher 应该怎么选
一个简单判断就够了:
- “到某个时间点必须执行” ->
timer - “只要库里有这种状态的数据,就应持续扫描处理” ->
watcher
例如:
- 每晚 2 点同步一次第三方目录:更适合
timer - 每隔一会儿重试发送失败消息:更适合
watcher
一个实践建议
timer 更适合做“触发”,而不是做“无限复杂的大任务”。
如果某个任务非常重,通常更好的做法是:timer 只负责按时挑出待处理数据,再通过状态位、分批处理、幂等动作等方式,把复杂工作拆开,而不是把整个大流程都塞进一次 cron 回调里。
定义routine
routine 是 Oak 中“在应用启动或停止时执行一次”的例程。
它和 watcher、timer 的区别在于:
watcher:周期轮询;timer:cron 调度;routine:只在启动或停止阶段执行一次。
因此,routine 非常适合处理这类工作:
- 启动时建立外部连接;
- 启动时初始化某些内存结构或索引;
- 停止前释放资源;
- 停止前做收尾动作。
routine 写在哪里
Oak 约定把 routine 分成两类文件:
src/routines/start.tssrc/routines/stop.ts
oak-backend-base/src/AppLoader.ts 会分别在应用启动和停止时调用:
execStartRoutines()execStopRoutines()
routine 的类型
oak-domain/src/types/Timer.ts 中定义:
type Routine = FreeRoutine | Watcher;
也就是说,routine 既可以写成自由函数式的启动例程,也可以直接复用 watcher 风格。
FreeRoutine
这种形式最适合做纯初始化或纯收尾逻辑:
{
name: '初始化mqtt客户端连接',
routine: async (context, env) => {
init(env.contextBuilder);
return context.opResult;
},
}
这里的 env 是 RuntimeRoutineEnv,当前包含:
socketcontextBuilderregisterTrigger/unregisterTriggerregisterChecker/unregisterCheckerregisterWatcher/unregisterWatcherregisterTimer/unregisterTimer/rescheduleTimer
所以 routine 不仅能拿到当前 context,还能管理应用运行期间动态加入的规则和后台任务。例如,启动 routine 可以根据数据库状态注册 timer,停止 routine 再注销对应任务。动态注册的对象仍须符合各自完整类型,不能通过补充未声明字段改变 loader 行为。
Watcher 风格 routine
如果你要在应用启动时,针对某类实体先扫一遍数据再处理,也可以直接写成 watcher 风格。
bm-smart/src/routines/start.ts 中就有这样一个例子:
{
name: '初始化所有系统的Shell的Exception向量化索引',
entity: 'system',
projection: systemProjectionForFaissSync,
filter: {},
type: 'base',
fn: syncExceptionVectors,
}
这说明 routine 不是只能做“无数据上下文”的初始化,它同样可以利用 Oak 已有的实体扫描与处理能力。
Watcher 风格 routine 复用完整的 Watcher 联合类型,因此既可以是直接 operate 的 BBWatcher,也可以是 WBWatcher / WBFreeWatcher,并不固定为“先查询再调用普通 context fn”。
一个真实的启动/停止例子
bm-smart 里的 start/stop routines 很适合作为理解样板:
启动
{
name: '初始化mqtt客户端连接',
routine: async (context, env) => {
init(env.contextBuilder);
return context.opResult;
},
}
停止
{
name: '关闭mqtt客户端连接',
routine: async (context, env) => {
await dispose();
return context.opResult;
},
}
从这个例子你会发现,routine 最擅长的其实就是“把系统外围资源的生命周期,接到 Oak 应用生命周期上”。
AppLoader 会串行执行 start/stop routine。自由函数式 routine 由 loader 创建 context,成功后提交、失败后回滚;Watcher 风格 routine 使用 watcher 自己的事务路径。任意一项失败都会继续向上抛出,而不是记录后忽略,因此启动例程失败会阻止正常启动流程继续完成,停止例程也不应假定后续项一定执行。
routine 应该做什么,不该做什么
适合写进 routine 的事情:
- 初始化 MQTT、消息队列、外部 SDK;
- 预热缓存;
- 建立长连接;
- 做一次启动时的全量扫描;
- 停止前释放连接或落盘。
不太适合写进 routine 的事情:
- 需要反复执行的任务;
- 必须按 cron 定时执行的任务;
- 每次业务操作都应触发的规则。
换句话说,routine 解决的是应用生命周期问题,而不是业务动作问题。
一个经验
只要你在想“这段逻辑应该在服务启动时主动跑一次”,就应该先想到 routine。
这比把初始化逻辑散落到各种 feature、aspect 或全局脚本里,要更符合 Oak 本身的运行模型。
定义port
port 是 Oak 对批量导入导出能力的抽象。它的目标非常明确:把 Excel 之类的结构化数据交换,也纳入 Oak 的实体和上下文体系,而不是在项目里到处散落手写上传脚本和导出脚本。
如果你的系统里存在下面这些需求,通常就适合用 port:
- 下载一个标准导入模板;
- 上传 Excel 后解析成某个实体的批量创建数据;
- 按过滤条件导出某类实体的数据;
- 让导入导出过程也走 Oak 的权限、上下文和事务体系。
port 的两种定义
oak-domain/src/types/Port.ts 定义了两种 port:
Importation
type Importation = {
name: string;
id: string;
entity: T;
headers: K[];
fn: (data, context, options?) => Promise<CreateMulti['data']>;
};
它负责把上传表格中的每一行,解析成 Oak create 所需的数据。
Exportation
type Exportation = {
name: string;
id: string;
entity: T;
projection: Projection;
headers?: K[];
fn: (data, context?, properties?) => Promise<FormatDataResult[]>;
};
它负责把 Oak 查询出来的实体数据,格式化成导出表格的一行行对象。
需要注意:虽然当前 Exportation 类型仍把 headers 标成可选,oak-common-aspect 的 exportEntity() 在运行时要求它是非空数组,否则会抛出前置条件异常。因此,现有项目定义导出 port 时应始终提供 headers,不要把类型层面的可选理解成运行时也可省略。
编写位置
一般在 src/ports/index.ts 中统一导出:
export const importations: Importation<...>[] = [
];
export const exportations: Exportation<...>[] = [
];
没有导入导出需求时,这两个数组可以为空;有需求时应让每个 id 在全部应用及依赖 port 中保持唯一。挂载阶段发现重复 importation/exportation id 会直接抛错。
port 在运行时如何生效
在服务端,oak-backend-base/src/AppLoader.ts 在挂载应用时会收集应用及依赖包的 port 配置,然后调用 registerPorts(importations, exportations)。正常安装并完成 Oak 依赖初始化后,不需要把业务依赖包的 port 再手工复制到应用数组中。
真正的导入导出实现位于 oak-common-aspect/src/port.ts:
importEntityexportEntitygetImportationTemplate
也就是说,port 最终是以 Oak 内置 aspect 的形式暴露出来的。
导入是怎么执行的
oak-common-aspect/src/port.ts 中的 importEntity() 大致流程是:
- 根据传入的
id找到对应Importation; - 用
xlsx读取上传的 Excel; - 把每个 sheet 转成 JSON 行数据;
- 调用你定义的
importation.fn(...)把这些行转成 Oakcreate数据; - 按
chunkSize分块; - 用
context.operate(entity, { action: 'create', data: chunk })批量写入。
因此,真正需要你关心的核心只有一件事:
如何把导入文件中的一行,准确翻译成 Oak 实体上的创建数据。
如果解析过程中出现具体某一行的错误,按框架约定,应抛出 OakImportDataParseException,这样前端才能得到明确的行号和表头信息。
导出是怎么执行的
exportEntity() 的流程则是:
- 根据
id找到对应Exportation; - 用你定义的
projection分页查询实体数据; - 调用
exportation.fn(...)把查询结果转换成导出行; - 用
xlsx生成 workbook 并返回。
导出时,Oak 还支持:
maxCount:最大导出条数,当前默认10000;count:每次分页查询条数,当前默认1000;checked:是否在导出前查询总量并在超限时抛错,当前默认false。
当 checked 为 false 时,maxCount 仍然生效,但超出的结果会被截断,而不是报错。count 和 maxCount 都必须大于 0。
这里还有一个非常值得直接说清楚的点:Exportation.projection 和前端调用 exportEntity(entity, id, filter, ...) 时传入的 filter,都直接复用 Oak 查询语法本身。
也就是说,平时你在列表页、aspect、watcher 里能写的那套:
- 级联 projection;
- 父对象 / 子对象 filter;
#sqp;$expr;- JSON filter;
放到导出链路里理解方式也是一样的。导出并不是另一套 DSL,它只是把“查询结果 -> 表格行”的这一步标准化了。
获取导入模板
Oak 还内置了 getImportationTemplate。它会根据 Importation.headers 直接生成一份只有表头的 Excel 模板。
这意味着,一个导入功能的最小闭环通常是:
- 定义
headers; - 提供模板下载;
- 提供导入解析;
- 提供错误定位。
而这些能力都可以通过同一个 port 定义串起来。
前端如何调用
Oak 前端已经内置了 Port feature,定义在 oak-frontend-base/src/features/port.ts 中。它暴露了三个方法:
importEntity(entity, id, file, options?, s2jOpts?)exportEntity(entity, id, filter?, properties?, options?)getImportationTemplate(id)
因此,port 的完整链路其实是:
- 后端定义 import/export 规则;
- 前端通过
features.port直接调用; - 中间过程由 Oak 公共 aspect 接管。
什么时候该用 port,而不是 aspect
如果只是一次普通上传,或者你根本不需要“模板 + 批量解析 + 批量创建 + 标准导出”,那直接写 aspect 往往更简单。
但只要你的需求是“一个规范化、可复用、可下载模板的批量导入导出功能”,port 就会比手写 aspect 更自然。
定义feature
feature 是 Oak 前端运行时中一个非常重要、但又很容易被忽视的概念。
简单地说,feature 就是被多个组件共享的前端状态与服务对象。它通常用来封装:
- 某类前端状态;
- 一组对 cache / localStorage / token / aspect 的组合调用;
- 某种可跨页面复用的业务工具能力。
如果说 aspect 更像“后端业务服务入口”,那么 feature 更像“前端业务服务对象”。
feature 不是业务一致性层
首先要建立一个很重要的边界:
feature 只运行在前端,不承担系统底层一致性职责。
这意味着:
- 需要保证正确性的约束,仍然应该写在
checker/trigger/ 权限体系里; - feature 负责的是前端组织、状态复用和调用便利性。
Oak 自带的基础 features
oak-frontend-base/src/initialize.ts 负责创建基础 features。当前 Oak 生成的 src/initialize.server.ts 再按依赖组合结果合入依赖 features,最后调用项目 src/features/index.ts 的 create(...) 并合入项目 features。旧的 initialize.frontend.ts 只用于历史 DebugConnector 场景。
这些基础 features 里,最重要的包括:
cacherunningTreelocalStoragelocalesmessagenotificationnavigatorportlocationenvironmentstylethemegeocontextMenuFactorysubscribersocket
因此,很多业务 feature 实际上就是在这些基础能力之上再做一层更贴近业务语义的封装。
自定义 feature 写在哪里
通常有两个关键文件:
src/features/index.tssrc/initializeFeatures.ts
src/features/index.ts
这里负责创建 feature 实例。当前 oak-general-business/template/featuresIndex.ts 给出的项目模板是:
export function create(features: BasicFeatures<EntityDict> & Ogb0FeatureDict<EntityDict>) {
const { cache, localStorage, token } = features;
const sample = new Sample(cache);
const console = new Console(cache, localStorage, token, () => ({
id: 1,
name: 1,
}));
const aspect = createService<EntityDict, MergeAspectDict>(cache);
return {
sample,
console,
aspect: aspect as typeof aspect & Feature,
};
}
从这个例子中可以看到,自定义 feature 的常见依赖来源主要是:
cachelocalStoragetoken- 依赖模块提供的 feature
src/initializeFeatures.ts
这里负责初始化 feature。当前 general-business 项目模板中会初始化依赖 feature,并加载项目 locale:
export default async function initialize(features: FeatureDict & BasicFeatures<EntityDict> & Ogb0FeatureDict<EntityDict>) {
await initializeOgb0Features(features, accessConfiguration, undefined, [Qiniu]);
features.locales.loadServerData(['projectName-l-common', 'projectName-l-error', 'projectName-l-menu']);
}
这个文件的职责通常包括:
- 调用依赖模块的
initialize(...); - 注册 selection / operation rewriter;
- 加载服务端 i18n 数据;
- 注册文件存储、SDK、全局配置等前端启动逻辑。
什么时候应该抽一个 feature
下面这些情况,通常值得单独抽 feature:
- 一段逻辑会被多个页面复用;
- 这段逻辑依赖多个基础 feature 组合调用;
- 这段逻辑有自己的状态,需要跨组件共享;
- 你不希望把复杂调用链直接堆在组件里。
例如 oak-general-business/src/features/index.ts 中,就把 token、application、extraFile、wechatSdk、humanVerify、invite 等能力封装成了 feature。theme 则是 oak-frontend-base 的基础 feature,不属于 general-business。组件层只需要使用合并后的 feature 字典,不需要知道内部具体如何操作 cache 或 localStorage。
在组件里如何使用 feature
OakComponent 本身就支持声明依赖的 features,并且组件实例上可以通过 this.features.xxx 访问。
同时,在 formData 中也可以拿到:
formData({ data, features }) {
return {
rows: data,
currentUserId: features.token.getCurrentUserId(),
};
}
因此,feature 是 Oak 组件和运行时能力之间最自然的桥梁。
一个很实用的经验
当你发现一个组件里开始出现下面这种代码味道时:
- 同时操作
cache、localStorage、token; - 同样一段查询和转换在多个页面里重复;
- 一大段“为了页面方便”而写的业务工具函数堆在组件文件里;
这通常就意味着,你应该把它抽成一个 feature 了。
feature 和 aspect 的关系
二者最常见的搭配方式是:
aspect负责后端业务入口;feature负责前端如何组织和调用这些入口。
这是一种非常自然的分层:
- 后端复杂动作写成 aspect;
- 前端再把这些 aspect 包装成更顺手的业务对象。
这样组件代码会轻很多,也更容易维护。
其它内容
前面几节介绍的,主要是 Oak 中最核心的“对象、组件、业务逻辑”三大部分。但真正把一个 Oak 应用写顺手,还需要再理解几组非常重要的基础概念:
- 上下文
context - 异常
exception - 权限
permission - 国际化
i18n - 前端图标库
OakIcon - 前后端配置
configuration
这些内容并不一定是你第一天就会大量手写的代码,但几乎会出现在 Oak 的每一个关键运行环节里。
为什么它们重要
上下文
Oak 中几乎所有后端逻辑,最终都落在某个 context 上执行。没有它,就没有事务、当前用户、root 身份、操作记录这些能力。
异常
Oak 不鼓励把错误都写成随意的 throw new Error(...)。框架定义了一整套异常体系,用来表达输入非法、权限不足、数据不可见、外部接口失败等不同语义,并且还能把必要的数据同步回前端缓存。
权限
Oak 的权限模型不是简单的“某个角色能访问某个页面”,而是围绕实体关系、对象路径、动作授权和用户关系来推导的。这是 Oak 和很多传统业务框架差异最大、也是最值得认真理解的一部分。
国际化
Oak 的 i18n 不是某个孤立前端插件,而是进入了整个项目和依赖模块的编译流程。页面、组件、模块、实体都可以把本地化资源统一编译进入运行时。
前端图标库
Oak 的前端图标库不是单纯在 React 页面里 import 一个图标组件,而是让页面配置、菜单配置、web 运行时和小程序编译都能复用同一套字符串图标名。OakIcon 负责运行时渲染,oak-cli 负责在编译时处理 web 按需加载和小程序 font 图标样式。
前后端配置
Oak 的配置文件并不是只服务某一端。src/configuration/access.ts 决定前端 connector 如何访问后端,src/configuration/server.ts 决定后端如何启动和暴露,页面和命名空间的 index.config.ts 又会进入 web 路由、菜单和访问控制生成流程。理解这些入口,才能把本地开发、预发部署、生产代理和页面权限排查清楚。
阅读这一节时的建议
这一节不需要你一开始死记所有细节,但最好先建立几个认识:
context是 Oak 运行时的载体;- 异常有明确语义,不只是字符串;
- 权限是数据化、可推导的;
- i18n 是编译期和运行时共同参与的一套机制;
- 图标配置要保持字符串化和可序列化,让
oak-cli能参与编译优化; - 前后端配置要分清网络访问、后端启动和页面访问控制三条线。
一旦把这些点想明白,后面再去看 Oak 项目的源码,就会顺畅很多。
上下文
在 Oak 中,context 可以理解为“当前这次逻辑执行所依赖的运行时环境”。
你在 aspect、trigger、checker、watcher、timer、routine 里拿到的那个 context,并不只是一个数据库操作对象,它同时还携带了:
- 当前事务信息;
- 当前用户信息;
- 当前是否处于 root 模式;
- 当前操作产生的 opRecords;
- 当前 locale;
- 异常推导与消息传递能力。
最基础的 Context 接口
oak-domain/src/types/Context.ts 中,最基础的公共接口只有几个方法:
export interface Context {
getCurrentTxnId(): string | undefined;
getCurrentUserId(allowUnloggedIn?: boolean): string | undefined;
isRoot(): boolean;
allowUserUpdate(): boolean;
toString(): Promise<string>;
}
这几个方法虽然不多,但已经把 Oak 上下文最核心的身份说清楚了:
- 它属于某个事务;
- 它知道当前用户是谁;
- 它知道当前是否有 root 权限;
- 它可以被序列化。
实际项目中的 context 会更强
在真实项目中,你拿到的通常不会只是这个最小接口,而是更具体的运行时上下文实现,例如:
- 前端的
FrontendRuntimeContext - 后端的
BackendRuntimeContext - 项目自己扩展出来的
src/context/BackendRuntimeContext
这些具体 context 还会继续提供:
selectoperatecountaggregatebegincommitrollbackgetLocale
也就是说,Oak 中真正的业务逻辑,大多数都是“在 context 上操作”的。
当前 SerializedData、BackendRuntimeContext 和 FrontendRuntimeContext 都已经带上 locale。需要按当前语言做翻译、日志、通知或多语言内容生成时,不要再通过零散参数传递语言,优先从 context 取得。
这里和查询最相关的一点是:
select/aggregate的语法,请直接以查询和操作对象里的Selection、Projection、Filter、Sorter为准;count适合“只要数量,不要行数据”的场景;- 如果要查软删除数据或加锁查询,则要在第三个参数里带
includedDeleted、forUpdate这类SelectOption。
为什么 Oak 要强调 context
因为 Oak 并不希望你把“当前用户”“当前事务”“当前缓存状态”“当前异常同步”等信息,到处通过函数参数手工传递。
框架的做法是:把这些信息统一放进 context 中,让所有运行时逻辑都在同一个载体上工作。这样才能保证:
- 前后端上下文语义一致;
- 一次业务动作中的多次操作共享同一事务;
- 异常和 opRecords 可以顺着上下文回流。
可复用业务库怎样贡献 Context
普通应用只消费最终 Context;真正需要定义 backendContextLayer 和 backendContextModule 的,是会向下游项目贡献后端 Context 能力的 Oak 业务库。前端完全对称,名称换成 frontendContextLayer 和 frontendContextModule。
这两个对象都放在原来的 src/context/BackendRuntimeContext.ts 中:
backendContextLayer是文件内私有常量,描述本库新增了哪些字段、方法和生命周期行为;backendContextModule是具名导出,声明模块身份、Context 依赖和 layer;- 原
BackendRuntimeContext类及其默认导出继续保留,业务代码不需要改去新的 Contract 或 Module 文件; - 普通应用不定义 module 描述,应用的
generatedBackend.ts负责组装最终运行时 class; - 没有
BackendRuntimeContext.ts的纯实体库会被自动忽略,不需要创建空 Context 文件。
普通业务库的定义方式
defineContextLayer<Requires, Provides> 的第一个类型参数表示传入 Base 必须已经具备的能力,第二个参数表示本 layer 新增的能力;第二个参数省略时默认为空对象。现有大型 RuntimeContext 为了兼容可以像 general/pay 一样直接用最终 Context 类型作为第一个参数。defineContextModule<Contract> 的 Contract 则表示下游从整个 module 获得的最终 Context 类型。
业务库自己的 BackendRuntimeContext 继承 make:dep 生成的 generatedBackend。如果本库确实增加运行时结构,用 apply 返回一个继承传入 Base 的 class;需要参加公共生命周期的方法,用 hooks 或 reducers 描述:
import {
defineContextLayer,
defineContextModule,
} from '@oak-domain/context/ContextComposer';
import {
backendContextModule as parentBackendContextModule,
} from '@parent-business/context/BackendRuntimeContext';
import GeneratedBackendRuntimeContext from './generatedBackend';
export class BackendRuntimeContext<ED extends EntityDict & BaseEntityDict>
extends GeneratedBackendRuntimeContext<ED>
implements RuntimeContext {
protected applicationProjection = projection;
}
type BusinessBackendContext = BackendRuntimeContext<
EntityDict & BaseEntityDict
>;
const backendContextLayer = defineContextLayer<BusinessBackendContext>({
apply: (Base) => {
abstract class BusinessBackendContextLayer extends Base {
protected applicationProjection = projection;
}
return BusinessBackendContextLayer;
},
hooks: {
refineOpRecords: {
handler: async function () {
await refineBusinessOpRecords(this);
},
},
},
members: {
applicationProjection: {
kind: 'field',
policy: 'replace',
},
},
});
export const backendContextModule =
defineContextModule<BusinessBackendContext>({
id: 'oak-business-name',
version: '1',
dependencies: [parentBackendContextModule],
layer: backendContextLayer,
});
export default BackendRuntimeContext;
apply 收到的是已经组合好依赖模块的 Context class。必须基于这个 Base 派生并返回 class,不能在这里重新继承某个父业务库的 RuntimeContext,也不能自己构造依赖 Context。像示例中的公共处理逻辑,最好抽成 helper,供原 RuntimeContext class 和 layer hook 共同调用,避免两条入口的行为漂移。
如果一个库在某一端没有新增成员或行为,只需要传递 Context 依赖,module 可以没有 layer。例如只依赖父前端 Context 的库可以只导出:
export const frontendContextModule = defineContextModule<BusinessFrontendContext>({
id: 'oak-business-name',
version: '1',
dependencies: [parentFrontendContextModule],
});
base 只用于兼容根层
base 不是普通业务库的写法。它用于把已有的大型 RuntimeContext class 整体接入组合系统,例如 oak-general-business:
const backendContextLayer = defineContextLayer<GeneralBackendContext>({
base: BackendRuntimeContext,
members: {
application: { kind: 'field' },
applicationProjection: {
kind: 'field',
policy: 'replace',
},
initialize: {
kind: 'method',
policy: 'series',
},
getSerializedData: {
kind: 'method',
policy: 'reduce',
},
},
});
一次组合最多只能有一个 base layer,而且这个兼容 layer 不能再依赖另一个带 layer 的 Context 模块。新业务库应使用 apply、hooks 和 reducers,不要继续增加 base。
成员策略
字段必须在 members 中显式声明。apply 返回 class 的自有原型方法能够被发现,但只要方法需要组合语义,也应明确声明或改用 hook/reducer。
| policy | 语义 |
|---|---|
unique | 默认值。整个依赖图中只允许一个模块拥有这个字段或方法。 |
replace | 覆盖祖先提供的同名成员。新模块必须传递依赖旧 owner,且双方都声明 replace;两个兄弟模块不能互相覆盖。最终只使用后代实现。 |
final | 表示方法不允许再被覆盖或组合;再次出现同名成员就是冲突。字段不能声明 final。 |
series | 一个生命周期方法需要让多个模块都执行。基础方法只执行一次,其余贡献通过 hooks 串行运行。 |
reduce | 多个模块依次变换同一个返回值。先调用基础方法,再把结果依次传给 reducers。 |
hooks 会自动把同名方法登记为 series,reducers 会自动登记为 reduce,因此同一个方法不能又写进 members。hook 的 handler 需要访问最终实例时,要使用 function 和 this,不要使用没有动态 this 的箭头函数。
hook 支持两个顺序维度:
order默认为forward,按依赖优先顺序执行;清理类行为可用reverse,按依赖的反方向执行;phase默认为after,即先执行原方法再执行 handlers;设为before时 handlers 先执行;- 同一方法的所有 hooks 必须使用相同的
order和phase。
reducer 的 order 同样默认为 forward,但没有 phase:它总是先取得原方法返回值,再串行交给各 reducer。一个模块如果需要稳定地排在另一个模块前后,必须通过 dependencies 表达;不要依赖两个无关兄弟模块的偶然顺序。
backendContextModule 的约束
id 通常使用 npm 包名,必须稳定且非空;version 表示这份 Context 组合合同的版本。dependencies 必须引用依赖库同一端 RuntimeContext 文件导出的 module,例如 backend 只引用 backendContextModule。依赖图会先执行依赖、后执行当前模块,并在菱形依赖中按 module id 去重。
运行时组装会拒绝依赖成环、同 id 的不一致版本或定义、多个 base、未返回 class 的 apply、非法覆盖、字段/方法冲突,以及 hook/reducer 顺序不一致。异常类型为 OakContextCompositionException,错误信息会明确列出冲突的两个模块,并带有“请联系管理员”。这些检查发生在最终 Context class 被动态创建时,不会静默选择某个实现。
生成与迁移
src/context/generatedBackend.ts 和 generatedFrontend.ts 由 make:dep 重建,不要手工修改。业务库中的 generated 文件提供依赖能力的合并类型,使本库源码可以调用父 Context 方法;真正的依赖运行时仍由最终应用一次性组合。应用只需要声明直接依赖,例如只依赖 pay 时,pay module 会继续带出 general module。
旧项目先执行 make:dep,再运行:
oak-cli migrate context
迁移器会把可确认的直接父类继承改成 generated Context 继承,并保留原 RuntimeContext 文件。局部 mixin、factory 或手工 composeContextModules(...) 等无法安全判断的写法需要人工审阅。
在哪里会接触到 context
你几乎会在 Oak 的所有运行时逻辑里接触到它:
aspect
getLicenseSecretKey: async (params, context) => {
const rows = await context.select('license', {
data: { secretKey: 1 },
filter: { id: params.licenseId },
}, {});
const secretKey = rows[0]?.secretKey;
if (!secretKey) {
throw new OakUserException(
'error::license.secretKeyNotFound',
'project-name',
{ licenseId: params.licenseId }
);
}
return secretKey;
}
checker
checker: (operation, context) => {
const userId = context.getCurrentUserId();
}
watcher
fn: async (context, data) => {
await context.operate(...);
return context.opResult;
}
root 模式
context.isRoot() 是 Oak 中非常关键的判断。很多系统级初始化、静态数据装载、超级管理员能力,都会依赖 root 模式。
例如 AppLoader.initialize() 在初始化静态数据时,就会调用 context.openRootMode()。
这意味着:
- 正常业务代码默认不应假设自己处在 root 模式;
- 只有系统初始化或极少数管理逻辑才应显式使用它。
不要自己随意创建 context
在 Oak 中,context 的生命周期通常由框架管理:
AppLoader在后端请求入口、aspect、endpoint、timer、routine 中创建 context;cache和 connector 在前端调用链里维护前端上下文。
因此,除非你非常清楚自己在做什么,否则不要把 context 当作普通类随便 new 出来。更推荐的做法是:
- 在框架给你的 context 上继续工作;
- 或者在
WBFreeWatcher/FreeTimer中使用框架提供的contextBuilder();FreeRoutine则从第二个参数env.contextBuilder获取。
一个理解上下文的方式
你可以把 Oak 的 context 粗略理解成:
“带有事务、身份、权限和操作记录能力的业务执行现场”。
一旦这样理解,后面再去看 Oak 里的各种逻辑类型,就会发现它们其实都只是“在不同阶段、以不同方式使用 context”而已。
异常定义
Oak 的异常体系非常重要,因为它不只是为了报错,更是为了让前后端都能理解“这次失败到底属于哪一种业务语义”。
如果你在 Oak 项目里把所有错误都写成普通 Error,短期内也许能跑,但你会很快失去:
- 明确的业务语义;
- 前端可识别的错误类型;
- opRecords 同步能力;
- 统一的提示处理方式。
OakException 是根
oak-domain/src/types/Exception.ts 中,所有 Oak 异常都继承自 OakException。
OakException 除了 message 之外,还会携带:
opRecords_moduleparams
这意味着,一个异常本身也可以带着“为了修正前端缓存而需要同步的数据”一起返回。
最重要的一层:OakUserException
从实际开发角度看,最重要的父类其实是 OakUserException。
它表示:
这是一个可预期的、由用户操作引发的异常。
只要你的错误属于“用户做了不被允许的事、或者输入的数据不合法”,通常都应该优先继承 OakUserException 或它的子类,而不是直接抛普通 Error。
常见异常类型
下面这些异常,是 Oak 项目里最常见的一批:
| 异常 | 含义 |
|---|---|
OakRowInconsistencyException | 当前数据状态不允许这次操作 |
OakInputIllegalException | 输入非法 |
OakAttrNotNullException | 非空属性为空 |
OakAttrCantUpdateException | 某属性不允许被更新 |
OakOperationUnpermittedException | 当前用户无权执行此操作 |
OakDataInvisibleException | 当前用户无权看到这批数据 |
OakUnloggedInException | 用户未登录 |
OakPreConditionUnsetException | 某个前置条件未满足 |
OakExternalException | 调用外部接口失败 |
OakUniqueViolationException | 唯一约束冲突 |
OakImportDataParseException | 导入数据解析失败 |
OakApplicationHasToUpgrade | 应用需要升级 |
为什么这些异常有意义
例如下面这几个异常,虽然看起来都像“失败了”,但语义完全不同:
OakInputIllegalException:是你传进来的参数错了;OakOperationUnpermittedException:是你没权限做这件事;OakDataInvisibleException:是你连看这批数据都不该看;OakExternalException:是第三方系统失败了,不一定是你输入错。
当前端能区分这些语义时,提示方式、恢复方式、重试方式都会完全不同。
一个典型写法
业务异常应优先使用可翻译的 key、模块名和结构化参数:
if (!license) {
throw new OakUserException(
'error::license.notFound',
'project-name',
{ licenseId: params.licenseId }
);
}
而在 checker 中,更推荐使用更具体的异常,例如:
throw new OakAttrNotNullException('address', ['name'], '地址命名不能为空');
自定义异常时的建议
如果 Oak 内置异常不够用,你当然可以自定义,但建议遵守下面两个原则:
- 尽量继承现有语义最接近的父类;
- 保持异常数据可序列化;
- 用户可见消息使用
error::...locale key,并通过_module与params提供翻译上下文。
例如:
- 输入校验问题,优先继承
OakInputIllegalException; - 权限问题,优先使用
OakOperationUnpermittedException、OakDataInvisibleException等相关权限异常; - 纯系统内部故障,再考虑更底层的
OakException。
makeException 的意义
Exception.ts 最后提供了一个 makeException(...) 方法,用于根据序列化后的数据重新构造异常对象。
这说明 Oak 的异常并不是“只在服务端抛完就算了”,而是被设计成可以跨前后端传播和重建的。也正因为如此,乱抛 Error 会让这套机制失去意义。
一个实践经验
在 Oak 中,异常本身就是业务协议的一部分。
所以写异常时不要只想着“报个错”,而要想清楚:
- 这属于哪一类业务语义;
- 前端是否需要识别它;
- 是否需要带回 opRecords 修正缓存;
- 用户应该看到什么提示。
一旦这样思考,Oak 的异常体系就会变得非常顺手。
权限
Oak 的权限模型,和很多传统系统里的“菜单权限 / 路由权限 / 角色字符串判断”不是一回事。
它的设计核心是:
权限不是一堆散落在代码里的
if (role === 'admin'),而是一套可以被数据表达、被路径推导、被对象关系复用的规则体系。
这也是 Oak 最值得认真理解的一部分。
Oak 权限体系里的几个核心实体
Relation
oak-domain/src/entities/Relation.ts
它表示某个对象上的“关系名”,例如:
- 某个
system上的admin - 某个
order上的owner - 某个
project上的member
也就是说,Relation 不是全局角色,而是某个对象上下文中的关系。
UserRelation
oak-domain/src/entities/UserRelation.ts
它表示“某个用户,在某个对象上,拥有某个 relation”。
这是 Oak 权限真正落到用户身上的那一层。
Path
oak-domain/src/entities/Path.ts
它定义了权限如何沿对象关系传播。最关键的字段是:
sourceEntitydestEntityvaluerecursive
例如,如果一个 application 通过 system 外键指向 system,那么从 system 权限传播到 application 的路径就可以写成 system。
ActionAuth
oak-domain/src/entities/ActionAuth.ts
它定义:
- 某种
relation - 沿某条
path - 可以对目标对象执行哪些
deActions
这就是“某类关系能做什么事”的核心授权数据。
RelationAuth
oak-domain/src/entities/RelationAuth.ts
它定义:
- 某种
sourceRelation - 沿某条
path - 能否去授予或移除某个
destRelation
也就是说,ActionAuth 解决“能操作什么”,而 RelationAuth 解决“能给别人授什么权”。
一个最容易理解的例子
假设有两个对象:
systemapplication
并且 application.systemId -> system.id。
那么 Oak 中一个很自然的权限表达方式就是:
- 在某个
system上定义adminrelation; - 通过
UserRelation把用户绑定到这个 system 的admin; - 定义一条
Path,表示从application回到system的路径是system; - 定义
ActionAuth,说明system.admin可以沿着system这条路径,对application执行select/update/...。
这样,权限就不是写死在某个页面按钮上,而是变成了“对象关系 + 路径 + 动作”的数据规则。
运行时是谁在做权限判断
真正的权限判定核心在 oak-domain/src/store/RelationAuth.ts。
AppLoader 在创建 dbStore 时,会把下面几类项目配置传进去:
authDeduceRelationMapselectFreeEntitiesupdateFreeDict
后续无论是前端还是后端,Oak 都会尽量通过这一套关系授权体系去推导:
- 当前用户能否
select - 当前用户能否
create/update/remove - 级联操作中的子对象权限是否成立
authDeduceRelationMap 是做什么的
有些对象本身并不需要单独定义权限,因为它的权限完全可以从某个父对象推导出来。
这时就可以在项目的 src/configuration/relation.ts 中配置:
export const authDeduceRelationMap = {
};
如果某个实体的权限可以通过某个外键直接 deduce,Oak 就不必再单独为它搜索完整的 relation 路径。这样既减少配置,也减少权限判断开销。
框架内部还会自动补上一条:
modi: 'entity'
也就是说,modi 的权限默认就会从其父实体推导。
selectFreeEntities 和 updateFreeDict
这两个配置是 Oak 权限体系中很实用的“开口子”能力。
selectFreeEntities
表示这些实体允许自由查询,不必经过完整的 relation auth 推导。
例如 bm-smart/src/configuration/relation.ts 中,就把 manufacture、product、oauthProvider、tag、community、banner、post、reply、answer 等对象列进了 selectFreeEntities。
这类对象通常具备公共内容或公共维表属性。
updateFreeDict
表示某些实体上的特定动作可以自由执行,而不走常规授权推导。
这类配置一定要慎用,因为它是在权限系统上显式开白名单。
权限和 checker 的关系
在 Oak 的 checker 类型中,有一个保留类型叫 relation。它的语义就是:这是权限相关的检查。
不过大多数时候,开发者并不需要自己手写 relation checker,因为 Oak 已经把权限规则数据化了。你真正需要做的事情,更多是:
- 定义 relation 数据;
- 配置 path;
- 配置 actionAuth / relationAuth;
- 维护好项目的 relation 配置。
权限数据写在哪里
项目通常把静态权限数据放在:
src/data/path.tssrc/data/actionAuth.tssrc/data/relationAuth.ts- 需要预置对象关系时的
src/data/relation.ts
这些文件使用生成的 CreateOperationData 类型,pathId、relationId 和 action 名称必须能由当前 domain 对上,不能用未声明字段或宽泛断言掩盖错误。修改这组数据后,应依次执行:
npm run build
npm run upgrade:auth
build 先生成服务端可加载的 lib/data,upgrade:auth 再按项目更新计划把 path、actionAuth、relation、relationAuth 收敛到数据库。只改源码但不执行升级,运行中的权限数据不会自动同步。
为什么 Oak 的权限模型更强
因为它解决的是“对象之间关系传播后的动作权限”,而不是“用户有一个字符串角色,所以放行”。
这带来的好处是:
- 权限可以落到具体业务对象上;
- 父子对象可以沿路径传递权限;
- 授权能力本身也可以被授权;
- 同一套规则前后端都能复用。
当然,它的代价也很明显:第一次理解起来会比传统 RBAC 更难。但一旦系统进入复杂业务阶段,这套模型的可维护性会远高于散落在各处的手写判断。
一个实践建议
在 Oak 中,优先思考“用户和对象之间是什么关系”,而不是“这个用户属于什么全局角色”。
只要你从这个角度出发,Relation / UserRelation / Path / ActionAuth / RelationAuth 这几个概念就会迅速连起来。
国际化
Oak 的国际化并不是一个孤立的前端插件,而是进入了项目编译流程的一部分。
这意味着:
- 页面可以定义自己的 locales;
- 组件可以定义自己的 locales;
- 模块可以定义自己的 locales;
- 实体生成出来的内容也可以带着 locales;
- 多个依赖模块的国际化资源会被统一编译进项目;
- 当前 locale 会进入前后端 runtime context。
make:locale 做了什么
npm run make:locale 最终会调用 oak-domain/src/compiler/localeBuilder.ts。
它会扫描项目和依赖模块中的 locale 目录,然后统一生成:
src/data/i18n.json
src/data/i18n.ts
其中 src/data/i18n.json 承载真实 i18n 行数据,src/data/i18n.ts 是兼容旧代码的 wrapper,会从 ./i18n.json 读取后导出。这样可以避免巨大的 i18n 数组继续塞进 TypeScript 源码里,也让 tscBuilder 在构建时把相对 JSON 运行时资产复制到 lib / es 等输出目录。
也就是说,Oak 的 i18n 不是“运行时到处动态拼 JSON”,而是先把项目里的本地化资源编译成标准数据;只是当前标准数据的主体已经从 TypeScript 文件迁移到了 JSON 文件。
默认会扫描哪些目录
LocaleBuilder 默认会收集这些位置:
src/pages/**/localessrc/components/**/localessrc/locales/*src/oak-app-domain/**/localesweb/src下的 locale 目录wechatMp/src下的 locale 目录
如果项目依赖了其它 Oak 模块,这些模块中的 locales 也会一并进入编译结果。
依赖模块是否参与共享 locale 编译由 package.json 的 Oak 元数据控制。当前 LocaleBuilder 会识别安装在顶层 node_modules、具有对象形式 oak 配置且 oak.i18n === true 的包,并从其 es 产物收集页面、组件和 locales。当前 Oak 包通常同时声明 oak.package: true,但 locale 收集本身判断的是 oak.i18n。实体相关的多语言元数据来自 es/entities 的声明和值合并解析,不要求发布 src。
应用侧排查“依赖包 locale 没进 src/data/i18n.json”时,先检查依赖包是否发布了对应 es/.../locales 文件和 oak.i18n: true,不要默认认为是 LocaleBuilder 漏扫了整个 node_modules。
locale 文件长什么样
Oak 中的 locale 文件通常就是普通 JSON,例如:
{
"title": "系统管理",
"create": "新建",
"remove": "删除"
}
文件名一般使用语言标识,例如:
zh_CN.jsonen_US.json
LocaleBuilder 在编译时会把 zh_CN 这种文件名转换成 zh-CN 语言标识。
namespace 是怎么来的
Oak 会根据 locale 所在的位置自动生成 namespace。
例如:
- 页面 locale 会带
p-前缀; - 组件 locale 会带
c-前缀; src/locales/common这类全局目录会带l-common这样的前缀;- 如果资源来自依赖模块,还会自动带上模块名。
所以在真实项目里,你经常会看到类似下面这样的 namespace:
projectName-l-common
projectName-l-error
projectName-l-menu
项目可以在 src/initializeFeatures.ts 中通过:
features.locales.loadServerData([
'projectName-l-common',
'projectName-l-error',
'projectName-l-menu'
]);
去主动加载的服务端 i18n 数据。
在组件里如何使用
Oak 组件方法里自带 t(key, params?),也就是说,组件层并不需要自己再额外接一个 i18n 工具对象。
你更需要关心的是:
- 文案是否放到了正确的 locale 目录;
- 是否执行过
make:locale; - 需要服务端参与的 namespace 是否已正确加载。
如果逻辑运行在 aspect、trigger、checker、watcher、timer 或 routine 中,则优先从 context 获取 locale。这样后端翻译、通知、LocalizedContent 生成和前端展示使用的是同一套语言上下文。
和系统翻译能力的关系
国际化不再只是页面文案。项目如果增加了翻译相关实体或依赖,还需要先完成 project:init -> make:domain -> make:dep 的依赖/domain 同步,再生成 locale;不要把实体变化只当作 locale 文件变化处理。
为什么 Oak 要把 i18n 做进编译流程
这样做有三个直接好处:
- 项目和依赖模块的多语言资源可以统一收敛;
- i18n 数据本身可以进入 Oak 的数据体系;
- 前端多端共享时,不需要每个端再各自维护一套散乱的 locale 逻辑。
从 Oak 的整体设计目标来看,这其实和它对 Entity、dependency、feature 的处理方式完全一致:尽量把“看似分散的共性能力”统一纳入框架体系中。
一个实践建议
在 Oak 项目里,最推荐的做法不是“哪里缺文案就临时补一段字符串”,而是:
- 页面文案放页面目录;
- 组件文案放组件目录;
- 全局文案放
src/locales/*; - 修改后执行
make:locale、build和upgrade:locale; - 提交和发布时同时保留
src/data/i18n.json与src/data/i18n.ts。
完整同步顺序是:
npm run make:locale
npm run build
npm run upgrade:locale
前两步更新并编译 locale 数据,最后一步才把 i18n 行同步到数据库。
这样一来,国际化资源的组织方式会和 Oak 其它部分一样清晰,不容易在项目变大后失控。
前端图标库
Oak 前端图标库围绕 OakIcon 组件工作。它解决的不是“怎么在某个 React 页面里临时引入一个图标”,而是让页面配置、菜单配置、web 运行时和小程序编译都能用同一套字符串图标名。
最重要的规则只有一条:
- 页面、菜单、namespace 配置里只写字符串;
- 真正的图标库实现写在
oak.config.ts、web 初始化 options 或 namespace config 里; - 渲染层统一把字符串交给
OakIcon。
这样做之后,oak-cli 才能在编译时生成路由、菜单、web 按需加载和小程序图标样式。
基本用法
业务代码里统一使用 OakIcon:
import OakIcon from '@oak-frontend-base/components/icon';
<OakIcon name="oak:setup_fill" size={18} />
<OakIcon name="trip:hotel" size={18} />
<OakIcon name="antd:ShopOutlined" size={18} />
在页面、菜单、namespace 配置里也只写字符串:
icon: 'antd:ShopOutlined'
不要在 CreatePageConfig(...) 或 CreateNamespaceConfig(...) 中写 ReactNode,例如不要写:
icon: <ShopOutlined />
这些配置需要保持可序列化。否则路由生成、菜单生成和按需图标加载都会失去静态分析基础。
图标名怎么写
Oak 当前支持三类常见写法。
1. Oak 内置图标
旧项目里常见的写法仍然可用:
icon: 'setup_fill'
这等价于:
icon: 'oak:setup_fill'
新代码更推荐显式写 oak::
icon: 'oak:setup_fill'
icon: 'oak:tasklist'
oak:* 来自 oak-frontend-base 内置 iconfont,web 和小程序都支持。
2. 自定义 font 图标
业务自己的字体图标使用 <library>:<name>:
icon: 'trip:hotel'
icon: 'trip:order'
其中 trip 是图标库名,hotel / order 是业务图标名。
3. Web React 图标
web 端可以使用 React 图标库,例如 Ant Design Icons:
icon: 'antd:ShopOutlined'
icon: 'antd:SettingOutlined'
type: 'react' 只支持 web。小程序不能直接渲染 React 图标组件。
在 oak.config.ts 中配置
跨端通用配置写在项目根目录 oak.config.ts 的 frontend.iconLibraries 下。
使用已有 iconfont 样式
如果项目已经有 iconfont 的 CSS / Less 文件,可以这样配置:
import { CreateCompilerConfig } from '@xuchangzju/oak-cli/lib/createConfig';
export default CreateCompilerConfig({
frontend: {
iconLibraries: [
{
name: 'trip',
type: 'font',
fontFamily: 'trip-iconfont',
fontFamilyClass: 'trip-iconfont',
classPrefix: 'trip-icon-',
style: '@project/assets/icons/trip/iconfont.less',
subset: 'auto',
},
],
},
});
这些字段的含义是:
name:图标库名,对应图标字符串里的trip:;type: 'font':字体图标库,web 和小程序都能用;fontFamily:匹配@font-face中的字体名;fontFamilyClass:运行时加到非oak图标上的字体 class;classPrefix:glyph class 前缀;style:已有 iconfont 样式文件;subset:小程序端是否裁剪未使用的 glyph。
用 fontUrl + icons 生成样式
如果项目不想维护完整 iconfont 样式,也可以只给字体文件和 glyph 映射:
import { CreateCompilerConfig } from '@xuchangzju/oak-cli/lib/createConfig';
export default CreateCompilerConfig({
frontend: {
iconLibraries: [
{
name: 'trip',
type: 'font',
fontFamily: 'trip-iconfont',
fontFamilyClass: 'trip-iconfont',
classPrefix: 'trip-icon-',
fontUrl: '@project/assets/icons/trip/iconfont.woff2',
fontFormat: 'woff2',
icons: {
hotel: '\\e600',
order: '\\e601',
},
subset: 'auto',
},
],
},
});
fontUrl 只说明字体文件在哪里,icons 才说明业务图标名和 glyph content 的对应关系。只配置字体文件,Oak 不能自动猜出 hotel、order 这些业务名。
运行时 trip:hotel 会生成类似这样的 class:
oak-icon trip-iconfont trip-icon-hotel oak-icon__primary
按平台配置
如果 web 和小程序使用不同图标库,可以放到 frontend.targets 下:
import { CreateCompilerConfig } from '@xuchangzju/oak-cli/lib/createConfig';
export default CreateCompilerConfig({
frontend: {
targets: {
web: {
iconLibraries: [
{
name: 'antd',
type: 'react',
packageName: '@ant-design/icons',
platforms: ['web'],
},
],
},
wechatMp: {
iconLibraries: [
{
name: 'trip',
type: 'font',
fontFamilyClass: 'trip-iconfont',
classPrefix: 'trip-icon-',
style: '@project/assets/icons/trip/iconfont.less',
subset: 'auto',
},
],
},
},
},
});
targets.web 只影响 web 编译,targets.wechatMp 只影响小程序编译。
在 namespace 中配置
某些后台 namespace 自己需要一套图标库时,可以写在:
web/src/app/namespaces/<namespace>/index.config.ts
示例:
import { CreateNamespaceConfig } from '@oak-frontend-base/config';
export default CreateNamespaceConfig({
iconLibraries: [
{
name: 'antd',
type: 'react',
packageName: '@ant-design/icons',
platforms: ['web'],
},
],
menu: {
groups: [
{
name: 'HotelOps',
icon: 'antd:ShopOutlined',
order: 1,
},
],
},
});
namespace config 里的 iconLibraries 会参与 web 初始化。菜单仍然只写字符串。
在页面菜单中使用
页面菜单项通常写在页面旁边的 index.config.ts:
import { CreatePageConfig } from '@oak-frontend-base/config';
export default CreatePageConfig({
menu: {
name: 'hotelArchive',
icon: 'antd:ShopOutlined',
group: 'HotelOps',
order: 1,
},
});
菜单渲染组件只需要把这个字符串交给 OakIcon:
<OakIcon name={icon || ''} size={18} />
不要在菜单数据里提前构造 React 组件。菜单数据应该保持平台无关和可序列化。
Web React 图标按需加载
React 图标库有两种接法。
1. 手工注册已导入组件
如果只用几个图标,可以在 web 初始化时显式导入:
import {
SettingOutlined,
ShopOutlined,
} from '@ant-design/icons';
initialize(features, appName, routers, {
namespaceConfigs,
iconLibraries: [
{
name: 'antd',
type: 'react',
platforms: ['web'],
icons: {
SettingOutlined,
ShopOutlined,
},
},
],
});
这种方式最直接,但要避免:
import * as Icons from '@ant-design/icons';
全量导入图标库会明显增加入口包体积。
2. 让 oak-cli 生成按需 loader
更推荐在 oak.config.ts 里声明 packageName:
{
name: 'antd',
type: 'react',
packageName: '@ant-design/icons',
platforms: ['web'],
}
oak-cli 会扫描静态图标字符串,例如:
icon: 'antd:ShopOutlined'
然后生成类似下面的动态 import:
import('@ant-design/icons/ShopOutlined')
默认 import 规则是:
{packageName}/{iconName}
如果图标包路径不同,可以配置 importPath:
{
name: 'custom',
type: 'react',
packageName: '@scope/icons',
importPath: '{packageName}/icons/{iconName}',
platforms: ['web'],
}
这个机制是编译期静态扫描,不是运行时任意字符串 import。动态拼接图标名不保证有对应 loader:
// 不推荐:编译器无法稳定收集所有图标
icon: `antd:${name}`
小程序 font 图标裁剪
小程序只能处理 oak:* 和 type: 'font' 图标库,不处理 type: 'react'。
小程序编译时,oak-cli 会把 font 图标样式合并进:
components/icon/index.wxss
同时它会扫描当前小程序入口图里的页面和组件,收集静态使用的图标名,例如:
<oak-icon name="trip:hotel" />
或者:
<OakIcon name="trip:hotel" />
subset 控制裁剪策略:
subset: 'auto':推荐。静态可判断时只保留用到的 glyph;发现动态图标名时回退整库并打印 warning;subset: true:强制按静态扫描结果裁剪;subset: false:保留整库 CSS 和字体。
如果图标名有动态拼接,优先用 subset: 'auto' 或 subset: false,避免小程序运行时才用到的 glyph 被裁掉。
常见排错
图标不显示时,先按下面顺序检查。
通用检查
- 图标名是否是静态字符串;
- 图标名前缀是否和图标库
name一致; oak.config.ts、namespace config 修改后是否重启了 dev server;- 页面、菜单、namespace 配置里是否误写了 ReactNode;
fontUrl是否同时配了icons映射。
Web 图标不显示
type: 'react'的依赖包是否已安装,例如@ant-design/icons;packageName对应的单图标入口是否真实存在;- 单图标入口不是
{packageName}/{iconName}时,是否配置了importPath; - 手工注册时是否只导入了用到的图标组件;
type: 'font'时,web 端是否能拿到对应样式或编译生成的styleText。
小程序图标不显示
- 小程序是否误用了
antd:*这类 React 图标; style指向的 iconfont 样式文件是否能被oak-cli解析;fontUrl指向的字体文件是否存在;- 动态图标名是否被
subset: true裁掉; - 构建日志中是否出现
[oak-vite-mp-icon]warning。
当前限制
type: 'react'只支持 web;type: 'image'目前只是类型预留,还不是完整的跨端图标资源方案;type: 'react' + packageName依赖编译期静态扫描,动态拼接图标名不会自动生成任意 import;- 小程序图标裁剪只扫描当前小程序入口图相关文件,不会无条件扫描项目里所有源码。
前后端配置
Oak 项目里有两类名字很接近、但含义完全不同的配置:
src/configuration/access.ts:前端如何访问后端服务;- 页面或命名空间里的
route.access:web 页面是否允许当前用户进入。
这两者不要混在一起理解。access.ts 解决的是“请求发到哪里”;route.access 解决的是“这个页面能不能看”。
src/configuration/access.ts
access.ts 是前端连接后端的统一入口。模板里通常长这样:
import accessConfiguration from './access.dev';
export default accessConfiguration;
也就是说,业务代码一般不直接 import access.dev.ts、access.prod.ts 或 access.staging.ts,而是统一从 @project/configuration/access 取当前环境的访问配置。
真正的配置类型是 AccessConfiguration,核心字段是:
import { AccessConfiguration } from '@oak-domain/types/Configuration';
const accessConfiguration: AccessConfiguration = {
http: {
hostname: 'localhost',
port: 3001,
ssl: false,
path: 'oak-api',
},
timeout: 5000,
clockDriftDuration: 10000,
};
export default accessConfiguration;
其中:
http.hostname是后端服务域名;http.port是后端端口,本地开发常用;http.ssl决定使用http还是https;http.path是反向代理路径,例如 nginx 映射到后端 API 的路径;timeout是前端请求超时时间;clockDriftDuration是允许的前后端时钟漂移时间。
AccessConfiguration 还可以配置几个框架路由前缀:
const accessConfiguration: AccessConfiguration = {
routerPrefixes: {
aspect: '/aspect',
endpoint: '/endpoint',
bridge: '/bridge',
getSubscribePoint: '/socketPoint',
},
socketPath: '/socket',
http: {
hostname: 'localhost',
port: 3001,
},
};
这些前缀一般不需要改。只有当后端路由或网关规则被项目刻意调整时,才应该同步配置。
它在哪里被使用
access.ts 最直接的消费者是 src/config/connector.ts:
import SimpleConnector from '@oak-domain/utils/SimpleConnector';
import accessConfiguration from '@project/configuration/access';
import { makeException } from '@project/types/Exception';
import { EntityDict } from '@project/oak-app-domain';
import FrontendRuntimeContext from '@project/context/FrontendRuntimeContext';
const connector = new SimpleConnector<EntityDict, FrontendRuntimeContext>(
accessConfiguration,
makeException
);
export default connector;
SimpleConnector 会根据 accessConfiguration 拼出:
- aspect 调用地址;
- endpoint 调用地址;
- bridge 地址;
- socket 订阅地址。
随后,生成的 src/initialize.server.ts 会把这个 connector 传给 @oak-frontend-base/initialize,web、小程序、native 的平台入口再统一 import @project/initialize。因此,access.ts 配错时,现象通常不是某个页面单独失败,而是前端所有 aspect、endpoint、订阅或缓存同步请求都可能连不上后端。
加密连接 EncConnector
如果项目需要在 Oak connector 层对请求和响应做加密,可以用 oak-internal-sdk 提供的 EncConnector 替换默认的 SimpleConnector。
EncConnector 的入口通常这样引入:
import { EncConnector } from '@oak-internal-sdk/utils/EncConnector';
这个入口会按编译平台选择实现:web、小程序、native 使用前端实现;server 使用后端实现。因此同一个 import 在前后端可以写一样,但构造参数不能完全一样。
前端侧的 src/config/connector/index.web.ts 可以这样写:
import accessConfiguration from '@project/configuration/access';
import { makeException } from '@project/types/Exception';
import { EntityDict } from '@project/oak-app-domain';
import FrontendRuntimeContext from '@project/context/FrontendRuntimeContext';
import { EncConnector } from '@oak-internal-sdk/utils/EncConnector';
const connector = new EncConnector<EntityDict, FrontendRuntimeContext>({
configuration: accessConfiguration,
makeException,
allowUnsafe: process.env.NODE_ENV !== 'production',
});
export default connector;
前端不需要 sessionStore。如果 allowUnsafe 为 false,前端会先通过内置 aspect oak:connector:key-exchange 和后端做密钥交换,然后用会话密钥加密后续请求中的 context 和 data。开发环境可以临时把 allowUnsafe 设为 true,让前端跳过密钥交换、继续发送未加密请求;生产环境一般应关闭。
后端侧也必须使用 EncConnector,否则后端无法解析前端发来的加密请求。后端的 src/config/connector/index.backend.ts 通常需要提供会话存储:
import accessConfiguration from '@project/configuration/access';
import { makeException } from '@project/types/Exception';
import { EntityDict } from '@project/oak-app-domain';
import FrontendRuntimeContext from '@project/context/FrontendRuntimeContext';
import { EncConnector } from '@oak-internal-sdk/utils/EncConnector';
import { createRedisSessionStore } from '@oak-internal-sdk/adaptor/redisAdaptor';
import redisConfig from '../../../configuration/redis.json';
const connector = new EncConnector<EntityDict, FrontendRuntimeContext>({
configuration: accessConfiguration,
makeException,
allowUnsafe: (options) => {
if (process.env.NODE_ENV !== 'production') {
return true;
}
const { headers } = options;
const oakVersion = headers['oak-version'];
const oakPlatform = headers['oak-platform'];
return !oakVersion && oakPlatform === 'mp';
},
sessionStore: createRedisSessionStore(redisConfig, {
keyPrefix: 'my_app:session:',
ttl: 24 * 60 * 60,
}),
cleanupInterval: 60 * 1000,
sessionMaxAge: 24 * 60 * 60 * 1000,
nonceMaxAge: 60 * 60 * 1000,
});
export default connector;
服务端 EncConnector 必须提供 sessionStore。oak-internal-sdk 提供了两种常用适配器:
createRedisSessionStore(...):适合生产部署,多个服务实例可以共享会话;createMemorySessionStore(...):适合单进程、本地测试或临时验证。
这两个创建函数只会在 process.env.OAK_PLATFORM === 'server' 时返回实例。自定义后端启动脚本如果没有先设置 OAK_PLATFORM=server,sessionStore 会是 undefined,服务端构造 EncConnector 时会抛出 error::backend.sessionStoreRequired。
一个项目如果要按平台拆 connector,可以采用类似结构:
src/config/connector/
index.ts // 默认导出后端实现,供 server 编译使用
index.backend.ts // EncConnector + sessionStore
index.web.ts // EncConnector,不传 sessionStore
index.mp.ts // 视项目情况使用 EncConnector 或 SimpleConnector
index.native.ts // 视项目情况使用 EncConnector 或 SimpleConnector
EncConnector 会额外使用这些 Oak 请求/响应头:
oak-session-idoak-encryptedoak-timestampoak-nonceoak-sequenceoak-versionoak-platform
Oak CLI 的 server 中间件会把 connector 的 getCorsHeader() 合并进允许请求头;开发和预发环境还会设置 connector 的响应头暴露。生产环境如果启用了自定义 CORS、nginx、网关或 CDN,需要额外确认这些头没有被拦截,并且响应头里的 oak-encrypted、oak-nonce 等能被浏览器读取。否则前端可能无法解密响应,或者 SSE 加密流无法正确解析。
EncConnector 也支持 endpoint 和 SSE endpoint。后端 endpoint 如果配置了 useConnector,Oak CLI 会先调用 connector.parseRequest(...) 解密请求;SSE endpoint 还会通过 serializeSSEEndpointResult(...) 加密 data: 数据包。加密 SSE 要求前后端 connector 同步升级,不能只升级其中一端。
迁移时最容易出错的地方有三个:
- 只把前端改成
EncConnector,后端仍然是SimpleConnector; - 后端没有提供
sessionStore,或者启动时没有设置OAK_PLATFORM=server; - 生产代理没有放行或暴露 Oak 加密相关 header。
多环境写法
模板通常会放三份访问配置:
src/configuration/access.dev.tssrc/configuration/access.staging.tssrc/configuration/access.prod.ts
本地开发常见写法是:
import { AccessConfiguration } from '@oak-domain/types/Configuration';
import { port } from './common';
export const hostname = 'localhost';
const accessConfiguration: AccessConfiguration = {
http: {
hostname,
port,
},
};
export default accessConfiguration;
生产环境如果通过 nginx 代理,常见写法是:
import { AccessConfiguration } from '@oak-domain/types/Configuration';
import { nginxServerProxyPath } from './common';
export const hostname = 'www.your-site.com';
export const ssl = true;
const accessConfiguration: AccessConfiguration = {
http: {
hostname,
ssl,
path: nginxServerProxyPath,
},
};
export default accessConfiguration;
access.ts 是最终统一出口。当前模板不会因为文件名存在就自动替你选择 access.prod.ts;如果项目有预发、生产构建,需要确保 access.ts 或项目自己的构建流程导出正确的环境配置。
src/configuration/server.ts
server.ts 是后端服务自己的运行配置,类型是 ServerConfiguration。它和 access.ts 经常引用同一批常量,但职责不同:
access.ts给前端 connector 用,描述“前端访问后端的地址”;server.ts给后端启动、代理和部署用,描述“后端自己怎么监听、如何暴露给 nginx”。
模板里的 server.ts 会从 access.dev.ts、access.staging.ts、access.prod.ts 读取域名和 ssl 配置,再根据 process.env.NODE_ENV 选择:
const serverConfiguration: ServerConfiguration = {
workDir: {
path: join(__dirname, '..', '..'),
},
port,
hostname: HostnameDict[process.env.NODE_ENV as string],
nginx: NginxConfDict[process.env.NODE_ENV as string],
};
所以部署时要同时检查两边:
- 前端构建产物里
access.ts指向的后端地址是否正确; - 后端启动时
NODE_ENV、server.ts、nginx 路径和实际监听端口是否一致。
src/configuration/index.ts
src/configuration/index.ts 是另一类配置入口,它导出的是 CommonConfiguration,会参与 Oak 前端运行时和内置 checker 初始化。模板中通常包含:
import attrUpdateMatrix from './attrUpdateMatrix';
import { CommonConfiguration } from '@oak-domain/types/Configuration';
import { actionDefDict } from '@project/oak-app-domain/ActionDefDict';
import { selectFreeEntities, authDeduceRelationMap, updateFreeDict } from './relation';
import cacheSavedEntities from './cache';
import { EntityDict } from '@project/oak-app-domain';
export default {
attrUpdateMatrix,
actionDefDict,
authDeduceRelationMap,
selectFreeEntities,
updateFreeDict,
cacheSavedEntities,
} as CommonConfiguration<EntityDict>;
这一组配置不是网络地址,而是 Oak 运行时规则,例如:
attrUpdateMatrix:属性更新矩阵,生成内置 checker;actionDefDict:实体 action / 状态矩阵定义;authDeduceRelationMap:权限关系推导;selectFreeEntities:允许自由查询的对象;updateFreeDict:允许自由更新的对象和动作;cacheSavedEntities:前端缓存持久化对象。
如果你是在改“前端访问哪个后端”,看 access.ts;如果你是在改“哪些对象能查、哪些属性能改、哪些 action 合法”,看 configuration/index.ts 以及它引用的 relation.ts、attrUpdateMatrix.ts 等文件。
页面访问控制 route.access
页面访问控制写在页面或命名空间的 index.config.ts,和 src/configuration/access.ts 没有直接关系。例如:
import { CreatePageConfig } from '@oak-frontend-base/config';
export default CreatePageConfig({
route: {
access: { type: 'login' },
},
});
常见取值包括:
public:直接允许;login:需要登录;root:需要 root;deny:始终拒绝;relation:按 Oak 关系权限判断;operation:用前端 checker 判断某个 Oak operation 是否允许;anyOf/allOf:组合多条规则。
组合规则使用 items:
export default CreatePageConfig({
route: {
access: {
type: 'allOf',
items: [
{ type: 'login' },
{ type: 'ref', refs: ['../system/detail'] },
],
},
},
});
也可以直接写规则数组,数组按 anyOf 语义处理。relation 规则使用 anyOf,operation 规则使用 target,不要把 anyOf / allOf 写成没有 items 的裸对象。
命名空间还可以通过 access.unconfiguredAccess 声明没有单独配置访问规则的页面默认如何处理:
export default CreateNamespaceConfig({
access: {
unconfiguredAccess: 'deny',
},
});
因此,排查访问问题时可以按这个顺序看:
- 前端请求是否打到了正确后端:看
src/configuration/access.ts和src/config/connector.ts; - 后端是否按正确端口和代理路径启动:看
src/configuration/server.ts; - 页面是否被路由权限拦住:看页面
index.config.ts的route.access和命名空间access.unconfiguredAccess; - operation 类访问控制是否被 checker 拦住:继续看
src/checkers、attrUpdateMatrix.ts和权限配置。
编译与构建配置
当前项目统一在根目录 oak.config.ts 中描述 Web、微信小程序和 Server 的编译行为:
import { CreateCompilerConfig } from '@xuchangzju/oak-cli/lib/createConfig';
export default CreateCompilerConfig({
vite: {
common: {},
web: {},
mp: {},
},
server: {},
});
vite.common 是共享层,vite.web、vite.mp 是目标覆盖层。alias、dedupe、插件和其它配置会按框架规则合并;目标配置优先级更高。不要同时维护一份旧 configuration/compiler.js 和一份相互矛盾的 oak.config.ts。
插件层次
每个 Vite 目标的插件分成三类:
plugins: {
pre: [],
buildin: {},
post: [],
}
pre:项目插件,在 Oak 内置插件前执行;buildin:配置 Oak 已提供的 CDN、PWA、Node polyfill、图片压缩等能力;post:项目插件,在 Oak 内置插件后执行。
优先使用 buildin 的结构化选项。只有框架没有提供相应能力时才增加项目插件,并确认插件在 Vite 8 / Rolldown 下可运行。
Web CDN
Web CDN 是显式 opt-in:只有 staging / production build 且配置了 vite.web.plugins.buildin.cdn 才会 external 对应依赖。未配置时 React、ReactDOM 等依赖正常进入 bundle。
CDN 配置支持多个候选源、超时、依赖顺序以及 global/UMD、ESM、CJS 模块。配置时至少确认:
- package key 与真实 import 一致;
- global/UMD 模块声明正确的全局变量;
- ESM 源允许跨域并能被 import map 或 preload 使用;
- 多源 fallback 不会执行同一个有副作用脚本两次;
- 生产页面在 CDN 全部失败时有明确降级行为。
Desktop production 会强制保持离线 bundle,不消费 Web CDN external。
一个带依赖关系和双源竞速的最小配置如下:
export default CreateCompilerConfig({
vite: {
web: {
plugins: {
buildin: {
cdn: {
moduleStrategy: 'parallel',
sourceStrategy: 'race',
timeout: 8000,
react: {
version: '19.2.4',
format: 'umd',
global: 'React',
sources: [
'https://cdn-a.example/react@{version}.js',
'https://cdn-b.example/react@{version}.js',
],
},
'react-dom/client': {
package: 'react-dom',
external: ['react-dom', 'react-dom/client'],
version: '19.2.4',
format: 'umd',
global: 'ReactDOM',
dependsOn: ['react'],
sources: [
'https://cdn-a.example/react-dom@{version}.js',
'https://cdn-b.example/react-dom@{version}.js',
],
},
},
},
},
},
},
});
moduleStrategy: 'parallel' 只并行加载彼此无依赖的模块。dependsOn 使用 CDN 配置 key,不是 npm 包名猜测;未知 key 和循环依赖会在构建配置阶段失败。
sourceStrategy: 'race' 会同时准备同一模块的候选源,但只执行最先成功的一个。global/UMD/IIFE 与 ESM 使用 preload 竞速,CJS 使用 fetch 竞速;它会增加瞬时请求量,适合跨 CDN 容灾,不应无条件开启。也可以只在某个模块上配置 sourceStrategy,并为 root、module 或单个 source 分别设置 timeout。
React 19 官方包不再提供传统 UMD 产物。使用 UMD 时必须确认 CDN 提供的真实文件和全局变量;使用官方 CJS 产物时要显式声明 dependsOn。构建成功不代表浏览器运行成功,至少要在 production 页面验证裸 import、CJS require、fallback 和全部 CDN 失败路径。
Web PWA
Vite Web 保留了可选 PWA 接入,但当前模板不会安装 vite-plugin-pwa:现有发布版与 Vite 8 baseline 存在 peer dependency 冲突。缺少该包时,production / staging 构建会提示 PWA 被跳过,不影响普通 Web 构建。
项目不使用 PWA 时,建议显式关闭并消除提示:
export default CreateCompilerConfig({
vite: {
web: {
plugins: {
buildin: {
pwa: false,
},
},
},
},
});
当前实现只可靠消费 pwa: false 或 { enabled: false } 这个关闭语义;其它 pwa 对象字段尚未传给 VitePWA(...)。不要在文档或项目配置中把 manifest/workbox 对象当作已经生效的合同。确实需要 PWA 时,应先确认一个与 Vite 8 兼容的插件版本,并用项目 pre / post 插件显式接入和验证 service worker、manifest、scope、start URL、图标及缓存策略。开发模式不会注册内置 PWA service worker,避免缓存干扰 HMR。
小程序 Node polyfill
Vite MP 使用 vite-plugin-node-polyfills 兼容部分第三方包。默认保留 process、global、Buffer 与 node: protocol import,但不自动引入体积和真机风险较高的 crypto、assert 完整兼容链。
确实需要 Node built-in 时使用完整白名单:
export default CreateCompilerConfig({
vite: {
mp: {
plugins: {
buildin: {
nodePolyfills: {
include: ['path', 'util'],
},
},
},
},
},
});
非空 include 是完整白名单,不是“在默认集合上追加”。加入 crypto 或 assert 前,应检查最终主包依赖图,并在真机验证随机数、加密、正则和启动阶段;微信开发者工具不能覆盖所有 JS 引擎差异。
小程序图片压缩
图片压缩作用于最终小程序 dist,因此能覆盖主包、分包和复制后的资源:
npm install -D sharp
export default CreateCompilerConfig({
vite: {
mp: {
plugins: {
buildin: {
compressImage: {
quality: 82,
include: ['assets/', /images/],
minSize: 4096,
skipIfLarger: true,
},
},
},
},
},
});
支持 PNG、JPEG、WebP、AVIF。sharp 必须安装在消费项目中;未安装时构建只给 warning 并跳过。保留 skipIfLarger: true,避免压缩结果反而扩大包体。
小程序 route map
小程序 Vite 构建会把 package.config.ts、页面配置和分包信息编译成 virtual:oak-mp-route-map。Oak navigator 使用 canonical route,例如:
this.features.navigator.navigateTo({
url: '/frontend/order/detail',
});
构建器负责把它解析为真实主包或分包路径。不要把生成路径保存进实体、菜单或共享常量。显式 route map 存在时,缺失 route 会报错或 warning,不再猜测 /pages/.../index。
Bundle 分析
Web 和小程序构建均支持:
oak-cli build --target web --mode production --vite --analyze
oak-cli build --target mp --mode production --vite --analyze
报告包含 bundle treemap、源码目录、NPM 依赖和反向依赖图;小程序还会显示主包、分包统计。先从报告确认大模块的真实 importer,再决定拆包、按需导入或调整 polyfill,不要只根据包名猜测。
Server bundle
oak.config.ts 的 server 只服务 opt-in 的 esbuild runtime bundle:
export default CreateCompilerConfig({
server: {
nodeModules: {
bundle: false,
},
esbuild: {
sourcemap: true,
},
},
});
server.nodeModules.bundle: false 时,普通第三方依赖保持裸 package import,部署目录需要安装生产依赖;设为 true 才会尝试把第三方运行依赖带入 bundle。数据库驱动是动态装载项,构建后必须确认目标环境需要的 mysql2、pg 或 better-sqlite3 已包含或可从外部解析。
Server bundle 的完整发布方式见部署。
严格 TypeScript 构建
当前模板的 build:es 默认执行:
oak-cli build --target tsc --configFile tsconfig.es.json --noEmit --enable-xml-check --emit-injection-types --check-style-less
这些检查分别覆盖 XML/WXML、编译器推导的 render props 声明、Less Module class 与真实 JSX/XML 作用域。报错应回到 properties、formData、methods、模板表达式和真实样式结构修复,不要通过扩大手写 props、空样式或关闭检查掩盖合同错误。
tscBuilder compiler plugin
oak-domain 的 tscBuilder 还提供程序化 TscCompilerPlugin,供 oak-cli 这类编译工具在创建 TypeScript Program 前替换 host、增加 rootNames 或映射 diagnostics:
import type { TscCompilerPlugin } from 'oak-domain/lib/compiler/tscBuilder';
const plugin: TscCompilerPlugin = {
prepareProgram(context) {
return {
host: context.host,
rootNames: context.rootNames,
mapDiagnostic(diagnostic) {
return diagnostic;
},
};
},
};
这个接口属于编译工具集成,不是普通 Oak 应用的 Vite 插件配置。普通项目不应为了隐藏诊断注册一个返回 undefined 的 mapper;需要扩展时,应保持 host、rootNames、source map 和 watch 行为可复现,并用独立 fixture 同时验证一次性构建和 watch。
发布和维护应用
Oak 的开发体验很好,一个项目在本地经常可以很快跑起来;但真正到了上线和维护阶段,问题就完全不一样了。
你会开始关心:
- 前后端产物分别怎么发布;
application和system这些运行时配置如何管理;- 数据字典、权限数据、i18n 数据如何同步到线上;
- 新老版本如何共存;
- 什么时候应该强制用户升级。
这些问题并不是 Oak 之外的“运维杂事”,它们其实和 Oak 的很多框架设计正好对应:
build负责生成运行产物;server:init负责首次初始化运行环境;db:upgrade:plan负责对比当前编译产物和目标数据库,生成结构升级计划;createUpdatePlan负责同步静态数据;oak-general-business的application负责应用发现与版本检查;OakApplicationHasToUpgrade负责把“不再兼容”的状态明确返回给客户端。
因此,这一章不会只讲“怎么把代码传到服务器”,而是会从 Oak 的真实运行机制出发,介绍一个应用从发布到升级的大致方法。
应用发布
Oak 应用的发布,至少包含三件事:
- 发布后端可执行代码;
- 发布前端目标端产物;
- 发布和校验应用本身的运行配置。
如果只做了前两件事,而忽略了 application/system/domain 这些运行时数据配置,那么应用很可能能启动,但无法正确识别自己,也无法向客户端提供正确的版本和域名信息。
一、Oak 应用真正的发布单元
从源码上看,一个 Oak 应用上线时真正依赖的东西包括:
1. 后端 lib
后端运行的是编译后的 lib 目录,而不是 src 目录中的 TypeScript。
2. 前端目标端产物
不同目标端分别由 Oak CLI 构建:
- web
- wechatMp
- native
3. 数据库与静态数据
数据库中不仅有业务数据,还包括:
- 权限相关数据
- i18n 数据
- 系统与应用配置数据
4. application / system 运行配置
如果你使用了 oak-general-business,前端启动时会先通过 features.application.initialize(...) 去拉取当前应用信息。这个过程最终会调用 getApplication(version, type, domain, appId)。
也就是说,Oak 前端并不是“只要打包好就能随便跑”,它还需要在后台找到与当前域名、当前端类型、当前版本匹配的 application 配置。
二、为什么 application 配置是发布的一部分
oak-general-business/src/aspects/application.ts 中的 getApplication(...) 在返回应用配置前,会调用 checkAppVersionSafe(...) 检查当前版本是否允许访问。
这一步会参考:
system.oldestVersionsystem.platform.oldestVersionapplication.dangerousVersionsapplication.warningVersions
如果当前客户端版本过低或命中了危险版本,后端会直接抛出 OakApplicationHasToUpgrade。
这说明 Oak 的发布不是“前端和后端各发各的”那么简单,而是要把版本策略也纳入发布动作。
三、一个推荐的发布顺序
比较稳妥的发布顺序如下:
- 在构建机执行
make:domain、make:locale、make:dep,依赖模块有增删时先执行project:init; - 编译后端
npm run build; - 对已有数据库执行
npm run db:upgrade:plan生成结构升级计划,并审核migration.sql、warnings.json、rename-candidates.json; - 按目标端构建前端,如
npm run build:web、npm run build:mp; - 发布后端代码和配置;
- 首次部署执行
npm run server:init;已有库按审核后的结构升级计划升级数据库; - 执行
upgrade:locale/upgrade:auth/upgrade:all这类静态数据同步脚本; - 校验
application/system/domain数据是否已经准备好; - 启动后端;
- 再发布前端静态资源或客户端包。
之所以把“校验应用配置”单独列出来,是因为 Oak 应用经常在这一环节出问题,而不是出在代码本身。
四、发布前至少要检查什么
对一个新手来说,发布前最值得检查的是下面这些信息:
- 当前目标端的
application.type是否正确; - web 端域名是否与
application/domain/system配置一致; - 小程序 / 公众号 / App 所需的
appId、appSecret等参数是否已配置; - 当前版本是否会被
oldestVersion或dangerousVersions拦截; - 依赖的对象存储、短信、OAuth、微信等外部配置是否已经上线可用。
这些信息很多并不在代码仓库里,而是在 Oak 的业务数据里。所以 Oak 项目的发布,往往天然就要求“代码发布”和“配置数据发布”配套进行。
五、如何理解“前端发布成功”
在 Oak 里,前端发布成功不只是“静态资源可访问”。
更准确地说,应该至少同时满足:
- 前端资源可正常加载;
- 前端能拿到正确的
application; - 后端没有因为版本策略直接拒绝它;
- 当前目标端的关键配置已经就位。
否则,用户看到的可能不是一个正常页面,而是直接进入升级态、错误态或配置异常态。
六、一个很重要的实践建议
在 Oak 中,把“应用配置数据”视为发布产物的一部分,而不是上线后的手工补丁。
只要你接受了这一点,Oak 的发布流程就会清晰很多。因为你会自然地把:
- 代码构建
- 数据初始化
- 配置校验
- 版本拦截策略
看成同一件事的不同阶段,而不是互不相干的杂项。
应用升级
和“发布”相比,“升级”最容易被说得过于理想化。就 Oak 当前仓库里的真实实现来看,升级能力主要由两部分组成:
- 代码与结构升级;
- 静态数据、权限数据、i18n 数据的同步升级。
其中第二部分已经有比较成型的工具链,核心就是 oak-backend-base/src/routines/update.ts 中的 createUpdatePlan(...)。
一、先区分两类升级
1. 代码 / 结构升级
这部分通常包括:
- 修改
Entity - 重新执行
make:domain - 如果 Oak 依赖模块有增删,重新执行
project:init - 重新执行
make:dep - 重新编译后端和前端
- 生成并审核
db:upgrade:plan - 发布新版本代码
- 首次部署执行
server:init;已有库按计划执行结构升级 SQL
这仍然属于一套完整的工程发布动作,不能简单地被一个“升级脚本”完全替代。
当前 make:domain 会优先从依赖模块的 es/entities 读取实体产物,其次才是 lib/entities 和 src/entities。升级 Oak 依赖后,即使项目自己的 src/entities 没变,也应重新执行 make:domain,否则 oak-app-domain 可能仍然停留在旧依赖类型上。
1.1 TypeScript 配置布局迁移
旧项目可能仍把 Web、小程序、Native 的 include、aliases 和编译选项堆在根 tsconfig.json。当前 CLI 使用“根构建配置 + 共享源码配置 + workspace 本地配置”的结构,可先查看迁移计划:
oak-cli migrate:tsconfig --dry-run
确认计划后执行:
oak-cli migrate:tsconfig
迁移命令会发现标准平台目录以及 npm scripts 中通过 --subDir 声明的自定义 workspace,并完成这些动作:
- 把 ES、Lib aliases 分别迁移到
tsconfig/paths.es.json、tsconfig/paths.lib.json; - 生成或修复
src/tsconfig.json和各 workspace 的tsconfig.json; - 修复配置之间的
extends,并为make:domain补充--configFile ./tsconfig.es.json; - 用 TypeScript parser、关键 alias 和最小平台配置检查验证迁移结果;
- 验证通过后才删除已被替代的旧根配置。
写入前,CLI 会把原文件备份到 .oak-tsconfig-migration/<时间戳>;写入或验证失败时会回滚。--dry-run 只输出计划,不创建备份或修改文件。
已有 workspace 配置默认会尽量保留,只修复能够安全判断的旧 extends。只有明确要用当前模板替换 workspace 配置时才使用:
oak-cli migrate:tsconfig --force
--force 可能覆盖 workspace 中的定制编译选项,执行前必须审核 dry-run 和 Git diff。遇到 project references、项目外部 extends、无法归属单一 workspace 的 include 等边界时,命令会拒绝做破坏性删除,应该人工拆分配置后重试。
1.2 数据库结构升级计划
当前 CLI 模板已经内置:
npm run db:upgrade:plan
它实际调用的是 oak-cli upgrade。这个命令会启动 AppLoader,读取当前编译后的 lib/oak-app-domain/Storage 和数据库现状,生成结构升级计划。默认只生成计划,不执行 SQL。
默认输出目录形如:
.oak-upgrade/20260508-153000
里面最重要的文件是:
migration.sql:按执行顺序整理后的正向结构升级 SQL;rollback.sql:由backwardSql生成的结构回滚 SQL;summary.json:本次计划的统计摘要;table-changes.json:逐表变更详情;warnings.json:需要人工关注的风险;rename-candidates.json:疑似重命名的列或索引。
常用参数如下:
npm run db:upgrade:plan -- -o .oak-upgrade/release-20260508
npm run db:upgrade:plan -- --largeTableRowThreshold 500000
npm run db:upgrade:plan -- --execute
需要注意几件事:
- 先执行
make:domain和build,再生成计划。oak-cli upgrade读取的是编译后的运行产物,不是直接读src/entities。 - 确认
NODE_ENV和数据库配置指向目标库。命令会按mysql.${NODE_ENV}.json、mysql.json、postgres.${NODE_ENV}.json、postgres.json的顺序找配置。 - 不带
--execute时只写文件,不会改库。带--execute时会执行排序后的结构升级 SQL,包含prepareSql / manualSql / forwardSql / onlineSql,不会自动跳过人工步骤。 manualSql代表 planner 认为这一步需要人工审核,不代表--execute会自动跳过。只要计划里有manualSql、warnings或renameCandidates,就应先人工确认。largeTableRowThreshold默认是100000。大表索引新增、删除、重建可能被转成manualSql,避免自动 DDL 长时间锁表或造成性能抖动。rollback.sql不是数据库备份。它只能表达框架能推导出来的结构回退,不能恢复被删除列里的业务数据,也不能替代上线前备份。- 这个命令只处理数据库结构,不负责静态数据、权限数据和 i18n 数据同步。后者仍然走
createUpdatePlan相关脚本。
2. 数据升级
这部分是 Oak 当前更擅长抽象的内容,尤其适合处理:
pathrelationactionAuthrelationAuthi18n- 某些静态业务数据
它们的共同特点是:数据本身通常放在 lib/data 中,可以被视为项目或模块的一部分,并且希望以一种可重复执行的方式同步到数据库。
二、createUpdatePlan 解决了什么问题
oak-backend-base/src/routines/update.ts 本质上是一个数据同步计划生成器。它会把当前项目 lib/data 中的数据与数据库里的现有数据做对比,然后按你给定的策略处理差异。
它支持的核心策略包括:
onUniqueViolation
当数据文件里的记录与数据库已有记录发生唯一索引冲突时,如何处理:
errorskipupdate
onOnlyExistingInDb
当数据库里有、但数据文件里没有时,如何处理:
skipdeletephysicalDelete
生命周期钩子
你还可以提供:
beforeCheckafterUpdate
这样就可以在真正写库前后,插入项目自定义逻辑。
三、真实项目中的升级脚本长什么样
bm-smart 已经给出了很直接的样板。
只升级 i18n
scripts/upgradeI18n.js
startup(pwd, simpleConnector, true, true, createUpdatePlan({
plan: {
i18n: { onUniqueViolation: 'update', onOnlyExistingInDb: 'physicalDelete' },
}
}))
只升级权限相关数据
scripts/upgradeAuth.js
它同步:
pathactionAuthrelationrelationAuth
同时还能在 beforeCheck 中对数据做额外修正。
新 CLI 模板已经补充了权限升级脚本入口。项目新增或调整权限模型后,应该把权限数据升级作为发布步骤的一部分,而不是只依赖 make:dep。
全量数据升级
scripts/update.js
它把 i18n、权限、以及部分业务静态数据一起纳入同步计划。
也正因为如此,npm run upgrade:all 在真实项目里的含义,不是“框架神奇地帮你升级一切”,而是:
按当前项目自己定义的 update plan,把
lib/data中指定的实体数据同步进数据库。
四、update plan 执行时会做什么
从 update.ts 的实现来看,它大致会经历下面这些步骤:
- 读取
lib/data/index; - 合并
oak-domain自带的 i18n 数据; - 对每个目标实体做前置校验;
- 分析反向引用关系;
- 查询数据库中已有的数据;
- 比较差异,决定新增、更新、跳过还是删除;
- 处理唯一索引冲突;
- 必要时更新反向引用;
- 按依赖顺序删除多余数据;
- 执行
afterUpdate。
代码里还明确做了几件对升级非常关键的事情:
- 使用
forUpdate锁定记录,减少并发问题; - 默认
blockTrigger: true,避免触发器干扰升级过程; - 支持逻辑删除和物理删除两种清理策略;
- 支持对引用关系做拓扑排序后再删除。
这说明 Oak 的数据升级工具,关注的重点是“可重复执行、引用关系正确、差异同步清晰”,而不是简单粗暴地覆盖数据。
五、升级时如何处理旧版本客户端
这是 Oak 里另一个很重要的升级问题。
oak-general-business/src/aspects/application.ts 中的 checkAppVersionSafe(...) 会根据:
system.oldestVersionplatform.oldestVersionapplication.dangerousVersionsapplication.warningVersions
来决定当前客户端:
- 是否必须升级;
- 是否只给出警告。
如果版本过低,后端会抛出 OakApplicationHasToUpgrade。与此同时,oak-general-business/src/context/BackendRuntimeContext.ts 在异常推导阶段也会继续返回这个异常。
所以 Oak 的“升级”并不只是数据库更新,它还包括:
- 如何允许旧版本继续访问;
- 从什么时候开始强制升级;
- 哪些版本只是提醒,哪些版本必须拦截。
六、一个推荐的升级顺序
在实际项目里,比较稳妥的升级顺序通常是:
- 修改代码、实体与静态数据;
- 依赖变化时先执行
project:init,再执行make:domain、make:locale、make:dep和build; - 执行
db:upgrade:plan,审核migration.sql、warnings.json、rename-candidates.json; - 发布新后端代码;
- 首次部署执行
server:init;已有库执行审核后的结构升级 SQL 或谨慎使用db:upgrade:plan -- --execute; - 执行
upgrade:locale/upgrade:auth/upgrade:all这类数据同步脚本; - 再发布前端产物;
- 根据版本策略决定是否拦截旧客户端。
如果你的升级中包含不兼容结构变更,就更不能跳过这套顺序。
七、近期框架升级特别注意
近期 Oak 框架有几类变化会影响升级判断:
- 发布包实体解析改为
es/entities -> lib/entities -> src/entities,模块不应靠发布src维持编译; oak-db新建表不再默认输出数据库物理外键,旧项目应通过db:upgrade:plan审核引用关系和生成 SQL;- update expression 已支持 MySQL / PostgreSQL,但 JSON 字段中的普通对象按字面量处理;
- 前后端 context 已带 locale,翻译、通知和多语言内容生成应改用统一上下文;
- 小程序路由以 Oak canonical route 为准,动态裸
wx.*跳转应迁移到 Oak navigator。
八、不要把升级理解成“一条命令”
最后强调一点:
Oak 的升级能力是拆开的:
db:upgrade:plan负责结构升级计划,createUpdatePlan负责静态数据同步,不能把它们理解成“一条命令包办所有升级问题”。
这并不算缺点。恰恰相反,这种拆分反而更符合真实项目:
- 代码升级归代码发布流程;
- 数据库结构升级归
db:upgrade:plan; - 静态数据升级归 update plan;
- 客户端兼容归 application 版本策略。
把这三者混成一件事,往往才是上线事故的开始。
Oak 通用业务逻辑
oak-general-business 是 Oak 生态里最重要的公共业务包之一。它并不是一个“示例仓库”,而是一套已经拆好了对象、触发器、校验器、前端组件、aspect、endpoint、feature 和后台例程的通用业务底座。用户、登录、文件、微信、OAuth、消息、文章、地址这些看上去彼此独立的能力,在这个包里都已经被组织成了可复用的模块。
如果你只是把它当成“顺手依赖一下的工具包”,那么很快就会迷路。更好的理解方式是:
src/entities定义这套通用业务到底有哪些对象;src/checkers和src/triggers定义这些对象的默认业务规则;src/aspects和src/endpoints暴露可调用的业务入口;src/features把这些入口包装成前端可直接使用的能力;src/components提供已经写好的 Oak 组件;src/watchers和src/routines/start.ts则把后台补偿任务和启动注入点也一起准备好了。
近期 oak-general-business 又补上了系统翻译相关对象、组件和触发链路,并把 Area 数据推进到全球 locale 化。也就是说,通用业务包现在不只是“登录、文件、微信、系统配置”,还承担了一部分多语言内容生产和系统翻译基础能力。
这部分为什么要重新拆分
按照目录名直接理解 oak-general-business,很容易把能力划分错。
例如:
Passport、ApplicationPassport明明和登录相关,但它们一部分依赖System,另一部分又和Application强绑定;Token虽然是一个对象,但真正的登录逻辑主要写在aspects/token.ts和features/token.ts;HumanVerify不是captcha验证码实体,而是挂在登录、注册、发送验证码入口之前的人机校验策略;OAuth既包含“用第三方账号登录 Oak 应用”的能力,也包含“把 Oak 应用作为 OAuth 服务端”的能力;ToDo甚至不是一组自动注入的触发器,而是一组需要你在项目层手工调用的辅助函数。
所以本章不再沿用“看到一个对象就开一章”的机械拆法,而是按真实的功能域来组织:
System、Passport 与系统级配置Application、Domain 与应用装配Users、Mobile 与账号体系Token 与多端登录态HumanVerify 人机校验Invite 邀请归因UserEntityGrant 授权分享Parasite 寄生登录Session、Message 与通知ExtraFile 文件与对象存储WeChat 公众号/小程序能力Article 与内容树Address、Area 与地图能力SMS 与消息模板Subscription 订阅源ToDo 协作待办Livestream 直播流OAuth 客户端与第三方登录
这样的拆法更贴近实际开发时的思考顺序。
先建立组件地图
第一次接 oak-general-business,不要一头扎进 src/components 逐个翻。更高效的做法,是先建立一张“这类业务先看哪组组件”的脑图。
1. 登录、注册与身份绑定
这一组通常先看:
passport/*user/login/*user/registerchangePassword/*wechatLogin/*oauth/*
它们覆盖的其实不是一件事,而是“正式登录入口”这一整层:
- 用户名 / 密码 / 手机验证码登录
- 微信扫码登录
- OAuth 授权登录
- 账号密码修改和补齐
所以项目层做登录页时,通常不是从零画,而是先从这几组现成组件里挑合适的入口。
2. 授权分享、临时入口与关系分发
这组最值得优先看的组件是:
invite/landingmy/inviteuserEntityGrant/listuserEntityGrant/upsertuserEntityGrant/claimparasite/listparasite/upsertparasite/detailparasite/excess
这三组能力虽然都和“分享一个入口出去”有关,但语义完全不同:
invite更偏邀请来源归因,最终记录谁邀请了哪个 tokenuserEntityGrant更偏正式授权、对象关系分发parasite更偏临时身份、一次性或短期访问入口
如果项目里有“邀请成员”“分享授权链接”“给外部用户一个临时访问口”这类需求,通常先从这里选。
3. 会话、消息与通知面板
这一组最常直接复用的是:
session/listsession/forMessagesessionMessage/listsessionMessage/upsertmessage/listmessage/detailmy/message
实际项目里,最稳的拆法通常是:
- 左侧会话列表复用
session/list - 中间消息主体复用
session/forMessage - 单条消息输入或补发再按需用
sessionMessage/upsert
这样新项目很快就能先把“能聊起来”的骨架搭出来。
4. 文件、素材与对象存储
这组最常先用的是:
extraFile/uploadextraFile/commitextraFile/forUrlextraFile/avatarextraFile/gallerywechatMaterialLibrary
其中真正最值得先理解的,还是前面几章已经详细写过的:
upload负责选文件和上传临时对象commit负责把上传结果落成正式extraFileforUrl负责纯展示或 URL 场景复用
如果项目层只想做头像上传、附件上传、图片选择,通常先从这三组开始就够了。
5. 系统、应用、域与后台配置
后台管理页最常先复用的则是:
system/*application/*domain/*config/*platform/*subscription/*
这组组件的特点不是“直接面向终端用户”,而是适合:
- 系统配置后台
- 应用接入后台
- 域名与 COS 配置页
- 订阅源和内容配置页
所以项目里一旦要做“平台后台”或“系统管理台”,先看这组通常比先写页面壳更快。
6. 哪些能力本来就没有成品组件
文档里必须把这一点说死,否则新手很容易找半天:
ToDo当前没有现成 UI 组件,主要靠createToDo(...)/completeToDo(...)helper- 某些微信菜单、自动回复、标签管理能力虽然有后台组件,但前台业务页通常还是项目层自己组合
也就是说,oak-general-business 不是“所有对象都配了成品页面”,而是:
- 一部分能力给了完整组件
- 一部分能力只给实体、trigger、checker、aspect 或 helper
理解这条边界之后,阅读源码时就不容易误判。
先看接入点,再看具体模块
一个业务项目真正“接入” oak-general-business,至少会经过三个位置。
前端运行时装配
在当前模板里,前端运行时的主入口通常是 src/initialize.ts -> src/initialize.server.ts。make:dep 会在类似 bm-smart/src/initialize.server.ts 这样的文件里,把 oak-general-business 的:
checkerscommon configurationrender configurationfeatures
和项目自己的实现合并起来,然后再创建 oak-general-business 的 features:
const ogb0Features = createOgb0Features(totalFeatures);
Object.assign(totalFeatures, ogb0Features);
这一步决定了前端运行时能不能直接调用 features.token、features.extraFile、features.application 等能力。旧的 initialize.frontend.ts 只剩历史 DebugConnector 场景,不再是当前推荐的纯前台入口。
前端启动后的初始化
在类似 bm-smart/src/initializeFeatures.ts 的文件里,还会继续调用:
await initializeOgb0Features(features, accessConfiguration, undefined, [Qiniu, S3, Aliyun]);
这个初始化过程非常关键,它至少做了几件事:
- 注册 selection / operation rewriter;
- 调用
features.application.initialize(...)识别当前应用; - 在 web 环境下设置微信网页授权落地地址;
- 注册 COS 类,打通
extraFile上传; - 在小程序环境下按需自动登录。
后台启动注入
oak-general-business/src/routines/start.ts 当前注入了三个后台启动逻辑:
- 注册 selection / operation rewriter;
- 向
oak-common-aspect注入地图服务获取逻辑。 - 注册系统翻译 timer 的运行时环境,并按已有 system 配置重建调度任务。
对应的 src/routines/stop.ts 会注销全部系统翻译 timer 并清理运行时环境。翻译调度因此是完整的启动/停止生命周期能力,不只是某个页面发起的临时任务。
后台侧不需要在 initialize.frontend.ts 中手工拼接。server:start 使用的 AppLoader 会根据依赖图从项目和依赖包的 lib/... 中合并 trigger、checker、aspect、endpoint、watcher、timer、port 和 routine。也就是说,oak-general-business 不只是“提供几个对象”,而是真的会改造项目的前后端运行时。
最小接入示例
如果你是在自己的 Oak 项目里第一次接 oak-general-business,最小可用接法通常就三步。
1. 在依赖配置和生成文件中接入公共能力
当前推荐先在 src/configuration/dependency.ts 声明 oak-general-business,再依次执行 project:init、make:domain 和 make:dep。以 bm-smart 为例,生成后的 initialize.server.ts 会把项目自己的前端运行时配置和 oak-general-business 合并:
const totalCheckers = mergeConcatMany([checkers, ogb0Checkers])!;
const totalCommon = mergeConcatMany([common, ogb0Common])!;
const totalRender = mergeConcatMany([render, ogb0Render])!;
const ogb0Features = createOgb0Features(totalFeatures);
Object.assign(totalFeatures, ogb0Features);
这一步做完后,项目里才真正拥有:
features.tokenfeatures.applicationfeatures.extraFilefeatures.wechatSdkfeatures.template- 以及对应的 checker / common / render 装配
后端 trigger、checker、aspect、endpoint、watcher、timer、port 和 routine 则由 AppLoader 在服务端运行态按依赖图合并。
2. 应用启动后继续执行 initializeOgb0Features(...)
在 bm-smart/src/initializeFeatures.ts 里,真实写法是:
await initializeOgb0Features(
features,
accessConfiguration,
undefined,
[Qiniu, S3, Aliyun]
);
这一步会继续做四件关键的事:
- 注册 selection / operation rewriter;
- 调用
features.application.initialize(...)识别当前应用; - web 环境下设置微信落地地址;
- 给
features.extraFile注册 COS 实现; - 小程序环境下按需自动登录。
如果项目启用系统翻译,还需要在初始化、静态数据同步和权限升级脚本里一起考虑翻译相关实体和动作。只执行 make:locale 只能生成 i18n 数据,不能替代系统翻译状态和数据升级。
3. 业务页面里统一从 feature 取能力
项目层真正使用时,通常会像 bm-smart 这样写:
const application = this.features.application.getApplication();
const systemId = application.systemId;
const userId = this.features.token.getUserId(true);
const imageUrl = this.features.extraFile.getUrl(extraFile);
也就是说,项目层一般不会自己去重写“应用识别”“token 管理”“文件 URL 拼接”这些公共逻辑,而是直接站在 oak-general-business 的 feature 上继续写业务。
参考项目里的真实接法
如果你已经在看 haina-busi 和 taicang,会发现这两个项目接 oak-general-business 的方式其实很接近,而且都不是“单独只初始化 general-business 一次”这么简单。
1. 初始化阶段先同时创建 ogb0 和 opb1
这两个项目都会在:
src/initialize.server.tssrc/initializeFeatures.ts
里创建和初始化:
createOgb0Features(...)createOpb1Features(...)
前端运行时负责合并 checkers / common / render / features,后台侧的 aspects / triggers / watchers / timers / data / ports / routines 则由 AppLoader 按依赖图合并进服务端运行态。
2. 很多项目最终只调用 initializeOpb1Features(...)
这点非常容易让新手困惑。haina-busi/src/initializeFeatures.ts 和 taicang/src/initializeFeatures.ts / initializeFeatures.web.ts 实际都只调用了:
await initializeOpb1Features(features, accessConfiguration, config, cosClazzes);
之所以成立,不是因为它们“没用 general-business”,而是因为 oak-pay-business/src/features/index.ts 里的 initialize(...) 内部本来就先调用了:
await initializeGeneral(features, access, mergedConfig, clazzes);
也就是说:
- 只接
oak-general-business的项目,自己调用initializeOgb0Features(...) - 同时接
oak-pay-business的项目,通常直接调用initializeOpb1Features(...)就够了
3. 真实项目还会把启动注册写在 routines 里
oak-general-business 不只是组件和 feature。像 haina-busi、taicang 这类项目,还会在启动例程里继续注册:
- 短信实现:
registerSms(...)/registSms(...) - COS 实现:
registerCosBackend(...)/registerCos(...) - 消息类型:
registerMessageType(...) - 消息/通知处理器:
registerMessageHandler(...)、registerNotificationHandler(...)
所以新手读公共包时,最好把“初始化 feature”和“启动时注册具体供应商实现”一起看,不要只看页面层。
阅读源码时建议按这个顺序
第一次阅读 oak-general-business,建议按下面的顺序走:
- 先看
src/features/index.ts - 再看
src/aspects/index.ts - 然后看
src/endpoints/index.ts - 再看
src/watchers/index.ts - 然后回到
src/entities - 最后才去翻
src/components
原因很简单:新手最容易被组件数量吓住,但真正决定模块能力边界的,其实是 feature / aspect / endpoint / trigger / checker / watcher 这些运行时入口。
接下来的每一章,我都会把下面这些信息明确写出来:
- 这一块对应哪些实体;
- 已经有哪些可直接复用的组件;
- 前端通过哪些 feature 调用;
- 后端通过哪些 aspect 或 endpoint 暴露;
- 后台还有哪些 trigger / checker / watcher / routine 在兜底;
- 这些能力究竟是在哪里被注入到项目里的。
System、Passport 与系统级配置
System 是 oak-general-business 里最顶层的业务配置对象。很多新手刚看到它时,会把它当成一个普通的“系统信息表”;但在 Oak 里,它的作用更接近“整套通用业务逻辑的系统级根配置”。
密码规则、邮箱能力、地图服务、样式、最低版本约束、默认登录方式,这些看起来互不相关的能力,最终都要么直接挂在 system.config 上,要么通过 system 去找到对应的 passport、application 和 platform。
主要对象
这一章最重要的两个实体是:
System:定义系统名称、描述、配置、样式、所属平台以及最低版本等系统级信息;Passport:定义一个系统允许出现哪些登录方式,例如sms、email、loginName、wechatPublicForWeb、wechatMpForWeb、oauth等。
其中 Passport 不是“用户已经启用的登录方式”,而是“系统层面允许提供哪些登录入口”。真正落实到某个应用上,还要看后面的 ApplicationPassport。
组件
围绕这两个对象,oak-general-business 已经提供了几组常用组件:
src/components/system/detailsrc/components/system/panelsrc/components/system/passportsrc/components/system/upsertsrc/components/passport
此外,src/components/platform/detail、platform/panel、platform/system、platform/upsert 这组组件虽然属于 Platform,但在实际管理后台里通常也是和 System 一起出现的。
组件适合放在哪里
结合 haina-busi 和 taicang 的现有页面,最常见的放法其实很固定:
system/panel:放在系统详情页、系统配置页,例如haina-busi/src/pages/business/system/web.pc.tsx、taicang/src/pages/console/system/panel/web.pc.tsxsystem/upsert:放在系统创建页或系统基本信息编辑页,taicang的系统控制台就是这种拆法platform/panel:放在平台详情页,适合把平台级附加配置做成页签挂进去,haina-busi/src/pages/business/platform/web.pc.tsx就传了自定义tabs
system/panel / platform/panel 常用参数
这两个组件项目层最常传的参数其实很少,核心就是:
oakId:当前systemId或platformIdoakPath:当前页面下的数据节点路径tabs:给公共面板追加项目自己的页签
从源码看,system/panel 自带的投影里已经包含:
nameconfigdescriptionoldestVersionsuperplatformIdstyletranslationtranslateStatetranslationErrordomain$system
所以它并不是一个“空壳 tab 容器”,而是已经默认把系统基础资料、样式、域名等常用配置一起带上了。
system/panel / platform/panel 内置了哪些页签
这两个组件的另一个关键点,是它们其实已经内置了系统管理后台的大部分标准结构。
system/panel 默认包含:
detailconfigtranslation(内含翻译配置与翻译数据管理)styleapplication-listdomain-listsmsTemplate-listloginoauth-manage
platform/panel 默认包含:
detailconfigstylesystem-list
所以项目层如果只是要做常规系统后台,通常根本不需要重写页面骨架;真正需要自定义的,往往只是再追加一两个业务 tab。
haina-busi 的平台页就是一个很典型的例子:
<PlatformPanel
oakId={platformId}
oakPath={`${oakFullpath}-PlatformPanel`}
tabs={[
{
label: '演示账号设置',
key: 'demo-account-config',
children: <DemoAccountConfig oakId={platformId} oakPath={`${oakFullpath}-DemoAccountConfig`} />,
},
]}
/>
system/passport 是一个组合组件
很多人第一次看到“登录配置”时,会分别去找 passport 列表和 applicationPassport 列表。但从 oak-general-business/src/components/system/passport/web.pc.tsx 看,公共包其实已经把这两块封成了一个组合组件:
- 第一个 tab 是
PassportList - 第二个 tab 是
ApplicationPassport
它最关键的参数也很少:
systemIdsystemNameoakPath
这意味着它适合直接作为“系统登录配置”页签挂进 system/panel 或项目自己的系统详情页里,而不是让项目层手工再拼一次“系统级登录方式 + 应用级登录方式”。
从职责上看,这个组件解决的是两层配置连续操作的问题:
- 先在系统层确认有哪些
passport - 再在应用层确认每个
application实际启用哪些passport
这两个动作如果拆成两张完全独立的页面,新手很容易不知道应该先配哪一层;而 system/passport 的组合页签正好把这个顺序固定下来了。
前端入口
系统级配置相关的前端入口主要不是专门的 system feature,而是 features.config:
updateConfig(...)updateStyle(...)
对应的后端 aspect 位于 src/aspects/config.ts:
updateConfigupdateStyle
这也说明一个很典型的 Oak 设计习惯:有些对象本身不一定有独立 feature,但会通过更通用的 feature 来暴露操作入口。
后台规则
这一章最值得认真读的是 src/triggers/system.ts 和 src/triggers/passport.ts。
System 的触发器会自动维护登录方式基础设施:
- 新建
system时,自动创建sms和loginName类型的passport; - 更新
system.config.Emails时,自动创建、启用、关闭或删除email类型的passport; - 删除
system时,自动清理相关passport。
Passport 的触发器则负责把系统级登录方式和应用级登录方式解耦:
- 禁用
passport时,删除关联的applicationPassport; - 删除
passport时,也同步删除关联的applicationPassport。
对应的 checker 在 src/checkers/system.ts 中,负责约束系统配置的合法性。
注入点
这一组能力的注入点有两个:
- 后端通过
ogb0Triggers、ogb0Checkers合并进入项目初始化; - 前端通过
createOgb0Features(...)创建出的features.config暴露配置修改入口。
也就是说,System / Passport 不是一个“手工管理的静态配置区”,而是已经被接到 Oak 运行时里的。
项目中如何接入
System 这一章在项目里的接入,通常不是“写一个页面去查 system 表”这么简单,而是三层一起接:
- 初始化阶段合并
oak-general-business的triggers / checkers / aspects / watchers / common / routines; - 前端创建并初始化
ogb0Features; - 后台或管理台通过
System.config、Passport、ApplicationPassport配出系统级能力。
在 bm-smart 这类项目里,最小接入代码就是:
const ogb0Features = createOgb0Features(totalFeatures);
Object.assign(totalFeatures, ogb0Features);
await initializeOgb0Features(
features,
accessConfiguration,
undefined,
[Qiniu, S3, Aliyun]
);
这一层接好以后,后面用户、token、文件、微信、短信等能力才会按 system.config 真正工作。
真实项目里的页面包法
haina-busi 和 taicang 都没有自己重写整套系统配置后台,而是用一个很薄的页面壳去包公共组件:
<SystemPanel
oakId={systemId}
oakPath={`${oakFullpath}.system`}
/>
这也是更推荐的项目层写法。系统配置页尽量只负责:
- 从路由或父节点拿到
systemId - 传递稳定的
oakPath - 根据项目需要补一两个自定义页签
不要把 System、Passport、ApplicationPassport 的增删改查又在项目里重做一遍。
开发注意事项
如果你准备扩展系统管理页,最稳妥的方式通常不是改公共组件内部,而是继续往 tabs 里挂项目自己的块。因为:
system/panel已经依赖了固定的系统投影platform/panel也是同样的设计- 项目层只补自定义 tab,最不容易和公共包后续升级冲突
使用示例
1. 在管理台更新系统配置
oak-general-business/src/types/Config.ts 已经把 System.config 的结构定义得很清楚了,所以项目里最推荐直接通过 features.config.updateConfig(...) 去更新:
await this.features.config.updateConfig('system', systemId, {
App: {
scanPage: 'wechatQrCode/scan',
wechatQrCodeExpireSeconds: 2592000,
tokenRefreshTime: 60 * 60 * 1000,
tokenExpireTime: 7 * 24 * 60 * 60 * 1000,
needManualVerification: true,
},
Password: {
min: 8,
max: 24,
verify: true,
regexs: ['^(?=.*[A-Z])(?=.*\\d).+$'],
},
Sms: {
defaultOrigin: 'tencent',
},
Map: {
amaps: [{ key: 'your-amap-key', type: 'web' }],
},
});
这里的 scanPage 是微信扫码后的中转页逻辑路径,默认 wechatQrCode/scan;小程序侧会自动拼成 pages/<scanPage>/index。wechatQrCodeExpireSeconds 是微信临时二维码有效期,单位秒,不配置时默认 2592000,超过微信上限也会按 2592000 处理。
2. 用组件管理登录方式,而不是自己拼表单
系统级登录方式和应用级登录方式,推荐直接复用这些现成组件:
src/components/passport/*src/components/applicationPassportsrc/components/config/upsertsrc/components/config/application
这样后续 components/user/login、features.token、微信登录组件拿到的就是同一套配置,不会出现“页面判断一套、后台校验另一套”的问题。
使用建议
对于一个新项目,最推荐的顺序是:
- 先创建并配置
System; - 再确认
system.config中的Password、Emails、Map、Security等系统级规则; - 然后检查系统级
Passport是否已经被自动建立; - 最后再去每个
Application上配置具体启用哪些登录方式。
如果把这几个步骤反过来做,就很容易出现“界面上有登录组件,但后端并没有正确的 passport 和配置支撑”的情况。
Application、Domain 与应用装配
如果说 System 解决的是“这套业务系统总体怎么配置”,那么 Application 解决的就是“这套业务系统要以什么端形态对外运行”。
在 Oak 里,一个系统往往不只对应一个前端入口。你可能同时有:
webwechatMpwechatPublicnative
每一种端形态都对应一条 Application 记录。oak-general-business 会根据当前访问环境、域名和版本,自动判断你现在到底命中了哪个应用。
主要对象
这一章实际涉及四个对象:
Application:定义应用类型、系统归属、端配置、样式、版本策略;Domain:定义域名和访问入口;ApplicationPassport:定义某个应用实际启用了哪些登录方式;Platform:系统上级平台对象,通常在系统/应用管理界面里一起出现。
其中 Application.config 是最关键的数据,它把不同端需要的配置统一放在一起:
web的微信网页登录配置;wechatMp的appId/appSecret/server;wechatPublic的公众号配置和跳小程序配置;native的微信原生登录配置。
这里要特别注意:访问入口已经不再放在 Application.config.location 里。当前版本把前端页面地址、扫码中转页和后台接口路径拆开维护:
Domain负责系统级域名、协议、端口和 API 代理路径;Application.domainId只在某个应用必须绑定指定域名时使用;System.config.App.scanPage负责微信扫码后的中转页逻辑路径;System.config.App.wechatQrCodeExpireSeconds负责微信临时二维码有效期。
组件
围绕应用管理,这个包提供的组件已经很完整:
src/components/application/detailsrc/components/application/detailForPlatformsrc/components/application/panelsrc/components/application/upsertsrc/components/application/cossrc/components/domain/detailsrc/components/domain/listsrc/components/domain/upsertsrc/components/domain/upsertItemsrc/components/applicationPassportsrc/components/config/applicationsrc/components/config/stylesrc/components/theme/setting
其中 Application 组件更偏向“应用本身”的管理,而 Domain 组件族负责维护域名入口。两者合在一起,才构成真正完整的“应用装配后台”。
如果你是在写一个管理后台,这些组件通常可以直接复用,而不需要从零搭应用管理页。
常用组件与参数
这一组组件里,最常直接包页面的通常是下面四类:
application/paneldomain/listapplicationPassportsystem/application
其中几个最关键的参数分别是:
application/panel:tabsdomain/list:systemIdapplicationPassport:systemIdsystem/application:systemId
从源码看:
application/panel和system/panel、platform/panel一样,都支持通过tabs追加项目自己的页签domain/list会直接按systemId过滤域名applicationPassport会按systemId拉这个系统下所有application与passport,再生成“每个应用启用哪些登录方式”的管理矩阵
这意味着如果项目已经有系统详情页,通常不需要自己拼:
- 应用列表
- 域名列表
- 应用级登录方式开关
而是直接把这几块公共组件挂进对应页签里。
application/panel 内置了哪些页签
这点很值得单独写出来,因为很多人会误以为 application/panel 只是一个“留给项目自己填内容的面板壳”。实际上从源码看,它默认已经包含:
detailconfigstylecos
如果 application.type === 'wechatPublic',还会自动再补:
menuautoReplytagusertemplate
如果 application.type === 'wechatMp',还会自动补:
template
也就是说,项目层在大多数情况下只需要继续往 tabs 里补“自己的业务 tab”,而不是把微信菜单、自动回复、模板管理这些基础页签再手工拼一遍。
domain/list 的真实交互
domain/list 不是一个只读列表。从 oak-general-business/src/components/domain/list/web.pc.tsx 看,它在 web 端已经把常见管理动作都包进去了:
- 组件内部始终按
systemId过滤域名 - 点击“创建”时会先
addItem({ systemId }),也就是新建行会自动挂到当前系统下 - 创建和更新都不是跳新页面,而是直接弹
DomainUpsertItem的模态框 - 列表通过启用 / 禁用动作控制域名是否参与运行时匹配,不再把删除作为常规入口
它默认编辑和展示的字段就是:
urlapiPathportprotocolableState
所以项目里如果只是做“系统域名配置”,更推荐直接把它挂到系统详情页或系统配置页里,而不是自己再写一套域名 CRUD。
Domain 的运行时语义
Domain 现在是应用访问入口的统一来源。它的字段含义不要和 src/configuration/access.ts 混在一起:
protocol和url组成站点基础地址,例如https://example.comport是可选字段,80、443 这类默认端口通常不需要写apiPath只给后台接口地址使用,常见于 nginx 反向代理路径ableState为disabled时,这条域名不会参与应用识别和二维码链接兜底
oak-general-business/src/utils/domain.ts 里有两个拼接函数,名字就体现了这个边界:
composeDomainUrl(domain, url, props):拼面向前端页面的地址,不拼apiPathcomposeServerUrl(domain, url, props):拼面向后台接口的地址,会先拼apiPath
微信扫码图文链接、邀请落地页、开发环境二维码调试链接这类“用户浏览器要打开的页面”,应该走 composeDomainUrl(...)。后台 API、endpoint、反向代理服务地址才应该走 composeServerUrl(...)。
Application.domainId 与兜底规则
getApplication 会按“端类型 + 当前域名 + 版本”识别当前应用。当前域名匹配时的顺序是:
- 先找
application.domainId指向的启用域名; - 找不到时,再找同一个
system下没有指定domainId的同类型应用; ableState === 'disabled'的域名会被排除。
所以 domainId 不需要每条 application 都配置。更推荐的做法是:
- 一个系统下某个类型只有一个应用时,可以让应用不填
domainId,由系统启用域名兜底; - 一个系统下有多个
web或多个同类型应用时,给需要区分的应用配置domainId; - 不想某条域名继续被运行时命中时,禁用它,而不是直接删除历史数据。
getApplicationDomain(application) 也遵循类似语义:如果应用有 domainId,优先取这个启用域名;否则从 system.domain$system 里挑启用域名兜底。兜底排序会优先普通域名,其次 IP,最后 localhost,避免生产环境误用本地域名。
扫码中转页与二维码有效期
微信二维码相关配置现在放在 System.config.App:
await this.features.config.updateConfig('system', systemId, {
App: {
scanPage: 'wechatQrCode/scan',
wechatQrCodeExpireSeconds: 2592000,
},
});
scanPage 写逻辑路径即可,默认是 wechatQrCode/scan。公共工具会做规范化:
- web / 公众号图文链接直接使用这个路径,例如
https://example.com/wechatQrCode/scan?scene=... - 小程序码会拼成
pages/wechatQrCode/scan/index - 如果误写成
/wechatQrCode/scan、pages/wechatQrCode/scan/index,运行时也会规整成同一逻辑路径
wechatQrCodeExpireSeconds 单位是秒,用于微信临时二维码和本地 expiresAt。不配置、配置非法值或小于等于 0 时,默认 2592000 秒;超过微信上限时也会按 2592000 秒处理。
wechatQrCode 的应用选择
wechatQrCode 创建时可以显式指定 applicationId 和 type。如果指定了,就按这个目标应用生成二维码;如果没有指定,公共 trigger 才进入自动兜底:
- 优先使用
System.config.App.qrCodeApplicationId和qrCodeType - 当前应用是服务号时,生成公众号二维码
- 当前应用是小程序时,有
qrCodePrefix就生成小程序普通链接二维码,否则生成小程序码 - 当前应用不是微信端时,优先找系统下服务号,再找小程序
这意味着项目层需要强约束二维码目标时,应该在创建 wechatQrCode 时传目标应用;只想使用系统默认策略时,再交给公共包自动兜底。
applicationPassport 的真实交互
applicationPassport 也值得单独说明,因为它并不是一个简单的“勾选启用登录方式”组件。当前源码的真实行为是:
- 必传
systemId - 进入页面时,会先拉当前系统下所有
application - 再拉当前系统下所有
enabled: true的passport,并排除password - 最终按“应用”为行、“登录方式类型”为列,生成一张配置矩阵
这个矩阵里还有两个很容易忽略的点:
default列不是展示字段,而是真正的“默认登录方式”选择器loginName一旦启用,会直接创建isDefault: true、allowPwd: true的applicationPassport
从组件内部逻辑看,allowPwd 相关行为也已经做了约束:
loginName会强制带密码,界面上不会允许你把它关掉sms、email在启用后可以额外控制allowPwd
另外,如果同一系统下同一类 passport 不止一个,组件不一定用单纯的开关。对 web 应用,或者 wechatMp / wechatPublic 下的 sms、email,它会切成下拉选择模式,让你明确指定这一类登录方式到底绑定哪一个 passport。
前端入口与 aspect
应用管理相关的前端 feature 主要有:
features.applicationfeatures.configfeatures.themefeatures.template
对应的后端 aspect 主要有:
getApplicationsignatureJsSDKupdateApplicationConfigupdateConfigupdateStylegetApplicationPassportsremoveApplicationPassportsByPIds
其中最核心的是 getApplication。features.application.initialize(...) 最终就是通过这个 aspect,按“端类型 + 域名 + 版本”确定当前应用,并把应用数据缓存到前端。
后台规则
这一章真正的复杂性,分散在四个文件里:
src/checkers/application.tssrc/triggers/application.tssrc/checkers/applicationPassport.tssrc/triggers/applicationPassport.ts
Application 相关规则包括:
- 校验
dangerousVersions、warningVersions、soaVersion的合法性; - 创建
application时校验name/type/systemId,并在缺省时补一个空config; - 创建
application时自动补config.type; - 创建
application时按type自动准备基础passport; - 根据
application.type和配置,自动创建、删除或关闭相关passport; - 删除
application时清理关联的applicationPassport和某些自动生成的passport。
ApplicationPassport 则负责“应用级登录方式选择”:
- 只允许同一个应用有一个默认登录方式;
loginName类型在创建前会自动把allowPwd置为true;- 删除或更新默认登录方式时会自动维持一致性。
注入点
应用装配能力的注入点有三个非常关键:
create(...)
oak-general-business/src/features/index.ts 在 create(...) 时会创建 application feature,并把它挂到 features.application。
initialize(...)
真正让它生效的,是后续的 initialize(...):
- 注册 selection / operation rewriter;
- 调用
features.application.initialize(...)识别当前应用; - 在 web 环境下设置微信落地地址;
- 在后续章节里还会看到,它也会顺手带动文件上传、小程序自动登录等流程。
后端装配
前端只是把 application feature 建起来,真正让 Application / Domain / ApplicationPassport 的规则生效,还需要后端运行态加载公共包能力:
ogb0Aspectsogb0Checkersogb0Triggers
当前后端装配不再靠项目手写 initialize.frontend.ts 拼接。server:start 启动的 AppLoader 会根据依赖图从项目和依赖包的 lib/... 里合并 aspects / checkers / triggers / watchers / timers / ports / routines 等模块。这样 getApplication、版本校验、默认登录方式维护、自动补 passport 等逻辑才会进入运行时。
项目中如何接入
Application 这一章是 oak-general-business 真正落到项目运行时的第一入口。项目接入它,一般有两步:
- 在
src/configuration/dependency.ts声明oak-general-business,依赖变化后依次执行project:init、make:domain和make:dep; - 前端运行时由生成的
initialize.server.ts创建createOgb0Features(...),再在启动后通过initializeFeatures.ts执行initializeOgb0Features(...),让features.application.initialize(...)自动识别当前应用。
bm-smart/src/initializeFeatures.ts 的真实写法就是:
await initializeOgb0Features(
features,
accessConfiguration,
undefined,
[Qiniu, S3, Aliyun]
);
而 oak-general-business/src/features/index.ts 内部会继续调用:
await features.application.initialize(
oakGetPackageJsonVersion(),
access.http.hostname,
undefined,
config?.applicationExtraProjection
);
所以对项目层来说,最重要的不是“怎么手工查 application 表”,而是保证这条初始化链路先走通。
真实项目里的初始化方式
如果你的项目同时接了 oak-pay-business,真实写法往往不会直接显式调用 initializeOgb0Features(...)。haina-busi 和 taicang 都是这样:
await initializeOpb1Features(
features,
accessConfiguration,
{
applicationExtraProjection: APPLICATION_PROJECTION,
},
[ALiYun, S3]
);
或者:
await initializeOpb1Features(
features,
accessConfiguration,
{
dontAutoLoginInWechatmp: true,
},
[Qiniu]
);
这不是绕过了 Application,而是 oak-pay-business/features.initialize(...) 内部已经先调用了 oak-general-business/features.initialize(...),并且会把支付域需要的 applicationProjection 和项目自定义投影合并起来。
项目里最容易漏掉的两件事
- 后端必须把
ogb0Aspects / ogb0Checkers / ogb0Triggers真正并进运行时,否则getApplication、默认登录方式维护、应用配置校验都不会生效 - 前端初始化时如果项目还要做文件上传或小程序自动登录,需要把 COS 类列表一并传给初始化函数
开发注意事项
应用装配这章还有两个很容易踩坑的地方:
application feature识别当前应用时依赖域名、版本和额外投影,所以项目自己的applicationExtraProjection不要把公共包需要的字段覆盖掉applicationPassport组件不是简单勾选框,它内部会同时读取系统下的application和passport,所以系统层登录方式没有配好时,应用级页面看起来就会“没有可选项”
使用示例
1. 页面里统一从当前应用取 systemId
这类写法在 bm-smart 里非常常见:
const systemId = this.features.application.getApplication().systemId;
return {
systemId,
};
这样写的好处是,不需要页面自己判断域名、端类型、当前 appId,全部交给 features.application。
2. 后台逻辑里统一从 context 取当前应用
在 aspect、trigger、watcher 里,更推荐直接走 context.getApplication():
const application = context.getApplication();
const { system } = application!;
oak-general-business 里大量 token、短信、文件、微信逻辑都是这么拿当前应用和系统配置的。
3. 管理台修改应用配置
如果要在项目里改某个应用的端配置,推荐直接用 features.config.updateApplicationConfig(...):
await this.features.config.updateApplicationConfig('application', applicationId, {
type: 'web',
wechat: {
appId: 'wx-app-id',
appSecret: 'wx-app-secret',
enable: true,
},
});
应用自己的配置只保留端能力,例如微信 appId、COS 默认源、邀请落地页等;站点访问地址不要再写回 Application.config.location。
4. 配置系统域名与扫码页
域名入口和扫码中转页分别配置:
await this.features.config.updateConfig('system', systemId, {
App: {
scanPage: 'wechatQrCode/scan',
wechatQrCodeExpireSeconds: 2592000,
},
});
Domain 本身建议通过 domain/list 管理组件维护。如果是初始化数据,大致形态是:
const domain = {
protocol: 'https:',
url: 'example.com',
apiPath: '/rest/aspect',
ableState: 'enabled',
systemId,
};
如果使用 80 或 443 默认端口,可以不写 port。
使用建议
新手最容易犯的错误,是把 Application 当成一个普通配置对象来手工查询。实际上更推荐的方式是:
- 在前端永远通过
features.application.getApplication()取当前应用; - 在后台通过
context.getApplication()取当前上下文应用; - 把“一个系统下不同端”的端能力差异收敛到
Application.config和ApplicationPassport上; - 把访问入口、扫码 URL 和邀请落地页绝对地址交给
Domain和System.config.App。
这样一来,登录、文件、微信、版本控制这些横向能力,才能真正围绕应用自动生效。
Users、Mobile 与账号体系
oak-general-business 的“用户系统”并不是一个单表模型,而是一套拆得很细的身份体系。
User 只负责保存用户本身;手机号、账号名、验证码、改密过程、登录方式约束,则分别落在不同对象和不同规则层里。这样做的好处是:你可以非常清晰地控制“谁是用户本体”“哪些是登录凭证”“哪些只是一次性的验证过程”。
主要对象
这一章最重要的对象有:
User:保存姓名、昵称、性别、实名认证状态、密码相关状态、头像文件、地址等;Mobile:手机号凭证;LoginName:账号名凭证;Captcha:短信/邮箱验证码;ChangePasswordTemp:改密过程记录。
从业务角度看,这里最值得记住的一点是:User 不等于“所有登录信息”。登录凭证被拆在 Mobile、LoginName 等对象里,这恰好也是 Oak 后续能够灵活切换登录方式的基础。
组件
这部分已经提供了不少现成组件:
src/components/user/infosrc/components/user/managesrc/components/user/registersrc/components/user/passwordsrc/components/mobile/loginsrc/components/mobile/manageListsrc/components/mobile/upsertsrc/components/changePassword/byPasswordsrc/components/changePassword/byMobilesrc/components/my/infosrc/components/my/avatar
这些组件大多不是独立运行的,它们通常和后面的 Token 能力一起使用。
mobile/upsert 常用参数
oak-general-business/src/components/mobile/upsert/index.ts 最常用的三个参数是:
userId:要给哪个用户绑手机号maxNum:同一个用户最多允许绑定多少手机号onFinish:绑定完成后的回调
taicang/src/pages/frontend/user/authentication/web.pc.tsx 里就是:
<MobileUpsert
maxNum={5}
userId={userId}
oakPath={oakFullpath + '.mobiles'}
onFinish={() => {
onFinishByMobile();
}}
/>
mobile/login 常用参数
mobile/login 更适合做“只有手机号这一路登录”的页面或弹窗。当前常用参数有:
onlyCaptchaonlyPasswordcallbackeventLoggedIn
从组件源码看,它当前最真实的行为是:
- 发验证码时固定走
features.token.sendCaptcha('mobile', mobile, 'login') - 验证码登录时固定走
features.token.loginByMobile(mobile, captcha) - 小程序环境可以直接调
features.token.getWechatMpUserPhoneNumber(code)拉手机号 - 验证码发送节流时间会写到本地存储里,开发环境默认 10 秒,生产环境默认 60 秒
这意味着如果项目里已经确定只允许手机号登录,不需要再用大而全的 user/login 做裁剪,直接用 mobile/login 更直接。
mobile/manageList 的适用场景
这个组件比 mobile/upsert 更轻,它更像“当前数据节点里的手机号数组编辑器”。当前源码层面的行为非常简单:
addItem({ mobile: '' })直接增加一条空手机号updateItem(...)就地修改某条手机号removeItem(...)删除当前项
所以它更适合:
- 后台用户详情页里的手机号维护
- 不需要验证码校验的内部资料编辑页
- 纯 Oak 数据编辑流里的手机号列表节点
如果场景要求真正发验证码、绑定当前登录用户、控制最大绑定数,还是应该优先用 mobile/upsert。
userAuth/upsert 常用参数
当前实名认证编辑组件位于 oak-general-business/src/components/userAuth/upsert/index.ts,旧的 components/user/authenticate 已不存在。它最关键的参数是:
userId:创建认证记录时绑定的用户;origin:证件照片上传走哪个 COS 来源idCardType:可选的固定证件类型;传入后前端不允许切换autoUpload:是否自动上传证件图片needUploadPhotos:是否要求必须上传证件照片
组件在没有 oakId 的创建态下,会结合 userId 与当前 application 的 systemId 初始化 userAuth;提交动作仍由挂载它的页面或父组件通过当前 Oak 路径执行,不再通过旧的 onFinish 回调提交。
近期用户证件校验类型已经修正,认证链路会按 idCardType 区分身份证、护照和港澳台通行证等场景。项目层不要把证件类型当成普通字符串随意扩展;如果确实要新增类型,需要同时补实体枚举、entityDesc.locales.v.idCardType、证件照片标签和 checker 校验。
user/register 的真实依赖
user/register 不是一个固定规则的静态注册表单,它会在 ready() 时动态读取两套配置:
- 当前应用的
applicationPassport,找出loginName对应的passport.config - 当前系统的
system.config.Password
这意味着它会直接受这些配置影响:
loginName的最小/最大长度loginName是否启用正则校验- 当前应用是否允许注册
- 密码最小/最大长度
- 密码是否强校验
- 密码存储模式是否为
sha1
所以项目里如果发现“注册页规则和登录页规则不一致”,优先去看:
ApplicationPassport对应的loginName配置System.config.Password
而不是先怀疑前端组件本身。
user/info / user/manage / user/manage/detail
这三组组件分别解决不同层级的问题:
user/info:给当前用户自己改资料,常传changeMobileUrl、changePasswordUrl、authenticateUrl、onConfirmuser/manage:给后台做用户列表,常传userDetailUrl、createUserUrluser/manage/detail:给后台做单个用户详情,常传updateUserUrl、onUserUpdate、onUserPlay
taicang 前台个人资料页多处都把:
changePasswordUrl="/user/password/update"
直接传给 user/info,这样用户资料页和改密页就接起来了。
这三组组件还有几个源码里很明确的行为,值得直接写在文档里:
user/info会直接读取mobile$user、extraFile$entity(tag1='avatar')、wechatUser$useruser/info如果当前 token 对应的就是本应用的微信用户,会允许同步微信资料user/manage的搜索不是只搜昵称,而是同时按$text和mobile$user.mobile $startsWith查user/manage/detail在 root 查看他人时会额外暴露play动作,底层直接调用features.token.switchTo(...)
也就是说,如果项目想复用这些组件,实体关系最好保持和公共包一致;尤其头像、手机号、微信绑定关系不要随意改名。
my/info 更适合做轻量个人中心
如果页面只是“我的资料卡片”,不需要完整的 user/info 编辑表单,那么更适合直接用 my/info。它和 user/info 的区别在于:
- 数据直接从
features.token.getUserInfo()取 - 默认展示昵称/姓名、手机号、实名状态、用户状态、性别
- 可以直接调用
features.token.logout() - 支持用
updateAttribute(attr, value)就地更新当前用户字段
它还带一个很实用的参数:
showLogout
所以项目里的:
- “我的”首页
- 个人中心头部卡片
- 轻量版账户信息页
通常更适合挂 my/info,而不是一开始就上完整的 user/info。
user/password/update / user/password/verify
这两组组件项目里通常成对出现:
user/password/update:常用参数是once、onSuccessuser/password/verify:常用参数是onVerified
源码里这两个组件都会主动读取 system.config.Password,所以密码长度、正则、是否需要校验都应该在 System.config 里配,而不是写死在页面上。
user/password/update 还有一个很容易忽略的参数语义:
once: true时只输入一次密码,适合后台直接重置用户密码once: false时才会要求重复确认密码,适合用户自己修改密码
而 user/password/verify 则更像一个“敏感操作前置确认器”,它不会自己改密码,而是:
- 读取当前系统的密码模式
- 必要时按
sha1处理输入 - 调用
features.token.verifyPassword(...) - 验证成功后再执行
onVerified
所以删账号、切身份、提现申请前确认之类的动作,都很适合先包一层这个组件。
changePassword/byPassword / changePassword/byMobile
这两组组件和 user/password/update 不完全是一回事。前者更像“完整找回/修改密码流程页”,后者更像“密码输入控件”。
changePassword/byPassword 的真实行为是:
- 读取当前系统密码策略
- 调
updateUserPassword({ userId, prevPassword, newPassword }) - 如果密码模式是
sha1,会先做加密 - 后端返回失败次数时,会把
times写回页面状态
它更适合“用户已登录,知道旧密码,走常规改密”的页面。
changePassword/byMobile 的真实行为则是:
- 从当前用户的
mobile$user里取启用中的手机号 - 发送用途为
changePassword的验证码 - 调
updateUserPassword({ userId, mobile, captcha, newPassword }) - 同样会跟随系统密码模式决定是否
sha1
它更适合:
- 忘记旧密码但还能验证手机号
- 需要走短信校验后改密
- 前台个人中心里的“手机验证改密”
前端入口与 aspect
这一章没有单独的 user feature,用户体系的前端入口主要通过 features.token 间接暴露。
与用户资料和账号体系直接相关的 aspect 包括:
registerUserByLoginNamegetChangePasswordChannelsupdateUserPasswordmergeUserbindByMobilebindByEmailsendCaptchaByMobilesendCaptchaByEmail
也就是说,用户体系并不是靠“前端直接操作数据行”来完成的,很多关键动作已经被封装成了命名业务接口。
后台规则
这一章最需要熟悉的文件是 src/triggers/user.ts 和 src/checkers/user.ts。
默认规则包括:
- 新建用户时,初始状态默认是
shadow; - 系统里创建出的第一个用户默认会成为
root; - 更新密码相关字段时,会自动维护
hasPassword; - 用户激活后,会把相关的
parasite失效; - 实名认证时,会根据系统配置决定是否自动通过。
对应 checker 则负责限制敏感操作:
- 非 root 用户不能任意禁用、删除关键用户;
- 实名认证所需数据必须满足要求;
- 某些敏感字段不能随意更新。
此外,src/triggers/mobile.ts 还会在删除手机号前清理相关的失效 token。
注入点
用户体系的注入点主要有两个:
- 后端:通过
ogb0Triggers、ogb0Checkers合并进入项目; - 前端:通过
features.token暴露登录、绑手机号、发验证码等动作;注册和改密则通过对应 aspect 由cache.exec(...)调用。
所以如果你在项目里依赖了 oak-general-business,这些规则通常已经默认生效了,不需要再自己补一套重复逻辑。
项目中如何接入
用户体系在项目里通常不是直接 operate('user') 完事,而是走“组件 + aspect + token feature”的组合:
- 注册页直接复用
src/components/user/register - 手机号登录与绑定复用
src/components/mobile/login、src/components/mobile/manageList - 改密复用
src/components/changePassword/byPassword、src/components/changePassword/byMobile - 发验证码、绑手机、绑邮箱统一走
features.token - 注册和改密则分别走
registerUserByLoginName、updateUserPassword这些 aspect
这也是为什么用户体系虽然没有单独的 user feature,但项目侧仍然很容易用起来。
一个推荐的新手接法
如果你要在现有项目里补一套“先绑手机号、再实名、再改资料”的前台流程,taicang 已经给了一个很好的参考:
/user/authentication页面里先用mobile/upsert- 绑定完成后切到
userAuth/upsert - 资料页用
user/info - 改密页单独挂
user/password/update - 对敏感动作再加一层
user/password/verify
这样每一步都复用公共组件,页面层只负责路由和跳转,不去重写账号体系本身。
使用示例
1. 按账号名注册用户
src/components/user/register/index.ts 的真实调用方式是:
await this.features.cache.exec('registerUserByLoginName', {
loginName,
password: pwd,
});
如果系统密码策略要求 sha1,公共组件还会先按 Password.mode 做加密再提交。
2. 发送验证码并绑定手机号/邮箱
在项目组件里,推荐直接走 features.token:
await this.features.token.sendCaptcha('mobile', mobile, 'login');
await this.features.token.bindByMobile(mobile, captcha);
await this.features.token.sendCaptcha('email', email, 'confirm');
await this.features.token.bindByEmail(email, captcha);
这条链路会自动复用当前应用的验证码模板、渠道配置和校验规则。
3. 修改密码
src/components/changePassword/byPassword/index.ts 的调用方式是:
await this.features.cache.exec('updateUserPassword', {
userId,
prevPassword,
newPassword,
});
如果项目安全级别较高,还可以配合 features.token.verifyPassword(...) 先做敏感动作前的密码确认。
4. 在页面里组合手机号绑定和实名认证
下面这段就是 taicang 的真实思路,先手机号,后实名:
{activeIndex === 0 ? (
<MobileUpsert
maxNum={5}
userId={userId}
oakPath={oakFullpath + '.mobiles'}
onFinish={() => onFinishByMobile()}
/>
) : null}
{activeIndex === 1 ? (
<UserAuthenticate
oakId={userId}
oakPath={oakFullpath + '.user'}
onFinish={() => onFinishByAuthentication()}
/>
) : null}
使用建议
对新手来说,最重要的一条经验是:
把
User当成用户主体,把Mobile/LoginName当成登录凭证,把Captcha/ChangePasswordTemp当成流程记录。
这样你在读源码时就不会混乱,也更容易理解为什么有些逻辑写在 user.ts,有些逻辑却写在 token.ts。
另外还有一个很实际的注意点:
- 如果项目层扩展了
User,尽量不要改掉公共组件依赖的关系名和字段习惯,例如mobile$user、extraFile$entity(tag1='avatar')、wechatUser$user
因为 user/info、user/manage、token/me 这些组件都直接按这套投影和关系去读数据。字段名改掉了,页面不会自己适配。
Token 与多端登录态
如果说前一章解决的是“用户是谁”,那么这一章解决的就是“用户现在是以什么身份、从什么端、在什么应用里登录进来的”。
Oak 里的 Token 不是一个简单的 session 字符串。它还绑定了:
- 当前应用;
- 当前用户;
- 当前扮演者
player; - 当前环境
env; - token 的刷新时间和旧值。
这也是为什么 oak-general-business 可以同时处理 web、小程序、公众号、原生 App 这些不同环境的登录态。
主要对象
这一章的核心实体是 Token。
它保存了:
entity/entityId:token 关联的是谁;user/player:当前真实用户与当前扮演者;application:当前应用;env:登录环境;value/oldValue:当前 token 值与上一个 token 值;refreshedAt/disablesAt:刷新时间与禁用时间。
这里还有一个非常关键的设计:oldValue 允许刷新 token 后的一小段时间里仍能识别旧 token,这对前后端切换 token 值很有帮助。
组件
和登录态直接相关的组件主要有:
src/components/user/loginsrc/components/user/login/passwordsrc/components/user/login/smssrc/components/user/login/emailsrc/components/token/mesrc/components/common/weChatLoginGrantsrc/components/common/weChatLoginQrCode
其中 components/user/login/index.ts 是最值得读的一个例子。它会根据当前应用的 applicationPassport 动态决定:
- 展示哪些登录方式;
- 是否允许密码登录;
- 是否展示 OAuth 登录;
- 是否允许注册。
user/login 常用参数
oak-general-business/src/components/user/login/index.ts 暴露的参数比表面看起来多,但项目里最常用的是下面这些:
onlyCaptcha:只保留手机号验证码登录onlyPassword:只保留密码登录disabled:禁用某类登录方式redirectUri:微信登录成功后回跳到哪个wechatUser/login页面url:登录完成后最终要回到的业务页面callback:非微信登录成功后的回调goRegister:跳注册页isRegisterBack:从注册页返回时优先切到密码登录goOauthLogin:跳指定 OAuth 提供方登录
taicang/src/pages/frontend/login/web.tsx 的真实写法就是:
<Login
redirectUri={redirectUri}
url={backUrl}
callback={() => {
go();
}}
/>
user/login 真正是怎么决定显示哪些入口的
这一点值得直接写清楚,因为它不是一个静态登录页。
从 oak-general-business/src/components/user/login/index.ts 看,它在 ready() 里会先做这几步:
- 调
getApplicationPassports(applicationId)取当前应用真正启用的登录方式 - 找出
isDefault=true的默认登录方式 - 再结合本地存储里的
loginMode决定当前默认显示哪个 tab - 根据
sms/email/loginName上的allowPwd推导是否允许显示密码登录 - 根据
oauth类型passport.config.oauthIds去加载第三方 OAuth 提供方列表 - 从
passport.config.digit里取短信、邮箱验证码位数 - 从
application.system.config.Password里取密码存储模式和密码规则
也就是说,user/login 的显示来源同时依赖:
ApplicationPassportPassport.configSystem.config.Password- 本地存储里的上次登录方式
项目层如果发现登录页和后台配置不一致,优先应该回查这四层,而不是先改组件渲染。
user/login/password 常用参数
如果项目只想单独复用密码登录子组件,而不是整套 user/login,最值得先记住的是这些参数:
pwdAllowMobilepwdAllowEmailpwdAllowLoginNameallowSmsallowEmailallowWechatMpsetLoginModepwdModeallowRegistergoRegister
它当前的真实行为包括:
- 账号输入框占位文案会按
pwdAllowMobile / pwdAllowEmail / pwdAllowLoginName自动拼成“账号/手机号/邮箱”提示 - 提交前只校验“账号非空 + 密码满足
isPassword(...)” pwdMode === 'sha1'时,会先走encryptPasswordSha1(...)再调用features.token.loginByAccount(...)- 登录成功后优先走
callback,没有callback才按url跳转
所以它更适合:
- 项目已经确定只做密码登录
- 但仍然想保留“手机号/邮箱/账号名都可作为账号输入”的灵活性
user/login/sms 常用参数
短信登录子组件最关键的参数则是:
digitallowPasswordallowEmailallowWechatMpsetLoginModecallbackurl
它的真实行为也很值得写进文档:
- 发验证码固定走
features.token.sendCaptcha('mobile', mobile, 'login') - 登录固定走
features.token.loginByMobile(mobile, captcha) - 验证码格式校验直接使用
isCaptcha(value, digit) - 发送冷却时间会写本地存储,开发环境默认 10 秒,生产环境默认 60 秒
这意味着项目层如果只是想裁出“纯短信登录页”,直接用这个子组件会比从 user/login 再做条件裁剪更直接。
token/me 常用参数
oak-general-business/src/components/token/me/index.ts 更像“当前登录用户入口卡片”,项目里最常传:
loginUrl:未登录时跳去哪里myInfoUrl:查看我的资料manageUserUrl:进入用户管理onMyInfoClicked:自定义点击“我的资料”行为Body:在默认卡片下方追加项目自己的内容
taicang/src/pages/frontend/my/web.pc.tsx 里是这样接的:
<GeneralMe
oakPath="$$general-my"
loginUrl="/login"
myInfoUrl="/my/info"
manageUserUrl="/user/manage"
Body={<Button onClick={() => logout()}>{t('logout')}</Button>}
/>
token/me 的真实判断逻辑
这个组件看起来像一张简单的“我的”卡片,但它背后有几层很明确的 token 语义:
- 它查询的不是“全部 token”,而是按
features.token.getTokenValue()过滤当前本地 token 值 - 会额外再查一次
extraFile(tag1='avatar')来拼头像 URL isPlayingAnother的判断条件是token.userId !== token.playerIdisRoot取的是当前player.isRoot,不是单纯看user.isRoot
它的登录入口逻辑也不是固定跳转:
- 小程序环境
doLogin()会直接调用features.token.loginWechatMp() - Web 环境才会按
loginUrl跳独立登录页
所以 token/me 很适合做:
- 小程序首页“我的”卡片
- PC 前台右上角当前用户入口
- 需要区分“当前是不是在代入别的 player” 的后台入口
common/weChatLoginGrant / common/weChatLoginQrCode
这两个组件虽然不直接挂在 Token 实体上,但它们本质上都是给微信网页登录链路做“前置入口”。
weChatLoginGrant 适合做按钮式授权入口,常用参数包括:
appIdscoperedirectUristatedisableddisableTextdev
它的真实行为是:
- 生产环境直接跳微信 OAuth 授权地址
- 开发环境用本地模拟
code的方式跳到redirectUri disabled时不会跳转,而是提示disableText
weChatLoginQrCode 则适合桌面端扫码登录,常用参数也很接近:
appIdscoperedirectUristatedisableddisableTextdevhref
它的额外特点是:
- 生产环境会动态加载微信官方
wxLogin.js href可以覆盖默认二维码样式disabled时会显示一层“禁用微信二维码”的遮罩,而不是直接卸载组件
所以项目里如果需要“按钮授权”和“扫码授权”两种入口,并不需要自己拼 OAuth URL,直接复用这两个公共组件更稳。
前端 feature 与 aspect
这一章最重要的前端 feature 是 features.token。它几乎承载了整套登录行为:
loginByAccountloginByMobileloginByEmailbindByMobilebindByEmailsendCaptchaloginWechatloginWechatMploginWechatNativeloginByOAuthloginWebByMpTokenrefreshTokenlogoutswitchToverifyPasswordgetWechatMpUserPhoneNumberwakeupParasiterefreshWechatPublicUserInfosyncUserInfoWechatMp
对应的后端 aspect 集中在 src/aspects/token.ts,其中还包含:
sendCaptchaByMobilesendCaptchaByEmailbindByMobilebindByEmailrefreshWechatPublicUserInfosetUserAvatarFromWechat
这就是 oak-general-business 最典型的一种设计:登录态通过一个统一的 feature 暴露,后端则用一组命名清晰的 aspect 支撑它。
watcher 与后台规则
这一章还有一个必须知道的后台补偿逻辑:
src/watchers/token.ts会定期把已经到达disablesAt的 token 执行disable。
另外,refreshToken(...) 这条 aspect 还做了很多运行时保护:
- 检查 token 对应的环境和当前环境是否一致;
- 在 server 模式下按系统配置或默认间隔轮换 token 值;
- 必要时回写
applicationId。
所以 token 的刷新不是一个“前端本地行为”,而是 Oak 运行时和后台共同维护的一条业务链。
注入点
features.token 的注入点在 oak-general-business/src/features/index.ts:
create(...)时创建 token feature;- token feature 会订阅
applicationfeature,在应用识别成功后读取本地存储里的 token; initialize(...)在小程序环境下还会按需自动执行loginWechatMp()。
这意味着项目只要正确接入了 oak-general-business,小程序登录、token 本地缓存、刷新与失效,都会自动串起来。
项目中如何接入
Token 能力的项目接入非常固定:
createOgb0Features(...)注入features.tokeninitializeOgb0Features(...)里自动识别应用、读取本地 token、必要时执行小程序自动登录- 页面、组件、页面守卫统一只从
features.token读写登录态
也就是说,项目一旦把 oak-general-business 初始化链路接好,后面大部分页面都只需要关心“有没有登录”“当前用户是谁”。
真实项目里的常见页面落点
从 taicang 的现有页面看,token 相关组件大致会落在三个位置:
- 独立登录页:直接包
user/login - “我的”首页:已登录显示
token/me,未登录显示user/login - 需要先登录再继续的业务页:直接内嵌
user/login,并把redirectUri指向统一的/wechatUser/login
如果页面是桌面端工作台、运营后台或 PC 登录页,还很适合补:
- 一键授权按钮:
common/weChatLoginGrant - 扫码登录区:
common/weChatLoginQrCode
这类页面最重要的不是自己判断有哪些登录方式,而是保证当前应用的 ApplicationPassport 已经配置正确,让 user/login 自己读配置渲染。
使用示例
1. 账号密码登录
await this.features.token.loginByAccount(account, password);
const userId = this.features.token.getUserId(true);
2. 短信验证码登录
await this.features.token.sendCaptcha('mobile', mobile, 'login');
await this.features.token.loginByMobile(mobile, captcha);
这也是 src/components/mobile/login、src/components/user/login/sms 的真实调用方式。
3. 页面里判断登录态和退出登录
bm-smart 很多页面就是这么写的:
const loggedIn = !!this.features.token.getTokenValue();
const user = this.features.token.getUserInfo();
if (loggedIn) {
await this.features.token.logout();
}
4. 小程序场景直接拿手机号
await this.features.token.getWechatMpUserPhoneNumber(code);
如果你已经正确执行了 initializeOgb0Features(...),小程序端首次进入时还会按需自动触发 loginWechatMp()。
使用建议
对于组件开发来说,一条经验非常重要:
不要在页面里直接去拼
token查询和写入逻辑,而是统一走features.token。
因为登录、刷新、环境校验、自动登出、密码强度检查这些逻辑,已经被集中封装在这里了。绕开它,反而更容易把系统行为写乱。
HumanVerify 人机校验
oak-general-business 现在已经把人机校验做成了一套独立能力。它和 captcha 不是一回事:
captcha是短信、邮箱验证码实体,负责保存和校验用户收到的验证码;humanVerify是请求前的人机校验,负责在登录、注册、发送验证码这类高风险入口前先挡一层机器人流量。
这套能力的核心源码在 oak-general-business/src/types/HumanVerify.ts、src/utils/humanVerify/*、src/features/humanVerify.ts、src/endpoints/humanVerify.ts 和 src/components/humanVerify/*。
能力模型
人机校验的运行配置挂在 system.config.humanVerify 上,类型是 HumanVerifyConfig:
type HumanVerifyConfig = {
activeType?: string;
providers?: Record<string, { config?: Record<string, unknown> }>;
scenes?: Record<string, HumanVerifyScenePolicy>;
};
这里有三层含义:
activeType:当前启用哪个 provider。同一个系统可以保存多个 provider 配置,但运行时只用一个。providers[type].config:provider 自己的配置,例如 Turnstile 的siteKey/secretKey,ALTCHA 的hmacKey。scenes[scene]:按业务场景决定是否启用、是观察还是强制拦截。
内置场景在 HUMAN_VERIFY_SCENES 里,目前有四个:
auth.login.account:账号密码登录,对应loginByAccount。auth.register.loginName:用户名注册,对应registerUserByLoginName。auth.captcha.sendMobile:发送手机验证码,对应sendCaptchaByMobile。auth.captcha.sendEmail:发送邮箱验证码,对应sendCaptchaByEmail。
需要特别注意:短信、邮箱验证码登录本身仍然由 captcha 记录来校验;人机校验挡在“发送验证码”之前,而不是挡在“提交验证码登录”之前。
场景策略
每个 scene 都可以配置 HumanVerifyScenePolicy:
type HumanVerifyScenePolicy = {
enabled?: boolean;
mode?: 'observe' | 'enforce';
action?: string;
minScore?: number;
providerErrorPolicy?: 'allow' | 'deny';
};
真实行为由 src/utils/humanVerify/policy.ts 里的 verifyHumanVerifyScene(...) 决定:
enabled !== true时,这个场景不做人机校验。mode默认是enforce。enforce会在 proof 缺失、provider 不匹配、校验失败或分数不足时抛OakUserException。observe不拦截请求,适合灰度观察或临时兜底;当前实现不把观察结果落库,只是不抛错。providerErrorPolicy决定 provider 未注册、第三方服务异常等情况怎么处理;默认按deny理解,allow只处理 provider 错误,不豁免enforce模式下的 proof 缺失。minScore只对会返回风险分数的 provider 有意义,低于阈值时enforce会拦截。当前内置debug/altcha成功时返回score: 1,Turnstile 内置 provider 不返回分数。
root context 会直接跳过人机校验,所以内部初始化、后台 root 操作不会被这层能力卡住。
服务端失败时会抛 OakUserException,错误 key 包括:
error::humanVerify.requirederror::humanVerify.unsupportedProvidererror::humanVerify.failederror::humanVerify.expirederror::humanVerify.serviceUnavailableerror::humanVerify.riskTooHigh
内置 Provider
oak-general-business 内置了三个 provider:
| provider | 作用 | 关键配置 |
|---|---|---|
debug | 调试用,通过约定 token 模拟通过或失败。 | token,默认 debug-pass |
turnstile | Cloudflare Turnstile。前端隐藏执行 challenge,后端调用 Cloudflare siteverify。 | siteKey、secretKey、expectedHostname、action、theme、language |
altcha | ALTCHA 自托管挑战。后端签发 challenge,前端显示 altcha-widget,后端验证 payload。 | hmacKey、challengeUrl、expiresIn、maxNumber、saltLength、hideFooter |
后端 provider 默认在 src/utils/humanVerify/index.backend.ts 中注册。应用如果要增加自定义 provider,通过 @oak-general-business/registry.backend 暴露的 registerHumanVerifyProvider(...) 注册即可。注册要放在应用启动早期、第一次人机校验发生之前;后端 provider registry 被使用后会锁定,不能再追加 provider。
前端 provider 不会自动全部打进应用包。应用侧需要通过 @oak-general-business/registry.frontend 手动注册 frontend bundle、配置组件和交互组件。
ALTCHA Endpoint
ALTCHA 需要服务端签发 challenge,公共包已经提供 endpoint:
humanVerify/altcha/challenge
这个 endpoint 在 src/endpoints/humanVerify.ts 中定义,并通过 src/endpoints/index.ts 导出。它会:
- 根据请求里的
applicationId切到对应应用; - 读取当前应用所属
system.config.humanVerify; - 只有
activeType === 'altcha'时才使用 ALTCHA 配置; - 用
hmacKey、expiresIn、maxNumber、saltLength创建 challenge; - 把
scene和action写入 challenge params。
ALTCHA 前端组件默认会用:
features.cache.makeEndpointUrl('humanVerify/altcha/challenge')
如果 providerConfig.challengeUrl 有值,则使用配置里的地址。
前端调用链
前端统一通过 features.humanVerify.acquireProofForScene(scene, payload) 获取 proof。它会先读取当前应用的 system.config.humanVerify,解析出当前 scene 是否启用、使用哪个 provider、provider 配置和策略。
如果 scene 没启用,方法直接返回 undefined。如果启用了,则按 provider 的前端能力走两种路径:
- 有
client的 provider,直接调用client.acquire(...)获取 proof。Turnstile 就是这种路径。 - 没有
client、但有acquireComponent的 provider,通过全局 host 组件弹出交互组件。debug 和 ALTCHA 走这条路径。
HumanVerifyProof 最终会传给对应 aspect:
type HumanVerifyProof = {
provider?: string;
scene?: string;
token?: string;
payload?: Record<string, unknown>;
};
服务端公共策略层会先要求 token 存在,并校验 proof.provider 是否和当前启用 provider 一致;随后再交给具体 provider 校验 token、action、hostname 或 challenge payload。
Host 组件
需要交互组件的 provider 依赖全局 host:
oak-humanVerifyHost
这个全局组件在 oak-general-business/package.json 的 oak.frontend.globalComponents 中声明,指向:
@oak-general-business/components/humanVerify/host/index
web 端 host 会从 features.humanVerify 取 pending request,然后渲染对应 AcquireComponent。小程序端通过 componentGenerics.acquire 暴露一个泛型组件插槽,默认是空实现 emptyAcquire;如果小程序要做人机校验,需要项目侧提供对应 acquire 组件。
如果某个 scene 是 enforce,但前端没注册 provider bundle、没有 acquire component,或者 host 没挂载,acquireProofForScene(...) 通常会抛错。即使前端因为 providerErrorPolicy: 'allow' 返回了 undefined,服务端在 enforce 下仍会把它当作 proof 缺失并抛 error::humanVerify.required。
已接入的人机校验入口
公共包的成品组件已经在关键入口调用 features.humanVerify.acquireProofForScene(...):
| 入口 | scene | 后续调用 |
|---|---|---|
components/user/login/password | auth.login.account | features.token.loginByAccount(...) |
components/user/register | auth.register.loginName | features.token.registerByLoginName(...) |
components/user/login/sms、components/mobile/login、components/changePassword/byMobile | auth.captcha.sendMobile | features.token.sendCaptcha('mobile', ...) |
components/user/login/email、components/email/upsert | auth.captcha.sendEmail | features.token.sendCaptcha('email', ...) |
服务端对应的强制校验点在:
src/aspects/token.ts的loginByAccount(...)src/aspects/token.ts的sendCaptchaByMobile(...)src/aspects/token.ts的sendCaptchaByEmail(...)src/aspects/user.ts的registerUserByLoginName(...)
如果项目自己绕开这些公共组件、直接调用 features.token 或后端 aspect,也要自己先调用 features.humanVerify.acquireProofForScene(...) 并把 proof 传进去。否则在 scene 为 enforce 时,服务端会按缺失 proof 处理。
管理台配置入口
系统配置组件 components/config/upsert 已经把“人机校验”作为标准 tab 加进去了。运行时读取的是当前应用所属系统上的配置,配置写入路径是:
system.config.humanVerify
这个 tab 做三件事:
- 选择
activeType; - 编辑当前 provider 的配置;
- 按四个内置 scene 配置
enabled/mode/action/minScore/providerErrorPolicy。
不过 provider 专属配置表单也需要前端注册。公共包提供了三组内置配置组件:
@oak-general-business/components/config/upsert/humanVerify/providers/debug@oak-general-business/components/config/upsert/humanVerify/providers/turnstile@oak-general-business/components/config/upsert/humanVerify/providers/altcha
应用侧接入示例
web 应用如果要使用内置 provider,通常在前端 registry 或初始化入口里注册:
import {
registerHumanVerifyAcquireComponent,
registerHumanVerifyConfigComponent,
registerHumanVerifyFrontendProviderBundle,
} from '@oak-general-business/registry.frontend';
import { HUMAN_VERIFY_PROVIDER_TYPES } from '@oak-general-business';
import {
altchaHumanVerifyFrontendBundle,
debugHumanVerifyFrontendBundle,
turnstileHumanVerifyFrontendBundle,
} from '@oak-general-business/utils/humanVerify/frontendBundles';
import AltchaConfig from '@oak-general-business/components/config/upsert/humanVerify/providers/altcha';
import DebugConfig from '@oak-general-business/components/config/upsert/humanVerify/providers/debug';
import TurnstileConfig from '@oak-general-business/components/config/upsert/humanVerify/providers/turnstile';
import AltchaAcquire from '@oak-general-business/components/humanVerify/acquire/altcha/web';
import DebugAcquire from '@oak-general-business/components/humanVerify/acquire/debug/web';
registerHumanVerifyFrontendProviderBundle(altchaHumanVerifyFrontendBundle);
registerHumanVerifyFrontendProviderBundle(debugHumanVerifyFrontendBundle);
registerHumanVerifyFrontendProviderBundle(turnstileHumanVerifyFrontendBundle);
registerHumanVerifyConfigComponent(HUMAN_VERIFY_PROVIDER_TYPES.altcha, AltchaConfig);
registerHumanVerifyConfigComponent(HUMAN_VERIFY_PROVIDER_TYPES.debug, DebugConfig);
registerHumanVerifyConfigComponent(HUMAN_VERIFY_PROVIDER_TYPES.turnstile, TurnstileConfig);
registerHumanVerifyAcquireComponent(HUMAN_VERIFY_PROVIDER_TYPES.altcha, AltchaAcquire);
registerHumanVerifyAcquireComponent(HUMAN_VERIFY_PROVIDER_TYPES.debug, DebugAcquire);
Turnstile 有 frontend client,可以隐藏执行,不需要额外 acquire component。debug 和 ALTCHA 需要弹窗交互组件,所以必须注册 acquire component,并确保 oak-humanVerifyHost 已经出现在页面树里。
直接调用示例
如果项目自己写登录按钮,不走公共登录组件,调用方式应该类似这样:
const humanVerify = await this.features.humanVerify.acquireProofForScene(
HUMAN_VERIFY_SCENES.loginByAccount,
{ account }
);
await this.features.token.loginByAccount(account, password, humanVerify);
发验证码也是同样的结构:
const humanVerify = await this.features.humanVerify.acquireProofForScene(
HUMAN_VERIFY_SCENES.sendCaptchaByMobile,
{ mobile, type: 'login' }
);
await this.features.token.sendCaptcha('mobile', mobile, 'login', humanVerify);
这里的 payload 主要给前端 provider 或交互组件使用,服务端当前校验核心仍然是 proof.token 和当前 provider。
自定义 Provider
项目可以扩展自己的 provider,最小需要两端:
- 后端实现
HumanVerifyProvider.verify(...),通过registerHumanVerifyProvider(...)注册。 - 前端实现
HumanVerifyClient.acquire(...),或者实现一个 acquire component,再通过 frontend registry 注册。
如果要让系统配置页支持这个 provider,还要实现 HumanVerifyConfigComponentProps 对应的配置组件,并通过 registerHumanVerifyConfigComponent(...) 注册。
后端返回值统一是:
type HumanVerifyResult = {
success: boolean;
score?: number;
reasonCode?: string;
message?: string;
raw?: unknown;
};
reasonCode 会进入 OakUserException 的 params,前端可通过错误国际化显示更明确的原因。
注意事项
- 不要把 HumanVerify 和
captcha混在一起。验证码发送前做人机校验,验证码登录时校验captcha。 activeType为空时,人机校验不会触发,即使 scene 配了enabled。mode: 'observe'不适合当正式防刷策略,只适合灰度观察。providerErrorPolicy: 'allow'会在 provider 出错时放行,高风险场景要谨慎使用。- ALTCHA 的
hmacKey和 Turnstile 的secretKey都是服务端密钥,不应出现在前端公开配置之外的地方。 - 小程序端 host 只是预留了泛型 acquire 插槽;当前公共包内置的 debug/ALTCHA acquire 组件是 web 实现,小程序要按项目需要补。
- 如果项目不用公共登录/注册/验证码组件,而是自己写 UI,必须显式获取 proof 并传给对应 feature/aspect。
Invite 邀请归因
Invite 是 oak-general-business 6.1.0 新增的一套通用邀请归因能力。它解决的不是“注册表单怎么画”,而是“一个用户通过谁的邀请入口进入应用,并在后续登录或注册成功后,把这次来源关系可靠地落下来”。
所以阅读这块能力时,最好先把它和 UserEntityGrant、Parasite 分开:
UserEntityGrant解决的是把某个对象上的关系权限分享给别人认领;Parasite解决的是先给一个临时身份入口,后续再唤醒或激活;Invite解决的是邀请来源归因,最终落到邀请人、被邀请 token 和应用之间的关系。
也就是说,Invite 更适合“邀请注册”“邀请好友”“渠道归因”“扫码进入后再登录”这类场景。
主要对象
邀请归因链路里有三个实体:
inviteinviteTouchinviteRelation
invite 是邀请人的邀请身份。当前后端会保证同一个 inviterId + applicationId 下只有一条有效邀请记录,调用 getMyInvite 时如果不存在会自动创建,如果存在但被停用则会重新启用。
inviteTouch 是一次触达记录。用户打开邀请链接、扫邀请二维码,或者从微信分享进入时,只要最终进入公共触达页并调用了 touchInvite,都会先生成一条 touch。它记录:
- 属于哪条
invite - 属于哪个
application - 来源是
webLink、wechatQrCode、wechatPublicScan还是wechatMpShare - 是否已经转化成正式邀请关系
- 可选的
wechatUserId
inviteRelation 是最终归因结果。它记录:
- 邀请人
inviterId - 被邀请人的
inviteeTokenId - 应用
applicationId - 原始 touch
touchId - 生效时间
effectedAt
数据库上会约束 inviteeTokenId + applicationId 唯一,也会约束一个 touchId 只能生成一条关系。因此同一个 token 在同一个应用里不会被重复归因。
一次完整邀请链路
完整流程可以拆成四步。
1. 邀请人生成邀请入口
前端可以直接调用:
const result = await this.features.invite.getMyInvite();
这一步需要当前用户已经登录,因为后端会用当前 userId 作为邀请人。
如果要给指定应用生成邀请入口,也可以调用:
const result = await this.features.invite.getMyInviteByTarget(targetApplicationId);
返回结果里最常用的是:
result.invite.coderesult.landingUrlresult.landingRoute
landingUrl 是对外分享的完整 URL。它不是从旧的 application.config.location 拼出来的,而是通过当前 Application 绑定的 Domain 生成,路径固定走邀请触达页:
/invite/landing?code=...
公共包也提供了 components/my/invite,可以直接展示邀请码、邀请链接、二维码和触达记录。项目里如果只需要一个“我的邀请”入口,优先包这个组件,而不是从零写。
2. 被邀请人打开邀请落地页
模板里已经有:
template/src/pages/frontend/invite/landing
这个页面会挂载 components/invite/landing。组件拿到 code 和 source 后,会调用:
await this.features.invite.touchInvite({
code,
source,
});
后端会校验邀请码是否存在、是否有效、是否属于当前应用,并创建 inviteTouch。如果当前访问者已经登录,并且上下文里有 token,会立刻尝试生成 inviteRelation。
如果访问者还没有登录,features.invite 会把这次 touch 暂存在 localStorage 里,等待后续登录或注册成功。
3. 跳到项目配置的业务落地页
/invite/landing 只是公共触达页,不应该承载业务注册 UI。真正跳到哪里,由当前应用配置决定:
invite: {
landing: {
pathname: '/frontend/login',
props: {}
},
touchTtl: 7
}
其中:
landing.pathname是 touch 成功后跳转的页面;landing.props会透传给跳转;touchTtl是触达记录在前后端保留的天数,不配时默认 7 天。
这个配置在 Application.config.invite 上,web、wechatMp、wechatPublic、native 类型应用都支持。
4. 登录或注册成功后物化邀请关系
当前 features.token 已经和 features.invite 接好了。它在调用登录或注册 aspect 前,会自动取 pending touch:
const inviteTouchId = await this.getInviteTouchId();
下面这些前端方法都会自动带上 inviteTouchId:
features.token.loginByMobile(...)features.token.loginByEmail(...)features.token.loginByAccount(...)features.token.registerByLoginName(...)
后端登录或注册成功后,会通过 materializeInviteRelationForUser 创建 inviteRelation,然后把 touch 标记成 transformed。前端拿到登录结果后,也会清理本地 pending touch。
这里的“自动带上”只覆盖上面列出的公共 token feature 方法。features.token.loginByOAuth(...)、loginWechat(...)、loginWechatMp(...)、loginWechatNative(...) 当前不会读取本地 pending touch 再传 inviteTouchId。项目页面如果使用公共账号、手机、邮箱或登录名注册方法,通常不需要自己传;如果绕过公共 token feature、直接调用后端 aspect,或者自定义了其它登录入口,就要确认这条登录链路是否支持 inviteTouchId。不支持时,应在项目自己的后端登录入口里调用邀请归因物化逻辑,而不是只在前端多传一个无效参数。
微信二维码和扫码来源
创建 invite 时,公共 trigger 会按应用类型自动补邀请二维码。
当前规则是:
wechatPublic服务号应用会生成公众号二维码;wechatMp如果配置了qrCodePrefix,会生成小程序 domain URL 二维码;- 普通
wechatMp会生成小程序码; - 非微信应用不会自动生成微信二维码。
二维码里的页面仍然会指向公共触达页,并带上:
{
code,
source: 'wechatQrCode'
}
wechatQrCode 是当前公共 invite 二维码 trigger 会主动写入的来源。wechatPublicScan 和 wechatMpShare 是 touchInvite 接受的来源值,但需要具体入口显式传入;不要误以为公共包会在所有公众号扫码或小程序分享场景里自动创建 touch。
微信登录还有一个特殊归因能力:loginWechat、loginWechatMp、loginWechatNative 等流程最终会加载 token 信息;如果 token 关联的是 wechatUser,后端会尝试用同一个 wechatUserId 最近一次未转化 touch 来生成 inviteRelation。这个能力的前提是 touch 记录本身带了 wechatUserId。当前公共 /invite/landing 模板只接收 code 和 source,不会自动取得并传入 wechatUserId;普通 web 链接和邀请二维码落地后,主要还是依赖本地 pending touch 加公共 token feature 完成归因。如果项目要做“公众号扫码后不经过浏览器本地缓存也能归因”,需要在自定义微信回调或自定义落地逻辑里显式调用 touchInvite 并传入 wechatUserId。
项目最小接入步骤
如果项目已经接入 oak-general-business,邀请归因通常不需要单独初始化。最小接入重点是下面几项。
1. 同步依赖和生成文件
确认 src/configuration/dependency.ts 已经依赖 oak-general-business,然后执行项目的初始化和依赖生成流程:
npm run project:init
npm run make:dep
生成后的前端运行时会创建 features.invite,并在 features.token 里注入 invite feature。后端的 aspect、trigger、checker 则由 AppLoader 按依赖图合并。
2. 执行 6.1.0 数据库升级
需要执行 oak-general-business/upgrade/6.1.0/04.sql,创建:
inviteinviteTouchinviteRelation
如果项目还没完成 6.1.0 的其它升级,也要同时处理同目录下其它 SQL,尤其是 Application.config.location 迁到 Domain 的升级脚本。
3. 配置 Domain 和 Application invite
getMyInvite 返回的 landingUrl 依赖应用域名。项目至少要保证:
- 当前
Application能通过Domain解析出可访问域名; Application.config.invite.landing.pathname指向一个真实存在的业务页面;- 如果有多个应用共享一个系统域名,需要用
Application.domainId明确绑定。
典型配置类似:
{
type: 'web',
invite: {
landing: {
pathname: '/frontend/login',
props: {
from: 'invite'
}
},
touchTtl: 7
}
}
4. 挂载邀请触达页
如果项目来自当前模板,通常已经有:
src/pages/frontend/invite/landing
如果没有,需要从 oak-general-business/template/src/pages/frontend/invite/landing 补进项目路由,让 /invite/landing 能被访问。
这个页面不要改成注册页。它的职责只是消费 code,创建 inviteTouch,再跳转到 Application.config.invite.landing。
5. 登录注册页使用公共 token feature
业务登录、注册页面尽量使用公共 token feature:
await this.features.token.loginByMobile(mobile, captcha);
await this.features.token.loginByAccount(account, password);
await this.features.token.registerByLoginName(loginName, password);
这样 pending invite touch 会自动随登录或注册请求带到后端。
如果项目自己封了登录组件,也不要直接调用 cache.exec('loginByMobile', ...) 后就结束。要么调用 features.token,要么显式读取:
const inviteTouchId = await this.features.invite.getPendingTouchId();
并把它传给支持 inviteTouchId 的登录 aspect,再在登录成功后清理对应 touch。如果登录 aspect 本身没有这个参数,需要在后端自定义登录逻辑里完成邀请关系物化。
常见坑
把 /invite/landing 当成注册页
/invite/landing 是归因触达页。真正的注册或登录页应该放在 Application.config.invite.landing.pathname。
只配了 landing,没有配 Domain
邀请链接需要完整 URL。landingUrl 会通过 Domain 拼接,如果当前应用找不到启用的 domain,会报应用配置不完整。
自己绕过 features.token
公共 features.token 已经会自动携带 pending touch。项目如果绕过它直接调支持邀请参数的 aspect,就要自己传 inviteTouchId;如果调用的是不支持该参数的 OAuth、微信或自定义登录 aspect,需要在后端扩展登录逻辑,显式完成邀请关系物化。否则登录注册成功后不会落 inviteRelation。
以为所有登录方式都自动携带 pending touch
当前自动携带 inviteTouchId 的是 loginByMobile、loginByEmail、loginByAccount 和 registerByLoginName。OAuth、微信登录或项目自定义登录入口,需要单独确认有没有自己的归因链路。
误以为一个用户可以被多次归因
当前唯一约束是 inviteeTokenId + applicationId。同一个 token 在同一应用里只会归因一次;后续再打开其它邀请链接,最多只会把新的 touch 标记为已转化,不会改写已有归因。
忽略 touch 有效期
默认有效期是 7 天。过期 touch 不会再生成关系。项目如果需要更短或更长的窗口,应该配置 Application.config.invite.touchTtl。
和其它分享能力怎么选
如果只是想知道“这个注册用户是谁邀请来的”,用 Invite。
如果要把某个具体业务对象的权限分享给别人领取,用 UserEntityGrant。
如果要给一个还没正式登录的人先创建临时身份,并让他以这个临时身份进入流程,用 Parasite。
这三个能力可以组合,但不要互相替代。邀请归因应该保持轻量,只记录来源关系;业务权限、临时身份和后续奖励逻辑,应放在各自更合适的对象或项目私有逻辑里。
UserEntityGrant 授权分享
UserEntityGrant 是 oak-general-business 里很有 Oak 味道的一块能力。它解决的不是“普通菜单授权”,而是“把某个对象上的一组关系权限,以链接或二维码的形式分享给另一个用户来认领”。
这类需求在客服转交、资源共享、邀请协作这些场景里很常见,而 oak-general-business 已经把它做成了一套完整的对象和规则。
主要对象
这一章最重要的实体是 UserEntityGrant。
它定义了:
- 权限作用在哪个
entity/entityId上; - 授权类型是
grant还是transfer; - 关系的选择规则
rule; - 行对象的选择规则
ruleOnRow; - 是否允许多人认领;
- 二维码类型
qrCodeType; - 过期时间、重定向页面和认领路由。
从运行时看,它还会和 wechatQrCode 以及 Oak 内建的认领关系数据一起工作。
组件
围绕这一能力,已经有四类常用组件:
src/components/userEntityGrant/listsrc/components/userEntityGrant/sharesrc/components/userEntityGrant/upsertsrc/components/userEntityGrant/claim
其中 claim 组件最值得认真读。它不是单纯展示一条授权,而是会根据 rule、ruleOnRow 和当前用户已有认领状态,组织“选择关系 + 选择对象行 + 执行认领”的完整前端流程。
userEntityGrant/claim 常用参数
oak-general-business/src/components/userEntityGrant/claim/index.ts 项目里最常用的参数有:
picker:自定义领取对象选择器hideInfohideTipafterClaim
其中最重要的是 picker。它决定“授权领取时,用户究竟从什么对象里挑关系和行”。taicang/src/pages/frontend/userEntityGrant/claim/web.tsx 的真实写法就是:
<UserEntityGrantClaim
oakId={oakId}
oakPath={oakFullpath}
picker={UbPicker}
/>
也就是说,这个组件本身已经把授权领取流程做好了,项目层真正需要补的是“如何挑选业务对象”的那块 picker。
claim 里的 picker 到底要满足什么接口
这一点在源码里其实写得很清楚,但如果文档不展开,新手很容易不知道该怎么自定义 picker。
userEntityGrant/claim 当前要求的 picker 组件签名是:
disabledentityentityFilterrelationIdsruleruleOnRowonPickRelations(ids)onPickRows(ids)pickedRowIdspickedRelationIdsoakPath
也就是说,项目层自定义 picker 时,不是只要“返回一组选中行”就够了,而是要同时处理:
- 关系怎么选
- 目标行怎么选
- 当前已经选中了什么
- 当前规则是不是单选 / 全选
claim 组件自己只负责在 pickedRelationIds + pickedRowIds 都具备时,自动把它们展开成 userEntityClaim$ueg.create 数据,再执行 claim。
默认 ubPicker 的真实行为
如果项目不传自定义 picker,最值得先参考的其实就是公共包自带的:
src/components/userEntityGrant/claim/ubPicker
它当前的真实行为包括:
entity()直接取授权上的relationEntity- 目标行 projection 会自动猜
name或title字段,没有就退回id entityFilter直接用于列表过滤- 会先刷新
relationIds对应的relation,并校验它们都属于同一个entity rule='all'时自动全选关系rule='single'且只有一个关系时自动选中ruleOnRow='all'时自动全选当前列表行ruleOnRow='single'且当前只有一行时自动选中
这意味着默认 ubPicker 已经够覆盖很多常见场景:
- 授权对象本身就有
name/title - 关系和目标对象不需要复杂树形选择
- 领取页只需要“勾关系 + 勾对象”
只有当你的对象选择逻辑明显更复杂时,才需要像 taicang 那样再包一层自己的 picker。
userEntityGrant/share 的真实职责
创建完授权之后,真正给用户看的通常不是原始数据,而是 userEntityGrant/share。这个组件当前的真实行为包括:
- 直接读取关联的
wechatQrCode$entity - 优先使用二维码的
url - 如果只有
buffer,会在前端把二进制内容转成 base64 图片 - 同时展示
relationIds、是否过期、过期时间
它还暴露了一组很适合项目层做样式定制的参数:
disableDownloadsizedisabledcolorbgColormaskColormaskTextmaskTextColormode: 'default' | 'simple'
所以项目里如果要做:
- 分享二维码弹窗
- 授权卡片页
- 领取入口海报区
通常不需要自己处理二维码 buffer 或图片转换,直接复用这个组件更稳。
userEntityGrant/list 的真实筛选能力
oak-general-business/src/components/userEntityGrant/list/index.ts 这组组件同样值得补出来,因为它不是简单把授权记录列出来。当前源码里的关键参数是:
entityentityIdrelationEntityrelationEntityFilter
组件会直接按这四个条件过滤 userEntityGrant,并默认按 $$createAt$$ desc 排序。也就是说,它更适合挂在“某个业务对象自己的授权记录列表”里,而不是全局授权台账。
从 web 端真实行为看,它还已经内置了两类非常常用的管理动作:
disable:如果当前行 legal action 里有disable,就直接把授权置失效二维码:弹出一个Modal,里面直接挂UserEntityGrantShare
所以项目层如果只是想给后台加一个“看历史分享、让某条分享失效、重新看二维码”的页,通常不需要自己再包一层复杂逻辑,直接用这组组件就够了。
userEntityGrant/upsert 的真实职责
userEntityGrant/upsert 不是一个通用大表单,它当前更像“生成一次分享授权”的专用入口。源码里最关键的输入参数有:
entityentityIdrelationEntityrelationEntityFilterrelationIdstyperedirectToAfterConfirmclaimUrlqrCodeTypemultipleruleruleOnRow
它在 ready() 里会把这些参数自动灌进当前创建数据,并默认补:
granterId = 当前用户type = 'grant'rule = 'single'ruleOnRow = 'single'
而当前 web 端表单真正让用户手填的核心只有一个:
period,也就是有效期天数,范围 1 到 30 天
点提交后,组件会先算出 expiresAt,执行创建;创建成功后不会立刻跳走,而是直接在当前页切成 UserEntityGrantShare 展示二维码,并给一个“重新生成”按钮。
这意味着它非常适合做:
- 关系分享弹窗
- 后台快速生成邀请二维码
- 某个对象详情页里的“生成领取链接”侧栏
而不太像一个需要项目层深度定制字段的后台表单。
aspect / endpoint / feature
这一章有一个很容易让人误判的地方:
- 它没有单独的 frontend feature;
- 它没有单独的对外 aspect;
- 它也没有专门的 HTTP endpoint。
UserEntityGrant 的主入口其实是实体动作本身,尤其是 claim。
但是它又不是孤立的,因为微信回调 endpoint 在扫描对应二维码时,会间接触发这套流程。
后台规则
这一章的核心逻辑集中在:
src/checkers/userEntityGrant.tssrc/triggers/userEntityGrant.ts
默认规则包括:
- 创建前检查
relationIds至少选了一个关系; - 创建授权时自动补授权人,并默认把
expired置为false; - 没有显式传
expiresAt时,默认 5 分钟后过期; - 自动创建关联的
wechatQrCode,而且二维码默认跳转到claimUrl || '/userEntityGrant/claim'; - 执行
claim时,checker 会把userEntityClaim$ueg自动展开成真正的userRelation创建数据; - 授权过期时,使关联二维码也过期;
- 执行
claim时,如果是单次授权,则自动失效。
也就是说,你创建的并不是一条“静态授权记录”,而是一条会自动派生二维码、自动处理过期、自动处理单次领取的动态业务数据。
注入点
这一章没有专门的 feature 注入点,它的注入点在后端:
ogb0Triggers注入userEntityGrant的派生逻辑;ogb0Checkers注入claim的校验逻辑。
只要你的项目初始化时合并了 oak-general-business 的 trigger / checker,这些规则就已经生效。
项目中如何接入
UserEntityGrant 这章在项目里的典型接法,不是手写二维码逻辑,而是直接复用公共组件和默认 trigger:
- 先在项目初始化时合并
ogb0Triggers/ogb0Checkers - 在页面里复用
src/components/userEntityGrant/upsert、share、claim - 如果你本来就在做用户关系管理,更推荐直接接
src/components/userRelation/upsert/byUserEntityGrant
而 byUserEntityGrant 这条接法里,真正决定分享行为的关键输入有:
entity/entityIdrelationsredirectToAfterConfirmclaimUrlqrCodeTypemultiplerule
这样分享页、二维码页、认领页、过期失效逻辑会一起工作。
如果按组件职责来落页,更推荐这样拆:
- 关系管理页或对象详情页里挂
userEntityGrant/upsert - 历史分享记录页挂
userEntityGrant/list - 分享成功弹窗或分享海报区直接挂
userEntityGrant/share - 真正的领取页单独挂
userEntityGrant/claim
这样“生成授权”和“消费授权”会天然分层,不会在一个页面里把创建、二维码展示、认领三件事搅在一起。
真实项目里的入口组织
haina-busi 和 taicang 的做法都很接近:
- 后台或管理页里的关系维护组件,通常会把
claimUrl直接设成/userEntityGrant/claim - 领取页本身再去包
userEntityGrant/claim - 如果默认 picker 不够用,就像
taicang一样传一个项目自己的UbPicker
从这次源码比对看,taicang 前台领取页其实也给了一条很典型的最小包法:
- 页面壳只负责从路由里拿
oakId - 直接把公共
UserEntityGrantClaim挂出来 picker先用公共@oak-general-business/components/userEntityGrant/claim/ubPicker
也就是说,即使项目后续准备自定义 picker,第一版通常也可以先直接落公共 ubPicker,等业务规则真的复杂了再替换。
这套分法很实用。关系管理页只负责“生成授权”,领取页只负责“消费授权”,职责很清楚。
使用示例
1. 在关系管理页里创建授权分享
userRelation/upsert/byUserEntityGrant 就是现成的项目接法,它会在创建授权后把生成的 userEntityGrantId 回传给上层:
<UserRelationUpsert
mode="byUserEntityGrant"
onUserEntityGrantCreated={(id) => this.setState({ grantId: id })}
/>
2. 拿到授权后直接展示分享组件
src/components/userRelation/upsert/byUserEntityGrant/web.tsx 的真实做法,就是继续挂 UserEntityGrantShare:
<UserEntityGrantShare
oakId={grantId}
oakPath="$userRelation/upsert/byUserEntityGrant-userEntityGrant/detail"
/>
这里不需要项目层自己生成二维码。创建授权记录后,默认 trigger 会自动派生 wechatQrCode。
使用建议
这一能力最适合的场景,不是“做一个自己的权限系统”,而是:
- 基于已有
relation体系,把授权分享出去; - 让别人通过二维码或链接来领取;
- 把共享行为表达成一次明确的业务动作。
因此在使用之前,最好先把对象上的 relation 设计好,再来使用 UserEntityGrant。
Parasite 寄生登录
Parasite 是 oak-general-business 里一个很特别的设计。它不是普通的用户,也不是普通的 token,而是一种“先寄生在某个对象或流程上,后续再被激活成正式登录态”的中间身份。
这个能力通常出现在这些场景里:
- 用户还没正式注册,但已经开始参与流程;
- 需要先发一个临时访问入口给用户;
- 需要在某个对象上生成一次短期、可回收的临时身份。
主要对象
这一章的核心实体是 Parasite。
它定义了:
- 临时身份属于哪个用户;
- 作用在哪个
entity/entityId上; - 过期时间和是否允许重复使用;
- 唤醒后应该跳到哪个页面;
- 关联生成出来的 token。
从对象结构就能看出来,它更像一个“临时登录入口”而不是“长期账号”。
组件
现成组件主要有:
src/components/parasite/detailsrc/components/parasite/excesssrc/components/parasite/listsrc/components/parasite/upsert
其中 detail 组件会在 web 环境下直接生成寄生访问链接,excess 则更像实际落地页。
parasite/upsert 常用参数
这组组件是项目里最常直接包起来用的创建入口,关键参数有:
entityentityIdrelationredirectTomultiplenameLabelnameRequired
它当前的真实流程是:
- 先按昵称前缀搜索
shadow用户 - 如果选中了现有
shadow用户,就直接复用userId - 如果没选中用户,就创建一个新的
shadow用户,并自动补一条userRelation - 把
expiresAt和tokenLifeLength都设成“有效期天数换算后的毫秒数” - 创建成功后直接切换到
parasite/detail展示二维码和链接
也就是说,这个组件不是“只创建 parasite 记录”,而是已经把:
- 找人
- 补影子用户
- 建关系
- 生成分享入口
这一整段流程串起来了。
parasite/list 适合放在哪里
它的关键参数是:
entityentityIdnameLabel
真实行为则是:
- 只看当前
entity + entityId下的 parasite - 默认按创建时间倒序
- 表格操作里直接暴露
cancel和qrcode - 点“详情”时会在弹窗里包
parasite/detail
所以这组组件最适合放在:
- 某个业务对象的后台管理页
- 某条邀请关系的分享记录页
- 某个领取流程的二维码管理页
parasite/detail 的可调参数
除了自动生成链接,它还暴露了几组很实用的展示参数:
disableDownloadsizedisabledcolorbgColor
源码里它的真实链接生成方式也值得直接写进文档:
- web 环境下按
window.location.protocol + hostname + port - 自动拼
/parasite/excess?oakId=${parasite.id}
这意味着项目层如果部署域名已经稳定,parasite/detail 生成的链接就可以直接拿去复制、发二维码、放海报。
parasite/excess 的真实职责
这个组件真正干的是“消费寄生入口”,不是单纯展示页面。它进入后会:
- 先按
oakId查 parasite - 非法就标记
illegal - 过期就标记
expired - 先执行
features.token.removeToken() - 再执行
features.token.wakeupParasite(parasite.id!) - 最后按
redirectTo跳回业务页
而且它在跳转时还会额外把:
nameparasiteId
一起塞进路由参数。
所以如果项目层想在目标页感知“这是寄生入口进来的”,完全可以直接读 parasiteId。
前端入口与 aspect
这一章没有单独的 parasite feature,但它并不是没有前端入口。
真正的唤醒入口在:
src/aspects/token.ts的wakeupParasitefeatures.token.wakeupParasite(...)
也就是说,Parasite 的激活最终仍然走的是 token 体系,而不是自己另起一套登录机制。
后台规则
Parasite 的默认规则主要在:
src/checkers/parasite.tssrc/triggers/parasite.ts
默认行为包括:
- 创建时强制检查
expiresAt和tokenLifeLength不能为空; - 如果是挂到已有
userId上,对应用户必须还处于shadow状态; - 过期时,使关联 token 自动失效;
- 执行
cancel时,也同步使关联 token 失效。
而在 src/aspects/token.ts 的 wakeupParasite(...) 里,还会继续做两层限制:
- 已经过期的
parasite不允许再唤醒; - 只有
shadow用户才能被借用身份唤醒。
真正唤醒成功后,创建出来的 token 也不是长期有效的,它会按 tokenLifeLength 计算 disablesAt。
此外,src/triggers/user.ts 里还有一条很重要的规则:当用户被正式激活后,会把相关的 parasite 作废。
另外还有一个直接影响业务设计的点:
- 如果
multiple=false,wakeupParasite(...)会在创建 token 前先把当前 parasite 标记成失效
这就是一次性寄生入口的真实落地方式。
这说明寄生模式本质上是一段过渡态,而不是长期身份模型。
注入点
这一章的注入点分成两部分:
- 后端规则通过
ogb0Triggers、ogb0Checkers注入; - 前端激活入口通过
features.token暴露。
所以项目层通常不需要自己再做一次“寄生态转正式态”的底层逻辑。
项目中如何接入
Parasite 在项目里通常会拆成两端:
- 管理端或后台页面,负责创建/查看寄生记录;
- 消费端页面,负责拿到
oakId后调用features.token.wakeupParasite(...)激活寄生 token。
公共包里已经把这两端组件都写好了:
src/components/parasite/listsrc/components/parasite/detailsrc/components/parasite/upsertsrc/components/parasite/excess
所以项目层真正要做的,通常只是把路由接出来。
而且 redirectTo 本身就是实体字段,项目层通常只要在创建时配好:
pathnamepropsstate
激活成功后,公共组件就会按这组配置跳回业务页。
当前项目里的实际情况
从这次对 haina-busi 和 taicang 的源码检索来看,当前没有看到它们各自落了独立的 parasite 页面壳,更多还是保留了实体、i18n 和公共能力本身。
这说明一件事:
Parasite当前更像一组随时可接入的公共基础能力- 真正用不用、落在哪个业务对象上,取决于项目自己有没有邀请/临时访问/借用身份的场景
也就是说,新项目接这章时,不需要去找“现成业务页面”,而是应该按自己的业务流程把公共组件挂出来。
推荐的页面拆法
最稳的接法通常是:
- 后台对象详情页挂
parasite/list - 新建弹窗或侧边抽屉挂
parasite/upsert - 分享详情弹窗直接复用
parasite/detail /parasite/excess路由单独包parasite/excess
这样:
- 管理端负责生成入口
- 消费端负责激活入口
职责会很清晰。
使用示例
1. 生成寄生链接
src/components/parasite/detail/index.ts 会直接把寄生链接组装成:
/parasite/excess?oakId=<parasiteId>
因此项目里最常见的做法,就是在管理台展示这个链接或二维码,让目标用户去消费它。
1.1 创建寄生入口时常用的传参方式
项目层最常见的写法通常像这样:
<ParasiteUpsert
oakPath="$parasite-upsert"
entity="yourEntity"
entityId={entityId}
relation="viewer"
redirectTo={{
pathname: '/frontend/yourPage/detail',
props: { oakId: entityId },
}}
multiple={false}
nameLabel="访问者名称"
/>
这里最关键的其实不是 UI,而是:
relation要能在当前对象上找到redirectTo要指向项目里真实存在的页面
2. 在消费页激活寄生 token
src/components/parasite/excess/index.ts 的核心逻辑就是:
const { data: [parasite] } = await this.features.cache.refresh('parasite', {
data: {
id: 1,
expired: 1,
redirectTo: 1,
user: { id: 1, nickname: 1 },
},
filter: { id: oakId },
});
if (!parasite?.expired) {
this.features.token.removeToken();
await this.features.token.wakeupParasite(parasite.id!);
}
公共包里也是先移除当前 token,再唤醒寄生 token,最后按 redirectTo 跳转,这就是项目侧最标准的接入方式。
使用建议
对新手来说,最重要的一点是不要把 Parasite 当成“另一种用户表”。
更准确的理解是:
User代表正式用户;Token代表正式登录态;Parasite代表一段可以被唤醒或回收的临时身份流程。
再补四条开发时必须注意的细节:
- 被复用或新建出来的用户必须是
shadow,否则 checker 和wakeupParasite(...)都会直接拒绝。 redirectTo.pathname最好始终写项目真实路由,不要把跳转逻辑散落在消费页里硬编码。- 如果希望一个分享入口只能用一次,就把
multiple设成false。 - 一旦用户被正式
activate,关联 parasite 会被用户 trigger 自动作废,所以不要把 parasite 当成长期邀请链接。
只要这样分清楚,在设计邀请、领取、临时访问这类功能时,就会顺很多。
Session、Message 与通知
oak-general-business 里有两套容易混淆的“消息”:
- 一套是围绕
Session/SessionMessage的会话消息,更像客服、聊天或对象会话; - 另一套是围绕
Message/Notification的系统消息,更像站内信、模板通知或跨渠道推送。
把这两套能力拆开理解,是读懂这一章的关键。
主要对象
这部分实际涉及的实体不少:
Session:一条会话,绑定到某个entity/entityId;SessionMessage:会话里的具体消息;Message:系统级消息;Notification:消息在具体渠道上的发送记录;MessageType:业务消息类型;MessageTypeTemplate:消息类型和微信模板的映射;MessageTypeSmsTemplate:消息类型和短信模板的映射。
从职责上可以先这样记:
Session/SessionMessage负责“人与对象之间的会话过程”;Message/Notification负责“系统要发出去的一条通知”。
组件
这部分已经带了不少现成组件:
src/components/session/listsrc/components/session/cellsrc/components/session/headersrc/components/session/forMessagesrc/components/session/messageNumbersrc/components/session/sessionMessagesrc/components/message/listsrc/components/message/detailsrc/components/message/cellsrc/components/message/simpleListsrc/components/my/messagesrc/components/sessionMessage/listsrc/components/sessionMessage/upsertsrc/components/sessionMessage/cellsrc/components/messageTypeTemplate/listsrc/components/messageTypeSmsTemplate/listsrc/components/messageTypeSmsTemplate/tab
也就是说,这部分不仅有实体和后台逻辑,连前端展示层都已经准备了不少基础积木。
aspect / endpoint / feature
这部分直接暴露的后端 aspect 主要是:
createSession
它定义在 src/aspects/session.ts,用于按当前应用类型和消息来源创建或获取会话,并在需要时级联创建 sessionMessage。
这一章没有独立的 session endpoint 或 message endpoint,但它会通过微信相关 endpoint 间接进入。最典型的入口就是 src/endpoints/wechat.ts,公众号或小程序消息回调最终会调用 createSession(...)。
此外,这一章还会和 features.template 发生关系。features.template 并不直接发送消息,但会负责:
- 获取消息类型列表;
- 同步微信模板;
- 同步短信模板。
所以消息类型和模板映射虽然在数据层属于“消息域”,但真正的同步入口在 Template feature 和对应 aspect 里。
后台规则
这一章的核心后台逻辑主要分布在几组 trigger / checker 文件里:
src/triggers/session.tssrc/triggers/sessionMessage.tssrc/triggers/message.tssrc/triggers/notification.tssrc/checkers/message.ts
默认行为包括:
- 创建
session时,通知订阅了 session 列表变化的前端事件; - 创建
sessionMessage时,更新关联session.lmts; - 创建
sessionMessage后,在提交事务后再做消息推送。
而 Message / Notification 这一支的规则是:
- 创建
message时,会先根据userSystem、消息权重、限制渠道等信息自动拆出notification; - 创建
notification后,在事务提交成功之后才真正发送; notification成功或失败后,会继续反向更新message.iState;medium权重消息在其它渠道都失败时,还会尝试补发短信;- 非 root 场景下查询
message时,checkers/message.ts会自动把结果收窄到当前systemId可见的数据。
这也是 Oak 的一个典型用法:真正需要推送外部消息的逻辑,放在提交之后,而不是直接塞进前端组件里。
注入点
这一章没有单独的 session feature,它的注入方式主要是:
- 后端通过
ogb0Triggers注入会话和消息的自动维护规则; - 前端组件直接围绕实体数据树工作;
- 模板同步能力通过
features.template注入。
这说明它更像一组已经接到 Oak 运行时上的基础设施,而不是一套必须从某个总入口手动初始化的业务模块。
项目中如何接入
Session 这章在项目里最常见的接法有三种:
- 做客服/私信后台时,直接复用
src/components/session/*和src/components/sessionMessage/* - 做微信消息接入时,让
wechat endpoint自动调用createSession(...)生成或更新会话 - 做系统通知中心时,直接创建
message记录,让message / notification触发链自动拆渠道并发送
换句话说,这一章通常不是“项目自己设计会话模型”,而是“项目只决定从哪里进入、展示在哪个页面”。
session/list 常用参数
oak-general-business/src/components/session/list/index.ts 这一组组件其实比看起来更灵活。项目里最常用的参数有:
entityFilterentityFilterSubStrentityDisplayentityProjectionsessionIddialogonItemClick
其中有两种非常不同的工作模式:
- 不传
entityFilter这时组件会默认按当前 userId查自己的会话列表,并订阅${DATA_SUBSCRIBER_KEYS.sessionList}-u-${userId}这类数据事件; - 传
entityFilter这时它会按业务对象维度查会话,并根据entityFilterSubStr订阅对应的数据事件。
entityDisplay + entityProjection 这组参数也很关键。它们决定“列表上显示的是用户名字,还是某个业务对象名字”。如果你是在做“对象会话列表”而不是“我的私信列表”,通常就应该一起传这两个参数,让组件把会话里关联对象名称补出来。
还有两个很实用的行为:
- 如果父层传了
sessionId,组件会默认把这一条设成选中态; - 如果传了
onItemClick,点击会话后不会直接跳/session/sessionMessage,而是把控制权交给父组件。
这意味着 session/list 既可以做独立页左侧会话栏,也可以做弹窗里的嵌入式会话选择器。
session/forMessage 的真实职责
这组组件在原文里还没展开,但它非常适合做“消息页顶部的当前会话头”。oak-general-business/src/components/session/forMessage/index.ts 当前最关键的参数是:
sessionIdisEntityentityDisplayentityProjection
它的真实行为是:
- 如果传了
sessionId,就直接从 cache 里把这条session及其用户 / 关联对象信息读出来; - 如果
sessionId发生变化,会自动重新取当前会话; isEntity = true时,标题优先按会话里的用户信息显示;isEntity = false时,则会调用你传入的entityDisplay([session])来决定显示名。
也就是说,这个组件的价值不是“再包一个 header”,而是把“当前聊天对象叫什么”这件事统一收口了。
my/message 的真实职责
如果只是做个人中心里的“未读消息入口”,通常没必要直接挂整页消息列表,my/message 就够了。这个组件的真实行为很集中:
- 进入时直接按当前登录用户
userId统计message.visitState === 'unvisited' - 维护一个未读数量
count - 点击后默认跳
/message/list
也就是说,它更像一个“消息角标入口组件”,适合放在:
- 我的页面
- 个人中心快捷入口区
- TabBar 上方的消息入口卡片
而真正的消息列表页、消息详情页,再分别交给 message/list、message/detail。
真实项目里的启动注册
这一块最值得直接参考 haina-busi 和 taicang 的例程:
haina-busi/src/routines/messageType.tstaicang/src/routines/registMessageType.ts
它们都会先执行:
registerMessageType(Object.keys(MessageTypes));
而 haina-busi/src/routines/start.ts 还继续注册了:
registerMessageHandler('wish', wishHandler);
registerNotificationHandler('wish', wishNotificationHandler);
这说明公共包把“消息对象、会话对象、通知链”准备好了,但真正有哪些消息类型、由谁来发送、由谁来处理,仍然应该由项目层在启动时注入。
组件适合落在哪里
从 taicang 的现有页面看:
message/list适合放在管理台消息列表页message/detail适合放在消息详情页- 会话相关组件更适合放在客服页、公众号消息页、系统通知页
项目层通常只做路由、筛选条件和业务跳转,不建议重写消息列表本身。
如果再按组合关系细一点拆,会更接近真实项目:
- 左侧会话栏:
session/list - 顶部当前会话信息:
session/forMessage - 中间消息流:
sessionMessage/list - 底部发送框:
sessionMessage/upsert - 个人中心未读入口:
my/message
这样拼出来的聊天页,基本就是公共包当前已经准备好的标准组合。
使用示例
1. 在微信回调里自动创建或更新会话
oak-general-business/src/endpoints/wechat.ts 会在收到公众号或小程序消息时调用:
await createSession(
{
data,
type: 'wechatPublic',
entity: 'application',
entityId: applicationId,
},
context
);
这一步会自动查找已有 session,必要时补出新的 sessionMessage 和 extraFile。
2. 客服页直接复用会话组件族
如果项目要做一个标准的聊天后台,最推荐的组合是:
src/components/session/listsrc/components/session/sessionMessagesrc/components/sessionMessage/listsrc/components/sessionMessage/upsert
这样会话列表、消息列表、消息发送框、图片上传和数据订阅都能直接复用,不需要项目层自己重新拼一套。
3. 创建系统消息,让通知链自动工作
如果你要给用户发系统通知,项目层更推荐直接创建 message,而不是自己手工落 notification:
await context.operate('message', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
entity: 'order',
entityId: orderId,
userId,
type: 'orderPaid',
weight: 'high',
title: '订单已支付',
content: '你的订单已经支付成功',
channels: ['wechatPublic', 'email'],
},
}, {});
后续的 notification 创建、提交后发送、状态回写,都会由 triggers/message.ts 和 triggers/notification.ts 自动完成。
使用建议
最简单的判断标准是:
- 需要围绕某个对象做持续会话,用
Session/SessionMessage; - 需要给用户发系统通知或跨渠道消息,用
Message/Notification; - 需要做模板映射,就把业务消息类型维护在
MessageType一侧,再绑定微信模板或短信模板。
这样拆开以后,后续对微信、短信等具体渠道的理解也会清晰很多。
ExtraFile 文件与对象存储
ExtraFile 是 oak-general-business 里复用率非常高的一块能力。头像、附件、图片、文章素材、消息图片、微信素材,很多看起来不同的“文件需求”,最后都会落到这一个对象模型和一组统一的上传流程上。
它最大的价值不是“多一个文件表”,而是把:
- 文件元数据;
- 应用级 COS 配置;
- 前端上传过程;
- 分片上传;
- 远端删除;
- URL 生成;
全部收敛成了一套统一运行方式。
主要对象
这一章的核心实体是 ExtraFile。
它定义了:
- 文件来源
origin - 文件类型
type - 关联对象
entity/entityId - 文件名、后缀、大小、对象存储桶等元数据
- 上传状态
uploadState - 是否启用分片上传
- 分片上传信息
chunkInfo
也就是说,在 Oak 里“文件”从来不是游离在业务对象之外的,而是明确绑定在某个业务对象上的。
组件
围绕文件能力,这个包提供的组件非常多:
src/components/extraFile/uploadsrc/components/extraFile/gallerysrc/components/extraFile/avatarsrc/components/extraFile/cropsrc/components/extraFile/commitsrc/components/extraFile/forUrl
如果只是要做上传、预览、头像或附件列表,通常都不需要自己从头写。
extraFile/upload 常用参数
oak-general-business/src/components/extraFile/upload/index.ts 的参数很多,但项目里最常用的是这些:
entity/entityId:文件挂到哪个实体、哪条记录上type:文件类型,常见是imageorigin:文件走哪个 COS 来源tag1/tag2:业务标签autoUpload:选完文件是否立刻上传maxNumber:最多允许多少个文件accept:web 端允许的 MIME 类型theme:file、image、image-flow、customdisablePreview/disableDelete/disableAdd/disableDownloadchunkOptions:大文件分片上传配置
结合 oak-general-business/src/components/extraFile/upload/index.ts 的真实实现,这里还有几条非常值得直接写出来的隐含约束:
- 如果
autoUpload = true,组件内部会直接assert(entityId),也就是自动上传模式下必须已经知道文件要挂到哪条业务记录上; - 组件卸载时,如果还有
uploading状态的文件,会主动调用features.extraFile.abortUpload(...)中止上传; - 小程序端会按
type自动分流:image/video走wx.chooseMedia,其它文件走wx.chooseMessageFile; calcMd5 = true时,会在前端先算 MD5,再把值带进extraFile.md5;tag1/tag2不只是展示标签,组件内部会把它们直接带进 filter 和创建数据。
也就是说,这个组件不是一个纯 UI 壳,而是已经把:
- 文件选择
- 本地暂存
- 上传态跟踪
- 自动上传 / 手动上传分流
- 卸载中止
这些都接进去了。
extraFile/commit 常用参数
这组组件的职责不是选文件,而是把当前节点里所有 extraFile 相关操作和实体提交一起执行。项目层最常传:
entity:当前页面真正要提交的实体action:默认update,也可以是项目自己的动作afterCommit:提交并上传成功后的回调beforeCommit:提交前拦截校验messageProps:执行时的消息提示
它的真实执行顺序也很值得写清楚:
- 先从当前 runningTree 里递归找出本次操作里涉及的
extraFile创建项; - 先执行当前实体的
execute(...); - 再把这些
extraFile里仍处于local/failed状态的文件逐个调用features.extraFile.upload(...); - 如果有失败文件,会把
failureIds留在组件状态里; - 下一次再点提交时,不会重复执行实体操作,而是只重试失败上传。
这意味着 extraFile/commit 很适合放在“表单提交按钮”位置,因为它本来就不是普通按钮,而是“实体提交 + 文件补传”的组合动作。
extraFile/upload 和 extraFile/commit 的典型搭配
这两组组件最推荐的理解方式其实是:
upload负责把文件先挂进当前 Oak 数据节点;commit负责把业务实体和文件一起真正提交完成。
所以项目层如果是“编辑资料页 + 上传附件”这种典型表单,不建议把上传和保存拆成两套互不相干的按钮,更推荐让底部主按钮直接走 extraFile/commit。
extraFile/forUrl 常用参数
这个组件适合“图片地址不是本地上传,而是外部 URL”的场景。最常用的是:
entity/entityIdtag1/tag2imgUrlsorigin
taicang 里外链新闻素材页就用了它来处理外部图片 URL。
它还有几个很容易被忽略、但源码里已经做好的行为:
- 支持三种录入方式:本地上传、直接填 URL、从
imgUrls里挑原图; - 如果 URL 是
mmbiz.qpic.cn这类微信图片地址,会自动把isBridge置为true; origin为外链模式时当前会直接记成unknown,并把uploadState设为success;- 如果当前节点本来已经挂了一张图,重新选择时会先删旧图再建新图。
所以它并不是一个“单纯展示 URL 输入框”的组件,而更像“把外部图片也纳入 extraFile 统一模型”的桥接器。
extraFile/gallery 常用参数
如果页面只需要“展示已经挂好的文件”,通常更适合直接用 extraFile/gallery,而不是继续复用上传组件。这个组件常用参数包括:
entity/entityIdtag1/tag2modesizestyledisablePreviewdisableDownload
从源码看,它有几个很明确的默认行为:
- 会先按
sort排序 - 如果传了
tag1/tag2,会先在当前数据集中做二次过滤 - 展示 URL 和缩略图 URL 都统一走
features.extraFile.getUrl(...) - 文件名统一走
features.extraFile.getFileName(...)
其中:
style用来控制缩略图 URL 的样式参数mode和size更偏小程序展示- 小程序环境下如果没禁用预览,会直接调用
wx.previewImage(...)
所以项目里做:
- 图库
- 商品图片列表
- 文章插图预览
- 用户上传附件展示
这类只读场景时,优先用 gallery 会更干净,不要再拿上传组件硬改成只读模式。
前端 feature 与 aspect
文件能力最主要的前端入口是 features.extraFile。它负责:
- 本地文件暂存;
- 普通上传;
- 自动上传;
- 分片上传;
- 终止上传;
- 生成展示 URL。
对外公开的后端 aspect 主要有:
getInfoByUrlmergeChunkedUploadpresignFilepresignMultiPartUpload
这些 aspect 主要服务于上传链路本身,而不是给业务层做复杂的文件处理。
trigger / watcher
这部分的后台规则非常完整。
src/triggers/extraFile.ts 负责:
- 创建
extraFile时生成上传所需的元数据; - 对分片上传生成初始分片信息;
- 删除
extraFile时同步删除远端文件。
src/watchers/extraFile.ts 负责:
- 定期检查长时间处于
uploading状态的普通上传; - 定期处理长时间未完成的分片上传;
- 能合并就合并,不能合并就标记失败。
这说明 ExtraFile 不是一个“前端传完就结束”的能力,它有完整的后台补偿链路。
注入点
这一章最重要的注入点在 oak-general-business/src/features/index.ts 的 initialize(...):
- 如果你传入了 COS 类数组
clazzes,就会执行features.extraFile.registerCos(clazzes); - 之后
features.extraFile才知道不同origin应该如何上传、签名和拼接 URL。
因此,像 bm-smart 这类项目会在初始化时显式传入 Qiniu、S3、Aliyun 等实现。
真实项目里的 COS 注册方式
这块在 haina-busi 和 taicang 里都能看到,而且前后端写法略有不同:
haina-busi/src/routines/start.ts:registerCosBackend(Aliyun)、registerCosBackend(S3)taicang/src/routines/start.ts/start.frontend.ts:registerCos(Qiniu)- 前端初始化时再把 COS 类数组传给
initializeOgb0Features(...)或initializeOpb1Features(...)
这意味着:
- 后端负责真正把 COS 供应商实现注册进运行时
- 前端初始化负责把这些实现交给
features.extraFile - 页面组件只需要传
origin
项目中如何接入
ExtraFile 在项目里真正接通,至少要满足两个条件:
- 初始化时执行
initializeOgb0Features(...) - 把当前项目要支持的 COS 实现传进去
bm-smart/src/initializeFeatures.ts 的真实写法就是:
await initializeOgb0Features(
features,
accessConfiguration,
undefined,
[Qiniu, S3, Aliyun]
);
只有这一步做完,features.extraFile.registerCos(clazzes) 才会被调用,后面的签名、上传、URL 拼接、分片合并才都知道该走哪个存储实现。
真实项目中的典型组合
从 haina-busi 和 taicang 的页面来看,最常见的组合其实只有三种:
- 表单页里嵌
extraFile/upload,底部按钮用extraFile/commit - 富文本或图片位配置页,直接把
extraFile/upload当一个字段控件用 - 外部图片 URL 场景用
extraFile/forUrl - 详情页和只读页用
extraFile/gallery
像供应商资料、Banner、合同申请、系统 Logo、房间配置这些页面,基本都是这三种变体,没有必要每个项目自己重造上传流程。
如果按页面职责再细分一下,更推荐:
- 已有业务对象 id,且选完就想立刻传:
extraFile/upload + autoUpload - 业务对象还没最终提交,希望和表单一起保存:
extraFile/upload + extraFile/commit - 图片来自公众号文章、第三方链接或素材抓取:
extraFile/forUrl - 纯只读图片墙或附件展示:
extraFile/gallery
使用示例
1. 在组件里自动上传文件
bm-smart 和公共包自己的编辑器组件,都是这么调用的:
const url = await this.features.extraFile.autoUpload({
extraFile: {
origin: 's3',
type: 'image',
entity: 'post',
entityId: postId,
filename: file.name,
size: file.size,
sort: 1000,
},
file,
});
这条调用会先创建 extraFile 行,再由 trigger 补上传元数据,最后上传成功后返回展示 URL。
2. 在页面里渲染文件 URL
bm-smart 里很多页面都是这样取图:
const imgUrl = this.features.extraFile.getUrl(extraFile);
所以项目层不要手工去拼对象存储访问地址,统一交给 features.extraFile.getUrl(...)。
使用建议
正确的使用顺序通常是:
- 先创建
extraFile数据; - 让 trigger 补齐上传元数据;
- 再通过
features.extraFile发起上传; - 如果是分片上传,交给 watcher 兜底检查和补偿。
如果一开始就绕过 ExtraFile 直接往对象存储写文件,那么后面很多 Oak 侧的关联关系和自动行为就都接不上了。
WeChat 公众号/小程序能力
oak-general-business 里最复杂的一块通用能力,几乎一定是微信生态。
这里不是只有“微信登录”这么简单,而是同时覆盖了:
- 网页微信登录;
- 小程序登录;
- 公众号网页登录;
- 公众号回调;
- 微信用户绑定;
- 微信二维码;
- 菜单管理;
- 公众号标签;
- 微信模板;
- 素材管理;
- 短信跳小程序 openlink。
如果把这些能力都混在一起看,会非常乱。所以这一章的重点,是把它们在 Oak 里的边界讲清楚。
主要对象
微信相关的主要实体包括:
WechatUserWechatLoginWechatQrCodeWechatMenuWechatPublicTagUserWechatPublicTagWechatTemplateWechatPublicAutoReplyWechatMpJump
此外,很多微信能力其实还依赖 Application.config 里的微信配置,所以 Application 仍然是微信能力的根配置入口。
组件
这一章涉及的组件非常多,常见的有:
src/components/passport/wechatMpsrc/components/passport/wechatMpForWebsrc/components/passport/wechatPublicsrc/components/passport/wechatPublicForWebsrc/components/wechatLogin/qrCodesrc/components/wechatLogin/confirmsrc/components/wechatUser/loginsrc/components/wechatUser/bindingListsrc/components/wechatUser/unbindBtnsrc/components/wechatQrCode/scansrc/components/wechatQrCode/sharesrc/components/wechatMenu/*src/components/wechatPublicTag/*src/components/userWechatPublicTag/*src/components/wechatMaterialLibrarysrc/components/wechatPublicAutoReplysrc/components/common/weChatLoginGrantsrc/components/common/weChatLoginQrCode
从这些组件命名就能看出来,微信能力在这个包里已经不只是“一个登录按钮”了。
wechatUser/login 常用参数
这个组件本身几乎不要求项目层传很多东西,最常见的就是:
codestateoakPath
taicang/src/pages/frontend/wechatUser/login/web.pc.tsx 和 haina-busi/src/pages/wechat/wechatUser/login/web.pc.tsx 的写法都很薄:
<WechatUserLogin code={code} state={state} oakPath="$wechatUser/login-cpn" />
它内部会自己读取当前 URL 上的 code / state,调用 features.token.loginWechat(...),成功后按 state 跳回业务页。
wechatQrCode/scan 常用参数
扫码组件最关键的两个参数是:
scene:小程序码扫码场景值q:普通链接二维码携带的值
组件内部会把它们展开成二维码记录 id,然后按二维码里保存的 pathname / props / state 自动跳转。
自定义微信授权确认页
除了公共组件,haina-busi 里还有一个很值得参考的项目层包装:components/wechatLogin/confirm。它会先拼好公众号 OAuth 地址,再把 redirectUri 指向:
/wechat/wechatUser/login
这种做法适合项目需要在真正跳微信授权前,先展示一层“确认登录到哪个业务对象”的页面。
wechatUser/bindingList / wechatUser/unbindBtn
这两个组件更偏“账号绑定管理”,通常不会放在登录页,而会放在:
- 个人中心
- 账号安全页
- 后台用户详情页
wechatUser/bindingList 的真实行为很轻量:
- 读取
wechatUser列表 - 只保留已经绑定到本地用户的记录,也就是
userId不为空的项
所以它适合做“我当前绑定了哪些微信身份”的列表视图,而不是通用的微信用户管理页。
wechatUser/unbindBtn 更简单,它只读取当前 wechatUser.id 和 userId,作用就是给解绑按钮提供当前记录上下文。项目层通常会把它和:
unbindingWechat
这类 aspect 动作一起用,而不是自己去查 wechatUser 再手工拼按钮。
公众号后台管理组件
除了登录和扫码,oak-general-business 还把公众号后台常用能力拆成了几组现成组件。它们最适合直接挂在 application/panel 自动生成的微信页签里。
wechatMenu
这个组件最关键的参数是:
applicationIdtabKey
它在进入页面时会先拉当前 applicationId 下、没有 wechatPublicTagId 的默认菜单,并把 menuId 放到内部状态里。也就是说,它默认管理的是“公众号主菜单”,不是泛化的任意菜单树。
wechatPublicTag/list
这个列表组件的关键参数只有:
applicationId
但它内部已经带了几个实际动作:
- 单条
sync oneKeySync- 删除标签
这意味着项目后台如果只是做公众号标签同步,不需要自己调用 feature 拼一个管理页,直接复用它就够了。
userWechatPublicTag/subscribedList
这也是公众号运营后台里很实用的一个组件,关键参数同样只有:
applicationId
它默认只查:
origin: 'public'subscribed: true
所以它适合做“已关注用户与标签”的运营列表,而不是泛化的全部微信用户列表。它内部还能直接调用:
userWechatPublicTag.tagging(...)userWechatPublicTag.syncToLocale(...)
wechatMaterialLibrary
这个组件最重要的参数是:
applicationIdtypegetMenuContent
其中 type 决定它去拉:
news图文素材- 其它永久素材类型,例如
image、voice、video
它内部会直接调 features.wechatMenu.batchGetArticle(...)、batchGetMaterialList(...)、createMaterial(...)、getMaterial(...),所以非常适合给菜单编辑、自动回复编辑器或图文选择弹窗做素材选择面板。
wechatPublicAutoReply
这个组件也只要求:
applicationId
但它有一个很关键的默认行为:如果当前公众号还没有自动回复配置,ready() 阶段会自动补一条:
type: 'text'event: 'subscribe'
所以项目层不需要担心“第一次进入页面没有任何回复配置时界面空掉”,公共组件已经把首条默认记录准备好了。
前端 feature 与 aspect
前端直接可用的微信相关 feature 主要有:
features.tokenfeatures.wechatSdkfeatures.wechatMenufeatures.wechatPublicTagfeatures.userWechatPublicTagfeatures.template
这里要特别注意两点:
- 微信登录虽然是“微信能力”,但前端真正调用的通常是
features.token.loginWechatMp()、features.token.loginByWechatInWebEnv(...)这一层; - 微信模板同步虽然属于微信生态,但前端入口并不是单独的
wechat feature,而是features.template。
对应的 aspect 也很丰富,主要包括:
- 登录相关:
loginWechat、loginWechatMp、loginWechatNative、loginByWechat - 用户同步相关:
syncUserInfoWechatMp、refreshWechatPublicUserInfo、getWechatMpUserPhoneNumber、setUserAvatarFromWechat - 微信登录中间态:
createWechatLogin - 微信解绑:
unbindingWechat - 二维码:
getMpUnlimitWxaCode - 菜单:
getCurrentMenu、getMenu、createMenu、createConditionalMenu、deleteConditionalMenu、deleteMenu - 标签:
createTag、getTags、editTag、deleteTag、syncTag、oneKeySync - 用户标签:
getTagUsers、batchtagging、batchuntagging、getUserTags、getUsers、tagging、syncToLocale、syncToWechat - openlink:
wechatMpJump - 微信素材与模板:
signatureJsSDK、uploadWechatMedia、batchGetArticle、getArticle、batchGetMaterialList、getMaterial、deleteMaterial、syncWechatTemplate
这已经是一整套相当成型的微信业务中台能力了。
endpoint
这一章必须单独强调真实暴露出来的 endpoint 名称,而不只是文件名。
src/endpoints/wechat.ts 里注册了三个 endpoint:
wechatPublicEventwechatMpEventwechatMaterial
其中:
wechatPublicEvent同时包含 GET 验证接口和 POST 回调接口;wechatMpEvent同时包含 GET 验证接口和 POST 回调接口;wechatMaterial用于读取素材二进制内容,通常会被素材库、菜单预览等界面间接使用。
src/endpoints/index.ts 则在导出这些 endpoint 的同时,额外导出了:
registerWeChatPublicEventCallback
它允许项目层在不改公共包 endpoint 主流程的情况下,继续挂接自己的公众号事件处理逻辑。
这些 endpoint 共同承担了微信服务器回调入口,用于处理:
- 公众号事件;
- 小程序事件;
- 扫码、订阅、菜单点击等回调。
也正因为有这一层 endpoint,WechatLogin、WechatQrCode、WechatUser 等对象之间的联动才真正成立。
后台规则
后台规则主要散落在这些 trigger / checker 里:
triggers/wechatLogin.ts:创建登录中间态时自动生成二维码,过期时同步二维码过期;triggers/wechatQrCode.ts:按二维码类型真正生成公众号二维码、小程序码或跳转链接;triggers/wechatMenu.ts:删除个性化菜单前调用微信删除接口;triggers/wechatPublicTag.ts:删除标签前同步删除微信端标签并清理关联数据;triggers/wechatMpJump.ts:创建WechatMpJump时生成 openlink;checkers/wechatPublicTag.ts、checkers/wechatQrCode.ts:校验微信标签和二维码数据。
这一套规则说明,微信相关对象并不是“你手工写好数据再去用”,很多关键字段和远端副作用都是由 trigger 自动完成的。
注入点
微信能力的注入点非常明确:
- 前端在
create(...)时注入wechatSdk、wechatMenu、wechatPublicTag、userWechatPublicTag; - 登录相关能力通过
features.token暴露给页面和组件; - 模板同步能力通过
features.template暴露给页面和组件; initialize(...)在 web 环境下调用features.wechatSdk.setLandingUrl(window.location.href);initialize(...)在小程序环境下会按需自动登录;- 后端通过
ogb0Aspects、ogb0Checkers、ogb0Endpoints、ogb0Triggers合并微信相关能力; - 如果项目需要补充公众号或小程序事件处理,直接从
src/registry.backend.ts注册registerWeChatPublicEventHandler(...)/registerWeChatPublicEventAfterHandler(...)和registerWeChatMpEventHandler(...)/registerWeChatMpEventAfterHandler(...);主处理器先于默认逻辑执行,after 处理器在默认逻辑后补充收尾。
换句话说,微信生态的前后台装配都已经在 oak-general-business 里设计好了。
真实项目里的接法
taicang 和 haina-busi 实际都采用了一条统一链路:
- 登录页或业务页里的
user/login把redirectUri指向/wechatUser/login /wechatUser/login页面只包wechatUser/login- 二维码场景再单独提供
/wechatQrCode/scan - 公众号或小程序回调最终还是落回
features.token
这样做的好处是,项目层不用到处自己处理 code/state,而是统一交给公共组件。
项目中如何接入
微信能力在项目里的接入,通常分成三段:
Application.config里配好wechatMp/wechatPublic/web.wechat- 初始化时执行
initializeOgb0Features(...),让wechatSdk、token、template、application这些 feature 串起来 - 后台把
ogb0Aspects / ogb0Checkers / ogb0Triggers / ogb0Endpoints合并进去,真正暴露微信回调入口并接上默认规则
所以项目层真正要关心的,通常是“应用配置是否正确”“路由和回调地址是否对上”,而不是从零写微信协议处理。
使用示例
1. Web 端调用微信 JSSDK
oak-general-business/src/features/wechatSdk.web.ts 已经把签名和 wx.config 包好了,项目里直接这样用:
await this.features.wechatSdk.loadWxAPi('chooseImage', { count: 1 });
await this.features.wechatSdk.loadWxAPi('scanQRCode', { needResult: 1 });
通常不需要页面自己再去调用 signatureJsSDK(...),因为 WechatSdk feature 会先完成签名初始化。
2. 管理公众号标签和模板
这些能力已经有直接可调用的 feature:
const applicationId = this.features.application.getApplication().id!;
await this.features.wechatPublicTag.createTag({
applicationId,
name: '核心用户',
});
await this.features.template.syncWechatTemplate(applicationId);
3. 小程序端显式触发登录
如果项目关闭了自动登录,也可以手工调用:
await this.features.token.loginWechatMp();
4. 项目侧补充公众号事件处理
如果默认的 wechatPublicEvent 流程之外,你还要补项目自己的事件逻辑,可以注册带 msgType / event 的处理器:
import {
registerWeChatPublicEventAfterHandler,
registerWeChatPublicEventHandler,
} from '@oak-general-business/registry.backend';
registerWeChatPublicEventHandler({
applicationId: 'wx-public-app-id',
msgType: 'event',
event: 'subscribe',
handler: async ({ data, context }) => {
// 项目自己的补充逻辑
},
});
registerWeChatPublicEventAfterHandler({
applicationId: 'wx-public-app-id',
msgType: 'event',
event: 'subscribe',
handler: async ({ response }) => {
// 默认逻辑后的收尾处理
},
});
5. 项目侧补充小程序事件处理
小程序回调也走同一套注册表:
import { registerWeChatMpEventHandler } from '@oak-general-business/registry.backend';
registerWeChatMpEventHandler({
applicationId: 'wx-mp-app-id',
msgType: 'event',
event: 'subscribe',
handler: async ({ data }) => {
// 小程序项目自己的补充逻辑
},
});
6. 用统一扫码页承接二维码跳转
如果项目里有活动页、分享页、落地页需要二维码跳转,推荐像 taicang / haina-busi 一样单独挂一个扫码页:
<WechatQrCodeScan
scene={scene}
q={q}
oakPath="$wechatQrCode/scan-cpn"
/>
这样二维码失效、非法二维码、跳转目标解析这些细节都会由公共组件统一处理。
使用建议
最推荐的理解方式是按链路拆:
- 登录链路:
Passport/ApplicationPassport+Token aspect+WechatLogin - 用户绑定链路:
WechatUser - 二维码链路:
WechatQrCode - 管理后台链路:
WechatMenu/WechatPublicTag/UserWechatPublicTag - 素材模板链路:
WechatTemplate+ 素材相关 aspect
这样读,你会发现这并不是零散功能,而是围绕微信生态拆出来的一整层业务基础设施。
Article 与内容树
oak-general-business 的文章能力不是一个简单的“富文本表”。它更像一套轻量级内容树:
ArticleMenu负责内容分类树;Article负责具体内容;- 组件层再补上目录、预览、编辑器、树形展示等能力。
所以如果你需要做帮助中心、知识库、栏目页、文章树,这一章会非常有价值。
主要对象
文章模块的核心实体只有两个:
ArticleArticleMenu
但这两个对象之间的联动很强:
ArticleMenu维护树结构、是否存在文章、最近编辑时间;Article挂在某个ArticleMenu下,并且可以关联文件。
组件
这一章已经提供了非常完整的前端组件族:
src/components/article/detailsrc/components/article/editorsrc/components/article/listsrc/components/article/previewsrc/components/article/tocsrc/components/article/treeListsrc/components/article/upsertsrc/components/articleMenu/containersrc/components/articleMenu/detailsrc/components/articleMenu/listsrc/components/articleMenu/treeCellsrc/components/articleMenu/treeListsrc/components/articleMenu/treeManager
对新手来说,这意味着你完全可以把文章模块当成一个“可直接拼起来的内容子系统”。
article/upsert 常用参数
oak-general-business/src/components/article/upsert/index.ts 这一组参数最常用:
articleMenuId:新建文章时挂到哪个菜单下tocPosition:编辑时目录放左、右还是不显示highlightBgColor:点击目录时的高亮颜色onArticlePreview:点预览时把内容交给项目层origin:正文里插图上传走哪个 COS 来源scrollId:目录联动的滚动容器height:编辑器高度activeColor:目录当前项高亮色
haina-busi/src/pages/business/article/upsert/web.pc.tsx 的真实接法很典型:
<Upsert
oakId={oakId}
oakPath="$article-upsert"
onArticlePreview={(content, title) => {
saveData(content, title);
window.open('/doc/article/preview');
}}
tocPosition="left"
/>
article/detail / article/preview
这两组组件更适合做“读”而不是“写”:
article/detail:适合正式详情页,常用参数有tocClosed、tocFixed、tocPosition、scrollId、showtitlearticle/preview:适合编辑器预览页,参数和detail很接近,但内容来源是本地缓存的article_html
所以项目里一般会采用:
- 编辑页用
article/upsert - 预览弹窗或独立预览页用
article/preview - 对外展示页用
article/detail
article/list / article/treeList / article/editor
如果项目里不只是“编辑单篇文章”,而是要做完整内容后台,这三组组件也很值得单独说明。
article/list 的关键参数包括:
articleMenuIdgenerateUrlemptymenuCheck
它当前的真实行为是:
- 固定按
articleMenuId过滤文章 - 默认按创建时间升序排列
- 自动格式化
$$updateAt$$ - 通过
generateUrl(mode, action, id)统一生成详情、复制、编辑跳转地址 - 删除文章后,会回头检查所属菜单的
isArticle状态并通过menuCheck回传
这意味着它非常适合做“某个栏目下文章列表”的后台组件,而不是泛化的全站文章搜索页。
article/treeList 则更偏“树节点下的文章子列表”,常用参数有:
articleMenuIdshowselectedArticleIdsetCurrentArticlesetCopyArticleUrldrawerOpenchangeDrawerOpen
它还支持直接在当前菜单节点下:
addItem({ name: '文章标题', content: '', articleMenuId })- 执行创建
所以它很适合挂在 articleMenu/treeManager 旁边,做“左边目录树,右边文章子树/预览”的组合界面。
article/editor 本身就是 article/upsert 的核心编辑器能力,除了前面提到的上传和预览,它还有几个很关键的实现细节:
isCreation()且传了articleMenuId时,会自动把新文章挂到当前菜单- 编辑器组件卸载时会主动
destroy() - 标题变化会同步改
document.title
也就是说,项目层如果需要自己重组文章编辑页布局,通常应该优先复用 article/editor,而不是从零接一套富文本编辑器。
articleMenu/container 更适合做完整后台壳
如果项目不是只想要一棵菜单树,而是想做“左边目录、上面面包屑、右边文章列表/新增入口”的完整后台壳,更应该先看:
src/components/articleMenu/container
它的关键参数包括:
entityentityIdtitleoriginmenuEmptyarticleEmptygenerateUrl(mode, action, id)
这里 generateUrl 的动作枚举是公共类型里直接定义好的:
detaileditorpreviewcreatecopy
也就是说,这个组件不是帮你“固定死路由”,而是把真正的跳转地址决定权继续留给项目层。
它当前的真实行为非常适合直接写进文档:
- 进入时按
title初始化面包屑 - 会订阅
articleCreate-entityId、articleMenuUpdate-entityId数据事件,自动调整当前节点是不是文章目录 - 点菜单节点后,会在内部切换
parentId / articleMenuId / showAddArticle / showAddMenu - 新建分类时直接创建
articleMenu - 新建文章时会调用
generateUrl('article', 'create', articleMenuId || parentId)打开新页
所以它特别适合做:
- 帮助中心后台
- 知识库后台
- 文档中心后台
也就是“先选目录,再决定新增分类还是新增文章”的这一类页面。
articleMenu/treeManager 常用参数
这组组件通常用来做“文档树 + 菜单树”的后台入口,常用参数包括:
entity/entityId:菜单树挂在哪个业务对象下show:edit、doc、previewarticleMenuId/articleIdtocPositiononMenuView/onMenuViewByIdonArticleView/onArticlePreview/onArticleEditsetCopyArticleUrl
它和 articleMenu/container 的分工并不完全一样:
container更偏数据节点、面包屑、创建入口和文章列表外壳treeManager更偏完整的左右布局 UI,直接把TreeList + ArticleUpsert/ArticleCell组起来
从 web.pc.tsx 看,treeManager 当前内置了三种模式:
show='edit':左边菜单树,右边直接编辑文章show='doc':左边菜单树,右边展示文章正文show='preview':左边菜单树,右边切换“查看 / 复制链接 / 更新”
这也是为什么项目层常常不是“二选一”,而是:
- 列表或目录后台页用
container - 需要完整文档树编辑体验时再直接上
treeManager
article/detail 的真实刷新行为
article/detail 看起来像一个纯展示组件,但它其实还有一层运行时行为:
- 进入时会订阅
DATA_SUBSCRIBER_KEYS.articleUpdate-${oakId} - 因此文章在别处被更新后,这个详情组件会跟着刷新
它最关键的参数则是:
tocClosedtocFixedtocPositionhighlightBgColorheaderTopscrollIdtocWidthtocHeightshowtitleactiveColor
也就是说,它并不只是“把 HTML 打出来”,而是已经把目录吸顶、滚动容器、标题显示这些阅读页常见细节一起做了。
aspect / endpoint / feature
这一章有一个边界要特别讲清楚:
- 文章模块没有单独的 feature;
- 也没有独立的 article aspect;
- 更没有专门的 article endpoint。
如果你在微信素材相关代码里看到了 batchGetArticle、getArticle,那是微信素材接口的一部分,属于前一章的微信能力,而不是这里的本地文章模块。
本地文章模块主要靠实体、组件、checker、trigger 运转。
后台规则
文章模块的自动行为主要集中在:
src/triggers/article.tssrc/triggers/articleMenu.tssrc/checkers/article.tssrc/checkers/articleMenu.ts
默认规则包括:
- 创建/删除文章时,自动维护所属分类的
isArticle; - 创建/更新/删除文章时,更新分类树的
latestAt; - 创建/更新文章和分类后,通知订阅了数据事件的前端;
- 创建和更新分类时检查同级是否重名;
- 删除文章前,级联删除其关联的
extraFile; - 删除分类前,会级联删除子分类、子文章以及这些对象关联的
extraFile。
这说明文章树的一致性并不是前端自己维护的,而是后端规则层在兜底。
注入点
文章能力的注入点并不在 feature,而在后端规则:
ogb0Triggers注入文章和文章树的自动维护逻辑;ogb0Checkers注入删除和命名等校验;- 前端组件则直接围绕
article/articleMenu数据节点工作。
article/detail 组件还会订阅文章更新事件,这说明这套能力和 Oak 的数据事件机制是连通的。
项目中如何接入
文章模块在项目里的接入方式,通常很简单:
- 后台页面直接复用
articleMenu/treeManager、article/editor、article/detail - 不去自己维护分类树状态,而是把树一致性留给 trigger
- 图片和附件仍然统一复用
ExtraFile
也就是说,项目层最推荐的做法是把文章模块当成一套完整的“内容子系统”,而不是只拿 Article 表自己写一遍后台。
真实项目里的包法
haina-busi 和 taicang 基本都采用同一种思路:
- 后台文章编辑页直接包
article/upsert - 文档目录后台页直接包
articleMenu/treeManager - 面向用户的详情/预览页再分别包
article/detail或article/preview
也就是说,项目层通常只补:
- 页面标题
- 返回按钮
- 文章预览页路由
- 某些业务对象自己的菜单跳转逻辑
公共包本身已经把富文本编辑、目录联动、预览缓存这些基础能力准备好了。
使用示例
一个典型的帮助中心后台,通常会直接把这几块拼起来:
src/components/articleMenu/treeManager负责目录树维护;src/components/article/editor负责正文编辑;src/components/article/detail/preview负责详情和预览。
如果正文里要插图,公共组件本身也是通过 features.extraFile.autoUpload(...) 把图片挂到文章上的,所以项目层最好继续沿用这条链路,而不是自己绕过 ExtraFile 上传。
3. 用目录管理器承接项目自己的文章跳转
haina-busi/src/pages/business/articleMenu/forSquare/web.pc.tsx 的做法就是:
- 菜单页本身交给
articleMenu/treeManager onArticleEdit这类跳转由页面壳决定跳去/doc/article/upsert- 这样项目层仍然可以自由安排路由结构,但不需要重写树管理逻辑
使用建议
如果你准备复用这套内容系统,最推荐的做法是:
- 先把
ArticleMenu当成内容目录树; - 再把
Article当成目录叶子上的内容; - 前端直接复用
treeManager、editor、detail这些组件; - 不要自己在页面里手动维护
isLeaf、isArticle、latestAt。
因为这些值本来就应该交给 trigger 自动维护。
Address、Area 与地图能力
地址模块看上去很普通,但在 oak-general-business 里,它实际上把三类东西放到了一起:
- 业务地址
Address - 行政区划
Area - 地图与地铁辅助能力
因此,这一章不只是“收货地址怎么存”,还包括地址选择器、区域树以及地图服务的注入方式。
主要对象
这一章涉及的实体有:
AddressAreaSubwayStationSubwayStation
其中:
Address是业务地址;Area是只读的行政区划字典;Subway/Station/SubwayStation则提供地铁线路与站点关系。
近期 Area 数据已经进入全球 locale 化。项目如果展示多语言地址或区域选择器,应把区域名称当作 i18n 数据的一部分同步,而不是在页面里手写地区名映射。
组件
围绕这些对象,已经有一组很实用的组件:
src/components/address/listsrc/components/address/upsertsrc/components/area/upsertsrc/components/pickers/areasrc/components/config/upsert/mapsrc/components/subwayLine/listsrc/components/subwayLine/pickersrc/components/subwayLine/upsertStationsrc/components/subwayLine/upsertSubwaysrc/components/amap/mapsrc/components/amap/location
这说明地址模块并不局限于“表单里填几项字段”,而是包含了一整套选择和辅助展示能力。
address/list 与 address/upsert
公共包里的这两个组件是最基础的一组地址页能力。
address/list 当前行为很直接:
- 读取
name、phone、detail、area - 自动把
省市区 + detail组合成展示文本 - 点击某一项时跳到
/address/upsert?oakId=... - 没有数据时展示“创建”按钮并跳到
/address/upsert
也就是说,公共实现更像“最小地址簿”,默认没有:
- 默认地址切换
- 删除按钮
- 当前用户过滤
- 业务对象级别的定制文案
address/upsert 则负责最基础的地址编辑,当前会直接维护:
namephoneareaIddetail
它内部还有两个很关键的默认行为:
confirm()会直接execute()然后navigateBack()callAreaPicker()默认跳/pickers/area
所以项目层如果路由沿用公共约定,接起来会非常顺;如果路由不是 /pickers/area,就需要像业务项目那样包一层自己的页面壳。
pickers/area 常用参数
src/components/pickers/area/index.ts 的关键参数很少,但非常重要:
depth,默认是3onAreaSelected
它的真实行为是:
- 初始只查“国家”下一层区域
- 如果点到的区域
depth !== props.depth,继续按parentId往下钻 - 只有点到目标深度,才触发
onAreaSelected(id)
这意味着:
- 收货地址、门店地址这类一般都应该保持
depth=3 - 如果你只想选到城市,可以把
depth改成2
taicang 的真实接法也很典型:
- 单独给它包了
/frontend/pickers/area页面 - 在地址编辑组件里再把它包成一个
visible/onClose/onChange的弹层选择器
也就是说,公共组件负责区域树查询,项目层负责“放成独立页还是弹窗”。
config/upsert/map 常用字段
地图配置页本质上是在编辑 System.config.Map。当前主要支持两套配置:
1. mapWorld
字段很少,核心就是:
webApiKey
2. amaps
这是一个数组,每项主要维护:
keytype
其中 type 当前支持:
personalspecialbusiness
源码里还有两个很值得写进文档的行为:
- 如果配置多个地图服务,系统默认优先使用第一个
- 高德地图这边允许配置多个 key,组件文案也明确提示可轮换使用
而在启动注入处,优先级是:
- 先用
MapWorld - 没有
MapWorld再用AMap
所以项目里不要同时瞎配两套然后期待运行时自动做复杂路由,公共实现不是这么设计的。
amap/map 与 amap/location
如果你的项目要直接复用地图 React 组件,这两组能力最好拆开理解。
amap/map 更偏底层地图容器,常用参数包括:
akeyversionmapPropsmapRefuseAMapUIuiVersionuiCallbacksecurityJsCodeserviceHost
它适合:
- 只展示地图
- 在地图上挂自己的 marker / overlay / 自定义控件
- 自己控制
MapProps
amap/location 则是更完整的“选点对话框”,常用参数包括:
akeyvisibleonCloseonConfirmgeolocationPropsuseGeolocationdialogPropssecurityJsCodeserviceHost
它已经把:
- 地图拖拽选点
- POI 搜索
- 当前定位
- 结果确认
这些交互做完整了。所以项目里如果只是要“让用户选一个地址点位”,优先用 amap/location,不要自己再重拼一套地图搜索弹窗。
subwayLine/* 组件更适合什么场景
这组组件更偏城市服务、线路筛选或门店覆盖范围,不是收货地址的必选项。
subwayLine/list 当前会:
- 读取
subway -> subwayStation$subway -> station - 组装成树结构
- 默认带
areaId='330100'的过滤 - 在
ready()时加载所有城市级area选项
subwayLine/picker 的关键参数有:
areaIdonCancelonConfirm(stationIds)selectIds
它适合做“按地铁站多选筛选”的场景。
而:
subwayLine/upsertStation主要参数是openStation、onClose、subwayIdsubwayLine/upsertSubway主要参数是openSubway、onClose
它们更偏后台字典维护,不是前台用户常用组件。
aspect / endpoint / feature
地址本身没有专门的 frontend feature、aspect 或 endpoint。
这并不代表它是“弱能力”,只是意味着:
- 地址对象的读写主要通过普通 Oak 组件完成;
- 地图能力不是通过本仓库里的自定义 aspect 暴露,而是通过
oak-common-aspect和启动注入点来接入; - 区域树相关的查询辅助则放在
src/utils/area.ts,已经提供了makeAreaAncestorFilter(...)和makeAreaDecendantFilter(...)两个 helper。 - 区域多语言数据需要跟随
make:locale和数据升级流程进入运行时。
另外有一个很容易忽略的真实依赖关系:
oak-pay-business的Order、Ship实体都直接引用了oak-general-business里的Address
所以地址这章并不只是“用户中心的小功能”,支付、物流域本身就在依赖它。
后台规则
地址域自己的 checker 很简单:
src/checkers/address.ts会校验手机号是否合法。
而 src/triggers/address.ts 当前并没有额外触发器逻辑。这反而说明这个模块的后端规则比较干净,更多复杂性留在前端选择与地图辅助上。
注入点
地图能力的真正注入点,不在地址组件里,而在 oak-general-business/src/routines/start.ts。
启动时它会:
- 向
oak-common-aspect注册getMapService; - 按
application.system.config.Map的配置,优先实例化MapWorld,否则再实例化AMap。
所以如果你看到 components/amap/* 能正常工作,根本原因不是组件自己神奇,而是后台启动时已经把地图服务注入进去了。
项目中如何接入
地址和地图能力在项目里的接法,重点不在页面,而在系统配置和启动注入:
- 先在
System.config.Map里配置地图服务; - 如果你要做后台配置页,直接复用
src/components/config/upsert/map; - 启动时由
routines/start.ts注册getMapService; - 页面里再复用地址、区域、地图组件;
- 如果你要自己拼区域查询,优先复用
src/utils/area.ts里的树过滤 helper。
因此这章的项目接入通常是“先把地图配置好,再去放组件”。
真实项目里的接法
taicang 已经给出了一套很典型的项目拆法:
/frontend/address/list页面负责地址簿入口/frontend/address/upsert页面负责地址新增/编辑/frontend/pickers/area页面单独承接区域选择- 项目组件
components/address/myAddress/list、components/address/myAddress/upsert再包一层业务行为
这一层项目包装主要补了三件事:
- 地址只看当前登录用户
- 支持默认地址切换
- 移动端交互改成弹层区域选择和底部保存按钮
也就是说,公共包提供的是“地址能力底座”,真实项目通常还会再补一层“我的地址”“发货地址”这类业务语义组件。
项目层常见的补充规则
公共包默认不会强制“同一对象只能有一个默认地址”,但 taicang/src/triggers/address.ts 已经示范了最常见的项目规则:
- 当某条地址的
default=true时,把同一entity/entityId下其它默认地址全部取消
如果你的项目也需要默认地址,一般都应该在项目层补这条 trigger,而不是指望前端自己保证唯一性。
使用示例
1. 在系统配置里打开地图服务
await this.features.config.updateConfig('system', systemId, {
Map: {
amaps: [{ key: 'your-amap-key', type: 'personal' }],
},
});
2. 在表单页直接复用地址与区域组件
更推荐的页面组合通常是:
src/components/address/upsertsrc/components/pickers/areasrc/components/amap/location
这样区划选择、地址编辑、定位和地图展示能直接沿用公共包现成的交互,不需要项目层再写一遍。
2.1 taicang 里更真实的包法
taicang 当前不是直接把公共 address/upsert 原封不动地塞进页面,而是:
- 在项目组件里把
AreaPicker包成visible/onClose/onChange - 创建态自动把
entity='user'、entityId=currentUserId补进去 - 同时补了
default这一层业务语义
这是一种非常推荐的接法。公共组件负责通用字段,项目组件负责“这个地址属于谁、默认地址怎么切、移动端怎么交互”。
3. 在项目查询里复用区域树 helper
如果你的业务对象不是直接挂 address 组件,而是自己做筛选列表,更推荐直接复用公共 helper:
import { makeAreaDecendantFilter } from '@oak-general-business/utils/area';
const districtFilter = makeAreaDecendantFilter(
{ id: '330100' },
2,
true
);
这个 helper 适合拿来做“某个城市下所有区县”“某个行政区下所有下级区域”这类 Oak filter 组合。
4. 选点场景优先复用 amap/location
如果你的项目需要的是“用户在地图上选门店位置、活动位置、服务地址”,更推荐直接复用:
<Location
akey={amapKey}
visible={visible}
onClose={() => setVisible(false)}
onConfirm={(poi) => {
update({
longitude: poi.location.lng,
latitude: poi.location.lat,
address: poi.address,
});
}}
/>
这样拖图、搜索、定位、确认结果都已经在公共组件里处理好了。
使用建议
对新手来说,这一章最值得记住的是:
Address只负责“业务上要保存的地址”;Area负责“可选的标准区划”;- 地图能力是启动时注入的,不要在组件里自己重复初始化一套地图 SDK。
再补三条实战里非常容易踩坑的点:
- 地址类表单通常应该把
areaId落到区县级,否则公共组件里拼接parent.parent.name时很容易出现展示不完整。 - 如果项目里有“默认地址”,最好在项目 trigger 里保证唯一,而不是只靠前端互斥。
- 只要页面要用
amap/map或amap/location,就要先确认System.config.Map已经配好,否则前端组件就算能渲染,后端注入的地图服务能力也不完整。
如果你的项目需要复杂的地址定位、路线或地图服务,先看这一套注入链路,再决定要不要在项目层扩展。
SMS 与消息模板
短信能力在 oak-general-business 里分成了两个层次:
- 一层是“手机号登录、验证码发送”这类账号体系能力,它更多依赖
Passport和Token; - 另一层是“系统消息模板同步和消息类型映射”,这才是本章要重点讲的内容。
主要对象
这部分主要涉及:
SmsTemplateMessageTypeMessageTypeSmsTemplate
它们的关系很清晰:
SmsTemplate保存某个系统下、某个短信渠道的模板信息;MessageType定义业务消息类型;MessageTypeSmsTemplate负责把业务消息类型映射到具体短信模板。
组件
短信相关组件并不多,但都很实用:
src/components/config/upsert/smssrc/components/passport/smssrc/components/user/login/smssrc/components/messageTypeSmsTemplate/listsrc/components/messageTypeSmsTemplate/tab
也就是说,短信模块既包含模板管理,也直接连接着系统配置、登录方式配置和登录页本身。
config/upsert/sms 常用字段
这组组件编辑的是 System.config.Sms。当前源码里最关键的字段有:
mockSenddefaultOriginali[]tencent[]ctyun[]
其中:
mockSend打开后,发送验证码不会真的调用短信厂商接口defaultOrigin决定默认短信渠道
三类厂商配置的重点字段分别是:
1. 阿里云
accessKeyIdaccessKeySecretendpointapiVersiondefaultSignName
2. 腾讯云
secretIdsecretKeysmsSdkAppIdregionendpointdefaultSignName
3. 天翼云
accessKeysecurityKeyendpointdefaultSignName
源码里这组组件还有两个实现细节很值得直接告诉开发:
- 三个渠道都是“数组配置”,可以添加多组帐号
- 但真正默认发短信走哪家,还是看
defaultOrigin
所以项目里不要只配帐号不配 defaultOrigin,否则验证码发送链路很容易因为拿不到默认渠道而失败。
messageTypeSmsTemplate/list 常用参数
这是短信模板映射里最核心的一个管理组件,关键参数只有两个:
systemIdorigin
但它内部已经做了不少事情:
ready()时先拉当前系统、当前渠道下的smsTemplate- 同时调用
features.template.getMessageType()拉业务消息类型 - 点击“同步模板”时直接调用
features.template.syncSmsTemplate(systemId, origin) - 新建映射时默认给一条
messageType + templateId - 同一种
messageType在下拉里会被禁用,避免重复绑定
也就是说,这个组件并不只是一个普通 CRUD 表,它已经把:
- 同步远端模板
- 查看现有模板
- 维护消息类型与模板映射
这些步骤合在一起了。
messageTypeSmsTemplate/tab 适合放在哪里
tab 组件的关键参数是:
systemId
它内部会按固定渠道自动分三栏:
alitencentctyun
并且每个页签里都挂一个 messageTypeSmsTemplate/list。
这组组件最适合放在:
- 系统后台页
- 系统配置页
- 平台级短信模板管理页
而不是登录页本身。
passport/sms 的真实职责
src/components/passport/sms/index.tsx 在文档里也值得单独写出来,因为它不是登录页,而是系统登录方式管理页里的一个“短信登录配置卡片”。
它当前接收的核心输入其实不是散装字段,而是三样东西:
passportchangeEnabledupdateConfig
其中 passport.config 里,这个组件真正会维护的是:
mockSenddefaultOrigintemplateNamecodeDurationdigit
也就是说,账号体系里“短信登录”这一项真正依赖的,不只是系统级 System.config.Sms,还包括 passport 自己这份登录级配置:
- 验证码模板名是什么
- 验证码有效几分钟
- 验证码是几位
从 components/passport/index.ts 的实现看,它还会在保存前主动检查配置完整性。如果启用了短信登录,但:
- 没填
templateName - 没选
defaultOrigin
组件会直接给出 warning。也就是说,项目层如果复用整套 passport/* 管理页,很多“短信登录为什么不生效”的基础配置错误其实已经能在页面上提前暴露出来。
user/login/sms 常用参数
src/components/user/login/sms/index.ts 和 web 端渲染层当前最值得写进文档的参数有:
disabledurlcallbackallowPasswordallowEmailallowWechatMpsetLoginModedigit
其中真正影响登录流程的是:
url:登录成功后跳回哪里callback:登录成功后的自定义回调digit:验证码位数校验
而 allowPassword / allowEmail / allowWechatMp / setLoginMode 更偏小程序或多登录方式切换场景,决定这块表单是否要切到别的登录模式。
这组组件内部已经直接把两条关键链路接好了:
sendCaptcha()->features.token.sendCaptcha('mobile', mobile, 'login')loginByCaptcha()->features.token.loginByMobile(mobile, captcha)
也就是说,项目层不应该再自己调用短信厂商 SDK,也不应该自己拼验证码登录接口。
还有两个很实用的源码细节:
- 发送验证码有冷却时间,开发环境默认 10 秒,非开发环境默认 60 秒
- 冷却时间戳会写到本地缓存,所以组件刷新后也不会立刻重新允许发送
一个很容易忽略的对齐点
user/login/sms 的 digit 和 passport/sms 里配置的验证码位数,本质上应该保持一致。
否则前端校验按 4 位、后台实际按 6 位生成时,就会出现:
- 前端以为验证码合法,后台却认为长度不对
- 或者前端根本不允许提交正确验证码
这也是为什么更推荐项目层直接复用公共 passport 配置页和公共登录组件,而不是把验证码位数写死在业务页里。
前端 feature 与 aspect
这一章主要依赖的前端入口是 features.template,其中和短信相关的方法有:
syncSmsTemplate(systemId, origin)getMessageType()
对应的后端 aspect 则是:
syncSmsTemplategetMessageTypesendCaptchaByMobilesendCaptchaByEmail
其中:
syncSmsTemplate会直接调用具体短信渠道实现,把远端模板同步到本地的smsTemplate对象里;- 验证码发送虽然前端统一走
features.token.sendCaptcha(...),但后端最终落到的是sendCaptchaByMobile/sendCaptchaByEmail这两个 aspect。
还有一个很容易漏掉的实现细节:
src/aspects/sms.ts里的syncSmsTemplate(...)当前会按templateCode做“存在则 update,不存在则 create”- 但源码里“删除本地已失效模板”的逻辑目前是注释掉的
这意味着同步模板的真实语义更接近:
- 增量更新
- 增量新增
而不是“远端模板全量对齐本地模板”。
endpoint / watcher
短信模板这部分当前没有单独的 endpoint,也没有单独的 watcher。
这说明它的工作方式不是“被第三方平台回调驱动”,而是更适合通过后台管理动作或定期运维脚本来主动同步。
注入点
这一章的注入点在两个地方:
- 前端通过
create(...)注入features.template; - 后端通过
ogb0Aspects注入syncSmsTemplate和getMessageType。
所以只要你的项目已经接好了 oak-general-business,短信模板同步入口其实已经具备。
公共系统页已经有现成入口
oak-general-business/src/components/system/panel/web.pc.tsx 已经把短信模板页签接进系统后台了:
smsTemplate-list页签直接包messageTypeSmsTemplate/tab
这意味着如果项目本身已经复用 system/panel,往往不需要再额外写一页“短信模板管理台”。
项目中如何接入
短信能力在项目里主要通过两条线接入:
System.config.Sms配渠道和签名;features.template、features.token负责模板同步和验证码发送。
所以项目层通常不直接调短信 SDK,而是:
- 管理台同步短信模板;
- 登录/改密等页面调用
features.token.sendCaptcha(...)。
真实项目里通常怎么组织
从当前 haina-busi、taicang 的源码来看,没有再各自重写一套独立短信模板管理界面,更多是直接吃公共包和生成域能力。
这也符合这章的定位:
- 系统配置页负责配
System.config.Sms - 公共系统页签负责模板同步和映射
- 业务登录页只负责发验证码,不直接碰短信供应商细节
这种分法是最稳的。配置、模板、登录三层职责分得很清楚。
使用示例
1. 同步短信模板
src/features/template.ts 已经提供了直接入口:
await this.features.template.syncSmsTemplate(systemId, 'tencent');
对应的后台逻辑会调用 src/aspects/sms.ts,把模板同步到 smsTemplate 表。
1.1 在系统页里直接挂模板映射组件
如果你没有复用完整的 system/panel,最省事的做法通常是自己在系统配置页里直接挂:
<MessageTypeSmsTemplateTab
oakPath={`$system-smsTemplate-${systemId}`}
systemId={systemId}
/>
这样阿里云、腾讯云、天翼云三组模板映射就会自动分栏展示。
2. 发送短信验证码
项目登录页、改密页推荐统一走:
await this.features.token.sendCaptcha('mobile', mobile, 'login');
await this.features.token.sendCaptcha('mobile', mobile, 'changePassword');
这样实际短信渠道、签名、模板映射都由系统配置控制,项目层不需要关心底层短信厂商细节。
2.1 直接复用短信登录组件
如果项目登录页只想保留手机号验证码登录,最直接的包法通常就是:
<UserLoginBySms
oakPath="#LoginBySms"
url="/frontend/index"
digit={6}
/>
如果这个页面本身只是登录弹窗,也可以不用 url,改传 callback:
<UserLoginBySms
oakPath="#LoginBySms"
callback={() => this.setState({ visible: false })}
digit={6}
/>
使用建议
最推荐的理解方式是:
- 登录验证码的短信配置,看
Passport; - 系统消息的短信模板映射,看
SmsTemplate和MessageTypeSmsTemplate; - 真正同步远端模板,走
features.template.syncSmsTemplate(...)。
再补三条特别实用的注意事项:
defaultOrigin一定要和实际配置过的ali/tencent/ctyun帐号对应上,否则验证码发送时虽然链路通了,最终还是会在 provider 选择这里出问题。mockSend只是不真的调用短信厂商,不代表验证码表不写入;开发环境下它仍然会创建captcha记录。- 模板同步当前不会自动删掉本地旧模板,所以如果厂商后台删过模板,项目侧最好结合运维规则人工检查一次映射关系。
把这三件事分开,短信模块就不会再显得混乱。
Subscription 订阅源
Subscription 是 oak-general-business 里相对轻量的一块能力。它提供的是“订阅源的结构化记录”,而不是一整套自动抓取和自动发布引擎。
从实体结构看,它更像一个被别的同步逻辑消费的配置对象:
- 订阅源属于哪个业务对象;
- 订阅源名称和描述是什么;
- 配置是什么;
- 当前已经同步到了哪个偏移位置。
主要对象
这一章的核心实体只有一个:
Subscription
当前它的 config 结构偏向微信订阅号场景,但它本身作为实体是比较中性的,因此本章也把它称为“订阅源”,而不再狭义地理解成某一个具体渠道。
组件
虽然实体很轻,但组件已经准备了基础管理能力:
src/components/subscription/detailsrc/components/subscription/listsrc/components/subscription/upsertsrc/components/subscription/config/upsert
这意味着你可以先把“订阅源管理后台”搭起来,再决定项目层要不要继续补同步逻辑。
常用组件与参数
这一组组件里,最值得直接按源码理解的是下面三块:
subscription/listsubscription/upsertsubscription/config/upsert
subscription/list 不是全局订阅源列表,它当前是按业务对象维度工作的。oak-general-business/src/components/subscription/list/index.ts 暴露的关键参数只有两个:
entityentityId
组件内部会把这两个参数直接变成固定 filter:
{
entityId: this.props.entityId,
entity: this.props.entity,
}
因此它更适合挂在“某个对象自己的订阅源列表”里,例如某个栏目、某个应用、某个业务主体下的订阅号配置,而不是做全系统订阅源总表。
从真实行为看,它还已经内置了几条常见管理动作:
- 详情跳
/subscription/detail - 更新跳
/subscription/upsert - 配置跳
/subscription/config/upsert - 删除后直接
removeItem(id)再execute()
subscription/upsert 则是最基础的订阅源新增/编辑页。它最关键的两个参数同样是:
entityentityId
并且源码里已经做了一个很实用的默认行为:如果当前是“创建”而不是“编辑”(也就是没有 oakId),组件会在 ready() 里自动把 entity/entityId 写进当前编辑数据。也就是说,项目层通常只要把业务对象上下文传进来,不必在页面里再手工 update({ entity, entityId }) 一遍。
subscription/config/upsert 更值得单独写清楚,因为它不是一套自定义表单,而是直接复用了 components/config/application:
isService={false}type="wechatPublic"entity="subscription"entityId={oakId}name={name}
这意味着当前公共包里 Subscription.config 虽然挂在 subscription 实体上,但实际配置界面完全按“微信公众号配置”来处理。结合 src/entities/Subscription.ts 的定义,这一章当前真正稳定支持的配置形状就是:
{
type: 'wechatPublic',
appId: string,
appSecret: string,
}
如果项目层未来要把 Subscription 扩展成别的订阅源类型,通常不能只改实体类型,还要一起补配置组件。
subscription/config/upsert 当前真实读取了哪些数据
这个配置组件虽然最终渲染的是 config/application,但它自己的数据读取范围其实很窄。从 src/components/subscription/config/upsert/index.ts 看,它只取:
idnameconfig
也就是说,进入“订阅源配置页”时,公共组件默认只关心:
- 当前订阅源是谁
- 展示名称是什么
- 当前渠道配置是什么
像:
descriptionoffsetentity/entityId
这些字段都不会在这里展示,也不会在这里直接编辑。它们仍然分别归:
subscription/upsert- 同步逻辑本身
这也是为什么项目层如果要做“订阅同步状态监控页”,通常不能只靠这一个配置组件。
subscription/detail 当前展示边界
subscription/detail 的 projection 里虽然带了 config、entity、entityId,但当前 web 端实现实际上只展示:
idnamedescription
也就是说,它现在更像一个“订阅源概览卡片”,而不是完整详情页。像 config、offset 这种更偏配置和同步状态的信息,项目层如果需要展示,通常要么直接走 config/upsert,要么再包一层自己的 detail 页面。
aspect / endpoint / feature
这一章需要明确说清楚:
- 当前没有专门的 frontend feature;
- 没有独立的 aspect;
- 也没有专门的 endpoint。
也就是说,Subscription 现在主要提供的是:
- 一个实体模型;
- 一组基础组件;
- 一个可以被项目层进一步扩展的配置入口。
后台规则与注入点
当前 oak-general-business 里没有为 subscription 单独提供 trigger、checker、watcher 或 routine。
这也正是它和前面那些“高度自动化”的模块最大的区别:这部分更多是在给项目层留扩展点,而不是强行内置一套默认流程。
因此它的注入点其实非常简单:
- 实体跟随
oak-general-business进入项目; - 组件可以直接复用;
- 其它业务逻辑由项目层自己补。
项目中如何接入
Subscription 在项目里的接法比较轻:
- 如果你只是要做订阅号配置后台,直接复用
subscription/list、detail、upsert、config/upsert - 如果你要把订阅号挂到某个业务对象上,就按
entity + entityId建立关联
它没有 feature 和 aspect,所以项目层更多是在页面和实体数据层使用它。
结合组件源码,更推荐按下面这种方式落:
- 列表页把
entity和entityId固定住,直接包subscription/list - 新增/编辑页继续把同样的
entity/entityId传给subscription/upsert - 配置页单独包
subscription/config/upsert
我这次也额外对 haina-busi、taicang 做了定点检索,目前没有找到它们直接复用这组公共 subscription/* 页面的现成包法。更合理的理解是:这套组件已经够搭基础后台,但最终“订阅谁、同步谁、入口放哪儿”通常仍由项目层自己薄包一层页面壳。
一个更稳的页面拆法
结合当前组件边界,更推荐项目里按下面方式拆:
- 对象详情页里挂
subscription/list - 新增/编辑页挂
subscription/upsert - 渠道配置页单独挂
subscription/config/upsert - 同步偏移量、最近同步时间、同步日志等监控信息,项目层自己再补一张页面
这样职责会比较清楚:
- 公共组件管“订阅源对象”
- 项目逻辑管“订阅同步过程”
使用示例
1. 创建一个挂在业务对象上的订阅源
根据 src/entities/Subscription.ts 的定义,项目层最小数据骨架通常是:
await this.features.cache.operate('subscription', {
id: generateNewId(),
action: 'create',
data: {
id: generateNewId(),
entity: 'articleMenu',
entityId: menuId,
name: '帮助中心订阅号',
description: '用于推送帮助中心更新',
config: {
type: 'wechatPublic',
appId: 'wx-app-id',
appSecret: 'wx-app-secret',
},
},
});
2. 管理台直接复用现成组件
如果你只是做后台,最省事的做法通常是直接接出这几个页面:
/subscription/detail/subscription/upsert/subscription/config/upsert
公共组件已经围绕这些路径和数据结构组织好了。
例如,一个最小的“某业务对象下的订阅源列表页”通常就可以这样包:
<SubscriptionList
oakPath="#Subscriptions"
entity="articleMenu"
entityId={menuId}
/>
而新增页继续把相同上下文带进去即可:
<SubscriptionUpsert
oakPath="#SubscriptionUpsert"
entity="articleMenu"
entityId={menuId}
/>
使用建议
如果你看到这里只有对象和组件,没有自动同步逻辑,不要觉得奇怪。
更合理的理解是:
Subscription负责定义“订阅了什么”和“同步到了哪里”,至于“怎么同步”,通常应该根据具体业务场景在项目层补充 aspect、trigger 或 timer。
ToDo 协作待办
ToDo 在 oak-general-business 里不是一个完整的“待办系统成品”,而是一套非常适合 Oak 的协作待办辅助模型。
它的核心思想是:
- 某个对象上有某个动作还没完成;
- 需要把这件事分配给一批协作者;
- 当真正执行了目标动作后,对应待办自动完成。
这和一般意义上的“个人待办清单”很不一样。
主要对象
这一章的核心实体是:
ToDo
它定义了:
- 待办标题和描述;
- 目标对象
targetEntity; - 目标过滤条件
targetFilter; - 目标动作
action; - 完成后应该跳转到哪里;
- 当前这条待办属于哪个业务对象。
此外,ToDo 自己还内置了:
- 关系
collaborator - 状态
active / done - 动作
complete
组件
这一章反而要明确指出:当前 oak-general-business 里没有现成的 toDo 组件。
这意味着:
- 你可以直接复用这个实体模型;
- 但待办列表、待办详情、待办面板通常要在项目层自己补页面或组件。
aspect / endpoint / feature
ToDo 也没有单独的 feature、aspect 或 endpoint。
更准确地说,它在 oak-general-business 里的主要价值其实不在“对外入口”,而在 src/triggers/toDo.ts 里提供的两个辅助函数:
createToDo(...)completeToDo(...)
这两个 helper 都是围绕 Oak 的动作模型设计的,不是普通的“新建一条任务、改个状态”。
createToDo(...) 的真实参数与行为
这个 helper 的完整入参语义,最好直接说清楚:
entityfilteractioncontextdatauserIds?
其中 data 当前至少应该准备:
titledescription?redirectToentityentityId
它内部还做了三件非常关键的事:
1. 先查重
它会先按下面这些条件数一遍现有 toDo:
targetEntitytargetFilteractioniState='active'entityentityId
如果已经有同语义的活跃待办,就直接不再创建。
这意味着项目层不需要自己额外做一层“防重复建单”。
2. 不传 userIds 时自动推导协作者
如果你没显式传 userIds,它会调用:
getUserRelationsByActions(...)
自动找出对当前对象在当前 filter 下拥有这个 action 的用户,然后把他们全部挂成 toDo.collaborator。
这也是这套 helper 最大的价值之一:它不是简单待办,而是把 Oak 权限关系直接复用到了协作分派上。
3. 自动补 collaborator 关系
helper 自己会去查 toDo 上名为 collaborator 的 relation,然后创建对应的 userRelation$entity。
所以项目层在大多数场景下,只需要关心:
- 什么时候建待办
- 给谁建待办
不需要自己再手写协作者关系创建逻辑。
后台规则
这里有一个特别容易误会的点:
src/triggers/toDo.ts 这个文件虽然叫 trigger,但它并没有像其它模块那样被自动加入 src/triggers/index.ts。
这意味着:
- 它不是一组默认自动注入的触发器;
- 而是一组需要你在项目层主动调用的 helper。
例如,你可以在项目自己的某个 trigger 里:
- 在需要人工处理时调用
createToDo(...)创建待办; - 在真正完成动作后调用
completeToDo(...)自动关闭待办。
而 createToDo(...) 还有一个很关键的默认行为:
- 如果你没传
userIds,它会调用getUserRelationsByActions(...),自动找出对当前对象拥有该action权限的用户; - 然后把这些用户挂到
toDo的collaborator关系上。
这正是 Oak 很推荐的一种复用方式:公共包提供通用例程,项目层决定在哪些业务节点把它接进去。
completeToDo(...) 的真实完成条件
这个 helper 也不是“按 id 直接把待办置完成”。
它真正做的是:
- 先按
targetEntity + targetFilter + action + iState='active'找出对应活跃待办 - 再去
count(targetEntity, { filter: targetFilter }) - 只有当这个计数已经变成
0时,才会真正执行toDo.complete
这背后的设计含义很重要:
completeToDo(...)更适合“当某类待处理对象已经不存在”这种语义- 如果你的目标动作只是把状态从 A 改成 B,而对象仍然能被原
filter命中,那待办就不会被自动关闭
所以项目层设计 filter 时,一定要和“完成后这条对象还能不能被查出来”一起考虑。
注入点
这一章的注入点不是 ogb0Triggers,而是你自己的项目代码。
也就是说:
ToDo实体会自动进入项目;- 但
createToDo/completeToDo需要你手工在项目 trigger 里 import 并调用。
这一点如果不说清楚,新手会很容易以为“我依赖了这个包,待办为什么没有自动跑起来”。
项目中如何接入
ToDo 这章的项目接入点非常明确:
- 在项目自己的 trigger 里
import { createToDo, completeToDo } from '@oak-general-business/triggers/toDo' - 在合适的业务动作前后主动调用它们
- 如果你不想走自动协作者推导,也可以显式传
userIds
它不是自动注入能力,所以项目层必须显式接上。
当前项目里的实际情况
这次对 haina-busi、taicang 的源码检索里,没有看到它们直接调用 createToDo(...) / completeToDo(...)。
这更能说明它的定位:
- 它是一组公共协作 helper
- 不是两个示例项目当前正在重度依赖的成品待办模块
所以接这章时,项目团队要自己决定:
- 哪些业务动作值得建待办
- 待办在页面上以什么形式呈现
- 完成条件是“状态变化”还是“对象不再命中过滤条件”
推荐的项目层拆法
最稳的接法通常是:
- 在
before/aftertrigger 里决定什么时候调用createToDo(...) - 在对应完成动作的
aftertrigger 里调用completeToDo(...) - 页面层自己做
toDo列表、卡片、提醒角标
因为公共包没有现成 UI,所以项目层页面一般只需要围绕实体 toDo 正常做 Oak 页面即可。
使用示例
1. 在业务 trigger 里创建待办
最小调用骨架通常像这样:
const targetEntity = 'application' as const;
const targetAction: EntityDict['application']['Action'] = 'update';
const targetFilter: EntityDict['application']['Filter'] = {
id: applicationId,
};
await createToDo(
targetEntity,
targetFilter,
targetAction,
context,
{
title: '请更新应用配置',
description: '这是一条由 trigger 创建的协作待办',
redirectTo: {
batchPath: '/console/application/list',
singlePath: '/console/application/detail',
},
entity: targetEntity,
entityId: applicationId,
}
);
如果这里不传最后一个 userIds 参数,helper 会自动按 yourEntity + yourAction 去推导协作者。
1.1 显式指定协作者的写法
如果你不希望 helper 自动推导协作者,也可以直接把用户列表传进去:
await createToDo(
targetEntity,
targetFilter,
targetAction,
context,
{
title: '请更新应用配置',
redirectTo: {
batchPath: '/console/application/list',
singlePath: '/console/application/detail',
},
entity: targetEntity,
entityId: applicationId,
},
[reviewerId, operatorId]
);
这更适合协作者是明确固定人选,而不是由权限自动推导的流程。
2. 在动作完成后的 after trigger 里关闭待办
调用时要保证传入的 entity / filter / action 和创建待办时保持同一条业务语义:
await completeToDo(
targetEntity,
targetFilter,
targetAction,
context
);
对新手来说,最重要的是记住:completeToDo(...) 设计上就应该放在对应动作的后置 trigger 里调用。它不是“按 id 直接关单”,而是会先找匹配 targetFilter 的活跃待办,再检查目标对象在这个过滤条件下是否已经不存在,只有这样才会真正执行 complete。
使用建议
最适合 ToDo 的场景,是那些本来就有明确目标动作的业务对象,例如:
- 某条记录需要审核;
- 某个流程需要确认;
- 某个动作需要协作者去完成。
再补三条非常关键的实战建议:
createToDo(...)的filter和completeToDo(...)的filter必须保持同一条业务语义,否则要么重复建单,要么永远完不成。- 如果目标动作只是更新状态,最好让
filter包含“待处理状态”这一层条件,这样完成后count()才会归零。 - 因为 helper 自己不会给你做前端页面,所以项目立项时最好同时规划好列表页、提醒入口和跳转页,不要只建数据不落使用场景。
它不是通用的任务管理系统,而是一组和 Oak 动作模型天然契合的协作待办工具。
Livestream 直播流
Livestream 是 oak-general-business 里最“轻”的一个模块之一。当前公共包为它提供的,主要还是对象模型本身,而不是一整套直播平台集成。
这一点非常重要,因为很多人看到“直播”两个字,会下意识以为这里已经封装好了推流、鉴权、回调、聊天室等完整能力。实际上目前并不是这样。
主要对象
这一章的核心实体是:
Livestream
它保存了直播流最基础的一组信息:
- 直播标题;
- 直播流名称;
- 是否在线;
- 所属直播空间;
- 串流密钥;
- 推流地址、播放地址和 OBS 地址;
- 地址过期时间;
- 它归属于哪个业务对象。
从这个结构也能看出来,它更像“直播资源记录”,而不是“直播平台 SDK 封装”。
组件 / aspect / endpoint / feature
当前 oak-general-business 里:
- 没有现成的
livestream组件; - 没有独立的
livestream feature; - 没有专门的 aspect;
- 也没有 endpoint。
但这里有一个容易忽略的事实:虽然没有 livestream 实体自己的页面组件,系统配置里已经有 src/components/config/upsert/live,可以直接拿来维护 System.config.Live。
这说明公共包在这里提供的是“统一数据模型 + 配置编辑入口 + 后端工具函数”,而不是成品业务模块。
config/upsert/live 当前真实支持什么
这组配置组件现在不是“多平台直播配置中心”,而是一个明确偏向七牛直播云的配置页。oak-general-business/src/components/config/upsert/live/index.tsx 当前真正维护的是 System.config.Live.qiniu,核心字段包括:
accessKeyhubliveHostpublishDomainplayDomainTypeplayDomainplayBackDomainpublishSecuritypublishKeyplayKey
也就是说,这组公共组件当前更适合做“七牛直播配置页”,而不是泛化的直播厂商配置页。
这里还有一个很值得直接写出来的实现边界:Config.Live 在类型上虽然是一个对象容器,但公共组件目前只渲染了 qiniu 这一项,并没有腾讯云、阿里云等平行配置表单。
config/upsert/live 的真实组件参数
这个组件本身不是直接操作 systemId 的业务页,而是一个被更大配置页包进去的“配置片段组件”。从源码看,它真正接受的参数只有两个:
livesetValue(path, value)
也就是说:
live代表当前已经读出来的System.config.LivesetValue负责把局部字段写回上层配置表单
它内部再把所有字段路径都统一收敛成:
qiniu.accessKeyqiniu.hubqiniu.liveHostqiniu.publishDomainqiniu.playDomainTypeqiniu.playDomainqiniu.playBackDomainqiniu.publishSecurityqiniu.publishKeyqiniu.playKey
这意味着项目层如果不是复用更上层的 config/upsert 体系,而是想单独把直播配置嵌进自己的系统后台,也最好保持同样的 setValue('qiniu.xxx', value) 约定。
playDomainType / publishSecurity 当前有哪些可选值
这组下拉值在源码里其实已经写死,文档里最好直接列出来。
playDomainType 当前支持:
rtmphlsflv
publishSecurity 当前支持:
nonestaticexpiryexpiry_sk
这里必须以 src/types/Config.ts 的 QiniuLiveConfig 合同为准。当前 config/upsert/live 的 Select 选项把前两个值误写成了 none: / static:,与类型和七牛 SDK 调用合同不一致;这不是可复用的新枚举。项目层自己包 UI 时应使用无冒号的 none / static,复用该组件前也应先确认公共包已经修正这一处实现。
后台规则与注入点
当前也没有为 Livestream 提供默认 trigger、checker、watcher 或 routine。
因此它的注入点非常直接:
- 实体会跟随
oak-general-business进入项目; - 具体的推流地址生成、有效期刷新、直播平台回调、上下线切换等逻辑,需要项目层自己补。
项目中如何接入
直播能力在项目里的正确接法,不是先写页面,而是先补系统配置和项目层 aspect / trigger:
- 管理台可以直接复用
src/components/config/upsert/live维护直播配置; - 在
System.config.Live.qiniu里配置直播空间; - 在项目自己的 aspect 或 trigger 里调用
src/utils/livestream.ts; - 再把生成出的推拉流地址写回
Livestream实体。
这也符合它当前在公共包里的定位:提供统一模型和工具函数,项目层决定具体业务流程。
当前项目里的实际情况
这次对 taicang、haina-busi 的定点比对里,没有看到它们直接复用公共 config/upsert/live 页面壳的现成落点。
更接近现状的事实是:
taicang自己有qiniuLive、qiniuLiveStream这一层业务模型和 trigger- 公共包这边主要提供
Livestream统一实体和七牛配置/工具函数
所以直播这章目前更准确的理解应该是:
- 公共包负责基础直播资源模型
- 项目层负责把“直播间、业务对象、供应商侧回调和状态流转”真正接起来
这里还要补一条非常关键的源码事实:src/utils/livestream.ts 里的三个核心方法
getLivestream(...)getStreamObj(...)getPlayBackUrl(...)
当前都会直接走七牛直播实现,而且前两个函数里明确有:
assert(origin === 'qiniu');
所以虽然 AccountOrigin 是更宽的联合类型,但在公共包当前版本里,直播工具函数真正稳定支持的只有 qiniu。
如果项目层要接腾讯云、阿里云或别的直播平台,正确做法通常不是沿用这三个工具函数硬传别的 origin,而是:
- 继续复用
Livestream实体表达直播资源 - 在项目层自己补对应平台的配置表单、工具函数、aspect 或 trigger
- 必要时再把这些能力向公共包抽象回去
使用示例
1. 创建直播流并写回业务实体
src/utils/livestream.ts 已经提供了可直接复用的后端工具:
const stream = await getLivestream(
{
origin: 'qiniu',
streamTitle: liveId,
expireAt: Date.now() + 2 * 60 * 60 * 1000,
},
context
);
await context.operate('livestream', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id: liveId,
entity: 'course',
entityId: courseId,
...stream,
},
}, {});
2. 已有直播流时重新生成推拉流地址或回放地址
const streamObj = await getStreamObj(
{
origin: 'qiniu',
streamTitle,
expireAt,
},
context
);
const playbackUrl = await getPlayBackUrl(
{
origin: 'qiniu',
streamTitle,
start,
end,
},
context
);
使用建议
如果你的项目要做直播相关能力,可以把这一章理解成:
Livestream提供了一套统一的直播资源数据结构;而当前公共包真正打通的直播云实现,主要还是七牛。
这样理解会更贴近源码现状:
- 数据层已经有稳定表达;
- 七牛直播配置页和工具函数已经可直接复用;
- 其它直播平台接入仍主要留给项目层实现。
OAuth 客户端与第三方登录
oak-general-business 里的 OAuth 能力,其实同时做了两件不同的事:
- 让 Oak 应用去接入第三方 OAuth 提供商,用第三方账号登录;
- 让 Oak 应用自己成为一个 OAuth 服务端,对外发授权码和 token。
如果不先把这两条线拆开,看代码时会非常容易绕晕。
主要对象
这一章的对象可以分成两组。
作为 OAuth 客户端,接入第三方登录
这一组对象包括:
OauthProviderOauthStateOauthUser
其中:
OauthProvider保存第三方提供商配置;OauthState保存登录或绑定时的 state;OauthUser保存第三方账号和 Oak 用户之间的连接关系。
作为 OAuth 服务端,对外提供授权
这一组对象包括:
OauthApplicationOauthAuthorizationCodeOauthTokenOauthUserAuthorization
其中:
OauthApplication表示一个外部客户端;OauthAuthorizationCode是授权码;OauthToken是服务端签发给客户端的 token;OauthUserAuthorization则记录用户是否授权、是否撤销。
组件
这一章已经提供了基础的管理和登录组件:
src/components/oauthsrc/components/oauth/managementsrc/components/oauth/recordssrc/components/login/oauth
另外,components/user/login/index.ts 也会根据应用的 applicationPassport 动态展示 OAuth 登录选项。
login/oauth/authorize 适合放在哪里
这组组件更像“OAuth 服务端授权确认页”。bm-smart/src/pages/oauth/authorize/web.pc.tsx 和 TripSlayer/src/pages/frontend/login/oauth/authorize/web.pc.tsx 的包法都非常薄:
<Auth oakPath="#Authorize" />
也就是说:
- 当前项目如果要作为 OAuth 服务端对外授权,页面层只要把这个组件挂出来
- 真正的授权码校验、用户确认、授权流程,还是走公共包
oauth 组件常用参数
oak-general-business/src/components/oauth/index.ts 是 OAuth 回调页组件,最关键的两个参数是:
onRetryonSuccess
它会自己从 URL 里读取:
codestateerrorerror_description
然后调用 features.token.loginByOAuth(code, state)。所以项目层真正要做的是“成功后回哪里、失败后怎么重试”。
oauth/management 的定位
这个组件更偏系统后台管理,而不是登录页。当前源码里它是一个虚拟组件,核心参数很少:
systemIdsystemName
它更适合做:
- OAuth provider 管理页
- OAuth application 管理页
- 系统级第三方登录配置入口
也就是说,项目里如果要做“OAuth 能力管理台”,通常应该把它挂到系统配置或平台配置页里,而不是混在普通登录页组件里。
进一步看 oak-general-business/src/components/oauth/management/web.pc.tsx,它当前并不是一个空壳,而是已经内置了两个页签:
providersapplications
对应的就是:
oauth/management/oauthProvideroauth/management/oauthApps
这两个子组件都会强依赖 systemId,并且内部直接按 systemId 过滤:
oauthProvider管提供商配置,如授权地址、token 地址、scope、clientId、clientSecretoauthApps管外部客户端,如redirectUris、isConfidential、requirePKCE
所以项目层如果只是要搭系统级 OAuth 管理后台,最稳妥的做法通常就是直接包 oauth/management,而不是自己重新拆 provider 表和 application 表。
oauth/records 的真实职责
这一组组件在原文里还没展开,但其实很适合直接写出来。oak-general-business/src/components/oauth/records/index.ts 当前的真实行为包括:
- 实体是
oauthUserAuthorization - 自动过滤当前登录用户
userId - 自动过滤当前应用所属系统
application.systemId - 默认只看
usageState in ['granted', 'denied', 'revoked'] - 默认分页
pageSize = 5 - 提供
revoke(item),实际走的是oauthUserAuthorization的revokeaction
也就是说,它不是 OAuth provider 管理页,而是“当前用户已经授权过哪些外部应用”的记录页,更适合放在:
- 个人中心
- 安全中心
- 第三方授权管理页
从 web.pc.tsx 的实际渲染看,它还已经把这些细节整理成可直接展示的卡片:
- 应用 logo / 名称 / 描述
- 当前授权状态
- scope
- 最近使用时间
- 撤销时间
项目层通常只需要把这个组件挂出来,不需要再自己判断哪些记录能 revoke。
oauth 回调页的真实参数语义
oak-general-business/src/components/oauth/index.ts 这页虽然只暴露了两个参数:
onRetryonSuccess
但它内部已经把回调页真正该做的工作都接上了:
- 从 URL 自动读取
code、state - 识别
error、error_description - 调用
features.token.loginByOAuth(code, state) - 失败时只要求项目层决定“怎么重试”
- 成功时只要求项目层决定“回哪里”
因此项目层不应该再自己重复写一遍“从 querystring 取 code/state 再换 Oak token”的逻辑。
aspect / feature
OAuth 相关的核心 aspect 在 src/aspects/oauth.ts:
loginByOauthgetOAuthClientInfocreateOAuthStateauthorize
从前端看,并没有单独的 oauth feature,登录动作是通过:
features.token.loginByOAuth(...)
来触发的。
这再次体现了 oak-general-business 的分层方式:登录入口还是收敛到 token feature,OAuth 只是其中的一条登录链路。
endpoint
这一章还有一组非常关键的 HTTP endpoint,定义在 src/endpoints/oauth.ts:
oauth/access_tokenoauth/userinfooauth/tokenoauth/revoke
它们分别负责:
- 用授权码换 token;
- 获取用户信息;
- 刷新 token;
- 撤销 token。
如果你要让 Oak 应用真的作为 OAuth 服务端对外工作,这一组 endpoint 就是最核心的公开入口。
需要额外说明的是:源码里并没有单独的 oauth/authorize endpoint。授权确认这一步走的是 authorize aspect,再配合 src/components/login/oauth/authorize 这个公共授权页组件完成。
watcher 与后台规则
OAuth 还带了一条后台补偿逻辑:
src/watchers/oauth.ts会定期刷新即将过期但仍可用的oauthUsertoken。
同时,相关 trigger 也不少:
triggers/oauthProvider.ts会根据 provider 变化维护passport(type='oauth');triggers/oauthUser.ts负责第三方登录后的一些用户侧补充逻辑;triggers/oauthUserAuth.ts负责授权撤销等行为的联动。
所以 OAuth 并不是只靠几个 endpoint 在工作,后台状态维护同样已经接好了。
注入点
OAuth 能力的注入点分成三层:
- 后端通过
ogb0Aspects、ogb0Endpoints、ogb0Watchers、ogb0Triggers注入; - 前端通过
components/oauth/*和features.token.loginByOAuth(...)进入; - 系统层还需要
Passport(type='oauth')和ApplicationPassport把这条登录方式真正暴露给某个应用。
如果少了最后一步,即使 OAuth provider 配好了,前端也不会真正展示对应的登录入口。
项目中如何接入
OAuth 在项目里一般分成两种接法:
- 把 Oak 当作客户端,去接第三方登录
- 把 Oak 当作服务端,对外暴露 token/userinfo/revoke endpoint,并通过授权页组件调用
authorizeaspect 完成授权确认
无论哪一种,最重要的都是:
- 应用和系统初始化时先把
ogb0Aspects/ogb0Endpoints合并进去; - 前端页面统一走
features.token.loginByOAuth(...)或公共登录组件; - 服务端统一复用
src/endpoints/oauth.ts,不要自己再造一套 OAuth 协议实现。
真实项目里的页面拆法
从 haina-busi 和 taicang 的现有代码来看,一个项目里最常见的是三类页面同时存在:
- 普通登录页里的第三方登录按钮,最终走
features.token.loginByOAuth /oauth/authorize这种授权确认页,直接包login/oauth/authorize/oauth这种回调页,直接包components/oauth
这三类页面分开之后,客户端登录和服务端授权就不会互相搅在一起。
taicang 当前的回调页和授权页包法都很薄,基本就是:
<OAuth oakPath="#OAuth" />
<Auth oakPath="#Authorize" />
这点很值得直接模仿。页面层真正需要做的通常只是:
- 给它稳定的
oakPath - 在外面包路由壳
- 如果未登录要先跳登录,再通过
onUnLogin把 OAuth 参数带回授权页
使用示例
1. 前端发起第三方 OAuth 登录
bm-smart/src/components/login/byOauth/ProviderList/index.ts 的真实做法是:
const state = await this.features.aspect.createOAuthState({
providerId: provider.id!,
type: 'login',
userId: this.features.token.getUserId(true),
});
window.location.href =
`${provider.authorizationEndpoint}?response_type=code` +
`&client_id=${clientId}` +
`&redirect_uri=${encodeURIComponent(redirectUri!)}` +
`&state=${state}`;
2. OAuth 回调页换回 Oak token
回调页最终只需要调用:
await this.features.token.loginByOAuth(code, state);
公共包里的 components/oauth、components/login/oauth/authorize 就是按这条链路工作的。
3. 作为 OAuth 服务端时复用 endpoint 与授权页组件
src/endpoints/oauth.ts 已经提供了:
oauth/access_tokenoauth/tokenoauth/revokeoauth/userinfo
而授权确认页则直接复用 src/components/login/oauth/authorize,内部会调用:
await this.features.cache.exec('authorize', {
response_type,
client_id,
redirect_uri,
scope,
state,
action: 'grant',
});
如果项目只是要把自己的用户体系对外开放,最推荐直接挂这组 endpoint,并复用公共授权页组件,而不是项目层自己重新实现授权码、refresh token 和 PKCE。
使用建议
这一章最重要的一条经验是:
先分清楚“第三方登录到我的 Oak 应用”与“我的 Oak 应用对外发 token”是两套对象。
理解了这一点,再去读 OauthProvider/OauthUser/OauthState 和 OauthApplication/OauthAuthorizationCode/OauthToken/OauthUserAuthorization,就不会再觉得它们重复了。
Oak 支付业务逻辑
oak-pay-business 是 Oak 生态里的支付域公共业务包。它不是“微信支付 SDK 的简单封装”,也不是“只有订单和支付两个对象”的薄层模块,而是一套已经把订单、支付、退款、充值、提现、物流、系统资金、结算计划这些能力拆成对象、checker、trigger、aspect、endpoint、feature、组件和后台补偿任务的完整支付底座。
如果你前面已经读过 oak-general-business,那么可以把 oak-pay-business 理解成它在支付域上的继续延伸:
oak-general-business负责应用、用户、token、微信、小程序、文件、消息这些通用基础能力;oak-pay-business则在这套基础之上继续补上资金、支付渠道、充值退款、提现拆单、物流同步和结算。
近期 oak-pay-business 已适配新的 oak-domain 发布包实体解析,并和 oak-general-business 的 general-system 翻译能力对齐。支付域里的 System 覆盖必须继续兼容通用业务包的翻译字段、翻译状态和翻译动作。
这一章为什么不按实体名拆
第一次打开 oak-pay-business/src/entities,很容易被这些对象绕晕:
Order、Pay、Refund看起来是一组;Account、Deposit、SysAccountOper又像另一组;Withdraw、WithdrawTransfer、WithdrawAccount还会和退款串起来;Ship、ShipServiceSystem、WechatMpShip明明是物流,却又会反过来影响充值到账;SettlePlan、Settlement又会继续影响订单的settled、settlePlanned。
所以这一章不再按“一个实体一章”的方式写,而是按真实开发时更容易理解的能力域拆成:
系统支付配置与系统资金订单、支付与退款账户、充值与流水提现物流渠道、组件注入与扩展点结算
这种拆法更接近项目实际接入和排查问题时的思路。
先看能力入口,再看具体对象
真正接入 oak-pay-business 时,最重要的入口一共有五类。
1. feature
src/features/index.ts 只创建了一个前端 feature:
pay
它内部真实实现位于 src/features/Pay.ts,目前暴露三个方法:
getPayChannels(type?: 'deposit' | 'pay', accountId?: string)calcDepositLoss(price, channel)getDepositRatio(channel),当前源码里仍然直接抛错,尚未实现
另外,@oak-pay-business/features 的 initialize(...) 会先调用 @oak-general-business/features 的 initialize(...),也就是说支付域初始化本身就依赖通用业务包的应用识别、登录态、微信环境等基础能力。
因此同时依赖通用业务包和支付业务包的项目,通常只需要调用 initializeOpb1Features(...)。不要再手工重复调用一遍 initializeOgb0Features(...),除非你明确知道初始化顺序和投影合并边界。
2. aspect
src/aspects/index.ts 当前只导出四个 aspect:
getWithdrawCreateDatagetMpShipStategetExpressPrintInfoshipConfirmSuccess
这四个入口都是真实在前端组件里被调用的:
- 提现创建页会先调
getWithdrawCreateData - 小程序收货确认会调
shipConfirmSuccess - 物流详情和面单打印会调
getMpShipState、getExpressPrintInfo
3. endpoint
支付域当前提供了多组可选回调模块:
wechatPay.ts:支付与退款回调;aliPay.ts:支付与退款回调;epay.ts:支付回调;stripePay.ts:支付与退款回调;creemPay.ts:支付回调;waffoPay.ts:支付与退款回调;paypalPay.ts:支付与退款回调。
但 src/endpoints/index.ts 当前默认导出空对象,并明确要求项目按需注入。框架会自动加载依赖包的 endpoint 索引,不等于这些可选渠道模块会自动启用;项目必须在自己的 src/endpoints/index.ts 中显式选择所需模块。
4. registry 与注入点
支付域最重要的项目级扩展点不在 feature 里,而在 registry.backend.ts / registry.frontend.ts。
src/registry.backend.ts 只暴露:
registerPayClazz
src/registry.frontend.ts 则只暴露前端可用的这几项:
registerPayChannelComponentregisterFrontendPayRoutineregisterShipSettingComponentregisterSysAccountCardTopComponentregisterSysAccountDetailComponent
这意味着:
- 新支付渠道的“后端实现”通过
registerPayClazz(...)注入; - 新支付渠道的“系统配置 UI”通过
registerPayChannelComponent(...)注入; - 新支付渠道的“前端唤起流程”通过
registerFrontendPayRoutine(...)注入; - 新物流系统的“系统设置页”通过
registerShipSettingComponent(...)注入; - 系统资金总览页顶部卡片和详情页也都可以继续扩展。
5. watcher 与 timer
除了实体动作本身,支付域还有一套后台补偿逻辑:
watchers/order.ts会把过期订单自动置为timeoutwatchers/pay.ts会轮询外部支付状态,并在超时后自动关闭paywatchers/refund.ts会轮询外部退款状态watchers/settlePlan.ts会在到达结算时间后自动执行settletimers/ship.ts会定时同步快递状态和小程序虚拟/自提发货状态
相反,src/routines/start.ts 当前没有真正启用的启动例程,支付域主要依靠 trigger、watcher、timer 驱动。
先建立组件地图
第一次接 oak-pay-business,最省时间的做法不是先把所有实体看完,而是先知道“这条支付链应该从哪组组件进”。下面这张地图最适合新手先建立整体感。
1. 系统配置与系统资金
这一组通常先看:
payConfig/systemofflineAccount/configwpAccount/configwpProduct/configapAccount/configapProduct/configsysAccount/surveysysAccountOper/listsysAccount/transferListsysAccountMove/create
这组组件解决的不是“支付过程”,而是:
- 系统层有哪些支付渠道
- 每个渠道怎么配置
- 系统账户余额和流水怎么看
- 提现打款任务在哪里处理
也就是说,后台管理台通常先从这一组落。
2. 订单支付与退款主线
这一组最常直接复用的是:
order/payorder/listpay/detailpay/listpay/channelPicker2refund/list
如果项目里已经有订单详情页,最常见的组合就是:
- 订单页里弹出
order/pay - 创建完支付单后切到
pay/detail - 后台管理页再补
pay/list、refund/list
这样“发起支付”和“排查支付”两条线就都有现成入口。
3. 账户、充值与流水
这组组件通常先看:
account/detaildeposit/newaccountOper/list
真实使用时,它们的职责边界很清楚:
account/detail更像一个完整账户主页,内部已经串了充值和未完成支付跳转deposit/new更像受控的“充值参数录入器”accountOper/list负责账户流水
如果项目要做钱包页、保证金页、账户历史页,通常先从这一组搭。
4. 提现与打款
提现链路最值得先看的组件是:
withdraw/createwithdraw/detailwithdraw/displaywithdraw/listwithdrawAccount/listwithdrawAccount/upsertwithdrawTransfer/list
这一组组件加在一起,才构成完整提现链:
- 创建申请
- 选择提现账户
- 查看拆单详情
- 查看历史
- 运营处理打款
如果只接 withdraw/create 一个表单,后面排查“为什么部分成功”会非常难受。
5. 物流与收货确认
物流这组最关键的是:
ship/systemship/wechatMpShipshipServiceSystem/list
它们更偏后台配置,而不是前台纯展示组件。真正要理解的重点是:
- 哪个系统启用了哪些物流服务
- 微信小程序发货配置有没有挂上
- 后端有没有注册真实
shipClazz
所以新项目接物流时,通常先搭后台配置页,再考虑订单或充值页怎么消费物流状态。
6. 哪些能力本来就是项目层自己包
和 oak-general-business 一样,这里也不是“每条链都有完整页面成品”。
例如:
- 结算
settlePlan/settlement当前没有公共成品组件 - 新支付渠道的回调 endpoint 常常是项目层自己包一层路由,再复用公共
utils/pay
所以阅读支付域源码时,最合理的预期应该是:
- 公共包把主模型、状态机、公共组件和注入点准备好
- 项目层再按自己的业务路由和页面壳把它们串起来
最小接入思路
第一次把 oak-pay-business 接进项目时,建议按下面的顺序来。
1. 先声明依赖并生成装配代码
当前推荐先在 src/configuration/dependency.ts 中声明 oak-pay-business,再执行 project:init、make:domain 和 make:dep。生成后的 initialize.server.ts 会创建支付域 feature;server:start 启动的 AppLoader 会按依赖图自动从项目和依赖包 lib/... 合并支付域的 checker、trigger、watcher、timer、aspect、endpoint、data、port、routine。
支付包的 src/endpoints/index.ts 默认为空。项目应在自己的 src/endpoints/index.ts 中显式导入并组织需要的渠道 endpoint,例如:
import * as wechatPay from '@oak-pay-business/endpoints/wechatPay';
export default {
wechatPay: [wechatPay.payNotify, wechatPay.refundNotify],
};
这一步只是选择并挂载公共回调合同,不是重写支付状态推进。其它渠道按实际启用情况分别导入,避免把未配置密钥或路由的回调全部暴露出去。
2. 创建并初始化支付 feature
前端初始化时,真实入口就是 src/features/index.ts:
import { create as createPayFeatures, initialize as initializePayFeatures } from '@oak-pay-business/features';
const opbFeatures = createPayFeatures(totalFeatures);
Object.assign(totalFeatures, opbFeatures);
await initializePayFeatures(totalFeatures, accessConfiguration, {
applicationExtraProjection: {
system: {
id: 1,
payConfig: 1,
offlineAccount$system: {
$entity: 'offlineAccount',
data: {
id: 1,
type: 1,
allowDeposit: 1,
allowPay: 1,
},
},
},
wpProduct$application: {
$entity: 'wpProduct',
data: {
id: 1,
type: 1,
enabled: 1,
},
},
},
});
这里最关键的不是“把 pay feature 建出来”,而是要确保当前应用投影里真的带上了支付域会读到的对象,例如 system.payConfig、offlineAccount$system、wpProduct$application。
3. 再决定是否注册项目层扩展
如果项目只用当前内建的 account、offlineAccount、wpProduct、apProduct、epProduct、spProduct、cpProduct、wfProduct、ppProduct 渠道,可以直接开始用;Web redirect 类产品仍受平台限制。
如果项目要继续扩展:
- 新支付产品,注册
registerPayClazz(...) - 新支付配置页,注册
registerPayChannelComponent(...) - 新支付唤起流程,注册
registerFrontendPayRoutine(...) - 新物流系统配置页,注册
registerShipSettingComponent(...)
参考项目里的真实接法
如果你对接入方式还有点抽象,haina-busi 和 taicang 这两个项目基本已经把 oak-pay-business 的常见接法都跑过一遍了。
1. 初始化通常只调用 initializeOpb1Features(...)
当前项目通常会在前端运行时初始化链路里同时创建:
createOgb0Features(...)createOpb1Features(...)
但真正启动时,很多地方只调用:
await initializeOpb1Features(features, accessConfiguration, config, cosClazzes);
这是因为 oak-pay-business/src/features/index.ts 的 initialize(...) 内部已经先调用了 oak-general-business 的初始化。也就是说,支付域初始化本身就把应用识别、登录态、微信环境、文件上传这些基础能力一起带上了。
2. 支付域的项目扩展一般写在 routines 和初始化文件里
从这两个项目的真实写法看,最常见的扩展点分布是:
initializeFeatures.web.ts:注册前端支付配置组件,例如registerPayChannelComponent('wpAccount', WpAccountConfig)routines/pay.ts:注册后端支付类,例如registerPayClazz('cmbProduct', ...)routines/start.ts:注册短信、COS、消息类型,以及项目自己的通知处理器- 页面层:直接包
payConfig/system、order/pay、pay/detail、account/detail、withdraw/create、ship/system
2.1 支付回调经常是“项目路由壳 + 公共处理逻辑”
这里有一个很值得新手提前建立的认知:
oak-pay-business已提供微信、支付宝、Epay、Stripe、Creem、Waffo、PayPal 等可选 endpoint 模块;- 项目层仍可以按同样模式补自己的
cmbPay.ts,或为公共模块包一层不同 HTTP 契约。
haina-busi 就是这么做的。它自己的 src/endpoints 里补了:
wechatPay.tsaliPay.tscmbPay.ts
但内部并没有重写整套状态推进,而是继续复用公共的:
@oak-pay-business/utils/pay.payNotify@oak-pay-business/utils/pay.refundNotify
所以项目层如果扩新渠道,最稳的做法通常不是“完全自己写 controller”,而是:
- 项目里补自己的 endpoint 路由名和参数结构
- 真正的回调处理继续复用公共支付逻辑
3. 实体层通常不是“重写一套支付模型”,而是扩展公共模型
haina-busi 的 CmbAccount、CmbProduct 是典型例子:
CmbAccount继承AbstractPayAccountCmbProduct继承AbstractPayProduct
taicang 则更偏向“在公共支付实体上继续补业务字段”:
System继承@oak-pay-business/entities/SystemOrder继承@oak-pay-business/entities/OrderShip继承@oak-pay-business/entities/Ship
这两种方式都说明:项目层最好是在公共支付模型上扩展,而不是脱离公共模型重新做一套资金域。
阅读源码时建议按这个顺序
第一次系统阅读 oak-pay-business,最推荐的顺序是:
src/features/index.ts与src/features/Pay.tssrc/registry.backend.ts与src/registry.frontend.tssrc/aspects/index.tssrc/entities/*.tssrc/checkerssrc/triggerssrc/watchers与src/timers- 最后再看
src/components
原因和 oak-general-business 一样:新手最容易先被组件数量吸走注意力,但真正决定支付域能力边界的,还是对象定义、状态机、trigger、checker 和注入点。
接下来的各章,我都会明确写出:
- 这一块解决什么问题;
- 对应哪些实体;
- 已经有哪些组件、aspect、endpoint、feature 可以直接复用;
- 后台有哪些 checker、trigger、watcher、timer 在兜底;
- 项目层该在哪里接入,以及应该写成什么样子。
系统支付配置与系统资金
支付域里最容易被低估的对象不是 pay,而是 system。在 oak-pay-business 里,很多真正影响资金行为的规则,并不挂在订单或支付对象上,而是统一挂在 System.payConfig 和系统级支付渠道配置上。
换句话说,订单怎么付、充值怎么收手续费、提现怎么扣手续费、系统层有哪些线下收款方式、有没有可用的微信支付产品,这些能力最终都要回到 system。
主要对象
这一章最重要的对象有四组:
System.payConfigOfflineAccountWpAccount/WpProductSysAccountOper/SysAccountMove
其中 System.payConfig 在 src/entities/System.ts 里额外定义了两块支付配置:
withdrawLossdepositLoss
这个 System 是对 oak-general-business 中系统对象的扩展。近期支付包已经适配 general-system 的翻译字段、翻译状态和翻译动作,项目层如果继续覆盖 System,必须把这些通用字段和动作保留下来。
withdrawLoss 当前支持这些字段:
conservativeratiolowesthighesttrim: 'jiao' | 'yuan'
depositLoss 当前支持这些字段:
ratiolowesthighest
OfflineAccount 则表示系统下配置的线下收款渠道,例如银行卡、微信收款码、支付宝收款码等。WpAccount / WpProduct 对应的则是微信支付账号与具体支付产品。
组件
系统支付配置最核心的现成组件有两组。
1. src/components/payConfig/system/web.pc.tsx
这是系统支付配置页的真实入口,它不是一个“只有手续费表单”的页面,而是一个带多个页签的总入口。源码里当前固定提供了两类页签:
system:编辑System.payConfigofflineAccount:管理OfflineAccount
除此之外,它还会把所有通过 registerPayChannelComponent(...) 注册进来的渠道配置组件继续拼成额外页签。
也就是说,这个页面本身就是支付渠道配置的前端注入点。
2. src/components/sysAccount/survey/web.pc.tsx
这是系统资金总览页的核心组件。它内置了两类系统账户卡片和详情组件:
offlineAccountwpAccount
并且继续提供两个注入点:
registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
项目层如果又加了新的系统资金账户类型,可以把展示卡片和详情区一起注册进来,而不用改这个组件本身。
真实项目里的页面包法
haina-busi 和 taicang 这两个项目在系统支付配置页上的思路非常统一,都是只写一层很薄的页面壳:
<SystemPayConfig
oakId={systemId}
oakPath={`${oakFullpath}.system`}
/>
页面本身只负责:
- 拿到当前
systemId - 给组件一个稳定的
oakPath - 在组件外面补
PageHeader或路由壳
payConfig/system 与 sysAccount/survey 常用参数
这两个组件项目里最常用的参数分别是:
payConfig/system:oakId、oakPathsysAccount/survey:oakId、oakPath
如果重新生成 oak-app-domain 后发现 system 缺少 translate、translateSuccess、translateFail 等动作,通常说明发布包实体解析或 ActionDef 别名兼容出了问题。应优先检查依赖包的 es/entities/System.d.ts 和同名 .js 是否一起发布。
如果项目层要给系统支付配置页追加新的支付渠道页签,真正传给被注册组件的参数是:
systemIdoakPath
也就是 registerPayChannelComponent(...) 对应的组件签名。
从源码看,这两个组件还有几个很关键的隐含行为:
payConfig/system的基础投影里已经固定带了payConfig、wpAccount$system、offlineAccount$systemsysAccount/survey如果没传systemId,会回退到features.application.getApplication().systemId
也就是说:
- 前者适合明确挂在“某个系统的配置页”
- 后者既能做后台总览页,也能做当前应用上下文下的系统资金概览
sysAccount/survey 当前实际统计了什么
这个组件不只是把各类系统账户列出来,它还会在 ready() 时主动做几组聚合统计:
- 所有
sysAccountOper.entity.ref里声明过的系统账户实体余额汇总 account侧的账户数、总额、可用额汇总refund侧“退款中”的数量和金额汇总withdrawTransfer侧“打款中”的数量和金额汇总order侧“未结算订单”的数量、已付总额、已退总额汇总
也就是说,它本质上不是“账户卡片容器”,而是一个已经把系统资金全景统计做好的总览组件。
另一个很值得提前写清楚的行为是:
- 它会读取
schema.sysAccountOper.attributes.entity.ref - 再把这些实体按
systemId全部拉出来
所以项目层新增系统账户实体时,只补展示组件还不够,最好确保新实体也真的进入了 sysAccountOper.entity.ref。
真实项目里的渠道注册位置
这一点 haina-busi 和 taicang 刚好给了两个真实样例:
haina-busi/src/pages/business/square/payConfig/web.pc.tsx:页面文件顶部直接注册cmbAccount、apAccount、wpAccounttaicang/src/initializeFeatures.web.ts:前端初始化阶段注册wpAccount
更推荐的项目写法仍然是放在初始化文件里统一注册,但如果项目结构较轻,也可以像 haina-busi 一样在具体页面里先注册再渲染。
sysAccount/survey 的适配要求
这个组件比表面上看更“动态”。它会读取:
schema.sysAccountOper.attributes.entity.ref
然后把这里声明过的所有系统账户实体都按 systemId 拉出来汇总展示。
这意味着如果项目层新增了一个系统账户实体,只注册展示组件还不够,最好同时确认:
- 该实体已经进入
sysAccountOper.entity.ref - 该实体具备
systemId字段,能按系统维度查询
否则系统资金总览页根本不知道应该去拉哪类账户。
系统资金明细组件怎么搭配
很多项目只接了 sysAccount/survey,但没有把后续明细页搭起来。结合公共组件源码,更推荐的搭配是:
- 总览页:
sysAccount/survey - 单账户流水页:
sysAccountOper/list - 待处理转账页:
sysAccount/transferList
其中:
sysAccountOper/list通过entity、entityId锁定某一类系统账户sysAccount/transferList默认只查当前系统下iState === 'transferring'的withdrawTransfer
也就是说,前者适合做“某个系统账户的历史流水”,后者适合做“运营人员当前要处理的打款任务列表”。
sysAccount/transferList 的真实边界
这个组件当前几乎没有项目层参数,它的过滤条件是写死在组件里的:
withdrawAccount.ofSystemId = 当前 application.systemIdiState = 'transferring'
并且展示时会自动把渠道转成人类可读文案:
- 普通渠道直接按
withdraw::channel.${entity} offlineAccount会进一步展开成具体type
这意味着它非常适合:
- 当前系统运营后台的“待打款列表”
- 财务处理页
但如果你要做:
- 跨系统打款总表
- 已完成 / 已失败历史
- 指定账户或指定渠道筛选
通常就要像 haina-busi 一样,在项目层再包一层列表组件,而不要直接拿公共 transferList 当万能列表。
sysAccountMove/create 适合放在哪里
这组组件在系统资金后台里也很有用,但文档里很容易漏掉。它当前最关键的参数有:
systemIdentitiesonSuccess
真实行为则是:
- 默认会拉
wpAccount、offlineAccount - 再把
entities里追加的系统账户实体一起并进可选列表 - 让用户选择
from、to两个系统账户 - 输入
price、externalId、remark - 最终创建一条
sysAccountMove - 同时自动带两条
sysAccountOper$sysAccountMovemoveOutmoveIn
也就是说,它不是一个简单“记一条备注”的组件,而是专门给系统账户之间做内部划拨用的。
这组组件尤其适合:
- 财务后台做手工调账
- 系统账户之间做内部资金搬运
- 新增支付账户后,需要临时从旧账户转一笔资金过去的场景
如果项目要支持更多系统账户类型,最关键的不是改组件本身,而是把额外实体名通过 entities 传进来。
前端入口
系统支付配置相关的前端入口主要有两类。
features.pay
src/features/Pay.ts 会直接依赖当前应用和当前系统配置:
getPayChannels('pay')会从system.offlineAccount$system和application.wpProduct$application里计算可用支付渠道getPayChannels('deposit')会只挑允许充值的渠道calcDepositLoss(price, channel)会按system.payConfig.depositLoss计算充值手续费
要特别注意一点:getDepositRatio(channel) 当前还没有实现,源码里直接 throw new Error('method not implemented')。如果项目层要展示“某渠道费率”,不要误以为这个方法已经可用。
系统配置组件
payConfig/system 组件会直接对 system.payConfig 发起更新。也就是说,这块能力并不是单独通过一个 aspect 暴露的,而是作为 system 的正常更新操作进入 Oak 数据流。
后台规则
系统支付配置本身虽然只是几个字段,但后面的资金流程都依赖它。
充值手续费
features.pay.calcDepositLoss(...) 实际会读取:
system.payConfig.depositLoss.ratiosystem.payConfig.depositLoss.lowestsystem.payConfig.depositLoss.highest
并返回:
- 扣多少手续费
- 用哪条多语言文案解释这次手续费
- 解释文案的参数
提现手续费
aspects/withdraw.ts#getWithdrawCreateData(...) 会读取:
system.payConfig.withdrawLoss.conservativesystem.payConfig.withdrawLoss.ratiosystem.payConfig.withdrawLoss.lowestsystem.payConfig.withdrawLoss.highestsystem.payConfig.withdrawLoss.trim
如果这块配置根本没配,aspect 会直接抛 error::system.withdrawLossUnSet。
换句话说,提现功能是否能正常创建,不只是取决于账户余额,更先取决于系统有没有把提现损耗规则配完整。
注入点
系统支付配置相关的注入点一共有四个:
registerPayChannelComponent(...)registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)registerFrontendPayRoutine(...)
其中:
- 前三个决定系统管理后台“怎么配置、怎么展示”
- 最后一个决定支付详情页“怎么真正发起支付”
项目中如何接入
这部分能力真正接进项目里,通常要做三件事。
1. 确保应用投影带上支付域需要的系统数据
features.pay 不是靠远程现查渠道,而是直接从当前应用数据里拿:
application.system.offlineAccount$systemapplication.wpProduct$applicationapplication.system.payConfig
所以初始化 oak-pay-business feature 时,就要把这些投影带进去。
2. 管理后台直接复用现成系统配置页
如果项目已经有系统详情页,最稳妥的做法是直接挂 components/payConfig/system,不要自己再画一套支付配置表单。因为这个组件已经把:
System.payConfigOfflineAccount- 注册进来的其它支付渠道组件
统一放到了同一个入口里。
3. 按需要补系统账户展示
如果项目层扩展了新的支付账户实体,除了注册支付类本身,最好顺手把系统资金总览页也补上:
import {
registerSysAccountCardTopComponent,
registerSysAccountDetailComponent,
} from '@oak-pay-business/registry.frontend';
registerSysAccountCardTopComponent('myPayAccount', MyPayAccountCard);
registerSysAccountDetailComponent('myPayAccount', MyPayAccountDetail);
真实项目里的系统资金入口
在 taicang 里,系统支付配置和系统资金总览已经拆成两张独立页面:
pages/console/system/payConfigpages/console/system/accountSurvey
这是一种很实用的拆法。配置页负责“能不能用、费率怎么配”,总览页负责“现在账上还有多少钱、流水长什么样”。
而从这次对 haina-busi 的检索看,它在系统资金历史和转账处理上则更偏项目层包裹:
- 系统资金流水会再包自己的
squareBusiness/sysAccountOper/list - 提现打款列表也会再包自己的
wallet/deposit/transferList
这说明公共组件当前更像:
taicang这种常规系统后台可以直接落的基础页haina-busi这种平台化更强的后台,会在此基础上再补业务筛选和展示字段
开发注意事项
系统支付配置和系统资金总览通常要一起看。最常见的误区是只注册了:
registerPayChannelComponent(...)
却没有补:
registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
结果就是配置页能看到新渠道,但资金总览页完全不知道怎么展示它。
使用示例
1. 在系统页里配置充值和提现手续费
这就是 payConfig/system 组件实际维护的数据形状:
await this.features.cache.exec('operate', {
entity: 'system',
operation: {
id: await generateNewIdAsync(),
action: 'update',
data: {
payConfig: {
depositLoss: {
ratio: 0.6,
lowest: 1,
highest: 200,
},
withdrawLoss: {
conservative: false,
ratio: 0.8,
lowest: 100,
highest: 5000,
trim: 'jiao',
},
},
},
filter: {
id: systemId,
},
},
});
2. 在前端读取当前可用支付渠道
const payChannels = this.features.pay.getPayChannels('pay', accountId);
const depositChannels = this.features.pay.getPayChannels('deposit');
const depositLoss = this.features.pay.calcDepositLoss(10000, depositChannels[0]);
这三行背后实际就已经把:
- 当前应用挂着的
wpProduct - 当前系统配置的
offlineAccount System.payConfig.depositLoss
都一起利用起来了。
使用建议
对一个新项目来说,最推荐的顺序是:
- 先把
System.payConfig配完整; - 再把
OfflineAccount和WpProduct这些系统级渠道配好; - 然后才开始接订单支付、充值、提现页面;
- 如果项目有新的渠道实体,再继续补注入点。
如果顺序反过来做,最常见的问题就是:页面已经能选渠道了,但系统配置页和系统资金总览页还是缺的,最后很难排查“为什么这个渠道能展示但不能真正工作”。
订单、支付与退款
oak-pay-business 里最核心的一条主线,就是 Order -> Pay -> Refund。但 Oak 里的这条链并不是“用户点一下支付按钮,然后状态改掉”这么简单,它背后至少同时有四层逻辑在一起工作:
- 实体状态机
- checker 的入场校验
- trigger 的状态推进和副作用
- watcher / endpoint 的异步补偿
如果你把这四层拆开看,就会明白为什么很多支付项目自己写起来会越来越乱,而 oak-pay-business 却能保持相对稳定。
主要对象
Order
src/entities/Order.ts 里的状态包括:
unpaidtimeoutcancelledpayingpartiallyPaidpaidrefundingpartiallyRefundedrefunded
动作包括:
startPayingpayAllpayPartiallypayNonetimeoutcancelstartRefundingrefundAllrefundPartiallyrefundNone
Pay
src/entities/Pay.ts 里的状态包括:
unpaidpayingpaidclosedrefundingpartiallyRefundedrefunded
动作包括:
startPayingsucceedPayingclosestartRefundingrefundAllrefundPartiallystopRefunding
另外还额外定义了两个动作:
closeRefundcontinuePaying
近期 stopRefunding 后订单状态回滚已修正。项目层不要绕过 refund.fail / pay.stopRefunding 自己手动改订单状态,否则会跳过公共 trigger 中对支付、退款和订单状态的一致性处理。
Refund
src/entities/Refund.ts 里的状态相对简单:
refundingsuccessfulfailed
动作则是:
succeedfail
组件
这条主线里最值得先读的前端组件有两个。
1. src/components/order/pay/index.ts
这个组件负责把“订单要怎么支付”拆成真正的 pay$order 创建数据。它不是单纯选一个渠道,而是支持同时拼出两段支付:
- 一段
account余额支付 - 一段外部渠道支付,例如
offlineAccount或wpProduct
也就是说,项目层如果要做“余额 + 微信支付”这种组合支付,不需要自己重新设计数据结构,这个组件已经按 pay$order 的 Oak 操作结构拼好了。
2. src/components/pay/detail/index.ts
这个组件负责展示单个支付单,并决定“下一步如何真正发起支付”。它内部已经做了几件很重要的事:
- 会合并所有已注册渠道的额外投影
- 会判断当前渠道能不能发起支付
- 内置了
wpProduct的前端支付流程 - 小程序充值待确认收货时,会调用
shipConfirmSuccess
项目层如果自己再单写一套“支付详情页”,很容易把这些渠道分支和确认收货逻辑漏掉。
order/pay 常用参数
oak-pay-business/src/components/order/pay/index.ts 项目里最常用的参数有:
accountId:允许使用哪个余额账户抵扣accountAvailMax:本次最多可用多少余额onSetPays:组件生成pay$order后回传给父组件accountTips:余额抵扣提示autoStartPay:外部渠道支付单是否自动开始
taicang/src/pages/console/order/detail/web.pc.tsx 的真实接法是:
<OrderPay
accountAvailMax={available + freeable}
oakId={order.id}
accountId={accountId}
oakPath="$$console-order-detail-order:pay"
onSetPays={setPays}
accountTips={t('tips.account', {
count: ToYuan(locked),
price: ToYuan(available + freeable),
})}
/>
pay/detail 常用参数
oak-pay-business/src/components/pay/detail/index.ts 最常用的参数则是:
oakId、oakPathonCloseonPaidonPayFailuremode: 'frontend' | 'backend'disableAutoPaycloseWhenFailuredisableClose
taicang 在后台支付详情弹窗里传的是:
<PayDetail
oakPath="$$console-order-detail-pay:detail"
oakId={unCompletedPayId}
onClose={() => {
unsetPays();
setShowPay(false);
}}
disableClose={true}
mode="backend"
/>
这里还有两个很容易忽略、但在项目里很有用的参数语义:
mode="backend":允许后台场景展示和手工处理支付,不完全按前台用户交互来限制disableClose={true}:适合订单详情里的支付弹窗,避免用户把“待支付中的支付单”随手关掉后丢失上下文
pay/channelPicker2 的职责
如果项目不是直接复用 order/pay,而是自己写支付表单,那么最推荐先复用的通常不是整个支付详情页,而是 pay/channelPicker2。
这个组件暴露的参数非常明确:
payChannelspayChannelonPick
它本身只做一件事:
- 把
features.pay.getPayChannels(...)返回的渠道数组转成可选项,并把选中的PayChannel回传给父组件
它不会自己创建 pay,也不会自己发起支付,所以更适合做:
- 订单支付页里的渠道选择器
- 充值页里的渠道选择器
- 后台人工补单页里的支付方式选择器
也正因为它只认 PayChannel 结构,项目层新增支付产品后,只要渠道数据还遵守公共包约定,这个组件通常不用改。
refund/list 适合放在哪里
退款列表虽然不是支付最显眼的前台组件,但在后台排查支付链路时很有价值。它当前的真实行为包括:
- 默认按当前应用所属
systemId过滤退款 - 自动把
creator.name / nickname / mobile整理成展示字段 - 自动把
pay.entity转成渠道名称 - 通过
withdrawId标记这条退款是不是“提现拆单产生的退款”
所以它很适合放在:
- 财务后台的退款记录页
- 订单详情里的退款历史页
- 提现详情的辅助排查页
如果项目层改动了 pay.application、creator.mobile$user 这些关系,这个列表的默认展示就会受影响,文档里最好提前提醒使用方。
order/list / pay/list
这两个列表组件也很值得单独写出来,因为它们通常就是后台运营和财务页最直接的入口。
order/list 的真实行为包括:
- 默认按当前应用所属
systemId过滤订单 - 自动把金额字段转成人类可读的元单位字符串
- 自动整理
creatorName、creatorMobile
它适合做:
- 后台订单列表
- 财务订单查询页
- 某个系统的支付订单概览
pay/list 则更偏支付单后台,当前真实行为包括:
- 默认按
application.systemId过滤支付单 - 默认按
$$createAt$$ desc排序 - 自动刷新当前系统下的
offlineAccount - 自动把
creator信息整理成展示字段 - 暴露
close、succeedPaying动作
所以它很适合:
- 支付单管理页
- 财务对账页
- 线下支付人工确认页
相比订单列表,pay/list 更适合查“这一笔支付本身发生了什么”;而 order/list 更适合查“订单整体支付到哪一步了”。
前端入口
这一章的前端入口分成三块。
features.pay.getPayChannels(...)
订单支付页和支付详情页都会依赖它来拿渠道。当前返回结果包括:
offlineAccount- 当前平台允许的
wpProduct - Web 环境下的
apProduct、epProduct、spProduct、cpProduct、wfProduct、ppProduct - 如果传了
accountId且场景是支付,还会追加account
registerFrontendPayRoutine(...)
支付详情页的真正唤起动作不是硬编码的,它通过 registerFrontendPayRoutine(...) 扩展。
当前内建实现包括:
wpProduct:小程序调用wx.requestPayment(...),微信 H5 走wechatSdk.loadWxAPi('chooseWXPay', ...);apProduct、epProduct、spProduct、cpProduct、wfProduct、ppProduct:仅 Web 使用公共 redirect routine,从pay.meta解析跳转地址。
如果项目又加了新的支付产品,就要继续注册自己的前端支付 routine。
支付回调 endpoint
src/endpoints/wechatPay.ts 当前直接提供了两个回调 endpoint:
payNotifyrefundNotify
它们内部继续复用了 src/utils/pay.ts 中的回调处理逻辑,项目层一般不需要再自己解微信支付通知。
后台规则
Order checker
src/checkers/order.ts 做了两类很关键的检查:
create时补默认值,例如creatorId、paid、refunded、settled、settlePlannedstartPaying时检查支付单是否齐全,以及在不允许部分支付时,支付总额必须等于订单金额
如果订单还带了结算目标,checker 还会检查这些分账目标的金额总和是否等于订单金额。
Pay checker
src/checkers/pay.ts 会约束:
- 支付金额必须不小于 0
- 充值类支付必须带
depositId - 充值类支付不能使用
account - 订单上所有
paying / paid的支付总额不能超过订单金额 - 手工
succeedPaying时必须带successAt - 非 root 用户只能手工成功
offlineAccount类型的支付
Pay trigger
src/triggers/pay.ts 是整条链里最重要的触发器文件之一,至少做了这些事情:
changeOrderStateByPay(...)会汇总pay$order推进订单状态autoStart的支付在创建后会自动执行startPayingstartPaying前会调用对应payClazz.prepay(...)close前会调用渠道关闭逻辑,必要时也会让充值失败succeedPaying后会记录sysAccountOper- 如果这是充值类支付,还会进一步推进
deposit - 对
wpProduct.needReceiving的小程序充值,会先把deposit推进到ship
另外,tryCompleteAccountPay(...) 还会自动补齐或关闭 account 类型支付,这就是为什么余额支付通常不需要你自己再写一套专门的支付回调。
Refund checker 与 trigger
src/checkers/refund.ts 会约束:
- 同一个
pay不能同时存在多个refunding - 某些成功回调场景必须有
externalId - 非
account类型退款失败时必须有reason
src/triggers/refund.ts 则负责:
- 创建退款前做合法性检查和准备
- 创建退款后以
strict: 'makeSure'触发真实外部退款 - 退款成功或失败后更新
pay - 充值退款时更新
accountOper - 需要时记录
sysAccountOper
这里的 makeSure 很重要,它意味着退款不是“尽量触发一下”,而是按 Oak 的补偿机制保证最终完成。
watcher 与异步补偿
支付这条链里至少有三组 watcher 在兜底。
watchers/order.ts
把到期未支付的订单自动改成 timeout。
watchers/pay.ts
做两件事:
- 轮询
paying且已有externalId的支付,和第三方渠道同步真实支付状态 - 到达
timeoutAt后自动关闭支付 - 到达
forbidRefundAt后自动执行closeRefund
watchers/refund.ts
轮询 refunding 且已有 externalId 的退款,调用 payClazz.getRefundState(...) 与真实渠道同步。
项目中如何接入
项目里真正接这条链,一般按下面方式分层。
1. 订单页负责收集支付方案
推荐直接复用 components/order/pay,让它生成 pay$order 的创建数据。
2. 服务端或父组件执行 order.startPaying
这个动作不是简单改状态,而是会触发前面的 checker 和 trigger,把支付单挂到订单上并推进状态机。
3. 支付详情页负责真正拉起支付
推荐直接用 components/pay/detail。它已经知道什么时候该:
- 显示线下收款信息
- 拉起小程序支付
- 拉起微信网页支付
- 允许手工成功
- 在充值收货确认后继续推进
4. 第三方支付平台回调走 endpoint
不要把异步回调自己写成零散 controller,直接接 payNotify / refundNotify。
真实项目里的页面组合
从 taicang 的订单详情页可以直接看出推荐的页面分层:
- 订单详情页里弹出
order/pay - 父组件拿到
pays后执行order.startPaying - 如果订单已经在支付中,再切到
pay/detail
这比“订单页自己调 SDK、自己改状态、自己处理回调”稳定得多。
开发注意事项
支付详情页还有一个新手容易忽略的点:
pay/detail会根据已注册的前端支付 routine 自动合并额外 projection
所以项目层如果新增了支付渠道,却只写了后端 registerPayClazz(...),没有写:
registerFrontendPayRoutine(...)
那么页面层通常连拉起支付所需的数据都拿不全。
真实项目里的回调复用
haina-busi 的 wechatPay.ts、cmbPay.ts、aliPay.ts 都没有重写整套支付回调逻辑,而是统一复用了:
@oak-pay-business/utils/pay的payNotify@oak-pay-business/utils/pay的refundNotify
这点很值得模仿。项目层真正需要做的通常只是暴露不同路由和参数,不要重写回调状态推进本身。
使用示例
1. 用订单支付组件生成 pay$order
<OrderPay
oakPath="order"
accountId={accountId}
accountAvailMax={account.avail}
autoStartPay
onSetPays={(pays) => this.setState({ pays })}
/>
这个组件最终回给父组件的 pays,就是可以直接塞进 order.startPaying 的 pay$order 创建数据。
2. 执行订单支付
await this.features.cache.exec('operate', {
entity: 'order',
operation: {
id: await generateNewIdAsync(),
action: 'startPaying',
data: {
pay$order: pays.map((pay) => ({
id: await generateNewIdAsync(),
action: 'create',
data: pay,
})),
},
filter: {
id: orderId,
},
},
});
3. 微信支付异步回调如何进入应用
import * as wechatPay from '@oak-pay-business/endpoints/wechatPay';
export default {
wechatPay: [wechatPay.payNotify, wechatPay.refundNotify],
};
这段代码写在项目的 src/endpoints/index.ts。当前 oak-pay-business/src/endpoints/index.ts 默认导出空对象,所以依赖自动装载不会主动暴露任何支付回调;项目必须显式选择实际启用的渠道。公共 wechatPay endpoint 已负责解析并调用支付域状态推进逻辑,项目不需要再重写通知解密和状态机。
调用公共路由时参数仍要带:
payNotify的payIdrefundNotify的refundId
使用建议
如果你准备在项目里复用这条链,最重要的建议只有两条:
- 订单状态不要自己手动推,尽量通过
order.startPaying、pay.succeedPaying、refund.succeed这些动作进入状态机。 - 前端支付流程不要只写页面逻辑,要同时把
registerFrontendPayRoutine(...)、支付回调 endpoint、watcher 补偿一起接上。
否则最常见的问题就是:页面能跳支付,但订单状态、退款状态、过期关闭和异步回调并没有跟着一起工作。
账户、充值与流水
支付域里除了“对订单付款”,还有另一条经常单独存在的主线:账户充值。oak-pay-business 没把它做成一个孤立的钱包模块,而是把充值、到账、系统资金、账户流水放进了一套统一模型里。
如果你从源码角度看,这条线的核心其实是:
AccountDepositAccountOperSysAccountOperSysAccountMove
它和订单支付会复用同一套渠道能力,但状态推进和记账方式并不完全一样。
主要对象
Account
账户本身记录的是用户或业务对象的余额。最常见的两个数是:
totalavail
Deposit
src/entities/Deposit.ts 定义了充值单状态:
depositingsuccessfulfailedshipped
动作包括:
succeedfailship
这里的 shipped 很容易让人意外。原因是 oak-pay-business 允许把某些充值做成“先支付,后确认收货再到账”的模式,尤其是小程序虚拟发货场景。
AccountOper
这是账户流水。充值到账、退款返还、提现冻结或回退,最后都会落到 accountOper 上。
SysAccountOper / SysAccountMove
这两类对象对应的是系统资金层面的流水和划拨。也就是说,用户账户余额变化之外,平台自身的系统账户也有一套独立流水。
组件
这条主线最关键的现成组件有三组。
1. src/components/deposit/new/index.ts
这是“创建充值”的真实前端组件。它会:
- 通过
features.pay.getPayChannels('deposit')列出充值可用渠道 - 通过
features.pay.calcDepositLoss(...)计算充值手续费 - 在小程序环境里默认优先选
wpProduct
如果项目里自己做充值输入框和渠道选择,至少也要把这三件事一起补上。
deposit/new 常用参数与组件边界
这不是一个“自己持有全部状态并直接提交充值”的黑盒组件,而是一个受控表单。当前源码里暴露的关键参数有:
accountIddepositMinCentdepositMaxCentpricechannelonSetPriceonSetChannellossfocus
其中真正会直接参与渲染和联动的,是:
depositMinCent / depositMaxCent:控制金额上下限,并在ready()时用最小充值额初始化输入框price / channel / loss:父组件维护的受控状态onSetPrice / onSetChannel:组件内部只负责把变更往外抛
focus 目前主要用于小程序输入框聚焦,web 端并不会额外依赖它。accountId 虽然保留在属性里,但当前组件本体并不直接用它创建充值单,所以真正的提交动作仍然应该放在父组件,例如 account/detail 或项目自己的充值页里。
从实现上看,它还有两个默认行为很适合直接写进接入文档:
- 小程序环境且未指定渠道时,会自动优先选
wpProduct price和loss变化后,会重新计算小程序里的cursorSpacing,保证渠道和手续费区域不会挡住输入框
也就是说,deposit/new 更适合做“充值参数录入器”,而不是一个独立完成整条充值链路的页面。
2. src/components/account/detail/index.ts
这是账户详情页的核心组件。它不是只读组件,内部还封装了完整的充值创建流程:
- 调用
account.deposit - 嵌套创建
deposit - 再嵌套创建
pay - 轮询
pay直到进入paying - 最后跳转到未完成支付详情
也就是说,项目层如果直接复用它,充值链路已经是完整的。
3. 系统资金相关组件
系统侧的资金展示和流水组件主要分布在:
src/components/sysAccount/surveysrc/components/sysAccountMove/createsrc/components/sysAccountOper/listsrc/components/accountOper/*
这些组件通常会和上一章的系统支付配置页一起出现在管理后台里。
account/detail 常用参数
oak-pay-business/src/components/account/detail/index.ts 不是一个纯展示组件,项目里最常传的参数有:
depositMinCentdepositMaxCentautoStartPayonGoToUnfinishedPayonWithdrawpreWithdrawonGoToHistory
taicang/src/pages/frontend/account/detail/web.pc.tsx 的真实包法是:
<AccountDetail
autoStartPay={true}
depositMinCent={depositMinCent}
oakId={accountId}
oakPath={`${oakFullpath}.account`}
onGoToUnfinishedPay={(payId) => navigateTo({
url: '/pay/detail',
oakId: payId,
})}
onWithdraw={() => gotoWithdraw()}
preWithdraw={() => preWithdraw()}
onGoToHistory={gotoHistory}
/>
从组件内部逻辑看,它默认还会关心:
- 是否存在
depositing状态的充值单 - 是否存在
shipped状态、待确认收货的充值单 - 最近几条
accountOper$account
所以它适合做“账户主页”而不是只做一个充值按钮。项目层如果只是想要极简充值入口,才更适合单独包 deposit/new。
组件适合放在哪里
这组组件最适合的几个落点其实很明确:
- 用户钱包页、保证金账户页:用
account/detail - 账户流水页:用
accountOper/list - 系统资金总览页:用
sysAccount/survey
taicang 前台账户页和后台账户页都是围绕这套公共组件搭出来的。
accountOper/list 的真实筛选能力
这个列表组件并不只是简单把流水按时间倒序列出来。它当前的关键参数是:
accountId
但内部还已经内置了两组常见筛选:
- 按月份筛选
$$createAt$$ - 按收支方向筛选
availPlus
具体来说:
type: 'in'只看收入流水type: 'out'只看支出流水type: 'both'会过滤掉availPlus === 0的无效流水
所以项目层如果做账户历史页,通常不需要自己再额外写一个月份/收支筛选器,直接复用这个组件即可。
系统资金列表组件
除了总览卡片,系统资金后台里还有两组很值得直接复用的列表组件:
sysAccountOper/listsysAccount/transferList
sysAccountOper/list 当前通过下面两个参数工作:
entityentityId
它会按这两个字段过滤系统流水,并内置:
- 月份筛选
- 类型筛选,当前枚举包括
withdrawTransfer、pay、refund、compensate、moveIn、moveOut
sysAccount/transferList 则更偏运营处理页。它默认只看:
- 当前应用所属系统下的提现转账
iState === 'transferring'
并且会把 withdrawAccount.channel 自动转成可读渠道名。所以它很适合做“待打款提现处理列表”,而不是通用历史查询页。
开发注意事项
account/detail 的一个重要适配前提是:项目实体关系不要把公共充值链路打断。至少要保证这些关系还能正常工作:
deposit$accountpay$depositaccountOper$account
因为组件内部会直接沿着这些关系判断“是否有未完成充值”“是否需要去支付详情页”“最近流水是什么”。
前端入口
这条线主要依赖的前端入口还是 features.pay。
getPayChannels('deposit')
会返回所有允许充值的渠道。当前内建来源是:
offlineAccount.allowDeposit === true- 当前环境可用的
wpProduct
calcDepositLoss(price, channel)
会按 System.payConfig.depositLoss 直接计算充值损耗。返回值是一个三元组:
- 手续费金额
- 说明文案 key
- 文案参数
这也是 deposit/new 和 account/detail 里实际在用的方法。
后台规则
account.detail 发起充值时的真实操作
src/components/account/detail/index.ts 里最值得注意的代码,是它并不是直接创建一个 pay,而是走:
account.deposit- 嵌套
deposit$account.create - 再嵌套
pay$deposit.create
也就是说,在 Oak 模型里,“账户充值”不是一个页面自定义动作,而是账户对象上的正式业务动作。
Pay trigger 与 Deposit trigger 的配合
充值能不能真正到账,主要靠两层 trigger 协作:
triggers/pay.tstriggers/deposit.ts
当前源码里的真实行为是:
pay.succeedPaying后会根据depositId去推进充值单- 如果支付产品要求确认收货,充值单会先进入
ship deposit.succeed时才真正补记账户和系统账户的流水deposit状态变化还会推送数据事件
这就是为什么有些充值会“支付成功但余额还没到账”,因为它还卡在确认收货这一层。
小程序确认收货
如果是带确认收货的小程序充值,前端组件会调用:
shipConfirmSuccess
这个 aspect 定义在 src/aspects/ship.ts,如果微信侧物流状态已经确认收货,就会把 ship 从 receiving 推到 succeedReceiving,再由后续 trigger 推动 deposit.succeed。
注入点
账户充值这一章本身没有额外的独立 registry,但它会直接复用支付域已有的几个扩展点:
registerPayClazz(...)registerFrontendPayRoutine(...)registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
也就是说,项目一旦加了新的充值渠道,不只订单支付会受影响,账户充值入口和系统资金展示也要一起补。
项目中如何接入
1. 先让应用能拿到充值渠道
features.pay.getPayChannels('deposit') 依赖当前应用和系统的投影,所以初始化阶段一定要把:
system.offlineAccount$systemwpProduct$applicationsystem.payConfig
带进来。
2. 充值入口优先复用现成组件
对新项目来说,最简单且最稳的做法一般是:
- 账户页直接挂
components/account/detail - 需要单独充值表单时,再挂
components/deposit/new
这样充值手续费、充值渠道和支付详情跳转都能统一起来。
3. 如果有系统资金管理台,再补系统侧组件
当项目需要运营后台查看平台收支时,再接:
sysAccount/surveysysAccountMove/createsysAccountOper/list
真实项目里的页面拆法
taicang 的做法很值得直接照搬:
/frontend/account/detail负责账户余额、充值入口、提现入口/frontend/pay/detail负责未完成支付/frontend/account/history负责账户流水
这样充值、支付、流水三条链路各自有独立页面,但底层仍然是同一套公共组件。
使用示例
1. 单独使用充值创建组件
<DepositNew
price={price}
channel={channel}
loss={loss}
depositMinCent={100}
depositMaxCent={200000}
onSetPrice={(nextPrice) => this.setState({ price: nextPrice })}
onSetChannel={(nextChannel) => this.setState({ channel: nextChannel })}
/>
当价格或渠道变化时,可以直接用 feature 重新计算手续费:
const loss = price && channel
? this.features.pay.calcDepositLoss(price, channel)
: [0, '', undefined];
2. 通过账户动作创建充值和支付
这就是 components/account/detail 当前真实采用的 Oak 操作结构:
await this.execute(undefined, undefined, undefined, [
{
entity: 'account',
operation: {
id: await generateNewIdAsync(),
action: 'deposit',
data: {
deposit$account: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
price: depPrice,
loss: depositLoss[0] || 0,
creatorId: this.features.token.getUserId()!,
pay$deposit: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
id: payId,
autoStart: true,
price: depPrice,
entity: depositChannel.entity,
entityId: depositChannel.entityId,
},
},
],
},
},
],
},
filter: {
id: accountId,
},
},
},
]);
这段结构的意义在于:充值、支付、账户动作三者天然保持同一条 Oak 数据链。
使用建议
如果项目里既有订单支付,又有账户充值,最容易犯的错是把两条链拆成两套完全不同的前端和后台逻辑。实际上更推荐:
- 渠道层统一复用
features.pay和registerFrontendPayRoutine(...) - 记账层统一复用
deposit、accountOper、sysAccountOper - 需要确认收货的充值,直接复用
ship主线,不要额外发明一个“待到账状态”
这样后面要查“钱为什么没到”“系统资金为什么少了一笔”“用户余额为什么和支付成功不一致”时,排查路径会清楚很多。
提现
提现是 oak-pay-business 里最容易看起来“像一个简单表单”,但实际上规则最复杂的能力之一。因为在 Oak 的支付域模型里,提现不是单一渠道直接打款,而是可能同时拆成两部分:
- 能原路退回的部分,走
Refund - 不能原路退回的部分,走
WithdrawTransfer
这也是为什么提现这一章一定要连着退款一起理解。
主要对象
Withdraw
src/entities/Withdraw.ts 里的状态包括:
withdrawingsuccessfulpartiallySuccessfulfailedapplying
动作包括:
succeedfailsucceedPartially
WithdrawTransfer
src/entities/WithdrawTransfer.ts 里的状态包括:
transferringsuccessfulfailed
动作包括:
succeedfail
WithdrawAccount / WithdrawChannel
这两个对象分别代表:
- 用户或业务对象可用的提现账户
- 提现账户关联的打款渠道
当原路退款额度不够时,提现就会继续依赖这里配置的提现账户和渠道。
组件
提现这条线最值得先看的两个组件是:
src/components/withdraw/create/index.tssrc/components/withdraw/detail/index.ts
其中创建页最关键,因为它真实体现了 Oak 支付域里的提现流程不是“点提交直接 create withdraw”,而是:
- 先调用 aspect 预计算可行的提现拆单方案
- 再拿这个方案去执行
withdraw.create
详情页则会直接展示:
refund$withdrawwithdrawTransfer$withdraw
这两个关系正好对应提现拆单后的两段执行路径。
withdraw/create 常用参数
oak-pay-business/src/components/withdraw/create/index.ts 项目里最常用的参数有:
accountIdwithdrawAccountFilteronNewWithdrawAccountonCreateWithdrawonGoToHistoryonGoToWaManage
taicang/src/pages/frontend/account/withdraw/web.pc.tsx 的真实写法就是:
<WithdrawCreate
accountId={account.id}
withdrawAccountFilter={{
entity: 'user',
entityId: userId,
}}
onCreateWithdraw={gotoWithdrawDetail}
onGoToHistory={gotoHistory}
onGoToWaManage={gotoWaManageMp}
/>
withdraw/detail 与相关列表组件
提现页一般不是只接一个创建表单,还会和这些组件一起出现:
withdraw/detailwithdraw/listwithdrawAccount/list
taicang 前后台都已经把这几个组件分开挂页了,这样一旦出现“部分成功”“部分原路退回”,业务方可以直接在详情页看到拆单结果。
withdraw/detail 的职责边界
withdraw/detail 在 web 端的渲染其实很薄,它本身主要就是把一条 withdraw 交给 withdraw/display 展示。
这意味着:
- 创建逻辑不应该放进详情页
- 拆单解释、退款明细、转账明细适合统一在详情展示层处理
- 页面层更适合负责“从列表跳转到哪条提现详情”
这种拆法和 taicang 当前的页面组织是一致的。
withdraw/display 的真实职责
withdraw/detail 之所以能把“部分成功”“部分失败”讲清楚,关键不是页面壳本身,而是 withdraw/display 已经把提现拆单明细整理好了。它当前最关键的参数只有两个:
withdrawcreate
其中 withdraw 里它会直接读取:
refund$withdrawwithdrawTransfer$withdraw
然后把两边数据拍平成一组统一的展示项。也就是说,这个组件不是分别画两张表,而是把“原路退款”和“转账打款”都收敛成同一种明细卡片结构。
渠道名称的解析也已经内置了:
- 优先从
withdrawTransfer.withdrawAccount.channel取渠道 - 如果详情数据里没带全,再回退到缓存里按
withdrawAccountId查一次 offlineAccount会额外展开成具体线下账户类型文案
create 参数的作用主要是控制展示阶段:
create: true时按“刚提交申请”显示步骤,不展示执行态和更新时间- 详情页常规查看时,则会显示
refund/withdrawTransfer的执行状态、更新时间和失败原因
所以项目里如果想自己重画提现详情,至少也要把这套“退款 + 转账”的汇总逻辑一起带走。
withdraw/list 常用参数
oak-pay-business/src/components/withdraw/list/index.ts 最常用的参数非常明确:
accountIdgotoDetail
它内部会:
- 强制按
accountId过滤 - 默认按
$$createAt$$ desc排序 - 直接把每条提现的拆单数量聚合出来
所以它很适合直接作为“某个账户的提现历史页”。
withdrawAccount/list 常用参数
oak-pay-business/src/components/withdrawAccount/list/index.ts 则更适合两种模式复用:
- 管理模式:维护某个对象的提现账户
- 选择器模式:给
withdraw/create挑一个打款账户
常用参数包括:
entityentityIdonPickonCancel
从源码看它会默认:
- 只展示
enabled: true的账户 - 优先选中默认账户
- 在
onPick存在时自动切成 picker 模式
这也是为什么 withdraw/create 只要传 withdrawAccountFilter,后续选择器链路就能自然接起来。
withdrawAccount/list 的两种模式
结合 index.ts 和 web.pc.tsx,这个组件其实明确支持两种模式。
1. 管理模式
不传 onPick 时,它就是普通管理列表,适合放在“我的提现账户”或后台账户维护页里。当前 web 端行为包括:
- 可以直接打开
WdaUpsert模态框创建或编辑 - 可以切换
isDefault - 可以删除账户;如果当前行没有
remove权限,则会退化成执行disable
2. 选择器模式
传了 onPick 以后,它会自动进入 picker 模式:
asPicker会变成true- 底部出现确认选择和取消按钮
- 默认会优先选中
isDefault的提现账户
所以项目层如果是在提现创建页里选打款账户,直接传 onPick / onCancel 就够了;如果是在个人中心维护账户,就不要传 onPick,让它走管理模式。
withdrawTransfer/list 适合放在哪里
如果提现后台需要真正处理“打款执行”这一步,通常不会直接看 withdraw/list,而会继续接 withdrawTransfer/list。这个组件当前的真实行为包括:
- 默认按当前应用所属系统过滤
withdraw.account.ofSystemId - 自动整理
creatorName、creatorMobile - 自动整理
operatorName、operatorMobile - 自动把
withdrawAccount.channel转成渠道名称 - 暴露
succeed、fail两类动作
它还有一个很实际的实现细节:
- 在准备更新某条转账记录时,会先刷新对应系统账户余额,并写到本地状态
sysAccountAmount
所以它更适合:
- 运营后台待打款列表
- 提现转账处理页
- 财务审核后的执行页
而 withdraw/list 更适合作为用户或业务对象视角的“提现历史”。
aspect
提现最核心的后端入口就是 src/aspects/withdraw.ts#getWithdrawCreateData(...)。
它的参数很简单:
accountIdpricewithdrawAccountId?
但内部做的事情非常多:
- 检查
System.payConfig.withdrawLoss是否已配置 - 锁定当前账户,读取
avail和refundable - 如果申请提现金额超过可原路退款额度,且又没选
withdrawAccountId,直接报错 - 计算本次提现应拆成哪些
refund$withdraw - 如果仍有剩余金额,再补一笔
withdrawTransfer$withdraw - 最后算出整单总手续费
loss
也就是说,提现创建页拿到的不是一个“展示用报价”,而是最终可直接落库的 withdraw.create 数据。
后台规则
提现手续费计算
提现手续费由 System.payConfig.withdrawLoss 决定,源码里有两套模式。
1. conservative: true
保守模式下,不按系统固定比例算,而是根据真实渠道税费估损:
- 原路退款部分会调用
payClazz.calcRefundTax(...)和payClazz.calcPayTax(...) - 转账部分会调用
payClazz.calcTransferTax(...)
2. conservative: false
非保守模式下,会先按系统配置预计算总手续费,再按提现拆单比例分摊到每一笔 refund 或 withdrawTransfer 上。
这时会用到:
ratiolowesthighesttrim
其中 trim 支持:
jiaoyuan
Withdraw trigger
src/triggers/withdraw.ts 负责的事情主要有三类。
1. 创建提现时先扣账户并记流水
提现创建时会先扣减账户可用余额,并创建关联的 accountOper。
2. 汇总执行结果更新提现状态
updateWithdrawState(...) 会把:
refund$withdrawwithdrawTransfer$withdraw
两边实际完成的金额和手续费汇总成:
dealPricedealLoss
再决定提现最终是:
failsucceedsucceedPartially
3. 失败或部分成功时返还差额
如果整单失败,或者只成功了一部分,剩余差额会通过新的 accountOper 返还到账户。
WithdrawTransfer checker 与 trigger
src/checkers/withdrawTransfer.ts 约束很明确:
succeed时必须有externalIdfail时必须有reason
src/triggers/withdrawTransfer.ts 则会在成功或失败后继续更新 withdraw,并在成功时记:
sysAccountOper- 系统账户侧
accountOper
WithdrawAccount checker
src/checkers/withdrawAccount.ts 会保证:同一 entity / entityId 下默认提现账户只能有一个。
注入点
提现这条线自己没有单独的 registry,但它直接依赖支付渠道扩展能力:
- 原路退款依赖
getPayClazz(...) - 转账打款依赖
WithdrawChannel关联的支付类 - 所以项目层新增支付渠道后,提现能力也会自动用到这套扩展
项目中如何接入
提现最推荐的接法就是严格复用 withdraw/create 组件里的顺序。
1. 先调 aspect 拿最终创建数据
这是必须的,不能省略。因为只有后端才能正确判断:
- 哪些金额能原路退款
- 哪些金额必须打到提现账户
- 本次手续费到底该怎么算
2. 再执行 withdraw.create
拿到 withdrawData 后,再直接走 Oak operate。
3. 详情页统一看拆单结果
提现详情页应该直接展示 refund$withdraw 和 withdrawTransfer$withdraw,不要只显示一个总状态。否则一旦出现部分成功,就很难让业务方理解到底发生了什么。
真实项目里的页面拆法
从 taicang 的页面结构看,提现最稳的拆法是:
- 账户详情页只负责跳到提现创建页
- 提现创建页只负责生成并提交
withdrawData - 提现详情页展示
refund$withdraw和withdrawTransfer$withdraw - 提现账户管理页单独维护
WithdrawAccount
这样每个页面的职责都很单一,出问题也更好排查。
开发注意事项
提现这块有两个实现细节很值得提前写进文档:
withdraw/list是按账户维度查数据的,不是全局提现列表组件withdrawAccount/list的默认过滤是“只看启用中的账户”,如果你项目里有停用后仍需展示的管理需求,就要单独做后台页,而不要直接拿 picker 模式复用
使用示例
1. 创建页先向后端要提现方案
const { result: withdrawData } = await this.features.cache.exec('getWithdrawCreateData', {
accountId,
price,
withdrawAccountId,
});
如果金额超过了当前可原路退款额度,但又没有选择 withdrawAccountId,这里就会直接报错,而不是等到真正创建后才失败。
2. 再执行提现创建
await this.features.cache.exec('operate', {
entity: 'withdraw',
operation: {
id: await generateNewIdAsync(),
action: 'create',
data: withdrawData,
},
});
3. 提现数据的真实结构
getWithdrawCreateData(...) 返回的数据结构里,最重要的就是这两段:
{
id: 'withdraw-id',
accountId,
price,
loss,
refund$withdraw: [
{
id: '...',
action: 'create',
data: {
payId: '...',
price: 5000,
loss: 120,
},
},
],
withdrawTransfer$withdraw: [
{
id: '...',
action: 'create',
data: {
price: 3000,
loss: 80,
withdrawAccountId,
},
},
],
}
也就是说,一次提现在 Oak 模型里本来就是“退款 + 转账”的组合单。
使用建议
提现这块最重要的经验是:不要把它写成一个只看余额的简单转账表单。更好的做法是:
- 始终先调
getWithdrawCreateData(...) - 始终把
refund$withdraw和withdrawTransfer$withdraw当成两条真正独立的执行路径 - 系统配置里先把
withdrawLoss配完整,再开放提现入口
否则一旦碰到“部分金额原路退,部分金额人工打款”的场景,前台和后台很快就会对不上。
物流
很多人第一次接 oak-pay-business 时,会把 Ship 当成“订单附带的物流表”。但如果你沿着源码往下读,会发现它的作用远不止展示快递单号:
- 它会决定快递单由哪个物流系统接单
- 它会和微信小程序发货信息录入打通
- 它会影响虚拟充值什么时候真正到账
- 它还会通过 timer 和 aspect 与第三方物流状态持续同步
所以在 oak-pay-business 里,物流不是支付域的边角料,而是一个和支付、充值直接耦合的正式模块。
主要对象
这一章最关键的对象有五个:
ShipShipServiceShipServiceSystemShipCompanyWechatMpShip
其中 Ship 本身在 src/entities/Ship.ts 里定义了完整状态机。
Ship 状态
当前状态包括:
unshippedshippingcancelledreceivedrejectedunknownreceiving
动作包括两类。
主动作
shipreceivecancelrejectunknowstartReceivingsucceedReceiving
扩展动作
syncStatesyncPathssyncAllprint
ShipService / ShipServiceSystem 决定“系统支持哪些物流服务”;WechatMpShip 则是微信小程序发货配置对象,主要服务于微信发货信息录入和确认收货流程。
组件
物流这一章最关键的前端组件有三组。
1. src/components/ship/system/web.pc.tsx
这是系统物流设置页的真实入口。它固定包含两块内容:
- 已注册物流系统设置组件的页签区
shipServiceSystem/list的物流服务配置页签
如果当前项目还没有注册任何物流系统组件,它会直接在页面里提示应该如何调用:
import { registerShipSettingComponent } from '@oak-pay-business/registry.frontend';
import WechatMpShipSetting from '@oak-pay-business/components/ship/wechatMpShip';
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
2. src/components/ship/wechatMpShip/index.ts
这是 WechatMpShip 的系统配置组件。它会在当前 systemId 下拉取:
- 所有
type === 'wechatMp'的application
然后用于配置当前系统对应的小程序发货设置。
3. 其它 ship/* 组件
oak-pay-business/src/components/ship 目录下还有物流详情、列表等实体组件;不过真正决定系统如何扩展的,还是上面两个“系统配置入口”。
ship/system 常用参数
系统物流配置页本身项目层最常传的还是两个参数:
oakIdoakPath
taicang/src/pages/console/systemProvider/system/shipConfig/web.pc.tsx 的真实写法就是:
<SystemShipConfig
oakId={systemId}
oakPath={`${oakFullpath}.system`}
/>
它在页面顶部先执行了:
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
所以新物流系统要不要出现在后台配置页,核心不是改页面,而是先注册。
wechatMpShip 组件适配要求
oak-pay-business/src/components/ship/wechatMpShip/index.ts 的关键参数其实只有:
systemId
但它内部会额外拉:
- 当前
systemId下所有type === 'wechatMp'的application
所以如果项目层系统里根本没有小程序应用,这个配置页就不会有可选 application。换句话说,WechatMpShip 能不能配,不只取决于物流模块本身,还取决于前面 Application 这一章有没有把小程序应用配好。
shipServiceSystem/list 的真实作用
这一块在系统物流页里很容易被忽略,但它其实决定了“当前系统到底开放了哪些物流服务”。当前组件最关键的参数只有:
systemId
它的真实行为包括:
- 列出当前
systemId下已经启用的shipServiceSystem - 根据
systemId反查系统名称,作为页面展示上下文 - 新增物流服务时,不是查全部
shipService,而是用#sqp: 'not in'排除已经绑定到当前系统的服务 - 创建时会一次性批量写入多条
shipServiceSystem.create
所以这不是一个普通“服务字典列表”,而是“系统与物流服务的挂接关系管理器”。
从项目整合角度看,ship/system 之所以能成立,就是因为它把:
- 物流系统实现配置
- 物流服务与系统的绑定关系
两块同时放到了同一个后台页里。
真实项目里的物流类注册参考
taicang/src/routines/weChatMpShip.ts 虽然当前保留成注释示例,但它恰好把 registerShipClazzEntity(...) 需要补的两类回调写得很完整:
- 如何从订单/应用里取收件人
openId和小程序appWxId - 如何为物流单准备打印、货品、重量、图片等扩展信息
如果项目要接新的物流系统,最好直接参考这种结构,而不是只写一个“下单函数”。
aspect
物流模块当前导出了三个真正会被项目层调用的 aspect。
getMpShipState
定义在 src/aspects/ship.ts。它只在当前应用类型是 wechatMp 时工作,用来读取小程序订单的真实发货状态。
getExpressPrintInfo
同样定义在 src/aspects/ship.ts。它会先校验:
ship.type === 'express'ship.entity/ship.entityId存在ship.extraShipId存在
然后再通过对应的 shipClazz 获取打印面单所需信息。
shipConfirmSuccess
这是物流和充值真正连起来的那个 aspect。当前应用如果是 wechatMp,它会去查询微信侧真实物流状态;如果微信侧已经确认收货,而 Oak 中的 ship 仍停在 receiving,它就会执行:
ship.succeedReceiving
后续再由 trigger 推进充值到账。
后台规则
Ship checker
src/checkers/ship.ts 会约束几类关键动作:
syncPaths/syncState/syncAll只允许在unshipped或shipping状态执行- 非 root 用户手工
ship/receive时,只能操作没有extraShipId的单 print只能对已有外部单号、且尚未发货的快递单执行startReceiving/succeedReceiving只允许虚拟物流,或“支付产品要求确认收货”的订单物流执行
Ship trigger
src/triggers/ship.ts 里有一整条完整的物流自动化链路。
1. 创建时自动选择物流系统
如果创建 ship 时带了 shipOrder$ship,trigger 会先调用 getShipEntity(...),按系统配置里各物流系统的 sort 和 available(...) 结果挑出一个可用物流系统,并把:
entityentityId
自动回填到 ship 上。
2. 创建后自动发货或自动下单
virtual/pickup类型在创建后会自动shipexpress类型在创建后会自动调用外部物流系统下单
这两段都是 when: 'commit' 且 strict: 'makeSure' 的触发器。
3. 发货后自动录入微信小程序发货信息
当 ship 进入 shipping 时,如果它属于:
- 充值单对应的发货
- 或带
wpProduct.needReceiving的订单发货
trigger 会调用微信小程序发货信息录入逻辑。
4. 取消快递单时调用外部取消接口
如果 ship.type === 'express' 且已有 extraShipId,cancel 之后会继续调用物流渠道的取消下单接口。
5. 进入签收确认阶段时发送小程序提醒
startReceiving 之后,如果能拿到收货人的 openId,会调用小程序确认收货提醒。
6. 虚拟收货确认后推进充值到账
当 ship.succeedReceiving 发生在虚拟充值链路上时,trigger 会继续执行:
deposit.succeed
这也是为什么物流这章必须和充值一起理解。
timer 与后台补偿
物流的异步同步主要由 src/timers/ship.ts 负责,而不是靠页面手动刷新。
当前有两类定时任务:
同步快递ship状态同步微信小程序虚拟、自提ship状态
也就是说:
- 快递单状态变化,会定时去第三方物流系统刷新
- 小程序虚拟/自提场景,会定时去刷新微信侧确认收货状态
相反,src/watchers/ship.ts 目前没有启用中的 watcher,实际同步逻辑主要都在 timer。
注入点
物流这一章有两类注入点。
前端设置页注入
通过 registerShipSettingComponent(...) 注入新的系统设置组件。
后端物流类注入
通过 src/utils/shipClazz/index.ts 里的:
registerShipClazzEntity(...)
注册新的物流系统实现。这个注册函数会检查被注册实体是否满足这些字段约束:
sortsystemIddisabled
然后 getShipEntity(...) 会在创建物流单时,从当前系统所有可用物流实体里按 sort 选出最优的那个。
项目中如何接入
1. 先把系统物流配置页接起来
如果项目需要管理后台配置物流,先注册物流系统设置组件,然后在系统详情页里挂 components/ship/system。
2. 后端注册物流类
只有把物流类实现注册进 registerShipClazzEntity(...),自动下单、取消、打印面单、收件人信息获取这些行为才会真正工作。
3. 小程序项目再接 WechatMpShip
如果项目是微信小程序或者涉及微信小程序发货信息录入,就继续补 WechatMpShip 配置组件和对应物流类实现。
真实项目里的整合顺序
把 ship 接进现有项目时,最稳妥的顺序通常是:
- 先注册
registerShipSettingComponent(...) - 再注册
registerShipClazzEntity(...) - 确保系统页已经能配置
ShipServiceSystem - 最后再让订单/充值创建
ship
否则经常会出现“页面上能选物流服务,但后端没有任何可用物流类接单”的空跑情况。
使用示例
1. 注册系统物流设置组件
import { registerShipSettingComponent } from '@oak-pay-business/registry.frontend';
import WechatMpShipSetting from '@oak-pay-business/components/ship/wechatMpShip';
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
2. 后端注册物流类
import { registerShipClazzEntity } from '@oak-pay-business/utils/shipClazz';
registerShipClazzEntity(
'myExpressAccount',
async (entityId, context) => new MyExpressClazz(entityId, context),
storageSchema,
);
3. 物流创建时让系统自动挑渠道
await context.operate('ship', {
id: await generateNewIdAsync(),
action: 'create',
data: {
type: 'express',
shipServiceId,
shipOrder$ship: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
orderId,
},
},
],
},
}, {});
只要系统已经注册了可用物流类,并且当前物流服务可用,entity / entityId 就会在创建前被 trigger 自动补上。
使用建议
物流这块最推荐的思路是:
- 把“哪个物流系统来接单”交给
getShipEntity(...) - 把“怎么发货、取消、查状态、打面单”交给
shipClazz - 把“小程序收货确认后的业务推进”继续交还给
shipConfirmSuccess和shiptrigger
不要把这些逻辑零散写在订单页面、充值页面或者单独的 cron 脚本里。否则最后最难排查的,往往不是物流本身,而是“为什么收货确认后余额还没到账”这种跨模块问题。
渠道、组件注入与扩展点
oak-pay-business 最有价值的地方之一,不是它内置了多少渠道,而是它把“如何继续接新渠道”也做成了正式扩展点。你不用把公共包源码改烂,只要按它定义好的几个注册入口去接,就能把后端渠道类、系统配置页、前端支付唤起流程和系统资金展示一起补进去。
这一章的重点不是业务流程,而是这些注入点究竟怎么配合工作。
先分清两类扩展
支付域里的扩展其实分成两大类。
1. 后端渠道实现
这类扩展决定:
- 如何预下单
- 如何关单
- 如何退款
- 如何计算手续费
- 如何查询支付/退款状态
真实入口是:
registerPayClazz(...)getPayClazz(...)
2. 前端和后台管理扩展
这类扩展决定:
- 系统支付配置页出现哪些额外页签
- 支付详情页如何拉起新的支付渠道
- 系统资金总览页如何展示新的账户类型
- 物流设置页如何展示新的物流配置
真实入口是:
registerPayChannelComponent(...)registerFrontendPayRoutine(...)registerShipSettingComponent(...)registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
后端支付类扩展
当前内建渠道
src/utils/payClazz/index.ts 里当前内建了以下支付实现:
accountofflineAccountwpProductapProductepProductspProductcpProductwfProductppProduct
这些产品实体都会被 getPayClazz(...) 识别并实例化;是否能在当前前端场景展示,还要看应用投影、产品启用状态以及 Pay feature 的平台限制。当前 Web redirect 类产品的前端 routine 才会在 Web 环境展示,不能把“后端 payClazz 已内建”理解成所有平台都能直接拉起。
registerPayClazz(...)
这个注册函数位于 src/utils/payClazz/index.ts,用途是把新的支付产品实体接进 Oak 支付域。
它不只是简单挂一个构造函数,还会校验被注册实体是否满足公共支付模型的要求。
对账户实体的约束
accountEntity 对应的实体至少要有这些字段:
pricesystemIdallowWithdrawTransferwithdrawTransferLossRatio
对支付产品实体的约束
支付产品实体至少要有这些字段:
applicationIdenabledtaxLossRatiorefundGapDaysrefundCompensateRationeedReceiving
对 pay.entity 的约束
还会检查 schema.pay.attributes.entity.ref 里是否真的包含你注册的实体名。
也就是说,项目层要加新支付产品,不只是写个类就行,而是实体设计本身也要符合支付域公共约束。
真实项目里的实体适配方式
haina-busi 和 taicang 在实体层给了两个非常典型的接法。
1. 新增一个完全独立的支付渠道实体
haina-busi 的做法是:
CmbAccount继承@oak-pay-business/entities/AbstractPayAccountCmbProduct继承@oak-pay-business/entities/AbstractPayProduct
然后只补自己的渠道字段,例如:
- 账户侧的商户号、密钥、回调地址、是否启用
- 产品侧的类型、关联应用、是否启用
这种方式很适合新增一个全新的支付产品体系。
2. 在公共支付实体上继续加项目字段
taicang 的做法则是:
System继承@oak-pay-business/entities/SystemOrder继承@oak-pay-business/entities/OrderShip继承@oak-pay-business/entities/Ship
也就是说,项目自己的业务字段继续往公共支付实体上叠,而不是另起一套平行实体。
getPayClazz(...)
这是运行时真正拿渠道实现的入口。它会:
- 对
account按实体类型缓存 - 对其它渠道按
entity.entityId缓存 - 首次取用时再调用对应的构造函数
后面的:
pay.startPayingrefund.createwithdraw.getWithdrawCreateData- watcher 轮询支付状态和退款状态
都会依赖它。
物流类扩展
registerShipClazzEntity(...)
物流扩展和支付类扩展的思路完全一样,只是入口在 src/utils/shipClazz/index.ts。
被注册的物流实体当前至少要满足这些字段:
sortsystemIddisabled
之后 getShipEntity(...) 会按当前系统里所有已注册实体的 sort 从大到小挑可用物流类,并调用它的 available(...)。
registerShipSettingComponent(...)
后端物流类注册完之后,通常还要顺手把系统设置页也接上,这就是前端对应的注入点。
前端支付流程扩展
registerFrontendPayRoutine(...)
这个注册函数定义在 src/components/pay/detail/index.ts。它接受四部分内容:
entityroutineprojectionjudgeCanPay
也就是说,一个新支付渠道要想在支付详情页里真正被唤起,不只是写一个 routine 就行,还要告诉组件:
- 为了拉起支付,前端还需要预取哪些额外字段
- 在什么条件下允许拉起支付
当前默认实现
当前内建实现包括:
- 小程序环境调用
wx.requestPayment(...) - 微信网页环境调用
chooseWXPay - Web 环境下,
apProduct、epProduct、spProduct、cpProduct、wfProduct、ppProduct共用 redirect routine,从pay.meta读取渠道返回的跳转 URL
如果项目还有新的支付产品,比如银联或自定义聚合支付,仍应通过这个入口注册,而不是直接改 pay/detail 组件源码。
系统配置与系统资金展示扩展
registerPayChannelComponent(...)
这个入口用于把新的支付配置组件挂进 components/payConfig/system/web.pc.tsx 的页签里。
注册后,系统支付配置页会自动多出一个:
- 以实体名为 key 的新页签
并把:
oakPathsystemId
传给对应组件。
公共渠道配置组件
除了注册入口,本仓库本身也已经给几类常见渠道准备了管理组件。它们通常都是被 registerPayChannelComponent(...) 挂进 payConfig/system 页签里的。
offlineAccount/config
这个组件的关键参数是:
systemId
它默认会展示并维护:
typechannelnameqrCodeallowDepositallowPaypriceenabledtaxLossRatiorefundCompensateRatiorefundGapDaysallowWithdrawTransferwithdrawTransferLossRatio
所以它本质上是“线下收款账户 + 提现打款账户”的系统管理页,而不只是一个收款码列表。
wpAccount/config
这个组件同样按 systemId 工作,当前主要维护:
mchIdwechatPayIdapiV3KeypublicKeyFilePathprivateKeyFilePathrefundGapDaystaxLossRatiorefundCompensateRatioallowWithdrawTransferwithdrawTransferLossRationeedReceiving
从源码看,它还有一个很重要的限制:
canCreate只有在当前系统下不存在已启用账户时才为真
也就是说,公共实现默认把 wpAccount 当成“一个系统下单一主账号”的配置方式。
另外它在展示层还有一个很容易被忽略的设计:
- 每个账户卡片内部其实是“详情 +
wpProduct/config”两个页签
所以项目层如果直接复用它,通常不需要再额外写一页“某个微信支付账号下有哪些支付产品”。
wpProduct/config
这个组件的关键参数有:
systemIdwpAccountId
它会在进入时主动刷新当前 systemId 下的所有 application,然后为某个 wpAccount 维护它挂载的支付产品。当前维护的重点字段包括:
typeapplicationIdtaxLossRatiorefundCompensateRatiorefundGapDaysneedReceivingenabled
这也说明一个关键适配点:
- 如果系统下应用没配好,
wpProduct/config就不会有可选application
apAccount/config
支付宝账号配置组件和 wpAccount/config 是平行设计,关键参数同样是:
systemId
当前源码里它会维护的重点字段包括:
appIdmchIdaliPayIdpublicKeyPathprivateKeyPathencryptKeyalipayRootCertPathalipayPublicCertPathappCertPathmodegatewayendpointcallbackUrlwsServiceUrlsettingkeyTypetimeoutneedEncryptrefundGapDaystaxLossRatiorefundCompensateRatioallowWithdrawTransferwithdrawTransferLossRationeedReceivingenabled
它同样内置了两个很关键的行为:
canCreate只有在当前系统下没有已启用账号时才为真- 每个账号卡片内部直接带一个
apProduct/config页签
所以它不是一个“只填支付宝证书路径”的表单,而是“支付宝账户 + 支付产品”的组合管理入口。
apProduct/config
这个组件的关键参数有:
systemIdapAccountId
它和 wpProduct/config 一样,会在进入时先刷新当前系统下的 application,再给某个 apAccount 维护挂载的支付产品。当前会重点维护:
typeapplicationIdconfigtaxLossRatiorefundCompensateRatiorefundGapDaysneedReceivingenabled
实际界面里它还有两个值得提前告诉开发的行为:
- 列表支持直接开关
enabled - 删除、创建都是在当前账号上下文内完成,不需要项目层额外再传过滤条件
所以项目里真正要保证的是:
- 当前系统已经有可选
application apProduct实体已经把applicationId、apAccountId等公共约束定义完整
aliPay/upsert
它和前面的 wechatPay/upsert 是平行的“基础支付配置单页”,关键参数也是:
systemId
它同样会根据 system.domain$system 自动计算 serverUrl,并在创建态自动写入 systemId。
不过这里有一个很关键的源码细节:
- 当前
web.pc.tsx只真正暴露了payNotifyUrl refundNotifyUrl的表单项虽然存在,但在前端代码里被注释掉了
所以项目如果需要在后台界面里单独配置支付宝退款回调,要么确认已有默认约定,要么自己在项目层补表单,不要直接假设公共页面已经把退款回调入口放出来了。
wechatPay/upsert
这个组件更偏“微信支付基础配置单页”,关键参数是:
systemId
它会根据 system.domain$system 自动算出 serverUrl,并在创建态自动把 systemId 回填进去。所以项目层在接微信支付基础配置时,最好保证系统域名已经先配好,否则回调地址这类字段很难一次配准确。
和 aliPay/upsert 相比,它当前会同时维护:
payNotifyUrlrefundNotifyUrl
因此如果你的项目同时接微信和支付宝,不能简单以为两边配置页完全对称。
registerSysAccountCardTopComponent(...)
给系统资金总览页顶部卡片增加新的账户显示样式。
registerSysAccountDetailComponent(...)
给系统资金总览页里的详情区域增加新的账户详情组件。
这两个入口通常会和新的支付账户实体一起出现。
registry.backend.ts 与 registry.frontend.ts
这两个文件的区别要记清楚。
src/registry.backend.ts
后端入口只导出:
registerPayClazz
src/registry.frontend.ts
前端环境只导出:
registerPayChannelComponentregisterFrontendPayRoutineregisterShipSettingComponentregisterSysAccountCardTopComponentregisterSysAccountDetailComponent
故意不导出 registerPayClazz(...),因为后端渠道类注册本来就不应该在前端运行时里做。
项目里该从哪个入口 import
这一点最好在文档里直接说死,否则项目里很容易出现“能跑但 import 路径混乱”的情况:
- 后端运行时注册:从
registry.backend.tsimport - 前端运行时注册:优先从
registry.frontend.tsimport - 个别老项目可能直接从组件文件或 utils 文件 import,例如
haina-busi就有直接从components/payConfig/system/web.pc注册渠道的写法
从长期维护角度看,更推荐:
- 前端统一走
registry.frontend.ts - 后端统一走
registry.backend.ts
taicang/src/initializeFeatures.web.ts 就是这种较新的写法:
- 从
@oak-pay-business/registry.frontend引入registerPayChannelComponent - 注册
wpAccount -> WpAccountConfig
而 haina-busi/src/pages/business/square/payConfig/web.pc.tsx 则保留了较早的页面级注册写法:
- 直接从组件内部文件引入注册函数
- 在页面文件里注册
cmbAccount、apAccount、wpAccount
两种方式都能工作,但如果是新项目或准备整理老项目,优先收敛到 registry.frontend.ts 会更清楚。
这样项目代码一眼就能看出“这是前端注入还是后端注入”。
真实项目里的注册顺序
haina-busi/src/routines/pay.ts 已经把一个完整样例跑通了:
- 先在实体层准备
cmbAccount/cmbProduct registerPayClazz('cmbProduct', { accountEntity: 'cmbAccount', ... }, storageSchema)- 在系统支付配置页注册
registerPayChannelComponent('cmbAccount', CmbAccountConfig) - 再根据需要补
registerFrontendPayRoutine(...)或复用已有详情页逻辑
同一个文件里还注册了:
registerPayClazz('apProduct', { accountEntity: 'apAccount', ... }, storageSchema)
这说明一个项目里同时扩多个渠道,本来就应该通过 registry 统一管理,而不是在页面里分散硬编码。
回调 endpoint 也应该复用公共处理
haina-busi 的 wechatPay.ts、cmbPay.ts、aliPay.ts 都直接复用了:
@oak-pay-business/utils/pay的payNotify@oak-pay-business/utils/pay的refundNotify
因此一个完整渠道接入,最好同时包括:
- 实体
registerPayClazz(...)- 系统配置组件
- 前端唤起
- 回调 endpoint
项目中如何接入
一个新渠道接入时,最稳妥的顺序通常是:
- 先补实体,确保符合公共支付模型
- 后端注册
registerPayClazz(...) - 系统配置页注册
registerPayChannelComponent(...) - 支付详情页注册
registerFrontendPayRoutine(...) - 如果涉及系统资金账户,再注册系统资金展示组件
如果只做了第 2 步,后台虽然能跑,但系统管理台和支付详情页都还不知道这个渠道怎么配置、怎么发起。
使用示例
1. 注册新的支付渠道类
下面是一个项目层示例。名字用 myPayProduct / myPayAccount,表示这是项目自己的扩展实体:
import { registerPayClazz } from '@oak-pay-business/registry.backend';
registerPayClazz(
'myPayProduct',
{
accountEntity: 'myPayAccount',
clazzConstructor: async (entityId, context) => new MyPayClazz(entityId, context),
},
storageSchema,
);
2. 注册系统支付配置页
import { registerPayChannelComponent } from '@oak-pay-business/registry.frontend';
registerPayChannelComponent('myPayProduct', MyPayProductConfig);
3. 注册前端支付唤起流程
import { registerFrontendPayRoutine } from '@oak-pay-business/registry.frontend';
registerFrontendPayRoutine(
'myPayProduct',
async (pay, features) => {
await myPaySdk.start(pay.meta);
},
{
myPayProduct: {
id: 1,
config: 1,
},
},
(pay) => pay.iState === 'paying',
);
4. 注册系统资金展示
import {
registerSysAccountCardTopComponent,
registerSysAccountDetailComponent,
} from '@oak-pay-business/registry.frontend';
registerSysAccountCardTopComponent('myPayAccount', MyPayAccountCard);
registerSysAccountDetailComponent('myPayAccount', MyPayAccountDetail);
使用建议
对项目层来说,最重要的不是“能不能很快写出一个新渠道类”,而是要把渠道接入看成四件一起完成的事:
- 后端支付能力
- 系统配置入口
- 前端支付唤起
- 系统资金展示
只补其中一层,后面几乎一定会在管理后台、支付详情页或者提现链路里出现断层。
项目支付系统设计与接入
如果你已经看完前面几篇,会发现 oak-pay-business 并不是“拿来就能直接付款”的一个页面组件包,而是一套完整的支付域基础设施。真正做项目时,最重要的问题也不是“怎么拉起微信支付”,而是:
- 支付系统应该怎么分层;
- 哪些能力应该沉到公共支付域里;
- 哪些规则应该留在项目自己的 trigger / aspect 里;
- 一个新项目最小应该接哪些东西,才不至于只把支付页面做出来,却把状态机、回调和补偿链路丢了。
这一篇就用 taicang 作为已经跑通的参考项目,把“项目支付系统应该怎么设计”完整梳理一遍。
先说结论
一个 Oak 项目的支付系统,最稳的设计不是“自己写一套 payment service”,而是分成下面四层:
1. 支付域公共底座
这层由 oak-pay-business 提供,负责:
- 支付、退款、充值、提现、物流、系统资金、结算这些通用实体;
- checker、trigger、watcher、timer 组成的资金状态机;
- 支付渠道抽象
payClazz; - 支付回调处理;
- 前端
payfeature; - 后台配置组件、支付详情组件、账户与流水组件。
2. 项目支付接入层
这层在项目里负责:
- 把
oak-pay-business的checkers / triggers / watchers / timers / aspects / features合并进来; - 让
make:dep生成的 Context 组合支付域 module,项目自己的RuntimeContext继承生成的 Context; - 暴露项目自己的支付回调 endpoint;
- 按需要注册新的支付渠道、前端拉起流程和后台配置页。
3. 业务支付编排层
这层仍然在项目里,但职责不是“解支付协议”,而是:
- 决定当前订单该拆成几笔
pay; - 决定能不能余额支付、能不能押金抵扣、能不能部分支付;
- 决定支付成功之后业务对象怎么变化。
4. 渠道协议层
这层才是各支付渠道自己的 SDK 交互,例如:
- 微信预下单;
- 微信回调解密;
- 微信查单、关单、退款;
- 其它支付机构的下单和状态查询。
这一层应该放进 payClazz,而不应该散在页面、controller 或项目自己的 service 里。
taicang 当前是怎么做的
taicang 基本就是这套分层的标准样板。
1. 依赖层直接接入 oak-pay-business
src/configuration/dependency.ts 直接声明了:
oak-general-businessoak-pay-business
这一步的意义不是“安装一个库”,而是把支付域对象和运行时能力纳入 Oak 依赖体系。
2. 初始化阶段合并支付域能力
taicang 在初始化时并没有自己重写支付流程,而是把公共支付域能力接入当前 Oak 运行时。需要区分当前模板和历史文件:
- 新项目先在
src/configuration/dependency.ts声明oak-pay-business,再执行project:init、make:domain和make:dep - 当前模板的前端运行时主入口是
src/initialize.ts -> src/initialize.server.ts - 应用启动后继续执行
src/initializeFeatures.ts,web 端可按需追加src/initializeFeatures.web.ts - 老项目里如果还保留
src/initialize.frontend.ts,通常只是历史 DebugConnector 场景,不应再当作纯前台模式入口复制
这里合并的真实内容包括:
- 前端运行时:
checkers / common / render / features - 后端运行时:
aspects / checkers / triggers / watchers / timers / data / ports / routines
同时,taicang 的前后端 RuntimeContext 都继承各自的 generatedBackend / generatedFrontend。生成文件从 oak-pay-business 原 RuntimeContext 入口取得具名 module,并按 oak-general-business -> oak-pay-business -> 项目 的依赖顺序构造最终 Context:
src/context/BackendRuntimeContext.tssrc/context/FrontendRuntimeContext.ts
这一步非常关键。项目不应再手工直继承支付库的 RuntimeContext,也不需要直接依赖 pay 已经传递依赖的 general Context;具体 layer/module 写法和冲突规则见上下文。很多项目失败就失败在这里只是“引了组件”,却没有把支付域运行时真的接进来。
3. 实体层复用公共支付模型
taicang 没有自己另起一套 paymentOrder、paymentRecord、wallet 模型,而是直接扩展公共实体:
src/entities/Order.ts继承@oak-pay-business/entities/Ordersrc/entities/System.ts继承@oak-pay-business/entities/Systemsrc/entities/Ship.ts继承@oak-pay-business/entities/Shipsrc/entities/Supplier.ts使用Account、WithdrawAccount
也就是说,订单支付状态机、系统支付配置、账户与提现账户这些核心模型,在 taicang 里本质都沿用了公共支付域。
4. 项目只在业务差异点上扩展
taicang 真正自己补的,是那些明显不属于通用支付域的规则:
- 号牌押金能否抵扣;
- 号牌押金在支付成功后如何返还或消费;
- 拍卖业务下订单完成后如何生成后续结算计划;
- 小程序确认收货时如何结合号牌、物流和支付元数据继续推进业务。
这些逻辑主要落在:
src/utils/spPlate.tssrc/aspects/spPlate.tssrc/triggers/pay.tssrc/triggers/order.ts
这就是正确的边界。通用支付域负责支付本身,项目触发器负责“支付成功后这门生意该怎么走”。
oak-pay-business 真正负责什么
如果要正确设计项目支付系统,先要知道 oak-pay-business 已经帮你做了什么。
1. 它已经提供了完整支付主模型
核心对象至少包括:
PayRefundDepositAccountWithdrawWithdrawTransferSystem.payConfigOfflineAccountWpAccountWpProductSettlementSettlePlan
这些对象不是孤立存在的,而是已经通过 checker、trigger 和 watcher 形成了一条可运行的支付状态机。
2. 它已经提供了支付状态推进机制
项目真正发起支付时,正确路径不是“页面调用 SDK 成功后自己改状态”,而是:
- 创建
pay - 执行
startPaying - trigger 在
before阶段调用渠道prepay - 回调或 watcher 再把
pay推进到paid / closed / refunding / refunded - trigger 再把
order、deposit、account、sysAccount往前推进
这套状态推进主要由这些文件负责:
src/checkers/order.tssrc/checkers/pay.tssrc/triggers/pay.tssrc/watchers/pay.tssrc/utils/pay.ts
3. 它已经提供了渠道抽象
真正和支付机构打交道的,不应该是页面,也不应该是项目 controller,而应该是 payClazz。
oak-pay-business 当前默认已经内建:
accountofflineAccountwpProductapProductepProductspProductcpProductwfProductppProduct
并通过下面这些入口暴露扩展点:
registerPayClazz(...)registerFrontendPayRoutine(...)registerPayChannelComponent(...)
其中 ap/ep/sp/cp/wf/pp 的前端选择与跳转当前只在 Web 环境启用。新项目只有在接入这些内建合同之外的支付机构时,才需要扩展渠道层。
4. 它已经提供了后台和前台现成组件
最常直接复用的组件包括:
payConfig/systemorder/paypay/detailpay/listrefund/listaccount/detaildeposit/newwithdraw/createsysAccount/survey
因此一个新项目的正确思路,不是自己重搭管理后台,而是优先复用这些组件,再在项目层补壳。
一个项目的支付系统应该怎么分对象
这部分最容易设计错。下面是推荐的对象分法。
1. 订单对象只负责业务订单
订单对象应该表达的是:
- 买了什么;
- 应付多少钱;
- 已付多少钱;
- 已退多少钱;
- 当前处于待支付、支付中、已支付还是退款中。
订单不应该自己塞一堆渠道协议字段,也不应该自己承担回调验签逻辑。
正确做法就是像 taicang/src/entities/Order.ts 一样,继承支付域 Order,然后只补业务字段。
2. 支付对象只负责一次资金动作
Pay 是一笔支付分录,不一定等于一个订单。
一个订单可能拆成多笔 pay:
- 一笔账户余额支付;
- 一笔外部渠道支付;
- 甚至多笔不同外部渠道支付。
因此项目不要把“订单”和“支付单”混成一个对象。真正的组合支付能力,就是靠订单下挂多笔 pay$order 实现的。
3. 系统对象统一承载支付策略
充值和提现手续费、系统可用渠道、系统资金账户这些内容,应该挂在 System,而不是挂在订单、用户或某个页面配置表里。
这一点 oak-pay-business/entities/System.ts 已经定好了,项目继续扩展这个对象即可。
4. 渠道对象统一承载支付机构配置
推荐分成两层:
- 支付账号,例如
WpAccount、OfflineAccount - 支付产品,例如
WpProduct
这样好处非常大:
- 一个系统可以有多个支付产品;
- 多个应用可以挂到不同支付产品;
- 账号配置和产品配置分离;
- 前端可以根据当前应用自动算出可用渠道。
一个项目的支付系统应该怎么分流程
1. 下单和选择支付方式
页面只负责收集支付方案,不直接触发第三方支付。
推荐做法是:
- 订单页里挂一个支付方案组件;
- 这个组件只回传
pay$order创建数据; - 父组件再调用
order.startPaying。
taicang 的前台订单详情页就是这样做的:
- 自定义组件
src/components/pay/index.ts负责拼pay$order - 页面
src/pages/frontend/order/detail/index.ts负责执行startPaying
2. 真正拉起支付
真正外部支付不应在订单页直接完成,而应该切到 pay/detail 或复用它的内部能力。
因为支付详情页已经内建:
- 渠道可支付性判断;
- 小程序
wx.requestPayment(...); - 微信网页
chooseWXPay; - 线下支付展示;
- 支付失败后的关闭或回退策略。
taicang 就是在 createOrderPay() 后跳到 /pay/detail 来完成外部支付。
3. 回调和补偿
异步通知应该只做一件事:把请求交给支付域公共处理逻辑。
taicang 的做法非常标准:
src/endpoints/wechatPay.ts里暴露项目 endpoint- 内部直接调用
@oak-pay-business/utils/pay的payNotify / refundNotify
这一步不要自己重写支付状态推进,否则后面的 watcher 和 trigger 会越来越难协调。
4. 支付成功后的业务动作
支付成功本身只是资金域事件,不应该直接写死在公共包里。
项目应该在自己的 triggers/pay.ts 或 triggers/order.ts 里处理:
- 这笔钱对应哪个业务对象;
- 押金怎么消费或返还;
- 订单之后如何结算;
- 是否要创建物流或确认收货流程。
taicang 正是把这部分放在项目 trigger 里,而不是改 oak-pay-business 本体。
新项目最小落地清单
这是最值得直接照抄的部分。一个新项目要接 oak-pay-business,最少应该做下面这些事。
1. 依赖与实体
- 在
dependency.ts里加入oak-pay-business - 让项目的
System继承支付域System - 让项目的
Order继承支付域Order - 如果有账户或提现需求,接入
Account、WithdrawAccount
2. 初始化与上下文
- 在
dependency.ts里声明oak-pay-business,执行project:init、make:domain和make:dep - 创建并注入
payfeature,当前模板由生成的initialize.server.ts处理 - 调用
initializeOpb1Features(...) - 让前后端 RuntimeContext 继承
make:dep生成的 Context;由 pay 的 module 递归带入 general Context
3. 系统配置后台
- 直接挂
payConfig/system - 让运营可以配置:
System.payConfigOfflineAccountWpAccountWpProduct
- 如果需要,再挂
sysAccount/survey
4. 订单支付前台
- 准备一个订单支付方案组件
- 由它生成
pay$order - 父组件执行
order.startPaying - 外部支付统一进入
pay/detail
5. 支付回调
- 项目里暴露自己的 endpoint 路由
- 内部复用
oak-pay-business/utils/pay - 至少接上:
payNotifyrefundNotify
6. 业务 trigger
- 把支付成功后的业务差异逻辑写在项目自己的 trigger 里
- 不要直接修改公共支付域的主状态机
什么时候应该扩展 oak-pay-business
并不是每个项目都要去注册新渠道。
不需要扩展的场景
如果项目只需要:
- 账户余额支付;
- 线下收款码或银行转账;
- 微信支付;
- 标准充值、退款、提现;
那么大多数情况下:
- 公共实体够用;
- 公共
payClazz够用; - 公共前端支付 routine 也够用。
这时项目只需要做接入和业务 trigger,不需要扩展渠道层。
需要扩展的场景
如果项目要接:
- 新支付机构;
- 新的支付产品实体;
- 新的后台支付配置页;
- 新的前端拉起支付流程;
- 新的系统资金账户展示;
才需要用这些扩展点:
registerPayClazz(...)registerFrontendPayRoutine(...)registerPayChannelComponent(...)registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
推荐的接入顺序
实践里,最稳的顺序通常是:
- 先接实体和初始化
- 再接系统支付配置后台
- 再接订单支付前台
- 再接支付回调
- 最后补业务 trigger 和补偿规则
不要一上来先写页面。只把页面做出来,是最容易形成“能点支付但状态不对、回调不进、资金不平”的假接入。
taicang 最值得复用的经验
最后把最值得照抄的经验直接列出来。
1. 公共支付域和业务规则边界划得很清楚
- 公共支付域负责支付本身;
- 项目 trigger 负责拍卖押金和订单结算。
2. 页面不直接持有支付协议
- 页面只创建
pay$order - 真正支付在
pay/detail里拉起 - 回调在 endpoint 里统一处理
3. 项目扩展点只落在必要位置
- 需要自定义前台支付交互时,包一层本地组件
- 需要业务差异时,写本地 aspect / trigger
- 不去改公共支付域主链路
4. 小程序特殊规则不污染主状态机
像“确认收货后到账”这种微信小程序特性,在支付域里通过 needReceiving、ship 和前端确认收货流程承接,而不是把订单和支付状态机写乱。
最后再强调一次
一个 Oak 项目的支付系统,正确目标不是“把支付接口调通”,而是把下面这五件事一起接完整:
- 资金对象模型
- 状态推进机制
- 支付渠道抽象
- 回调与补偿
- 项目自己的业务后处理
taicang 已经证明,这套方式是能跑通复杂业务的。新项目最不应该做的,就是绕开 oak-pay-business 重新做一套平行支付系统。那样前期看似快,后期几乎一定会在退款、补偿、回调、对账和系统资金上吃大亏。
结算
如果说 Order -> Pay -> Refund 解决的是“用户的钱怎么进来、怎么退回去”,那么 SettlePlan -> Settlement 解决的就是“这笔订单最终怎么结给内部账户或合作方账户”。
这块能力在很多项目里往往被放到单独的财务系统里,但 oak-pay-business 已经把它纳入同一套 Oak 模型:订单付款完成之后,可以继续生成结算计划,到时间后自动结算,并把金额记入目标账户。
主要对象
SettlePlan
src/entities/SettlePlan.ts 定义了:
whenorderpricesettledAtclosedAt
状态包括:
unsettledsettledclosed
动作包括:
settleclose
Settlement
src/entities/Settlement.ts 定义了具体结算明细:
accountplanpriceoperssettledAtclosedAt
状态同样包括:
unsettledsettledclosed
动作也同样是:
settleclose
可以把它们理解成:
SettlePlan代表“这张订单什么时候结、总共结多少”Settlement代表“这次结算实际要分到哪些账户、各是多少钱”
组件
这块能力当前和前面几章不一样:oak-pay-business/src/components 里并没有额外提供 settlePlan / settlement 的专用前端组件。
这并不代表功能不完整,而是说明它当前更偏向:
- 用实体动作驱动
- 用 trigger / watcher 自动推进
- 页面层由具体业务项目自己组合
所以如果你的项目需要结算管理页,一般是在项目层基于 settlePlan、settlement 这两个实体自己拼页面,而不是直接从公共包里拿成品组件。
真实项目里的页面拆法
虽然公共包没有现成结算组件,但 haina-busi 和 taicang 已经把两种典型接法跑出来了。
1. taicang:面向前台用户的“待结算拍品页”
taicang/src/pages/frontend/settlement/web.tsx 的做法是:
- 页面层只负责登录判断和 tabs 切换
- 真正的待结算主体交给项目组件
components/spBid/settlement
也就是说,前台场景通常不是直接展示 settlePlan / settlement 明细,而是先围绕“还有哪些待结算业务对象”组织页面。
2. haina-busi:面向后台财务/运营的计划页和明细页
haina-busi 则单独做了两类项目组件:
components/squareBusiness/settlePlan/listcomponents/squareBusiness/settlement/list
它们已经把最常见的后台视角做出来了:
- 按
settled / unsettled / closed切页签 - 按账户、机房、组织等业务维度筛选
- 展示
payAt、when、settledAt、closedAt - 展示比例、分账方向、目标节点等业务字段
这很值得参考,因为它说明了公共包的真实边界:
- 状态推进与记账逻辑在公共包
- 财务管理 UI 在项目层
后台规则
SettlePlan checker
src/checkers/settlePlan.ts 是这条线最关键的第一道约束。创建时会检查两件事:
settlement.price总和必须等于settlePlan.pricesettlePlan.price不能超过订单当前可结算金额
这里的订单可结算金额,源码里实际按下面这条式子算:
order.paid - order.refunded - order.settlePlanned
这就保证了:
- 不会超额结算
- 同一订单可以拆多个结算计划,但总额不会越界
SettlePlan trigger
src/triggers/settlePlan.ts 把整条结算流程串了起来。
1. 创建后更新订单的 settlePlanned
每创建一笔 settlePlan,都会把对应订单的 settlePlanned 累加上去。
2. 执行 settle 时自动结算所有 settlement
settlePlan.settle 的 before trigger 会遍历所有未结算的 settlement$plan,对每条结算明细执行:
settlement.settle
同时创建关联的 accountOper,把金额记入目标账户。
3. settle 后更新订单的 settled
结算计划成功执行后,会把订单的 settled 累加上去。
4. 执行 close 时自动关闭所有结算明细
如果结算计划被关闭,关联的未结算 settlement 也会一起执行 close。
5. close 后回退订单的 settlePlanned
关闭结算计划后,订单上预留的 settlePlanned 也会被减回去。
SettlePlan watcher
src/watchers/settlePlan.ts 负责自动执行到期结算。
当前规则是:
- 当
settlePlan.iState === 'unsettled' - 且
when <= now
就自动执行:
settlePlan.settle
也就是说,项目层只要创建好计划并设置时间,后面到点后的执行可以继续交给后台 watcher。
when 可以不填,交给项目动作决定何时结算
这点很容易被忽略。when 虽然是结算计划里最显眼的字段,但它不是必须总要有值。
taicang/src/triggers/ship.ts 就给了一个非常典型的例子:
- 当订单关联的
ship全部确认收货后 - 只对
when不存在的settlePlan执行settle
也就是说,项目完全可以把结算计划分成两类:
- 有
when的,交给公共 watcher 定时结算 - 没
when的,由项目自己的业务 trigger 在某个动作点触发结算
这个模式对“收货后结算”“验收后结算”特别有用。
和订单的关系
结算这块最容易理解错的地方是:settlePlan 并不是附着在 pay 上,而是附着在 order 上。
因此它和订单的几个金额字段直接联动:
paidrefundedsettlePlannedsettled
实际开发里,更推荐把“什么时候结算、结算给谁”都收敛在 order 维度,而不是分散到每一笔 pay 上。
实体适配要求
这章如果只写流程,不写实体适配,很容易误导新手。真实项目里,结算实体往往都会继续扩。
haina-busi 的扩展方式
haina-busi 直接在公共实体上继续加了很多财务字段:
SettlePlan
在公共 SettlePlan 之上补了:
roomRevenuesystemRevenueplatformRevenueinstallmentscurrentInstallmentisManual
Settlement
在公共 Settlement 之上补了:
orgSettlementscaletypeaccountSplitErrorsorderoperEntitys
这说明公共实体给的是结算主骨架,复杂平台型项目通常还会补:
- 分账类型
- 分账比例
- 多级组织结算关系
- 金额误差与操作追踪
taicang 的扩展方式
taicang 就轻很多。它至少把:
Settlement.order
这条关系补进去了,方便前台和后台都能直接沿订单维度取结算数据。
因此项目层在适配结算实体时,至少要先想清楚两件事:
- 你是否需要在
settlement上直接回查order - 你是否需要额外的分账类型、比例、组织归属字段
项目中如何接入
1. 先确定结算账户模型
Settlement 最终会把钱记到 account 上,所以项目在使用前,应该先明确:
- 哪些业务对象有
account - 订单应该结给哪个
account
2. 创建结算计划时把明细一起带上
最推荐的写法是创建 settlePlan 时,就同时创建 settlement$plan,让 checker 当场验证总额。
2.1 taicang 的真实分账构造方式
taicang/src/utils/order.ts 已经给了一个非常完整的公共实体用法样例。它创建一条 settlePlan 时,会同时生成三条 settlement$plan:
- 商家的佣金部分
- 系统抽成部分
- 商家拿到的剩余部分
也就是说,项目层完全可以把“怎么拆金额”这件事收敛到一个 util 里,然后继续让公共 checker / trigger / watcher 去兜底状态推进。
这种分层很推荐:
- 项目 util 决定“金额怎么拆”
- 公共包决定“计划怎么校验、怎么记账、怎么推进”
3. 到期自动结算交给 watcher
如果结算时间是确定的,直接设置 when 即可,让 watchers/settlePlan.ts 自动推进。
4. 页面层自己组合
由于公共包目前没有专门的结算页组件,项目层一般会自己做:
settlePlan列表settlement明细查看- 手工
settle/close按钮
但后台状态推进逻辑最好继续复用公共包,不要自己重写。
4.1 haina-busi 自定义列表组件里常见的参数
如果你要参考项目层怎么拼 UI,haina-busi 这两组组件已经很有代表性:
squareBusiness/settlePlan/list
关键参数包括:
machineSystemIdsettlementStateopenonCancel
它本质上是“某个业务范围内的结算计划列表”。
squareBusiness/settlement/list
关键参数包括:
accountIdentitymachineSystemIdsettlementState
并且当 entity='organization' 时,它还额外区分:
organizationType='system' | 'room'
也就是说,项目层如果要做财务后台,通常不是简单列 settlement,而是要按账户、组织层级、业务域再包一层组件。
使用示例
1. 创建结算计划与结算明细
await context.operate('settlePlan', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
orderId,
when: Date.now() + 24 * 60 * 60 * 1000,
price: 10000,
settlement$plan: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
accountId: sellerAccountId,
price: 7000,
},
},
{
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
accountId: partnerAccountId,
price: 3000,
},
},
],
},
}, {});
这里 7000 + 3000 === 10000,否则 checkers/settlePlan.ts 会直接拒绝这次创建。
2. 手工执行结算
await context.operate('settlePlan', {
id: await generateNewIdAsync(),
action: 'settle',
data: {},
filter: {
id: settlePlanId,
},
}, {});
执行后,关联 settlement 会一起变成 settled,并且目标账户会收到对应的 accountOper。
使用建议
结算这块最推荐的做法是:
- 把结算计划和结算明细一开始就建完整
- 用
settlePlan管总额和时间,用settlement管分配结果 - 让 watcher 负责到期自动执行,让 trigger 负责账户记账
再补四条在真实项目里非常重要的注意事项:
settlement$plan.price总和必须始终等于settlePlan.price,这是公共 checker 的硬约束,不是建议。- 只要要落到账,就必须先把目标
account体系准备好,否则 trigger 在记accountOper时就没法闭环。 - 如果你准备扩
SettlePlan/Settlement,尽量像haina-busi那样“在公共实体上继续加字段”,不要破坏公共主字段和动作语义。 when不是必填,它可以代表“定时结算”,也可以完全留空,让项目自己的ship、order、tradetrigger 来决定何时结算。
如果把这些逻辑拆到项目层零散页面或财务脚本里,很快就会出现订单金额、结算计划金额和账户流水三边对不上的问题。
框架更新日志
Oak Framework Changelog
按库、按版本、按 tag 区间记录框架变化
更新时间:2026-07-24。本章只记录会影响应用开发、发布、构建、升级和运行时行为的框架包变化。
package.json 当前版本、最新 tag 和上一个 tag,并结合 git diff --name-only、git diff --stat、git log --no-merges 汇总。这里不是只按提交标题写摘要。
快速入口
版本索引
| 包 | 当前 package | 最新 tag | 本章覆盖区间 |
|---|---|---|---|
oak-domain | 6.0.1 | 6.0.0 | 5.1.36..6.0.0、6.0.0..HEAD |
Oak Assistant | 3.5.0 | v3.5.0 | 3.0.0..3.5.0 |
oak-cli | 5.0.6 | 5.0.1 | 5.0.0..5.0.1、5.0.1..HEAD |
oak-db | 4.0.2 | 4.0.1 | 4.0.0..4.0.1、4.0.1..HEAD |
oak-backend-base | 5.0.1 | 5.0.0 | 4.1.29..5.0.0、5.0.0..HEAD |
oak-frontend-base | 6.0.4 | 6.0.3 | 6.0.1..6.0.3、6.0.3..HEAD |
oak-general-business | 6.1.0 | 6.0.0 | 5.11.2..6.0.0、6.0.0..HEAD |
oak-pay-business | 4.1.0 | 4.0.0 | 3.5.1..4.0.0、4.0.0..HEAD |
oak-common-aspect | 4.0.3 | 4.0.2 | 4.0.1..4.0.2、4.0.2..HEAD |
oak-memory-tree-store | 4.0.2 | 4.0.1 | 4.0.0..4.0.1、4.0.1..HEAD |
oak-external-sdk | 3.0.2 | 3.0.1 | 3.0.0..3.0.1、3.0.1..HEAD |
oak-internal-sdk | 1.1.5 | 1.1.4 | 1.1.3..1.1.4、1.1.4..HEAD |
oak-ui | 0.1.0 | 无 tag | 当前仓库状态 |
最近 14 天仓库审计
审计窗口为 2026-07-10 00:00 至 2026-07-24,先对所有 oak-* Git 仓库执行 fast-forward pull,再读取提交、变更文件、公开类型和测试。提交数量只用于确认审计覆盖,不直接代表功能数量。
| 仓库 | 提交数 | 审计结论 |
|---|---|---|
oak-assistant | 0 | 旧扩展仓库没有新提交;当前用户应安装 oak-team.oak-assistant-new。 |
oak-assistant-new | 56 | 3.0 - 3.3.2 完成实体、render props、WXML、Less、i18n 与调试语言服务;审计窗口后的 3.4.0 - 3.5.0 见独立更新日志。 |
oak-backend-base | 2 | 最近提交是切换 Oak CLI 编译和 render 检查;运行时新增能力已在该库既有未发布区间记录。 |
oak-book | 7 | 平台、独立项目和新手组件树文档集中更新。 |
oak-cli | 97 | WXML/LESS/render 编译器、按需 workspace、Desktop、Native、CDN、MP polyfill、独立项目模板是主要变化面。 |
oak-common-aspect | 1 | 构建链切换与 relation select 结果断言,没有新增业务入口。 |
oak-db | 1 | 数据库驱动改为优先从消费应用根解析。 |
oak-domain | 10 | compiler plugin、Desktop metadata/路由、命名 locale workspace 和无业务依赖上下文。 |
oak-external-sdk | 0 | 无新提交。 |
oak-frontend-base | 13 | Desktop web runtime、离线 locale/外观、pull-to-refresh 与 render 类型推导。 |
oak-general-business | 23 | Desktop Application 解析、Native 认证 render,以及严格 render/XML/Less 迁移。 |
oak-internal-sdk | 3 | connector 原始错误日志脱敏和测试补充。 |
oak-matrix | 10 | 部署计划、Git 同步和 Native/Desktop 参考实现;属于消费应用示例,不作为框架公共 API。 |
oak-memory-tree-store | 2 | toolkit、事务字面量字段清理和版本同步,没有新增公开方法。 |
oak-pay-business | 16 | 严格 render/XML/Less 迁移、过滤列表 product 草稿初始化,以及移除不支持的 Alipay 独立退款 callback。 |
oak-skills | 171 | 代理知识库持续同步;用于核验线索,不作为运行时能力计数。 |
oak-test | 0 | 无新提交。 |
oak-tutorial-todo | 10 | Group/Todo 组件树和 Web、MP、Electron、Native 教程参考项目;本地仓库暂无 origin。 |
oak-vite-example | 0 | 无新提交。 |
阅读建议
Breaking Changes
Upgrade Risk Map
先看会破坏构建、发布或运行语义的变化
普通 bugfix 和内部重构不放在这里;这里只列需要升级负责人主动处理的框架行为变化。
oak-domain 6.0.1 未发布:生产依赖实体必须能从声明和值合并解析
依赖实体解析已经支持 .d.ts + .js 合并,不再要求第三方包发布 src。生产包应发布 es/entities/*.d.ts、es/entities/*.js,以及这些声明依赖到的必要类型声明。
解析顺序是 es/entities -> lib/entities -> src/entities。lib/entities 只是历史兼容回退,未来包可以只保留 es。
oak-domain 6.0.1 未发布:同名实体覆盖必须兼容旧结构
依赖包如果重新定义了默认实体,例如 User、UserEntityGrant、System,编译器会用新实体覆盖已有定义。
覆盖实体必须兼容旧实体的字段、动作、状态、关系、索引、ActionDef、entityDesc.locales 和 style,否则旧逻辑会在权限、checker、trigger、类型生成或 UI 展示中出现不可预测问题。
oak-domain 6.0.1 未发布:Decimal / Price 字段改为字符串精度语义
Decimal<P, S> 和 Price 现在按字符串保存和传递精度,过滤类型也补充了 Q_DecimalValue。业务代码里直接做 +、-、*、/ 或把 decimal 字段传给只接受 number 的格式化函数,可能出现字符串拼接、精度丢失或类型错误。
升级时应把 decimal / price 的计算改成 decimal helper 或显式转换;写入、过滤和 update expression 使用字符串值或 $expr 包装。
oak-domain 6.0.0:编译器输出和依赖初始化规则调整
6.0.0 重构了 schema、dependency、locale、router、tsc 等编译链路,并把依赖包 feature/init metadata 作为发布包元数据处理。
旧项目如果依赖本地 src 或手写初始化拼接,需要按 project:init、make:domain、make:dep 的顺序重新生成,并检查 feature 初始化入口。
oak-db 4.0.2 未发布:migration 不再默认生成物理外键
schema migration 已移除新建表物理外键 SQL,并兼容历史 MySQL 库里从未实际建过外键的情况。
如果项目以前依赖数据库层外键兜底一致性,需要把约束迁移到 Oak 层:checker 做写入前校验,trigger 做级联处理,migration plan 审核结构变化,引用字段和索引负责查询性能。
同一版本里,index.config.unique 也不再生成数据库 UNIQUE INDEX。它现在只表达 Oak 框架层语义,历史唯一索引可能在迁移收敛时被重建为普通索引。需要强唯一约束的业务必须补 Oak checker 或专门的数据库迁移。
oak-cli 5.0.5:依赖实体、render/XML/Less、Desktop 和小程序路由语义收紧
make:domain 的第三方实体解析顺序和 oak-domain 对齐为 es/entities -> lib/entities -> src/entities。发布包缺少 es/entities 产物时,生产项目不应靠发布 src 补洞。
Oak 包的编译 transform 改为优先读取 package.json 中的 oak.compiler.transform,业务模块和 feature 初始化也应迁移到 oak.business.*。旧项目如果长期依赖 extraOakModules 或旧 metadata 字段,需要把共享包元数据补齐,避免不同项目重复维护硬编码转换列表。
Vite Web 不再默认 external React、ReactDOM、FingerprintJS、BN,也不会隐式注入 CDN。需要 CDN 的项目必须在 oak.config.ts 显式配置内置 CDN 插件;没有配置时这些依赖会进入 bundle。
小程序构建引入 route map 静态改写,Oak navigator 继续以 canonical route 为准。历史代码里手写生成产物路径、混用 namespace 路径和业务路由的地方,需要改回框架路由入口。
页面、组件和 namespace 的 index.config.ts 会驱动 web router、namespace config 和小程序虚拟 JSON 输出。升级时不要继续手工改 allRouters.ts、allNamespaceConfigs.ts 或生成后的页面 JSON,这些文件会在下一次构建中被覆盖。
当前应用模板的 build:es 默认启用 --enable-xml-check --emit-injection-types --check-style-less。传统 TSX render 的 props 由编译器从 index.ts 推导;小程序 XML 与 Less Module 也进入正式检查。升级后出现的未声明 property、错误事件、缺失 class、嵌套样式作用域或 portal 可达性错误,应回到组件 properties/formData/methods 与真实样式结构修复,不能继续用手写 WebComponentProps、空规则或宽泛类型绕过。
Desktop 工作区现在覆盖 Tauri 与 Electron,并区分 web platform 和 renderer。应用识别继续使用 web application type,再通过 runtime/renderer metadata 区分桌面壳;不要新增一个自定义 desktop application type 来绕过当前解析合同。
新应用默认只创建 Web workspace。旧脚本或教程如果假定新项目必然存在 wechatMp、native,应改为先执行 oak-cli add mp|rn|desktop。小程序 Node polyfill 也不再默认带入完整 crypto / assert 链;项目显式恢复后必须重新检查主包体积和真机启动。
oak-backend-base 5.0.1 未发布:connector-backed free endpoint 上下文语义变化
普通 free endpoint 仍然使用 makeContext(undefined, headers),不会自动继承调用方 application、token、user 或 rootMode。
声明 useConnector: true 的 free endpoint 会把 connector 解析出的 oak-cxt 传给 contextBuilder;如果请求没有 oak-cxt,现在会用 {} 初始化上下文,而不是走 undefined 初始化路径。依赖 initialize(undefined) 开 root 或匿名默认状态的旧 SSE / free endpoint,需要复核是否应该继续使用 useConnector。
同一版本里,start / stop routine 可以动态注册和注销 trigger、checker、watcher、timer。动态注册名仍必须唯一,升级时应检查自定义 routine 是否可能重复注册或忘记在 stop routine 中注销。
oak-frontend-base 6.0.4 未发布:web 初始化、route access 和小程序路由进入运行时边界
Web 初始化现在可以接收 options object,并在标准树中统一挂载 RouteAccessProvider、NamespaceConfigProvider、AntD / AntD Mobile provider 和 Oak theme shell。升级时应把 CLI 生成的 namespaceConfigs、oakTheme、renderLoading、AntD provider props 等放进 initialize(...) options,避免应用侧再包一层全局 provider 导致配置或上下文分裂。
route.access.operation 的 checker 范围字段是 target.checkerTypes,不是旧文档里出现过的 paths。依赖 console 上下文的规则使用 $context.entity / $context.entityId 时,如果当前 namespace 的 features.console.contextEntities 不允许该上下文,会返回 contextMismatch,菜单可见性和页面渲染都应按同一范围判断。
小程序 route map、subpackage route mapping、namespace route 语义和 tabBar 判断进入运行时。显式 route map 存在时,缺失路由不再自动猜测 /pages.../index;switchTab 会丢弃 query / state 并输出 warning。直接调用裸 wx.*、手写生成产物路径、或混用 namespace path 与业务 canonical route 的旧代码需要复核。
pageHeader2 已移除,继续导入它会构建失败;改用 @oak-frontend-base/components/pageHeader。ListPro 仍使用 Oak 自有 Pagination,并断言不接受 tablePagination;共享分页布局和文案调整应改 components/pagination。
cache.callSSEEndpoint(...) 只在 connector-backed 运行时可用,默认会带当前前端上下文,ignoreContext: true 才不传;DebugConnector.callSSEEndpoint() 仍会抛出不可用错误。本地 debug 场景不要假设它能模拟 SSE endpoint。
Oak 自有组件主题现在走 oakTheme / features.theme / --oak-* CSS 变量。AntD / AntD Mobile provider props 是三方 UI 库配置入口,不应继续用 AntD Mobile 的 adm cssVar 前缀当 Oak 主题契约。
oak-general-business 6.1.0 与 oak-pay-business 4.1.0 未发布:系统翻译和支付系统扩展会影响覆盖实体
通用业务包新增系统翻译、区域 locale、LocalizedContent 等能力;支付业务包扩展了 Waffo、Stripe、Epay、Creem、PayPal 等支付渠道,并适配 general-system。
如果业务项目覆盖 System、支付配置、支付产品或相关 action/state,必须保留旧结构兼容,尤其是系统翻译 action alias 和支付状态回滚语义。
oak-pay-business 4.1.0 会把支付、退款、账户、提现、结算、系统账户流水等金额列扩成 decimal(32,10),运行时代码也迁到 decimal 字符串 / helper 语义。旧业务代码如果继续把金额当 JS number 做加减乘除、比较或格式化,可能出现精度丢失、字符串拼接或类型错误。
当前支付包虽然提供多渠道 endpoint 文件,但 src/endpoints/index.ts 默认为空。升级项目必须在自己的 endpoint 索引中显式选择微信、支付宝、Epay、Stripe、Creem、Waffo、PayPal 等实际启用渠道;不能再依赖安装支付包后自动暴露回调路由。
4.1.0 的升级 SQL 分为新渠道表和金额列迁移:upgrade/4.1.0/accounts.sql 创建 Waffo、Stripe、Epay、Creem、PayPal 相关账号 / 产品 / 支付表,并手写物理外键;upgrade/4.1.0/priceDecimal.sql 修改历史金额列。生产库升级前需要确认执行顺序、历史金额单位、精度和项目自己的外键策略。
自定义支付渠道通过 registerPayClazz(...) 注册时会校验 product/account schema。账号实体必须提供 decimal price、decimal 费率字段、systemId、提现转账开关等字段;产品实体必须关联 application 并提供启用、税费、退款和收款配置字段。旧的自定义渠道如果只满足运行时类接口,升级后可能在注册阶段直接 assert。
Redirect 类支付只在 web 平台按当前 application 和 pay.meta URL 判定可用。Epay、Stripe、Creem、Waffo、PayPal 接入项目需要复核支付成功 / 取消 URL、notify URL、webhook secret、沙箱配置和前端 application 选择逻辑。
oak-general-business 6.1.0 的 System 需要 translation、translateState、translationError,以及 translate / translateSuccess / translateFail 动作。共享 components/system/panel 会投影翻译字段并挂载翻译页签;应用侧如果覆盖了 System 却没有同步这些字段,系统面板、翻译 trigger 和翻译定时任务都会出错。
6.1.0 的升级 SQL 会创建 localizedContent、areaLocale、userAuth、invite、inviteTouch、inviteRelation,并从 user 表删除旧的 nationality、idCardType、idNumber、idState 字段。已有实名认证数据不能只靠 schema upgrade 保留,升级前需要写清楚历史数据迁移和回滚策略。
旧的 @oak-general-business/components/user/authenticate 已移除,实名认证入口迁到 @oak-general-business/components/userAuth/upsert/index 和 userAuth 实体。消费项目里本地 /user/auth、/my/auth 或类似 wrapper page 需要同步改路径、节点实体和投影。
HumanVerify 如果配置为 enforce,账号登录、登录名注册、手机验证码、邮箱验证码会拒绝缺少 proof 或校验失败的请求。应用侧必须注册前端 provider bundle / acquire component,确认 oak-humanVerifyHost 被全局组件机制挂载,并保证后端 provider 配置可用;否则升级后登录和验证码链路可能被主动拦截。
registerUserByLoginName 现在注册成功后会直接创建 token 并进入登录态。旧代码如果依赖“注册后仍未登录”的流程,需要调整成功页跳转、token 状态和邀请归因清理逻辑。
通用业务包的主题能力已经迁到 oak-frontend-base。继续从 oak-general-business/features/theme、oak-general-business/types/Theme 或旧主题设置组件引入会失败;应用应改用 oak-frontend-base 的 features.theme、oakTheme、CSS 变量和主题设置组件。
多包同步:React/TypeScript/peer 版本需要整体升级
近期多个包同步了 React 19、TS6 参数、peer 依赖和 toolkit 替换 lodash。项目应按实际依赖整体对齐,否则可能出现类型通过但运行时依赖不一致的问题。
oak-common-aspect 4.0.3 把 oak-domain、oak-external-sdk 从普通依赖迁到 peer 依赖。消费项目或中间包如果以前靠 oak-common-aspect 间接带入这两个 Oak 包,升级后需要显式安装并对齐版本,否则 aspect 入口在构建或运行时可能解析失败。
oak-memory-tree-store 当前未发布变更同样把 oak-domain 迁到 peer 依赖,并把 lodash 工具入口切到 @oak-domain/utils/toolkit。消费项目需要显式安装 oak-domain ^6.0.1,自定义测试如果依赖事务节点上的 $txnId、$next、$path 等调试字段,也要按事务结束后字面量属性被 delete 清理的语义复核。
oak-external-sdk 3.0.2 顶层入口仍导出 SDK 单例,但 instance class 改为 type-only 导出;运行时代码如果从顶层解构 WechatMpInstance、WechatPublicInstance 等构造类会失效。WeChat externalRefreshFn 也从返回 token 字符串改为返回 { access_token, expires_in },自定义 token 刷新实现必须同步调整。
oak-internal-sdk 1.1.5 新增加密 SSE endpoint 支持,前端 callSSEEndpoint 和服务端 serializeSSEEndpointResult 需要配套升级。只升级一端时,SSE data: 可能被错误地当作普通 JSON 或加密 JSON 解析;自定义网关和 CORS 也要放行 oak-encrypted、oak-nonce 等响应头。
Oak Assistant 更新日志
3.5.0(2026-07-25)
复用组件的 Render Props
- 当本地
index.ts使用import OakComponent from '某个组件'; export default OakComponent;直接转发已有 Oak 组件时,同目录的标准 render 可以继承原组件对应平台 render 的 props,不必重新手写WebComponentProps。 - 本地源码组件会继续追踪到真正的
OakComponent({...})定义;已发布的依赖包则从web.d.ts、render.native.d.ts、render.desktop.d.ts等平台 render 声明读取合同。 - 编辑器支持继承字段的补全、hover、诊断和定义跳转。平台专用声明不存在时,会按 CLI 的平台规则回退,例如
web.pc -> web、render.android -> render.native、render.windows -> render.desktop -> web.pc -> web。 - 识别是有意收紧的:默认导入必须命名为
OakComponent,并由export default OakComponent直接导出。改名导入、二次赋值或动态包装不会被猜测为复用合同。
3.4.0(2026-07-24)
render.native.tsx、render.ios.tsx、render.android.tsx中默认导入的 Sass/SCSS Module 支持 class 补全、hover、定义跳转和错误诊断。- 插件使用 Dart Sass 解析变量、partial、
@use、import、alias 和 source map,并把可导出的 class 类型约束为 React Native 的ViewStyle & TextStyle & ImageStyle。
3.0.0 - 3.3.2(2026-07-18 至 2026-07-22)
实体 Schema 即时诊断
- 插件会发现当前 Oak 项目直接拥有的
src/entities/*.ts/*.tsx,即时检查 Schema 继承、属性、反向关系、Action、State、ActionDef、Relation、entityDesc、locale、index、多继承冲突和系统保留名称。 - 同一实体的独立错误会同时显示为
TS9300-TS9327,不再像领域生成器一样只报告第一个错误;分析器自身异常使用TS9399。 - 实体分析按 tsserver Program、项目根和版本缓存,多项目工作区不会把另一个项目的实体或 locale 混入当前项目。
- 这项能力只读源码,不修改生成目录;
npm run make:domain和正式构建仍是最终结论。
Render Props 与 Less Module
web.tsx、web.pc.tsx、web.mobile.tsx、render.desktop.tsx、render.native.tsx等标准 render 会直接使用同目录OakComponent推导出的精确 props。properties、data、formData、methods和 Oak 注入字段都能参与补全、hover、定义跳转和诊断;不再需要手写宽泛WebComponentProps。- 默认导入的
*.less会生成精确 CSS Module 类型,支持相对 import、tsconfig alias、node_modules、连字符 class 与 camelCase 访问。 - Less 作用域理解 compound selector、后代/子代/兄弟选择器、
:is、:where、:not、:local、:global、Fragment、条件 JSX 和本地 helper 调用链。只有在所有可达挂载位置都满足父选择器时,嵌套 class 才被视为合法。 - class hover 会显示真实 Less 声明,Ctrl+点击跳到定义;找不到 class 或挂载作用域错误时直接在 render 中显示诊断。
WXML/XML 语言服务
- 支持标签、原生属性、事件、微信指令、
usingComponents、componentGenerics、模板 import/include、资源路径、Rainbow Tags 与格式化。 - 对标签配对、
wx:if/elif/else、wx:for词法作用域、wx:key、class、资源、事件方法和组件属性执行诊断。 - 虚拟 TSX 会保留循环项、dataset、事件和组件 props 的真实 TypeScript 类型,并把补全、hover、定义和诊断映射回 XML/WXML。
- Oak 组件与微信原生
Component会被区分;只有 Oak 组件获得oakPath、oakId等 runtime props。 - 插件使用 VSIX 内置的 Oak CLI 编辑器 runtime,不读取项目旁边的 CLI checkout、全局 CLI 或
OAK_CLI_ROOT,从而保证扩展发布版本的行为可复现。
i18n 检查与跳转
- 标准 render 的
t(...)、OakComponent 的this.t(...)和 XML/WXML 的t(...)使用同一套检查。 - 支持组件 locale、
common::key和entity:key;缺失 key、动态 key 和 placeholder 参数问题会显示 warning。 - hover 会逐行显示已有语言文本,静态 key 可以直接跳到对应 JSON 字段;动态或缺失 key 不生成虚假链接。
调试和性能
oak-assistant.debug.enabled: true时,WXML 虚拟文件写入node_modules/.cache/oak-mp-debug,render、合同、Less 虚拟类型和 source map 写入node_modules/.cache/oak-render-debug。- 调试产物按最近 Oak 项目根组织,支持父目录多项目 workspace;这些文件不是源码,不应提交。
- 3.2.7 之后减少后台重复分析,并增加真实 VS Code Extension Host 性能测试,编辑器诊断不会无条件扫描整个父工作区。
常用命令
| 命令 | 用途 |
|---|---|
oak-assistant: Reload Metadata | 重新读取小程序组件 metadata 并刷新诊断 |
oak-assistant: Show Status | 查看项目、TypeScript、metadata 和组件发现状态 |
oak-assistant: Show Current Props | 查看当前组件属性的类型来源 |
oak-assistant: Pick Current Prop | 打开当前标签的属性选择与补全 |
oak-assistant: Toggle Component Tag Hover | 切换组件标签的完整属性 hover |
使用边界
js/ts.experimental.useTsgo。tsgo 下仍保留 metadata、原生组件和基础 XML 能力。npm run build、make:domain 和目标平台构建。完整安装与排错步骤见开发前必装:Oak Assistant。
oak-domain 更新日志
6.0.1 未发布(区间:6.0.0..HEAD)
- 编译器新增发布包实体解析模块,依赖实体入口按
es/entities -> lib/entities -> src/entities查找,并支持同名.d.ts + .js合并解析。 EntityDesc的第四泛型可以从声明文件继承枚举信息,覆盖字符串字面量联合、导入类型别名、enum、as const数组和keyof typeof CONST,例如Language = keyof typeof LANGUAGE_LABELS。ActionDef支持 runtime initializer、import alias 和本地 action/state alias bridge,发布包不再需要靠src提供 initializer 结构。- 新增
OakPackagemetadata 类型和读取规则:oak.business.module、oak.business.dependencies、oak.business.featureInit、oak.compiler.transform成为规范字段,旧的isLib、depLib、featureInit、needOakTransform只作为兼容回退。 LocalizedContent进入基础域,StorageDesc.localizedContent、SelectOption.localizedContent、BackendRuntimeContext.getLocale()和 selection/result rewrite 链路补齐,支持按当前 locale 自动注入翻译内容。Language扩展为zh_CN、en_US、es_ES、fr_FR、ar_SA、ru_RU,Q_FullTextValue.$language也改为复用Language。Decimal<P, S>/Price改为字符串语义,新增 decimal 工具函数和Q_DecimalValue,decimal 字段的 update expression 类型边界收紧,避免直接生成不支持的表达式类型。- 查询类型补齐
$xor、JSON 内部$and/$or/$length/ 点前缀字面量 key、Geo/SingleGeo字段级过滤,以及 Geo 表达式类型。 - locale 编译输出改为
src/data/i18n.json承载大数据,src/data/i18n.ts保持兼容 wrapper;tscBuilder会复制相对 JSON 运行时资产。 - routerBuilder 支持页面 / namespace 的
index.config,生成 web routemeta.title、titleI18nKey、access、namespaceKey、namespacePath,并识别web.mobile.tsx作为 web 渲染文件。 - routerBuilder 增加 Desktop renderer 路由发现;context 序列化与客户端环境元数据可携带 Desktop runtime / renderer 信息,供 application 解析区分浏览器与桌面壳。
- tscBuilder 增加程序化 compiler plugin:
build(pwd, args, plugins)接收TscCompilerPlugin[],插件的prepareProgram(context)可以替换 compiler host、rootNames,并映射或过滤 diagnostics;多个插件按顺序组合。watch program 与分析 Program 隔离,使用 plugin 时不会盲目复用旧 BuilderProgram。它是编译器 API,不等同于在oak.config.ts中填写任意 Vite 插件名。 - schema builder 支持显式配置,locale builder 支持命名 workspace,不再只固定扫描历史 web / wechatMp 目录。
- Connector 与跨端工具补齐 SSE / mini-program 路径:支持 connector-backed SSE endpoint、小程序 SSE fetch、socket scope 透传和轻量 URL/fetch 兼容层。
Connector增加makeEndpointUrl(path),SimpleConnector下沉通用 fetch 适配;小程序端 URL 改用轻量实现,移除旧 bundledwhatwg-url。- validator 拆分为
utils/validate/*,补充身份证、手机号、国籍、护照等独立入口;内部工具从 lodash 切到es-toolkit/utils/toolkit。 - 修复 update expression、
unset带点字面量 key、继承索引冲突、继承 enum locale 合并、runtime action def clone、自定义检查器 i18n 诊断等问题。
src/compiler/entitySource.ts、src/compiler/entityDescEnum.ts、src/compiler/schemaBuilder.ts、src/compiler/dependencyBuilder.ts、src/compiler/localeBuilder.ts、src/compiler/routerBuilder.ts、src/types/OakPackage.ts、src/entities/LocalizedContent.ts、src/utils/localizedContent.ts、src/utils/decimal.ts、src/utils/fetch/*、src/utils/url/lightweight.ts、test/*6.0.0(tag:6.0.0,区间:5.1.36..6.0.0)
- schema 编译支持多继承与 inherited metadata,继承来的动作、状态、索引、locale 和 style 会进入生成结果。
- dependency / feature 初始化输出重构,依赖包的初始化 metadata 下沉到发布包语义,不再依赖前端模式下的临时 init 文件。
- locale 编译支持依赖包发现和英文 locale 输出,为后续系统翻译能力打基础。
- tscBuilder 增加 alias emit / watch 支持,routerBuilder 增加 cache mode。
- 新增一批
customChecks,并补充 dependency feature 初始化说明文档。
src/compiler/dependencyBuilder.ts、src/compiler/schemaBuilder.ts、src/compiler/routerBuilder.ts、src/compiler/localeBuilder.ts、src/compiler/tscBuilder.ts、src/compiler/customChecks/*、docs/dependency-feature-init-metadata.md升级注意
src 来补编译器能力。应保证 es/entities 中同时有实体声明和值产物,并把声明依赖到的类型文件一起发布。ActionDef、entityDesc.locales 和 style。oak.business.* 与 oak.compiler.transform。旧字段仍兼容,但不要继续把通用业务包硬编码到 app 的 extraOakModules 或编译器分支里。oak-cli 更新日志
5.0.6(区间:5.0.1..HEAD)
- 后端新增 esbuild server runtime bundle 链路:模板提供 opt-in
build:bundle,输出dist/server.js、dist/package.json、dist/server-metafile.json、dist/target/*、dist/configuration/*和dist/oak-packages/*,Oak 包按package.json.oak.package元数据镜像,不再硬编码包名。项目自己的pm2.*.json按原名复制,不再生成固定pm2.prod.config.json。 - server bundle 会保持根级
configuration/*.json为运行时外部配置,生成的server.js会设置NODE_ENV、OAK_PLATFORM=server、OAK_SERVER_RUNTIME_DIR,并支持本地initialize/db:upgrade:plan命令;watch 重启、CORS 中间件顺序、nginx 后 socket public URL 和 connector-backed SSE endpoint 序列化也同步修复。 make:domain解析第三方实体时与oak-domain对齐为es/entities -> lib/entities -> src/entities,其中lib是历史兼容回退。- Oak 包编译 transform 改为读取发布包元数据,规范字段是
oak.compiler.transform;旧的needOakTransform/isLib和项目侧extraOakModules只作为兼容或非标准覆盖。 - 页面、组件和 namespace 配置进入
index.config.ts:新增CreatePageConfig、CreateComponentConfig、CreateNamespaceConfig,路由生成会写入route.access、meta.title、titleI18nKey、namespacePath和 namespace 菜单配置,生成物包括allRouters.ts与allNamespaceConfigs.ts。 route.access类型扩展为public、login、root、deny、ref、relation、operation、anyOf、allOf和数组简写;namespace 可声明route.path、first、notFound、params、菜单分组和运行时 feature 配置。- Web 渲染入口支持
web.mobile.tsx。移动宽度优先选择web.mobile.tsx -> web.tsx -> web.pc.tsx,宽屏优先选择web.pc.tsx -> web.tsx -> web.mobile.tsx,Vite render-entry invalidation 会监听这些兄弟入口增删。 - 前端编译配置新增
frontend.iconLibraries、frontend.targets.*.iconLibraries、frontend.workspaces.*.iconLibraries,namespace config 也可以声明iconLibraries。配置支持font、react、image三类图标库;font可提供style或fontUrl + icons,react可通过packageName/importPath让 web 编译按实际OakIcon字符串用量生成异步loadIcon(...)。 - Web Vite / webpack 会把编译期图标库写入
globalThis.__OAK_FRONTEND_ICON_LIBRARIES__,并为fontUrl + icons生成可注入的styleText;小程序 Vite 会把 font 图标库样式合并到 OakIcon 组件样式,并按静态<OakIcon name="...">/<oak-icon name="...">用量裁剪 glyph CSS,动态用法或无法解析字体时会回退完整样式并输出 warning。 - Web / Vite 构建新增内置 CDN 插件和共享配置层;CDN 只在 production / staging build 且项目显式配置时生效,不再默认 external React 等依赖。支持 global/UMD/IIFE、ESM、CJS,多源 fallback,
moduleStrategy: 'parallel'模块并行、dependsOn依赖图,以及 root/module 级sourceStrategy: 'race'源竞速;未知依赖和循环依赖会在配置归一化时失败。Vite 依赖升级到 8.x,补充 devtools、host/port 透传、linked package 路由依赖解析、resolve.dedupe和optimizeDeps.include的 package 收集。 - Web / MP
--analyze统一到 Oak 自研 bundle analyzer,支持 bundle treemap、源码目录、NPM 依赖、小程序分包统计、反向依赖图、稳定颜色和 canvas 缩放交互。 - 小程序配置从页面列表继续收敛到
package.config.ts:支持namespace页面范围、生成PagesDefine到typings/oak-wechat-mp-pages.d.ts,pages、subPackages、tabBar、entryPagePath、preloadRule获得类型约束。 - 小程序 Vite 构建新增 route map 虚拟模块,Oak navigator 使用 canonical route,裸
wx.navigateTo/redirectTo/reLaunch/switchTab的静态 URL 会在 script transform 中改写,无法静态识别的动态路径给出 warning。 - 小程序分包构建重写 chunk 和 asset 归属:支持普通分包 / 独立分包 JS 依赖复制、组件私有 chunk 预加载、分包 assets、包组件配置保留、独立分包
wxs/i18n.wxs复制和按输出位置注入 i18n WXS。 - 小程序页面 / 组件 JSON 可以由
index.config.ts虚拟生成,usingComponents、componentGenerics、componentPlaceholder会按主包 / 分包输出位置重写;缺少组件 JSON 时仍会兜底输出{ "component": true }。 - Vite MP 增加 XML / WXML 组件属性诊断,基于
usingComponents和静态OakComponent({ properties })/Component({ properties })输出 warning,并写入node_modules/.cache/oak-cli-wechat-mp-props/*、.vscode/oak-wechat-mp-*。语言服务与 render/WXML runtime 由oak-assistant消费,当前 oak-cli 不再暴露install:mp-xml-plugin命令。 - tsc build 已把 WXML 分析提升为可选的正式类型检查:
--enable-xml-check会检查表达式、循环作用域、组件 properties、事件和 class;当前模板的build:es已默认启用,不能再把 XML 当作无类型字符串模板。 - 传统 TSX render 会从同目录 Oak component 的
entity、isList、properties、formData、methods推导props.data/props.methods。如果index.ts采用import OakComponent from '组件'; export default OakComponent;直接复用已有组件,CLI 会继续追踪本地源码合同,或从依赖包对应平台的 render 声明继承合同;本地直接调用OakComponent({...})时仍以本地定义为准。 - 复用组件的平台声明按 render 入口回退:
web.pc/web.mobile -> web,render.ios/render.android -> render.native,render.desktop -> web.pc -> web,render.windows/render.macos/render.linux -> render.desktop -> web.pc -> web。依赖包需要发布这些平台 render 的.d.ts;组件目录的index.d.ts只描述外部 React props,不能替代内部 render props 合同。 --emit-injection-types会把推导或继承的真实 props 写入 render 声明。对复用组件,产物使用稳定的公开模块引用,例如Parameters<typeof import("@scope/pkg/components/example/web").default>[0],不会泄漏虚拟文件名或本机绝对路径。业务包应在声明构建中启用该选项,供下游继续继承。--check-style-less增加 Less Module class 与 JSX/XML 使用检查,并按嵌套选择器、祖先 class、局部容器、alias 和 portal 可达性判断作用域。空规则或伪造类型不能替代真实样式声明。- tscBuilder 支持 compiler plugins,并隔离 watch program;插件配置、显式 schema builder config 和命名 locale workspace 可由当前编译链消费。
- 新增完整 Desktop 工作区支持,覆盖 Tauri 与 Electron、renderer/platform 分离、桌面路由发现、离线 locale、原生窗口外观与启动模板;Desktop 是 web renderer 运行形态,不应伪造新的 application type。窗口背景材质由系统能力和应用设置决定,当前模板不再启动时自动强制材质。
- create 流程增加数据库驱动选择,并与
oak-db的 MySQL、PostgreSQL、SQLite 驱动装载边界对齐。 - 新应用改为默认只创建 Web workspace;小程序、React Native、Tauri 和 Electron 通过
oak-cli add mp|rn|desktop按需添加,并生成 workspace-local TypeScript 配置与带--subDir的脚本。 - Native workspace 现在同时生成
build:native:<name>:android与build:native:<name>:iosproduction 脚本;Desktop Electron workspace 默认忽略dist-electron/。Web/Desktop/Native 模板 render 使用编译器注入 props,不再生成伪造实体的WebComponentProps或@ts-nocheck。 - 不安装任何公共业务包时,create 使用精简 no-business scaffold:只保留可运行的 frontend 展示页、匿名 CRUD 所需上下文和基础 feature;不会生成依赖登录态的 console namespace。带 general/pay 业务依赖的项目仍走完整模板。
- 小程序 Vite 增加可配置 Node polyfill:默认保留
process、global、Buffer和node:imports,同时排除容易放大主包或触发真机兼容问题的crypto、assert;项目可用完整include白名单显式恢复需要的 built-in。 - 小程序最终产物可通过
vite.mp.plugins.buildin.compressImage压缩 PNG、JPEG、WebP、AVIF;压缩器从消费项目加载sharp,未安装时 warning 并跳过。 - Web Vite 保留可选 PWA 接入,但当前 Vite 8 模板不会安装存在 peer 冲突的
vite-plugin-pwa。vite.web.plugins.buildin.pwa: false或{ enabled: false }可显式关闭并消除缺包提示;其它对象字段目前尚未转发给插件,不能当作已生效的 manifest/workbox 配置。 - MP watch、入口图刷新、stale cleanup、hash chunk 清理、资源监听、构建进度、图片压缩和 analyzer 选择修复较多,减少增量构建时的重复全图刷新和过期产物残留。
- create / scaffold 流程新增确认步骤、next steps 提示、统一的
oak.config.ts项目编译配置入口、推荐 CDN 配置、scripts/upgradeAuth.js和显式 Oak 依赖清单;旧项目迁移脚本补充 React Router、AntD v6、lodash 迁移扫描和oak.config.ts迁移断言。 - 依赖和模板同步升级 React 19、Vite 8、TypeScript 参数兼容、peerDependencies、
toolkit替换 lodash,以及根tsconfig编辑器聚合配置。
src/server/server-build.ts、src/server/start-routes.ts、src/createConfig.ts、src/shared/create-compiler-config.ts、src/shared/template.ts、tooling/config/oakPackage.shared.js、tooling/config/utils/injectGetRender.js、tooling/config/utils/frontendIconLibraries.js、tooling/config/utils/frontendIconCss.js、tooling/config/mp/workspace-config.js、tooling/plugins/OakViteWebCdnPlugin.js、tooling/plugins/ViteRouterBuilderPlugin.js、tooling/plugins/OakViteMpRouteMapVirtualPlugin.js、tooling/plugins/ViteWechatMpPlugin.js、tooling/plugins/wechat-mp-plugin/*、tooling/scripts/upgrade-legacy-app.js、scaffold/*、docs/*5.0.1(tag:5.0.1,区间:5.0.0..5.0.1)
- create 流程的 compiler config 改为分层生成,减少模板和运行配置互相污染。
- Vite 类型支持和 web
optimizeDeps默认预构建依赖更新。 - scaffold package dependency 与 smoke 配置做了修正。
src/shared/create-compiler-config.ts、Vite / webpack 配置模板、smoke 测试。升级注意
es/entities 完整。不要为了 make:domain 在生产依赖里发布 src。oak.business.* / oak.compiler.transform。项目侧 extraOakModules 更适合保留给非标准本地覆盖。switchTab 不能携带 query / state,升级时应检查旧导航代码。oak.config.ts 显式配置内置 CDN 插件和对应 UMD / global 信息。oak:setup_fill、trip:hotel、antd:ShopOutlined。不要把 ReactNode 写进 index.config.ts;React 图标库应通过 frontend.iconLibraries 声明,让编译器生成按需加载代码。使用 fontUrl 时必须同时提供 icons 映射,否则无法生成业务 glyph class。index.config.ts 是页面、组件和 namespace 的结构化配置入口;allRouters.ts、allNamespaceConfigs.ts 和小程序虚拟 JSON 仍是生成物,不要手工修改。oak-db 更新日志
4.0.2 未发布(区间:4.0.1..HEAD)
- schema migration 不再输出数据库物理外键 SQL,
ref语义改由列注释中的oak_ref:元数据保存和反查;初始化阶段也不再单独补外键。 index.config.unique现在被视为 Oak 框架层语义,MySQL / PostgreSQL 建表和 migration 不再生成UNIQUE INDEXDDL;历史唯一索引会被规划为普通索引重建。- migration 计划补充引用注释、覆盖索引
include、浮点 / decimal 类型归一化、更保守的 rename candidate 判断,以及 nullable geometry 的方言差异处理。 - MySQL / PostgreSQL update expression 翻译补齐,支持
$case、布尔 /null常量、日期 floor / ceil 月年单位等;JSON / JSONB 属性中的普通对象不会再被误判为表达式。 Geo/SingleGeo字段补齐字段级过滤:$within、$contains、$intersects、$distance会分别翻译为 PostGIS / MySQL 空间函数,点数组会归一化为闭合 polygon。- decimal 读写边界收紧:数据库返回的 decimal / numeric 保留为字符串以避免精度丢失,decimal update 的直接赋值要求字符串或
$expr。 - PostgreSQL
money查询结果转换为整数,聚合和投影结果通过resultColumnMap映射避免长路径别名或特殊聚合键破坏结果回填。 - MySQL / PostgreSQL connector 增加
OAK_DB_LOG_SQL开关;MySQLdisconnect()会先回滚仍挂起的事务连接再关闭连接池。 - 新增 SQLite store 与 schema migration 支持,并把数据库驱动改为按需动态装载;安装检查只要求 MySQL、PostgreSQL、SQLite 中至少存在一个兼容驱动,消费端不使用的可选驱动不会被根入口强制加载。
- 驱动解析同时以
process.cwd()和 oak-db 自身目录为查找根。即使 oak-db 通过file:、workspace/link 或发布包安装,只要消费应用根安装了兼容的mysql2、pg或better-sqlite3,运行时就能解析;不要求驱动成为 oak-db 的直接依赖。 - XOR filter SQL、JSON path predicate、JSON boolean predicate、
unset带点字面量 key、translateCreateEntity结束分号等修复同步进存储层。 - 新增 VitePress 版 oak-db 使用指南,补充 query、aggregate、JSON、update expression、migration、方言边界和真实数据库测试覆盖。
src/sqlTranslator.ts、src/MySQL/*、src/PostgreSQL/*、src/migration.ts、src/utils/*、test/*、docs/*4.0.1(tag:4.0.1,区间:4.0.0..4.0.1)
升级注意
index.config.unique 不再对应数据库唯一索引。需要硬性唯一约束的项目必须在 Oak checker 或业务规则里实现,否则历史数据库唯一索引在迁移收敛时可能被重建为普通索引。oak-backend-base 更新日志
5.0.1 未发布(区间:5.0.0..HEAD)
AppLoader/ClusterAppLoader构造函数新增AppLoaderOptions,可传入runtimeManifest。server bundle 可直接注入依赖顺序、同步配置、domain schema / actionDef、BackendRuntimeContext、runtime modules、ports 和 socket entry,不再强依赖项目lib/*文件路径逐个require。- runtime module 合并入口覆盖
exceptions、relation、aspects、triggers、checkers、attrUpdateMatrix、data、endpoints、watchers、timers、startRoutines、stopRoutines;ports 也支持多份配置合并注册。 AppLoader暴露运行时注册接口:registerTrigger/unregisterTrigger、registerChecker/unregisterChecker、registerWatcher/unregisterWatcher、registerTimer/unregisterTimer/rescheduleTimer。DbStore同步透出 trigger / checker 注销能力。- start / stop free routine 收到的 runtime env 从
{ socket, contextBuilder }扩展为完整注册控制对象,routine 可以在启动或停止阶段动态挂载、移除、重排 trigger / checker / watcher / timer。 - watcher 初始化改为 registry 模式:
startWatchers()首次初始化后从 registry 读取,支持后续动态注册;lazywatcher 的 skip-once 状态集中保存,注销 watcher 时会同步清理 skip 状态和执行中标记。 - timer 调度拆成
executeTimer、scheduleTimer、registerTimer、unregisterTimer、rescheduleTimer,重复 timer 名会立即报错,非法 cron 创建失败也会明确抛错;unmount()统一通过unregisterTimer取消任务。 - free endpoint 增加 connector metadata 透传:
getEndpoints()会把protocol、useConnector作为 route options 返回给oak-cli;当useConnector为真时,free endpoint 的contextBuilder会使用请求头里的oak-cxt,没有oak-cxt时用{}初始化上下文,避免落入undefined初始化路径。 - 后端数据库创建链路增加 SQLite 支持;包自身编译已切到 Oak CLI,并启用当前 render 注入检查参数,和业务包的严格构建合同保持一致。
- connector-backed SSE endpoint 与
oak-domain/utils/sseEndpoint.makeSSEEndpoint(...)对齐:type: 'free'、protocol: 'sse'、useConnector: true的 endpoint 可以让oak-cli调用 connector 的 SSE 序列化能力。 Synchronizer的 sync trigger 增加运行锁,避免并发 commit trigger 同时扫描 / 锁定同一批oper;channel 推送失败会被正确 await 并转为OakPartialSuccess,不再让异步 rejection 漏出同步流程。- 同步 oper 数据清理从 lodash
unset改为只删除自身属性,避免TriggerDataAttribute/TriggerUuidAttribute这类带点字面量 key 被误当成路径删除。 - cluster volatile trigger 收到远端事件时会先检查本实例是否还注册对应 commit trigger;trigger 注销时同步清理
commitTriggers,避免动态卸载后仍响应跨进程事件。 - 依赖层调整:
lodash移除,改用oak-domain/lib/utils/toolkit;oak-common-aspect、oak-db、oak-domain转为 peer dependencies 并对齐oak-domain6.0.1;TypeScript / Vitest 等开发依赖升级,TS6 参数兼容同步进入源码和声明。
src/AppLoader.ts、src/ClusterAppLoader.ts、src/DbStore.ts、src/Synchronizer.ts、src/types/runtime-manifest.ts、src/routines/i18n.ts、src/utils/requirePrj.ts、test/test_sync_lock/index.ts、package.json5.0.0(tag:5.0.0,区间:4.1.29..5.0.0)
- upgrade 命令、upgrade table、migration plan、rollback artifacts 和执行顺序重构。
- 后端运行时上下文切到
oak-domain提供的BackendRuntimeContext。 - 初始化数据工具和
dbPriority配置读取路径整理。 - i18n 支持注入,不再作为后端基础包的硬依赖。
src/upgrade.ts、src/routines/update.ts、src/dbPriority.ts、test_upgrade/*升级注意
dbPriority 的当前规则。useConnector: true 的 free endpoint 会从 connector 解析出的 oak-cxt 初始化 context。匿名 endpoint 如果依赖 application / user / rootMode,仍应显式确认上下文来源。oak-frontend-base 更新日志
6.0.4 未发布(区间:6.0.3..HEAD)
- 增加 Desktop web runtime、离线 locale 与桌面外观支持,并把 web platform 与具体 renderer 分开表达;浏览器、Tauri、Electron 不再依赖同一个模糊环境分支。
- 导出 OakComponent template types 并恢复组件类型推导,配合 oak-cli 生成传统 render 的注入声明;平台 render props 的实验性扩展已回退,业务合同仍以组件定义推导为准。
- console route access 会等待权限上下文就绪再判定,避免初始化阶段把“尚未加载”误判成无权;旧 pull-to-refresh 实现也已替换并恢复正确 web platform 语义。
- Web 初始化入口保留旧 positional signature,同时支持 options object:
namespaceConfigs、renderError、renderLoading、renderProvider、oakTheme、iconLibraries、antdConfigProviderProps、antdMobileConfigProviderProps都进入标准初始化流程。 - 标准 web app tree 和初始化错误 tree 都会挂载
RouteAccessProvider、NamespaceConfigProvider、AntDConfigProvider、AntD MobileConfigProvider、StyleProvider和 Oak theme shell,应用侧可以通过useNamespaceConfig(...)读取 CLI 生成的 namespace 配置。 - 新增
RouteAccessBoundary与resolveRouteAccess(...),route.access支持public、deny、root、login、ref、relation、operation、anyOf、allOf和数组简写;operation会调用features.cache.checkOperation(...),并可通过$context.entity/$context.entityId引用当前 console 上下文。 - namespace 配置支持
access.unconfiguredAccess和features.console.contextEntities。当前 console context 不在 namespace 允许范围内时,relationaccess 和依赖$context.*的operationaccess 会返回contextMismatch。 oak-frontend-base/config导出CreatePageConfig、CreateComponentConfig、CreateNamespaceConfig,内置组件的小程序 JSON 从index.json迁移到index.config.ts,配合 oak-cli 生成虚拟页面 / 组件 JSON。- OakIcon 图标库能力扩展:
OakIcon支持旧内置名、显式oak:*、自定义 font 图标库和 web-only React 图标库。registerOakIconLibraries(...)会注册编译期、初始化 options 和 namespace config 中的图标库,font 图标支持运行时注入styleText,React 图标支持同步icons/resolveIcon(...)和异步loadIcon(...)。 - 新增 Oak theme runtime:
OakTheme、OakThemeProvider、mergeOakTheme、oakThemeToCssVars、oakCssVarsToCssText和oakTokenVars成为共享 token / CSS 变量契约;features.theme可以持久化theme-mode、主色和oakTheme,并兼容迁移旧的ogb:feature-theme-state;Web 初始化会把 Oak token 同步成 AntDConfigProvider.theme.token默认值,应用显式传入的 AntD token 优先级更高。 - 新增
components/theme/setting主题设置组件,支持浅色、深色、跟随系统和主色切换,读取初始化主题和运行时features.theme合并后的有效OakTheme。 cache.callSSEEndpoint(name, props, options)对齐 connector-backed SSE endpoint,默认透传当前前端上下文,ignoreContext: true时不传上下文;DebugConnector.callSSEEndpoint()仍明确不可用。- 前端 socket / subscriber 会从
socketPoint.get()读取scope,并在 socket.ioauth.oakSocketScope中透传给后端,保持多 scope 场景下的连接身份一致。 FrontendRuntimeContext增加 locale 序列化语义,前端运行现场可以携带当前语言信息。- 小程序 route map 改为构建期注入的运行时模块,支持 canonical Oak route 到 WeChat 产物路径的双向映射、namespace route、subpackage route mapping 和 tabBar 判断;显式 route map 存在时,缺失路由不再回退猜测。
- 小程序 navigator 统一使用
setNamespacePath(...)/getNamespacePath()语义,旧setNamespace(...)/getNamespace()/namespace仍兼容但开发态会警告;switchTab会丢弃 query / state 并输出 warning。 - 小程序 polyfill 改用
oak-domain的 URL / fetch 标准导出,补充DOMException、getRandomValues等运行能力;socket.io 小程序端改为 ESM repack,减少体积并修复导出。 - 新增轻量 / 专项 ECharts 小程序 canvas 组件:
ec-canvas-basic、line、bar、pie、gauge、map等,避免所有页面都引入完整 chart runtime。 pageHeader成为唯一维护的页面头部实现,pageHeader2被移除;web 端返回按钮判断会优先读取 namespace menu,再回退到contextMenuFactory.menus,支持相对 menu path 和 namespace-prefixed path 匹配。ListPro使用 runningTree loading 作为默认 loading / reload 状态,继续渲染 Oak 自有 Pagination,并断言不接受tablePagination;列表行操作按钮修复“执行后仍显示无权限动作”的问题,并增加紧凑操作按钮配置。- Pagination 组件重写布局和文案,补充中英文 locale、右对齐样式、
showSizeChanger、showQuickJumper、自定义showTotal和函数式分页选项。 - 新增
searchPanel共享筛选面板组件,补齐 web / pc / 小程序入口,用于列表页的命名过滤器 UI;近期同步修复了 PC 端折叠控制、FilterPanel 展开按钮栅格布局和展开后收起按钮裁剪问题。 runningTree增加静态 selection / fresh value / composed operations 缓存和失效逻辑,selectOption可从组件配置透传到节点;修复 zombie 虚拟节点复用、createNode行为和带点字面量 key 被unset误删导致的缓存异常。- Web 错误边界和初始化错误页重构,支持自定义初始化错误 UI;Loading 默认仍走 NProgress,传入
renderLoading时由应用完全接管 route lazy fallback;网络异常、断网和 server proxy 错误文案统一由getErrorPageCopy(...)/ locale 解析。 - 依赖和构建同步升级 React 19、AntD 6、AntD Mobile、React Native、peer dependencies、TypeScript 参数兼容和
optimizeDeps.include,并在package.json.oak.runtime.server标记前端包不参与 server runtime。
src/platforms/web/initialize/*、src/platforms/web/RouteAccessBoundary.tsx、src/platforms/web/NamespaceConfigProvider.tsx、src/features/routeAccess.ts、src/config/index.ts、src/theme.ts、src/features/theme.ts、src/features/cache.ts、src/features/socket/*、src/features/mpRouteMap.ts、src/features/navigator.mp.ts、src/features/runningTree.ts、src/types/Icon.ts、src/utils/iconLibrary.ts、src/components/icon/*、src/components/pageHeader/*、src/components/listPro/*、src/components/pagination/*、src/components/searchPanel/*、src/components/filterPanel/*、src/utils/errorPage.ts、src/miniprogram_npm/ec-canvas-*、test/routeAccess.test.ts、test/mpRouteMap.test.ts、test/cacheSSEEndpoint.test.ts、test/iconLibrary.test.ts6.0.3(tag:6.0.3,区间:6.0.1..6.0.3)
升级注意
namespaceConfigs、oakTheme、renderLoading、AntD provider props 等配置。不要在应用侧重复包一层全局 RouteAccess / NamespaceConfig / AntD provider,除非确实要隔离一个子树。operation.target.checkerTypes 是前端合法性探测的 checker 范围字段;依赖 console 上下文的规则应声明 $context.entity / $context.entityId,并确认 namespace 的 features.console.contextEntities 范围。switchTab 也不会保留 query / state。pageHeader2 已移除,改用 pageHeader。ListPro 不接受 tablePagination,共享分页样式应改 Oak 自有 components/pagination。oak:* 或 <library>:*。Web React 图标库只在 web 运行时可用,小程序侧需要使用 font 图标库;使用 fontUrl 的库仍需要配套 icons glyph 映射。oakTheme / features.theme / --oak-* CSS 变量;AntD / AntD Mobile 的 provider props 只用于三方 UI 库配置。oak-general-business 更新日志
6.1.0 未发布(区间:6.0.0..HEAD)
- 新增系统翻译链路:
System增加translation、translateState、translationError,并增加translate/translateSuccess/translateFail状态机动作;utils/systemTranslation会按系统翻译配置调用 OpenAI-compatible LLM,将目标实体文本写入localizedContent,通过 digest 跳过未变更内容,并保留人工翻译。 - 新增系统翻译运行时和 UI:start / stop routine 会注册和注销系统翻译 runtime env,
systemtrigger 会在翻译配置或 LLM 配置变化后维护定时任务,components/system/translation和components/system/translationManage支持配置翻译实体、筛选缺失 / 过期 / 已翻译内容、人工编辑翻译和触发翻译动作。 - 新增
localizedContent与areaLocale:区域名称增加en_US、es_ES、fr_FR、ar_SA、ru_RU入库语言,data/areaLocale同步新增多语言数据文件,areaLocale按area + language建唯一索引,供 Area picker 和多语言展示读取。 - 新增 LLM 配置能力:
Config增加Llm,OpenAICompatibleLlmConfig支持命名、extraBody、testExtraBody和默认 60000ms timeout,配置面板提供 LLM 账号维护和testLlmConfig(...)测试入口。 - 新增 HumanVerify 人机校验体系:内置
debug、turnstile、altchaprovider,内置场景覆盖账号登录、登录名注册、手机验证码和邮箱验证码;系统配置可按场景设置observe/enforce、minScore、action和 provider 错误策略。 - HumanVerify 前后端注册面补齐:后端默认注册 debug / Turnstile / ALTCHA provider,并开放
humanVerify/altcha/challengefree endpoint;前端增加features.humanVerify、oak-humanVerifyHost全局组件、provider bundle / client / acquire component / config component 注册入口,配置页内置 debug、Turnstile、ALTCHA 面板。 - 登录、注册和验证码流程接入 HumanVerify:
loginByAccount、registerUserByLoginName、sendCaptchaByMobile、sendCaptchaByEmail会校验传入的 proof;前端features.token方法增加可选humanVerify参数。 registerUserByLoginName变为“注册后立即登录”:后端创建用户和 loginName 后会建立 token,返回tokenValue和inviteTouchId,前端注册成功后直接进入登录态。- 新增邀请归因链路:新增
invite、inviteTouch、inviteRelation实体和getMyInvite/touchInviteaspect;前端features.invite会把未登录访问的 touch 暂存在 localStorage,登录、注册或 token materialize 后写入关系并清理 pending touch。 - 邀请二维码自动化:创建 invite 时会按目标应用类型创建关联
wechatQrCode,支持wechatPublic服务号、带qrCodePrefix的小程序 domain URL,以及普通小程序码。 - 用户实名认证从
user字段迁到独立userAuth申请实体:新增userAuth状态机、审核字段、附件关系和components/userAuth/*/template/my/userAuth,旧components/user/authenticate和template/my/auth已移除。 - 应用访问入口迁移到
Domain:Application.config.location从配置类型、模板数据和配置面板中移除,前端页面 URL 通过composeDomainUrl(...)生成,后台接口 URL 通过composeServerUrl(...)生成;getApplication、微信回调、邀请落地页和二维码调试链接都会过滤已禁用域名。 Domain增加启用状态并弱化端口配置:新增ableState以及enable/disable动作,port改为可空;管理列表改为启用 / 禁用确认操作,新增和编辑弹框补齐取消按钮、空值占位与国际化文案。- 微信扫码中转页和二维码有效期改为系统配置:
System.config.App.scanPage统一配置扫码中转页,默认wechatQrCode/scan,小程序侧会规范化为pages/<scanPage>/index;System.config.App.wechatQrCodeExpireSeconds控制微信临时二维码有效期,单位秒,默认并封顶为2592000。 - 微信公众号 / 小程序回调改为注册表模式:
src/endpoints/wechat.ts只负责 HTTP 验证和解析,项目层通过src/registry.backend.ts注册registerWeChatPublicEventHandler/registerWeChatPublicEventAfterHandler和registerWeChatMpEventHandler/registerWeChatMpEventAfterHandler;applicationId与msgType必填,event仅在msgType === 'event'时必填,旧registerWeChatPublicEventCallback只保留兼容并会在注册时提示弃用。 wechatQrCode创建兜底更明确:显式传入applicationId/type时按目标应用生成;未传时才按System.config.App.qrCodeApplicationId、当前应用、服务号、小程序顺序自动兜底。公众号扫码后的图文链接、微信登录扫码、授权领取和开发环境调试链接都改用启用的Domain拼接。- 配置组件扩展:
components/config/upsert增加 LLM、HumanVerify、SMS / COS 配置注入点,COS 增加 S3 / MinIO 兼容配置,Config类型补齐humanVerify、S3 account / cos 配置和组件 props 类型。 - 系统配置与 OAuth 管理界面优化:基础设置 tab 将默认值说明移到 help / tooltip,表单 label 防止被 tag 挤压;OAuth provider / OAuth app 创建弹框限制浏览器高度并在弹框内滚动,列表列宽和横向滚动做了适配。
- 前端主题迁出通用业务包:删除 package-local
features/theme和types/Theme,共享主题能力改由oak-frontend-base的features.theme、oakTheme和 CSS 变量承接。 - 页面 / 组件配置迁移到
index.config.ts:大量组件补齐CreateComponentConfig,模板和 tabBar 改用setNamespacePath(...)/getNamespacePath(),并统一使用当前pageHeader入口。 - 多语言和依赖元数据更新:组件、实体和 humanVerify locale 增加英文资源;
package.json.oak使用oak.package、oak.business.module、oak.business.featureInit、oak.compiler.transform和前端全局组件声明;peer 依赖对齐 React 19、AntD 6、oak-domain ^6.0.1、oak-frontend-base ^6.0.4。 - 修复 token web 多标签同步竞态:web 开发态也注册
storage监听,刷新旧 token、过期 remove 事件和 token rotation 不会再覆盖或清掉较新的本地 token;switchTo会清理 frontend-base 的 console context 缓存。 - 修复用户证件校验类型、
unset带点字面量 key、过时wx.*方法、AntDDivider titlePlacement和注册页 header 布局。 - 业务组件已迁移到 oak-cli 的 render props 推导,并在 ES 构建启用 XML 与 Less Module 严格检查;传统 TSX 不再以手写
WebComponentProps作为主合同,小程序模板和样式错误会直接阻断构建。 - Application feature 增加 Desktop 解析:浏览器 Web 与 Tauri/Electron 使用 renderer/runtime metadata 区分,离线快照会回放 application opRecords 后再从 cache 读取当前应用。
src/entities/System.ts、src/entities/Application.ts、src/entities/Domain.ts、src/entities/LocalizedContent.ts、src/entities/AreaLocale.ts、src/entities/UserAuth.ts、src/entities/Invite*.ts、src/types/HumanVerify.ts、src/types/Translation.ts、src/types/Config.ts、src/utils/domain.ts、src/utils/session.ts、src/utils/wechatQrCode.ts、src/utils/wechatEvent/*、src/utils/systemTranslation.ts、src/utils/humanVerify/*、src/aspects/session.ts、src/aspects/token.ts、src/aspects/user.ts、src/aspects/invite.ts、src/aspects/wechatQrCode.ts、src/registry.backend.ts、src/endpoints/wechat.ts、src/endpoints/index.ts、src/features/token.ts、src/features/invite.ts、src/features/humanVerify.ts、src/components/system/*、src/components/domain/*、src/components/config/upsert/*、src/components/oauth/management/*、src/components/userAuth/*、src/data/areaLocale/*、upgrade/6.1.0/*.sql6.0.0(tag:6.0.0,区间:5.11.2..6.0.0)
- 包 metadata 补齐
oak.package/isLib,feature 初始化转为 package metadata 语义。 - 去掉旧
OAK_DEV_MODE参数和部分 alias / fix 脚本依赖。 - 增加英文 locale,通用业务开始为多语言发布包做准备。
- token storage sync 范围收窄,Area picker breadcrumb 和权限编辑组件修复。
package.json、src/locales/*、src/initialize*、src/components/areaPicker/*、src/components/relation/*升级注意
User、UserEntityGrant、System 这类会被应用广泛引用的实体,不能只保留新字段而丢掉旧 action/state/locale/style。upgrade/6.1.0/01.sql 到 04.sql,并新增 05.mysql.sql / 05.postgres.sql。前四组脚本会创建 localizedContent、areaLocale、userAuth、invite、inviteTouch、inviteRelation,为 system 增加翻译字段,并从 user 删除旧证件字段;第 5 组脚本会让 domain.port 可空、增加 domain.ableState,并从 application.config 移除历史 location。升级前需要确认历史实名认证数据迁移策略和应用入口域名配置。System,必须保留 translation、translateState、translationError 以及 translate / translateSuccess / translateFail 动作,否则共享 components/system/panel、系统翻译 trigger 和定时任务会失效。enforce 后,登录、注册和验证码发送会因为缺少 proof、前端 provider bundle / host 未注册、后端 provider 不可用或评分不足而被拒绝。应用侧需要在 registry / 初始化流程中注册所选 provider,并确认 oak-humanVerifyHost 已被全局组件机制挂载。@oak-general-business/components/user/authenticate 已不存在,改用 @oak-general-business/components/userAuth/upsert/index 并以 userAuth 单节点承载申请 / 审核状态。features.token.registerByLoginName(...) 现在会注册并登录,成功后会写入 token。历史代码如果把注册成功页和登录页分离,或假设注册 aspect 只返回用户信息,需要调整后续跳转和状态处理。Application.config.location 里维护前端访问地址。升级后应在 Domain 配置系统可用域名,必要时用 Application.domainId 精确绑定某个应用;扫码中转页放到 System.config.App.scanPage,微信临时二维码有效期放到 System.config.App.wechatQrCodeExpireSeconds。oak-general-business/features/theme 或 oak-general-business/types/Theme 引入主题能力;改用 oak-frontend-base 的 features.theme、oakTheme 和主题设置组件。oak-pay-business 更新日志
4.1.0 未发布(区间:4.0.0..HEAD)
- 新增 Waffo、Stripe、Epay、Creem、PayPal 支付渠道,并补齐对应的账号实体、产品实体、支付记录实体、配置组件和 endpoint。
- 新增微信、支付宝、Epay、Stripe、Creem、Waffo、PayPal 回调模块;当前
src/endpoints/index.ts默认导出空对象,项目必须按实际启用渠道显式注入,依赖包不会自动暴露全部支付回调。 payClazz内置注册新增epProduct、spProduct、cpProduct、wfProduct、ppProduct,并对自定义支付渠道校验 account/product schema:账号金额、费率字段必须是 decimal,产品必须关联 application 并提供启用状态。- 金额字段迁到
Price/Decimal语义,支付、退款、账户、提现、结算、系统账户流水等路径改用 decimal helper / BigNumber,避免以 JS number 直接计算金额。 - 新增
upgrade/4.1.0/accounts.sql和upgrade/4.1.0/priceDecimal.sql:前者创建新渠道表,后者把历史金额列扩成decimal(32,10)。 - 前端支付渠道选择支持 web redirect 类支付,
redirectPay会识别pay.meta中的payUrl、htmlUrl、checkoutUrl、paymentUrl。 features/Pay、订单支付组件和payConfig/system配置界面同步扩展到新渠道;系统账户 survey、应用 projection 和 relation 配置也加入新账号 / 产品关系。System、User适配oak-general-business6.x:System继续保留translation、translateState、translationError和translate/translateSuccess/translateFailaction alias,同时追加支付账号、提现账号和payConfig。- 修复退款取消 /
stopRefunding后订单状态回滚问题,避免从refunding错走支付成功分支并重复触发订单已支付副作用。 - 账户支付、充值、退款、提现和提现转账相关 trigger 改用 decimal 值收敛,并补齐部分零金额退款和退款失败回滚路径。
- 小程序组件配置从
index.json批量迁到index.config.ts/CreateComponentConfig,样式变量迁到--oak-*主题变量。 components/pay/list支持传入分页覆盖项,支付详情、账单、账号和渠道配置组件做了 UI 与投影同步。- 包元数据升级为
oak.package、oak.i18n、oak.business.module和oak.compiler.transform,peer 对齐 React 19、AntD 6、oak-domain6、oak-frontend-base6、oak-general-business6。 Order/Pay增加多货币字段与配置链路;支付产品 draft 初始化、Alipay 不支持独立退款 callback 的边界也已修正。- ES 构建启用 XML、render 注入和 Less Module 严格检查,组件已迁移到编译器推导的 props;旧手写 render 合同和未声明 XML/style 使用会在构建阶段暴露。
package.json、src/entities/*、src/endpoints/*、src/checkers/*、src/triggers/*、src/features/Pay.ts、src/utils/payClazz/*、src/utils/redirectPay.ts、src/components/*、src/configuration/*、upgrade/4.1.0/*.sql4.0.0(tag:4.0.0,区间:3.5.1..4.0.0)
- 适配 React 19,并把迁移构建切到 ESM。
- 包 metadata 补齐
oak.package/isLib,feature 初始化转为 package metadata 语义。 - 移除旧 alias / build scripts,并补充英文 locale。
- 适配新的
oak-general-business和oak-domain依赖方式。
package.json、src/locales/*、src/initialize*、构建配置。升级注意
upgrade/4.1.0/accounts.sql 与 upgrade/4.1.0/priceDecimal.sql。前者新增渠道表并手写物理外键,后者把金额列改成 decimal(32,10);生产库升级前应确认历史金额单位、精度和外键策略。System、User、支付配置、支付产品或支付状态时,需要保留 general-business 字段、支付字段、旧 action/state 和翻译 alias。尤其是退款状态流转,不要只按新渠道字段重写旧状态机。registerPayClazz 现在会校验 product/account schema。自定义账号实体必须提供 decimal price 和费率字段、systemId、提现转账开关等字段;自定义产品实体必须关联 application 并提供 enabled、税费、退款和收款配置字段。pay.meta 中的跳转 URL。升级后需要检查支付成功 / 取消 URL、webhook secret、notify URL 和沙箱配置。oak-common-aspect 更新日志
4.0.3 未发布(区间:4.0.2..HEAD)
oak-domain、oak-external-sdk从直接依赖迁到peerDependencies,并分别对齐到^6.0.1、^3.0.2;本仓库开发环境改用file:../oak-domain、file:../oak-external-sdk。src/relation.ts把omit来源从@oak-domain/utils/lodash切到@oak-domain/utils/toolkit,跟随 Oak 公共工具入口迁移。loadRelations的返回类型断言改为unknown过渡,兼容 TS6 对泛型结构断言的更严格检查;运行时仍返回去掉relation字段后的userRelation列表。tsconfig.es.json、tsconfig.lib.json移除downlevelIteration,构建参数和当前 Oak 包链路保持一致。- 编译产物
es/relation.js、lib/relation.js已同步更新。 - 包自身构建切换到 oak-cli;relation 查询补充结果存在性断言,使空查询结果更早暴露为明确错误。
package.json、src/relation.ts、tsconfig.es.json、tsconfig.lib.json、es/relation.js、lib/relation.js4.0.2(tag:4.0.2,区间:4.0.1..4.0.2)
- 拆分 type imports,减少运行时 import 污染。
- 调整 exception exports。
- aspect、crud、geo、amap、port、relation 等导出入口整理。
升级注意
oak-domain、oak-external-sdk 当普通依赖安装。消费项目必须自己提供兼容版本,否则 common aspect 的 crud、geo、amap、relation 等 aspect 在运行或构建时会找不到 Oak 依赖。oak-memory-tree-store 更新日志
当前未发布变更(区间:4.0.1..HEAD)
oak-domain从直接依赖迁到peerDependencies,版本范围更新为^6.0.1;本仓库开发环境改用file:../oak-domain。src/store.ts的cloneDeep、get、set、unset、groupBy等工具从@oak-domain/utils/lodash切到@oak-domain/utils/toolkit,并移除@types/lodash。- 事务提交 / 回滚时对
$txnId、$next、$path、$nextNode、activeTxnDict[uuid]等字面量字段改用delete删除,避免unset把带点路径或特殊 key 当路径解析。 tsconfig.es.json、tsconfig.lib.json、tsconfig.mocha.json移除downlevelIteration,跟随当前 Oak 包构建参数。- 编译产物
es/store.js、lib/store.js已同步更新,package 版本元数据已更新为4.0.2。
package.json、src/store.ts、tsconfig*.json、es/store.js、lib/store.js4.0.1(tag:4.0.1,区间:4.0.0..4.0.1)
升级注意
oak-domain。消费项目必须显式提供兼容的 oak-domain ^6.0.1。delete 清理,语义是删除字面量属性,不再走 lodash 路径解析。oak-external-sdk 更新日志
3.0.2 未发布(区间:3.0.1..HEAD)
- 新增
LlmSDK、types/LLM和service/llm/OpenAICompatible,当前 provider 为openaiCompatible。 - OpenAI-compatible 实例支持
chat、json、translateText、translateFields、translateObject,可配置baseURL、apiKey、defaultModel、headers、extraBody、timeout 和responseFormat。 - LLM 返回解析支持普通 JSON、
data:event-stream 文本、reasoning/reasoning_content/<think>内容拆分,并把 usage 映射到统一的promptTokens、completionTokens、totalTokens。 - 顶层
src/index.ts改为直接导出各 SDK 单例和 type-only instance 类型,新增LlmSDK导出;WechatMpInstance、OpenAICompatibleInstance等 instance class 不再作为顶层运行时值导出。 - 微信
mp、public、web、nativeaccess token 刷新改为共享 in-flight promise,初始化失败后显式调用会重试,不再用 500ms 轮询无限等待;定时刷新失败会 warn 并保留后续调用重试能力。 - WeChat
externalRefreshFn类型从返回 token 字符串调整为返回{ access_token, expires_in },便于统一设置过期时间和刷新定时器。 fetch工具改为依赖 Node 18+ / 运行时已有globalThis.fetch;各服务从require('../../utils/fetch')迁到 ESM side-effect import。- Web 端
cheerio.load改成明确抛出error::cheerio.loadNotImplemented,避免把 Node 版 cheerio 打进 web fallback。 package.json修正build:lib/build:es的fix_import.mjstarget,oak-domain迁到 peer^6.0.1,本仓库 dev 使用file:../oak-domain。- 新增 CJS WeChat access token 测试,覆盖永久失败、临时失败重试、并发调用共享同一次刷新。
package.json、src/index.ts、src/LlmSDK.ts、src/types/LLM.ts、src/service/llm/OpenAICompatible.ts、src/WechatSDK.ts、src/service/wechat/*、src/utils/fetch/*、src/utils/cheerio/index.web.ts、test/testWechatAccessToken.cjs3.0.1(tag:3.0.1,区间:3.0.0..3.0.1)
升级注意
WechatSDK、LlmSDK 等 SDK 单例,但 instance class 改为 type-only 导出。运行时代码如果曾从 oak-external-sdk 顶层解构 WechatMpInstance、WechatPublicInstance 等构造类,需要改成使用 SDK 工厂或从具体 service/... 路径引入。externalRefreshFn 现在必须返回 { access_token, expires_in },不再只是 token 字符串。自定义 token 刷新接入需要同步调整返回值。fetch 依赖 Node 18+ 或宿主已有 globalThis.fetch;web 环境不再提供 cheerio 实现,调用 cheerio.load 会抛出明确异常。LlmSDK 和 types/LLM 引入,避免依赖内部 service/llm 文件路径。oak-internal-sdk 更新日志
1.1.5 未发布(区间:1.1.4..HEAD)
EncConnector增加 endpoint 调用能力:前端实现callEndpoint、openEndpointStream、callSSEEndpoint、makeEndpointUrl,并按routerPrefixes.endpoint生成 endpoint 地址。- 加密 SSE endpoint 打通前后端:服务端
serializeSSEEndpointResult会在请求头oak-encrypted: 1且存在 session key 时,把 SSEdata:packet 加密后输出;前端callSSEEndpoint会按oak-encrypted响应头逐包解密并分发事件。 - 前端 SSE client 支持
onMessage、按事件名on、onError、onDone、close;event: error中携带的 Oak exception 会通过makeException还原。 src/index.ts除SafeConnector外,新增导出utils/env和adaptor/memoryAdaptor,方便消费端直接拿到加密连接器和内存 adaptor。package.json增加oak.es.optimize.includeDeps,显式包含crypto-js/sha256、crypto-js/enc-hex,避免 ESM 优化时漏掉 SafeConnector 需要的 crypto-js 子路径。- 移除
lodash/@types/lodash,开发态oak-domain改为file:../oak-domain,并移除downlevelIteration以适配当前 TS 构建参数。 formate.d.ts的BaseEntityDict引用按 es/lib 产物分别改成oak-domain/es/index、oak-domain/lib/index,避免声明输出指到不匹配的入口。- 编译产物
es/、lib/已同步更新。 - connector 错误日志不再直接输出原始错误对象,避免把请求或加密上下文中的敏感细节写入普通日志。
package.json、src/index.ts、src/utils/env/impls/backend.ts、src/utils/env/impls/frontend.ts、tsconfig*.json、es/*.d.ts、lib/*.d.ts1.1.4(tag:1.1.4,区间:1.1.3..1.1.4)
- 增加 i18n 相关能力。
oak-domain转为 peerDependencies / version reference。- 增加 Vite build / alias 支持。
- memory / redis adaptor 输出整理。
- exception data debug、clock drift exception、
parseRequestarray bug 和 class name preservation 修复。
升级注意
oak-domain 版本,不要再假设它由 internal sdk 直接带入。callSSEEndpoint 与服务端 serializeSSEEndpointResult 配套升级。只升级一端时,客户端可能把加密后的 data: 当普通 JSON 解析,或服务端返回未加密流导致敏感数据明文传输。routerPrefixes.endpoint 或默认 /endpoint。自定义网关、反向代理和 CORS expose headers 需要允许 oak-encrypted、oak-nonce 等响应头。oak-ui 更新日志
0.1.0 当前状态(无 tag)
- 包名切换为
oak-ui,并补齐 Oak package metadata。 - 重建基础 primitives,并扩展 common primitives。
- 支持从 SVG assets 生成 icons。
- 加固 core interactions 和生产行为。
- 增加 bm-smart 迁移组件。