Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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仍然在不断开发完善中,如有问题,也欢迎加入讨论小组,给出您的宝贵建议。

基础知识

  • Oak使用Typescript语言,因此您需要提前掌握以下知识:
  1. javascript语言基础: 学习资料
  2. Node.js 环境: 学习资料 官方文档
  3. Typescript语言基础: 官方文档
  • 在前端,Oak目前使用React作为网页端框架(尽管这不是必须,但由于团队技术力量等原因,短期内并没有计划去适配vue等其它框架),因此您也需要掌握React的一些基本概念。如果您需要开发App或者小程序,也需要去了解一些Oak所采用的技术本的相关技术。
  1. React: 官方站点
  2. React-native 官方站点
  3. 微信小程序开发 官方站点

对于其它更多的前端环境,Oak也将在未来进行适配。Oak的前端技术路线介绍请参见:目录文件结构

对于新手开发者,可能对上述这么多的前置知识学习感到望而生畏。没关系,理论上只要了解并掌握基础概念即可进行开发,更多的技术细节可以在开发过程中再不断学习补充。

开发环境

当前 oak-clipackage.json 已经要求 Node.js >=20.0.0,新项目建议直接使用 Node.js 20 以上版本。推荐使用 Microsoft VS Code 作为开发 IDE。

新手入门:从 Web CRUD 到完整 Oak 组件树

本教程面向第一次接触 Oak 的开发者。我们从一个不安装 oak-general-businessoak-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. 先建立正确的学习顺序

第一次学习请按顺序完成:

  1. 安装 Oak Assistant (oak-team)
  2. 创建无公共业务依赖的 Oak 项目。
  3. 定义 Todo 实体并生成领域代码。
  4. 理解 ListNode、SingleNode 和 oakPath
  5. 用 Todo 列表 + Modal Upsert 完成基础 CRUD。
  6. 真实初始化数据库并逐项验证 CRUD。
  7. 新增 Group,与 Todo 建立一对多关系。
  8. 把 Web 页面升级为完整关系组件树。
  9. 再进入微信小程序DesktopNative
  10. 最后阅读知识归纳

不要一上来背 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
  • 不使用 anynever@ts-nocheck 绕过错误;
  • XML 使用到的字段必须由 dataproperties 或编译器可识别的 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 类似一张表,titlecompleted 是业务字段,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 创建列表草稿;
  • updateItemremoveItem 修改某一项;
  • 执行整条列表分支。

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>
        </>
    );
}

为什么保存必须由列表执行

addItemcreate 操作放在 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

按顺序验证:

  1. 新增任务并刷新页面,记录仍存在;
  2. 编辑标题并刷新,标题已更新;
  3. 切换完成状态并刷新,状态已保存;
  4. 删除任务并刷新,记录不再出现;
  5. 新增任务后点击取消,刷新后没有该记录。

不要只看“操作成功”提示。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. 完整验证分组关系

重新初始化或升级数据库后验证:

  1. 新增 Group,刷新后仍存在;
  2. 新增两个 Group,tabs 可切换;
  3. 在 A 组新增 Todo,切到 B 组看不到它;
  4. 切回 A 组能看到该 Todo;
  5. 编辑 Todo、切换完成状态、删除并刷新;
  6. 取消新增 Group 或 Todo,数据库不产生记录;
  7. 构建无 TypeScript、XML 或样式 diagnostics。
npm run make:locale
npm run build
git add .
git commit -m "feat: group Todo tasks through component tree"

12. 再增加其它端

现在才进入多端,因为此时业务模型和组件树已经稳定:

多端不是复制业务逻辑。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_HOMEnative/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 中安装

  1. 打开 VS Code。
  2. 打开左侧“扩展”。
  3. 搜索 Oak Assistant (oak-team)
  4. 确认发布者是 oak-team
  5. 点击安装。
  6. 安装或升级完成后执行 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 或更低版本。

操作步骤:

  1. 先在项目根目录执行 npm install,确保 node_modules/typescript 存在。
  2. 在 VS Code 中打开任意 .ts.tsx 文件。
  3. 打开命令面板。
  4. 执行 TypeScript: Select TypeScript Version
  5. 选择“Use Workspace Version”。
  6. 执行 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 路径和已发现的组件。然后依次确认:

  1. VS Code 状态栏使用的是工作区 TypeScript。
  2. 项目已经执行过 npm install
  3. 新增小程序 workspace 后已经执行过一次对应构建或 metadata 生成流程。
  4. 修改设置或插件版本后已经重新加载窗口。

如果仍然没有诊断,打开“输出”面板并选择 oak-assistant 通道查看原因。

5. 它会检查哪些文件

实体文件

对于 src/entities/*.ts,插件会检查:

  • Schema 是否正确继承 EntityShape
  • 字段、反向指针和继承关系是否合法;
  • Action、State、Relation、索引和 locale 是否匹配;
  • 是否使用系统保留名称;
  • 多重继承或循环关系是否冲突。

这对应传统后端或 ORM 开发中的“模型定义检查”。区别是 Oak 还会由实体继续生成前后端共享类型,所以实体错误会传播到页面、权限和数据库定义。

当前实体诊断会把同一文件的独立问题分别显示为 TS9300 - TS9327,范围落在实体名、属性、继承、ActionDef、locale 或 index 的真实节点。插件只读源码,不会替你运行或修改 make:domain 产物。

TSX render

插件会分析 web.tsxweb.pc.tsxweb.mobile.tsxrender.desktop.tsxrender.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 继承到本地文件,因此 titlesubmit 等成员仍有补全、hover、跳转和错误诊断。不要为了让这个 wrapper 通过检查再复制一份 WebComponentProps,否则公共组件升级后,本地手写类型很容易与真实合同分叉。

这不是任意导出的自动猜测。当前必须同时满足:默认导入名是 OakComponent,并且直接写 export default OakComponent。如果本地 index.ts 自己定义了 OakComponent({...}),本地定义优先;如果你需要增加新的 properties 或改动业务行为,就应建立本地真实组件合同,而不是继续把它当作透明转发。

Less Module

插件会检查 Styles.page 对应的类名是否真实存在,支持补全、悬浮和跳转,也理解嵌套选择器的作用域。

这和普通 CSS 的区别是:类名不再只是运行时字符串,而是 render 合同的一部分。拼错 Styles.compoesr 应当在编辑时被发现,而不是上线后才看到样式丢失。

XML/WXML

插件会检查:

  • 标签和原生属性;
  • usingComponents 和 Oak 自定义组件 properties;
  • datapropertiesmethodsformData
  • bindtapbindinput 等事件方法;
  • 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 变量共同构成组件输入合同
后端实体和动作类型会继续约束后端查询与写入
OakOak Assistant 把编译器生成的领域和渲染合同提前带入编辑器

最终结论:Oak Assistant 负责早发现,npm run build 负责最终确认。两者都要使用。

下一步回到新手入门主线,从创建项目开始完成任务清单。

微信小程序:把完整组件树接到 XML

开始本章前,Web 端应当已经完成 Group + Todo + Upsert。小程序不是重新写 CRUD,而是为同一组 OakComponent 增加 index.xmlindex.lessindex.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.tsmp 中声明:

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>

selectedGroupIdeditingGroupId 必须在 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 同理声明 titlecompleted,再使用 <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. 验证清单

在微信开发者工具中逐项验证:

  1. 新增 Group 并重进页面;
  2. tabs 切换后 Todo 相互隔离;
  3. 新增 Todo 后数据库得到正确 groupId
  4. 编辑、完成切换、删除均持久化;
  5. 取消 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 中的 setNamesetTitlesetCompleted。不要把 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 安装包。

验证:

  1. 原生窗口和 Desktop 导航正常;
  2. Group tabs、Modal Upsert、Todo 关系列表正常;
  3. 刷新或重启应用后数据仍存在;
  4. 主题和语言设置页没有类型绕过;
  5. 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 使用 TextInputSwitch

<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 outputDone copying assets,说明路由、Oak transform、Native render、Sass 与 JS 依赖已成功打包。它不能代替最终 APK 构建,但能把“代码问题”和“本机 SDK 问题”分开。

9. 验证

  1. Group tabs 可以横向滚动和切换;
  2. Group/Todo Modal Upsert 可新增和编辑;
  3. Todo 保存后关联正确 Group;
  4. 重启 App 后数据仍存在;
  5. Android cleartext/network 配置与 Oak access 地址一致;
  6. 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 对应
grouptodo实体 Storage
一行一个分组、一条任务实体 Schema
主键idEntityShape 提供的 id
外键todo.groupIdTodo.Schema.group 引用
一对多一个 Group 有多个 Todo生成关系 todo$group
SELECT 列id、name、titleprojection
WHERE只查某组任务组件树关系节点与 filter
ORDER BY新任务优先sorters
INSERT新增 Group/TodoaddItem 形成 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 组件树还要说明:

  1. 当前组件对应哪一个实体数据节点;
  2. 查询结果缓存在哪个节点;
  3. create、update、remove 操作属于哪个节点;
  4. 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组件本地状态和稳定初值selectedGroupIdeditingTodoId
formData把实体结果整理成 render 需要的形状groupstodos
properties父组件传入的业务参数只有确实需要普通输入时声明
methodsrender 可调用的业务交互saveTodosetTitle
框架注入Oak 节点状态与标准方法oakFullpathoakLoading

当前 Oak 编译器会根据同目录 index.ts 生成 render props。标准 TSX render 写:

export default function Render(props) {

不要手写宽泛 props 类型。render 使用了未声明字段时,应回到真实来源修复:父组件输入放进 properties,查询派生结果放进 formData,本地状态放进 data,交互函数放进 methods

小程序 XML 也使用同一合同检查字段、事件、组件属性和 class。anynever@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小程序DesktopNative
Group/Todo 实体共享共享共享共享
关系名 todo$group共享共享共享共享
Group/Todo 组件逻辑共享共享共享共享
节点执行层级共享共享共享共享
渲染技术React/AntDXML/WXMLReact/Fluent UIReact Native
平台事件适配DOM小程序 eventDOM/FluentNative 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 增加截止日期:

  1. 从数据库角度判断字段类型、是否可空、旧数据和索引。
  2. 修改 Todo 实体与 locale。
  3. 运行 make:domainmake:locale,检查生成类型。
  4. 把日期加入 TodoList 和 TodoUpsert projection。
  5. 在 Upsert index.ts 增加统一 setter。
  6. Web 先实现日期控件并完成真实 CRUD。
  7. 若日期规则必须全端成立,写 checker,不在 render 中复制。
  8. 再为小程序、Desktop、Native 增加平台日期控件。
  9. 对已有数据库生成并审核升级计划。

12. 提交前检查

  1. 实体变化后运行 make:domainmake:localebuild 和项目要求的升级命令。
  2. 真实验证 Group 与 Todo 的新增、查询、更新、删除。
  3. 确认新增 Todo 的 groupId 正确,不只是界面显示在某个 tab 下。
  4. 运行目标平台构建,处理 XML、render props 和 Less Module 检查。
  5. 搜索并清除 anynever@ts-nocheck 和手写宽泛 props。
  6. 不提交数据库密码、token、local.properties、keystore 或本地数据库文件。

回到新手入门主线,或继续查看微信小程序DesktopNative的完整实现。

创建项目

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 模块,而不是完整应用。模块模式下不会生成 webwechatMp 等前端工程骨架,适合编写一个可复用的业务包。

创建过程中会询问什么

执行创建命令后,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-domainoak-frontend-baseoak-general-business 等库,那么项目目录最好和这些仓库处在同一级目录下,否则这些本地依赖路径就无法正确解析。

如果不使用 --devoak-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 会完成下面这些事情:

  1. 检查目标目录是否已经存在;
  2. 复制模板目录;
  3. 在完整应用模式下生成默认 web workspace;小程序、React Native 和 Desktop workspace 留给后续 oak-cli add
  4. 生成 oak.config.jsonoak.config.ts,前者保存项目运行/检查配置,后者保存当前 Vite / webpack / alias / CDN 等编译配置;
  5. 生成 tsconfig.lib.jsontsconfig.es.jsonsrc/tsconfig.jsontsconfig/paths.*.jsonweb/tsconfig.json;新增平台时再生成对应 workspace 配置;
  6. 生成 package.json、Oak 默认脚本、推荐 Web CDN 配置、可选 build:bundle 和升级脚本入口;
  7. 把你选择的依赖写入 src/configuration/dependency.ts
  8. 在页面、组件和 Web 命名空间目录中放置 index.config.ts,作为新项目的结构化配置入口;
  9. 将模板中的默认项目名统一重命名为你输入的名字;
  10. 根据数据库选择只保留对应驱动依赖和 configuration/mysql.jsonpostgres.jsonsqlite.json
  11. 如果勾选示例,额外复制一套示例业务代码。

因此,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 能力,例如 usingComponentscomponentGenericsstyleIsolation
  • 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.tsoak-cli 根据命名空间配置和页面菜单配置生成,不应该手工维护;index.ts 负责重新导出聚合结果;web/src/index.tsx 会把它传给 oak-frontend-base/platforms/web/initializenamespaceConfigs 参数。

创建完成后的第一步

项目创建完成后,建议按下面的顺序继续:

npm install
npm run dev

这三步分别对应:

  • 安装依赖,并由 postinstall 顺序执行 project:initmake:domainmake: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,最推荐的起步方式其实非常简单:

  1. 在 Oak 家族仓库同级目录下执行 oak-cli create xxx --dev
  2. 依赖 oak-general-business
  3. 执行 npm install;当前模板会在 postinstall 中完成 project:initmake:domainmake:dep
  4. npm run dev 同时启动后端和 web;
  5. 编写一个最小的 Entity 和一个最小的页面。

这样学习曲线是最平缓的,也符合当前 Oak “开发时就使用真实后端运行态”的默认模板。旧的纯前台模式已经移除,不要再按 start:web 单独替代后端来理解。

需要其它平台时,再按多平台工作区添加,不必在创建当天把所有原生工具链一并安装。

多平台工作区

当前 Oak 应用把业务源码和平台壳分开组织:src 保存实体、业务逻辑、页面和组件,Web、微信小程序、React Native、Desktop 各自拥有独立 workspace。新应用默认只创建 Web;其它平台在真正需要时通过 oak-cli add 加入。

平台模型

平台添加命令构建目标说明
Web默认生成,或 oak-cli add web web2web浏览器 renderer
微信小程序oak-cli add mp wechatMpmp / wechatMpVite 小程序编译与微信产物
React Nativeoak-cli add rn nativern / nativeiOS、Android 原生应用
Desktopoak-cli add desktop desktopdesktop默认 Tauri,也支持 Electron

mprn 是命令别名,内部会规范为 wechatMpnative。workspace 名可以自定义,CLI 会把 --subDir 写入对应 npm script;不要在业务代码里假设目录一定叫 wechatMpnativedesktop

添加 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=tauriOAK_RENDER=electron 区分原生壳。oak-general-business 中的应用仍使用 Web Application 类型,并通过 config.renderidentifyId 区分浏览器、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.navigateToredirectToreLaunchswitchTab 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_HOMEnative/android/local.properties 时会在 SDK 检查处失败。不要把 local.properties、keystore、签名密码或渠道凭证提交到仓库。

页面/组件优先使用 render.native.tsxrender.native.scss,需要平台差异时再增加 render.ios.tsxrender.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/entities
  • lib/entities
  • src/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.ts
  • src/initializeFeatures.ts
  • src/features/index.ts
  • src/types/RuntimeCxt.ts
  • src/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_HOMEnative/android/local.properties;iOS 构建要求 macOS 与 Xcode。Metro bundle 成功只能验证 JavaScript、路由和 render 链路,不能代替 APK/IPA 构建。

旧文档里的无 workspace 后缀 run:androidrun:iosrun: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-jsonbuild:lib 生成后端运行需要的 lib 产物;build:es 使用 tsconfig.es.jsonnoEmit 检查,并启用 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.jsdist/package.jsondist/targetdist/configurationdist/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.sqlrollback.sqlsummary.jsontable-changes.jsonwarnings.jsonrename-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。如果计划里有 manualSqlwarningsrenameCandidates,先人工确认再进入发布执行。

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 已添加时存在。这里的 wechatMpnative 是示例 workspace 名;自定义名称会生成对应后缀,例如 start:mp:mp-adminstart:native:mobilestart:desktop:ops-desktop

不要再把 start:webstart:mp 理解成旧文档里的“前台模式”。

开发

当前 Oak 模板已经移除了旧的“纯前台模式”。开发时应把后端运行态视为默认组成部分:web / 小程序 / App 负责目标端渲染,server:start 负责真实的 Oak 后端运行、数据库访问、aspect、endpoint、watcher、timer 和 routine。

1. 生成领域代码

修改项目实体,或升级依赖模块实体产物后,先执行:

npm run make:domain

依赖模块实体会优先从 es/entities 读取,其次才是 lib/entitiessrc/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:androidrun:iosrun: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:domainproject:initmake:dep

部署

Oak 的开发模式非常灵活,但真正部署到线上时,建议把它理解成两部分:

  • 前端产物的构建与发布;
  • 后端服务进程的编译、初始化与运行。

本地开发也应默认使用真实后端运行态。当前模板的 npm run dev 会同时启动 server:startstart:web,线上部署时则应把后端服务进程和各目标端产物分开发布。

部署前要先明确的事实

一个真正启动起来的 Oak 后端,不只是提供查询和保存数据这么简单。根据 oak-backend-base/src/AppLoader.ts 的实现,服务启动后还会继续负责:

  • 装配所有 triggerchecker 和内置逻辑;
  • 暴露 aspectendpoint
  • 注册导入导出 port
  • 每 120 秒轮询一次 watcher
  • 通过 node-schedule 运行 timer
  • 在启动和停止时执行 routines/startroutines/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:domainnpm run make:dep。这一步完成后,项目中的 TypeScript 后端代码会被编译到 lib 目录。真正上线运行的 Node.js 进程,就是基于这里的代码。

已有数据库的结构变更不要直接混在 server:init 里处理。编译后先生成升级计划:

npm run db:upgrade:plan

它会在 .oak-upgrade/<时间戳> 下输出 migration.sqlrollback.sqlsummary.jsontable-changes.jsonwarnings.jsonrename-candidates.json。审核无误后,再由 DBA 或发布脚本执行结构升级。只有明确接受风险时,才使用:

npm run db:upgrade:plan -- --execute

如果计划里有 manualSqlwarningsrenameCandidates,不要直接执行;先确认是否是列/索引重命名、大表索引调整、枚举变更或需要人工拆分的 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 后端运行进程。通常线上环境还会再用 pm2systemd、容器编排系统等方式去守护这个进程,这一层属于通用 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.jsonidentifyId 与后端 Desktop Application 配置一致;
  • src/configuration/access.ts 指向真实后端;
  • 安装包签名、自动更新和操作系统权限符合项目发布策略。

但无论是哪一个前端目标端,只要业务逻辑依赖真实数据库、第三方服务、消息推送、定时任务或导入导出能力,线上都必须配套部署 Oak 后端。

四、推荐的上线顺序

一个比较稳妥的上线顺序如下:

  1. 在构建机上执行生成与编译脚本;
  2. 发布后端代码和配置;
  3. 对已有数据库执行并审核 db:upgrade:plan
  4. 首次部署执行 server:init,已有库执行审核后的结构升级 SQL;
  5. 执行权限、i18n、静态数据升级脚本;
  6. 启动新的后端进程;
  7. 再发布前端静态资源或多端包。

如果你的项目已经进入持续发布阶段,还应把数据库变更、静态数据更新和前端发布进一步拆成明确的流水线步骤。Oak 在框架层面已经把“对象定义”“依赖装配”“国际化编译”“服务端运行”这些关键阶段分清楚了,部署脚本只需要老老实实顺着这个顺序来。

五、不要把开发命令直接当成生产命令

最后强调一个很容易被新手忽略的问题:

start:webstart: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.jsontsconfig.es.jsonsrc/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.tswechatMp/src/app.less 全局事件处理和样式
  • wechatMp/mp.config.ts 小程序基础配置
  • wechatMp/package.config.ts 页面和分包配置
  • wechatMp/src/sitemap.json 小程序 sitemap 配置

如果是开发 App,在 native 目录下您还需要编写:

  • native/App.tsxnative/index.tsx 全局事件处理和插件加载
  • native/router/index.ts App 路由配置

如果是开发 Desktop,在对应 workspace 下主要维护:

  • src/index.tsxsrc/app/namespaces/desktop 的桌面壳与 namespace
  • Tauri 的 src-tauri,或 Electron 的 electronelectron-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 运行时值合并解析。

这意味着模块发布时要保证:

  • SchemaActionState、枚举类型等声明出现在 .d.ts 中;
  • entityDescActionDef 等运行时值出现在同名 .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>TextString<L> 适合短文本,Text 适合长文本
文件类ImageFile图片、文件资源标识
时间类DatetimeDayTime日期时间相关
布尔Boolean布尔值
地理位置GeoSingleGeo地图和坐标相关

例如 Address 中就有这样一组非常典型的字段:

export interface Schema extends EntityShape {
    detail: String<32>;
    phone: String<12>;
    name: String<32>;
    default: Boolean;
    remark?: Text;
}

这里还有两个很实用的经验:

  • 是否可选,直接用 ? 表达,例如 remark?: Text
  • TextImageFileObject 在框架内部被视为不适合建立普通索引的类型,设计索引时要慎用。

2.2 枚举、字面量联合与可选字段

Oak 不要求所有字段都必须来自 DataType。像枚举、字面量联合这类纯 TypeScript 写法,框架也是支持的。

例如 oak-general-businessUser

export interface Schema extends User {
    gender?: 'male' | 'female';
    idCardType?: 'ID-Card' | 'passport' | 'Mainland-passport';
}

这种写法非常适合:

  • 性别、状态等级、来源类型这类有限枚举值;
  • 需要在 entityDesc.locales.v 中统一做显示文案映射的字段;
  • 后续还要配合颜色、图标做统一展示的字段。

2.3 对象字段与 JSON 风格字段

如果某个字段本身就是一个复杂对象,Oak 也允许你直接使用 TypeScript 对象类型,而不必强行拆成大量基础字段。

例如 oak-general-businessApplication

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-businessUser

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 表示父对象的主键;
  • 它们不是普通业务字段,不要拿这两个名字表达别的含义;
  • 类型也不要自行改动。

AddressSessionAccountOperOperEntity 都使用了这种模式。

如果你希望某个对象可以成为这类动态关联的“父对象”,通常还要在父对象上补出对应的一对多数组关系。例如 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: WebConfigNativeConfig`
普通多对一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 / UserActionDef
  • IdAction / IdState / IdActionDef

最后再把所有动作汇总成当前对象的 Action

export type Action = UserAction | IdAction | 'play';

这也是比较推荐的写法:不同语义的状态机分开写,最后统一汇总动作。

自定义 Action 不一定非得改状态

不一定。Oak 支持你定义“不改变状态,但有明确业务语义”的动作。

这类动作依然很有价值,因为它们会:

  • 让操作日志更清楚;
  • 让权限控制更细;
  • 让 trigger、checker、aspect 中的业务判断更明确。

通用 Action 关键字

框架本身还保留了一组通用动作名:

  • create
  • update
  • remove
  • select
  • count
  • download
  • aggregate
  • stat

因此你定义自己的 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动作名
rRelation 名
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唯一索引
typefulltextbtreehashspatial
parserMySQL 全文索引分词器
tsConfigPostgreSQL 文本检索配置
chineseParserPostgreSQL 中文分词配置

索引设计时有两点要牢记:

  • 对象查询能力会受到索引声明影响,例如全文搜索必须先有全文索引;
  • TextImageFileObject 这类字段不适合作为普通索引候选。

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 用于声明对象的整体行为模式。当前最常用的是 actionTypestatic

actionType 支持的值如下:

含义
readOnly只读对象
appendOnly只能新增,不能改删
excludeUpdate不允许更新
excludeRemove不允许删除
crud标准增删改查

公共业务包里有两个很好的例子:

  • Area 使用 configuration: { actionType: 'readOnly', static: true },表示它是只读维表;
  • AccountOperOperEntity 使用 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;
  • 查询、聚合、级联更新等运行时能力。

所以要记住两件事:

  1. 只要改了 src/entities,就应重新执行 npm run make:domain
  2. 只要依赖模块的实体产物更新,也应重新执行 npm run make:domain
  3. 如果项目已经上线,修改对象定义时还要同时维护升级脚本,保证数据库结构和线上数据能正确迁移。

下一小节会继续介绍:对象编译完成后,如何查询和操作这些对象。

7. 编写对象时最常见的坑

  • 只写了 Schema,却没有把 entityDesc.locales 补全,导致后续显示名称到处不统一。
  • entityentityId 当成普通字段使用。它们在 Oak 中是动态多对一关系的保留写法。
  • 在一个对象里试图定义两组不同含义的动态多对一关系。Oak 并不支持这种设计。
  • 在父对象上忘记声明反向数组关系,结果后续查询和级联语义不完整。
  • 继续使用 FloatDouble 设计业务金额,新项目应优先使用 Decimal
  • TextImageFile、对象字段随意建普通索引,后续查询效果和存储成本都可能出问题。
  • 修改对象后忘记重新执行 npm run make:domain
  • 升级依赖模块后忘记重新执行 npm run make:domain,导致 oak-app-domain 仍然是旧类型。
  • 项目已上线后,只改了对象定义,却没有补数据库升级脚本。
  • 先把 entityDesc 组装到别的变量里再赋值。当前编译器是直接解析 entityDesc 字面量的,这种写法不稳妥。
  • 继续沿用旧式的 localesindexesconfiguration 分散定义方式。当前推荐的规范写法是统一收进 entityDesc
  • 覆盖默认实体时只考虑新增字段,没有保持旧动作、状态、关系和常量兼容。

如果你第一次写对象,建议按下面这个顺序来:

  1. 先把 Schema 写清楚;
  2. 再补普通关系、自关联、动态关系;
  3. 如果对象有业务状态,再补 Action / State / ActionDef
  4. 最后把 entityDesclocalesindexesstyleconfiguration 补完整。

按这个顺序写,对象定义通常就不会乱。

查询和操作对象

编译

在编写或更新了Entity定义后,都需要执行命令来编译完整的对象数据字典

npm run make:domain

编译出来的数据字典声明在src/oak-app-domain目录下,同时也会编译出来一个数据的存储格式供框架引用,可以在代码中像这样去引用它们:

// EntityDict是数据字典声明,StorageSchema是存储格式定义
import { EntityDict, StorageSchema } from '@project/oak-app-domain';

数据字典和存储格式是整个Oak框架最核心的内容,贯穿于使用框架的各个层面,因此需要深刻理解。本章节将使用上小节的AddressArea对象,介绍一些查询和操作的核心概念。

编译后的对象结构

编译后的对象原生结构称为OpSchema,其结构仅仅在用户定义的属性上增加了一些通用的属性类型,以及将引用对象转化成了外键。

每个Entity的OpSchema可以在编译后的oak-app-domain/${Entity}/Schema.ts中查看,本章下面的大多数数据结构都是如此

对象上增加的通用属性包括:

属性类型含义
idstring<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,
}, {});

这里也要按源码现状理解:distinctSelection 类型和 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$necompare.ts
字符串$startsWith$endsWith$includes$concatstring.ts
布尔/逻辑$true$false$and$or$notbool.ts
数学$add$subtract$multiply$divide$abs$round$floor$ceil$pow$modmath.tscomplax.ts
日期$year$month$weekday$weekOfYear$day$dayOfMonth$dayOfWeek$dayOfYear$dateDiff$dateFloor$dateCeildate.ts
引用节点#attr#id#refId#refAttrDemand.tsbase.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 中定义的常用过滤语法如下:

算子参数类型作用
$gtnumber | string大于
$gtenumber | string大于等于
$ltnumber | string小于
$ltenumber | string小于等于
$eqnumber | string | boolean等于
$nenumber | string | boolean不等于
$in(number | string)[]在……中
$nin(number | string)[]不在……中
$between[number, number]在……之间(含边界)
$mod[number, number]取模
$startsWithstring以……开头
$endsWithstring以……结尾
$includesstring包含……
$existsboolean字段是否存在/是否为空
$andFilter[]
$orFilter[]
$notFilter
$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.tsoak-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 中还定义了 JsonFilteroak-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 也都已经实现了翻译。但这里有两个前提必须同时满足:

  1. 对象上必须声明全文索引;
  2. 当前数据库实现要真的支持对应的全文检索语法。

一个典型写法如下:

{
    $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-noak-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.tsoak-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 链路更偏框架层能力
ignoreAttrMisscache 场景下允许属性缺失前端缓存相关

例如:

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 lockednowait 这类数据库方言,仍建议自己补集成测试。

软删除数据如果要重新查出来,则要显式带上:

const rows = await context.select('house', {
    data: { id: 1 },
    filter: { id: houseId },
}, {
    includedDeleted: true,
});

一个总的建议

查询语法这块,新同学最容易犯的错,就是把 Oak 当成“只有 Mongo 风格 filter 的 ORM”。实际上 Oak 当前已经同时支持:

  • 关系级联 projection;
  • 子查询谓词 #sqp
  • $expr 表达式;
  • JSON 嵌套过滤;
  • 聚合查询;
  • 软删除可见性控制;
  • 加锁查询;
  • 列表页额外 total 与随机抽样。

因此遇到“这个语法到底能不能写”的问题,正确的核对顺序应该是:

  1. 先看生成后的 Entity 类型;
  2. 再看 oak-domain/src/types/Demand.tsExpression.ts
  3. 最后看 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

声明更新的数据。更新的数据可以是两种:

  1. 自身的数据属性
    例如要更新地址的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

  1. 级联数据属性 同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关联。

下面列出了框架所支持的级联更新的情况:

  • 子对象级联父对象
子对象父对象效果
createcreate父子对象和关联关系一起创建
updatecreate更新子对象、创建父对象及关联关系(如果原来子对象上有关联的父对象关系会丢失)
updateupdate更新子对象,同时更新关联的父对象
updateremove更新子对象,同时删除关联的父对象及关联关系
removeupdate移除子对象及关联关系,同时更新关联的父对象
removeremove同时移除父子对象,以及关联关系
  • 父对象级联子对象
父对象子对象效果
createcreate创建父子对象,并创建关联关系
updateupdate更新父对象,并更新关联的子对象
updateremove更新父对象,同时删除关联的子对象

编写组件/页面

设计完Entity后,您可立即进入应用页面的编写(Oak框架会自动处理发请求、取数据、缓存等一系列工作,不需要你再编写任何一行相关代码了😁)。

编写组件/页面可以认为是应用编写最复杂的部分,因此这部分内容也较为繁重。直接编写完组件/页面,您就已经拥有一个可以演示的应用了。

三层架构

在Oak框架中,前端部分的结构可以由高到低分为三个层次,如下图所示: 层次结构

namespace

namespace是指应用在最顶层被划分成几个命名空间,每个命名空间中包含若干页面,命名空间一般按顶层路由来划分,同一个命名空间中的整体布局是相同的。例如,一个典型的网站会分为frontendconsole两个命名空间,分别代表普通用户访问的前端和管理人员访问的控制台。前者有统一的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用于声明未单独配置访问规则的页面默认如何处理,常见取值为allowdenylogin

管理端命名空间还可以在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中声明menuroute.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目录下,同样是一个子目录代表一个组件。编写组件的目的是:

  1. 为了在多个page之间复用代码
  2. 避免page过于复杂

在Oak中,一个page可以包含多个component,一个component也可以引用其它component,但在component之间不能出现循环引用。

page之间不要相互引用,尽管这样做也不会错,但会让整个项目变得更加混乱,难以维护。

目录文件结构

src/pagessrc/components目录下,您可以编写应用页面和组件了。每个页面/组件都占据着唯一的子目录,在子目录下可能有如下若干文件:

文件名作用
index.ts定义页面/组件的逻辑(必需)
index.config.ts页面/组件的结构化配置。页面使用CreatePageConfig,组件使用CreateComponentConfig
index.json历史兼容的小程序页面/组件JSON配置。新代码优先使用index.config.ts
locales/zh-CN.json页面/组件的i18n内容
web.pc.tsx宽屏的html渲染
web.tsxweb通用渲染,在没有更具体入口时回退
web.mobile.tsx移动宽度下优先使用的html渲染
web.pc.module.less宽屏样式
web.module.less窄屏样式
index.xml小程序渲染
index.less小程序样式
render.native.tsxApp渲染
render.native.scssApp渲染样式
render.ios.tsxios渲染
render.android.tsxandroid渲染

看上去有些复杂,但是实际上绝大多数项目都只会实现其中的部分文件。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中的配置项,例如usingComponentscomponentGenericscomponentPlaceholderstyleIsolationnavigationBarTitleText等。保留index.json仍然可以兼容旧项目,但新页面和新组件建议优先写index.config.ts

web端

如果您的应用基于 web,可以按需要提供 web.tsxweb.pc.tsxweb.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.tsmp字段中,例如usingComponentsnavigationBarTitleTextcomponentGenerics等;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/panelSystem 的容器组件;
  • system/detail 展示并承接提交入口;
  • system/upsert 负责编辑 System
  • system/application 展示并管理属于某个 SystemApplication 列表。

application/panel 也采用了同样的模式:自己先取当前 Application 的核心数据,再把配置、样式、公众号能力、模板能力等拆成多个 tab 子组件。

这类 panel 组件,是 Oak 项目里非常值得掌握的一种组织方式。

优先复用的顺序

在 Oak 项目里,建议按下面这个顺序考虑复用,而不是一上来就自己写一套新组件。

1. 先看 oak-frontend-base 有没有抽象组件

oak-frontend-base/src/components 里有一批通用抽象组件,比如:

  • detail
  • upsert
  • list
  • filter
  • filterPanel
  • pagination
  • actionBtn
  • refAttr

这类组件的特点是:

  • 它们通常不代表某个具体业务;
  • 更像“对象展示器”“对象编辑器”“列表渲染器”;
  • 更适合拿来快速搭一个管理页、详情块、编辑块。

2. 再看 oak-general-business / oak-pay-business 有没有完整业务组件

如果你的需求已经落在公共业务包的职责范围里,优先复用它们现成的业务组件。例如:

  • SystemPanel
  • ApplicationPanel
  • 用户、登录、授权、消息、文件类组件;
  • 支付、账户、提现、物流类组件。

这些组件不仅有 UI,还通常已经把对象结构、路径组织、交互流程、动作提交一并处理好了。

3. 只有当公共组件不匹配时,再写项目私有组件

通常在下面几种情况下,才值得自己新写:

  • 现有公共组件的对象结构与你项目不一致;
  • 页面交互或展示方式有明显差异;
  • 你需要新增项目私有字段、动作或子组件关系;
  • 你需要一个专门的 panel 来组织项目内多个公共能力。

oak-frontend-base 抽象组件怎么用

这部分文档以前讲得还不够。这里直接按源码整理一份速查表。

抽象 detail 组件

源码入口:

oak-frontend-base/src/components/detail/index.ts

这类组件更像“详情展示壳”。它本身不负责对象取数,而是渲染父组件已经拿到的数据。

常用参数如下:

参数作用
entity当前展示的是哪个对象
attributes要展示哪些字段
data当前行数据
title标题
bordered是否带边框
layouthorizontalvertical
column列数,可按断点配置

适合场景:

  • 某个 panel 已经取到了当前对象数据;
  • 你只想快速把一组字段渲染成标准详情块;
  • 不想每次都手写 Descriptions / label 映射 / 枚举颜色逻辑。

抽象 upsert 组件

源码入口:

oak-frontend-base/src/components/upsert/index.ts

这类组件更像“表单编辑壳”,主要负责把字段定义转成输入控件,并通过 update(...) 写回当前结点。

常用参数如下:

参数作用
entity当前编辑的是哪个对象
attributes要编辑哪些字段
data当前行数据
layout表单布局
modedefaultcard
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 限定候选范围;
  • 标题如何渲染;
  • 选择模式是 radioselect 还是别的形态。

源码里还有两个很实用的约束:

  • 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:日期选择器;
  • booleanenum:选择器;
  • 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.ts
  • oak-frontend-base/src/components/actionTabPanel/index.ts

这两个组件都属于动作集合展示壳,区别主要在布局:

  • actionBtnPanel 更像按钮面板 / 操作工具条;
  • actionTabPanel 更像分页签 / 卡片式动作网格。

actionBtnPanel 的常用参数:

参数作用
entity动作所属对象
items要展示的动作项
mode展示模式,如 defaultcelltable-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,但没有自定义 onReloadlistPro 工具条上的刷新会直接调用 features.runningTree.refresh(oakPath)
  • 因此它最适合和 Oak list 结点放在同一路径上下文里使用。

适合场景:

  • 后台标准列表页;
  • 列表上方带标题、按钮、刷新入口;
  • 想复用 List + ToolBar 的统一外观,而不是每次手拼。

在真实项目中,这种用法非常常见。例如:

  • oak-pay-business/src/components/order/list/web.pc.tsx
  • taicang/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页面主体内容

适合场景:

  • 后台管理页的统一页头;
  • 列表页、详情页、配置页的内容容器;
  • 你希望把“页面标题 + 筛选区 + 列表区”包在一个稳定的视觉外壳里。

taicanghaina-busi 里,这类页面壳的使用都非常多,尤其是:

  • 列表页最外层包 PageHeader
  • 页头内部先放筛选区;
  • 下方再放 ListPro 或详情内容。

关系权限管理相关组件

源码入口:

  • oak-frontend-base/src/components/relation/path/list
  • oak-frontend-base/src/components/relation/path/detail
  • oak-frontend-base/src/components/relation/path/upsert
  • oak-frontend-base/src/components/relation/actionAuth
  • oak-frontend-base/src/components/relation/relationAuth

这组组件不是通用页面壳,而是围绕系统实体 pathactionAuthrelationAuth 的专用管理组件。它们主要用于:

  • 配置对象路径;
  • 配置动作授权矩阵;
  • 配置关系授权矩阵。

对新手来说,先把它们理解成“系统管理后台专用组件”就够了。平时业务开发里,不建议把它们当成通用抽象组件到处复用;真正需要对象关系授权配置时,再顺着这几个目录去读源码会更合适。

再补一个实用建议:通用组件放哪一层

这些抽象通用组件,最常见的放置位置通常是:

组件推荐放置层
detaildetail / panel 子块
upsertupsert / 弹窗 / 步骤块
list业务 list 外壳内部
actionBtn列表操作列、详情页动作区
actionBtnPanel / actionTabPanel首页动作区、页头动作区、操作面板
searchlist 页顶部快速搜索区
filter自定义单筛选项区域
filterPanellist 页顶部或侧边筛选区
refAttr表单字段内部
picker弹窗/抽屉中的对象选择区
paginationlist 页底部
listPro后台标准列表页主体
pageHeader页面最外层壳

在项目里通常会再包一层 AbstractComponents

这也是实际项目里非常常见的一步。

taicangoak-pay-businesshaina-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 类型;
  • 页面里不用每次都手写一长串泛型;
  • 后续如果要统一替换或二次封装抽象组件,也有一个稳定入口。

如果你的项目已经进入“组件越来越多”的阶段,很建议尽早建立这层封装。

一个后台列表页的推荐拼法

如果你现在正在写一个后台列表页,最稳妥、也最接近真实项目的组合通常是:

  1. 最外层用 pageHeader 做页面壳;
  2. 页头或顶部区域放 filterPanel
  3. 主体区域放 listPro
  4. 行内新增、编辑再用弹窗挂 upsert
  5. 详情跳转或局部编辑再决定用共享路径、行路径或绝对路径。

一个非常典型的渲染结构大概像这样:

<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>

这里要特别注意一件事:

  • FilterPanelListPro 最好放在同一条 list 路径上;
  • 这样筛选条件、刷新动作、分页状态才会自然落在同一个 runningTree 结点里。

什么时候用抽象组件,什么时候自己写 OakComponent

可以用下面这个标准判断:

优先用抽象组件

当你只是需要:

  • 展示一条当前对象数据;
  • 编辑一条当前对象数据;
  • 渲染一组当前对象数据;
  • 做标准字段展示、标准字段编辑、标准表格列表。

优先自己写 OakComponent

当你需要:

  • 自己定义 projectionfilterssorterspagination
  • 自己组织 oakPath 和父子结点;
  • formData 中组合多段数据;
  • 自己决定 executeclean、弹窗、tab、步骤条等交互结构;
  • 把多个业务组件组装成一个 panel。

实际上,Oak 项目里最常见的写法不是“二选一”,而是:

  • 外层自己写一个业务 OakComponent
  • 内层在合适的位置复用 oak-frontend-base 抽象组件。

一个很典型的 panel 模式

如果你打开下面两个组件,会看到非常相似的结构:

  • oak-general-business/src/components/system/panel
  • oak-general-business/src/components/application/panel

它们的共同特点是:

  1. panel 自己先拿到当前对象的核心数据;
  2. 再通过 tab 或子区域,挂多个子组件;
  3. 子组件有的共享路径,有的走关联路径,有的走绝对路径;
  4. panel 自己不一定负责每个 tab 的细节,但负责整体编排。

这种模式非常适合:

  • 管理后台详情页;
  • 配置中心;
  • 一个对象下挂很多子能力的业务场景;
  • 支付、系统配置、公众号能力这种“一个对象,多块配置”的页面。

阅读顺序建议

为了避免一开始被路径和组件树绕晕,建议按下面顺序阅读后面的章节:

  1. 先看 编写详情组件
  2. 再看 编写更新组件
  3. 再看 编写列表组件
  4. 然后回到 组织组件
  5. 最后看 定义组件

这样先理解“单个组件怎么工作”,再理解“多个组件怎么组成页面”,会更顺。

编写详情组件

本节示例代码可参看 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(),
        };
    },
});

这里有两个关键信息:

  1. 这是一个单行组件,所以 isListfalse
  2. 它没有自己声明 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 中的 entityisListpropertiesformDatamethods 为传统 TSX render 注入 props.data / props.methods 的精确类型。因此这里不再手写 WebComponentProps,也不应保留仅为旧签名服务的 WebComponentPropsEntityDict 类型导入。业务输入仍必须在 index.tsproperties 中声明,不能因为 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

这个例子应该记住什么

  1. 详情组件不一定要自己负责 projection,完全可以复用父组件的对象结点。
  2. 详情组件在 Oak 中经常不只是“展示”,还会兼任“提交入口”。
  3. 如果详情组件和更新组件共享同一条 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 || {};
    },
});

这里有三个要点:

  1. 它仍然是单行组件,所以 isList: false
  2. 它显式声明了 projection,说明这个 Upsert 组件本身可以独立工作,而不完全依赖父组件兜底取数;
  3. 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 还没取到,oakIdundefined,组件会按 create 初始化;等数据回来后 oakId 又变成了已有主键,运行树会认为这是一种异常状态切换。

更稳妥的写法是:

{!!application && (
    <SystemUpsert
        oakId={application.systemId}
        oakPath={`${oakFullpath}.system`}
    />
)}

也就是:等主键真的确定后,再渲染这个单行组件。

3. 如果要连续创建,需要在提交后显式再 create 一次

Oak 不会在一次创建提交完成后自动帮你进入下一轮创建。如果你需要“连续创建”,要在 execute() 成功后手动再准备下一条:

await this.execute();
this.create({
    ...
});

这一节最重要的结论

  1. Upsert 组件的核心职责是调用 update / create 把待提交修改写进 runningTree。
  2. 是否在 Upsert 自己身上声明 projection,取决于它是否需要独立承担取数责任。
  3. 提交按钮不一定要放在 Upsert 组件里,很多场景下交给父组件统一 execute 更合理。
  4. 单行组件一旦涉及创建流程,oakId 的时序一定要特别小心。

编写列表组件

接下来看看列表组件的例子。还是沿用 oak-general-business 中的 SystemApplication:在 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(),
        };
    },
});

这里比单行组件多了几个典型特征:

  1. isList: true,说明这是列表结点;
  2. formData 中的 data 不再是一行,而是一个数组;
  3. 组件接受一个额外的 systemId 参数;
  4. 组件同样在 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 同样由编译器注入类型:applicationsoakExecutable 来自 formDatasystemId 来自 properties,Oak 运行状态与方法由框架合同补齐。传统 TSX 不再手写 WebComponentProps。如果这里使用了未在 formDataproperties 或框架内置合同中出现的字段,严格构建应当直接报错,正确修复位置通常是 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 拿到的是一组行数据;
  • 通过 addItemremoveItemupdateItemexecute 等方法管理这组数据;
  • 每一行仍然能通过子路径继续往下挂组件。

所以在 Oak 里,列表完全可以长成:

  • 表格;
  • Tabs;
  • 卡片网格;
  • 时间线;
  • 左侧列表 + 右侧详情。

真正的关键不是 UI 形态,而是你有没有把它挂成一个正确的 list 结点。

另一类最常见的列表页:PageHeader + FilterPanel + ListPro

除了 Tabs 型列表,在真实项目里更常见的其实是标准后台列表页。这个模式在:

  • oak-pay-business/src/components/order/list
  • taicang/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>

这个结构里最重要的不是视觉层,而是运行时关系:

  • FilterPanelListPro 共用同一条 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 列表组件很适合配合:

  • 行内编辑;
  • 局部弹窗;
  • 批量修改;
  • 列表内创建和删除。

写列表页时的一个顺手检查清单

每次写完列表组件,建议顺手检查下面几项:

  1. 这是不是一个真正的 list 结点,也就是 isList: true
  2. oakPath 有没有表达清楚它和父组件的关系?
  3. 如果页面用了 FilterPanelSearchPagination,它们是不是挂在同一条 list 路径上?
  4. 行数据有没有必要先在 formData 里整理成更易展示的结构?
  5. 新增、删除、局部更新,是不是都走 runningTree 的方法,而不是在 TSX 里自己额外维护一套假状态?

这一节最重要的结论

  1. 列表组件的 formData 中拿到的是行数组,而不是单条对象。
  2. 列表组件通常不只负责展示,还会负责新增、删除、分页、过滤、排序等交互入口。
  3. addItem(...) + 子路径 Upsert + execute() 是 Oak 列表内创建子项的典型模式。
  4. 如果父子对象关系明确,优先用相对 oakPath 表达关系,再考虑额外写 filters
  5. 列表页最常见的两种形态,是 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/detail
  • system/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.applicationfeatures.consolefeatures.cache 算出 accountId
  • 然后在渲染层里挂:
<AccountDetail
    oakId={accountId}
    oakPath={`${oakFullpath}.account`}
/>

这种组织方式特别适合:

  • 页面只是业务控制器;
  • 页面要复用 oak-general-businessoak-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 共享当前路径;
  • ApplicationListapplication$system 关联路径;
  • DomainListdomain$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
  • ApplicationListDomainList 分别走 application$systemdomain$system
  • PassportOAuthManagement 这类 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 里确实对 oakPathoakId 后到做了兼容处理,但从源码行为和项目经验看,更推荐的组织方式仍然是条件渲染

也就是说,像下面这样:

{!!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-busitaicang 这类项目里,经常会看到这样的结构:

  • 页面自己是 Virtual;
  • 页面内部组合 oak-general-businessoak-pay-business 或项目私有组件;
  • 页面先根据业务模式判断,再决定挂哪些 Entity 子组件。

这类页面的价值不是自己直接取一条数据,而是:

  • 组织页面壳;
  • 组织标题、筛选、Tab、弹窗;
  • 统一处理跳转、环境判断、权限判断;
  • 决定子组件各自应该挂在哪条路径上。

如果你发现一个页面“业务编排很多,但单个实体逻辑不集中”,通常就很适合做成 Virtual 控制页。

8.2 后台页面的推荐装配顺序

如果你在项目里要拼一个典型后台页,可以优先按下面这个顺序来组织:

  1. PageHeader / PageHeader2 作为页面最外层壳;
  2. FilterPanelSearch 这类筛选组件挂到当前 list 路径上;
  3. ListProList 负责主体列表;
  4. 行内新增、编辑通过 Modal + Upsert 完成;
  5. 详情页或复杂配置页再拆成 panel + detail + upsert + list 的组合。

taicang/src/pages/console/order/list/web.pc.tsx 这种页面,基本就是这个思路:

  • 页头壳负责标题和内容容器;
  • FilterPanelListPro 共享同一条 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(...)

它们的思路基本一致:

  1. 公共业务包先定义一个注册表;
  2. 项目侧在初始化阶段注册自己的渠道组件或物流设置组件;
  3. 公共页面在渲染时遍历注册表;
  4. 再把这些组件挂到 ${oakFullpath}.${entity}$system 这样的关系路径下。

这种模式特别适合:

  • 公共业务包知道“这里应该出现一类组件”,但不知道项目最终会接哪几个具体实现;
  • 各项目会接入不同的支付渠道、物流实体、配置实体;
  • 希望公共页面结构稳定,但把具体扩展点开放给项目层。

组织时要注意两点:

  • 注册进来的组件最好仍然遵守当前页面的数据树规则,优先使用公共页面传下来的 oakPathsystemId 等上下文;
  • 如果注册组件实际上对应某个真实关系,路径也应继续沿关系命名,而不是重新发明一套和页面脱节的绝对路径。

项目里通常会在初始化代码或业务入口处完成注册,思路大致像这样:

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.tsregistry.frontend.ts 里,还能看到另一层更完整的组织方式:按运行端把可注册能力集中导出。

例如这里统一导出了:

  • registerPayChannelComponent
  • registerFrontendPayRoutine
  • registerShipSettingComponent
  • registerSysAccountCardTopComponent
  • registerSysAccountDetailComponent

这种做法的价值在于:

  • 项目侧只需要记住一个整合入口;
  • 公共业务包可以把“哪些位置允许扩展”集中暴露出来;
  • 初始化代码更清楚,不用到处找具体组件内部的注册函数。

如果你自己的公共包也有很多可插拔组件、流程或页面片段,推荐按运行端把注册函数统一汇总到:

  • registry.backend.ts
  • registry.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.tsregistry.frontend.ts 怎么分工

oak-pay-business 里同时存在:

  • registry.backend.ts
  • registry.frontend.ts

它们按运行端严格分工:

  • registry.backend.ts 只导出 registerPayClazz,用于后端渠道实现注册;
  • registry.frontend.ts 导出配置组件、前端支付流程、物流设置和系统资金展示注册函数。

不要从前端入口导入后端渠道类,也不要让后端入口聚合 React 组件。需要增加新的注册能力时,先判断它属于哪个运行端,再放入对应入口。

从组织角度看,这样分层的价值是:

  • 项目初始化代码更清楚;
  • 前后端边界更清楚;
  • 注册能力不会散落在各个组件内部,被项目层到处直接引用。

9. 目录层面的推荐拆分

前面这些讨论主要解决的是“运行时怎么组织组件树”。但在真实项目里,组件还涉及一个非常实际的问题:目录怎么拆,页面和组件怎么分层。

结合 oak-general-businessoak-pay-businesshaina-busitaicang 的写法,可以优先按下面这套方式拆:

  • 路由入口页放在 src/pages/...,它负责页面级 path、页面级 zombie、环境判断、标题和路由参数处理。
  • 可复用的实体业务块放在 src/components/...,例如 detaillistupsertpanelmodaltab
  • 同一实体相关的组件尽量就近放在同一棵目录下,不要把 detaillistupsert 散落到完全不同的业务目录里。
  • 如果某个页面主要是在组合 oak-general-businessoak-pay-business 和项目私有组件,它通常更适合做成 Virtual 页面壳,而不是再塞一堆实体逻辑进去。
  • 如果某个子块根本不需要 entityoakPathlifetimeslisteners,那它就继续做普通 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/.../detaillistupsertpanel 这类目录,通常仍然是 Oak 组件目录;
  • components/.../purecomponents/common/.../*.tsx 这类目录,更适合放纯展示组件或平台组件;
  • AbstractComponents.tsregistry.frontend.tsregistry.backend.ts 这类文件,则更像业务包的基础设施层,不直接承载某个实体页面,而是服务整个组件体系。

再补一个在真实项目里非常常见、但容易忽略的层次:复杂业务组件目录本身也可以继续包含子组件树。

像:

  • oak-general-business/src/components/wechatMenu
  • oak-general-business/src/components/oauth/management

都属于“一个大业务组件目录,下面继续挂多个局部子目录”的结构。它说明组件组织不一定只有两层:

  • pages
  • components

很多时候还会出现第三层:

  • components/某业务根组件/局部子组件/...

这类目录适合:

  • 同一业务块下有多个 tab、选择器、预览块、编辑块;
  • 这些子块共享同一业务上下文,但各自又值得独立维护;
  • 外层根组件更像一个局部 panel / controller。

10. 一个实用的组织原则

如果你不确定一个页面该怎么拆,可以直接按下面的顺序思考:

  1. 先找出页面主对象是谁;
  2. 再决定页面根 panel 是否应该先把主对象取出来;
  3. 判断每个子块是在处理同一条对象,还是在处理关联对象;
  4. 同一条对象就优先共享路径;
  5. 关联对象就优先沿对象关系写相对路径;
  6. 只有在确实不想级联时,才考虑绝对路径。

按这个顺序来拆,绝大多数 Oak 页面都会比较清晰,也更容易维护。

定义组件

Oak 前端组件的入口是 OakComponent(...)。它的真实类型定义在 oak-frontend-base/src/types/Page.ts 中,运行时主要由 oak-frontend-base/src/page.react.tsxpage.mp.tspage.common.tsfeatures/runningTree.ts 驱动。

如果只从使用层面记忆,Oak 组件可以理解成四件事的组合:

  1. 定义这个组件在页面数据树中的结点;
  2. 定义这个结点如何取数、改数、分页和校验动作;
  3. 把取到的数据整理成渲染层更容易消费的形态;
  4. 把运行时方法和状态注入到组件中。

因此,写 Oak 组件时,最重要的不是先写 TSX,而是先把 OakComponent 的定义写清楚。

先看一个最小骨架

如果只看 oak-frontend-base/src/types/Page.tsOakComponent(...) 最常见的骨架大致就是这样:

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() {},
    },
});

真正写业务时,不一定每个字段都要写,但你可以把它理解成六层:

  1. 结点定义:entityisListpathstalezombie
  2. 查询定义:projectionfilterssorterspaginationgetTotal
  3. 入参定义:properties
  4. 组件内部状态:data
  5. 运行时整形:formData
  6. 联动与行为:featureslifetimeslistenersmethods

先建立一个运行模型

一个典型的 Entity 组件,运行顺序大致是这样的:

  1. 页面或父组件传入 oakPath,单行组件通常还会传入 oakId
  2. 框架根据 entityprojectionfilterssorterspagination 等配置,在 runningTree 中创建结点;
  3. 结点刷新后拿到对象数据;
  4. 框架调用 formData(...)
  5. formData 返回的数据和组件自己的 data、外部 props 一起进入渲染层;
  6. 在渲染层中,通过 props.dataprops.methods 使用这些数据与方法。

所以 Oak 组件真正的“输入”通常不是一个,而是这三类:

  • 页面或父组件传入的参数,如 oakPathoakIdsystemId
  • OakComponent 配置项中声明的查询/行为定义;
  • 框架在运行时注入的状态和方法。

组件文件通常怎么落地

在 Oak 项目里,OakComponent(...) 的定义通常只放在组件目录下的 index.ts。而真正的渲染文件、样式文件、locale 文件,会和它并排放在同一个目录里。

taicanghaina-busioak-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(...) 定义、projectionformDatalistenersmethods
  • index.config.ts:页面/组件的结构化配置。页面通常放 routemenump,组件通常放 mp,用于替代新代码中的小程序 index.json
  • web.pc.tsx / web.tsx:各端渲染层,只消费 props.dataprops.methods,尽量不要把数据树逻辑再塞回渲染文件。
  • index.xml / index.less:小程序端模板和样式;小程序组件声明、usingComponents 等配置优先写在 index.config.tsmp 字段中。
  • *.module.less / *.less:平台样式文件。
  • locales/*.json:当前组件自己的文案。

真实项目里常见三种落地方式:

  • 只有 PC 端的业务组件:例如 oak-general-business/src/components/system/panelhaina-busi/src/components/business/daemon/config,通常只有 index.ts + web.pc.tsx + less + locales
  • 同时覆盖 web / 小程序的页面或复杂组件:例如 taicang/src/pages/console/account/detailtaicang/src/components/spBid/modaltaicang/src/pages/frontend/spAuctionCollection/detail,通常会同时存在 web.pc.tsxweb.tsxindex.xmlindex.config.ts。旧项目里也可能还保留 index.json
  • 纯 Oak 逻辑 + 薄渲染层:最推荐的方式是把查询、监听、订阅、提交、权限判断都放在 index.ts,让各端渲染文件只负责布局和交互。

也就是说,定义 Oak 组件时最重要的分层不是“先写页面再补逻辑”,而是:

  1. 先在 index.ts 把数据树结点和行为定义清楚;
  2. 再让 web.pc.tsx / web.tsx / index.xml 去消费这些定义好的数据和方法;
  3. 不要把 projection、订阅、refresh 触发条件分散到多个渲染文件里。

大型组件目录本身也可以是一棵局部组件树

Oak 项目里还有一种非常常见的情况:一个“业务组件”本身并不是一个单目录单文件,而是一个根组件目录,下面继续挂很多局部子组件目录

例如:

  • oak-general-business/src/components/wechatMenu
  • oak-general-business/src/components/oauth/management

这两类目录都不是“只有一个 index.ts + web.pc.tsx 就结束”,而是会继续拆出:

  • menu
  • conditionalMenu
  • tagList
  • oauthProvider
  • oauthApps
  • upsert

这类拆法适合:

  • 一个业务块本身就有多个 tab / 面板 / 弹窗 / 选择器;
  • 子块之间属于同一个业务域,拆太散反而不好维护;
  • 你希望外层组件做总编排,内层子目录做局部 Oak 结点或局部展示逻辑。

从工程角度看,可以把它理解成:页面有一棵组件树,复杂业务组件目录内部也可以再有一棵局部组件树。

但即便这样拆,原则还是一样:

  • 外层根组件负责整体上下文;
  • 子目录组件负责局部路径、局部状态和局部展示;
  • 不要因为目录层级变深,就把路径设计和职责分层搞乱。

同一个 index.ts 通常会复用到多个渲染端

在 Oak 项目里,一个组件目录下同时出现:

  • web.pc.tsx
  • web.tsx
  • index.xml
  • index.config.ts

是很常见的。这通常不表示“这里有四套不同逻辑”,而是表示:同一套 Oak 逻辑,分别接到多个端的渲染壳上,并通过 index.config.ts 补充平台配置。

例如:

  • taicang/src/components/spBid/modal
  • taicang/src/pages/frontend/spAuctionCollection/detail
  • taicang/src/pages/console/account/detail

它们的共同特点是:

  • index.ts 仍然只有一份,负责 Oak 逻辑;
  • web PC、web mobile、小程序模板各自只处理平台渲染;
  • 多端之间共享同一份 projectionformDatalistenersmethods

这也是 Oak 组件很重要的一条工程纪律:

  • 数据树逻辑尽量只写一份;
  • 平台差异尽量收敛在渲染文件里;
  • 不要把“某端专属的布局差异”升级成“某端专属的一套 Oak 逻辑”,除非业务真的不同。

复用组件逻辑并覆写本地 render

跨项目或跨平台开发中,还有一种更薄的组件目录:逻辑完全沿用现有 Oak 组件,本地只提供自己的 render。例如应用要复用公共业务包的查询、formDatamethods,但 Web 页面布局要由当前应用定制。

本地 index.ts 应使用 CLI 能明确识别的直接转发形式:

import OakComponent from '@oak-general-business/components/example';

export default OakComponent;

然后在同目录编写本地 web.tsxweb.pc.tsxrender.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>
    );
}

这里发生了两件事:

  1. 运行时复用原组件的 Oak 逻辑合同;
  2. CLI 和 Oak Assistant 为本地 render 继承原组件对应平台的 props 合同。

如果目标还是工作区源码,编译器会沿转发链找到真正的 OakComponent({...})。如果目标是已安装的发布包,编译器不会用组件 index.d.ts 猜内部 render 数据,因为该文件通常只描述父组件可传入的外部 props;它会读取原组件的平台 render 声明。

平台声明的选择顺序如下:

本地 render依赖声明查找顺序
web.tsxweb.d.ts
web.pc.tsxweb.pc.d.ts -> web.d.ts
web.mobile.tsxweb.mobile.d.ts -> web.d.ts
render.ios.tsxrender.ios.d.ts -> render.native.d.ts
render.android.tsxrender.android.d.ts -> render.native.d.ts
render.desktop.tsxrender.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 组件,最稳妥的顺序通常不是先写页面样式,而是先把下面四个问题答清楚:

  1. 这个组件是 Virtual,还是 Entity?
  2. 如果是 Entity,它是单行还是列表?
  3. 它最终会挂在哪条 oakPath 上?
  4. 它的业务参数里,哪些是运行时参数,哪些是业务参数,哪些是 UI 参数?

在真实项目里,一个更实用的起手模板通常是:

  1. 先写 entityisListprojectionfiltersproperties
  2. 再写 formData,先把渲染层真正需要的数据整理出来;
  3. 然后补 methodslisteners
  4. 最后再写 web.pc.tsx / web.tsx / index.xml

这样做的好处是:

  • 你会先把结点和数据关系想清楚;
  • 渲染层拿到的是已经整理好的字段,而不是一堆原始查询结果;
  • 后面拆分组件时,更容易判断哪些逻辑该留在 Oak 层,哪些只属于展示层。

不是所有 Oak 生态组件都由 OakComponent(...) 定义

这一点对新手非常重要:在 Oak 项目里,大家常说“组件”,但它不一定都指 OakComponent(...) 生成的组件。

除了自己写的 Oak 组件之外,项目里还大量使用这几类抽象组件:

  • FilterPanel
  • List
  • ListPro
  • Detail
  • Upsert

它们通常来自:

  • @oak-frontend-base/components/...
  • 或公共业务包里的 AbstractComponents.ts

例如:

  • oak-pay-business/src/components/withdrawTransfer/list/web.pc.tsx
  • oak-pay-business/src/components/pay/list/web.pc.tsx
  • oak-general-business/src/components/user/manage/web.pc.tsx

都会直接在渲染层中使用 FilterPanelListPro

你可以这样理解它们的角色:

  • OakComponent(...) 定义“数据树结点、查询、状态、行为”;
  • FilterPanel / ListPro / Detail / Upsert 定义“通用的展示与交互骨架”;
  • 业务页面则把两者拼起来。

所以在真实工程里,一个完整页面经常不是“全都写成 OakComponent”,而是:

  1. 先用 index.ts 定义当前 Oak 结点;
  2. 再在 web.pc.tsx / web.tsx 里组合 FilterPanelListProDetailUpsert
  3. 最后把局部纯展示块继续拆成普通 React 组件。

这也是为什么文档里要把“定义组件”和“组织组件”分开讲。前者是在讲 Oak 结点怎么定义,后者是在讲这些 Oak 结点和抽象展示组件怎么拼成真正页面。

1. 先分清组件类型

Oak 中常见的前端组件可以先分成两大类:

1.1 Virtual 组件

如果没有声明 entity,这个组件就是 Virtual 组件。它仍然可以:

  • 使用生命周期;
  • 监听 features
  • 使用 tnavigateTosetMessage 等公共方法;
  • 挂在某个 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 配置项可直接声明
nsi18n 命名空间补充可写单个或多个
lifetimes生命周期方法createdreadymature
listeners监听 props / state 变化适合联动逻辑
methods组件自定义方法会注入到 thisprops.methods
wechatMp小程序额外配置externalClasses、组件 options

下面只展开那些最容易写错的配置项。

2.1 entityisListpath

entity 决定组件关联哪个对象。支持两种写法:

entity: 'system'

或者:

entity() {
    return this.props.entityName as 'system';
}

isList 决定组件是列表结点还是单行结点:

isList: true

或:

isList: false

path 比较特殊。源码里明确限制了:只有页面级根组件才应该直接声明 path 子组件不要在配置项中写 path,而应通过外部传入 oakPath

可以这样理解:

  • 顶层 page:用 path 定义页面根结点;
  • 普通子组件:由父组件传入 oakPath
  • 单行子组件:通常同时传 oakPathoakId

另外,运行时还有一个细节值得知道:在 page.common.tsonPathSet(...) 里,如果这是页面根组件并且同时传了 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$entity
  • spAgent$auctionCollection
  • 与当前用户投标板相关的数据

一起查出来。

这种写法适合:

  • 登录前后看到的字段结构不同;
  • 某些字段只在特定模式下需要;
  • 某些关联查询代价较高,希望按条件裁剪。

但要记住和上一条规则配套的结论:动态 projection 也要对当前路径上的其它共享组件负责。 如果某条路径上挂了多个子组件,不能一个组件想查一套,另一个组件又假设另一套。

2.3 filterssorters

它们只对 list 组件有效,语法分别对应查询章节里的 Filter 和 Sorter。

真实结构不是单个对象,而是数组:

filters: [
    {
        filter() {
            return {
                systemId: this.props.systemId,
            };
        },
        '#name': 'bySystem',
    },
]
sorters: [
    {
        sorter: {
            $attr: {
                name: 1,
            },
            $direction: 'asc',
        },
        '#name': 'nameAsc',
    },
]

这里的几个细节很值得记住:

  • filtersorter 都可以直接写对象,也可以写函数;
  • '#name' 可用于后续按名称替换、删除;
  • filter 还支持 hot: true,表示前台取数时也持续参与判断。

后续你可以通过组件方法动态调整这些条件,比如:

  • addNamedFilter
  • setNamedFilters
  • removeNamedFilterByName
  • addNamedSorter
  • removeNamedSorterByName

2.4 paginationgetTotal

分页配置的真实结构是:

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 propertiesdataformData

properties 用于声明外部传入参数,相当于组件 props 的声明:

properties: {
    systemId: '',
}

data 用于声明组件自身状态初值,相当于 state 初值:

data: {
    keyword: '',
    open: false,
}

它也可以写成函数:

data() {
    return {
        keyword: '',
    };
}

例如 taicang/src/pages/console/news/list/index.ts 就保留了这种写法。源码中,data() 会在组件构造阶段以 this 为上下文执行一次。

这里还有一个非常值得建立的习惯:把组件参数按“运行时参数 / 业务参数 / 交互参数”分开理解。

  • 运行时参数:oakPathoakIdoakZombieoakStalewidth。这些是 Oak 运行时已经内置的 props,不需要再在 properties 里重复声明。
  • 业务参数:systemIdapplicationIdentityentityIdtabKeyagentOnly 这类业务输入,应明确写在 properties 里。
  • 交互参数:visibledisabledonCloseshowRecharge 这类 UI 或回调型参数,也应该写在 properties 里,并给出稳定默认值。

例如 taicang/src/components/spBid/modal/index.ts 就很典型:

  • oakId 不是它自己声明的业务参数,而是运行时给它的当前拍品主键;
  • agentOnlyvisibledisabledisLiveonCloseshowRecharge 才是它自己真正关心的业务/UI 参数。

再比如 oak-general-business/src/components/system/panel/web.pc.tsx 中的 SystemDetail

  • oakIdoakPath 决定它挂在哪个 Oak 结点上;
  • 但像 ConfigUpsertStyleUpsert 这类普通业务组件,则更多接收 entityentityIdnameconfig 这种业务参数。

这两个层次不要混在一起。否则新手最容易写出这样的代码:

  • 一边把组件当 Oak Entity 组件使用;
  • 一边又把 oakPathoakId 当成自己手写的普通业务 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.ts
  • oak-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 actionscascadeActions

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 stalezombie

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.tshaina-busi/src/pages/business/machine/list/index.ts 这类列表片段,会写 stale: true,把首轮主动刷新责任交给外层页面或特定 feature 联动。

因此可以这样记:

  • zombie 更像“页面退出后,结点别急着销毁”;
  • stale 更像“组件挂上来时,先别自动刷新”。

stale 很有用,但也不要滥用。只有当你明确知道:

  • 当前数据已经由父层准备好;
  • 或者稍后会通过 features / 自定义逻辑主动 refresh;

时,才适合这么做。

2.9 lifetimeslisteners

Oak 组件支持的核心生命周期包括:

  • created
  • attached
  • mature
  • ready
  • detached

以及一些平台相关生命周期:

  • moved
  • error
  • show
  • hide
  • resize

其中新手最需要分清的是这几个:

生命周期真实含义
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 会根据 plateaccountId 的变化去增删数据订阅。

3. 页面如何把组件挂到数据树上

组件能不能正常工作,关键不只是 entity,还取决于它是不是被正确挂到了数据树路径上。

最常见的三个运行时入口参数是:

参数用途
oakPath当前组件所在的数据树路径
oakId单行结点关联的主键
oakZombie / oakStale以 props 形式覆盖结点行为

一个典型的详情组件嵌套写法如下:

<SystemDetail
    oakId={id}
    oakPath={oakFullpath}
/>

一个典型的子列表组件嵌套写法如下:

<ApplicationList
    oakPath={`${oakFullpath}.application$system`}
    systemId={id}
/>

这里要注意两件事:

  1. oakPath 不只是“一个字符串”,它表达的是组件之间的数据关系;
  2. 子组件最好尽量沿着真实对象关系去组织路径,这样查询、级联更新和重用都会自然很多。

还有一条实践里很重要的规则:oakPathoakId 最好在组件首次渲染时就稳定下来。

虽然 page.react.tsx 确实对“oakPath 晚一点才传进来”做了兼容处理,但源码里也明确会对“先创建结点、后补路径”的情况发出警告。实际业务里,更稳妥的方式通常是:

  • 等主键准备好再渲染子组件;
  • 等父组件拿到 oakFullpath 再继续往下挂子结点;
  • 不要让同一个组件在 undefined -> 有值 之间反复切换路径和主键。

4. 组件中的数据从哪里取

Oak 组件里常见的数据来源有三层:

  • props:外部传入;
  • state:组件自己的状态;
  • formData 返回的数据:Oak 框架整理后的运行时数据。

4.1 在逻辑层里怎么取

formDatalifetimesmethods 中,通常通过 this.propsthis.state 访问:

const { oakId } = this.props;
const { oakFullpath, oakDirty } = this.state;

4.2 在 React 渲染层里怎么取

在 web / native 渲染函数里,统一通过 props.dataprops.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 自动注入,不需要再手写 WebComponentPropsproperties 是组件对外业务输入的真实声明,formData 决定额外渲染数据,methods 决定自定义方法;三者不能靠 TSX 中的类型标注替代。启用 --emit-injection-types 时,这份推导合同还会写入 web.d.tsweb.pc.d.tsrender.native.d.ts,供下游包消费。

4.3 常见内置 props

这些值通常由页面或父组件传入:

名称含义
oakPath当前组件路径
oakId单行组件主键
oakZombie子组件是否保留结点状态
oakStale子组件是否作为 stale 结点
oakFilters以 props 形式追加过滤条件
oakActions以 props 形式覆盖动作定义
oakCascadeActions以 props 形式覆盖级联动作定义
width当前宽度标识,如 xssmmd

其中 oakActionsoakCascadeActions 在真实类型里是字符串通道,通常由框架或通用组件透传,不建议业务页面手动乱拼。

oakAutoUnmount 是已废弃的历史参数,不再列入新组件可用参数。当前 React 运行时会在组件卸载时自动销毁对应 runningTree 结点;需要控制 UI 是否卸载时,使用条件渲染或 Tabs 自身的卸载策略。

4.4 Render、XML 与样式检查

当前严格构建通常同时启用 render 类型注入、XML 类型检查和 Less Module 检查:

  • render 中的 props.data / props.methods 必须来自框架合同、propertiesformDatamethods
  • 小程序 index.xml 中的数据、方法、组件属性和事件绑定会按同一组件合同检查;
  • Styles.xxx 必须在导入的 Less Module 中真实存在,嵌套选择器还要满足 JSX 祖先作用域;
  • 下拉菜单、弹层等 portal 内容如果脱离原父级 DOM,所用样式应放到实际可达的模块级作用域。

这些错误应通过补齐真实 properties、修正数据合同或调整真实 CSS 作用域解决,不要用 anynever、空样式规则或扩大手写 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.tsxoak-pay-business/src/components/withdrawAccount/list/web.pc.tsx 都是很标准的例子:

  1. 列表组件自己持有当前 list 结点;
  2. 点击新增时,先调用 addItem(...) 在当前 list 结点上插入一条待创建数据,拿到新 id;
  3. 再把 upsert 组件挂到 ${oakFullpath}.${upsertId}
  4. 如果是编辑已有行,就继续使用同一条子路径,并补上 oakId={upsertId}
  5. 点击确认后,由列表组件统一 execute()
  6. 点击取消时,统一 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 组件的关键阶段通常是:

  1. 构造函数中初始化 data、默认 properties、自定义 methods
  2. 同步执行 created
  3. componentDidMount 中订阅 localescache 和用户声明的 features
  4. 执行 attached
  5. onPathSet(...) 创建 runningTree 结点;
  6. 对非 list child、非 stale 的结点执行首轮 refresh()
  7. 首轮数据回来后执行 mature
  8. 执行 ready
  9. 页面显示时执行 show
  10. 组件销毁时先 destroyNode(...),再执行 detached

Virtual 组件的路径会更简单一些,但也同样遵循“先建立结点,再进入 ready”的总体顺序。

一个简单的使用建议是:

  • created:只做最轻量的同步初始化;
  • attached:可以做订阅、埋点、非数据树依赖逻辑;
  • mature:适合“首次取数完成后的处理”;
  • ready:最适合依赖 oakFullpath、运行树方法、初始数据的逻辑;
  • detached:做清理。

特别注意:不要在 created / attached 中假定 runningTree 结点已经完全就绪。 真实源码里,路径创建和首轮 refresh 是在更后面的阶段完成的。凡是依赖数据树的方法,比如:

  • refresh
  • getId
  • update
  • setNamedFilters
  • loadMore

都更适合放在 ready 之后使用。

7. 真实项目里的几种定义方式

只看类型定义很容易抽象过头。下面几种写法,都是 haina-busitaicang 里真实存在、而且很值得借鉴的模式。

7.1 Virtual 控制页

例如 taicang/src/pages/console/account/detail/index.ts,它自己不声明 entity,而是:

  • 通过 features.applicationfeatures.consolefeatures.cache 算出当前 accountId
  • 在渲染层中,再把 oak-pay-businessAccountDetail 挂到 ${oakFullpath}.account

这种模式很适合:

  • 页面本身更像控制器,而不是单一实体页;
  • 页面要组合公共业务包组件;
  • 需要先根据当前模式、当前用户、当前系统环境推导真正要展示的实体。

7.2 动态实体组件

例如 haina-busi/src/components/business/daemon/config/index.ts,同一组件同时服务 systemroom

  • 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.tshaina-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.tsoak-pay-business/src/components/withdraw/display/index.ts 就很典型:

  • 它们都没有声明 entity
  • 主要依赖 propertiesformDatafeaturesmethods
  • 通过 features.cachefeatures.applicationfeatures.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 再包一层。

例如里面会把:

  • FilterPanel
  • List
  • ListPro
  • Detail
  • Upsert

重新导出成适配当前业务包类型的组件。

这层适配的价值主要有两点:

  • 业务包内部直接使用时,不用每次都手工补完整的泛型;
  • 项目代码和公共包代码里,entity、列定义、RowWithActions 的类型会更稳定。

如果你在自己的公共业务包里也准备封一组常用抽象组件,推荐沿用这种思路:

  1. 先以框架抽象组件为基础;
  2. 再用当前业务包的 EntityDict 做一次类型收口;
  3. 最后让业务页面统一从这一层导入。

这样做不会改变运行时逻辑,但会明显改善项目里的组件书写体验和类型一致性。

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()listenersfeatures
  • 你希望这个子块在多个 Oak 页面里被反复复用;
  • 你不想让页面里每个小块都额外再长出一棵子结点。

可以把判断标准记成一句话:如果子块需要“数据树能力”,写 Oak 组件;如果子块只需要“展示能力”,就让它退回普通 React 组件。

7.10 FilterPanel / ListPro / Detail / Upsert 的标准装配方式

再往前走一步,Oak 页面里最常见的渲染层装配,其实就是下面这几种骨架组合:

第一种,FilterPanel + ListPro 的标准列表页
像:

  • oak-general-business/src/components/user/manage/web.pc.tsx
  • oak-pay-business/src/components/pay/list/web.pc.tsx
  • oak-pay-business/src/components/withdrawTransfer/list/web.pc.tsx

都在用这个模式。它的关键点是:

  • FilterPanelListPro 共享同一条 oakFullpath
  • FilterPanel 负责筛选条件;
  • ListPro 负责表格、按钮组、行操作;
  • 外层 Oak 组件负责把列表数据先整理成渲染层需要的结构。

第二种,Detail + Upsert 的配置/详情页
像:

  • oak-pay-business/src/components/ship/wechatMpShip/web.pc.tsx
  • oak-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() 的结果。
  • pathzombie 的配置项只在页面级直接声明;子组件请改用 oakPathoakZombie
  • 如果组件只是父组件某个对象片段的展示壳,而不需要自己主动刷新,可以认真考虑是否应该设为 stale 或直接做成 Virtual 组件。
  • 如果组件依赖 oakIdoakPath、上游异步结果,优先条件渲染,避免让路径和主键在挂载后才补上。
  • 如果你的页面只是做环境判断、权限判断、业务包拼装,完全可以把它写成 Virtual 控制页,再把真正的 Entity 组件挂到子路径上。
  • 如果你做的是“列表新增/编辑弹窗”,优先用 addItem + ${oakFullpath}.${id} + execute/clean 这套标准模式,不要一上来就新开绝对路径。
  • 如果你做的是向导、结果页、选择器这类业务流程组件,可以先问自己:是否真的需要 entity,还是写成 Virtual Oak 组件更合适。
  • 如果当前单行组件里还要编辑一个强关联子对象,优先把子 upsert 挂到 ${oakFullpath}.关系名 这样的相对路径上。
  • 如果一个子块只是消费已经整理好的数据,不需要 Oak 生命周期和运行树能力,就把它拆成 pure 展示组件,而不是继续往下挂 Oak 子结点。
  • 如果你的业务包会大量复用 FilterPanelListProDetailUpsert,可以考虑先做一层 AbstractComponents 类型适配,再统一对外使用。
  • 如果你在渲染层使用 FilterPanelListProDetailUpsert,先想清楚它们消费的是哪条 Oak 路径、哪份结构化数据,不要把筛选、列表、编辑挂到三条互不相干的路径上。
  • 遇到“这个配置项到底能不能这样写”的问题,先对 oak-frontend-base/src/types/Page.ts,再对 page.react.tsxpage.common.ts,不要只凭旧文档猜。

编写业务逻辑

在 Oak 中,Entity 只负责把“对象长什么样、能做什么动作”定义清楚;而一个真正可运行的业务系统,还必须再补上一层“对象如何联动、什么情况下允许操作、系统启动后要持续做什么”的运行时逻辑。

这些运行时逻辑,主要就写在 src 下面的这些目录中:

  • triggers
  • checkers
  • watchers
  • aspects
  • timers
  • routines
  • ports
  • features

它们虽然都属于“业务逻辑”,但解决的问题完全不同。

这一组概念分别是做什么的

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 新手最容易犯的错误,不是“不会写”,而是“写错地方”。

下面这几个判断非常重要:

  • 涉及数据一致性的约束,优先考虑 triggerchecker
  • 涉及显式业务服务入口,优先考虑 aspect
  • 涉及后台持续轮询,使用 watcher
  • 涉及 cron 调度,使用 timer
  • 涉及应用启动初始化,使用 routine
  • 涉及前端共享状态或工具封装,使用 feature

尤其不要把所有复杂逻辑都堆进 aspectaspect 很方便,但它本质上只是一个入口,不会自动替你解决对象间联动、权限推导、前后端一致检查这些更底层的问题。

一个建议的编写顺序

实际开发时,比较推荐的顺序通常是:

  1. 先定义 Entity
  2. 再用 checker 明确哪些操作允许发生;
  3. trigger 补齐对象联动;
  4. 如果需要后台扫描,再补 watchertimer
  5. 最后才去写 aspectfeatureport 这类更偏“入口”和“交互”的逻辑。

这样写出来的代码,会更贴近 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有以下属性需要定义:

属性取值范围是否必填含义
entityEntityDict中的对象触发的对象
action该entity的action触发的操作
name字符串给trigger命名,便于后续跟踪调试(命名需要唯一)
priority1-99触发器执行的优先级(只有当entity和action完全相同时才有意义),数字越小优先级越高
when'before'/'after'/'commit'执行时机,在操作前/后/提交时
strict'takeEasy'/'makeSure'是否需要严格执行(只有当when为commit时才有意义),见下文解释
attributesentity的属性更新的属性(只有当action为update时才有意义),如果更新的属性和定义的attributes没有交集,则此trigger不会被触发
checkfunction更新的数据检查(只有当action为update/remove时才有意义),如果更新/删除的操作不满足检查,则此trigger不会被触发
filter该entity的Filter/function更新的条件检查(只有当action为update/remove时才有意义),如果更新/删除的数据条件不满足filter,则此trigger不会被触发
mt'create'/'apply'/'both'当存在延时更新Modi时的行为控制
fnfunction触发器的行为

以下代码定义了一个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的定义分成三类,接下来系统会:

  1. 按照priority从小到大的顺序执行before类型的trigger
  2. 执行operation
  3. 按照priority从小到大的顺序执行after类型的trigger

而commit类型的trigger会延时到operation提交(类似关系数据库的事务提交)之后,这种类型的trigger的行为和普通trigger区别较大,更多的细节请参看下一小节。

trigger的这种执行机制意味着可能产生递归调用,例如:在一个对A的operation触发了对B对象的另一个operation,然后后一个operation又触发了对C对象的一个operation……,因此在设计系统的时候,对象之间有关联逻辑的先后顺序应加以仔细制定,一个原则是:

应尽量减少trigger的数量,相同entity相同action的trigger尽量进行合并。

另外,trigger只在后台执行,这点是和checker的重要区别。

跨事务trigger

前面在whenstrict配置项中已经初步诠释了跨事务trigger的基本含义和使用方法,跨事务trigger是业务系统和外部系统达成数据一致性的重要手段。在Oak的实现中,before/after的trigger会和触发Operation处于同一事务当中,从而保证行为的一致性。而对于跨业务系统的行为一致性较难实现,下面先简要介绍跨事务trigger的实现过程:

  1. 在 Oak 执行某 Operation 之前,commit trigger 都会注册事务提交回调;只有 strict: 'makeSure' 还会在更新数据中加入两个持久化属性:
属性类型
$$triggerUuid$$uuid
$$triggerData$$object

这是 Oak 框架自动管理的内置属性。$$triggerData$$ 记录 trigger 名称、序列化上下文和 option,业务代码通常不应直接读写。

  1. 事务提交后,回调函数以 { ids } 作为第一个参数调用 trigger。makeSure 成功后,框架会清除上述两个属性;若设置了 cleanTriggerDataBySelf,则由 trigger 自行清理。

  2. makeSure trigger 执行失败,这两个属性不会被清除,checkpoint 会在后续 watcher 周期继续发现并重试;当前 AppLoader 的周期是 2 分钟。标记已物化到数据行,因此应用重启后仍可继续收敛。takeEasy 不写入这些标记,也不会进入 checkpoint 重试。

跨事务trigger的错误处理

当跨事务trigger执行失败时:

  • 如果 stricttakeEasy(默认),只在事务提交后尽力执行一次,失败不会重试;
  • 如果 strictmakeSure,失败状态会保留在数据行上,由 checkpoint 反复执行直到成功。

只有 makeSure 会通过 $$triggerUuid$$$$triggerData$$ 追踪失败状态,即使应用重启后也会继续重试。

跨事务trigger的fn

跨事务trigger的执行函数和普通trigger有两个不同:

  1. fn的第一个调用参数里增加了一个参数ids,以数组形式传入本次trigger涉及的id;
  2. fn可以返回一个更新数据对象或回调函数,如果返回的是更新数据对象,框架会在消除triggerUuid和triggerData属性的同时将这部分数据更新到数据行上,如果返回的是一个回调函数,框架会在消除triggerUuid和triggerData后执行此函数(同一事务保护)。

跨事务trigger的配置

对于跨事务trigger,除了strict之外还有一些额外的配置,简要介绍如下:

属性取值范围是否必填含义
cstrue在集群环境下,这个trigger涉及的数据将被分配到固定的结点上执行
singletontrue在集群环境下,这个trigger将只会在唯一的实例上执行
cleanTriggerDataBySelftruetriggerUuid和triggerData不会自动清除
groupedtrue被同一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时,要从根本上来杜绝这种情况。像上面这种情况,我们的解决方案可以是:

  1. 在photo对象中增加一个状态status,当create时,其状态设为'uploading'(可以通过下一节介绍的logicalData类型的checker来赋初值).
  2. 在跨事务上传动作完成后,将这个状态更新成'uploaded',
{
    entity: 'photo',
    action: 'create',
    when: 'commit',
    strict: 'makeSure',
fn: async ({ ids }, context) => {
        // ...去根据ids中行的信息上传图片到OSS
        return {
            status: 'uploaded',
        }
    }
}
  1. 对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章节的解释:

属性取值范围是否必填含义
entityEntityDict中的对象检查的对象
action该entity的action检查的操作
priority1-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 只能由 payrefunddepositwithdrawTransfer 这些业务 action 修改。

这个文件不是只给后端看的配置。项目启动时,src/configuration/index.ts 会把它作为 CommonConfigurationattrUpdateMatrix 导出,前后端都会读取这份配置。后端会把它注册成内置 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框架中它们的优先级定义如下:

类型优先级
logicalData31
logical33
row51
data61

而默认的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'],
});

上面代码表示,要检查当前用户是否具有:

  1. 执行创建(满足filter约束条件的)address操作的权限
  2. 执行更新(查询出来的)address行的权限

检查结果会在formData的参数中以如下格式返回:

  1. 如果有对create动作的检查,在formData的参数中会有一个legalActions项,如果create动作通过检查会出现在其中;
  2. 如果有对其它动作的检查,在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 很像,也需要 entityfilterprojection,但执行函数拿到的不是现成 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 都有的属性

属性是否必填说明
namewatcher 的唯一名字
entity要扫描的实体
filter查询或操作条件;WB 类型可写成异步函数,BB 类型只接受对象或同步函数
singleton集群环境下只允许一个实例执行
lazy启动后的第一轮跳过执行

WBWatcher / WBFreeWatcher 额外属性

属性是否必填说明
projection查询出的字段
fn处理函数
forUpdate查询时是否加锁,适合需要串行修改的场景
exclusive同一实例中跳过仍在处理的同一行;只支持 WB 类型

这里的 filterprojectionforUpdate 并不是 watcher 自己发明的新语法,而是直接复用 Oak 查询语法:

  • filter / projection 的写法,参见查询和操作对象
  • forUpdate 对应的就是 SelectOption.forUpdate
  • 如果 watcher 要筛一批待处理数据,再逐条更新,这里通常就应该考虑是否需要加锁。

BBWatcher 额外属性

属性是否必填说明
action对目标实体执行的动作
actionData固定写入的数据,也可以写成异步函数

BBWatcher 不支持 exclusive;即使配置,当前 AppLoader 也只会输出警告并忽略。需要按行排他处理时,应改用 WBWatcherWBFreeWatcher

执行函数长什么样

WBWatcher

fn: async (context, data) => {
    // data 是查出来的多行结果
    return context.opResult;
}

WBFreeWatcher

fn: async (builder, data) => {
    const context = await builder();
    // 自行控制 context 的使用
    return context.opResult;
}

Oak 在执行 WBWatcher 时,会先用 filterprojection 做一次查询,然后把查询结果数组传给 fn。所以 watcher 的思考方式不是“监听事件”,而是“定期扫描待处理行”。

lazysingletonexclusive 的区别

这三个参数很容易混淆,但它们解决的是完全不同的问题:

  • lazy:应用刚启动后的第一轮先跳过;
  • singleton:在多实例部署时,只让一个实例执行;
  • exclusive:在同一个实例中,如果某条数据上一次还没处理完,本次不要并发重复处理。

例如 bm-smart/src/timers/index.ts 中的示例 timer 就使用了 exclusive: true,这类控制同样适用于 watcher。

什么时候该用 watcher

下面这些情况,通常就该优先想到 watcher:

  • “数据库里只要有这种状态的数据,就要持续处理”
  • “上一次失败了,后面要自动重试”
  • “这个逻辑不要求精确到某个秒级 cron 点,只要求不断收敛”

如果你的需求是“每天凌晨 1 点一定执行一次”,那应该用 timer;如果你的需求是“某个事务提交后立即补偿”,那应该优先考虑 commit trigger

使用 watcher 时的一个原则

watcher 最适合处理幂等、可重复扫描、允许延迟收敛的后台任务。

因为它本质上是轮询。如果你把一个必须“立刻且只执行一次”的动作塞进 watcher,最后往往会把系统设计得更复杂,而不是更简单。

定义aspect

如果说 triggerchecker 更像 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() 会负责:

  1. 创建并初始化后台 context
  2. 找到对应名称的 aspect 函数;
  3. 执行 aspect;
  4. 调用 context.refineOpRecords()
  5. 提交事务;
  6. 返回 { 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 可以组织流程,但不要拿它替代 checkertrigger。否则一旦这个流程之外还有别的入口触发同样的数据变化,规则就会失效。

定义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,也合并了 shellExceptionpostApplyment 等分模块 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.tsstartTimers() 中,框架会:

  1. 读取 lib/timers/index
  2. 对每个 timer 调用 scheduleJob(name, cron, ...)
  3. 到点后根据 timer 类型执行:
  4. BaseTimertimer(context)
  5. FreeTimertimer(builder)
  6. 如果 timer 具备 entity,就按 watcher 的方式执行。

因此,timer 只是“调度层”不同,真正的数据处理方式仍然沿用了 Oak 已有的上下文和 watcher 能力。

三种 timer 执行失败时,AppLoader 都会记录错误并调用 publishInternalError('timer', ...)。Watcher 风格 timer 复用 watcher 的事务处理;BaseTimer 由 loader 创建 context,成功时提交,失败时回滚(OakPartialSuccess 除外);FreeTimer 的 context 生命周期由实现通过 builder 自行管理。不要为了让调度继续运行而在 fn 中笼统捕获并吞掉异常,否则 loader 会把本次 watcher context 当作成功提交。

常用属性

属性是否必填说明
nametimer 名称,必须唯一
croncron 表达式,也可以是 DatenumberRecurrenceRule
singleton集群环境下只允许一个实例执行

如果你用的是 watcher 风格 timer,还可以继续使用:

  • 三种 watcher 都有:entityfiltersingletonlazy
  • BBWatcher 使用:actionactionData
  • WBWatcher / WBFreeWatcher 使用:projectionfnforUpdateexclusive
  • WBFreeWatcher 还必须使用 type: 'free'

虽然当前底层 BBWatcher 类型仍带有 exclusive 字段,但 AppLoader 会警告并忽略它,因此不要在 BB 形态中配置 exclusive。Watcher 类型也没有 sortercountindexFrom 等字段;需要排序或截断时,应在 fn 中明确处理查询结果。

这里的 filterprojectionforUpdate 也不是 timer 自己的一套新语法,而是直接复用 watcher / 查询章节里的同一套定义:

  • filter / projection 的写法,参见查询和操作对象
  • forUpdate 的含义,和 watcher 里一样,适合“先扫出来,再逐条改”的串行处理场景。

timer 和 watcher 应该怎么选

一个简单判断就够了:

  • “到某个时间点必须执行” -> timer
  • “只要库里有这种状态的数据,就应持续扫描处理” -> watcher

例如:

  • 每晚 2 点同步一次第三方目录:更适合 timer
  • 每隔一会儿重试发送失败消息:更适合 watcher

一个实践建议

timer 更适合做“触发”,而不是做“无限复杂的大任务”。

如果某个任务非常重,通常更好的做法是:timer 只负责按时挑出待处理数据,再通过状态位、分批处理、幂等动作等方式,把复杂工作拆开,而不是把整个大流程都塞进一次 cron 回调里。

定义routine

routine 是 Oak 中“在应用启动或停止时执行一次”的例程。

它和 watchertimer 的区别在于:

  • watcher:周期轮询;
  • timer:cron 调度;
  • routine:只在启动或停止阶段执行一次。

因此,routine 非常适合处理这类工作:

  • 启动时建立外部连接;
  • 启动时初始化某些内存结构或索引;
  • 停止前释放资源;
  • 停止前做收尾动作。

routine 写在哪里

Oak 约定把 routine 分成两类文件:

  • src/routines/start.ts
  • src/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;
    },
}

这里的 envRuntimeRoutineEnv,当前包含:

  • socket
  • contextBuilder
  • registerTrigger / unregisterTrigger
  • registerChecker / unregisterChecker
  • registerWatcher / unregisterWatcher
  • registerTimer / 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。

这比把初始化逻辑散落到各种 featureaspect 或全局脚本里,要更符合 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-aspectexportEntity() 在运行时要求它是非空数组,否则会抛出前置条件异常。因此,现有项目定义导出 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

  • importEntity
  • exportEntity
  • getImportationTemplate

也就是说,port 最终是以 Oak 内置 aspect 的形式暴露出来的。

导入是怎么执行的

oak-common-aspect/src/port.ts 中的 importEntity() 大致流程是:

  1. 根据传入的 id 找到对应 Importation
  2. xlsx 读取上传的 Excel;
  3. 把每个 sheet 转成 JSON 行数据;
  4. 调用你定义的 importation.fn(...) 把这些行转成 Oak create 数据;
  5. chunkSize 分块;
  6. context.operate(entity, { action: 'create', data: chunk }) 批量写入。

因此,真正需要你关心的核心只有一件事:

如何把导入文件中的一行,准确翻译成 Oak 实体上的创建数据。

如果解析过程中出现具体某一行的错误,按框架约定,应抛出 OakImportDataParseException,这样前端才能得到明确的行号和表头信息。

导出是怎么执行的

exportEntity() 的流程则是:

  1. 根据 id 找到对应 Exportation
  2. 用你定义的 projection 分页查询实体数据;
  3. 调用 exportation.fn(...) 把查询结果转换成导出行;
  4. xlsx 生成 workbook 并返回。

导出时,Oak 还支持:

  • maxCount:最大导出条数,当前默认 10000
  • count:每次分页查询条数,当前默认 1000
  • checked:是否在导出前查询总量并在超限时抛错,当前默认 false

checkedfalse 时,maxCount 仍然生效,但超出的结果会被截断,而不是报错。countmaxCount 都必须大于 0

这里还有一个非常值得直接说清楚的点:Exportation.projection 和前端调用 exportEntity(entity, id, filter, ...) 时传入的 filter,都直接复用 Oak 查询语法本身。

也就是说,平时你在列表页、aspect、watcher 里能写的那套:

  • 级联 projection;
  • 父对象 / 子对象 filter;
  • #sqp
  • $expr
  • JSON filter;

放到导出链路里理解方式也是一样的。导出并不是另一套 DSL,它只是把“查询结果 -> 表格行”的这一步标准化了。

获取导入模板

Oak 还内置了 getImportationTemplate。它会根据 Importation.headers 直接生成一份只有表头的 Excel 模板。

这意味着,一个导入功能的最小闭环通常是:

  1. 定义 headers
  2. 提供模板下载;
  3. 提供导入解析;
  4. 提供错误定位。

而这些能力都可以通过同一个 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.tscreate(...) 并合入项目 features。旧的 initialize.frontend.ts 只用于历史 DebugConnector 场景。

这些基础 features 里,最重要的包括:

  • cache
  • runningTree
  • localStorage
  • locales
  • message
  • notification
  • navigator
  • port
  • location
  • environment
  • style
  • theme
  • geo
  • contextMenuFactory
  • subscriber
  • socket

因此,很多业务 feature 实际上就是在这些基础能力之上再做一层更贴近业务语义的封装。

自定义 feature 写在哪里

通常有两个关键文件:

  • src/features/index.ts
  • src/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 的常见依赖来源主要是:

  • cache
  • localStorage
  • token
  • 依赖模块提供的 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 中,就把 tokenapplicationextraFilewechatSdkhumanVerifyinvite 等能力封装成了 feature。theme 则是 oak-frontend-base 的基础 feature,不属于 general-business。组件层只需要使用合并后的 feature 字典,不需要知道内部具体如何操作 cachelocalStorage

在组件里如何使用 feature

OakComponent 本身就支持声明依赖的 features,并且组件实例上可以通过 this.features.xxx 访问。

同时,在 formData 中也可以拿到:

formData({ data, features }) {
    return {
        rows: data,
        currentUserId: features.token.getCurrentUserId(),
    };
}

因此,feature 是 Oak 组件和运行时能力之间最自然的桥梁。

一个很实用的经验

当你发现一个组件里开始出现下面这种代码味道时:

  • 同时操作 cachelocalStoragetoken
  • 同样一段查询和转换在多个页面里重复;
  • 一大段“为了页面方便”而写的业务工具函数堆在组件文件里;

这通常就意味着,你应该把它抽成一个 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 路由、菜单和访问控制生成流程。理解这些入口,才能把本地开发、预发部署、生产代理和页面权限排查清楚。

阅读这一节时的建议

这一节不需要你一开始死记所有细节,但最好先建立几个认识:

  1. context 是 Oak 运行时的载体;
  2. 异常有明确语义,不只是字符串;
  3. 权限是数据化、可推导的;
  4. i18n 是编译期和运行时共同参与的一套机制;
  5. 图标配置要保持字符串化和可序列化,让 oak-cli 能参与编译优化;
  6. 前后端配置要分清网络访问、后端启动和页面访问控制三条线。

一旦把这些点想明白,后面再去看 Oak 项目的源码,就会顺畅很多。

上下文

在 Oak 中,context 可以理解为“当前这次逻辑执行所依赖的运行时环境”。

你在 aspecttriggercheckerwatchertimerroutine 里拿到的那个 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 还会继续提供:

  • select
  • operate
  • count
  • aggregate
  • begin
  • commit
  • rollback
  • getLocale

也就是说,Oak 中真正的业务逻辑,大多数都是“在 context 上操作”的。

当前 SerializedDataBackendRuntimeContextFrontendRuntimeContext 都已经带上 locale。需要按当前语言做翻译、日志、通知或多语言内容生成时,不要再通过零散参数传递语言,优先从 context 取得。

这里和查询最相关的一点是:

  • select / aggregate 的语法,请直接以查询和操作对象里的 SelectionProjectionFilterSorter 为准;
  • count 适合“只要数量,不要行数据”的场景;
  • 如果要查软删除数据或加锁查询,则要在第三个参数里带 includedDeletedforUpdate 这类 SelectOption

为什么 Oak 要强调 context

因为 Oak 并不希望你把“当前用户”“当前事务”“当前缓存状态”“当前异常同步”等信息,到处通过函数参数手工传递。

框架的做法是:把这些信息统一放进 context 中,让所有运行时逻辑都在同一个载体上工作。这样才能保证:

  • 前后端上下文语义一致;
  • 一次业务动作中的多次操作共享同一事务;
  • 异常和 opRecords 可以顺着上下文回流。

可复用业务库怎样贡献 Context

普通应用只消费最终 Context;真正需要定义 backendContextLayerbackendContextModule 的,是会向下游项目贡献后端 Context 能力的 Oak 业务库。前端完全对称,名称换成 frontendContextLayerfrontendContextModule

这两个对象都放在原来的 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;需要参加公共生命周期的方法,用 hooksreducers 描述:

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 模块。新业务库应使用 applyhooksreducers,不要继续增加 base

成员策略

字段必须在 members 中显式声明。apply 返回 class 的自有原型方法能够被发现,但只要方法需要组合语义,也应明确声明或改用 hook/reducer。

policy语义
unique默认值。整个依赖图中只允许一个模块拥有这个字段或方法。
replace覆盖祖先提供的同名成员。新模块必须传递依赖旧 owner,且双方都声明 replace;两个兄弟模块不能互相覆盖。最终只使用后代实现。
final表示方法不允许再被覆盖或组合;再次出现同名成员就是冲突。字段不能声明 final
series一个生命周期方法需要让多个模块都执行。基础方法只执行一次,其余贡献通过 hooks 串行运行。
reduce多个模块依次变换同一个返回值。先调用基础方法,再把结果依次传给 reducers

hooks 会自动把同名方法登记为 seriesreducers 会自动登记为 reduce,因此同一个方法不能又写进 members。hook 的 handler 需要访问最终实例时,要使用 functionthis,不要使用没有动态 this 的箭头函数。

hook 支持两个顺序维度:

  • order 默认为 forward,按依赖优先顺序执行;清理类行为可用 reverse,按依赖的反方向执行;
  • phase 默认为 after,即先执行原方法再执行 handlers;设为 before 时 handlers 先执行;
  • 同一方法的所有 hooks 必须使用相同的 orderphase

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.tsgeneratedFrontend.tsmake: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
  • _module
  • params

这意味着,一个异常本身也可以带着“为了修正前端缓存而需要同步的数据”一起返回。

最重要的一层: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 内置异常不够用,你当然可以自定义,但建议遵守下面两个原则:

  1. 尽量继承现有语义最接近的父类;
  2. 保持异常数据可序列化;
  3. 用户可见消息使用 error::... locale key,并通过 _moduleparams 提供翻译上下文。

例如:

  • 输入校验问题,优先继承 OakInputIllegalException
  • 权限问题,优先使用 OakOperationUnpermittedExceptionOakDataInvisibleException 等相关权限异常;
  • 纯系统内部故障,再考虑更底层的 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

它定义了权限如何沿对象关系传播。最关键的字段是:

  • sourceEntity
  • destEntity
  • value
  • recursive

例如,如果一个 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 解决“能给别人授什么权”。

一个最容易理解的例子

假设有两个对象:

  • system
  • application

并且 application.systemId -> system.id

那么 Oak 中一个很自然的权限表达方式就是:

  1. 在某个 system 上定义 admin relation;
  2. 通过 UserRelation 把用户绑定到这个 system 的 admin
  3. 定义一条 Path,表示从 application 回到 system 的路径是 system
  4. 定义 ActionAuth,说明 system.admin 可以沿着 system 这条路径,对 application 执行 select/update/...

这样,权限就不是写死在某个页面按钮上,而是变成了“对象关系 + 路径 + 动作”的数据规则。

运行时是谁在做权限判断

真正的权限判定核心在 oak-domain/src/store/RelationAuth.ts

AppLoader 在创建 dbStore 时,会把下面几类项目配置传进去:

  • authDeduceRelationMap
  • selectFreeEntities
  • updateFreeDict

后续无论是前端还是后端,Oak 都会尽量通过这一套关系授权体系去推导:

  • 当前用户能否 select
  • 当前用户能否 create/update/remove
  • 级联操作中的子对象权限是否成立

authDeduceRelationMap 是做什么的

有些对象本身并不需要单独定义权限,因为它的权限完全可以从某个父对象推导出来。

这时就可以在项目的 src/configuration/relation.ts 中配置:

export const authDeduceRelationMap = {
};

如果某个实体的权限可以通过某个外键直接 deduce,Oak 就不必再单独为它搜索完整的 relation 路径。这样既减少配置,也减少权限判断开销。

框架内部还会自动补上一条:

  • modi: 'entity'

也就是说,modi 的权限默认就会从其父实体推导。

selectFreeEntitiesupdateFreeDict

这两个配置是 Oak 权限体系中很实用的“开口子”能力。

selectFreeEntities

表示这些实体允许自由查询,不必经过完整的 relation auth 推导。

例如 bm-smart/src/configuration/relation.ts 中,就把 manufactureproductoauthProvidertagcommunitybannerpostreplyanswer 等对象列进了 selectFreeEntities

这类对象通常具备公共内容或公共维表属性。

updateFreeDict

表示某些实体上的特定动作可以自由执行,而不走常规授权推导。

这类配置一定要慎用,因为它是在权限系统上显式开白名单。

权限和 checker 的关系

在 Oak 的 checker 类型中,有一个保留类型叫 relation。它的语义就是:这是权限相关的检查

不过大多数时候,开发者并不需要自己手写 relation checker,因为 Oak 已经把权限规则数据化了。你真正需要做的事情,更多是:

  • 定义 relation 数据;
  • 配置 path;
  • 配置 actionAuth / relationAuth;
  • 维护好项目的 relation 配置。

权限数据写在哪里

项目通常把静态权限数据放在:

  • src/data/path.ts
  • src/data/actionAuth.ts
  • src/data/relationAuth.ts
  • 需要预置对象关系时的 src/data/relation.ts

这些文件使用生成的 CreateOperationData 类型,pathIdrelationId 和 action 名称必须能由当前 domain 对上,不能用未声明字段或宽泛断言掩盖错误。修改这组数据后,应依次执行:

npm run build
npm run upgrade:auth

build 先生成服务端可加载的 lib/dataupgrade:auth 再按项目更新计划把 pathactionAuthrelationrelationAuth 收敛到数据库。只改源码但不执行升级,运行中的权限数据不会自动同步。

为什么 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/**/locales
  • src/components/**/locales
  • src/locales/*
  • src/oak-app-domain/**/locales
  • web/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.json
  • en_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 做进编译流程

这样做有三个直接好处:

  1. 项目和依赖模块的多语言资源可以统一收敛;
  2. i18n 数据本身可以进入 Oak 的数据体系;
  3. 前端多端共享时,不需要每个端再各自维护一套散乱的 locale 逻辑。

从 Oak 的整体设计目标来看,这其实和它对 Entitydependencyfeature 的处理方式完全一致:尽量把“看似分散的共性能力”统一纳入框架体系中。

一个实践建议

在 Oak 项目里,最推荐的做法不是“哪里缺文案就临时补一段字符串”,而是:

  • 页面文案放页面目录;
  • 组件文案放组件目录;
  • 全局文案放 src/locales/*
  • 修改后执行 make:localebuildupgrade:locale
  • 提交和发布时同时保留 src/data/i18n.jsonsrc/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.tsfrontend.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 不能自动猜出 hotelorder 这些业务名。

运行时 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.tsaccess.prod.tsaccess.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。如果 allowUnsafefalse,前端会先通过内置 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 必须提供 sessionStoreoak-internal-sdk 提供了两种常用适配器:

  • createRedisSessionStore(...):适合生产部署,多个服务实例可以共享会话;
  • createMemorySessionStore(...):适合单进程、本地测试或临时验证。

这两个创建函数只会在 process.env.OAK_PLATFORM === 'server' 时返回实例。自定义后端启动脚本如果没有先设置 OAK_PLATFORM=serversessionStore 会是 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-id
  • oak-encrypted
  • oak-timestamp
  • oak-nonce
  • oak-sequence
  • oak-version
  • oak-platform

Oak CLI 的 server 中间件会把 connector 的 getCorsHeader() 合并进允许请求头;开发和预发环境还会设置 connector 的响应头暴露。生产环境如果启用了自定义 CORS、nginx、网关或 CDN,需要额外确认这些头没有被拦截,并且响应头里的 oak-encryptedoak-nonce 等能被浏览器读取。否则前端可能无法解密响应,或者 SSE 加密流无法正确解析。

EncConnector 也支持 endpoint 和 SSE endpoint。后端 endpoint 如果配置了 useConnector,Oak CLI 会先调用 connector.parseRequest(...) 解密请求;SSE endpoint 还会通过 serializeSSEEndpointResult(...) 加密 data: 数据包。加密 SSE 要求前后端 connector 同步升级,不能只升级其中一端。

迁移时最容易出错的地方有三个:

  1. 只把前端改成 EncConnector,后端仍然是 SimpleConnector
  2. 后端没有提供 sessionStore,或者启动时没有设置 OAK_PLATFORM=server
  3. 生产代理没有放行或暴露 Oak 加密相关 header。

多环境写法

模板通常会放三份访问配置:

  • src/configuration/access.dev.ts
  • src/configuration/access.staging.ts
  • src/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.tsaccess.staging.tsaccess.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_ENVserver.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.tsattrUpdateMatrix.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 规则使用 anyOfoperation 规则使用 target,不要把 anyOf / allOf 写成没有 items 的裸对象。

命名空间还可以通过 access.unconfiguredAccess 声明没有单独配置访问规则的页面默认如何处理:

export default CreateNamespaceConfig({
    access: {
        unconfiguredAccess: 'deny',
    },
});

因此,排查访问问题时可以按这个顺序看:

  1. 前端请求是否打到了正确后端:看 src/configuration/access.tssrc/config/connector.ts
  2. 后端是否按正确端口和代理路径启动:看 src/configuration/server.ts
  3. 页面是否被路由权限拦住:看页面 index.config.tsroute.access 和命名空间 access.unconfiguredAccess
  4. operation 类访问控制是否被 checker 拦住:继续看 src/checkersattrUpdateMatrix.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.webvite.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 兼容部分第三方包。默认保留 processglobalBuffernode: protocol import,但不自动引入体积和真机风险较高的 cryptoassert 完整兼容链。

确实需要 Node built-in 时使用完整白名单:

export default CreateCompilerConfig({
    vite: {
        mp: {
            plugins: {
                buildin: {
                    nodePolyfills: {
                        include: ['path', 'util'],
                    },
                },
            },
        },
    },
});

非空 include 是完整白名单,不是“在默认集合上追加”。加入 cryptoassert 前,应检查最终主包依赖图,并在真机验证随机数、加密、正则和启动阶段;微信开发者工具不能覆盖所有 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.tsserver 只服务 opt-in 的 esbuild runtime bundle:

export default CreateCompilerConfig({
    server: {
        nodeModules: {
            bundle: false,
        },
        esbuild: {
            sourcemap: true,
        },
    },
});

server.nodeModules.bundle: false 时,普通第三方依赖保持裸 package import,部署目录需要安装生产依赖;设为 true 才会尝试把第三方运行依赖带入 bundle。数据库驱动是动态装载项,构建后必须确认目标环境需要的 mysql2pgbetter-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 作用域。报错应回到 propertiesformDatamethods、模板表达式和真实样式结构修复,不要通过扩大手写 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 的开发体验很好,一个项目在本地经常可以很快跑起来;但真正到了上线和维护阶段,问题就完全不一样了。

你会开始关心:

  • 前后端产物分别怎么发布;
  • applicationsystem 这些运行时配置如何管理;
  • 数据字典、权限数据、i18n 数据如何同步到线上;
  • 新老版本如何共存;
  • 什么时候应该强制用户升级。

这些问题并不是 Oak 之外的“运维杂事”,它们其实和 Oak 的很多框架设计正好对应:

  • build 负责生成运行产物;
  • server:init 负责首次初始化运行环境;
  • db:upgrade:plan 负责对比当前编译产物和目标数据库,生成结构升级计划;
  • createUpdatePlan 负责同步静态数据;
  • oak-general-businessapplication 负责应用发现与版本检查;
  • 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.oldestVersion
  • system.platform.oldestVersion
  • application.dangerousVersions
  • application.warningVersions

如果当前客户端版本过低或命中了危险版本,后端会直接抛出 OakApplicationHasToUpgrade

这说明 Oak 的发布不是“前端和后端各发各的”那么简单,而是要把版本策略也纳入发布动作。

三、一个推荐的发布顺序

比较稳妥的发布顺序如下:

  1. 在构建机执行 make:domainmake:localemake:dep,依赖模块有增删时先执行 project:init
  2. 编译后端 npm run build
  3. 对已有数据库执行 npm run db:upgrade:plan 生成结构升级计划,并审核 migration.sqlwarnings.jsonrename-candidates.json
  4. 按目标端构建前端,如 npm run build:webnpm run build:mp
  5. 发布后端代码和配置;
  6. 首次部署执行 npm run server:init;已有库按审核后的结构升级计划升级数据库;
  7. 执行 upgrade:locale / upgrade:auth / upgrade:all 这类静态数据同步脚本;
  8. 校验 application/system/domain 数据是否已经准备好;
  9. 启动后端;
  10. 再发布前端静态资源或客户端包。

之所以把“校验应用配置”单独列出来,是因为 Oak 应用经常在这一环节出问题,而不是出在代码本身。

四、发布前至少要检查什么

对一个新手来说,发布前最值得检查的是下面这些信息:

  • 当前目标端的 application.type 是否正确;
  • web 端域名是否与 application/domain/system 配置一致;
  • 小程序 / 公众号 / App 所需的 appIdappSecret 等参数是否已配置;
  • 当前版本是否会被 oldestVersiondangerousVersions 拦截;
  • 依赖的对象存储、短信、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/entitiessrc/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.jsontsconfig/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

需要注意几件事:

  1. 先执行 make:domainbuild,再生成计划。oak-cli upgrade 读取的是编译后的运行产物,不是直接读 src/entities
  2. 确认 NODE_ENV 和数据库配置指向目标库。命令会按 mysql.${NODE_ENV}.jsonmysql.jsonpostgres.${NODE_ENV}.jsonpostgres.json 的顺序找配置。
  3. 不带 --execute 时只写文件,不会改库。带 --execute 时会执行排序后的结构升级 SQL,包含 prepareSql / manualSql / forwardSql / onlineSql,不会自动跳过人工步骤。
  4. manualSql 代表 planner 认为这一步需要人工审核,不代表 --execute 会自动跳过。只要计划里有 manualSqlwarningsrenameCandidates,就应先人工确认。
  5. largeTableRowThreshold 默认是 100000。大表索引新增、删除、重建可能被转成 manualSql,避免自动 DDL 长时间锁表或造成性能抖动。
  6. rollback.sql 不是数据库备份。它只能表达框架能推导出来的结构回退,不能恢复被删除列里的业务数据,也不能替代上线前备份。
  7. 这个命令只处理数据库结构,不负责静态数据、权限数据和 i18n 数据同步。后者仍然走 createUpdatePlan 相关脚本。

2. 数据升级

这部分是 Oak 当前更擅长抽象的内容,尤其适合处理:

  • path
  • relation
  • actionAuth
  • relationAuth
  • i18n
  • 某些静态业务数据

它们的共同特点是:数据本身通常放在 lib/data 中,可以被视为项目或模块的一部分,并且希望以一种可重复执行的方式同步到数据库。

二、createUpdatePlan 解决了什么问题

oak-backend-base/src/routines/update.ts 本质上是一个数据同步计划生成器。它会把当前项目 lib/data 中的数据与数据库里的现有数据做对比,然后按你给定的策略处理差异。

它支持的核心策略包括:

onUniqueViolation

当数据文件里的记录与数据库已有记录发生唯一索引冲突时,如何处理:

  • error
  • skip
  • update

onOnlyExistingInDb

当数据库里有、但数据文件里没有时,如何处理:

  • skip
  • delete
  • physicalDelete

生命周期钩子

你还可以提供:

  • beforeCheck
  • afterUpdate

这样就可以在真正写库前后,插入项目自定义逻辑。

三、真实项目中的升级脚本长什么样

bm-smart 已经给出了很直接的样板。

只升级 i18n

scripts/upgradeI18n.js

startup(pwd, simpleConnector, true, true, createUpdatePlan({
    plan: {
        i18n: { onUniqueViolation: 'update', onOnlyExistingInDb: 'physicalDelete' },
    }
}))

只升级权限相关数据

scripts/upgradeAuth.js

它同步:

  • path
  • actionAuth
  • relation
  • relationAuth

同时还能在 beforeCheck 中对数据做额外修正。

新 CLI 模板已经补充了权限升级脚本入口。项目新增或调整权限模型后,应该把权限数据升级作为发布步骤的一部分,而不是只依赖 make:dep

全量数据升级

scripts/update.js

它把 i18n、权限、以及部分业务静态数据一起纳入同步计划。

也正因为如此,npm run upgrade:all 在真实项目里的含义,不是“框架神奇地帮你升级一切”,而是:

按当前项目自己定义的 update plan,把 lib/data 中指定的实体数据同步进数据库。

四、update plan 执行时会做什么

update.ts 的实现来看,它大致会经历下面这些步骤:

  1. 读取 lib/data/index
  2. 合并 oak-domain 自带的 i18n 数据;
  3. 对每个目标实体做前置校验;
  4. 分析反向引用关系;
  5. 查询数据库中已有的数据;
  6. 比较差异,决定新增、更新、跳过还是删除;
  7. 处理唯一索引冲突;
  8. 必要时更新反向引用;
  9. 按依赖顺序删除多余数据;
  10. 执行 afterUpdate

代码里还明确做了几件对升级非常关键的事情:

  • 使用 forUpdate 锁定记录,减少并发问题;
  • 默认 blockTrigger: true,避免触发器干扰升级过程;
  • 支持逻辑删除和物理删除两种清理策略;
  • 支持对引用关系做拓扑排序后再删除。

这说明 Oak 的数据升级工具,关注的重点是“可重复执行、引用关系正确、差异同步清晰”,而不是简单粗暴地覆盖数据。

五、升级时如何处理旧版本客户端

这是 Oak 里另一个很重要的升级问题。

oak-general-business/src/aspects/application.ts 中的 checkAppVersionSafe(...) 会根据:

  • system.oldestVersion
  • platform.oldestVersion
  • application.dangerousVersions
  • application.warningVersions

来决定当前客户端:

  • 是否必须升级;
  • 是否只给出警告。

如果版本过低,后端会抛出 OakApplicationHasToUpgrade。与此同时,oak-general-business/src/context/BackendRuntimeContext.ts 在异常推导阶段也会继续返回这个异常。

所以 Oak 的“升级”并不只是数据库更新,它还包括:

  • 如何允许旧版本继续访问;
  • 从什么时候开始强制升级;
  • 哪些版本只是提醒,哪些版本必须拦截。

六、一个推荐的升级顺序

在实际项目里,比较稳妥的升级顺序通常是:

  1. 修改代码、实体与静态数据;
  2. 依赖变化时先执行 project:init,再执行 make:domainmake:localemake:depbuild
  3. 执行 db:upgrade:plan,审核 migration.sqlwarnings.jsonrename-candidates.json
  4. 发布新后端代码;
  5. 首次部署执行 server:init;已有库执行审核后的结构升级 SQL 或谨慎使用 db:upgrade:plan -- --execute
  6. 执行 upgrade:locale / upgrade:auth / upgrade:all 这类数据同步脚本;
  7. 再发布前端产物;
  8. 根据版本策略决定是否拦截旧客户端。

如果你的升级中包含不兼容结构变更,就更不能跳过这套顺序。

七、近期框架升级特别注意

近期 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/checkerssrc/triggers 定义这些对象的默认业务规则;
  • src/aspectssrc/endpoints 暴露可调用的业务入口;
  • src/features 把这些入口包装成前端可直接使用的能力;
  • src/components 提供已经写好的 Oak 组件;
  • src/watcherssrc/routines/start.ts 则把后台补偿任务和启动注入点也一起准备好了。

近期 oak-general-business 又补上了系统翻译相关对象、组件和触发链路,并把 Area 数据推进到全球 locale 化。也就是说,通用业务包现在不只是“登录、文件、微信、系统配置”,还承担了一部分多语言内容生产和系统翻译基础能力。

这部分为什么要重新拆分

按照目录名直接理解 oak-general-business,很容易把能力划分错。

例如:

  • PassportApplicationPassport 明明和登录相关,但它们一部分依赖 System,另一部分又和 Application 强绑定;
  • Token 虽然是一个对象,但真正的登录逻辑主要写在 aspects/token.tsfeatures/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/register
  • changePassword/*
  • wechatLogin/*
  • oauth/*

它们覆盖的其实不是一件事,而是“正式登录入口”这一整层:

  • 用户名 / 密码 / 手机验证码登录
  • 微信扫码登录
  • OAuth 授权登录
  • 账号密码修改和补齐

所以项目层做登录页时,通常不是从零画,而是先从这几组现成组件里挑合适的入口。

2. 授权分享、临时入口与关系分发

这组最值得优先看的组件是:

  • invite/landing
  • my/invite
  • userEntityGrant/list
  • userEntityGrant/upsert
  • userEntityGrant/claim
  • parasite/list
  • parasite/upsert
  • parasite/detail
  • parasite/excess

这三组能力虽然都和“分享一个入口出去”有关,但语义完全不同:

  • invite 更偏邀请来源归因,最终记录谁邀请了哪个 token
  • userEntityGrant 更偏正式授权、对象关系分发
  • parasite 更偏临时身份、一次性或短期访问入口

如果项目里有“邀请成员”“分享授权链接”“给外部用户一个临时访问口”这类需求,通常先从这里选。

3. 会话、消息与通知面板

这一组最常直接复用的是:

  • session/list
  • session/forMessage
  • sessionMessage/list
  • sessionMessage/upsert
  • message/list
  • message/detail
  • my/message

实际项目里,最稳的拆法通常是:

  • 左侧会话列表复用 session/list
  • 中间消息主体复用 session/forMessage
  • 单条消息输入或补发再按需用 sessionMessage/upsert

这样新项目很快就能先把“能聊起来”的骨架搭出来。

4. 文件、素材与对象存储

这组最常先用的是:

  • extraFile/upload
  • extraFile/commit
  • extraFile/forUrl
  • extraFile/avatar
  • extraFile/gallery
  • wechatMaterialLibrary

其中真正最值得先理解的,还是前面几章已经详细写过的:

  • upload 负责选文件和上传临时对象
  • commit 负责把上传结果落成正式 extraFile
  • forUrl 负责纯展示或 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.tsmake:dep 会在类似 bm-smart/src/initialize.server.ts 这样的文件里,把 oak-general-business 的:

  • checkers
  • common configuration
  • render configuration
  • features

和项目自己的实现合并起来,然后再创建 oak-general-business 的 features:

const ogb0Features = createOgb0Features(totalFeatures);
Object.assign(totalFeatures, ogb0Features);

这一步决定了前端运行时能不能直接调用 features.tokenfeatures.extraFilefeatures.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:initmake:domainmake: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.token
  • features.application
  • features.extraFile
  • features.wechatSdk
  • features.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-busitaicang,会发现这两个项目接 oak-general-business 的方式其实很接近,而且都不是“单独只初始化 general-business 一次”这么简单。

1. 初始化阶段先同时创建 ogb0opb1

这两个项目都会在:

  • src/initialize.server.ts
  • src/initializeFeatures.ts

里创建和初始化:

  • createOgb0Features(...)
  • createOpb1Features(...)

前端运行时负责合并 checkers / common / render / features,后台侧的 aspects / triggers / watchers / timers / data / ports / routines 则由 AppLoader 按依赖图合并进服务端运行态。

2. 很多项目最终只调用 initializeOpb1Features(...)

这点非常容易让新手困惑。haina-busi/src/initializeFeatures.tstaicang/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-busitaicang 这类项目,还会在启动例程里继续注册:

  • 短信实现:registerSms(...) / registSms(...)
  • COS 实现:registerCosBackend(...) / registerCos(...)
  • 消息类型:registerMessageType(...)
  • 消息/通知处理器:registerMessageHandler(...)registerNotificationHandler(...)

所以新手读公共包时,最好把“初始化 feature”和“启动时注册具体供应商实现”一起看,不要只看页面层。

阅读源码时建议按这个顺序

第一次阅读 oak-general-business,建议按下面的顺序走:

  1. 先看 src/features/index.ts
  2. 再看 src/aspects/index.ts
  3. 然后看 src/endpoints/index.ts
  4. 再看 src/watchers/index.ts
  5. 然后回到 src/entities
  6. 最后才去翻 src/components

原因很简单:新手最容易被组件数量吓住,但真正决定模块能力边界的,其实是 feature / aspect / endpoint / trigger / checker / watcher 这些运行时入口。

接下来的每一章,我都会把下面这些信息明确写出来:

  • 这一块对应哪些实体;
  • 已经有哪些可直接复用的组件;
  • 前端通过哪些 feature 调用;
  • 后端通过哪些 aspect 或 endpoint 暴露;
  • 后台还有哪些 trigger / checker / watcher / routine 在兜底;
  • 这些能力究竟是在哪里被注入到项目里的。

System、Passport 与系统级配置

Systemoak-general-business 里最顶层的业务配置对象。很多新手刚看到它时,会把它当成一个普通的“系统信息表”;但在 Oak 里,它的作用更接近“整套通用业务逻辑的系统级根配置”。

密码规则、邮箱能力、地图服务、样式、最低版本约束、默认登录方式,这些看起来互不相关的能力,最终都要么直接挂在 system.config 上,要么通过 system 去找到对应的 passportapplicationplatform

主要对象

这一章最重要的两个实体是:

  • System:定义系统名称、描述、配置、样式、所属平台以及最低版本等系统级信息;
  • Passport:定义一个系统允许出现哪些登录方式,例如 smsemailloginNamewechatPublicForWebwechatMpForWeboauth 等。

其中 Passport 不是“用户已经启用的登录方式”,而是“系统层面允许提供哪些登录入口”。真正落实到某个应用上,还要看后面的 ApplicationPassport

组件

围绕这两个对象,oak-general-business 已经提供了几组常用组件:

  • src/components/system/detail
  • src/components/system/panel
  • src/components/system/passport
  • src/components/system/upsert
  • src/components/passport

此外,src/components/platform/detailplatform/panelplatform/systemplatform/upsert 这组组件虽然属于 Platform,但在实际管理后台里通常也是和 System 一起出现的。

组件适合放在哪里

结合 haina-busitaicang 的现有页面,最常见的放法其实很固定:

  • system/panel:放在系统详情页、系统配置页,例如 haina-busi/src/pages/business/system/web.pc.tsxtaicang/src/pages/console/system/panel/web.pc.tsx
  • system/upsert:放在系统创建页或系统基本信息编辑页,taicang 的系统控制台就是这种拆法
  • platform/panel:放在平台详情页,适合把平台级附加配置做成页签挂进去,haina-busi/src/pages/business/platform/web.pc.tsx 就传了自定义 tabs

system/panel / platform/panel 常用参数

这两个组件项目层最常传的参数其实很少,核心就是:

  • oakId:当前 systemIdplatformId
  • oakPath:当前页面下的数据节点路径
  • tabs:给公共面板追加项目自己的页签

从源码看,system/panel 自带的投影里已经包含:

  • name
  • config
  • description
  • oldestVersion
  • super
  • platformId
  • style
  • translation
  • translateState
  • translationError
  • domain$system

所以它并不是一个“空壳 tab 容器”,而是已经默认把系统基础资料、样式、域名等常用配置一起带上了。

system/panel / platform/panel 内置了哪些页签

这两个组件的另一个关键点,是它们其实已经内置了系统管理后台的大部分标准结构。

system/panel 默认包含:

  • detail
  • config
  • translation(内含翻译配置与翻译数据管理)
  • style
  • application-list
  • domain-list
  • smsTemplate-list
  • login
  • oauth-manage

platform/panel 默认包含:

  • detail
  • config
  • style
  • system-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

它最关键的参数也很少:

  • systemId
  • systemName
  • oakPath

这意味着它适合直接作为“系统登录配置”页签挂进 system/panel 或项目自己的系统详情页里,而不是让项目层手工再拼一次“系统级登录方式 + 应用级登录方式”。

从职责上看,这个组件解决的是两层配置连续操作的问题:

  • 先在系统层确认有哪些 passport
  • 再在应用层确认每个 application 实际启用哪些 passport

这两个动作如果拆成两张完全独立的页面,新手很容易不知道应该先配哪一层;而 system/passport 的组合页签正好把这个顺序固定下来了。

前端入口

系统级配置相关的前端入口主要不是专门的 system feature,而是 features.config

  • updateConfig(...)
  • updateStyle(...)

对应的后端 aspect 位于 src/aspects/config.ts

  • updateConfig
  • updateStyle

这也说明一个很典型的 Oak 设计习惯:有些对象本身不一定有独立 feature,但会通过更通用的 feature 来暴露操作入口。

后台规则

这一章最值得认真读的是 src/triggers/system.tssrc/triggers/passport.ts

System 的触发器会自动维护登录方式基础设施:

  • 新建 system 时,自动创建 smsloginName 类型的 passport
  • 更新 system.config.Emails 时,自动创建、启用、关闭或删除 email 类型的 passport
  • 删除 system 时,自动清理相关 passport

Passport 的触发器则负责把系统级登录方式和应用级登录方式解耦:

  • 禁用 passport 时,删除关联的 applicationPassport
  • 删除 passport 时,也同步删除关联的 applicationPassport

对应的 checker 在 src/checkers/system.ts 中,负责约束系统配置的合法性。

注入点

这一组能力的注入点有两个:

  • 后端通过 ogb0Triggersogb0Checkers 合并进入项目初始化;
  • 前端通过 createOgb0Features(...) 创建出的 features.config 暴露配置修改入口。

也就是说,System / Passport 不是一个“手工管理的静态配置区”,而是已经被接到 Oak 运行时里的。

项目中如何接入

System 这一章在项目里的接入,通常不是“写一个页面去查 system 表”这么简单,而是三层一起接:

  • 初始化阶段合并 oak-general-businesstriggers / checkers / aspects / watchers / common / routines
  • 前端创建并初始化 ogb0Features
  • 后台或管理台通过 System.configPassportApplicationPassport 配出系统级能力。

bm-smart 这类项目里,最小接入代码就是:

const ogb0Features = createOgb0Features(totalFeatures);
Object.assign(totalFeatures, ogb0Features);

await initializeOgb0Features(
  features,
  accessConfiguration,
  undefined,
  [Qiniu, S3, Aliyun]
);

这一层接好以后,后面用户、token、文件、微信、短信等能力才会按 system.config 真正工作。

真实项目里的页面包法

haina-busitaicang 都没有自己重写整套系统配置后台,而是用一个很薄的页面壳去包公共组件:

<SystemPanel
  oakId={systemId}
  oakPath={`${oakFullpath}.system`}
/>

这也是更推荐的项目层写法。系统配置页尽量只负责:

  • 从路由或父节点拿到 systemId
  • 传递稳定的 oakPath
  • 根据项目需要补一两个自定义页签

不要把 SystemPassportApplicationPassport 的增删改查又在项目里重做一遍。

开发注意事项

如果你准备扩展系统管理页,最稳妥的方式通常不是改公共组件内部,而是继续往 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>/indexwechatQrCodeExpireSeconds 是微信临时二维码有效期,单位秒,不配置时默认 2592000,超过微信上限也会按 2592000 处理。

2. 用组件管理登录方式,而不是自己拼表单

系统级登录方式和应用级登录方式,推荐直接复用这些现成组件:

  • src/components/passport/*
  • src/components/applicationPassport
  • src/components/config/upsert
  • src/components/config/application

这样后续 components/user/loginfeatures.token、微信登录组件拿到的就是同一套配置,不会出现“页面判断一套、后台校验另一套”的问题。

使用建议

对于一个新项目,最推荐的顺序是:

  1. 先创建并配置 System
  2. 再确认 system.config 中的 PasswordEmailsMapSecurity 等系统级规则;
  3. 然后检查系统级 Passport 是否已经被自动建立;
  4. 最后再去每个 Application 上配置具体启用哪些登录方式。

如果把这几个步骤反过来做,就很容易出现“界面上有登录组件,但后端并没有正确的 passport 和配置支撑”的情况。

Application、Domain 与应用装配

如果说 System 解决的是“这套业务系统总体怎么配置”,那么 Application 解决的就是“这套业务系统要以什么端形态对外运行”。

在 Oak 里,一个系统往往不只对应一个前端入口。你可能同时有:

  • web
  • wechatMp
  • wechatPublic
  • native

每一种端形态都对应一条 Application 记录。oak-general-business 会根据当前访问环境、域名和版本,自动判断你现在到底命中了哪个应用。

主要对象

这一章实际涉及四个对象:

  • Application:定义应用类型、系统归属、端配置、样式、版本策略;
  • Domain:定义域名和访问入口;
  • ApplicationPassport:定义某个应用实际启用了哪些登录方式;
  • Platform:系统上级平台对象,通常在系统/应用管理界面里一起出现。

其中 Application.config 是最关键的数据,它把不同端需要的配置统一放在一起:

  • web 的微信网页登录配置;
  • wechatMpappId/appSecret/server
  • wechatPublic 的公众号配置和跳小程序配置;
  • native 的微信原生登录配置。

这里要特别注意:访问入口已经不再放在 Application.config.location 里。当前版本把前端页面地址、扫码中转页和后台接口路径拆开维护:

  • Domain 负责系统级域名、协议、端口和 API 代理路径;
  • Application.domainId 只在某个应用必须绑定指定域名时使用;
  • System.config.App.scanPage 负责微信扫码后的中转页逻辑路径;
  • System.config.App.wechatQrCodeExpireSeconds 负责微信临时二维码有效期。

组件

围绕应用管理,这个包提供的组件已经很完整:

  • src/components/application/detail
  • src/components/application/detailForPlatform
  • src/components/application/panel
  • src/components/application/upsert
  • src/components/application/cos
  • src/components/domain/detail
  • src/components/domain/list
  • src/components/domain/upsert
  • src/components/domain/upsertItem
  • src/components/applicationPassport
  • src/components/config/application
  • src/components/config/style
  • src/components/theme/setting

其中 Application 组件更偏向“应用本身”的管理,而 Domain 组件族负责维护域名入口。两者合在一起,才构成真正完整的“应用装配后台”。

如果你是在写一个管理后台,这些组件通常可以直接复用,而不需要从零搭应用管理页。

常用组件与参数

这一组组件里,最常直接包页面的通常是下面四类:

  • application/panel
  • domain/list
  • applicationPassport
  • system/application

其中几个最关键的参数分别是:

  • application/paneltabs
  • domain/listsystemId
  • applicationPassportsystemId
  • system/applicationsystemId

从源码看:

  • application/panelsystem/panelplatform/panel 一样,都支持通过 tabs 追加项目自己的页签
  • domain/list 会直接按 systemId 过滤域名
  • applicationPassport 会按 systemId 拉这个系统下所有 applicationpassport,再生成“每个应用启用哪些登录方式”的管理矩阵

这意味着如果项目已经有系统详情页,通常不需要自己拼:

  • 应用列表
  • 域名列表
  • 应用级登录方式开关

而是直接把这几块公共组件挂进对应页签里。

application/panel 内置了哪些页签

这点很值得单独写出来,因为很多人会误以为 application/panel 只是一个“留给项目自己填内容的面板壳”。实际上从源码看,它默认已经包含:

  • detail
  • config
  • style
  • cos

如果 application.type === 'wechatPublic',还会自动再补:

  • menu
  • autoReply
  • tag
  • user
  • template

如果 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 的模态框
  • 列表通过启用 / 禁用动作控制域名是否参与运行时匹配,不再把删除作为常规入口

它默认编辑和展示的字段就是:

  • url
  • apiPath
  • port
  • protocol
  • ableState

所以项目里如果只是做“系统域名配置”,更推荐直接把它挂到系统详情页或系统配置页里,而不是自己再写一套域名 CRUD。

Domain 的运行时语义

Domain 现在是应用访问入口的统一来源。它的字段含义不要和 src/configuration/access.ts 混在一起:

  • protocolurl 组成站点基础地址,例如 https://example.com
  • port 是可选字段,80、443 这类默认端口通常不需要写
  • apiPath 只给后台接口地址使用,常见于 nginx 反向代理路径
  • ableStatedisabled 时,这条域名不会参与应用识别和二维码链接兜底

oak-general-business/src/utils/domain.ts 里有两个拼接函数,名字就体现了这个边界:

  • composeDomainUrl(domain, url, props):拼面向前端页面的地址,不拼 apiPath
  • composeServerUrl(domain, url, props):拼面向后台接口的地址,会先拼 apiPath

微信扫码图文链接、邀请落地页、开发环境二维码调试链接这类“用户浏览器要打开的页面”,应该走 composeDomainUrl(...)。后台 API、endpoint、反向代理服务地址才应该走 composeServerUrl(...)

Application.domainId 与兜底规则

getApplication 会按“端类型 + 当前域名 + 版本”识别当前应用。当前域名匹配时的顺序是:

  1. 先找 application.domainId 指向的启用域名;
  2. 找不到时,再找同一个 system 下没有指定 domainId 的同类型应用;
  3. 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/scanpages/wechatQrCode/scan/index,运行时也会规整成同一逻辑路径

wechatQrCodeExpireSeconds 单位是秒,用于微信临时二维码和本地 expiresAt。不配置、配置非法值或小于等于 0 时,默认 2592000 秒;超过微信上限时也会按 2592000 秒处理。

wechatQrCode 的应用选择

wechatQrCode 创建时可以显式指定 applicationIdtype。如果指定了,就按这个目标应用生成二维码;如果没有指定,公共 trigger 才进入自动兜底:

  1. 优先使用 System.config.App.qrCodeApplicationIdqrCodeType
  2. 当前应用是服务号时,生成公众号二维码
  3. 当前应用是小程序时,有 qrCodePrefix 就生成小程序普通链接二维码,否则生成小程序码
  4. 当前应用不是微信端时,优先找系统下服务号,再找小程序

这意味着项目层需要强约束二维码目标时,应该在创建 wechatQrCode 时传目标应用;只想使用系统默认策略时,再交给公共包自动兜底。

applicationPassport 的真实交互

applicationPassport 也值得单独说明,因为它并不是一个简单的“勾选启用登录方式”组件。当前源码的真实行为是:

  • 必传 systemId
  • 进入页面时,会先拉当前系统下所有 application
  • 再拉当前系统下所有 enabled: truepassport,并排除 password
  • 最终按“应用”为行、“登录方式类型”为列,生成一张配置矩阵

这个矩阵里还有两个很容易忽略的点:

  • default 列不是展示字段,而是真正的“默认登录方式”选择器
  • loginName 一旦启用,会直接创建 isDefault: trueallowPwd: trueapplicationPassport

从组件内部逻辑看,allowPwd 相关行为也已经做了约束:

  • loginName 会强制带密码,界面上不会允许你把它关掉
  • smsemail 在启用后可以额外控制 allowPwd

另外,如果同一系统下同一类 passport 不止一个,组件不一定用单纯的开关。对 web 应用,或者 wechatMp / wechatPublic 下的 smsemail,它会切成下拉选择模式,让你明确指定这一类登录方式到底绑定哪一个 passport

前端入口与 aspect

应用管理相关的前端 feature 主要有:

  • features.application
  • features.config
  • features.theme
  • features.template

对应的后端 aspect 主要有:

  • getApplication
  • signatureJsSDK
  • updateApplicationConfig
  • updateConfig
  • updateStyle
  • getApplicationPassports
  • removeApplicationPassportsByPIds

其中最核心的是 getApplicationfeatures.application.initialize(...) 最终就是通过这个 aspect,按“端类型 + 域名 + 版本”确定当前应用,并把应用数据缓存到前端。

后台规则

这一章真正的复杂性,分散在四个文件里:

  • src/checkers/application.ts
  • src/triggers/application.ts
  • src/checkers/applicationPassport.ts
  • src/triggers/applicationPassport.ts

Application 相关规则包括:

  • 校验 dangerousVersionswarningVersionssoaVersion 的合法性;
  • 创建 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.tscreate(...) 时会创建 application feature,并把它挂到 features.application

initialize(...)

真正让它生效的,是后续的 initialize(...)

  • 注册 selection / operation rewriter;
  • 调用 features.application.initialize(...) 识别当前应用;
  • 在 web 环境下设置微信落地地址;
  • 在后续章节里还会看到,它也会顺手带动文件上传、小程序自动登录等流程。

后端装配

前端只是把 application feature 建起来,真正让 Application / Domain / ApplicationPassport 的规则生效,还需要后端运行态加载公共包能力:

  • ogb0Aspects
  • ogb0Checkers
  • ogb0Triggers

当前后端装配不再靠项目手写 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:initmake:domainmake: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-busitaicang 都是这样:

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 组件不是简单勾选框,它内部会同时读取系统下的 applicationpassport,所以系统层登录方式没有配好时,应用级页面看起来就会“没有可选项”

使用示例

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.configApplicationPassport 上;
  • 把访问入口、扫码 URL 和邀请落地页绝对地址交给 DomainSystem.config.App

这样一来,登录、文件、微信、版本控制这些横向能力,才能真正围绕应用自动生效。

Users、Mobile 与账号体系

oak-general-business 的“用户系统”并不是一个单表模型,而是一套拆得很细的身份体系。

User 只负责保存用户本身;手机号、账号名、验证码、改密过程、登录方式约束,则分别落在不同对象和不同规则层里。这样做的好处是:你可以非常清晰地控制“谁是用户本体”“哪些是登录凭证”“哪些只是一次性的验证过程”。

主要对象

这一章最重要的对象有:

  • User:保存姓名、昵称、性别、实名认证状态、密码相关状态、头像文件、地址等;
  • Mobile:手机号凭证;
  • LoginName:账号名凭证;
  • Captcha:短信/邮箱验证码;
  • ChangePasswordTemp:改密过程记录。

从业务角度看,这里最值得记住的一点是:User 不等于“所有登录信息”。登录凭证被拆在 MobileLoginName 等对象里,这恰好也是 Oak 后续能够灵活切换登录方式的基础。

组件

这部分已经提供了不少现成组件:

  • src/components/user/info
  • src/components/user/manage
  • src/components/user/register
  • src/components/user/password
  • src/components/mobile/login
  • src/components/mobile/manageList
  • src/components/mobile/upsert
  • src/components/changePassword/byPassword
  • src/components/changePassword/byMobile
  • src/components/my/info
  • src/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 更适合做“只有手机号这一路登录”的页面或弹窗。当前常用参数有:

  • onlyCaptcha
  • onlyPassword
  • callback
  • eventLoggedIn

从组件源码看,它当前最真实的行为是:

  • 发验证码时固定走 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:给当前用户自己改资料,常传 changeMobileUrlchangePasswordUrlauthenticateUrlonConfirm
  • user/manage:给后台做用户列表,常传 userDetailUrlcreateUserUrl
  • user/manage/detail:给后台做单个用户详情,常传 updateUserUrlonUserUpdateonUserPlay

taicang 前台个人资料页多处都把:

changePasswordUrl="/user/password/update"

直接传给 user/info,这样用户资料页和改密页就接起来了。

这三组组件还有几个源码里很明确的行为,值得直接写在文档里:

  • user/info 会直接读取 mobile$userextraFile$entity(tag1='avatar')wechatUser$user
  • user/info 如果当前 token 对应的就是本应用的微信用户,会允许同步微信资料
  • user/manage 的搜索不是只搜昵称,而是同时按 $textmobile$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:常用参数是 onceonSuccess
  • user/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 包括:

  • registerUserByLoginName
  • getChangePasswordChannels
  • updateUserPassword
  • mergeUser
  • bindByMobile
  • bindByEmail
  • sendCaptchaByMobile
  • sendCaptchaByEmail

也就是说,用户体系并不是靠“前端直接操作数据行”来完成的,很多关键动作已经被封装成了命名业务接口。

后台规则

这一章最需要熟悉的文件是 src/triggers/user.tssrc/checkers/user.ts

默认规则包括:

  • 新建用户时,初始状态默认是 shadow
  • 系统里创建出的第一个用户默认会成为 root
  • 更新密码相关字段时,会自动维护 hasPassword
  • 用户激活后,会把相关的 parasite 失效;
  • 实名认证时,会根据系统配置决定是否自动通过。

对应 checker 则负责限制敏感操作:

  • 非 root 用户不能任意禁用、删除关键用户;
  • 实名认证所需数据必须满足要求;
  • 某些敏感字段不能随意更新。

此外,src/triggers/mobile.ts 还会在删除手机号前清理相关的失效 token。

注入点

用户体系的注入点主要有两个:

  • 后端:通过 ogb0Triggersogb0Checkers 合并进入项目;
  • 前端:通过 features.token 暴露登录、绑手机号、发验证码等动作;注册和改密则通过对应 aspect 由 cache.exec(...) 调用。

所以如果你在项目里依赖了 oak-general-business,这些规则通常已经默认生效了,不需要再自己补一套重复逻辑。

项目中如何接入

用户体系在项目里通常不是直接 operate('user') 完事,而是走“组件 + aspect + token feature”的组合:

  • 注册页直接复用 src/components/user/register
  • 手机号登录与绑定复用 src/components/mobile/loginsrc/components/mobile/manageList
  • 改密复用 src/components/changePassword/byPasswordsrc/components/changePassword/byMobile
  • 发验证码、绑手机、绑邮箱统一走 features.token
  • 注册和改密则分别走 registerUserByLoginNameupdateUserPassword 这些 aspect

这也是为什么用户体系虽然没有单独的 user feature,但项目侧仍然很容易用起来。

一个推荐的新手接法

如果你要在现有项目里补一套“先绑手机号、再实名、再改资料”的前台流程,taicang 已经给了一个很好的参考:

  1. /user/authentication 页面里先用 mobile/upsert
  2. 绑定完成后切到 userAuth/upsert
  3. 资料页用 user/info
  4. 改密页单独挂 user/password/update
  5. 对敏感动作再加一层 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$userextraFile$entity(tag1='avatar')wechatUser$user

因为 user/infouser/managetoken/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/login
  • src/components/user/login/password
  • src/components/user/login/sms
  • src/components/user/login/email
  • src/components/token/me
  • src/components/common/weChatLoginGrant
  • src/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() 里会先做这几步:

  1. getApplicationPassports(applicationId) 取当前应用真正启用的登录方式
  2. 找出 isDefault=true 的默认登录方式
  3. 再结合本地存储里的 loginMode 决定当前默认显示哪个 tab
  4. 根据 sms/email/loginName 上的 allowPwd 推导是否允许显示密码登录
  5. 根据 oauth 类型 passport.config.oauthIds 去加载第三方 OAuth 提供方列表
  6. passport.config.digit 里取短信、邮箱验证码位数
  7. application.system.config.Password 里取密码存储模式和密码规则

也就是说,user/login 的显示来源同时依赖:

  • ApplicationPassport
  • Passport.config
  • System.config.Password
  • 本地存储里的上次登录方式

项目层如果发现登录页和后台配置不一致,优先应该回查这四层,而不是先改组件渲染。

user/login/password 常用参数

如果项目只想单独复用密码登录子组件,而不是整套 user/login,最值得先记住的是这些参数:

  • pwdAllowMobile
  • pwdAllowEmail
  • pwdAllowLoginName
  • allowSms
  • allowEmail
  • allowWechatMp
  • setLoginMode
  • pwdMode
  • allowRegister
  • goRegister

它当前的真实行为包括:

  • 账号输入框占位文案会按 pwdAllowMobile / pwdAllowEmail / pwdAllowLoginName 自动拼成“账号/手机号/邮箱”提示
  • 提交前只校验“账号非空 + 密码满足 isPassword(...)
  • pwdMode === 'sha1' 时,会先走 encryptPasswordSha1(...) 再调用 features.token.loginByAccount(...)
  • 登录成功后优先走 callback,没有 callback 才按 url 跳转

所以它更适合:

  • 项目已经确定只做密码登录
  • 但仍然想保留“手机号/邮箱/账号名都可作为账号输入”的灵活性

user/login/sms 常用参数

短信登录子组件最关键的参数则是:

  • digit
  • allowPassword
  • allowEmail
  • allowWechatMp
  • setLoginMode
  • callback
  • url

它的真实行为也很值得写进文档:

  • 发验证码固定走 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.playerId
  • isRoot 取的是当前 player.isRoot,不是单纯看 user.isRoot

它的登录入口逻辑也不是固定跳转:

  • 小程序环境 doLogin() 会直接调用 features.token.loginWechatMp()
  • Web 环境才会按 loginUrl 跳独立登录页

所以 token/me 很适合做:

  • 小程序首页“我的”卡片
  • PC 前台右上角当前用户入口
  • 需要区分“当前是不是在代入别的 player” 的后台入口

common/weChatLoginGrant / common/weChatLoginQrCode

这两个组件虽然不直接挂在 Token 实体上,但它们本质上都是给微信网页登录链路做“前置入口”。

weChatLoginGrant 适合做按钮式授权入口,常用参数包括:

  • appId
  • scope
  • redirectUri
  • state
  • disabled
  • disableText
  • dev

它的真实行为是:

  • 生产环境直接跳微信 OAuth 授权地址
  • 开发环境用本地模拟 code 的方式跳到 redirectUri
  • disabled 时不会跳转,而是提示 disableText

weChatLoginQrCode 则适合桌面端扫码登录,常用参数也很接近:

  • appId
  • scope
  • redirectUri
  • state
  • disabled
  • disableText
  • dev
  • href

它的额外特点是:

  • 生产环境会动态加载微信官方 wxLogin.js
  • href 可以覆盖默认二维码样式
  • disabled 时会显示一层“禁用微信二维码”的遮罩,而不是直接卸载组件

所以项目里如果需要“按钮授权”和“扫码授权”两种入口,并不需要自己拼 OAuth URL,直接复用这两个公共组件更稳。

前端 feature 与 aspect

这一章最重要的前端 feature 是 features.token。它几乎承载了整套登录行为:

  • loginByAccount
  • loginByMobile
  • loginByEmail
  • bindByMobile
  • bindByEmail
  • sendCaptcha
  • loginWechat
  • loginWechatMp
  • loginWechatNative
  • loginByOAuth
  • loginWebByMpToken
  • refreshToken
  • logout
  • switchTo
  • verifyPassword
  • getWechatMpUserPhoneNumber
  • wakeupParasite
  • refreshWechatPublicUserInfo
  • syncUserInfoWechatMp

对应的后端 aspect 集中在 src/aspects/token.ts,其中还包含:

  • sendCaptchaByMobile
  • sendCaptchaByEmail
  • bindByMobile
  • bindByEmail
  • refreshWechatPublicUserInfo
  • setUserAvatarFromWechat

这就是 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 会订阅 application feature,在应用识别成功后读取本地存储里的 token;
  • initialize(...) 在小程序环境下还会按需自动执行 loginWechatMp()

这意味着项目只要正确接入了 oak-general-business,小程序登录、token 本地缓存、刷新与失效,都会自动串起来。

项目中如何接入

Token 能力的项目接入非常固定:

  • createOgb0Features(...) 注入 features.token
  • initializeOgb0Features(...) 里自动识别应用、读取本地 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/loginsrc/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.tssrc/utils/humanVerify/*src/features/humanVerify.tssrc/endpoints/humanVerify.tssrc/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 默认是 enforceenforce 会在 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.required
  • error::humanVerify.unsupportedProvider
  • error::humanVerify.failed
  • error::humanVerify.expired
  • error::humanVerify.serviceUnavailable
  • error::humanVerify.riskTooHigh

内置 Provider

oak-general-business 内置了三个 provider:

provider作用关键配置
debug调试用,通过约定 token 模拟通过或失败。token,默认 debug-pass
turnstileCloudflare Turnstile。前端隐藏执行 challenge,后端调用 Cloudflare siteverifysiteKeysecretKeyexpectedHostnameactionthemelanguage
altchaALTCHA 自托管挑战。后端签发 challenge,前端显示 altcha-widget,后端验证 payload。hmacKeychallengeUrlexpiresInmaxNumbersaltLengthhideFooter

后端 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 导出。它会:

  1. 根据请求里的 applicationId 切到对应应用;
  2. 读取当前应用所属 system.config.humanVerify
  3. 只有 activeType === 'altcha' 时才使用 ALTCHA 配置;
  4. hmacKeyexpiresInmaxNumbersaltLength 创建 challenge;
  5. sceneaction 写入 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.jsonoak.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/passwordauth.login.accountfeatures.token.loginByAccount(...)
components/user/registerauth.register.loginNamefeatures.token.registerByLoginName(...)
components/user/login/smscomponents/mobile/logincomponents/changePassword/byMobileauth.captcha.sendMobilefeatures.token.sendCaptcha('mobile', ...)
components/user/login/emailcomponents/email/upsertauth.captcha.sendEmailfeatures.token.sendCaptcha('email', ...)

服务端对应的强制校验点在:

  • src/aspects/token.tsloginByAccount(...)
  • src/aspects/token.tssendCaptchaByMobile(...)
  • src/aspects/token.tssendCaptchaByEmail(...)
  • src/aspects/user.tsregisterUserByLoginName(...)

如果项目自己绕开这些公共组件、直接调用 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,最小需要两端:

  1. 后端实现 HumanVerifyProvider.verify(...),通过 registerHumanVerifyProvider(...) 注册。
  2. 前端实现 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 邀请归因

Inviteoak-general-business 6.1.0 新增的一套通用邀请归因能力。它解决的不是“注册表单怎么画”,而是“一个用户通过谁的邀请入口进入应用,并在后续登录或注册成功后,把这次来源关系可靠地落下来”。

所以阅读这块能力时,最好先把它和 UserEntityGrantParasite 分开:

  • UserEntityGrant 解决的是把某个对象上的关系权限分享给别人认领;
  • Parasite 解决的是先给一个临时身份入口,后续再唤醒或激活;
  • Invite 解决的是邀请来源归因,最终落到邀请人、被邀请 token 和应用之间的关系。

也就是说,Invite 更适合“邀请注册”“邀请好友”“渠道归因”“扫码进入后再登录”这类场景。

主要对象

邀请归因链路里有三个实体:

  • invite
  • inviteTouch
  • inviteRelation

invite 是邀请人的邀请身份。当前后端会保证同一个 inviterId + applicationId 下只有一条有效邀请记录,调用 getMyInvite 时如果不存在会自动创建,如果存在但被停用则会重新启用。

inviteTouch 是一次触达记录。用户打开邀请链接、扫邀请二维码,或者从微信分享进入时,只要最终进入公共触达页并调用了 touchInvite,都会先生成一条 touch。它记录:

  • 属于哪条 invite
  • 属于哪个 application
  • 来源是 webLinkwechatQrCodewechatPublicScan 还是 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.code
  • result.landingUrl
  • result.landingRoute

landingUrl 是对外分享的完整 URL。它不是从旧的 application.config.location 拼出来的,而是通过当前 Application 绑定的 Domain 生成,路径固定走邀请触达页:

/invite/landing?code=...

公共包也提供了 components/my/invite,可以直接展示邀请码、邀请链接、二维码和触达记录。项目里如果只需要一个“我的邀请”入口,优先包这个组件,而不是从零写。

2. 被邀请人打开邀请落地页

模板里已经有:

template/src/pages/frontend/invite/landing

这个页面会挂载 components/invite/landing。组件拿到 codesource 后,会调用:

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 上,webwechatMpwechatPublicnative 类型应用都支持。

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 会主动写入的来源。wechatPublicScanwechatMpSharetouchInvite 接受的来源值,但需要具体入口显式传入;不要误以为公共包会在所有公众号扫码或小程序分享场景里自动创建 touch。

微信登录还有一个特殊归因能力:loginWechatloginWechatMploginWechatNative 等流程最终会加载 token 信息;如果 token 关联的是 wechatUser,后端会尝试用同一个 wechatUserId 最近一次未转化 touch 来生成 inviteRelation。这个能力的前提是 touch 记录本身带了 wechatUserId。当前公共 /invite/landing 模板只接收 codesource,不会自动取得并传入 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,创建:

  • invite
  • inviteTouch
  • inviteRelation

如果项目还没完成 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 的是 loginByMobileloginByEmailloginByAccountregisterByLoginName。OAuth、微信登录或项目自定义登录入口,需要单独确认有没有自己的归因链路。

误以为一个用户可以被多次归因

当前唯一约束是 inviteeTokenId + applicationId。同一个 token 在同一应用里只会归因一次;后续再打开其它邀请链接,最多只会把新的 touch 标记为已转化,不会改写已有归因。

忽略 touch 有效期

默认有效期是 7 天。过期 touch 不会再生成关系。项目如果需要更短或更长的窗口,应该配置 Application.config.invite.touchTtl

和其它分享能力怎么选

如果只是想知道“这个注册用户是谁邀请来的”,用 Invite

如果要把某个具体业务对象的权限分享给别人领取,用 UserEntityGrant

如果要给一个还没正式登录的人先创建临时身份,并让他以这个临时身份进入流程,用 Parasite

这三个能力可以组合,但不要互相替代。邀请归因应该保持轻量,只记录来源关系;业务权限、临时身份和后续奖励逻辑,应放在各自更合适的对象或项目私有逻辑里。

UserEntityGrant 授权分享

UserEntityGrantoak-general-business 里很有 Oak 味道的一块能力。它解决的不是“普通菜单授权”,而是“把某个对象上的一组关系权限,以链接或二维码的形式分享给另一个用户来认领”。

这类需求在客服转交、资源共享、邀请协作这些场景里很常见,而 oak-general-business 已经把它做成了一套完整的对象和规则。

主要对象

这一章最重要的实体是 UserEntityGrant

它定义了:

  • 权限作用在哪个 entity/entityId 上;
  • 授权类型是 grant 还是 transfer
  • 关系的选择规则 rule
  • 行对象的选择规则 ruleOnRow
  • 是否允许多人认领;
  • 二维码类型 qrCodeType
  • 过期时间、重定向页面和认领路由。

从运行时看,它还会和 wechatQrCode 以及 Oak 内建的认领关系数据一起工作。

组件

围绕这一能力,已经有四类常用组件:

  • src/components/userEntityGrant/list
  • src/components/userEntityGrant/share
  • src/components/userEntityGrant/upsert
  • src/components/userEntityGrant/claim

其中 claim 组件最值得认真读。它不是单纯展示一条授权,而是会根据 ruleruleOnRow 和当前用户已有认领状态,组织“选择关系 + 选择对象行 + 执行认领”的完整前端流程。

userEntityGrant/claim 常用参数

oak-general-business/src/components/userEntityGrant/claim/index.ts 项目里最常用的参数有:

  • picker:自定义领取对象选择器
  • hideInfo
  • hideTip
  • afterClaim

其中最重要的是 picker。它决定“授权领取时,用户究竟从什么对象里挑关系和行”。taicang/src/pages/frontend/userEntityGrant/claim/web.tsx 的真实写法就是:

<UserEntityGrantClaim
  oakId={oakId}
  oakPath={oakFullpath}
  picker={UbPicker}
/>

也就是说,这个组件本身已经把授权领取流程做好了,项目层真正需要补的是“如何挑选业务对象”的那块 picker。

claim 里的 picker 到底要满足什么接口

这一点在源码里其实写得很清楚,但如果文档不展开,新手很容易不知道该怎么自定义 picker。

userEntityGrant/claim 当前要求的 picker 组件签名是:

  • disabled
  • entity
  • entityFilter
  • relationIds
  • rule
  • ruleOnRow
  • onPickRelations(ids)
  • onPickRows(ids)
  • pickedRowIds
  • pickedRelationIds
  • oakPath

也就是说,项目层自定义 picker 时,不是只要“返回一组选中行”就够了,而是要同时处理:

  • 关系怎么选
  • 目标行怎么选
  • 当前已经选中了什么
  • 当前规则是不是单选 / 全选

claim 组件自己只负责在 pickedRelationIds + pickedRowIds 都具备时,自动把它们展开成 userEntityClaim$ueg.create 数据,再执行 claim

默认 ubPicker 的真实行为

如果项目不传自定义 picker,最值得先参考的其实就是公共包自带的:

  • src/components/userEntityGrant/claim/ubPicker

它当前的真实行为包括:

  • entity() 直接取授权上的 relationEntity
  • 目标行 projection 会自动猜 nametitle 字段,没有就退回 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、是否过期、过期时间

它还暴露了一组很适合项目层做样式定制的参数:

  • disableDownload
  • size
  • disabled
  • color
  • bgColor
  • maskColor
  • maskText
  • maskTextColor
  • mode: 'default' | 'simple'

所以项目里如果要做:

  • 分享二维码弹窗
  • 授权卡片页
  • 领取入口海报区

通常不需要自己处理二维码 buffer 或图片转换,直接复用这个组件更稳。

userEntityGrant/list 的真实筛选能力

oak-general-business/src/components/userEntityGrant/list/index.ts 这组组件同样值得补出来,因为它不是简单把授权记录列出来。当前源码里的关键参数是:

  • entity
  • entityId
  • relationEntity
  • relationEntityFilter

组件会直接按这四个条件过滤 userEntityGrant,并默认按 $$createAt$$ desc 排序。也就是说,它更适合挂在“某个业务对象自己的授权记录列表”里,而不是全局授权台账。

从 web 端真实行为看,它还已经内置了两类非常常用的管理动作:

  • disable:如果当前行 legal action 里有 disable,就直接把授权置失效
  • 二维码:弹出一个 Modal,里面直接挂 UserEntityGrantShare

所以项目层如果只是想给后台加一个“看历史分享、让某条分享失效、重新看二维码”的页,通常不需要自己再包一层复杂逻辑,直接用这组组件就够了。

userEntityGrant/upsert 的真实职责

userEntityGrant/upsert 不是一个通用大表单,它当前更像“生成一次分享授权”的专用入口。源码里最关键的输入参数有:

  • entity
  • entityId
  • relationEntity
  • relationEntityFilter
  • relationIds
  • type
  • redirectToAfterConfirm
  • claimUrl
  • qrCodeType
  • multiple
  • rule
  • ruleOnRow

它在 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.ts
  • src/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/upsertshareclaim
  • 如果你本来就在做用户关系管理,更推荐直接接 src/components/userRelation/upsert/byUserEntityGrant

byUserEntityGrant 这条接法里,真正决定分享行为的关键输入有:

  • entity / entityId
  • relations
  • redirectToAfterConfirm
  • claimUrl
  • qrCodeType
  • multiple
  • rule

这样分享页、二维码页、认领页、过期失效逻辑会一起工作。

如果按组件职责来落页,更推荐这样拆:

  • 关系管理页或对象详情页里挂 userEntityGrant/upsert
  • 历史分享记录页挂 userEntityGrant/list
  • 分享成功弹窗或分享海报区直接挂 userEntityGrant/share
  • 真正的领取页单独挂 userEntityGrant/claim

这样“生成授权”和“消费授权”会天然分层,不会在一个页面里把创建、二维码展示、认领三件事搅在一起。

真实项目里的入口组织

haina-busitaicang 的做法都很接近:

  • 后台或管理页里的关系维护组件,通常会把 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 寄生登录

Parasiteoak-general-business 里一个很特别的设计。它不是普通的用户,也不是普通的 token,而是一种“先寄生在某个对象或流程上,后续再被激活成正式登录态”的中间身份。

这个能力通常出现在这些场景里:

  • 用户还没正式注册,但已经开始参与流程;
  • 需要先发一个临时访问入口给用户;
  • 需要在某个对象上生成一次短期、可回收的临时身份。

主要对象

这一章的核心实体是 Parasite

它定义了:

  • 临时身份属于哪个用户;
  • 作用在哪个 entity/entityId 上;
  • 过期时间和是否允许重复使用;
  • 唤醒后应该跳到哪个页面;
  • 关联生成出来的 token。

从对象结构就能看出来,它更像一个“临时登录入口”而不是“长期账号”。

组件

现成组件主要有:

  • src/components/parasite/detail
  • src/components/parasite/excess
  • src/components/parasite/list
  • src/components/parasite/upsert

其中 detail 组件会在 web 环境下直接生成寄生访问链接,excess 则更像实际落地页。

parasite/upsert 常用参数

这组组件是项目里最常直接包起来用的创建入口,关键参数有:

  • entity
  • entityId
  • relation
  • redirectTo
  • multiple
  • nameLabel
  • nameRequired

它当前的真实流程是:

  1. 先按昵称前缀搜索 shadow 用户
  2. 如果选中了现有 shadow 用户,就直接复用 userId
  3. 如果没选中用户,就创建一个新的 shadow 用户,并自动补一条 userRelation
  4. expiresAttokenLifeLength 都设成“有效期天数换算后的毫秒数”
  5. 创建成功后直接切换到 parasite/detail 展示二维码和链接

也就是说,这个组件不是“只创建 parasite 记录”,而是已经把:

  • 找人
  • 补影子用户
  • 建关系
  • 生成分享入口

这一整段流程串起来了。

parasite/list 适合放在哪里

它的关键参数是:

  • entity
  • entityId
  • nameLabel

真实行为则是:

  • 只看当前 entity + entityId 下的 parasite
  • 默认按创建时间倒序
  • 表格操作里直接暴露 cancelqrcode
  • 点“详情”时会在弹窗里包 parasite/detail

所以这组组件最适合放在:

  • 某个业务对象的后台管理页
  • 某条邀请关系的分享记录页
  • 某个领取流程的二维码管理页

parasite/detail 的可调参数

除了自动生成链接,它还暴露了几组很实用的展示参数:

  • disableDownload
  • size
  • disabled
  • color
  • bgColor

源码里它的真实链接生成方式也值得直接写进文档:

  • web 环境下按 window.location.protocol + hostname + port
  • 自动拼 /parasite/excess?oakId=${parasite.id}

这意味着项目层如果部署域名已经稳定,parasite/detail 生成的链接就可以直接拿去复制、发二维码、放海报。

parasite/excess 的真实职责

这个组件真正干的是“消费寄生入口”,不是单纯展示页面。它进入后会:

  1. 先按 oakId 查 parasite
  2. 非法就标记 illegal
  3. 过期就标记 expired
  4. 先执行 features.token.removeToken()
  5. 再执行 features.token.wakeupParasite(parasite.id!)
  6. 最后按 redirectTo 跳回业务页

而且它在跳转时还会额外把:

  • name
  • parasiteId

一起塞进路由参数。

所以如果项目层想在目标页感知“这是寄生入口进来的”,完全可以直接读 parasiteId

前端入口与 aspect

这一章没有单独的 parasite feature,但它并不是没有前端入口。

真正的唤醒入口在:

  • src/aspects/token.tswakeupParasite
  • features.token.wakeupParasite(...)

也就是说,Parasite 的激活最终仍然走的是 token 体系,而不是自己另起一套登录机制。

后台规则

Parasite 的默认规则主要在:

  • src/checkers/parasite.ts
  • src/triggers/parasite.ts

默认行为包括:

  • 创建时强制检查 expiresAttokenLifeLength 不能为空;
  • 如果是挂到已有 userId 上,对应用户必须还处于 shadow 状态;
  • 过期时,使关联 token 自动失效;
  • 执行 cancel 时,也同步使关联 token 失效。

而在 src/aspects/token.tswakeupParasite(...) 里,还会继续做两层限制:

  • 已经过期的 parasite 不允许再唤醒;
  • 只有 shadow 用户才能被借用身份唤醒。

真正唤醒成功后,创建出来的 token 也不是长期有效的,它会按 tokenLifeLength 计算 disablesAt

此外,src/triggers/user.ts 里还有一条很重要的规则:当用户被正式激活后,会把相关的 parasite 作废。

另外还有一个直接影响业务设计的点:

  • 如果 multiple=falsewakeupParasite(...) 会在创建 token 前先把当前 parasite 标记成失效

这就是一次性寄生入口的真实落地方式。

这说明寄生模式本质上是一段过渡态,而不是长期身份模型。

注入点

这一章的注入点分成两部分:

  • 后端规则通过 ogb0Triggersogb0Checkers 注入;
  • 前端激活入口通过 features.token 暴露。

所以项目层通常不需要自己再做一次“寄生态转正式态”的底层逻辑。

项目中如何接入

Parasite 在项目里通常会拆成两端:

  • 管理端或后台页面,负责创建/查看寄生记录;
  • 消费端页面,负责拿到 oakId 后调用 features.token.wakeupParasite(...) 激活寄生 token。

公共包里已经把这两端组件都写好了:

  • src/components/parasite/list
  • src/components/parasite/detail
  • src/components/parasite/upsert
  • src/components/parasite/excess

所以项目层真正要做的,通常只是把路由接出来。

而且 redirectTo 本身就是实体字段,项目层通常只要在创建时配好:

  • pathname
  • props
  • state

激活成功后,公共组件就会按这组配置跳回业务页。

当前项目里的实际情况

从这次对 haina-busitaicang 的源码检索来看,当前没有看到它们各自落了独立的 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/list
  • src/components/session/cell
  • src/components/session/header
  • src/components/session/forMessage
  • src/components/session/messageNumber
  • src/components/session/sessionMessage
  • src/components/message/list
  • src/components/message/detail
  • src/components/message/cell
  • src/components/message/simpleList
  • src/components/my/message
  • src/components/sessionMessage/list
  • src/components/sessionMessage/upsert
  • src/components/sessionMessage/cell
  • src/components/messageTypeTemplate/list
  • src/components/messageTypeSmsTemplate/list
  • src/components/messageTypeSmsTemplate/tab

也就是说,这部分不仅有实体和后台逻辑,连前端展示层都已经准备了不少基础积木。

aspect / endpoint / feature

这部分直接暴露的后端 aspect 主要是:

  • createSession

它定义在 src/aspects/session.ts,用于按当前应用类型和消息来源创建或获取会话,并在需要时级联创建 sessionMessage

这一章没有独立的 session endpointmessage endpoint,但它会通过微信相关 endpoint 间接进入。最典型的入口就是 src/endpoints/wechat.ts,公众号或小程序消息回调最终会调用 createSession(...)

此外,这一章还会和 features.template 发生关系。features.template 并不直接发送消息,但会负责:

  • 获取消息类型列表;
  • 同步微信模板;
  • 同步短信模板。

所以消息类型和模板映射虽然在数据层属于“消息域”,但真正的同步入口在 Template feature 和对应 aspect 里。

后台规则

这一章的核心后台逻辑主要分布在几组 trigger / checker 文件里:

  • src/triggers/session.ts
  • src/triggers/sessionMessage.ts
  • src/triggers/message.ts
  • src/triggers/notification.ts
  • src/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 这一组组件其实比看起来更灵活。项目里最常用的参数有:

  • entityFilter
  • entityFilterSubStr
  • entityDisplay
  • entityProjection
  • sessionId
  • dialog
  • onItemClick

其中有两种非常不同的工作模式:

  1. 不传 entityFilter 这时组件会默认按 当前 userId 查自己的会话列表,并订阅 ${DATA_SUBSCRIBER_KEYS.sessionList}-u-${userId} 这类数据事件;
  2. entityFilter 这时它会按业务对象维度查会话,并根据 entityFilterSubStr 订阅对应的数据事件。

entityDisplay + entityProjection 这组参数也很关键。它们决定“列表上显示的是用户名字,还是某个业务对象名字”。如果你是在做“对象会话列表”而不是“我的私信列表”,通常就应该一起传这两个参数,让组件把会话里关联对象名称补出来。

还有两个很实用的行为:

  • 如果父层传了 sessionId,组件会默认把这一条设成选中态;
  • 如果传了 onItemClick,点击会话后不会直接跳 /session/sessionMessage,而是把控制权交给父组件。

这意味着 session/list 既可以做独立页左侧会话栏,也可以做弹窗里的嵌入式会话选择器。

session/forMessage 的真实职责

这组组件在原文里还没展开,但它非常适合做“消息页顶部的当前会话头”。oak-general-business/src/components/session/forMessage/index.ts 当前最关键的参数是:

  • sessionId
  • isEntity
  • entityDisplay
  • entityProjection

它的真实行为是:

  • 如果传了 sessionId,就直接从 cache 里把这条 session 及其用户 / 关联对象信息读出来;
  • 如果 sessionId 发生变化,会自动重新取当前会话;
  • isEntity = true 时,标题优先按会话里的用户信息显示;
  • isEntity = false 时,则会调用你传入的 entityDisplay([session]) 来决定显示名。

也就是说,这个组件的价值不是“再包一个 header”,而是把“当前聊天对象叫什么”这件事统一收口了。

my/message 的真实职责

如果只是做个人中心里的“未读消息入口”,通常没必要直接挂整页消息列表,my/message 就够了。这个组件的真实行为很集中:

  • 进入时直接按当前登录用户 userId 统计 message.visitState === 'unvisited'
  • 维护一个未读数量 count
  • 点击后默认跳 /message/list

也就是说,它更像一个“消息角标入口组件”,适合放在:

  • 我的页面
  • 个人中心快捷入口区
  • TabBar 上方的消息入口卡片

而真正的消息列表页、消息详情页,再分别交给 message/listmessage/detail

真实项目里的启动注册

这一块最值得直接参考 haina-busitaicang 的例程:

  • haina-busi/src/routines/messageType.ts
  • taicang/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,必要时补出新的 sessionMessageextraFile

2. 客服页直接复用会话组件族

如果项目要做一个标准的聊天后台,最推荐的组合是:

  • src/components/session/list
  • src/components/session/sessionMessage
  • src/components/sessionMessage/list
  • src/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.tstriggers/notification.ts 自动完成。

使用建议

最简单的判断标准是:

  • 需要围绕某个对象做持续会话,用 Session / SessionMessage
  • 需要给用户发系统通知或跨渠道消息,用 Message / Notification
  • 需要做模板映射,就把业务消息类型维护在 MessageType 一侧,再绑定微信模板或短信模板。

这样拆开以后,后续对微信、短信等具体渠道的理解也会清晰很多。

ExtraFile 文件与对象存储

ExtraFileoak-general-business 里复用率非常高的一块能力。头像、附件、图片、文章素材、消息图片、微信素材,很多看起来不同的“文件需求”,最后都会落到这一个对象模型和一组统一的上传流程上。

它最大的价值不是“多一个文件表”,而是把:

  • 文件元数据;
  • 应用级 COS 配置;
  • 前端上传过程;
  • 分片上传;
  • 远端删除;
  • URL 生成;

全部收敛成了一套统一运行方式。

主要对象

这一章的核心实体是 ExtraFile

它定义了:

  • 文件来源 origin
  • 文件类型 type
  • 关联对象 entity/entityId
  • 文件名、后缀、大小、对象存储桶等元数据
  • 上传状态 uploadState
  • 是否启用分片上传
  • 分片上传信息 chunkInfo

也就是说,在 Oak 里“文件”从来不是游离在业务对象之外的,而是明确绑定在某个业务对象上的。

组件

围绕文件能力,这个包提供的组件非常多:

  • src/components/extraFile/upload
  • src/components/extraFile/gallery
  • src/components/extraFile/avatar
  • src/components/extraFile/crop
  • src/components/extraFile/commit
  • src/components/extraFile/forUrl

如果只是要做上传、预览、头像或附件列表,通常都不需要自己从头写。

extraFile/upload 常用参数

oak-general-business/src/components/extraFile/upload/index.ts 的参数很多,但项目里最常用的是这些:

  • entity / entityId:文件挂到哪个实体、哪条记录上
  • type:文件类型,常见是 image
  • origin:文件走哪个 COS 来源
  • tag1 / tag2:业务标签
  • autoUpload:选完文件是否立刻上传
  • maxNumber:最多允许多少个文件
  • accept:web 端允许的 MIME 类型
  • themefileimageimage-flowcustom
  • disablePreview / disableDelete / disableAdd / disableDownload
  • chunkOptions:大文件分片上传配置

结合 oak-general-business/src/components/extraFile/upload/index.ts 的真实实现,这里还有几条非常值得直接写出来的隐含约束:

  • 如果 autoUpload = true,组件内部会直接 assert(entityId),也就是自动上传模式下必须已经知道文件要挂到哪条业务记录上;
  • 组件卸载时,如果还有 uploading 状态的文件,会主动调用 features.extraFile.abortUpload(...) 中止上传;
  • 小程序端会按 type 自动分流:image/videowx.chooseMedia,其它文件走 wx.chooseMessageFile
  • calcMd5 = true 时,会在前端先算 MD5,再把值带进 extraFile.md5
  • tag1/tag2 不只是展示标签,组件内部会把它们直接带进 filter 和创建数据。

也就是说,这个组件不是一个纯 UI 壳,而是已经把:

  • 文件选择
  • 本地暂存
  • 上传态跟踪
  • 自动上传 / 手动上传分流
  • 卸载中止

这些都接进去了。

extraFile/commit 常用参数

这组组件的职责不是选文件,而是把当前节点里所有 extraFile 相关操作和实体提交一起执行。项目层最常传:

  • entity:当前页面真正要提交的实体
  • action:默认 update,也可以是项目自己的动作
  • afterCommit:提交并上传成功后的回调
  • beforeCommit:提交前拦截校验
  • messageProps:执行时的消息提示

它的真实执行顺序也很值得写清楚:

  1. 先从当前 runningTree 里递归找出本次操作里涉及的 extraFile 创建项;
  2. 先执行当前实体的 execute(...)
  3. 再把这些 extraFile 里仍处于 local/failed 状态的文件逐个调用 features.extraFile.upload(...)
  4. 如果有失败文件,会把 failureIds 留在组件状态里;
  5. 下一次再点提交时,不会重复执行实体操作,而是只重试失败上传。

这意味着 extraFile/commit 很适合放在“表单提交按钮”位置,因为它本来就不是普通按钮,而是“实体提交 + 文件补传”的组合动作。

extraFile/uploadextraFile/commit 的典型搭配

这两组组件最推荐的理解方式其实是:

  • upload 负责把文件先挂进当前 Oak 数据节点;
  • commit 负责把业务实体和文件一起真正提交完成。

所以项目层如果是“编辑资料页 + 上传附件”这种典型表单,不建议把上传和保存拆成两套互不相干的按钮,更推荐让底部主按钮直接走 extraFile/commit

extraFile/forUrl 常用参数

这个组件适合“图片地址不是本地上传,而是外部 URL”的场景。最常用的是:

  • entity / entityId
  • tag1 / tag2
  • imgUrls
  • origin

taicang 里外链新闻素材页就用了它来处理外部图片 URL。

它还有几个很容易被忽略、但源码里已经做好的行为:

  • 支持三种录入方式:本地上传、直接填 URL、从 imgUrls 里挑原图;
  • 如果 URL 是 mmbiz.qpic.cn 这类微信图片地址,会自动把 isBridge 置为 true
  • origin 为外链模式时当前会直接记成 unknown,并把 uploadState 设为 success
  • 如果当前节点本来已经挂了一张图,重新选择时会先删旧图再建新图。

所以它并不是一个“单纯展示 URL 输入框”的组件,而更像“把外部图片也纳入 extraFile 统一模型”的桥接器。

extraFile/gallery 常用参数

如果页面只需要“展示已经挂好的文件”,通常更适合直接用 extraFile/gallery,而不是继续复用上传组件。这个组件常用参数包括:

  • entity / entityId
  • tag1 / tag2
  • mode
  • size
  • style
  • disablePreview
  • disableDownload

从源码看,它有几个很明确的默认行为:

  • 会先按 sort 排序
  • 如果传了 tag1 / tag2,会先在当前数据集中做二次过滤
  • 展示 URL 和缩略图 URL 都统一走 features.extraFile.getUrl(...)
  • 文件名统一走 features.extraFile.getFileName(...)

其中:

  • style 用来控制缩略图 URL 的样式参数
  • modesize 更偏小程序展示
  • 小程序环境下如果没禁用预览,会直接调用 wx.previewImage(...)

所以项目里做:

  • 图库
  • 商品图片列表
  • 文章插图预览
  • 用户上传附件展示

这类只读场景时,优先用 gallery 会更干净,不要再拿上传组件硬改成只读模式。

前端 feature 与 aspect

文件能力最主要的前端入口是 features.extraFile。它负责:

  • 本地文件暂存;
  • 普通上传;
  • 自动上传;
  • 分片上传;
  • 终止上传;
  • 生成展示 URL。

对外公开的后端 aspect 主要有:

  • getInfoByUrl
  • mergeChunkedUpload
  • presignFile
  • presignMultiPartUpload

这些 aspect 主要服务于上传链路本身,而不是给业务层做复杂的文件处理。

trigger / watcher

这部分的后台规则非常完整。

src/triggers/extraFile.ts 负责:

  • 创建 extraFile 时生成上传所需的元数据;
  • 对分片上传生成初始分片信息;
  • 删除 extraFile 时同步删除远端文件。

src/watchers/extraFile.ts 负责:

  • 定期检查长时间处于 uploading 状态的普通上传;
  • 定期处理长时间未完成的分片上传;
  • 能合并就合并,不能合并就标记失败。

这说明 ExtraFile 不是一个“前端传完就结束”的能力,它有完整的后台补偿链路。

注入点

这一章最重要的注入点在 oak-general-business/src/features/index.tsinitialize(...)

  • 如果你传入了 COS 类数组 clazzes,就会执行 features.extraFile.registerCos(clazzes)
  • 之后 features.extraFile 才知道不同 origin 应该如何上传、签名和拼接 URL。

因此,像 bm-smart 这类项目会在初始化时显式传入 QiniuS3Aliyun 等实现。

真实项目里的 COS 注册方式

这块在 haina-busitaicang 里都能看到,而且前后端写法略有不同:

  • haina-busi/src/routines/start.tsregisterCosBackend(Aliyun)registerCosBackend(S3)
  • taicang/src/routines/start.ts / start.frontend.tsregisterCos(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-busitaicang 的页面来看,最常见的组合其实只有三种:

  • 表单页里嵌 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(...)

使用建议

正确的使用顺序通常是:

  1. 先创建 extraFile 数据;
  2. 让 trigger 补齐上传元数据;
  3. 再通过 features.extraFile 发起上传;
  4. 如果是分片上传,交给 watcher 兜底检查和补偿。

如果一开始就绕过 ExtraFile 直接往对象存储写文件,那么后面很多 Oak 侧的关联关系和自动行为就都接不上了。

WeChat 公众号/小程序能力

oak-general-business 里最复杂的一块通用能力,几乎一定是微信生态。

这里不是只有“微信登录”这么简单,而是同时覆盖了:

  • 网页微信登录;
  • 小程序登录;
  • 公众号网页登录;
  • 公众号回调;
  • 微信用户绑定;
  • 微信二维码;
  • 菜单管理;
  • 公众号标签;
  • 微信模板;
  • 素材管理;
  • 短信跳小程序 openlink。

如果把这些能力都混在一起看,会非常乱。所以这一章的重点,是把它们在 Oak 里的边界讲清楚。

主要对象

微信相关的主要实体包括:

  • WechatUser
  • WechatLogin
  • WechatQrCode
  • WechatMenu
  • WechatPublicTag
  • UserWechatPublicTag
  • WechatTemplate
  • WechatPublicAutoReply
  • WechatMpJump

此外,很多微信能力其实还依赖 Application.config 里的微信配置,所以 Application 仍然是微信能力的根配置入口。

组件

这一章涉及的组件非常多,常见的有:

  • src/components/passport/wechatMp
  • src/components/passport/wechatMpForWeb
  • src/components/passport/wechatPublic
  • src/components/passport/wechatPublicForWeb
  • src/components/wechatLogin/qrCode
  • src/components/wechatLogin/confirm
  • src/components/wechatUser/login
  • src/components/wechatUser/bindingList
  • src/components/wechatUser/unbindBtn
  • src/components/wechatQrCode/scan
  • src/components/wechatQrCode/share
  • src/components/wechatMenu/*
  • src/components/wechatPublicTag/*
  • src/components/userWechatPublicTag/*
  • src/components/wechatMaterialLibrary
  • src/components/wechatPublicAutoReply
  • src/components/common/weChatLoginGrant
  • src/components/common/weChatLoginQrCode

从这些组件命名就能看出来,微信能力在这个包里已经不只是“一个登录按钮”了。

wechatUser/login 常用参数

这个组件本身几乎不要求项目层传很多东西,最常见的就是:

  • code
  • state
  • oakPath

taicang/src/pages/frontend/wechatUser/login/web.pc.tsxhaina-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.iduserId,作用就是给解绑按钮提供当前记录上下文。项目层通常会把它和:

  • unbindingWechat

这类 aspect 动作一起用,而不是自己去查 wechatUser 再手工拼按钮。

公众号后台管理组件

除了登录和扫码,oak-general-business 还把公众号后台常用能力拆成了几组现成组件。它们最适合直接挂在 application/panel 自动生成的微信页签里。

wechatMenu

这个组件最关键的参数是:

  • applicationId
  • tabKey

它在进入页面时会先拉当前 applicationId 下、没有 wechatPublicTagId 的默认菜单,并把 menuId 放到内部状态里。也就是说,它默认管理的是“公众号主菜单”,不是泛化的任意菜单树。

wechatPublicTag/list

这个列表组件的关键参数只有:

  • applicationId

但它内部已经带了几个实际动作:

  • 单条 sync
  • oneKeySync
  • 删除标签

这意味着项目后台如果只是做公众号标签同步,不需要自己调用 feature 拼一个管理页,直接复用它就够了。

userWechatPublicTag/subscribedList

这也是公众号运营后台里很实用的一个组件,关键参数同样只有:

  • applicationId

它默认只查:

  • origin: 'public'
  • subscribed: true

所以它适合做“已关注用户与标签”的运营列表,而不是泛化的全部微信用户列表。它内部还能直接调用:

  • userWechatPublicTag.tagging(...)
  • userWechatPublicTag.syncToLocale(...)

wechatMaterialLibrary

这个组件最重要的参数是:

  • applicationId
  • type
  • getMenuContent

其中 type 决定它去拉:

  • news 图文素材
  • 其它永久素材类型,例如 imagevoicevideo

它内部会直接调 features.wechatMenu.batchGetArticle(...)batchGetMaterialList(...)createMaterial(...)getMaterial(...),所以非常适合给菜单编辑、自动回复编辑器或图文选择弹窗做素材选择面板。

wechatPublicAutoReply

这个组件也只要求:

  • applicationId

但它有一个很关键的默认行为:如果当前公众号还没有自动回复配置,ready() 阶段会自动补一条:

  • type: 'text'
  • event: 'subscribe'

所以项目层不需要担心“第一次进入页面没有任何回复配置时界面空掉”,公共组件已经把首条默认记录准备好了。

前端 feature 与 aspect

前端直接可用的微信相关 feature 主要有:

  • features.token
  • features.wechatSdk
  • features.wechatMenu
  • features.wechatPublicTag
  • features.userWechatPublicTag
  • features.template

这里要特别注意两点:

  • 微信登录虽然是“微信能力”,但前端真正调用的通常是 features.token.loginWechatMp()features.token.loginByWechatInWebEnv(...) 这一层;
  • 微信模板同步虽然属于微信生态,但前端入口并不是单独的 wechat feature,而是 features.template

对应的 aspect 也很丰富,主要包括:

  • 登录相关:loginWechatloginWechatMploginWechatNativeloginByWechat
  • 用户同步相关:syncUserInfoWechatMprefreshWechatPublicUserInfogetWechatMpUserPhoneNumbersetUserAvatarFromWechat
  • 微信登录中间态:createWechatLogin
  • 微信解绑:unbindingWechat
  • 二维码:getMpUnlimitWxaCode
  • 菜单:getCurrentMenugetMenucreateMenucreateConditionalMenudeleteConditionalMenudeleteMenu
  • 标签:createTaggetTagseditTagdeleteTagsyncTagoneKeySync
  • 用户标签:getTagUsersbatchtaggingbatchuntagginggetUserTagsgetUserstaggingsyncToLocalesyncToWechat
  • openlink:wechatMpJump
  • 微信素材与模板:signatureJsSDKuploadWechatMediabatchGetArticlegetArticlebatchGetMaterialListgetMaterialdeleteMaterialsyncWechatTemplate

这已经是一整套相当成型的微信业务中台能力了。

endpoint

这一章必须单独强调真实暴露出来的 endpoint 名称,而不只是文件名。

src/endpoints/wechat.ts 里注册了三个 endpoint:

  • wechatPublicEvent
  • wechatMpEvent
  • wechatMaterial

其中:

  • wechatPublicEvent 同时包含 GET 验证接口和 POST 回调接口;
  • wechatMpEvent 同时包含 GET 验证接口和 POST 回调接口;
  • wechatMaterial 用于读取素材二进制内容,通常会被素材库、菜单预览等界面间接使用。

src/endpoints/index.ts 则在导出这些 endpoint 的同时,额外导出了:

  • registerWeChatPublicEventCallback

它允许项目层在不改公共包 endpoint 主流程的情况下,继续挂接自己的公众号事件处理逻辑。

这些 endpoint 共同承担了微信服务器回调入口,用于处理:

  • 公众号事件;
  • 小程序事件;
  • 扫码、订阅、菜单点击等回调。

也正因为有这一层 endpoint,WechatLoginWechatQrCodeWechatUser 等对象之间的联动才真正成立。

后台规则

后台规则主要散落在这些 trigger / checker 里:

  • triggers/wechatLogin.ts:创建登录中间态时自动生成二维码,过期时同步二维码过期;
  • triggers/wechatQrCode.ts:按二维码类型真正生成公众号二维码、小程序码或跳转链接;
  • triggers/wechatMenu.ts:删除个性化菜单前调用微信删除接口;
  • triggers/wechatPublicTag.ts:删除标签前同步删除微信端标签并清理关联数据;
  • triggers/wechatMpJump.ts:创建 WechatMpJump 时生成 openlink;
  • checkers/wechatPublicTag.tscheckers/wechatQrCode.ts:校验微信标签和二维码数据。

这一套规则说明,微信相关对象并不是“你手工写好数据再去用”,很多关键字段和远端副作用都是由 trigger 自动完成的。

注入点

微信能力的注入点非常明确:

  • 前端在 create(...) 时注入 wechatSdkwechatMenuwechatPublicTaguserWechatPublicTag
  • 登录相关能力通过 features.token 暴露给页面和组件;
  • 模板同步能力通过 features.template 暴露给页面和组件;
  • initialize(...) 在 web 环境下调用 features.wechatSdk.setLandingUrl(window.location.href)
  • initialize(...) 在小程序环境下会按需自动登录;
  • 后端通过 ogb0Aspectsogb0Checkersogb0Endpointsogb0Triggers 合并微信相关能力;
  • 如果项目需要补充公众号或小程序事件处理,直接从 src/registry.backend.ts 注册 registerWeChatPublicEventHandler(...) / registerWeChatPublicEventAfterHandler(...)registerWeChatMpEventHandler(...) / registerWeChatMpEventAfterHandler(...);主处理器先于默认逻辑执行,after 处理器在默认逻辑后补充收尾。

换句话说,微信生态的前后台装配都已经在 oak-general-business 里设计好了。

真实项目里的接法

taicanghaina-busi 实际都采用了一条统一链路:

  1. 登录页或业务页里的 user/loginredirectUri 指向 /wechatUser/login
  2. /wechatUser/login 页面只包 wechatUser/login
  3. 二维码场景再单独提供 /wechatQrCode/scan
  4. 公众号或小程序回调最终还是落回 features.token

这样做的好处是,项目层不用到处自己处理 code/state,而是统一交给公共组件。

项目中如何接入

微信能力在项目里的接入,通常分成三段:

  • Application.config 里配好 wechatMp / wechatPublic / web.wechat
  • 初始化时执行 initializeOgb0Features(...),让 wechatSdktokentemplateapplication 这些 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 负责具体内容;
  • 组件层再补上目录、预览、编辑器、树形展示等能力。

所以如果你需要做帮助中心、知识库、栏目页、文章树,这一章会非常有价值。

主要对象

文章模块的核心实体只有两个:

  • Article
  • ArticleMenu

但这两个对象之间的联动很强:

  • ArticleMenu 维护树结构、是否存在文章、最近编辑时间;
  • Article 挂在某个 ArticleMenu 下,并且可以关联文件。

组件

这一章已经提供了非常完整的前端组件族:

  • src/components/article/detail
  • src/components/article/editor
  • src/components/article/list
  • src/components/article/preview
  • src/components/article/toc
  • src/components/article/treeList
  • src/components/article/upsert
  • src/components/articleMenu/container
  • src/components/articleMenu/detail
  • src/components/articleMenu/list
  • src/components/articleMenu/treeCell
  • src/components/articleMenu/treeList
  • src/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:适合正式详情页,常用参数有 tocClosedtocFixedtocPositionscrollIdshowtitle
  • article/preview:适合编辑器预览页,参数和 detail 很接近,但内容来源是本地缓存的 article_html

所以项目里一般会采用:

  • 编辑页用 article/upsert
  • 预览弹窗或独立预览页用 article/preview
  • 对外展示页用 article/detail

article/list / article/treeList / article/editor

如果项目里不只是“编辑单篇文章”,而是要做完整内容后台,这三组组件也很值得单独说明。

article/list 的关键参数包括:

  • articleMenuId
  • generateUrl
  • empty
  • menuCheck

它当前的真实行为是:

  • 固定按 articleMenuId 过滤文章
  • 默认按创建时间升序排列
  • 自动格式化 $$updateAt$$
  • 通过 generateUrl(mode, action, id) 统一生成详情、复制、编辑跳转地址
  • 删除文章后,会回头检查所属菜单的 isArticle 状态并通过 menuCheck 回传

这意味着它非常适合做“某个栏目下文章列表”的后台组件,而不是泛化的全站文章搜索页。

article/treeList 则更偏“树节点下的文章子列表”,常用参数有:

  • articleMenuId
  • show
  • selectedArticleId
  • setCurrentArticle
  • setCopyArticleUrl
  • drawerOpen
  • changeDrawerOpen

它还支持直接在当前菜单节点下:

  • addItem({ name: '文章标题', content: '', articleMenuId })
  • 执行创建

所以它很适合挂在 articleMenu/treeManager 旁边,做“左边目录树,右边文章子树/预览”的组合界面。

article/editor 本身就是 article/upsert 的核心编辑器能力,除了前面提到的上传和预览,它还有几个很关键的实现细节:

  • isCreation() 且传了 articleMenuId 时,会自动把新文章挂到当前菜单
  • 编辑器组件卸载时会主动 destroy()
  • 标题变化会同步改 document.title

也就是说,项目层如果需要自己重组文章编辑页布局,通常应该优先复用 article/editor,而不是从零接一套富文本编辑器。

如果项目不是只想要一棵菜单树,而是想做“左边目录、上面面包屑、右边文章列表/新增入口”的完整后台壳,更应该先看:

  • src/components/articleMenu/container

它的关键参数包括:

  • entity
  • entityId
  • title
  • origin
  • menuEmpty
  • articleEmpty
  • generateUrl(mode, action, id)

这里 generateUrl 的动作枚举是公共类型里直接定义好的:

  • detail
  • editor
  • preview
  • create
  • copy

也就是说,这个组件不是帮你“固定死路由”,而是把真正的跳转地址决定权继续留给项目层。

它当前的真实行为非常适合直接写进文档:

  • 进入时按 title 初始化面包屑
  • 会订阅 articleCreate-entityIdarticleMenuUpdate-entityId 数据事件,自动调整当前节点是不是文章目录
  • 点菜单节点后,会在内部切换 parentId / articleMenuId / showAddArticle / showAddMenu
  • 新建分类时直接创建 articleMenu
  • 新建文章时会调用 generateUrl('article', 'create', articleMenuId || parentId) 打开新页

所以它特别适合做:

  • 帮助中心后台
  • 知识库后台
  • 文档中心后台

也就是“先选目录,再决定新增分类还是新增文章”的这一类页面。

这组组件通常用来做“文档树 + 菜单树”的后台入口,常用参数包括:

  • entity / entityId:菜单树挂在哪个业务对象下
  • showeditdocpreview
  • articleMenuId / articleId
  • tocPosition
  • onMenuView / onMenuViewById
  • onArticleView / onArticlePreview / onArticleEdit
  • setCopyArticleUrl

它和 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}
  • 因此文章在别处被更新后,这个详情组件会跟着刷新

它最关键的参数则是:

  • tocClosed
  • tocFixed
  • tocPosition
  • highlightBgColor
  • headerTop
  • scrollId
  • tocWidth
  • tocHeight
  • showtitle
  • activeColor

也就是说,它并不只是“把 HTML 打出来”,而是已经把目录吸顶、滚动容器、标题显示这些阅读页常见细节一起做了。

aspect / endpoint / feature

这一章有一个边界要特别讲清楚:

  • 文章模块没有单独的 feature;
  • 没有独立的 article aspect;
  • 没有专门的 article endpoint。

如果你在微信素材相关代码里看到了 batchGetArticlegetArticle,那是微信素材接口的一部分,属于前一章的微信能力,而不是这里的本地文章模块。

本地文章模块主要靠实体、组件、checker、trigger 运转。

后台规则

文章模块的自动行为主要集中在:

  • src/triggers/article.ts
  • src/triggers/articleMenu.ts
  • src/checkers/article.ts
  • src/checkers/articleMenu.ts

默认规则包括:

  • 创建/删除文章时,自动维护所属分类的 isArticle
  • 创建/更新/删除文章时,更新分类树的 latestAt
  • 创建/更新文章和分类后,通知订阅了数据事件的前端;
  • 创建和更新分类时检查同级是否重名;
  • 删除文章前,级联删除其关联的 extraFile
  • 删除分类前,会级联删除子分类、子文章以及这些对象关联的 extraFile

这说明文章树的一致性并不是前端自己维护的,而是后端规则层在兜底。

注入点

文章能力的注入点并不在 feature,而在后端规则:

  • ogb0Triggers 注入文章和文章树的自动维护逻辑;
  • ogb0Checkers 注入删除和命名等校验;
  • 前端组件则直接围绕 article / articleMenu 数据节点工作。

article/detail 组件还会订阅文章更新事件,这说明这套能力和 Oak 的数据事件机制是连通的。

项目中如何接入

文章模块在项目里的接入方式,通常很简单:

  • 后台页面直接复用 articleMenu/treeManagerarticle/editorarticle/detail
  • 不去自己维护分类树状态,而是把树一致性留给 trigger
  • 图片和附件仍然统一复用 ExtraFile

也就是说,项目层最推荐的做法是把文章模块当成一套完整的“内容子系统”,而不是只拿 Article 表自己写一遍后台。

真实项目里的包法

haina-busitaicang 基本都采用同一种思路:

  • 后台文章编辑页直接包 article/upsert
  • 文档目录后台页直接包 articleMenu/treeManager
  • 面向用户的详情/预览页再分别包 article/detailarticle/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
  • 这样项目层仍然可以自由安排路由结构,但不需要重写树管理逻辑

使用建议

如果你准备复用这套内容系统,最推荐的做法是:

  1. 先把 ArticleMenu 当成内容目录树;
  2. 再把 Article 当成目录叶子上的内容;
  3. 前端直接复用 treeManagereditordetail 这些组件;
  4. 不要自己在页面里手动维护 isLeafisArticlelatestAt

因为这些值本来就应该交给 trigger 自动维护。

Address、Area 与地图能力

地址模块看上去很普通,但在 oak-general-business 里,它实际上把三类东西放到了一起:

  • 业务地址 Address
  • 行政区划 Area
  • 地图与地铁辅助能力

因此,这一章不只是“收货地址怎么存”,还包括地址选择器、区域树以及地图服务的注入方式。

主要对象

这一章涉及的实体有:

  • Address
  • Area
  • Subway
  • Station
  • SubwayStation

其中:

  • Address 是业务地址;
  • Area 是只读的行政区划字典;
  • Subway / Station / SubwayStation 则提供地铁线路与站点关系。

近期 Area 数据已经进入全球 locale 化。项目如果展示多语言地址或区域选择器,应把区域名称当作 i18n 数据的一部分同步,而不是在页面里手写地区名映射。

组件

围绕这些对象,已经有一组很实用的组件:

  • src/components/address/list
  • src/components/address/upsert
  • src/components/area/upsert
  • src/components/pickers/area
  • src/components/config/upsert/map
  • src/components/subwayLine/list
  • src/components/subwayLine/picker
  • src/components/subwayLine/upsertStation
  • src/components/subwayLine/upsertSubway
  • src/components/amap/map
  • src/components/amap/location

这说明地址模块并不局限于“表单里填几项字段”,而是包含了一整套选择和辅助展示能力。

address/listaddress/upsert

公共包里的这两个组件是最基础的一组地址页能力。

address/list 当前行为很直接:

  • 读取 namephonedetailarea
  • 自动把 省市区 + detail 组合成展示文本
  • 点击某一项时跳到 /address/upsert?oakId=...
  • 没有数据时展示“创建”按钮并跳到 /address/upsert

也就是说,公共实现更像“最小地址簿”,默认没有:

  • 默认地址切换
  • 删除按钮
  • 当前用户过滤
  • 业务对象级别的定制文案

address/upsert 则负责最基础的地址编辑,当前会直接维护:

  • name
  • phone
  • areaId
  • detail

它内部还有两个很关键的默认行为:

  • confirm() 会直接 execute() 然后 navigateBack()
  • callAreaPicker() 默认跳 /pickers/area

所以项目层如果路由沿用公共约定,接起来会非常顺;如果路由不是 /pickers/area,就需要像业务项目那样包一层自己的页面壳。

pickers/area 常用参数

src/components/pickers/area/index.ts 的关键参数很少,但非常重要:

  • depth,默认是 3
  • onAreaSelected

它的真实行为是:

  • 初始只查“国家”下一层区域
  • 如果点到的区域 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

这是一个数组,每项主要维护:

  • key
  • type

其中 type 当前支持:

  • personal
  • special
  • business

源码里还有两个很值得写进文档的行为:

  • 如果配置多个地图服务,系统默认优先使用第一个
  • 高德地图这边允许配置多个 key,组件文案也明确提示可轮换使用

而在启动注入处,优先级是:

  • 先用 MapWorld
  • 没有 MapWorld 再用 AMap

所以项目里不要同时瞎配两套然后期待运行时自动做复杂路由,公共实现不是这么设计的。

amap/mapamap/location

如果你的项目要直接复用地图 React 组件,这两组能力最好拆开理解。

amap/map 更偏底层地图容器,常用参数包括:

  • akey
  • version
  • mapProps
  • mapRef
  • useAMapUI
  • uiVersion
  • uiCallback
  • securityJsCode
  • serviceHost

它适合:

  • 只展示地图
  • 在地图上挂自己的 marker / overlay / 自定义控件
  • 自己控制 MapProps

amap/location 则是更完整的“选点对话框”,常用参数包括:

  • akey
  • visible
  • onClose
  • onConfirm
  • geolocationProps
  • useGeolocation
  • dialogProps
  • securityJsCode
  • serviceHost

它已经把:

  • 地图拖拽选点
  • POI 搜索
  • 当前定位
  • 结果确认

这些交互做完整了。所以项目里如果只是要“让用户选一个地址点位”,优先用 amap/location,不要自己再重拼一套地图搜索弹窗。

subwayLine/* 组件更适合什么场景

这组组件更偏城市服务、线路筛选或门店覆盖范围,不是收货地址的必选项。

subwayLine/list 当前会:

  • 读取 subway -> subwayStation$subway -> station
  • 组装成树结构
  • 默认带 areaId='330100' 的过滤
  • ready() 时加载所有城市级 area 选项

subwayLine/picker 的关键参数有:

  • areaId
  • onCancel
  • onConfirm(stationIds)
  • selectIds

它适合做“按地铁站多选筛选”的场景。

而:

  • subwayLine/upsertStation 主要参数是 openStationonClosesubwayId
  • subwayLine/upsertSubway 主要参数是 openSubwayonClose

它们更偏后台字典维护,不是前台用户常用组件。

aspect / endpoint / feature

地址本身没有专门的 frontend feature、aspect 或 endpoint。

这并不代表它是“弱能力”,只是意味着:

  • 地址对象的读写主要通过普通 Oak 组件完成;
  • 地图能力不是通过本仓库里的自定义 aspect 暴露,而是通过 oak-common-aspect 和启动注入点来接入;
  • 区域树相关的查询辅助则放在 src/utils/area.ts,已经提供了 makeAreaAncestorFilter(...)makeAreaDecendantFilter(...) 两个 helper。
  • 区域多语言数据需要跟随 make:locale 和数据升级流程进入运行时。

另外有一个很容易忽略的真实依赖关系:

  • oak-pay-businessOrderShip 实体都直接引用了 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/listcomponents/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/upsert
  • src/components/pickers/area
  • src/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/mapamap/location,就要先确认 System.config.Map 已经配好,否则前端组件就算能渲染,后端注入的地图服务能力也不完整。

如果你的项目需要复杂的地址定位、路线或地图服务,先看这一套注入链路,再决定要不要在项目层扩展。

SMS 与消息模板

短信能力在 oak-general-business 里分成了两个层次:

  • 一层是“手机号登录、验证码发送”这类账号体系能力,它更多依赖 PassportToken
  • 另一层是“系统消息模板同步和消息类型映射”,这才是本章要重点讲的内容。

主要对象

这部分主要涉及:

  • SmsTemplate
  • MessageType
  • MessageTypeSmsTemplate

它们的关系很清晰:

  • SmsTemplate 保存某个系统下、某个短信渠道的模板信息;
  • MessageType 定义业务消息类型;
  • MessageTypeSmsTemplate 负责把业务消息类型映射到具体短信模板。

组件

短信相关组件并不多,但都很实用:

  • src/components/config/upsert/sms
  • src/components/passport/sms
  • src/components/user/login/sms
  • src/components/messageTypeSmsTemplate/list
  • src/components/messageTypeSmsTemplate/tab

也就是说,短信模块既包含模板管理,也直接连接着系统配置、登录方式配置和登录页本身。

config/upsert/sms 常用字段

这组组件编辑的是 System.config.Sms。当前源码里最关键的字段有:

  • mockSend
  • defaultOrigin
  • ali[]
  • tencent[]
  • ctyun[]

其中:

  • mockSend 打开后,发送验证码不会真的调用短信厂商接口
  • defaultOrigin 决定默认短信渠道

三类厂商配置的重点字段分别是:

1. 阿里云

  • accessKeyId
  • accessKeySecret
  • endpoint
  • apiVersion
  • defaultSignName

2. 腾讯云

  • secretId
  • secretKey
  • smsSdkAppId
  • region
  • endpoint
  • defaultSignName

3. 天翼云

  • accessKey
  • securityKey
  • endpoint
  • defaultSignName

源码里这组组件还有两个实现细节很值得直接告诉开发:

  • 三个渠道都是“数组配置”,可以添加多组帐号
  • 但真正默认发短信走哪家,还是看 defaultOrigin

所以项目里不要只配帐号不配 defaultOrigin,否则验证码发送链路很容易因为拿不到默认渠道而失败。

messageTypeSmsTemplate/list 常用参数

这是短信模板映射里最核心的一个管理组件,关键参数只有两个:

  • systemId
  • origin

但它内部已经做了不少事情:

  • ready() 时先拉当前系统、当前渠道下的 smsTemplate
  • 同时调用 features.template.getMessageType() 拉业务消息类型
  • 点击“同步模板”时直接调用 features.template.syncSmsTemplate(systemId, origin)
  • 新建映射时默认给一条 messageType + templateId
  • 同一种 messageType 在下拉里会被禁用,避免重复绑定

也就是说,这个组件并不只是一个普通 CRUD 表,它已经把:

  • 同步远端模板
  • 查看现有模板
  • 维护消息类型与模板映射

这些步骤合在一起了。

messageTypeSmsTemplate/tab 适合放在哪里

tab 组件的关键参数是:

  • systemId

它内部会按固定渠道自动分三栏:

  • ali
  • tencent
  • ctyun

并且每个页签里都挂一个 messageTypeSmsTemplate/list

这组组件最适合放在:

  • 系统后台页
  • 系统配置页
  • 平台级短信模板管理页

而不是登录页本身。

passport/sms 的真实职责

src/components/passport/sms/index.tsx 在文档里也值得单独写出来,因为它不是登录页,而是系统登录方式管理页里的一个“短信登录配置卡片”。

它当前接收的核心输入其实不是散装字段,而是三样东西:

  • passport
  • changeEnabled
  • updateConfig

其中 passport.config 里,这个组件真正会维护的是:

  • mockSend
  • defaultOrigin
  • templateName
  • codeDuration
  • digit

也就是说,账号体系里“短信登录”这一项真正依赖的,不只是系统级 System.config.Sms,还包括 passport 自己这份登录级配置:

  • 验证码模板名是什么
  • 验证码有效几分钟
  • 验证码是几位

components/passport/index.ts 的实现看,它还会在保存前主动检查配置完整性。如果启用了短信登录,但:

  • 没填 templateName
  • 没选 defaultOrigin

组件会直接给出 warning。也就是说,项目层如果复用整套 passport/* 管理页,很多“短信登录为什么不生效”的基础配置错误其实已经能在页面上提前暴露出来。

user/login/sms 常用参数

src/components/user/login/sms/index.ts 和 web 端渲染层当前最值得写进文档的参数有:

  • disabled
  • url
  • callback
  • allowPassword
  • allowEmail
  • allowWechatMp
  • setLoginMode
  • digit

其中真正影响登录流程的是:

  • 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/smsdigitpassport/sms 里配置的验证码位数,本质上应该保持一致。

否则前端校验按 4 位、后台实际按 6 位生成时,就会出现:

  • 前端以为验证码合法,后台却认为长度不对
  • 或者前端根本不允许提交正确验证码

这也是为什么更推荐项目层直接复用公共 passport 配置页和公共登录组件,而不是把验证码位数写死在业务页里。

前端 feature 与 aspect

这一章主要依赖的前端入口是 features.template,其中和短信相关的方法有:

  • syncSmsTemplate(systemId, origin)
  • getMessageType()

对应的后端 aspect 则是:

  • syncSmsTemplate
  • getMessageType
  • sendCaptchaByMobile
  • sendCaptchaByEmail

其中:

  • syncSmsTemplate 会直接调用具体短信渠道实现,把远端模板同步到本地的 smsTemplate 对象里;
  • 验证码发送虽然前端统一走 features.token.sendCaptcha(...),但后端最终落到的是 sendCaptchaByMobile / sendCaptchaByEmail 这两个 aspect。

还有一个很容易漏掉的实现细节:

  • src/aspects/sms.ts 里的 syncSmsTemplate(...) 当前会按 templateCode 做“存在则 update,不存在则 create”
  • 但源码里“删除本地已失效模板”的逻辑目前是注释掉的

这意味着同步模板的真实语义更接近:

  • 增量更新
  • 增量新增

而不是“远端模板全量对齐本地模板”。

endpoint / watcher

短信模板这部分当前没有单独的 endpoint,也没有单独的 watcher。

这说明它的工作方式不是“被第三方平台回调驱动”,而是更适合通过后台管理动作或定期运维脚本来主动同步。

注入点

这一章的注入点在两个地方:

  • 前端通过 create(...) 注入 features.template
  • 后端通过 ogb0Aspects 注入 syncSmsTemplategetMessageType

所以只要你的项目已经接好了 oak-general-business,短信模板同步入口其实已经具备。

公共系统页已经有现成入口

oak-general-business/src/components/system/panel/web.pc.tsx 已经把短信模板页签接进系统后台了:

  • smsTemplate-list 页签直接包 messageTypeSmsTemplate/tab

这意味着如果项目本身已经复用 system/panel,往往不需要再额外写一页“短信模板管理台”。

项目中如何接入

短信能力在项目里主要通过两条线接入:

  • System.config.Sms 配渠道和签名;
  • features.templatefeatures.token 负责模板同步和验证码发送。

所以项目层通常不直接调短信 SDK,而是:

  • 管理台同步短信模板;
  • 登录/改密等页面调用 features.token.sendCaptcha(...)

真实项目里通常怎么组织

从当前 haina-busitaicang 的源码来看,没有再各自重写一套独立短信模板管理界面,更多是直接吃公共包和生成域能力。

这也符合这章的定位:

  • 系统配置页负责配 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
  • 系统消息的短信模板映射,看 SmsTemplateMessageTypeSmsTemplate
  • 真正同步远端模板,走 features.template.syncSmsTemplate(...)

再补三条特别实用的注意事项:

  • defaultOrigin 一定要和实际配置过的 ali/tencent/ctyun 帐号对应上,否则验证码发送时虽然链路通了,最终还是会在 provider 选择这里出问题。
  • mockSend 只是不真的调用短信厂商,不代表验证码表不写入;开发环境下它仍然会创建 captcha 记录。
  • 模板同步当前不会自动删掉本地旧模板,所以如果厂商后台删过模板,项目侧最好结合运维规则人工检查一次映射关系。

把这三件事分开,短信模块就不会再显得混乱。

Subscription 订阅源

Subscriptionoak-general-business 里相对轻量的一块能力。它提供的是“订阅源的结构化记录”,而不是一整套自动抓取和自动发布引擎。

从实体结构看,它更像一个被别的同步逻辑消费的配置对象:

  • 订阅源属于哪个业务对象;
  • 订阅源名称和描述是什么;
  • 配置是什么;
  • 当前已经同步到了哪个偏移位置。

主要对象

这一章的核心实体只有一个:

  • Subscription

当前它的 config 结构偏向微信订阅号场景,但它本身作为实体是比较中性的,因此本章也把它称为“订阅源”,而不再狭义地理解成某一个具体渠道。

组件

虽然实体很轻,但组件已经准备了基础管理能力:

  • src/components/subscription/detail
  • src/components/subscription/list
  • src/components/subscription/upsert
  • src/components/subscription/config/upsert

这意味着你可以先把“订阅源管理后台”搭起来,再决定项目层要不要继续补同步逻辑。

常用组件与参数

这一组组件里,最值得直接按源码理解的是下面三块:

  • subscription/list
  • subscription/upsert
  • subscription/config/upsert

subscription/list 不是全局订阅源列表,它当前是按业务对象维度工作的。oak-general-business/src/components/subscription/list/index.ts 暴露的关键参数只有两个:

  • entity
  • entityId

组件内部会把这两个参数直接变成固定 filter:

{
  entityId: this.props.entityId,
  entity: this.props.entity,
}

因此它更适合挂在“某个对象自己的订阅源列表”里,例如某个栏目、某个应用、某个业务主体下的订阅号配置,而不是做全系统订阅源总表。

从真实行为看,它还已经内置了几条常见管理动作:

  • 详情跳 /subscription/detail
  • 更新跳 /subscription/upsert
  • 配置跳 /subscription/config/upsert
  • 删除后直接 removeItem(id)execute()

subscription/upsert 则是最基础的订阅源新增/编辑页。它最关键的两个参数同样是:

  • entity
  • entityId

并且源码里已经做了一个很实用的默认行为:如果当前是“创建”而不是“编辑”(也就是没有 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 看,它只取:

  • id
  • name
  • config

也就是说,进入“订阅源配置页”时,公共组件默认只关心:

  • 当前订阅源是谁
  • 展示名称是什么
  • 当前渠道配置是什么

像:

  • description
  • offset
  • entity/entityId

这些字段都不会在这里展示,也不会在这里直接编辑。它们仍然分别归:

  • subscription/upsert
  • 同步逻辑本身

这也是为什么项目层如果要做“订阅同步状态监控页”,通常不能只靠这一个配置组件。

subscription/detail 当前展示边界

subscription/detail 的 projection 里虽然带了 configentityentityId,但当前 web 端实现实际上只展示:

  • id
  • name
  • description

也就是说,它现在更像一个“订阅源概览卡片”,而不是完整详情页。像 configoffset 这种更偏配置和同步状态的信息,项目层如果需要展示,通常要么直接走 config/upsert,要么再包一层自己的 detail 页面。

aspect / endpoint / feature

这一章需要明确说清楚:

  • 当前没有专门的 frontend feature;
  • 没有独立的 aspect;
  • 也没有专门的 endpoint。

也就是说,Subscription 现在主要提供的是:

  • 一个实体模型;
  • 一组基础组件;
  • 一个可以被项目层进一步扩展的配置入口。

后台规则与注入点

当前 oak-general-business 里没有为 subscription 单独提供 trigger、checker、watcher 或 routine。

这也正是它和前面那些“高度自动化”的模块最大的区别:这部分更多是在给项目层留扩展点,而不是强行内置一套默认流程。

因此它的注入点其实非常简单:

  • 实体跟随 oak-general-business 进入项目;
  • 组件可以直接复用;
  • 其它业务逻辑由项目层自己补。

项目中如何接入

Subscription 在项目里的接法比较轻:

  • 如果你只是要做订阅号配置后台,直接复用 subscription/listdetailupsertconfig/upsert
  • 如果你要把订阅号挂到某个业务对象上,就按 entity + entityId 建立关联

它没有 feature 和 aspect,所以项目层更多是在页面和实体数据层使用它。

结合组件源码,更推荐按下面这种方式落:

  • 列表页把 entityentityId 固定住,直接包 subscription/list
  • 新增/编辑页继续把同样的 entity/entityId 传给 subscription/upsert
  • 配置页单独包 subscription/config/upsert

我这次也额外对 haina-busitaicang 做了定点检索,目前没有找到它们直接复用这组公共 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 协作待办

ToDooak-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 的完整入参语义,最好直接说清楚:

  • entity
  • filter
  • action
  • context
  • data
  • userIds?

其中 data 当前至少应该准备:

  • title
  • description?
  • redirectTo
  • entity
  • entityId

它内部还做了三件非常关键的事:

1. 先查重

它会先按下面这些条件数一遍现有 toDo

  • targetEntity
  • targetFilter
  • action
  • iState='active'
  • entity
  • entityId

如果已经有同语义的活跃待办,就直接不再创建。

这意味着项目层不需要自己额外做一层“防重复建单”。

2. 不传 userIds 时自动推导协作者

如果你没显式传 userIds,它会调用:

  • getUserRelationsByActions(...)

自动找出对当前对象在当前 filter 下拥有这个 action 的用户,然后把他们全部挂成 toDo.collaborator

这也是这套 helper 最大的价值之一:它不是简单待办,而是把 Oak 权限关系直接复用到了协作分派上。

3. 自动补 collaborator 关系

helper 自己会去查 toDo 上名为 collaboratorrelation,然后创建对应的 userRelation$entity

所以项目层在大多数场景下,只需要关心:

  • 什么时候建待办
  • 给谁建待办

不需要自己再手写协作者关系创建逻辑。

后台规则

这里有一个特别容易误会的点:

src/triggers/toDo.ts 这个文件虽然叫 trigger,但它并没有像其它模块那样被自动加入 src/triggers/index.ts

这意味着:

  • 它不是一组默认自动注入的触发器;
  • 而是一组需要你在项目层主动调用的 helper。

例如,你可以在项目自己的某个 trigger 里:

  • 在需要人工处理时调用 createToDo(...) 创建待办;
  • 在真正完成动作后调用 completeToDo(...) 自动关闭待办。

createToDo(...) 还有一个很关键的默认行为:

  • 如果你没传 userIds,它会调用 getUserRelationsByActions(...),自动找出对当前对象拥有该 action 权限的用户;
  • 然后把这些用户挂到 toDocollaborator 关系上。

这正是 Oak 很推荐的一种复用方式:公共包提供通用例程,项目层决定在哪些业务节点把它接进去。

completeToDo(...) 的真实完成条件

这个 helper 也不是“按 id 直接把待办置完成”。

它真正做的是:

  1. 先按 targetEntity + targetFilter + action + iState='active' 找出对应活跃待办
  2. 再去 count(targetEntity, { filter: targetFilter })
  3. 只有当这个计数已经变成 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-busitaicang 的源码检索里,没有看到它们直接调用 createToDo(...) / completeToDo(...)

这更能说明它的定位:

  • 它是一组公共协作 helper
  • 不是两个示例项目当前正在重度依赖的成品待办模块

所以接这章时,项目团队要自己决定:

  • 哪些业务动作值得建待办
  • 待办在页面上以什么形式呈现
  • 完成条件是“状态变化”还是“对象不再命中过滤条件”

推荐的项目层拆法

最稳的接法通常是:

  • before / after trigger 里决定什么时候调用 createToDo(...)
  • 在对应完成动作的 after trigger 里调用 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(...)filtercompleteToDo(...)filter 必须保持同一条业务语义,否则要么重复建单,要么永远完不成。
  • 如果目标动作只是更新状态,最好让 filter 包含“待处理状态”这一层条件,这样完成后 count() 才会归零。
  • 因为 helper 自己不会给你做前端页面,所以项目立项时最好同时规划好列表页、提醒入口和跳转页,不要只建数据不落使用场景。

它不是通用的任务管理系统,而是一组和 Oak 动作模型天然契合的协作待办工具。

Livestream 直播流

Livestreamoak-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,核心字段包括:

  • accessKey
  • hub
  • liveHost
  • publishDomain
  • playDomainType
  • playDomain
  • playBackDomain
  • publishSecurity
  • publishKey
  • playKey

也就是说,这组公共组件当前更适合做“七牛直播配置页”,而不是泛化的直播厂商配置页。

这里还有一个很值得直接写出来的实现边界:Config.Live 在类型上虽然是一个对象容器,但公共组件目前只渲染了 qiniu 这一项,并没有腾讯云、阿里云等平行配置表单。

config/upsert/live 的真实组件参数

这个组件本身不是直接操作 systemId 的业务页,而是一个被更大配置页包进去的“配置片段组件”。从源码看,它真正接受的参数只有两个:

  • live
  • setValue(path, value)

也就是说:

  • live 代表当前已经读出来的 System.config.Live
  • setValue 负责把局部字段写回上层配置表单

它内部再把所有字段路径都统一收敛成:

  • qiniu.accessKey
  • qiniu.hub
  • qiniu.liveHost
  • qiniu.publishDomain
  • qiniu.playDomainType
  • qiniu.playDomain
  • qiniu.playBackDomain
  • qiniu.publishSecurity
  • qiniu.publishKey
  • qiniu.playKey

这意味着项目层如果不是复用更上层的 config/upsert 体系,而是想单独把直播配置嵌进自己的系统后台,也最好保持同样的 setValue('qiniu.xxx', value) 约定。

playDomainType / publishSecurity 当前有哪些可选值

这组下拉值在源码里其实已经写死,文档里最好直接列出来。

playDomainType 当前支持:

  • rtmp
  • hls
  • flv

publishSecurity 当前支持:

  • none
  • static
  • expiry
  • expiry_sk

这里必须以 src/types/Config.tsQiniuLiveConfig 合同为准。当前 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 实体。

这也符合它当前在公共包里的定位:提供统一模型和工具函数,项目层决定具体业务流程。

当前项目里的实际情况

这次对 taicanghaina-busi 的定点比对里,没有看到它们直接复用公共 config/upsert/live 页面壳的现成落点。

更接近现状的事实是:

  • taicang 自己有 qiniuLiveqiniuLiveStream 这一层业务模型和 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 客户端,接入第三方登录

这一组对象包括:

  • OauthProvider
  • OauthState
  • OauthUser

其中:

  • OauthProvider 保存第三方提供商配置;
  • OauthState 保存登录或绑定时的 state;
  • OauthUser 保存第三方账号和 Oak 用户之间的连接关系。

作为 OAuth 服务端,对外提供授权

这一组对象包括:

  • OauthApplication
  • OauthAuthorizationCode
  • OauthToken
  • OauthUserAuthorization

其中:

  • OauthApplication 表示一个外部客户端;
  • OauthAuthorizationCode 是授权码;
  • OauthToken 是服务端签发给客户端的 token;
  • OauthUserAuthorization 则记录用户是否授权、是否撤销。

组件

这一章已经提供了基础的管理和登录组件:

  • src/components/oauth
  • src/components/oauth/management
  • src/components/oauth/records
  • src/components/login/oauth

另外,components/user/login/index.ts 也会根据应用的 applicationPassport 动态展示 OAuth 登录选项。

login/oauth/authorize 适合放在哪里

这组组件更像“OAuth 服务端授权确认页”。bm-smart/src/pages/oauth/authorize/web.pc.tsxTripSlayer/src/pages/frontend/login/oauth/authorize/web.pc.tsx 的包法都非常薄:

<Auth oakPath="#Authorize" />

也就是说:

  • 当前项目如果要作为 OAuth 服务端对外授权,页面层只要把这个组件挂出来
  • 真正的授权码校验、用户确认、授权流程,还是走公共包

oauth 组件常用参数

oak-general-business/src/components/oauth/index.ts 是 OAuth 回调页组件,最关键的两个参数是:

  • onRetry
  • onSuccess

它会自己从 URL 里读取:

  • code
  • state
  • error
  • error_description

然后调用 features.token.loginByOAuth(code, state)。所以项目层真正要做的是“成功后回哪里、失败后怎么重试”。

oauth/management 的定位

这个组件更偏系统后台管理,而不是登录页。当前源码里它是一个虚拟组件,核心参数很少:

  • systemId
  • systemName

它更适合做:

  • OAuth provider 管理页
  • OAuth application 管理页
  • 系统级第三方登录配置入口

也就是说,项目里如果要做“OAuth 能力管理台”,通常应该把它挂到系统配置或平台配置页里,而不是混在普通登录页组件里。

进一步看 oak-general-business/src/components/oauth/management/web.pc.tsx,它当前并不是一个空壳,而是已经内置了两个页签:

  • providers
  • applications

对应的就是:

  • oauth/management/oauthProvider
  • oauth/management/oauthApps

这两个子组件都会强依赖 systemId,并且内部直接按 systemId 过滤:

  • oauthProvider 管提供商配置,如授权地址、token 地址、scope、clientId、clientSecret
  • oauthApps 管外部客户端,如 redirectUrisisConfidentialrequirePKCE

所以项目层如果只是要搭系统级 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),实际走的是 oauthUserAuthorizationrevoke action

也就是说,它不是 OAuth provider 管理页,而是“当前用户已经授权过哪些外部应用”的记录页,更适合放在:

  • 个人中心
  • 安全中心
  • 第三方授权管理页

web.pc.tsx 的实际渲染看,它还已经把这些细节整理成可直接展示的卡片:

  • 应用 logo / 名称 / 描述
  • 当前授权状态
  • scope
  • 最近使用时间
  • 撤销时间

项目层通常只需要把这个组件挂出来,不需要再自己判断哪些记录能 revoke。

oauth 回调页的真实参数语义

oak-general-business/src/components/oauth/index.ts 这页虽然只暴露了两个参数:

  • onRetry
  • onSuccess

但它内部已经把回调页真正该做的工作都接上了:

  • 从 URL 自动读取 codestate
  • 识别 errorerror_description
  • 调用 features.token.loginByOAuth(code, state)
  • 失败时只要求项目层决定“怎么重试”
  • 成功时只要求项目层决定“回哪里”

因此项目层不应该再自己重复写一遍“从 querystring 取 code/state 再换 Oak token”的逻辑。

aspect / feature

OAuth 相关的核心 aspect 在 src/aspects/oauth.ts

  • loginByOauth
  • getOAuthClientInfo
  • createOAuthState
  • authorize

从前端看,并没有单独的 oauth feature,登录动作是通过:

  • features.token.loginByOAuth(...)

来触发的。

这再次体现了 oak-general-business 的分层方式:登录入口还是收敛到 token feature,OAuth 只是其中的一条登录链路。

endpoint

这一章还有一组非常关键的 HTTP endpoint,定义在 src/endpoints/oauth.ts

  • oauth/access_token
  • oauth/userinfo
  • oauth/token
  • oauth/revoke

它们分别负责:

  • 用授权码换 token;
  • 获取用户信息;
  • 刷新 token;
  • 撤销 token。

如果你要让 Oak 应用真的作为 OAuth 服务端对外工作,这一组 endpoint 就是最核心的公开入口。

需要额外说明的是:源码里并没有单独的 oauth/authorize endpoint。授权确认这一步走的是 authorize aspect,再配合 src/components/login/oauth/authorize 这个公共授权页组件完成。

watcher 与后台规则

OAuth 还带了一条后台补偿逻辑:

  • src/watchers/oauth.ts 会定期刷新即将过期但仍可用的 oauthUser token。

同时,相关 trigger 也不少:

  • triggers/oauthProvider.ts 会根据 provider 变化维护 passport(type='oauth')
  • triggers/oauthUser.ts 负责第三方登录后的一些用户侧补充逻辑;
  • triggers/oauthUserAuth.ts 负责授权撤销等行为的联动。

所以 OAuth 并不是只靠几个 endpoint 在工作,后台状态维护同样已经接好了。

注入点

OAuth 能力的注入点分成三层:

  • 后端通过 ogb0Aspectsogb0Endpointsogb0Watchersogb0Triggers 注入;
  • 前端通过 components/oauth/*features.token.loginByOAuth(...) 进入;
  • 系统层还需要 Passport(type='oauth')ApplicationPassport 把这条登录方式真正暴露给某个应用。

如果少了最后一步,即使 OAuth provider 配好了,前端也不会真正展示对应的登录入口。

项目中如何接入

OAuth 在项目里一般分成两种接法:

  • 把 Oak 当作客户端,去接第三方登录
  • 把 Oak 当作服务端,对外暴露 token/userinfo/revoke endpoint,并通过授权页组件调用 authorize aspect 完成授权确认

无论哪一种,最重要的都是:

  • 应用和系统初始化时先把 ogb0Aspects / ogb0Endpoints 合并进去;
  • 前端页面统一走 features.token.loginByOAuth(...) 或公共登录组件;
  • 服务端统一复用 src/endpoints/oauth.ts,不要自己再造一套 OAuth 协议实现。

真实项目里的页面拆法

haina-busitaicang 的现有代码来看,一个项目里最常见的是三类页面同时存在:

  • 普通登录页里的第三方登录按钮,最终走 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/oauthcomponents/login/oauth/authorize 就是按这条链路工作的。

3. 作为 OAuth 服务端时复用 endpoint 与授权页组件

src/endpoints/oauth.ts 已经提供了:

  • oauth/access_token
  • oauth/token
  • oauth/revoke
  • oauth/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/OauthStateOauthApplication/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,很容易被这些对象绕晕:

  • OrderPayRefund 看起来是一组;
  • AccountDepositSysAccountOper 又像另一组;
  • WithdrawWithdrawTransferWithdrawAccount 还会和退款串起来;
  • ShipShipServiceSystemWechatMpShip 明明是物流,却又会反过来影响充值到账;
  • SettlePlanSettlement 又会继续影响订单的 settledsettlePlanned

所以这一章不再按“一个实体一章”的方式写,而是按真实开发时更容易理解的能力域拆成:

  • 系统支付配置与系统资金
  • 订单、支付与退款
  • 账户、充值与流水
  • 提现
  • 物流
  • 渠道、组件注入与扩展点
  • 结算

这种拆法更接近项目实际接入和排查问题时的思路。

先看能力入口,再看具体对象

真正接入 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/featuresinitialize(...) 会先调用 @oak-general-business/featuresinitialize(...),也就是说支付域初始化本身就依赖通用业务包的应用识别、登录态、微信环境等基础能力。

因此同时依赖通用业务包和支付业务包的项目,通常只需要调用 initializeOpb1Features(...)。不要再手工重复调用一遍 initializeOgb0Features(...),除非你明确知道初始化顺序和投影合并边界。

2. aspect

src/aspects/index.ts 当前只导出四个 aspect:

  • getWithdrawCreateData
  • getMpShipState
  • getExpressPrintInfo
  • shipConfirmSuccess

这四个入口都是真实在前端组件里被调用的:

  • 提现创建页会先调 getWithdrawCreateData
  • 小程序收货确认会调 shipConfirmSuccess
  • 物流详情和面单打印会调 getMpShipStategetExpressPrintInfo

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 则只暴露前端可用的这几项:

  • registerPayChannelComponent
  • registerFrontendPayRoutine
  • registerShipSettingComponent
  • registerSysAccountCardTopComponent
  • registerSysAccountDetailComponent

这意味着:

  • 新支付渠道的“后端实现”通过 registerPayClazz(...) 注入;
  • 新支付渠道的“系统配置 UI”通过 registerPayChannelComponent(...) 注入;
  • 新支付渠道的“前端唤起流程”通过 registerFrontendPayRoutine(...) 注入;
  • 新物流系统的“系统设置页”通过 registerShipSettingComponent(...) 注入;
  • 系统资金总览页顶部卡片和详情页也都可以继续扩展。

5. watcher 与 timer

除了实体动作本身,支付域还有一套后台补偿逻辑:

  • watchers/order.ts 会把过期订单自动置为 timeout
  • watchers/pay.ts 会轮询外部支付状态,并在超时后自动关闭 pay
  • watchers/refund.ts 会轮询外部退款状态
  • watchers/settlePlan.ts 会在到达结算时间后自动执行 settle
  • timers/ship.ts 会定时同步快递状态和小程序虚拟/自提发货状态

相反,src/routines/start.ts 当前没有真正启用的启动例程,支付域主要依靠 trigger、watcher、timer 驱动。

先建立组件地图

第一次接 oak-pay-business,最省时间的做法不是先把所有实体看完,而是先知道“这条支付链应该从哪组组件进”。下面这张地图最适合新手先建立整体感。

1. 系统配置与系统资金

这一组通常先看:

  • payConfig/system
  • offlineAccount/config
  • wpAccount/config
  • wpProduct/config
  • apAccount/config
  • apProduct/config
  • sysAccount/survey
  • sysAccountOper/list
  • sysAccount/transferList
  • sysAccountMove/create

这组组件解决的不是“支付过程”,而是:

  • 系统层有哪些支付渠道
  • 每个渠道怎么配置
  • 系统账户余额和流水怎么看
  • 提现打款任务在哪里处理

也就是说,后台管理台通常先从这一组落。

2. 订单支付与退款主线

这一组最常直接复用的是:

  • order/pay
  • order/list
  • pay/detail
  • pay/list
  • pay/channelPicker2
  • refund/list

如果项目里已经有订单详情页,最常见的组合就是:

  • 订单页里弹出 order/pay
  • 创建完支付单后切到 pay/detail
  • 后台管理页再补 pay/listrefund/list

这样“发起支付”和“排查支付”两条线就都有现成入口。

3. 账户、充值与流水

这组组件通常先看:

  • account/detail
  • deposit/new
  • accountOper/list

真实使用时,它们的职责边界很清楚:

  • account/detail 更像一个完整账户主页,内部已经串了充值和未完成支付跳转
  • deposit/new 更像受控的“充值参数录入器”
  • accountOper/list 负责账户流水

如果项目要做钱包页、保证金页、账户历史页,通常先从这一组搭。

4. 提现与打款

提现链路最值得先看的组件是:

  • withdraw/create
  • withdraw/detail
  • withdraw/display
  • withdraw/list
  • withdrawAccount/list
  • withdrawAccount/upsert
  • withdrawTransfer/list

这一组组件加在一起,才构成完整提现链:

  • 创建申请
  • 选择提现账户
  • 查看拆单详情
  • 查看历史
  • 运营处理打款

如果只接 withdraw/create 一个表单,后面排查“为什么部分成功”会非常难受。

5. 物流与收货确认

物流这组最关键的是:

  • ship/system
  • ship/wechatMpShip
  • shipServiceSystem/list

它们更偏后台配置,而不是前台纯展示组件。真正要理解的重点是:

  • 哪个系统启用了哪些物流服务
  • 微信小程序发货配置有没有挂上
  • 后端有没有注册真实 shipClazz

所以新项目接物流时,通常先搭后台配置页,再考虑订单或充值页怎么消费物流状态。

6. 哪些能力本来就是项目层自己包

oak-general-business 一样,这里也不是“每条链都有完整页面成品”。

例如:

  • 结算 settlePlan / settlement 当前没有公共成品组件
  • 新支付渠道的回调 endpoint 常常是项目层自己包一层路由,再复用公共 utils/pay

所以阅读支付域源码时,最合理的预期应该是:

  • 公共包把主模型、状态机、公共组件和注入点准备好
  • 项目层再按自己的业务路由和页面壳把它们串起来

最小接入思路

第一次把 oak-pay-business 接进项目时,建议按下面的顺序来。

1. 先声明依赖并生成装配代码

当前推荐先在 src/configuration/dependency.ts 中声明 oak-pay-business,再执行 project:initmake:domainmake: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.payConfigofflineAccount$systemwpProduct$application

3. 再决定是否注册项目层扩展

如果项目只用当前内建的 accountofflineAccountwpProductapProductepProductspProductcpProductwfProductppProduct 渠道,可以直接开始用;Web redirect 类产品仍受平台限制。

如果项目要继续扩展:

  • 新支付产品,注册 registerPayClazz(...)
  • 新支付配置页,注册 registerPayChannelComponent(...)
  • 新支付唤起流程,注册 registerFrontendPayRoutine(...)
  • 新物流系统配置页,注册 registerShipSettingComponent(...)

参考项目里的真实接法

如果你对接入方式还有点抽象,haina-busitaicang 这两个项目基本已经把 oak-pay-business 的常见接法都跑过一遍了。

1. 初始化通常只调用 initializeOpb1Features(...)

当前项目通常会在前端运行时初始化链路里同时创建:

  • createOgb0Features(...)
  • createOpb1Features(...)

但真正启动时,很多地方只调用:

await initializeOpb1Features(features, accessConfiguration, config, cosClazzes);

这是因为 oak-pay-business/src/features/index.tsinitialize(...) 内部已经先调用了 oak-general-business 的初始化。也就是说,支付域初始化本身就把应用识别、登录态、微信环境、文件上传这些基础能力一起带上了。

2. 支付域的项目扩展一般写在 routines 和初始化文件里

从这两个项目的真实写法看,最常见的扩展点分布是:

  • initializeFeatures.web.ts:注册前端支付配置组件,例如 registerPayChannelComponent('wpAccount', WpAccountConfig)
  • routines/pay.ts:注册后端支付类,例如 registerPayClazz('cmbProduct', ...)
  • routines/start.ts:注册短信、COS、消息类型,以及项目自己的通知处理器
  • 页面层:直接包 payConfig/systemorder/paypay/detailaccount/detailwithdraw/createship/system

2.1 支付回调经常是“项目路由壳 + 公共处理逻辑”

这里有一个很值得新手提前建立的认知:

  • oak-pay-business 已提供微信、支付宝、Epay、Stripe、Creem、Waffo、PayPal 等可选 endpoint 模块;
  • 项目层仍可以按同样模式补自己的 cmbPay.ts,或为公共模块包一层不同 HTTP 契约。

haina-busi 就是这么做的。它自己的 src/endpoints 里补了:

  • wechatPay.ts
  • aliPay.ts
  • cmbPay.ts

但内部并没有重写整套状态推进,而是继续复用公共的:

  • @oak-pay-business/utils/pay.payNotify
  • @oak-pay-business/utils/pay.refundNotify

所以项目层如果扩新渠道,最稳的做法通常不是“完全自己写 controller”,而是:

  • 项目里补自己的 endpoint 路由名和参数结构
  • 真正的回调处理继续复用公共支付逻辑

3. 实体层通常不是“重写一套支付模型”,而是扩展公共模型

haina-busiCmbAccountCmbProduct 是典型例子:

  • CmbAccount 继承 AbstractPayAccount
  • CmbProduct 继承 AbstractPayProduct

taicang 则更偏向“在公共支付实体上继续补业务字段”:

  • System 继承 @oak-pay-business/entities/System
  • Order 继承 @oak-pay-business/entities/Order
  • Ship 继承 @oak-pay-business/entities/Ship

这两种方式都说明:项目层最好是在公共支付模型上扩展,而不是脱离公共模型重新做一套资金域。

阅读源码时建议按这个顺序

第一次系统阅读 oak-pay-business,最推荐的顺序是:

  1. src/features/index.tssrc/features/Pay.ts
  2. src/registry.backend.tssrc/registry.frontend.ts
  3. src/aspects/index.ts
  4. src/entities/*.ts
  5. src/checkers
  6. src/triggers
  7. src/watcherssrc/timers
  8. 最后再看 src/components

原因和 oak-general-business 一样:新手最容易先被组件数量吸走注意力,但真正决定支付域能力边界的,还是对象定义、状态机、trigger、checker 和注入点。

接下来的各章,我都会明确写出:

  • 这一块解决什么问题;
  • 对应哪些实体;
  • 已经有哪些组件、aspect、endpoint、feature 可以直接复用;
  • 后台有哪些 checker、trigger、watcher、timer 在兜底;
  • 项目层该在哪里接入,以及应该写成什么样子。

系统支付配置与系统资金

支付域里最容易被低估的对象不是 pay,而是 system。在 oak-pay-business 里,很多真正影响资金行为的规则,并不挂在订单或支付对象上,而是统一挂在 System.payConfig 和系统级支付渠道配置上。

换句话说,订单怎么付、充值怎么收手续费、提现怎么扣手续费、系统层有哪些线下收款方式、有没有可用的微信支付产品,这些能力最终都要回到 system

主要对象

这一章最重要的对象有四组:

  • System.payConfig
  • OfflineAccount
  • WpAccount / WpProduct
  • SysAccountOper / SysAccountMove

其中 System.payConfigsrc/entities/System.ts 里额外定义了两块支付配置:

  • withdrawLoss
  • depositLoss

这个 System 是对 oak-general-business 中系统对象的扩展。近期支付包已经适配 general-system 的翻译字段、翻译状态和翻译动作,项目层如果继续覆盖 System,必须把这些通用字段和动作保留下来。

withdrawLoss 当前支持这些字段:

  • conservative
  • ratio
  • lowest
  • highest
  • trim: 'jiao' | 'yuan'

depositLoss 当前支持这些字段:

  • ratio
  • lowest
  • highest

OfflineAccount 则表示系统下配置的线下收款渠道,例如银行卡、微信收款码、支付宝收款码等。WpAccount / WpProduct 对应的则是微信支付账号与具体支付产品。

组件

系统支付配置最核心的现成组件有两组。

1. src/components/payConfig/system/web.pc.tsx

这是系统支付配置页的真实入口,它不是一个“只有手续费表单”的页面,而是一个带多个页签的总入口。源码里当前固定提供了两类页签:

  • system:编辑 System.payConfig
  • offlineAccount:管理 OfflineAccount

除此之外,它还会把所有通过 registerPayChannelComponent(...) 注册进来的渠道配置组件继续拼成额外页签。

也就是说,这个页面本身就是支付渠道配置的前端注入点。

2. src/components/sysAccount/survey/web.pc.tsx

这是系统资金总览页的核心组件。它内置了两类系统账户卡片和详情组件:

  • offlineAccount
  • wpAccount

并且继续提供两个注入点:

  • registerSysAccountCardTopComponent(...)
  • registerSysAccountDetailComponent(...)

项目层如果又加了新的系统资金账户类型,可以把展示卡片和详情区一起注册进来,而不用改这个组件本身。

真实项目里的页面包法

haina-busitaicang 这两个项目在系统支付配置页上的思路非常统一,都是只写一层很薄的页面壳:

<SystemPayConfig
  oakId={systemId}
  oakPath={`${oakFullpath}.system`}
/>

页面本身只负责:

  • 拿到当前 systemId
  • 给组件一个稳定的 oakPath
  • 在组件外面补 PageHeader 或路由壳

payConfig/systemsysAccount/survey 常用参数

这两个组件项目里最常用的参数分别是:

  • payConfig/systemoakIdoakPath
  • sysAccount/surveyoakIdoakPath

如果重新生成 oak-app-domain 后发现 system 缺少 translatetranslateSuccesstranslateFail 等动作,通常说明发布包实体解析或 ActionDef 别名兼容出了问题。应优先检查依赖包的 es/entities/System.d.ts 和同名 .js 是否一起发布。

如果项目层要给系统支付配置页追加新的支付渠道页签,真正传给被注册组件的参数是:

  • systemId
  • oakPath

也就是 registerPayChannelComponent(...) 对应的组件签名。

从源码看,这两个组件还有几个很关键的隐含行为:

  • payConfig/system 的基础投影里已经固定带了 payConfigwpAccount$systemofflineAccount$system
  • sysAccount/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-busitaicang 刚好给了两个真实样例:

  • haina-busi/src/pages/business/square/payConfig/web.pc.tsx:页面文件顶部直接注册 cmbAccountapAccountwpAccount
  • taicang/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 通过 entityentityId 锁定某一类系统账户
  • sysAccount/transferList 默认只查当前系统下 iState === 'transferring'withdrawTransfer

也就是说,前者适合做“某个系统账户的历史流水”,后者适合做“运营人员当前要处理的打款任务列表”。

sysAccount/transferList 的真实边界

这个组件当前几乎没有项目层参数,它的过滤条件是写死在组件里的:

  • withdrawAccount.ofSystemId = 当前 application.systemId
  • iState = 'transferring'

并且展示时会自动把渠道转成人类可读文案:

  • 普通渠道直接按 withdraw::channel.${entity}
  • offlineAccount 会进一步展开成具体 type

这意味着它非常适合:

  • 当前系统运营后台的“待打款列表”
  • 财务处理页

但如果你要做:

  • 跨系统打款总表
  • 已完成 / 已失败历史
  • 指定账户或指定渠道筛选

通常就要像 haina-busi 一样,在项目层再包一层列表组件,而不要直接拿公共 transferList 当万能列表。

sysAccountMove/create 适合放在哪里

这组组件在系统资金后台里也很有用,但文档里很容易漏掉。它当前最关键的参数有:

  • systemId
  • entities
  • onSuccess

真实行为则是:

  • 默认会拉 wpAccountofflineAccount
  • 再把 entities 里追加的系统账户实体一起并进可选列表
  • 让用户选择 fromto 两个系统账户
  • 输入 priceexternalIdremark
  • 最终创建一条 sysAccountMove
  • 同时自动带两条 sysAccountOper$sysAccountMove
    • moveOut
    • moveIn

也就是说,它不是一个简单“记一条备注”的组件,而是专门给系统账户之间做内部划拨用的。

这组组件尤其适合:

  • 财务后台做手工调账
  • 系统账户之间做内部资金搬运
  • 新增支付账户后,需要临时从旧账户转一笔资金过去的场景

如果项目要支持更多系统账户类型,最关键的不是改组件本身,而是把额外实体名通过 entities 传进来。

前端入口

系统支付配置相关的前端入口主要有两类。

features.pay

src/features/Pay.ts 会直接依赖当前应用和当前系统配置:

  • getPayChannels('pay') 会从 system.offlineAccount$systemapplication.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.ratio
  • system.payConfig.depositLoss.lowest
  • system.payConfig.depositLoss.highest

并返回:

  • 扣多少手续费
  • 用哪条多语言文案解释这次手续费
  • 解释文案的参数

提现手续费

aspects/withdraw.ts#getWithdrawCreateData(...) 会读取:

  • system.payConfig.withdrawLoss.conservative
  • system.payConfig.withdrawLoss.ratio
  • system.payConfig.withdrawLoss.lowest
  • system.payConfig.withdrawLoss.highest
  • system.payConfig.withdrawLoss.trim

如果这块配置根本没配,aspect 会直接抛 error::system.withdrawLossUnSet

换句话说,提现功能是否能正常创建,不只是取决于账户余额,更先取决于系统有没有把提现损耗规则配完整。

注入点

系统支付配置相关的注入点一共有四个:

  • registerPayChannelComponent(...)
  • registerSysAccountCardTopComponent(...)
  • registerSysAccountDetailComponent(...)
  • registerFrontendPayRoutine(...)

其中:

  • 前三个决定系统管理后台“怎么配置、怎么展示”
  • 最后一个决定支付详情页“怎么真正发起支付”

项目中如何接入

这部分能力真正接进项目里,通常要做三件事。

1. 确保应用投影带上支付域需要的系统数据

features.pay 不是靠远程现查渠道,而是直接从当前应用数据里拿:

  • application.system.offlineAccount$system
  • application.wpProduct$application
  • application.system.payConfig

所以初始化 oak-pay-business feature 时,就要把这些投影带进去。

2. 管理后台直接复用现成系统配置页

如果项目已经有系统详情页,最稳妥的做法是直接挂 components/payConfig/system,不要自己再画一套支付配置表单。因为这个组件已经把:

  • System.payConfig
  • OfflineAccount
  • 注册进来的其它支付渠道组件

统一放到了同一个入口里。

3. 按需要补系统账户展示

如果项目层扩展了新的支付账户实体,除了注册支付类本身,最好顺手把系统资金总览页也补上:

import {
  registerSysAccountCardTopComponent,
  registerSysAccountDetailComponent,
} from '@oak-pay-business/registry.frontend';

registerSysAccountCardTopComponent('myPayAccount', MyPayAccountCard);
registerSysAccountDetailComponent('myPayAccount', MyPayAccountDetail);

真实项目里的系统资金入口

taicang 里,系统支付配置和系统资金总览已经拆成两张独立页面:

  • pages/console/system/payConfig
  • pages/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

都一起利用起来了。

使用建议

对一个新项目来说,最推荐的顺序是:

  1. 先把 System.payConfig 配完整;
  2. 再把 OfflineAccountWpProduct 这些系统级渠道配好;
  3. 然后才开始接订单支付、充值、提现页面;
  4. 如果项目有新的渠道实体,再继续补注入点。

如果顺序反过来做,最常见的问题就是:页面已经能选渠道了,但系统配置页和系统资金总览页还是缺的,最后很难排查“为什么这个渠道能展示但不能真正工作”。

订单、支付与退款

oak-pay-business 里最核心的一条主线,就是 Order -> Pay -> Refund。但 Oak 里的这条链并不是“用户点一下支付按钮,然后状态改掉”这么简单,它背后至少同时有四层逻辑在一起工作:

  • 实体状态机
  • checker 的入场校验
  • trigger 的状态推进和副作用
  • watcher / endpoint 的异步补偿

如果你把这四层拆开看,就会明白为什么很多支付项目自己写起来会越来越乱,而 oak-pay-business 却能保持相对稳定。

主要对象

Order

src/entities/Order.ts 里的状态包括:

  • unpaid
  • timeout
  • cancelled
  • paying
  • partiallyPaid
  • paid
  • refunding
  • partiallyRefunded
  • refunded

动作包括:

  • startPaying
  • payAll
  • payPartially
  • payNone
  • timeout
  • cancel
  • startRefunding
  • refundAll
  • refundPartially
  • refundNone

Pay

src/entities/Pay.ts 里的状态包括:

  • unpaid
  • paying
  • paid
  • closed
  • refunding
  • partiallyRefunded
  • refunded

动作包括:

  • startPaying
  • succeedPaying
  • close
  • startRefunding
  • refundAll
  • refundPartially
  • stopRefunding

另外还额外定义了两个动作:

  • closeRefund
  • continuePaying

近期 stopRefunding 后订单状态回滚已修正。项目层不要绕过 refund.fail / pay.stopRefunding 自己手动改订单状态,否则会跳过公共 trigger 中对支付、退款和订单状态的一致性处理。

Refund

src/entities/Refund.ts 里的状态相对简单:

  • refunding
  • successful
  • failed

动作则是:

  • succeed
  • fail

组件

这条主线里最值得先读的前端组件有两个。

1. src/components/order/pay/index.ts

这个组件负责把“订单要怎么支付”拆成真正的 pay$order 创建数据。它不是单纯选一个渠道,而是支持同时拼出两段支付:

  • 一段 account 余额支付
  • 一段外部渠道支付,例如 offlineAccountwpProduct

也就是说,项目层如果要做“余额 + 微信支付”这种组合支付,不需要自己重新设计数据结构,这个组件已经按 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 最常用的参数则是:

  • oakIdoakPath
  • onClose
  • onPaid
  • onPayFailure
  • mode: 'frontend' | 'backend'
  • disableAutoPay
  • closeWhenFailure
  • disableClose

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

这个组件暴露的参数非常明确:

  • payChannels
  • payChannel
  • onPick

它本身只做一件事:

  • features.pay.getPayChannels(...) 返回的渠道数组转成可选项,并把选中的 PayChannel 回传给父组件

它不会自己创建 pay,也不会自己发起支付,所以更适合做:

  • 订单支付页里的渠道选择器
  • 充值页里的渠道选择器
  • 后台人工补单页里的支付方式选择器

也正因为它只认 PayChannel 结构,项目层新增支付产品后,只要渠道数据还遵守公共包约定,这个组件通常不用改。

refund/list 适合放在哪里

退款列表虽然不是支付最显眼的前台组件,但在后台排查支付链路时很有价值。它当前的真实行为包括:

  • 默认按当前应用所属 systemId 过滤退款
  • 自动把 creator.name / nickname / mobile 整理成展示字段
  • 自动把 pay.entity 转成渠道名称
  • 通过 withdrawId 标记这条退款是不是“提现拆单产生的退款”

所以它很适合放在:

  • 财务后台的退款记录页
  • 订单详情里的退款历史页
  • 提现详情的辅助排查页

如果项目层改动了 pay.applicationcreator.mobile$user 这些关系,这个列表的默认展示就会受影响,文档里最好提前提醒使用方。

order/list / pay/list

这两个列表组件也很值得单独写出来,因为它们通常就是后台运营和财务页最直接的入口。

order/list 的真实行为包括:

  • 默认按当前应用所属 systemId 过滤订单
  • 自动把金额字段转成人类可读的元单位字符串
  • 自动整理 creatorNamecreatorMobile

它适合做:

  • 后台订单列表
  • 财务订单查询页
  • 某个系统的支付订单概览

pay/list 则更偏支付单后台,当前真实行为包括:

  • 默认按 application.systemId 过滤支付单
  • 默认按 $$createAt$$ desc 排序
  • 自动刷新当前系统下的 offlineAccount
  • 自动把 creator 信息整理成展示字段
  • 暴露 closesucceedPaying 动作

所以它很适合:

  • 支付单管理页
  • 财务对账页
  • 线下支付人工确认页

相比订单列表,pay/list 更适合查“这一笔支付本身发生了什么”;而 order/list 更适合查“订单整体支付到哪一步了”。

前端入口

这一章的前端入口分成三块。

features.pay.getPayChannels(...)

订单支付页和支付详情页都会依赖它来拿渠道。当前返回结果包括:

  • offlineAccount
  • 当前平台允许的 wpProduct
  • Web 环境下的 apProductepProductspProductcpProductwfProductppProduct
  • 如果传了 accountId 且场景是支付,还会追加 account

registerFrontendPayRoutine(...)

支付详情页的真正唤起动作不是硬编码的,它通过 registerFrontendPayRoutine(...) 扩展。

当前内建实现包括:

  • wpProduct:小程序调用 wx.requestPayment(...),微信 H5 走 wechatSdk.loadWxAPi('chooseWXPay', ...)
  • apProductepProductspProductcpProductwfProductppProduct:仅 Web 使用公共 redirect routine,从 pay.meta 解析跳转地址。

如果项目又加了新的支付产品,就要继续注册自己的前端支付 routine。

支付回调 endpoint

src/endpoints/wechatPay.ts 当前直接提供了两个回调 endpoint:

  • payNotify
  • refundNotify

它们内部继续复用了 src/utils/pay.ts 中的回调处理逻辑,项目层一般不需要再自己解微信支付通知。

后台规则

Order checker

src/checkers/order.ts 做了两类很关键的检查:

  • create 时补默认值,例如 creatorIdpaidrefundedsettledsettlePlanned
  • startPaying 时检查支付单是否齐全,以及在不允许部分支付时,支付总额必须等于订单金额

如果订单还带了结算目标,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 的支付在创建后会自动执行 startPaying
  • startPaying 前会调用对应 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-busiwechatPay.tscmbPay.tsaliPay.ts 都没有重写整套支付回调逻辑,而是统一复用了:

  • @oak-pay-business/utils/paypayNotify
  • @oak-pay-business/utils/payrefundNotify

这点很值得模仿。项目层真正需要做的通常只是暴露不同路由和参数,不要重写回调状态推进本身。

使用示例

1. 用订单支付组件生成 pay$order

<OrderPay
  oakPath="order"
  accountId={accountId}
  accountAvailMax={account.avail}
  autoStartPay
  onSetPays={(pays) => this.setState({ pays })}
/>

这个组件最终回给父组件的 pays,就是可以直接塞进 order.startPayingpay$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 已负责解析并调用支付域状态推进逻辑,项目不需要再重写通知解密和状态机。

调用公共路由时参数仍要带:

  • payNotifypayId
  • refundNotifyrefundId

使用建议

如果你准备在项目里复用这条链,最重要的建议只有两条:

  1. 订单状态不要自己手动推,尽量通过 order.startPayingpay.succeedPayingrefund.succeed 这些动作进入状态机。
  2. 前端支付流程不要只写页面逻辑,要同时把 registerFrontendPayRoutine(...)、支付回调 endpoint、watcher 补偿一起接上。

否则最常见的问题就是:页面能跳支付,但订单状态、退款状态、过期关闭和异步回调并没有跟着一起工作。

账户、充值与流水

支付域里除了“对订单付款”,还有另一条经常单独存在的主线:账户充值。oak-pay-business 没把它做成一个孤立的钱包模块,而是把充值、到账、系统资金、账户流水放进了一套统一模型里。

如果你从源码角度看,这条线的核心其实是:

  • Account
  • Deposit
  • AccountOper
  • SysAccountOper
  • SysAccountMove

它和订单支付会复用同一套渠道能力,但状态推进和记账方式并不完全一样。

主要对象

Account

账户本身记录的是用户或业务对象的余额。最常见的两个数是:

  • total
  • avail

Deposit

src/entities/Deposit.ts 定义了充值单状态:

  • depositing
  • successful
  • failed
  • shipped

动作包括:

  • succeed
  • fail
  • ship

这里的 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 常用参数与组件边界

这不是一个“自己持有全部状态并直接提交充值”的黑盒组件,而是一个受控表单。当前源码里暴露的关键参数有:

  • accountId
  • depositMinCent
  • depositMaxCent
  • price
  • channel
  • onSetPrice
  • onSetChannel
  • loss
  • focus

其中真正会直接参与渲染和联动的,是:

  • depositMinCent / depositMaxCent:控制金额上下限,并在 ready() 时用最小充值额初始化输入框
  • price / channel / loss:父组件维护的受控状态
  • onSetPrice / onSetChannel:组件内部只负责把变更往外抛

focus 目前主要用于小程序输入框聚焦,web 端并不会额外依赖它。accountId 虽然保留在属性里,但当前组件本体并不直接用它创建充值单,所以真正的提交动作仍然应该放在父组件,例如 account/detail 或项目自己的充值页里。

从实现上看,它还有两个默认行为很适合直接写进接入文档:

  • 小程序环境且未指定渠道时,会自动优先选 wpProduct
  • priceloss 变化后,会重新计算小程序里的 cursorSpacing,保证渠道和手续费区域不会挡住输入框

也就是说,deposit/new 更适合做“充值参数录入器”,而不是一个独立完成整条充值链路的页面。

2. src/components/account/detail/index.ts

这是账户详情页的核心组件。它不是只读组件,内部还封装了完整的充值创建流程:

  • 调用 account.deposit
  • 嵌套创建 deposit
  • 再嵌套创建 pay
  • 轮询 pay 直到进入 paying
  • 最后跳转到未完成支付详情

也就是说,项目层如果直接复用它,充值链路已经是完整的。

3. 系统资金相关组件

系统侧的资金展示和流水组件主要分布在:

  • src/components/sysAccount/survey
  • src/components/sysAccountMove/create
  • src/components/sysAccountOper/list
  • src/components/accountOper/*

这些组件通常会和上一章的系统支付配置页一起出现在管理后台里。

account/detail 常用参数

oak-pay-business/src/components/account/detail/index.ts 不是一个纯展示组件,项目里最常传的参数有:

  • depositMinCent
  • depositMaxCent
  • autoStartPay
  • onGoToUnfinishedPay
  • onWithdraw
  • preWithdraw
  • onGoToHistory

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/list
  • sysAccount/transferList

sysAccountOper/list 当前通过下面两个参数工作:

  • entity
  • entityId

它会按这两个字段过滤系统流水,并内置:

  • 月份筛选
  • 类型筛选,当前枚举包括 withdrawTransferpayrefundcompensatemoveInmoveOut

sysAccount/transferList 则更偏运营处理页。它默认只看:

  • 当前应用所属系统下的提现转账
  • iState === 'transferring'

并且会把 withdrawAccount.channel 自动转成可读渠道名。所以它很适合做“待打款提现处理列表”,而不是通用历史查询页。

开发注意事项

account/detail 的一个重要适配前提是:项目实体关系不要把公共充值链路打断。至少要保证这些关系还能正常工作:

  • deposit$account
  • pay$deposit
  • accountOper$account

因为组件内部会直接沿着这些关系判断“是否有未完成充值”“是否需要去支付详情页”“最近流水是什么”。

前端入口

这条线主要依赖的前端入口还是 features.pay

getPayChannels('deposit')

会返回所有允许充值的渠道。当前内建来源是:

  • offlineAccount.allowDeposit === true
  • 当前环境可用的 wpProduct

calcDepositLoss(price, channel)

会按 System.payConfig.depositLoss 直接计算充值损耗。返回值是一个三元组:

  • 手续费金额
  • 说明文案 key
  • 文案参数

这也是 deposit/newaccount/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.ts
  • triggers/deposit.ts

当前源码里的真实行为是:

  • pay.succeedPaying 后会根据 depositId 去推进充值单
  • 如果支付产品要求确认收货,充值单会先进入 ship
  • deposit.succeed 时才真正补记账户和系统账户的流水
  • deposit 状态变化还会推送数据事件

这就是为什么有些充值会“支付成功但余额还没到账”,因为它还卡在确认收货这一层。

小程序确认收货

如果是带确认收货的小程序充值,前端组件会调用:

  • shipConfirmSuccess

这个 aspect 定义在 src/aspects/ship.ts,如果微信侧物流状态已经确认收货,就会把 shipreceiving 推到 succeedReceiving,再由后续 trigger 推动 deposit.succeed

注入点

账户充值这一章本身没有额外的独立 registry,但它会直接复用支付域已有的几个扩展点:

  • registerPayClazz(...)
  • registerFrontendPayRoutine(...)
  • registerSysAccountCardTopComponent(...)
  • registerSysAccountDetailComponent(...)

也就是说,项目一旦加了新的充值渠道,不只订单支付会受影响,账户充值入口和系统资金展示也要一起补。

项目中如何接入

1. 先让应用能拿到充值渠道

features.pay.getPayChannels('deposit') 依赖当前应用和系统的投影,所以初始化阶段一定要把:

  • system.offlineAccount$system
  • wpProduct$application
  • system.payConfig

带进来。

2. 充值入口优先复用现成组件

对新项目来说,最简单且最稳的做法一般是:

  • 账户页直接挂 components/account/detail
  • 需要单独充值表单时,再挂 components/deposit/new

这样充值手续费、充值渠道和支付详情跳转都能统一起来。

3. 如果有系统资金管理台,再补系统侧组件

当项目需要运营后台查看平台收支时,再接:

  • sysAccount/survey
  • sysAccountMove/create
  • sysAccountOper/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 数据链。

使用建议

如果项目里既有订单支付,又有账户充值,最容易犯的错是把两条链拆成两套完全不同的前端和后台逻辑。实际上更推荐:

  1. 渠道层统一复用 features.payregisterFrontendPayRoutine(...)
  2. 记账层统一复用 depositaccountOpersysAccountOper
  3. 需要确认收货的充值,直接复用 ship 主线,不要额外发明一个“待到账状态”

这样后面要查“钱为什么没到”“系统资金为什么少了一笔”“用户余额为什么和支付成功不一致”时,排查路径会清楚很多。

提现

提现是 oak-pay-business 里最容易看起来“像一个简单表单”,但实际上规则最复杂的能力之一。因为在 Oak 的支付域模型里,提现不是单一渠道直接打款,而是可能同时拆成两部分:

  • 能原路退回的部分,走 Refund
  • 不能原路退回的部分,走 WithdrawTransfer

这也是为什么提现这一章一定要连着退款一起理解。

主要对象

Withdraw

src/entities/Withdraw.ts 里的状态包括:

  • withdrawing
  • successful
  • partiallySuccessful
  • failed
  • applying

动作包括:

  • succeed
  • fail
  • succeedPartially

WithdrawTransfer

src/entities/WithdrawTransfer.ts 里的状态包括:

  • transferring
  • successful
  • failed

动作包括:

  • succeed
  • fail

WithdrawAccount / WithdrawChannel

这两个对象分别代表:

  • 用户或业务对象可用的提现账户
  • 提现账户关联的打款渠道

当原路退款额度不够时,提现就会继续依赖这里配置的提现账户和渠道。

组件

提现这条线最值得先看的两个组件是:

  • src/components/withdraw/create/index.ts
  • src/components/withdraw/detail/index.ts

其中创建页最关键,因为它真实体现了 Oak 支付域里的提现流程不是“点提交直接 create withdraw”,而是:

  1. 先调用 aspect 预计算可行的提现拆单方案
  2. 再拿这个方案去执行 withdraw.create

详情页则会直接展示:

  • refund$withdraw
  • withdrawTransfer$withdraw

这两个关系正好对应提现拆单后的两段执行路径。

withdraw/create 常用参数

oak-pay-business/src/components/withdraw/create/index.ts 项目里最常用的参数有:

  • accountId
  • withdrawAccountFilter
  • onNewWithdrawAccount
  • onCreateWithdraw
  • onGoToHistory
  • onGoToWaManage

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/detail
  • withdraw/list
  • withdrawAccount/list

taicang 前后台都已经把这几个组件分开挂页了,这样一旦出现“部分成功”“部分原路退回”,业务方可以直接在详情页看到拆单结果。

withdraw/detail 的职责边界

withdraw/detail 在 web 端的渲染其实很薄,它本身主要就是把一条 withdraw 交给 withdraw/display 展示。

这意味着:

  • 创建逻辑不应该放进详情页
  • 拆单解释、退款明细、转账明细适合统一在详情展示层处理
  • 页面层更适合负责“从列表跳转到哪条提现详情”

这种拆法和 taicang 当前的页面组织是一致的。

withdraw/display 的真实职责

withdraw/detail 之所以能把“部分成功”“部分失败”讲清楚,关键不是页面壳本身,而是 withdraw/display 已经把提现拆单明细整理好了。它当前最关键的参数只有两个:

  • withdraw
  • create

其中 withdraw 里它会直接读取:

  • refund$withdraw
  • withdrawTransfer$withdraw

然后把两边数据拍平成一组统一的展示项。也就是说,这个组件不是分别画两张表,而是把“原路退款”和“转账打款”都收敛成同一种明细卡片结构。

渠道名称的解析也已经内置了:

  • 优先从 withdrawTransfer.withdrawAccount.channel 取渠道
  • 如果详情数据里没带全,再回退到缓存里按 withdrawAccountId 查一次
  • offlineAccount 会额外展开成具体线下账户类型文案

create 参数的作用主要是控制展示阶段:

  • create: true 时按“刚提交申请”显示步骤,不展示执行态和更新时间
  • 详情页常规查看时,则会显示 refund / withdrawTransfer 的执行状态、更新时间和失败原因

所以项目里如果想自己重画提现详情,至少也要把这套“退款 + 转账”的汇总逻辑一起带走。

withdraw/list 常用参数

oak-pay-business/src/components/withdraw/list/index.ts 最常用的参数非常明确:

  • accountId
  • gotoDetail

它内部会:

  • 强制按 accountId 过滤
  • 默认按 $$createAt$$ desc 排序
  • 直接把每条提现的拆单数量聚合出来

所以它很适合直接作为“某个账户的提现历史页”。

withdrawAccount/list 常用参数

oak-pay-business/src/components/withdrawAccount/list/index.ts 则更适合两种模式复用:

  • 管理模式:维护某个对象的提现账户
  • 选择器模式:给 withdraw/create 挑一个打款账户

常用参数包括:

  • entity
  • entityId
  • onPick
  • onCancel

从源码看它会默认:

  • 只展示 enabled: true 的账户
  • 优先选中默认账户
  • onPick 存在时自动切成 picker 模式

这也是为什么 withdraw/create 只要传 withdrawAccountFilter,后续选择器链路就能自然接起来。

withdrawAccount/list 的两种模式

结合 index.tsweb.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
  • 自动整理 creatorNamecreatorMobile
  • 自动整理 operatorNameoperatorMobile
  • 自动把 withdrawAccount.channel 转成渠道名称
  • 暴露 succeedfail 两类动作

它还有一个很实际的实现细节:

  • 在准备更新某条转账记录时,会先刷新对应系统账户余额,并写到本地状态 sysAccountAmount

所以它更适合:

  • 运营后台待打款列表
  • 提现转账处理页
  • 财务审核后的执行页

withdraw/list 更适合作为用户或业务对象视角的“提现历史”。

aspect

提现最核心的后端入口就是 src/aspects/withdraw.ts#getWithdrawCreateData(...)

它的参数很简单:

  • accountId
  • price
  • withdrawAccountId?

但内部做的事情非常多:

  • 检查 System.payConfig.withdrawLoss 是否已配置
  • 锁定当前账户,读取 availrefundable
  • 如果申请提现金额超过可原路退款额度,且又没选 withdrawAccountId,直接报错
  • 计算本次提现应拆成哪些 refund$withdraw
  • 如果仍有剩余金额,再补一笔 withdrawTransfer$withdraw
  • 最后算出整单总手续费 loss

也就是说,提现创建页拿到的不是一个“展示用报价”,而是最终可直接落库的 withdraw.create 数据。

后台规则

提现手续费计算

提现手续费由 System.payConfig.withdrawLoss 决定,源码里有两套模式。

1. conservative: true

保守模式下,不按系统固定比例算,而是根据真实渠道税费估损:

  • 原路退款部分会调用 payClazz.calcRefundTax(...)payClazz.calcPayTax(...)
  • 转账部分会调用 payClazz.calcTransferTax(...)

2. conservative: false

非保守模式下,会先按系统配置预计算总手续费,再按提现拆单比例分摊到每一笔 refundwithdrawTransfer 上。

这时会用到:

  • ratio
  • lowest
  • highest
  • trim

其中 trim 支持:

  • jiao
  • yuan

Withdraw trigger

src/triggers/withdraw.ts 负责的事情主要有三类。

1. 创建提现时先扣账户并记流水

提现创建时会先扣减账户可用余额,并创建关联的 accountOper

2. 汇总执行结果更新提现状态

updateWithdrawState(...) 会把:

  • refund$withdraw
  • withdrawTransfer$withdraw

两边实际完成的金额和手续费汇总成:

  • dealPrice
  • dealLoss

再决定提现最终是:

  • fail
  • succeed
  • succeedPartially

3. 失败或部分成功时返还差额

如果整单失败,或者只成功了一部分,剩余差额会通过新的 accountOper 返还到账户。

WithdrawTransfer checker 与 trigger

src/checkers/withdrawTransfer.ts 约束很明确:

  • succeed 时必须有 externalId
  • fail 时必须有 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$withdrawwithdrawTransfer$withdraw,不要只显示一个总状态。否则一旦出现部分成功,就很难让业务方理解到底发生了什么。

真实项目里的页面拆法

taicang 的页面结构看,提现最稳的拆法是:

  • 账户详情页只负责跳到提现创建页
  • 提现创建页只负责生成并提交 withdrawData
  • 提现详情页展示 refund$withdrawwithdrawTransfer$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 模型里本来就是“退款 + 转账”的组合单。

使用建议

提现这块最重要的经验是:不要把它写成一个只看余额的简单转账表单。更好的做法是:

  1. 始终先调 getWithdrawCreateData(...)
  2. 始终把 refund$withdrawwithdrawTransfer$withdraw 当成两条真正独立的执行路径
  3. 系统配置里先把 withdrawLoss 配完整,再开放提现入口

否则一旦碰到“部分金额原路退,部分金额人工打款”的场景,前台和后台很快就会对不上。

物流

很多人第一次接 oak-pay-business 时,会把 Ship 当成“订单附带的物流表”。但如果你沿着源码往下读,会发现它的作用远不止展示快递单号:

  • 它会决定快递单由哪个物流系统接单
  • 它会和微信小程序发货信息录入打通
  • 它会影响虚拟充值什么时候真正到账
  • 它还会通过 timer 和 aspect 与第三方物流状态持续同步

所以在 oak-pay-business 里,物流不是支付域的边角料,而是一个和支付、充值直接耦合的正式模块。

主要对象

这一章最关键的对象有五个:

  • Ship
  • ShipService
  • ShipServiceSystem
  • ShipCompany
  • WechatMpShip

其中 Ship 本身在 src/entities/Ship.ts 里定义了完整状态机。

Ship 状态

当前状态包括:

  • unshipped
  • shipping
  • cancelled
  • received
  • rejected
  • unknown
  • receiving

动作包括两类。

主动作

  • ship
  • receive
  • cancel
  • reject
  • unknow
  • startReceiving
  • succeedReceiving

扩展动作

  • syncState
  • syncPaths
  • syncAll
  • print

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 常用参数

系统物流配置页本身项目层最常传的还是两个参数:

  • oakId
  • oakPath

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 只允许在 unshippedshipping 状态执行
  • 非 root 用户手工 ship / receive 时,只能操作没有 extraShipId 的单
  • print 只能对已有外部单号、且尚未发货的快递单执行
  • startReceiving / succeedReceiving 只允许虚拟物流,或“支付产品要求确认收货”的订单物流执行

Ship trigger

src/triggers/ship.ts 里有一整条完整的物流自动化链路。

1. 创建时自动选择物流系统

如果创建 ship 时带了 shipOrder$ship,trigger 会先调用 getShipEntity(...),按系统配置里各物流系统的 sortavailable(...) 结果挑出一个可用物流系统,并把:

  • entity
  • entityId

自动回填到 ship 上。

2. 创建后自动发货或自动下单

  • virtual / pickup 类型在创建后会自动 ship
  • express 类型在创建后会自动调用外部物流系统下单

这两段都是 when: 'commit'strict: 'makeSure' 的触发器。

3. 发货后自动录入微信小程序发货信息

ship 进入 shipping 时,如果它属于:

  • 充值单对应的发货
  • 或带 wpProduct.needReceiving 的订单发货

trigger 会调用微信小程序发货信息录入逻辑。

4. 取消快递单时调用外部取消接口

如果 ship.type === 'express' 且已有 extraShipIdcancel 之后会继续调用物流渠道的取消下单接口。

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(...)

注册新的物流系统实现。这个注册函数会检查被注册实体是否满足这些字段约束:

  • sort
  • systemId
  • disabled

然后 getShipEntity(...) 会在创建物流单时,从当前系统所有可用物流实体里按 sort 选出最优的那个。

项目中如何接入

1. 先把系统物流配置页接起来

如果项目需要管理后台配置物流,先注册物流系统设置组件,然后在系统详情页里挂 components/ship/system

2. 后端注册物流类

只有把物流类实现注册进 registerShipClazzEntity(...),自动下单、取消、打印面单、收件人信息获取这些行为才会真正工作。

3. 小程序项目再接 WechatMpShip

如果项目是微信小程序或者涉及微信小程序发货信息录入,就继续补 WechatMpShip 配置组件和对应物流类实现。

真实项目里的整合顺序

ship 接进现有项目时,最稳妥的顺序通常是:

  1. 先注册 registerShipSettingComponent(...)
  2. 再注册 registerShipClazzEntity(...)
  3. 确保系统页已经能配置 ShipServiceSystem
  4. 最后再让订单/充值创建 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 自动补上。

使用建议

物流这块最推荐的思路是:

  1. 把“哪个物流系统来接单”交给 getShipEntity(...)
  2. 把“怎么发货、取消、查状态、打面单”交给 shipClazz
  3. 把“小程序收货确认后的业务推进”继续交还给 shipConfirmSuccessship trigger

不要把这些逻辑零散写在订单页面、充值页面或者单独的 cron 脚本里。否则最后最难排查的,往往不是物流本身,而是“为什么收货确认后余额还没到账”这种跨模块问题。

渠道、组件注入与扩展点

oak-pay-business 最有价值的地方之一,不是它内置了多少渠道,而是它把“如何继续接新渠道”也做成了正式扩展点。你不用把公共包源码改烂,只要按它定义好的几个注册入口去接,就能把后端渠道类、系统配置页、前端支付唤起流程和系统资金展示一起补进去。

这一章的重点不是业务流程,而是这些注入点究竟怎么配合工作。

先分清两类扩展

支付域里的扩展其实分成两大类。

1. 后端渠道实现

这类扩展决定:

  • 如何预下单
  • 如何关单
  • 如何退款
  • 如何计算手续费
  • 如何查询支付/退款状态

真实入口是:

  • registerPayClazz(...)
  • getPayClazz(...)

2. 前端和后台管理扩展

这类扩展决定:

  • 系统支付配置页出现哪些额外页签
  • 支付详情页如何拉起新的支付渠道
  • 系统资金总览页如何展示新的账户类型
  • 物流设置页如何展示新的物流配置

真实入口是:

  • registerPayChannelComponent(...)
  • registerFrontendPayRoutine(...)
  • registerShipSettingComponent(...)
  • registerSysAccountCardTopComponent(...)
  • registerSysAccountDetailComponent(...)

后端支付类扩展

当前内建渠道

src/utils/payClazz/index.ts 里当前内建了以下支付实现:

  • account
  • offlineAccount
  • wpProduct
  • apProduct
  • epProduct
  • spProduct
  • cpProduct
  • wfProduct
  • ppProduct

这些产品实体都会被 getPayClazz(...) 识别并实例化;是否能在当前前端场景展示,还要看应用投影、产品启用状态以及 Pay feature 的平台限制。当前 Web redirect 类产品的前端 routine 才会在 Web 环境展示,不能把“后端 payClazz 已内建”理解成所有平台都能直接拉起。

registerPayClazz(...)

这个注册函数位于 src/utils/payClazz/index.ts,用途是把新的支付产品实体接进 Oak 支付域。

它不只是简单挂一个构造函数,还会校验被注册实体是否满足公共支付模型的要求。

对账户实体的约束

accountEntity 对应的实体至少要有这些字段:

  • price
  • systemId
  • allowWithdrawTransfer
  • withdrawTransferLossRatio

对支付产品实体的约束

支付产品实体至少要有这些字段:

  • applicationId
  • enabled
  • taxLossRatio
  • refundGapDays
  • refundCompensateRatio
  • needReceiving

pay.entity 的约束

还会检查 schema.pay.attributes.entity.ref 里是否真的包含你注册的实体名。

也就是说,项目层要加新支付产品,不只是写个类就行,而是实体设计本身也要符合支付域公共约束。

真实项目里的实体适配方式

haina-busitaicang 在实体层给了两个非常典型的接法。

1. 新增一个完全独立的支付渠道实体

haina-busi 的做法是:

  • CmbAccount 继承 @oak-pay-business/entities/AbstractPayAccount
  • CmbProduct 继承 @oak-pay-business/entities/AbstractPayProduct

然后只补自己的渠道字段,例如:

  • 账户侧的商户号、密钥、回调地址、是否启用
  • 产品侧的类型、关联应用、是否启用

这种方式很适合新增一个全新的支付产品体系。

2. 在公共支付实体上继续加项目字段

taicang 的做法则是:

  • System 继承 @oak-pay-business/entities/System
  • Order 继承 @oak-pay-business/entities/Order
  • Ship 继承 @oak-pay-business/entities/Ship

也就是说,项目自己的业务字段继续往公共支付实体上叠,而不是另起一套平行实体。

getPayClazz(...)

这是运行时真正拿渠道实现的入口。它会:

  • account 按实体类型缓存
  • 对其它渠道按 entity.entityId 缓存
  • 首次取用时再调用对应的构造函数

后面的:

  • pay.startPaying
  • refund.create
  • withdraw.getWithdrawCreateData
  • watcher 轮询支付状态和退款状态

都会依赖它。

物流类扩展

registerShipClazzEntity(...)

物流扩展和支付类扩展的思路完全一样,只是入口在 src/utils/shipClazz/index.ts

被注册的物流实体当前至少要满足这些字段:

  • sort
  • systemId
  • disabled

之后 getShipEntity(...) 会按当前系统里所有已注册实体的 sort 从大到小挑可用物流类,并调用它的 available(...)

registerShipSettingComponent(...)

后端物流类注册完之后,通常还要顺手把系统设置页也接上,这就是前端对应的注入点。

前端支付流程扩展

registerFrontendPayRoutine(...)

这个注册函数定义在 src/components/pay/detail/index.ts。它接受四部分内容:

  • entity
  • routine
  • projection
  • judgeCanPay

也就是说,一个新支付渠道要想在支付详情页里真正被唤起,不只是写一个 routine 就行,还要告诉组件:

  • 为了拉起支付,前端还需要预取哪些额外字段
  • 在什么条件下允许拉起支付

当前默认实现

当前内建实现包括:

  • 小程序环境调用 wx.requestPayment(...)
  • 微信网页环境调用 chooseWXPay
  • Web 环境下,apProductepProductspProductcpProductwfProductppProduct 共用 redirect routine,从 pay.meta 读取渠道返回的跳转 URL

如果项目还有新的支付产品,比如银联或自定义聚合支付,仍应通过这个入口注册,而不是直接改 pay/detail 组件源码。

系统配置与系统资金展示扩展

registerPayChannelComponent(...)

这个入口用于把新的支付配置组件挂进 components/payConfig/system/web.pc.tsx 的页签里。

注册后,系统支付配置页会自动多出一个:

  • 以实体名为 key 的新页签

并把:

  • oakPath
  • systemId

传给对应组件。

公共渠道配置组件

除了注册入口,本仓库本身也已经给几类常见渠道准备了管理组件。它们通常都是被 registerPayChannelComponent(...) 挂进 payConfig/system 页签里的。

offlineAccount/config

这个组件的关键参数是:

  • systemId

它默认会展示并维护:

  • type
  • channel
  • name
  • qrCode
  • allowDeposit
  • allowPay
  • price
  • enabled
  • taxLossRatio
  • refundCompensateRatio
  • refundGapDays
  • allowWithdrawTransfer
  • withdrawTransferLossRatio

所以它本质上是“线下收款账户 + 提现打款账户”的系统管理页,而不只是一个收款码列表。

wpAccount/config

这个组件同样按 systemId 工作,当前主要维护:

  • mchId
  • wechatPayId
  • apiV3Key
  • publicKeyFilePath
  • privateKeyFilePath
  • refundGapDays
  • taxLossRatio
  • refundCompensateRatio
  • allowWithdrawTransfer
  • withdrawTransferLossRatio
  • needReceiving

从源码看,它还有一个很重要的限制:

  • canCreate 只有在当前系统下不存在已启用账户时才为真

也就是说,公共实现默认把 wpAccount 当成“一个系统下单一主账号”的配置方式。

另外它在展示层还有一个很容易被忽略的设计:

  • 每个账户卡片内部其实是“详情 + wpProduct/config”两个页签

所以项目层如果直接复用它,通常不需要再额外写一页“某个微信支付账号下有哪些支付产品”。

wpProduct/config

这个组件的关键参数有:

  • systemId
  • wpAccountId

它会在进入时主动刷新当前 systemId 下的所有 application,然后为某个 wpAccount 维护它挂载的支付产品。当前维护的重点字段包括:

  • type
  • applicationId
  • taxLossRatio
  • refundCompensateRatio
  • refundGapDays
  • needReceiving
  • enabled

这也说明一个关键适配点:

  • 如果系统下应用没配好,wpProduct/config 就不会有可选 application

apAccount/config

支付宝账号配置组件和 wpAccount/config 是平行设计,关键参数同样是:

  • systemId

当前源码里它会维护的重点字段包括:

  • appId
  • mchId
  • aliPayId
  • publicKeyPath
  • privateKeyPath
  • encryptKey
  • alipayRootCertPath
  • alipayPublicCertPath
  • appCertPath
  • mode
  • gateway
  • endpoint
  • callbackUrl
  • wsServiceUrl
  • setting
  • keyType
  • timeout
  • needEncrypt
  • refundGapDays
  • taxLossRatio
  • refundCompensateRatio
  • allowWithdrawTransfer
  • withdrawTransferLossRatio
  • needReceiving
  • enabled

它同样内置了两个很关键的行为:

  • canCreate 只有在当前系统下没有已启用账号时才为真
  • 每个账号卡片内部直接带一个 apProduct/config 页签

所以它不是一个“只填支付宝证书路径”的表单,而是“支付宝账户 + 支付产品”的组合管理入口。

apProduct/config

这个组件的关键参数有:

  • systemId
  • apAccountId

它和 wpProduct/config 一样,会在进入时先刷新当前系统下的 application,再给某个 apAccount 维护挂载的支付产品。当前会重点维护:

  • type
  • applicationId
  • config
  • taxLossRatio
  • refundCompensateRatio
  • refundGapDays
  • needReceiving
  • enabled

实际界面里它还有两个值得提前告诉开发的行为:

  • 列表支持直接开关 enabled
  • 删除、创建都是在当前账号上下文内完成,不需要项目层额外再传过滤条件

所以项目里真正要保证的是:

  • 当前系统已经有可选 application
  • apProduct 实体已经把 applicationIdapAccountId 等公共约束定义完整

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 相比,它当前会同时维护:

  • payNotifyUrl
  • refundNotifyUrl

因此如果你的项目同时接微信和支付宝,不能简单以为两边配置页完全对称。

registerSysAccountCardTopComponent(...)

给系统资金总览页顶部卡片增加新的账户显示样式。

registerSysAccountDetailComponent(...)

给系统资金总览页里的详情区域增加新的账户详情组件。

这两个入口通常会和新的支付账户实体一起出现。

registry.backend.tsregistry.frontend.ts

这两个文件的区别要记清楚。

src/registry.backend.ts

后端入口只导出:

  • registerPayClazz

src/registry.frontend.ts

前端环境只导出:

  • registerPayChannelComponent
  • registerFrontendPayRoutine
  • registerShipSettingComponent
  • registerSysAccountCardTopComponent
  • registerSysAccountDetailComponent

故意不导出 registerPayClazz(...),因为后端渠道类注册本来就不应该在前端运行时里做。

项目里该从哪个入口 import

这一点最好在文档里直接说死,否则项目里很容易出现“能跑但 import 路径混乱”的情况:

  • 后端运行时注册:从 registry.backend.ts import
  • 前端运行时注册:优先从 registry.frontend.ts import
  • 个别老项目可能直接从组件文件或 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 则保留了较早的页面级注册写法:

  • 直接从组件内部文件引入注册函数
  • 在页面文件里注册 cmbAccountapAccountwpAccount

两种方式都能工作,但如果是新项目或准备整理老项目,优先收敛到 registry.frontend.ts 会更清楚。

这样项目代码一眼就能看出“这是前端注入还是后端注入”。

真实项目里的注册顺序

haina-busi/src/routines/pay.ts 已经把一个完整样例跑通了:

  1. 先在实体层准备 cmbAccount / cmbProduct
  2. registerPayClazz('cmbProduct', { accountEntity: 'cmbAccount', ... }, storageSchema)
  3. 在系统支付配置页注册 registerPayChannelComponent('cmbAccount', CmbAccountConfig)
  4. 再根据需要补 registerFrontendPayRoutine(...) 或复用已有详情页逻辑

同一个文件里还注册了:

  • registerPayClazz('apProduct', { accountEntity: 'apAccount', ... }, storageSchema)

这说明一个项目里同时扩多个渠道,本来就应该通过 registry 统一管理,而不是在页面里分散硬编码。

回调 endpoint 也应该复用公共处理

haina-busiwechatPay.tscmbPay.tsaliPay.ts 都直接复用了:

  • @oak-pay-business/utils/paypayNotify
  • @oak-pay-business/utils/payrefundNotify

因此一个完整渠道接入,最好同时包括:

  • 实体
  • registerPayClazz(...)
  • 系统配置组件
  • 前端唤起
  • 回调 endpoint

项目中如何接入

一个新渠道接入时,最稳妥的顺序通常是:

  1. 先补实体,确保符合公共支付模型
  2. 后端注册 registerPayClazz(...)
  3. 系统配置页注册 registerPayChannelComponent(...)
  4. 支付详情页注册 registerFrontendPayRoutine(...)
  5. 如果涉及系统资金账户,再注册系统资金展示组件

如果只做了第 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);

使用建议

对项目层来说,最重要的不是“能不能很快写出一个新渠道类”,而是要把渠道接入看成四件一起完成的事:

  1. 后端支付能力
  2. 系统配置入口
  3. 前端支付唤起
  4. 系统资金展示

只补其中一层,后面几乎一定会在管理后台、支付详情页或者提现链路里出现断层。

项目支付系统设计与接入

如果你已经看完前面几篇,会发现 oak-pay-business 并不是“拿来就能直接付款”的一个页面组件包,而是一套完整的支付域基础设施。真正做项目时,最重要的问题也不是“怎么拉起微信支付”,而是:

  • 支付系统应该怎么分层;
  • 哪些能力应该沉到公共支付域里;
  • 哪些规则应该留在项目自己的 trigger / aspect 里;
  • 一个新项目最小应该接哪些东西,才不至于只把支付页面做出来,却把状态机、回调和补偿链路丢了。

这一篇就用 taicang 作为已经跑通的参考项目,把“项目支付系统应该怎么设计”完整梳理一遍。

先说结论

一个 Oak 项目的支付系统,最稳的设计不是“自己写一套 payment service”,而是分成下面四层:

1. 支付域公共底座

这层由 oak-pay-business 提供,负责:

  • 支付、退款、充值、提现、物流、系统资金、结算这些通用实体;
  • checker、trigger、watcher、timer 组成的资金状态机;
  • 支付渠道抽象 payClazz
  • 支付回调处理;
  • 前端 pay feature;
  • 后台配置组件、支付详情组件、账户与流水组件。

2. 项目支付接入层

这层在项目里负责:

  • oak-pay-businesscheckers / 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-business
  • oak-pay-business

这一步的意义不是“安装一个库”,而是把支付域对象和运行时能力纳入 Oak 依赖体系。

2. 初始化阶段合并支付域能力

taicang 在初始化时并没有自己重写支付流程,而是把公共支付域能力接入当前 Oak 运行时。需要区分当前模板和历史文件:

  • 新项目先在 src/configuration/dependency.ts 声明 oak-pay-business,再执行 project:initmake:domainmake: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.ts
  • src/context/FrontendRuntimeContext.ts

这一步非常关键。项目不应再手工直继承支付库的 RuntimeContext,也不需要直接依赖 pay 已经传递依赖的 general Context;具体 layer/module 写法和冲突规则见上下文。很多项目失败就失败在这里只是“引了组件”,却没有把支付域运行时真的接进来。

3. 实体层复用公共支付模型

taicang 没有自己另起一套 paymentOrderpaymentRecordwallet 模型,而是直接扩展公共实体:

  • src/entities/Order.ts 继承 @oak-pay-business/entities/Order
  • src/entities/System.ts 继承 @oak-pay-business/entities/System
  • src/entities/Ship.ts 继承 @oak-pay-business/entities/Ship
  • src/entities/Supplier.ts 使用 AccountWithdrawAccount

也就是说,订单支付状态机、系统支付配置、账户与提现账户这些核心模型,在 taicang 里本质都沿用了公共支付域。

4. 项目只在业务差异点上扩展

taicang 真正自己补的,是那些明显不属于通用支付域的规则:

  • 号牌押金能否抵扣;
  • 号牌押金在支付成功后如何返还或消费;
  • 拍卖业务下订单完成后如何生成后续结算计划;
  • 小程序确认收货时如何结合号牌、物流和支付元数据继续推进业务。

这些逻辑主要落在:

  • src/utils/spPlate.ts
  • src/aspects/spPlate.ts
  • src/triggers/pay.ts
  • src/triggers/order.ts

这就是正确的边界。通用支付域负责支付本身,项目触发器负责“支付成功后这门生意该怎么走”。

oak-pay-business 真正负责什么

如果要正确设计项目支付系统,先要知道 oak-pay-business 已经帮你做了什么。

1. 它已经提供了完整支付主模型

核心对象至少包括:

  • Pay
  • Refund
  • Deposit
  • Account
  • Withdraw
  • WithdrawTransfer
  • System.payConfig
  • OfflineAccount
  • WpAccount
  • WpProduct
  • Settlement
  • SettlePlan

这些对象不是孤立存在的,而是已经通过 checker、trigger 和 watcher 形成了一条可运行的支付状态机。

2. 它已经提供了支付状态推进机制

项目真正发起支付时,正确路径不是“页面调用 SDK 成功后自己改状态”,而是:

  1. 创建 pay
  2. 执行 startPaying
  3. trigger 在 before 阶段调用渠道 prepay
  4. 回调或 watcher 再把 pay 推进到 paid / closed / refunding / refunded
  5. trigger 再把 orderdepositaccountsysAccount 往前推进

这套状态推进主要由这些文件负责:

  • src/checkers/order.ts
  • src/checkers/pay.ts
  • src/triggers/pay.ts
  • src/watchers/pay.ts
  • src/utils/pay.ts

3. 它已经提供了渠道抽象

真正和支付机构打交道的,不应该是页面,也不应该是项目 controller,而应该是 payClazz

oak-pay-business 当前默认已经内建:

  • account
  • offlineAccount
  • wpProduct
  • apProduct
  • epProduct
  • spProduct
  • cpProduct
  • wfProduct
  • ppProduct

并通过下面这些入口暴露扩展点:

  • registerPayClazz(...)
  • registerFrontendPayRoutine(...)
  • registerPayChannelComponent(...)

其中 ap/ep/sp/cp/wf/pp 的前端选择与跳转当前只在 Web 环境启用。新项目只有在接入这些内建合同之外的支付机构时,才需要扩展渠道层。

4. 它已经提供了后台和前台现成组件

最常直接复用的组件包括:

  • payConfig/system
  • order/pay
  • pay/detail
  • pay/list
  • refund/list
  • account/detail
  • deposit/new
  • withdraw/create
  • sysAccount/survey

因此一个新项目的正确思路,不是自己重搭管理后台,而是优先复用这些组件,再在项目层补壳。

一个项目的支付系统应该怎么分对象

这部分最容易设计错。下面是推荐的对象分法。

1. 订单对象只负责业务订单

订单对象应该表达的是:

  • 买了什么;
  • 应付多少钱;
  • 已付多少钱;
  • 已退多少钱;
  • 当前处于待支付、支付中、已支付还是退款中。

订单不应该自己塞一堆渠道协议字段,也不应该自己承担回调验签逻辑。

正确做法就是像 taicang/src/entities/Order.ts 一样,继承支付域 Order,然后只补业务字段。

2. 支付对象只负责一次资金动作

Pay 是一笔支付分录,不一定等于一个订单。

一个订单可能拆成多笔 pay

  • 一笔账户余额支付;
  • 一笔外部渠道支付;
  • 甚至多笔不同外部渠道支付。

因此项目不要把“订单”和“支付单”混成一个对象。真正的组合支付能力,就是靠订单下挂多笔 pay$order 实现的。

3. 系统对象统一承载支付策略

充值和提现手续费、系统可用渠道、系统资金账户这些内容,应该挂在 System,而不是挂在订单、用户或某个页面配置表里。

这一点 oak-pay-business/entities/System.ts 已经定好了,项目继续扩展这个对象即可。

4. 渠道对象统一承载支付机构配置

推荐分成两层:

  • 支付账号,例如 WpAccountOfflineAccount
  • 支付产品,例如 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/paypayNotify / refundNotify

这一步不要自己重写支付状态推进,否则后面的 watcher 和 trigger 会越来越难协调。

4. 支付成功后的业务动作

支付成功本身只是资金域事件,不应该直接写死在公共包里。

项目应该在自己的 triggers/pay.tstriggers/order.ts 里处理:

  • 这笔钱对应哪个业务对象;
  • 押金怎么消费或返还;
  • 订单之后如何结算;
  • 是否要创建物流或确认收货流程。

taicang 正是把这部分放在项目 trigger 里,而不是改 oak-pay-business 本体。

新项目最小落地清单

这是最值得直接照抄的部分。一个新项目要接 oak-pay-business,最少应该做下面这些事。

1. 依赖与实体

  • dependency.ts 里加入 oak-pay-business
  • 让项目的 System 继承支付域 System
  • 让项目的 Order 继承支付域 Order
  • 如果有账户或提现需求,接入 AccountWithdrawAccount

2. 初始化与上下文

  • dependency.ts 里声明 oak-pay-business,执行 project:initmake:domainmake:dep
  • 创建并注入 pay feature,当前模板由生成的 initialize.server.ts 处理
  • 调用 initializeOpb1Features(...)
  • 让前后端 RuntimeContext 继承 make:dep 生成的 Context;由 pay 的 module 递归带入 general Context

3. 系统配置后台

  • 直接挂 payConfig/system
  • 让运营可以配置:
    • System.payConfig
    • OfflineAccount
    • WpAccount
    • WpProduct
  • 如果需要,再挂 sysAccount/survey

4. 订单支付前台

  • 准备一个订单支付方案组件
  • 由它生成 pay$order
  • 父组件执行 order.startPaying
  • 外部支付统一进入 pay/detail

5. 支付回调

  • 项目里暴露自己的 endpoint 路由
  • 内部复用 oak-pay-business/utils/pay
  • 至少接上:
    • payNotify
    • refundNotify

6. 业务 trigger

  • 把支付成功后的业务差异逻辑写在项目自己的 trigger 里
  • 不要直接修改公共支付域的主状态机

什么时候应该扩展 oak-pay-business

并不是每个项目都要去注册新渠道。

不需要扩展的场景

如果项目只需要:

  • 账户余额支付;
  • 线下收款码或银行转账;
  • 微信支付;
  • 标准充值、退款、提现;

那么大多数情况下:

  • 公共实体够用;
  • 公共 payClazz 够用;
  • 公共前端支付 routine 也够用。

这时项目只需要做接入和业务 trigger,不需要扩展渠道层。

需要扩展的场景

如果项目要接:

  • 新支付机构;
  • 新的支付产品实体;
  • 新的后台支付配置页;
  • 新的前端拉起支付流程;
  • 新的系统资金账户展示;

才需要用这些扩展点:

  • registerPayClazz(...)
  • registerFrontendPayRoutine(...)
  • registerPayChannelComponent(...)
  • registerSysAccountCardTopComponent(...)
  • registerSysAccountDetailComponent(...)

推荐的接入顺序

实践里,最稳的顺序通常是:

  1. 先接实体和初始化
  2. 再接系统支付配置后台
  3. 再接订单支付前台
  4. 再接支付回调
  5. 最后补业务 trigger 和补偿规则

不要一上来先写页面。只把页面做出来,是最容易形成“能点支付但状态不对、回调不进、资金不平”的假接入。

taicang 最值得复用的经验

最后把最值得照抄的经验直接列出来。

1. 公共支付域和业务规则边界划得很清楚

  • 公共支付域负责支付本身;
  • 项目 trigger 负责拍卖押金和订单结算。

2. 页面不直接持有支付协议

  • 页面只创建 pay$order
  • 真正支付在 pay/detail 里拉起
  • 回调在 endpoint 里统一处理

3. 项目扩展点只落在必要位置

  • 需要自定义前台支付交互时,包一层本地组件
  • 需要业务差异时,写本地 aspect / trigger
  • 不去改公共支付域主链路

4. 小程序特殊规则不污染主状态机

像“确认收货后到账”这种微信小程序特性,在支付域里通过 needReceivingship 和前端确认收货流程承接,而不是把订单和支付状态机写乱。

最后再强调一次

一个 Oak 项目的支付系统,正确目标不是“把支付接口调通”,而是把下面这五件事一起接完整:

  • 资金对象模型
  • 状态推进机制
  • 支付渠道抽象
  • 回调与补偿
  • 项目自己的业务后处理

taicang 已经证明,这套方式是能跑通复杂业务的。新项目最不应该做的,就是绕开 oak-pay-business 重新做一套平行支付系统。那样前期看似快,后期几乎一定会在退款、补偿、回调、对账和系统资金上吃大亏。

结算

如果说 Order -> Pay -> Refund 解决的是“用户的钱怎么进来、怎么退回去”,那么 SettlePlan -> Settlement 解决的就是“这笔订单最终怎么结给内部账户或合作方账户”。

这块能力在很多项目里往往被放到单独的财务系统里,但 oak-pay-business 已经把它纳入同一套 Oak 模型:订单付款完成之后,可以继续生成结算计划,到时间后自动结算,并把金额记入目标账户。

主要对象

SettlePlan

src/entities/SettlePlan.ts 定义了:

  • when
  • order
  • price
  • settledAt
  • closedAt

状态包括:

  • unsettled
  • settled
  • closed

动作包括:

  • settle
  • close

Settlement

src/entities/Settlement.ts 定义了具体结算明细:

  • account
  • plan
  • price
  • opers
  • settledAt
  • closedAt

状态同样包括:

  • unsettled
  • settled
  • closed

动作也同样是:

  • settle
  • close

可以把它们理解成:

  • SettlePlan 代表“这张订单什么时候结、总共结多少”
  • Settlement 代表“这次结算实际要分到哪些账户、各是多少钱”

组件

这块能力当前和前面几章不一样:oak-pay-business/src/components 里并没有额外提供 settlePlan / settlement 的专用前端组件。

这并不代表功能不完整,而是说明它当前更偏向:

  • 用实体动作驱动
  • 用 trigger / watcher 自动推进
  • 页面层由具体业务项目自己组合

所以如果你的项目需要结算管理页,一般是在项目层基于 settlePlansettlement 这两个实体自己拼页面,而不是直接从公共包里拿成品组件。

真实项目里的页面拆法

虽然公共包没有现成结算组件,但 haina-busitaicang 已经把两种典型接法跑出来了。

1. taicang:面向前台用户的“待结算拍品页”

taicang/src/pages/frontend/settlement/web.tsx 的做法是:

  • 页面层只负责登录判断和 tabs 切换
  • 真正的待结算主体交给项目组件 components/spBid/settlement

也就是说,前台场景通常不是直接展示 settlePlan / settlement 明细,而是先围绕“还有哪些待结算业务对象”组织页面。

2. haina-busi:面向后台财务/运营的计划页和明细页

haina-busi 则单独做了两类项目组件:

  • components/squareBusiness/settlePlan/list
  • components/squareBusiness/settlement/list

它们已经把最常见的后台视角做出来了:

  • settled / unsettled / closed 切页签
  • 按账户、机房、组织等业务维度筛选
  • 展示 payAtwhensettledAtclosedAt
  • 展示比例、分账方向、目标节点等业务字段

这很值得参考,因为它说明了公共包的真实边界:

  • 状态推进与记账逻辑在公共包
  • 财务管理 UI 在项目层

后台规则

SettlePlan checker

src/checkers/settlePlan.ts 是这条线最关键的第一道约束。创建时会检查两件事:

  1. settlement.price 总和必须等于 settlePlan.price
  2. settlePlan.price 不能超过订单当前可结算金额

这里的订单可结算金额,源码里实际按下面这条式子算:

  • order.paid - order.refunded - order.settlePlanned

这就保证了:

  • 不会超额结算
  • 同一订单可以拆多个结算计划,但总额不会越界

SettlePlan trigger

src/triggers/settlePlan.ts 把整条结算流程串了起来。

1. 创建后更新订单的 settlePlanned

每创建一笔 settlePlan,都会把对应订单的 settlePlanned 累加上去。

2. 执行 settle 时自动结算所有 settlement

settlePlan.settlebefore 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 上。

因此它和订单的几个金额字段直接联动:

  • paid
  • refunded
  • settlePlanned
  • settled

实际开发里,更推荐把“什么时候结算、结算给谁”都收敛在 order 维度,而不是分散到每一笔 pay 上。

实体适配要求

这章如果只写流程,不写实体适配,很容易误导新手。真实项目里,结算实体往往都会继续扩。

haina-busi 的扩展方式

haina-busi 直接在公共实体上继续加了很多财务字段:

SettlePlan

在公共 SettlePlan 之上补了:

  • roomRevenue
  • systemRevenue
  • platformRevenue
  • installments
  • currentInstallment
  • isManual

Settlement

在公共 Settlement 之上补了:

  • orgSettlement
  • scale
  • type
  • accountSplitErrors
  • order
  • operEntitys

这说明公共实体给的是结算主骨架,复杂平台型项目通常还会补:

  • 分账类型
  • 分账比例
  • 多级组织结算关系
  • 金额误差与操作追踪

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

关键参数包括:

  • machineSystemId
  • settlementState
  • open
  • onCancel

它本质上是“某个业务范围内的结算计划列表”。

squareBusiness/settlement/list

关键参数包括:

  • accountId
  • entity
  • machineSystemId
  • settlementState

并且当 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

使用建议

结算这块最推荐的做法是:

  1. 把结算计划和结算明细一开始就建完整
  2. settlePlan 管总额和时间,用 settlement 管分配结果
  3. 让 watcher 负责到期自动执行,让 trigger 负责账户记账

再补四条在真实项目里非常重要的注意事项:

  • settlement$plan.price 总和必须始终等于 settlePlan.price,这是公共 checker 的硬约束,不是建议。
  • 只要要落到账,就必须先把目标 account 体系准备好,否则 trigger 在记 accountOper 时就没法闭环。
  • 如果你准备扩 SettlePlan / Settlement,尽量像 haina-busi 那样“在公共实体上继续加字段”,不要破坏公共主字段和动作语义。
  • when 不是必填,它可以代表“定时结算”,也可以完全留空,让项目自己的 shipordertrade trigger 来决定何时结算。

如果把这些逻辑拆到项目层零散页面或财务脚本里,很快就会出现订单金额、结算计划金额和账户流水三边对不上的问题。

框架更新日志

Oak Framework Changelog

按库、按版本、按 tag 区间记录框架变化

更新时间:2026-07-24。本章只记录会影响应用开发、发布、构建、升级和运行时行为的框架包变化。

记录口径:每个仓库都读取 package.json 当前版本、最新 tag 和上一个 tag,并结合 git diff --name-onlygit diff --statgit log --no-merges 汇总。这里不是只按提交标题写摘要。

快速入口

版本索引

当前 package最新 tag本章覆盖区间
oak-domain6.0.16.0.05.1.36..6.0.06.0.0..HEAD
Oak Assistant3.5.0v3.5.03.0.0..3.5.0
oak-cli5.0.65.0.15.0.0..5.0.15.0.1..HEAD
oak-db4.0.24.0.14.0.0..4.0.14.0.1..HEAD
oak-backend-base5.0.15.0.04.1.29..5.0.05.0.0..HEAD
oak-frontend-base6.0.46.0.36.0.1..6.0.36.0.3..HEAD
oak-general-business6.1.06.0.05.11.2..6.0.06.0.0..HEAD
oak-pay-business4.1.04.0.03.5.1..4.0.04.0.0..HEAD
oak-common-aspect4.0.34.0.24.0.1..4.0.24.0.2..HEAD
oak-memory-tree-store4.0.24.0.14.0.0..4.0.14.0.1..HEAD
oak-external-sdk3.0.23.0.13.0.0..3.0.13.0.1..HEAD
oak-internal-sdk1.1.51.1.41.1.3..1.1.41.1.4..HEAD
oak-ui0.1.0无 tag当前仓库状态

最近 14 天仓库审计

审计窗口为 2026-07-10 00:00 至 2026-07-24,先对所有 oak-* Git 仓库执行 fast-forward pull,再读取提交、变更文件、公开类型和测试。提交数量只用于确认审计覆盖,不直接代表功能数量。

仓库提交数审计结论
oak-assistant0旧扩展仓库没有新提交;当前用户应安装 oak-team.oak-assistant-new
oak-assistant-new563.0 - 3.3.2 完成实体、render props、WXML、Less、i18n 与调试语言服务;审计窗口后的 3.4.0 - 3.5.0 见独立更新日志。
oak-backend-base2最近提交是切换 Oak CLI 编译和 render 检查;运行时新增能力已在该库既有未发布区间记录。
oak-book7平台、独立项目和新手组件树文档集中更新。
oak-cli97WXML/LESS/render 编译器、按需 workspace、Desktop、Native、CDN、MP polyfill、独立项目模板是主要变化面。
oak-common-aspect1构建链切换与 relation select 结果断言,没有新增业务入口。
oak-db1数据库驱动改为优先从消费应用根解析。
oak-domain10compiler plugin、Desktop metadata/路由、命名 locale workspace 和无业务依赖上下文。
oak-external-sdk0无新提交。
oak-frontend-base13Desktop web runtime、离线 locale/外观、pull-to-refresh 与 render 类型推导。
oak-general-business23Desktop Application 解析、Native 认证 render,以及严格 render/XML/Less 迁移。
oak-internal-sdk3connector 原始错误日志脱敏和测试补充。
oak-matrix10部署计划、Git 同步和 Native/Desktop 参考实现;属于消费应用示例,不作为框架公共 API。
oak-memory-tree-store2toolkit、事务字面量字段清理和版本同步,没有新增公开方法。
oak-pay-business16严格 render/XML/Less 迁移、过滤列表 product 草稿初始化,以及移除不支持的 Alipay 独立退款 callback。
oak-skills171代理知识库持续同步;用于核验线索,不作为运行时能力计数。
oak-test0无新提交。
oak-tutorial-todo10Group/Todo 组件树和 Web、MP、Electron、Native 教程参考项目;本地仓库暂无 origin。
oak-vite-example0无新提交。

阅读建议

升级项目:先读 Breaking Changes,再看项目实际依赖的包。
发布 Oak 模块:优先看 oak-domainoak-cli,重点确认 es/entities、声明文件和值产物是否完整。

Breaking Changes

Upgrade Risk Map

先看会破坏构建、发布或运行语义的变化

普通 bugfix 和内部重构不放在这里;这里只列需要升级负责人主动处理的框架行为变化。

oak-domain 6.0.1 未发布:生产依赖实体必须能从声明和值合并解析

发布包风险es/entities 必须同时提供声明和值产物。

依赖实体解析已经支持 .d.ts + .js 合并,不再要求第三方包发布 src。生产包应发布 es/entities/*.d.tses/entities/*.js,以及这些声明依赖到的必要类型声明。

解析顺序是 es/entities -> lib/entities -> src/entitieslib/entities 只是历史兼容回退,未来包可以只保留 es

oak-domain 6.0.1 未发布:同名实体覆盖必须兼容旧结构

兼容性风险覆盖默认实体不是自由替换。

依赖包如果重新定义了默认实体,例如 UserUserEntityGrantSystem,编译器会用新实体覆盖已有定义。

覆盖实体必须兼容旧实体的字段、动作、状态、关系、索引、ActionDefentityDesc.localesstyle,否则旧逻辑会在权限、checker、trigger、类型生成或 UI 展示中出现不可预测问题。

oak-domain 6.0.1 未发布:Decimal / Price 字段改为字符串精度语义

数据语义风险金额和高精度小数字段不要再当成普通 number。

Decimal<P, S>Price 现在按字符串保存和传递精度,过滤类型也补充了 Q_DecimalValue。业务代码里直接做 +-*/ 或把 decimal 字段传给只接受 number 的格式化函数,可能出现字符串拼接、精度丢失或类型错误。

升级时应把 decimal / price 的计算改成 decimal helper 或显式转换;写入、过滤和 update expression 使用字符串值或 $expr 包装。

oak-domain 6.0.0:编译器输出和依赖初始化规则调整

生成物风险需要重新生成 dependency、domain 和 feature 初始化入口。

6.0.0 重构了 schema、dependency、locale、router、tsc 等编译链路,并把依赖包 feature/init metadata 作为发布包元数据处理。

旧项目如果依赖本地 src 或手写初始化拼接,需要按 project:initmake:domainmake: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 和小程序路由语义收紧

构建风险生产包不要再依赖 src/entities

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.tsallNamespaceConfigs.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。旧脚本或教程如果假定新项目必然存在 wechatMpnative,应改为先执行 oak-cli add mp|rn|desktop。小程序 Node polyfill 也不再默认带入完整 crypto / assert 链;项目显式恢复后必须重新检查主包体积和真机启动。

oak-backend-base 5.0.1 未发布:connector-backed free endpoint 上下文语义变化

运行时风险useConnector 决定 free endpoint 是否解析 oak-cxt

普通 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 和小程序路由进入运行时边界

运行时风险初始化 provider、权限边界、小程序 route map 和组件入口都需要复核。

Web 初始化现在可以接收 options object,并在标准树中统一挂载 RouteAccessProviderNamespaceConfigProvider、AntD / AntD Mobile provider 和 Oak theme shell。升级时应把 CLI 生成的 namespaceConfigsoakThemerenderLoading、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.../indexswitchTab 会丢弃 query / state 并输出 warning。直接调用裸 wx.*、手写生成产物路径、或混用 namespace path 与业务 canonical route 的旧代码需要复核。

pageHeader2 已移除,继续导入它会构建失败;改用 @oak-frontend-base/components/pageHeaderListPro 仍使用 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 未发布:系统翻译和支付系统扩展会影响覆盖实体

业务兼容风险System、支付状态和翻译 action alias 必须保留。

通用业务包新增系统翻译、区域 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 需要 translationtranslateStatetranslationError,以及 translate / translateSuccess / translateFail 动作。共享 components/system/panel 会投影翻译字段并挂载翻译页签;应用侧如果覆盖了 System 却没有同步这些字段,系统面板、翻译 trigger 和翻译定时任务都会出错。

6.1.0 的升级 SQL 会创建 localizedContentareaLocaleuserAuthinviteinviteTouchinviteRelation,并从 user 表删除旧的 nationalityidCardTypeidNumberidState 字段。已有实名认证数据不能只靠 schema upgrade 保留,升级前需要写清楚历史数据迁移和回滚策略。

旧的 @oak-general-business/components/user/authenticate 已移除,实名认证入口迁到 @oak-general-business/components/userAuth/upsert/indexuserAuth 实体。消费项目里本地 /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/themeoak-general-business/types/Theme 或旧主题设置组件引入会失败;应用应改用 oak-frontend-basefeatures.themeoakTheme、CSS 变量和主题设置组件。

多包同步:React/TypeScript/peer 版本需要整体升级

依赖风险不要只升级单个 Oak 包。

近期多个包同步了 React 19、TS6 参数、peer 依赖和 toolkit 替换 lodash。项目应按实际依赖整体对齐,否则可能出现类型通过但运行时依赖不一致的问题。

oak-common-aspect 4.0.3 把 oak-domainoak-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 导出;运行时代码如果从顶层解构 WechatMpInstanceWechatPublicInstance 等构造类会失效。WeChat externalRefreshFn 也从返回 token 字符串改为返回 { access_token, expires_in },自定义 token 刷新实现必须同步调整。

oak-internal-sdk 1.1.5 新增加密 SSE endpoint 支持,前端 callSSEEndpoint 和服务端 serializeSSEEndpointResult 需要配套升级。只升级一端时,SSE data: 可能被错误地当作普通 JSON 或加密 JSON 解析;自定义网关和 CORS 也要放行 oak-encryptedoak-nonce 等响应头。

Oak Assistant 更新日志

当前扩展3.5.0
最新 tagv3.5.0
Marketplace IDoak-team.oak-assistant-new

3.5.0(2026-07-25)

已发布复用 Oak 组件的本地 render 也能继承原组件的 props 合同。

复用组件的 Render Props

  • 当本地 index.ts 使用 import OakComponent from '某个组件'; export default OakComponent; 直接转发已有 Oak 组件时,同目录的标准 render 可以继承原组件对应平台 render 的 props,不必重新手写 WebComponentProps
  • 本地源码组件会继续追踪到真正的 OakComponent({...}) 定义;已发布的依赖包则从 web.d.tsrender.native.d.tsrender.desktop.d.ts 等平台 render 声明读取合同。
  • 编辑器支持继承字段的补全、hover、诊断和定义跳转。平台专用声明不存在时,会按 CLI 的平台规则回退,例如 web.pc -> webrender.android -> render.nativerender.windows -> render.desktop -> web.pc -> web
  • 识别是有意收紧的:默认导入必须命名为 OakComponent,并由 export default OakComponent 直接导出。改名导入、二次赋值或动态包装不会被猜测为复用合同。

3.4.0(2026-07-24)

已发布Native render 获得 Sass Module 编辑期类型与样式诊断。
  • render.native.tsxrender.ios.tsxrender.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)

已发布Oak 编辑期检查从小程序 XML 扩展为实体、render、Less 和 i18n 的完整语言服务。

实体 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.tsxweb.pc.tsxweb.mobile.tsxrender.desktop.tsxrender.native.tsx 等标准 render 会直接使用同目录 OakComponent 推导出的精确 props。
  • propertiesdataformDatamethods 和 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 语言服务

  • 支持标签、原生属性、事件、微信指令、usingComponentscomponentGenerics、模板 import/include、资源路径、Rainbow Tags 与格式化。
  • 对标签配对、wx:if/elif/elsewx:for 词法作用域、wx:key、class、资源、事件方法和组件属性执行诊断。
  • 虚拟 TSX 会保留循环项、dataset、事件和组件 props 的真实 TypeScript 类型,并把补全、hover、定义和诊断映射回 XML/WXML。
  • Oak 组件与微信原生 Component 会被区分;只有 Oak 组件获得 oakPathoakId 等 runtime props。
  • 插件使用 VSIX 内置的 Oak CLI 编辑器 runtime,不读取项目旁边的 CLI checkout、全局 CLI 或 OAK_CLI_ROOT,从而保证扩展发布版本的行为可复现。

i18n 检查与跳转

  • 标准 render 的 t(...)、OakComponent 的 this.t(...) 和 XML/WXML 的 t(...) 使用同一套检查。
  • 支持组件 locale、common::keyentity: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

使用边界

TypeScript 版本:完整实体语义和 render props 集成依赖 tsserver plugin。当前 TypeScript 7 / tsgo 不能加载同等插件,应在 VS Code 中选择项目的 TypeScript 6 或更低版本并关闭 js/ts.experimental.useTsgo。tsgo 下仍保留 metadata、原生组件和基础 XML 能力。
插件不是构建替代品:编辑器只检查当前项目和当前打开文件的语言服务状态;提交前仍需执行项目的 npm run buildmake:domain 和目标平台构建。

完整安装与排错步骤见开发前必装:Oak Assistant

oak-domain 更新日志

当前 package6.0.1
最新 tag6.0.0
覆盖区间5.1.36..6.0.0 / 6.0.0..HEAD

6.0.1 未发布(区间:6.0.0..HEAD

未发布package 已到 6.0.1,latest tag 后仍有编译器变更。
  • 编译器新增发布包实体解析模块,依赖实体入口按 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 结构。
  • 新增 OakPackage metadata 类型和读取规则:oak.business.moduleoak.business.dependenciesoak.business.featureInitoak.compiler.transform 成为规范字段,旧的 isLibdepLibfeatureInitneedOakTransform 只作为兼容回退。
  • LocalizedContent 进入基础域,StorageDesc.localizedContentSelectOption.localizedContentBackendRuntimeContext.getLocale() 和 selection/result rewrite 链路补齐,支持按当前 locale 自动注入翻译内容。
  • Language 扩展为 zh_CNen_USes_ESfr_FRar_SAru_RUQ_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 route meta.titletitleI18nKeyaccessnamespaceKeynamespacePath,并识别 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 改用轻量实现,移除旧 bundled whatwg-url
  • validator 拆分为 utils/validate/*,补充身份证、手机号、国籍、护照等独立入口;内部工具从 lodash 切到 es-toolkit / utils/toolkit
  • 修复 update expression、unset 带点字面量 key、继承索引冲突、继承 enum locale 合并、runtime action def clone、自定义检查器 i18n 诊断等问题。
主要 diff 文件:src/compiler/entitySource.tssrc/compiler/entityDescEnum.tssrc/compiler/schemaBuilder.tssrc/compiler/dependencyBuilder.tssrc/compiler/localeBuilder.tssrc/compiler/routerBuilder.tssrc/types/OakPackage.tssrc/entities/LocalizedContent.tssrc/utils/localizedContent.tssrc/utils/decimal.tssrc/utils/fetch/*src/utils/url/lightweight.tstest/*

6.0.0(tag:6.0.0,区间:5.1.36..6.0.0

已发布6.x 编译链路重构版本。
  • schema 编译支持多继承与 inherited metadata,继承来的动作、状态、索引、locale 和 style 会进入生成结果。
  • dependency / feature 初始化输出重构,依赖包的初始化 metadata 下沉到发布包语义,不再依赖前端模式下的临时 init 文件。
  • locale 编译支持依赖包发现和英文 locale 输出,为后续系统翻译能力打基础。
  • tscBuilder 增加 alias emit / watch 支持,routerBuilder 增加 cache mode。
  • 新增一批 customChecks,并补充 dependency feature 初始化说明文档。
主要 diff 文件:src/compiler/dependencyBuilder.tssrc/compiler/schemaBuilder.tssrc/compiler/routerBuilder.tssrc/compiler/localeBuilder.tssrc/compiler/tscBuilder.tssrc/compiler/customChecks/*docs/dependency-feature-init-metadata.md

升级注意

发布包要求:发布 Oak 模块时不要发布 src 来补编译器能力。应保证 es/entities 中同时有实体声明和值产物,并把声明依赖到的类型文件一起发布。
覆盖实体:如果模块覆盖默认实体,必须保持旧实体兼容,尤其是动作、状态、关系、ActionDefentityDesc.localesstyle
Decimal 语义:decimal / price 字段现在按字符串保精度处理。业务代码不要再把这类字段当普通 number 做四则运算,过滤、更新和表达式更新都应使用字符串或 decimal helper。
包元数据:新包和升级后的共享包应迁移到 oak.business.*oak.compiler.transform。旧字段仍兼容,但不要继续把通用业务包硬编码到 app 的 extraOakModules 或编译器分支里。

oak-cli 更新日志

当前 package5.0.6
最新 tag5.0.1
覆盖区间5.0.0..5.0.1 / 5.0.1..HEAD

5.0.6(区间:5.0.1..HEAD

tag 后持续更新server bundle、严格 render/XML/Less 编译、Desktop、页面配置、前端图标库和小程序 Vite 是主要变化面。
  • 后端新增 esbuild server runtime bundle 链路:模板提供 opt-in build:bundle,输出 dist/server.jsdist/package.jsondist/server-metafile.jsondist/target/*dist/configuration/*dist/oak-packages/*,Oak 包按 package.json.oak.package 元数据镜像,不再硬编码包名。项目自己的 pm2.*.json 按原名复制,不再生成固定 pm2.prod.config.json
  • server bundle 会保持根级 configuration/*.json 为运行时外部配置,生成的 server.js 会设置 NODE_ENVOAK_PLATFORM=serverOAK_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:新增 CreatePageConfigCreateComponentConfigCreateNamespaceConfig,路由生成会写入 route.accessmeta.titletitleI18nKeynamespacePath 和 namespace 菜单配置,生成物包括 allRouters.tsallNamespaceConfigs.ts
  • route.access 类型扩展为 publicloginrootdenyrefrelationoperationanyOfallOf 和数组简写;namespace 可声明 route.pathfirstnotFoundparams、菜单分组和运行时 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.iconLibrariesfrontend.targets.*.iconLibrariesfrontend.workspaces.*.iconLibraries,namespace config 也可以声明 iconLibraries。配置支持 fontreactimage 三类图标库;font 可提供 stylefontUrl + iconsreact 可通过 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.dedupeoptimizeDeps.include 的 package 收集。
  • Web / MP --analyze 统一到 Oak 自研 bundle analyzer,支持 bundle treemap、源码目录、NPM 依赖、小程序分包统计、反向依赖图、稳定颜色和 canvas 缩放交互。
  • 小程序配置从页面列表继续收敛到 package.config.ts:支持 namespace 页面范围、生成 PagesDefinetypings/oak-wechat-mp-pages.d.tspagessubPackagestabBarentryPagePathpreloadRule 获得类型约束。
  • 小程序 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 虚拟生成,usingComponentscomponentGenericscomponentPlaceholder 会按主包 / 分包输出位置重写;缺少组件 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 的 entityisListpropertiesformDatamethods 推导 props.data / props.methods。如果 index.ts 采用 import OakComponent from '组件'; export default OakComponent; 直接复用已有组件,CLI 会继续追踪本地源码合同,或从依赖包对应平台的 render 声明继承合同;本地直接调用 OakComponent({...}) 时仍以本地定义为准。
  • 复用组件的平台声明按 render 入口回退:web.pc/web.mobile -> webrender.ios/render.android -> render.nativerender.desktop -> web.pc -> webrender.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>:androidbuild:native:<name>:ios production 脚本;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:默认保留 processglobalBuffernode: imports,同时排除容易放大主包或触发真机兼容问题的 cryptoassert;项目可用完整 include 白名单显式恢复需要的 built-in。
  • 小程序最终产物可通过 vite.mp.plugins.buildin.compressImage 压缩 PNG、JPEG、WebP、AVIF;压缩器从消费项目加载 sharp,未安装时 warning 并跳过。
  • Web Vite 保留可选 PWA 接入,但当前 Vite 8 模板不会安装存在 peer 冲突的 vite-plugin-pwavite.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 编辑器聚合配置。
主要 diff 文件:src/server/server-build.tssrc/server/start-routes.tssrc/createConfig.tssrc/shared/create-compiler-config.tssrc/shared/template.tstooling/config/oakPackage.shared.jstooling/config/utils/injectGetRender.jstooling/config/utils/frontendIconLibraries.jstooling/config/utils/frontendIconCss.jstooling/config/mp/workspace-config.jstooling/plugins/OakViteWebCdnPlugin.jstooling/plugins/ViteRouterBuilderPlugin.jstooling/plugins/OakViteMpRouteMapVirtualPlugin.jstooling/plugins/ViteWechatMpPlugin.jstooling/plugins/wechat-mp-plugin/*tooling/scripts/upgrade-legacy-app.jsscaffold/*docs/*

5.0.1(tag:5.0.1,区间:5.0.0..5.0.1

已发布create / compiler config 和 Vite 依赖预构建调整。
  • create 流程的 compiler config 改为分层生成,减少模板和运行配置互相污染。
  • Vite 类型支持和 web optimizeDeps 默认预构建依赖更新。
  • scaffold package dependency 与 smoke 配置做了修正。
主要 diff 文件:src/shared/create-compiler-config.ts、Vite / webpack 配置模板、smoke 测试。

升级注意

生产发布:发布包实体入口应优先保证 es/entities 完整。不要为了 make:domain 在生产依赖里发布 src
包元数据:共享 Oak 包应把业务模块、feature 初始化和编译 transform 迁移到 oak.business.* / oak.compiler.transform。项目侧 extraOakModules 更适合保留给非标准本地覆盖。
小程序路由:历史代码中直接拼生成产物路径的地方,应优先改为 Oak navigator 或能被 route map 静态识别的调用。switchTab 不能携带 query / state,升级时应检查旧导航代码。
Web CDN:Vite Web 不再默认 external React 等依赖。需要 CDN 的项目必须在 oak.config.ts 显式配置内置 CDN 插件和对应 UMD / global 信息。
图标库:页面、菜单和 namespace 配置里的图标继续保持字符串形态,例如 oak:setup_filltrip:hotelantd:ShopOutlined。不要把 ReactNode 写进 index.config.ts;React 图标库应通过 frontend.iconLibraries 声明,让编译器生成按需加载代码。使用 fontUrl 时必须同时提供 icons 映射,否则无法生成业务 glyph class。
配置生成物:index.config.ts 是页面、组件和 namespace 的结构化配置入口;allRouters.tsallNamespaceConfigs.ts 和小程序虚拟 JSON 仍是生成物,不要手工修改。

oak-db 更新日志

当前 package4.0.2
最新 tag4.0.1
覆盖区间4.0.0..4.0.1 / 4.0.1..HEAD

4.0.2 未发布(区间:4.0.1..HEAD

未发布migration 和 SQL translator 是主要变化面。
  • schema migration 不再输出数据库物理外键 SQL,ref 语义改由列注释中的 oak_ref: 元数据保存和反查;初始化阶段也不再单独补外键。
  • index.config.unique 现在被视为 Oak 框架层语义,MySQL / PostgreSQL 建表和 migration 不再生成 UNIQUE INDEX DDL;历史唯一索引会被规划为普通索引重建。
  • 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 开关;MySQL disconnect() 会先回滚仍挂起的事务连接再关闭连接池。
  • 新增 SQLite store 与 schema migration 支持,并把数据库驱动改为按需动态装载;安装检查只要求 MySQL、PostgreSQL、SQLite 中至少存在一个兼容驱动,消费端不使用的可选驱动不会被根入口强制加载。
  • 驱动解析同时以 process.cwd() 和 oak-db 自身目录为查找根。即使 oak-db 通过 file:、workspace/link 或发布包安装,只要消费应用根安装了兼容的 mysql2pgbetter-sqlite3,运行时就能解析;不要求驱动成为 oak-db 的直接依赖。
  • XOR filter SQL、JSON path predicate、JSON boolean predicate、unset 带点字面量 key、translateCreateEntity 结束分号等修复同步进存储层。
  • 新增 VitePress 版 oak-db 使用指南,补充 query、aggregate、JSON、update expression、migration、方言边界和真实数据库测试覆盖。
主要 diff 文件:src/sqlTranslator.tssrc/MySQL/*src/PostgreSQL/*src/migration.tssrc/utils/*test/*docs/*

4.0.1(tag:4.0.1,区间:4.0.0..4.0.1

已发布该 tag 主要是版本发布标记和依赖版本对齐,未发现独立的行为变化。

升级注意

外键语义:如果项目以前依赖数据库物理外键兜底一致性,需要把约束迁移到 Oak checker / trigger,并确认引用字段索引足够支撑查询。升级后 ref 关系主要通过 Oak schema 和列注释元数据维持,不再通过数据库 FK 约束维持。
唯一索引语义:index.config.unique 不再对应数据库唯一索引。需要硬性唯一约束的项目必须在 Oak checker 或业务规则里实现,否则历史数据库唯一索引在迁移收敛时可能被重建为普通索引。

oak-backend-base 更新日志

当前 package5.0.1
最新 tag5.0.0
覆盖区间4.1.29..5.0.0 / 5.0.0..HEAD

5.0.1 未发布(区间:5.0.0..HEAD

未发布server bundle runtime、routine 动态注册、endpoint 上下文和同步 trigger 是主要变化面。
  • AppLoader / ClusterAppLoader 构造函数新增 AppLoaderOptions,可传入 runtimeManifest。server bundle 可直接注入依赖顺序、同步配置、domain schema / actionDef、BackendRuntimeContext、runtime modules、ports 和 socket entry,不再强依赖项目 lib/* 文件路径逐个 require
  • runtime module 合并入口覆盖 exceptionsrelationaspectstriggerscheckersattrUpdateMatrixdataendpointswatcherstimersstartRoutinesstopRoutines;ports 也支持多份配置合并注册。
  • AppLoader 暴露运行时注册接口:registerTrigger / unregisterTriggerregisterChecker / unregisterCheckerregisterWatcher / unregisterWatcherregisterTimer / unregisterTimer / rescheduleTimerDbStore 同步透出 trigger / checker 注销能力。
  • start / stop free routine 收到的 runtime env 从 { socket, contextBuilder } 扩展为完整注册控制对象,routine 可以在启动或停止阶段动态挂载、移除、重排 trigger / checker / watcher / timer。
  • watcher 初始化改为 registry 模式:startWatchers() 首次初始化后从 registry 读取,支持后续动态注册;lazy watcher 的 skip-once 状态集中保存,注销 watcher 时会同步清理 skip 状态和执行中标记。
  • timer 调度拆成 executeTimerscheduleTimerregisterTimerunregisterTimerrescheduleTimer,重复 timer 名会立即报错,非法 cron 创建失败也会明确抛错;unmount() 统一通过 unregisterTimer 取消任务。
  • free endpoint 增加 connector metadata 透传:getEndpoints() 会把 protocoluseConnector 作为 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/toolkitoak-common-aspectoak-dboak-domain 转为 peer dependencies 并对齐 oak-domain 6.0.1;TypeScript / Vitest 等开发依赖升级,TS6 参数兼容同步进入源码和声明。
主要 diff 文件:src/AppLoader.tssrc/ClusterAppLoader.tssrc/DbStore.tssrc/Synchronizer.tssrc/types/runtime-manifest.tssrc/routines/i18n.tssrc/utils/requirePrj.tstest/test_sync_lock/index.tspackage.json

5.0.0(tag:5.0.0,区间:4.1.29..5.0.0

已发布5.x upgrade 和后端上下文迁移版本。
  • upgrade 命令、upgrade table、migration plan、rollback artifacts 和执行顺序重构。
  • 后端运行时上下文切到 oak-domain 提供的 BackendRuntimeContext
  • 初始化数据工具和 dbPriority 配置读取路径整理。
  • i18n 支持注入,不再作为后端基础包的硬依赖。
主要 diff 文件:src/upgrade.tssrc/routines/update.tssrc/dbPriority.tstest_upgrade/*

升级注意

升级流程:升级 5.x 时应重新跑 upgrade 流程并检查 rollback artifacts。后端启动脚本如果手写 DB 配置读取顺序,需要对齐 dbPriority 的当前规则。
endpoint 上下文:普通 free endpoint 仍不会自动继承调用方上下文;只有声明 useConnector: true 的 free endpoint 会从 connector 解析出的 oak-cxt 初始化 context。匿名 endpoint 如果依赖 application / user / rootMode,仍应显式确认上下文来源。
动态注册:routine 现在可以动态注册和注销 watcher / timer / trigger / checker。注册名仍必须保持唯一,注销 watcher 会清理该 watcher 的 skip-once 和执行中标记。

oak-frontend-base 更新日志

当前 package6.0.4
最新 tag6.0.3
覆盖区间6.0.1..6.0.3 / 6.0.3..HEAD

6.0.4 未发布(区间:6.0.3..HEAD

未发布web 初始化、route access、主题、OakIcon、小程序路由和基础组件是主要变化面。
  • 增加 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:namespaceConfigsrenderErrorrenderLoadingrenderProvideroakThemeiconLibrariesantdConfigProviderPropsantdMobileConfigProviderProps 都进入标准初始化流程。
  • 标准 web app tree 和初始化错误 tree 都会挂载 RouteAccessProviderNamespaceConfigProvider、AntD ConfigProvider、AntD Mobile ConfigProviderStyleProvider 和 Oak theme shell,应用侧可以通过 useNamespaceConfig(...) 读取 CLI 生成的 namespace 配置。
  • 新增 RouteAccessBoundaryresolveRouteAccess(...)route.access 支持 publicdenyrootloginrefrelationoperationanyOfallOf 和数组简写;operation 会调用 features.cache.checkOperation(...),并可通过 $context.entity / $context.entityId 引用当前 console 上下文。
  • namespace 配置支持 access.unconfiguredAccessfeatures.console.contextEntities。当前 console context 不在 namespace 允许范围内时,relation access 和依赖 $context.*operation access 会返回 contextMismatch
  • oak-frontend-base/config 导出 CreatePageConfigCreateComponentConfigCreateNamespaceConfig,内置组件的小程序 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:OakThemeOakThemeProvidermergeOakThemeoakThemeToCssVarsoakCssVarsToCssTextoakTokenVars 成为共享 token / CSS 变量契约;features.theme 可以持久化 theme-mode、主色和 oakTheme,并兼容迁移旧的 ogb:feature-theme-state;Web 初始化会把 Oak token 同步成 AntD ConfigProvider.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.io auth.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 标准导出,补充 DOMExceptiongetRandomValues 等运行能力;socket.io 小程序端改为 ESM repack,减少体积并修复导出。
  • 新增轻量 / 专项 ECharts 小程序 canvas 组件:ec-canvas-basiclinebarpiegaugemap 等,避免所有页面都引入完整 chart runtime。
  • pageHeader 成为唯一维护的页面头部实现,pageHeader2 被移除;web 端返回按钮判断会优先读取 namespace menu,再回退到 contextMenuFactory.menus,支持相对 menu path 和 namespace-prefixed path 匹配。
  • ListPro 使用 runningTree loading 作为默认 loading / reload 状态,继续渲染 Oak 自有 Pagination,并断言不接受 tablePagination;列表行操作按钮修复“执行后仍显示无权限动作”的问题,并增加紧凑操作按钮配置。
  • Pagination 组件重写布局和文案,补充中英文 locale、右对齐样式、showSizeChangershowQuickJumper、自定义 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。
主要 diff 文件:src/platforms/web/initialize/*src/platforms/web/RouteAccessBoundary.tsxsrc/platforms/web/NamespaceConfigProvider.tsxsrc/features/routeAccess.tssrc/config/index.tssrc/theme.tssrc/features/theme.tssrc/features/cache.tssrc/features/socket/*src/features/mpRouteMap.tssrc/features/navigator.mp.tssrc/features/runningTree.tssrc/types/Icon.tssrc/utils/iconLibrary.tssrc/components/icon/*src/components/pageHeader/*src/components/listPro/*src/components/pagination/*src/components/searchPanel/*src/components/filterPanel/*src/utils/errorPage.tssrc/miniprogram_npm/ec-canvas-*test/routeAccess.test.tstest/mpRouteMap.test.tstest/cacheSSEEndpoint.test.tstest/iconLibrary.test.ts

6.0.3(tag:6.0.3,区间:6.0.1..6.0.3

已发布该区间主要是版本发布、构建和依赖对齐,未发现独立的大行为变化。

升级注意

Web 初始化:新项目应使用 options object 传递 namespaceConfigsoakThemerenderLoading、AntD provider props 等配置。不要在应用侧重复包一层全局 RouteAccess / NamespaceConfig / AntD provider,除非确实要隔离一个子树。
route access:operation.target.checkerTypes 是前端合法性探测的 checker 范围字段;依赖 console 上下文的规则应声明 $context.entity / $context.entityId,并确认 namespace 的 features.console.contextEntities 范围。
小程序路由:历史小程序代码如果直接拼生成产物路径,或把 namespace 路由和业务 canonical route 混用,需要回到 Oak navigator 或当前 route map 能识别的静态调用方式。显式 route map 存在时缺失路由不会自动猜测,switchTab 也不会保留 query / state。
组件迁移:pageHeader2 已移除,改用 pageHeaderListPro 不接受 tablePagination,共享分页样式应改 Oak 自有 components/pagination
OakIcon:菜单、页面和 namespace 配置中的图标应继续写字符串,推荐显式使用 oak:*<library>:*。Web React 图标库只在 web 运行时可用,小程序侧需要使用 font 图标库;使用 fontUrl 的库仍需要配套 icons glyph 映射。
主题:Oak 自有组件主题应走 oakTheme / features.theme / --oak-* CSS 变量;AntD / AntD Mobile 的 provider props 只用于三方 UI 库配置。

oak-general-business 更新日志

当前 package6.1.0
最新 tag6.0.0
覆盖区间5.11.2..6.0.0 / 6.0.0..HEAD

6.1.0 未发布(区间:6.0.0..HEAD

未发布系统翻译、HumanVerify、邀请归因、UserAuth、Domain 路由和配置面板是主要变化面。
  • 新增系统翻译链路:System 增加 translationtranslateStatetranslationError,并增加 translate / translateSuccess / translateFail 状态机动作;utils/systemTranslation 会按系统翻译配置调用 OpenAI-compatible LLM,将目标实体文本写入 localizedContent,通过 digest 跳过未变更内容,并保留人工翻译。
  • 新增系统翻译运行时和 UI:start / stop routine 会注册和注销系统翻译 runtime env,system trigger 会在翻译配置或 LLM 配置变化后维护定时任务,components/system/translationcomponents/system/translationManage 支持配置翻译实体、筛选缺失 / 过期 / 已翻译内容、人工编辑翻译和触发翻译动作。
  • 新增 localizedContentareaLocale:区域名称增加 en_USes_ESfr_FRar_SAru_RU 入库语言,data/areaLocale 同步新增多语言数据文件,areaLocalearea + language 建唯一索引,供 Area picker 和多语言展示读取。
  • 新增 LLM 配置能力:Config 增加 LlmOpenAICompatibleLlmConfig 支持命名、extraBodytestExtraBody 和默认 60000ms timeout,配置面板提供 LLM 账号维护和 testLlmConfig(...) 测试入口。
  • 新增 HumanVerify 人机校验体系:内置 debugturnstilealtcha provider,内置场景覆盖账号登录、登录名注册、手机验证码和邮箱验证码;系统配置可按场景设置 observe / enforceminScoreaction 和 provider 错误策略。
  • HumanVerify 前后端注册面补齐:后端默认注册 debug / Turnstile / ALTCHA provider,并开放 humanVerify/altcha/challenge free endpoint;前端增加 features.humanVerifyoak-humanVerifyHost 全局组件、provider bundle / client / acquire component / config component 注册入口,配置页内置 debug、Turnstile、ALTCHA 面板。
  • 登录、注册和验证码流程接入 HumanVerify:loginByAccountregisterUserByLoginNamesendCaptchaByMobilesendCaptchaByEmail 会校验传入的 proof;前端 features.token 方法增加可选 humanVerify 参数。
  • registerUserByLoginName 变为“注册后立即登录”:后端创建用户和 loginName 后会建立 token,返回 tokenValueinviteTouchId,前端注册成功后直接进入登录态。
  • 新增邀请归因链路:新增 inviteinviteTouchinviteRelation 实体和 getMyInvite / touchInvite aspect;前端 features.invite 会把未登录访问的 touch 暂存在 localStorage,登录、注册或 token materialize 后写入关系并清理 pending touch。
  • 邀请二维码自动化:创建 invite 时会按目标应用类型创建关联 wechatQrCode,支持 wechatPublic 服务号、带 qrCodePrefix 的小程序 domain URL,以及普通小程序码。
  • 用户实名认证从 user 字段迁到独立 userAuth 申请实体:新增 userAuth 状态机、审核字段、附件关系和 components/userAuth/* / template/my/userAuth,旧 components/user/authenticatetemplate/my/auth 已移除。
  • 应用访问入口迁移到 DomainApplication.config.location 从配置类型、模板数据和配置面板中移除,前端页面 URL 通过 composeDomainUrl(...) 生成,后台接口 URL 通过 composeServerUrl(...) 生成;getApplication、微信回调、邀请落地页和二维码调试链接都会过滤已禁用域名。
  • Domain 增加启用状态并弱化端口配置:新增 ableState 以及 enable / disable 动作,port 改为可空;管理列表改为启用 / 禁用确认操作,新增和编辑弹框补齐取消按钮、空值占位与国际化文案。
  • 微信扫码中转页和二维码有效期改为系统配置:System.config.App.scanPage 统一配置扫码中转页,默认 wechatQrCode/scan,小程序侧会规范化为 pages/<scanPage>/indexSystem.config.App.wechatQrCodeExpireSeconds 控制微信临时二维码有效期,单位秒,默认并封顶为 2592000
  • 微信公众号 / 小程序回调改为注册表模式:src/endpoints/wechat.ts 只负责 HTTP 验证和解析,项目层通过 src/registry.backend.ts 注册 registerWeChatPublicEventHandler / registerWeChatPublicEventAfterHandlerregisterWeChatMpEventHandler / registerWeChatMpEventAfterHandlerapplicationIdmsgType 必填,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/themetypes/Theme,共享主题能力改由 oak-frontend-basefeatures.themeoakTheme 和 CSS 变量承接。
  • 页面 / 组件配置迁移到 index.config.ts:大量组件补齐 CreateComponentConfig,模板和 tabBar 改用 setNamespacePath(...) / getNamespacePath(),并统一使用当前 pageHeader 入口。
  • 多语言和依赖元数据更新:组件、实体和 humanVerify locale 增加英文资源;package.json.oak 使用 oak.packageoak.business.moduleoak.business.featureInitoak.compiler.transform 和前端全局组件声明;peer 依赖对齐 React 19、AntD 6、oak-domain ^6.0.1oak-frontend-base ^6.0.4
  • 修复 token web 多标签同步竞态:web 开发态也注册 storage 监听,刷新旧 token、过期 remove 事件和 token rotation 不会再覆盖或清掉较新的本地 token;switchTo 会清理 frontend-base 的 console context 缓存。
  • 修复用户证件校验类型、unset 带点字面量 key、过时 wx.* 方法、AntD Divider titlePlacement 和注册页 header 布局。
  • 业务组件已迁移到 oak-cli 的 render props 推导,并在 ES 构建启用 XML 与 Less Module 严格检查;传统 TSX 不再以手写 WebComponentProps 作为主合同,小程序模板和样式错误会直接阻断构建。
  • Application feature 增加 Desktop 解析:浏览器 Web 与 Tauri/Electron 使用 renderer/runtime metadata 区分,离线快照会回放 application opRecords 后再从 cache 读取当前应用。
主要 diff 文件:src/entities/System.tssrc/entities/Application.tssrc/entities/Domain.tssrc/entities/LocalizedContent.tssrc/entities/AreaLocale.tssrc/entities/UserAuth.tssrc/entities/Invite*.tssrc/types/HumanVerify.tssrc/types/Translation.tssrc/types/Config.tssrc/utils/domain.tssrc/utils/session.tssrc/utils/wechatQrCode.tssrc/utils/wechatEvent/*src/utils/systemTranslation.tssrc/utils/humanVerify/*src/aspects/session.tssrc/aspects/token.tssrc/aspects/user.tssrc/aspects/invite.tssrc/aspects/wechatQrCode.tssrc/registry.backend.tssrc/endpoints/wechat.tssrc/endpoints/index.tssrc/features/token.tssrc/features/invite.tssrc/features/humanVerify.tssrc/components/system/*src/components/domain/*src/components/config/upsert/*src/components/oauth/management/*src/components/userAuth/*src/data/areaLocale/*upgrade/6.1.0/*.sql

6.0.0(tag:6.0.0,区间:5.11.2..6.0.0

已发布通用业务包的 6.x metadata 和多语言准备版本。
  • 包 metadata 补齐 oak.package / isLib,feature 初始化转为 package metadata 语义。
  • 去掉旧 OAK_DEV_MODE 参数和部分 alias / fix 脚本依赖。
  • 增加英文 locale,通用业务开始为多语言发布包做准备。
  • token storage sync 范围收窄,Area picker breadcrumb 和权限编辑组件修复。
主要 diff 文件:package.jsonsrc/locales/*src/initialize*src/components/areaPicker/*src/components/relation/*

升级注意

默认实体覆盖:覆盖通用业务默认实体时必须兼容旧结构。尤其是 UserUserEntityGrantSystem 这类会被应用广泛引用的实体,不能只保留新字段而丢掉旧 action/state/locale/style。
数据库升级:6.1.0 带有 upgrade/6.1.0/01.sql04.sql,并新增 05.mysql.sql / 05.postgres.sql。前四组脚本会创建 localizedContentareaLocaleuserAuthinviteinviteTouchinviteRelation,为 system 增加翻译字段,并从 user 删除旧证件字段;第 5 组脚本会让 domain.port 可空、增加 domain.ableState,并从 application.config 移除历史 location。升级前需要确认历史实名认证数据迁移策略和应用入口域名配置。
System 覆盖:应用如果覆盖 System,必须保留 translationtranslateStatetranslationError 以及 translate / translateSuccess / translateFail 动作,否则共享 components/system/panel、系统翻译 trigger 和定时任务会失效。
HumanVerify:开启 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/themeoak-general-business/types/Theme 引入主题能力;改用 oak-frontend-basefeatures.themeoakTheme 和主题设置组件。

oak-pay-business 更新日志

当前 package4.1.0
最新 tag4.0.0
覆盖区间3.5.1..4.0.0 / 4.0.0..HEAD

4.1.0 未发布(区间:4.0.0..HEAD

未发布多支付渠道、金额 Decimal 化和 general-system 适配是重点。
  • 新增 Waffo、Stripe、Epay、Creem、PayPal 支付渠道,并补齐对应的账号实体、产品实体、支付记录实体、配置组件和 endpoint。
  • 新增微信、支付宝、Epay、Stripe、Creem、Waffo、PayPal 回调模块;当前 src/endpoints/index.ts 默认导出空对象,项目必须按实际启用渠道显式注入,依赖包不会自动暴露全部支付回调。
  • payClazz 内置注册新增 epProductspProductcpProductwfProductppProduct,并对自定义支付渠道校验 account/product schema:账号金额、费率字段必须是 decimal,产品必须关联 application 并提供启用状态。
  • 金额字段迁到 Price / Decimal 语义,支付、退款、账户、提现、结算、系统账户流水等路径改用 decimal helper / BigNumber,避免以 JS number 直接计算金额。
  • 新增 upgrade/4.1.0/accounts.sqlupgrade/4.1.0/priceDecimal.sql:前者创建新渠道表,后者把历史金额列扩成 decimal(32,10)
  • 前端支付渠道选择支持 web redirect 类支付,redirectPay 会识别 pay.meta 中的 payUrlhtmlUrlcheckoutUrlpaymentUrl
  • features/Pay、订单支付组件和 payConfig/system 配置界面同步扩展到新渠道;系统账户 survey、应用 projection 和 relation 配置也加入新账号 / 产品关系。
  • SystemUser 适配 oak-general-business 6.x:System 继续保留 translationtranslateStatetranslationErrortranslate / translateSuccess / translateFail action alias,同时追加支付账号、提现账号和 payConfig
  • 修复退款取消 / stopRefunding 后订单状态回滚问题,避免从 refunding 错走支付成功分支并重复触发订单已支付副作用。
  • 账户支付、充值、退款、提现和提现转账相关 trigger 改用 decimal 值收敛,并补齐部分零金额退款和退款失败回滚路径。
  • 小程序组件配置从 index.json 批量迁到 index.config.ts / CreateComponentConfig,样式变量迁到 --oak-* 主题变量。
  • components/pay/list 支持传入分页覆盖项,支付详情、账单、账号和渠道配置组件做了 UI 与投影同步。
  • 包元数据升级为 oak.packageoak.i18noak.business.moduleoak.compiler.transform,peer 对齐 React 19、AntD 6、oak-domain 6、oak-frontend-base 6、oak-general-business 6。
  • Order / Pay 增加多货币字段与配置链路;支付产品 draft 初始化、Alipay 不支持独立退款 callback 的边界也已修正。
  • ES 构建启用 XML、render 注入和 Less Module 严格检查,组件已迁移到编译器推导的 props;旧手写 render 合同和未声明 XML/style 使用会在构建阶段暴露。
主要 diff 文件:package.jsonsrc/entities/*src/endpoints/*src/checkers/*src/triggers/*src/features/Pay.tssrc/utils/payClazz/*src/utils/redirectPay.tssrc/components/*src/configuration/*upgrade/4.1.0/*.sql

4.0.0(tag:4.0.0,区间:3.5.1..4.0.0

已发布支付业务包的 4.x ESM 和 React 19 适配版本。
  • 适配 React 19,并把迁移构建切到 ESM。
  • 包 metadata 补齐 oak.package / isLib,feature 初始化转为 package metadata 语义。
  • 移除旧 alias / build scripts,并补充英文 locale。
  • 适配新的 oak-general-businessoak-domain 依赖方式。
主要 diff 文件:package.jsonsrc/locales/*src/initialize*、构建配置。

升级注意

数据库升级:4.1.0 需要审阅并执行 upgrade/4.1.0/accounts.sqlupgrade/4.1.0/priceDecimal.sql。前者新增渠道表并手写物理外键,后者把金额列改成 decimal(32,10);生产库升级前应确认历史金额单位、精度和外键策略。
金额语义:支付、退款、账户、提现和结算金额不应继续按普通 JS number 计算。消费项目里的金额写入、比较、加减、格式化和 update expression 需要迁到 decimal 字符串 / helper 语义。
覆盖实体兼容:支付项目覆盖 SystemUser、支付配置、支付产品或支付状态时,需要保留 general-business 字段、支付字段、旧 action/state 和翻译 alias。尤其是退款状态流转,不要只按新渠道字段重写旧状态机。
自定义支付渠道:registerPayClazz 现在会校验 product/account schema。自定义账号实体必须提供 decimal price 和费率字段、systemId、提现转账开关等字段;自定义产品实体必须关联 application 并提供 enabled、税费、退款和收款配置字段。
Redirect 支付:Epay、Stripe、Creem、Waffo、PayPal 等 redirect 类渠道主要面向 web 平台,前端可用性依赖当前 application 和 pay.meta 中的跳转 URL。升级后需要检查支付成功 / 取消 URL、webhook secret、notify URL 和沙箱配置。

oak-common-aspect 更新日志

当前 package4.0.3
最新 tag4.0.2
覆盖区间4.0.1..4.0.2 / 4.0.2..HEAD

4.0.3 未发布(区间:4.0.2..HEAD

未发布依赖边界和 TS6 类型兼容调整为主。
  • oak-domainoak-external-sdk 从直接依赖迁到 peerDependencies,并分别对齐到 ^6.0.1^3.0.2;本仓库开发环境改用 file:../oak-domainfile:../oak-external-sdk
  • src/relation.tsomit 来源从 @oak-domain/utils/lodash 切到 @oak-domain/utils/toolkit,跟随 Oak 公共工具入口迁移。
  • loadRelations 的返回类型断言改为 unknown 过渡,兼容 TS6 对泛型结构断言的更严格检查;运行时仍返回去掉 relation 字段后的 userRelation 列表。
  • tsconfig.es.jsontsconfig.lib.json 移除 downlevelIteration,构建参数和当前 Oak 包链路保持一致。
  • 编译产物 es/relation.jslib/relation.js 已同步更新。
  • 包自身构建切换到 oak-cli;relation 查询补充结果存在性断言,使空查询结果更早暴露为明确错误。
主要 diff 文件:package.jsonsrc/relation.tstsconfig.es.jsontsconfig.lib.jsones/relation.jslib/relation.js

4.0.2(tag:4.0.2,区间:4.0.1..4.0.2

已发布导出入口和 type import 清理。
  • 拆分 type imports,减少运行时 import 污染。
  • 调整 exception exports。
  • aspect、crud、geo、amap、port、relation 等导出入口整理。

升级注意

Peer 依赖:4.0.3 不再把 oak-domainoak-external-sdk 当普通依赖安装。消费项目必须自己提供兼容版本,否则 common aspect 的 crud、geo、amap、relation 等 aspect 在运行或构建时会找不到 Oak 依赖。
导入路径:如果应用直接从内部路径导入 common aspect 的 exception 或 relation 工具,应改回公开导出入口。

oak-memory-tree-store 更新日志

当前 package4.0.2
最新 tag4.0.1
覆盖区间4.0.0..4.0.1 / 4.0.1..HEAD

当前未发布变更(区间:4.0.1..HEAD

未发布依赖、TS6、工具库和 package 元数据同步调整。
  • oak-domain 从直接依赖迁到 peerDependencies,版本范围更新为 ^6.0.1;本仓库开发环境改用 file:../oak-domain
  • src/store.tscloneDeepgetsetunsetgroupBy 等工具从 @oak-domain/utils/lodash 切到 @oak-domain/utils/toolkit,并移除 @types/lodash
  • 事务提交 / 回滚时对 $txnId$next$path$nextNodeactiveTxnDict[uuid] 等字面量字段改用 delete 删除,避免 unset 把带点路径或特殊 key 当路径解析。
  • tsconfig.es.jsontsconfig.lib.jsontsconfig.mocha.json 移除 downlevelIteration,跟随当前 Oak 包构建参数。
  • 编译产物 es/store.jslib/store.js 已同步更新,package 版本元数据已更新为 4.0.2
主要 diff 文件:package.jsonsrc/store.tstsconfig*.jsones/store.jslib/store.js

4.0.1(tag:4.0.1,区间:4.0.0..4.0.1

已发布该 tag 主要是版本发布标记和依赖版本对齐,未发现独立的行为变化。

升级注意

peer 依赖:4.0.1 之后未发布变更不再由 memory tree store 直接安装 oak-domain。消费项目必须显式提供兼容的 oak-domain ^6.0.1
事务字段:如果项目自定义了基于 TreeStore 节点元字段的测试或调试逻辑,需要注意事务完成后这些字段现在通过 delete 清理,语义是删除字面量属性,不再走 lodash 路径解析。

oak-external-sdk 更新日志

当前 package3.0.2
最新 tag3.0.1
覆盖区间3.0.0..3.0.1 / 3.0.1..HEAD

3.0.2 未发布(区间:3.0.1..HEAD

未发布LLM SDK、WeChat token 刷新和 ESM 兼容调整。
  • 新增 LlmSDKtypes/LLMservice/llm/OpenAICompatible,当前 provider 为 openaiCompatible
  • OpenAI-compatible 实例支持 chatjsontranslateTexttranslateFieldstranslateObject,可配置 baseURLapiKeydefaultModel、headers、extraBody、timeout 和 responseFormat
  • LLM 返回解析支持普通 JSON、data: event-stream 文本、reasoning / reasoning_content / <think> 内容拆分,并把 usage 映射到统一的 promptTokenscompletionTokenstotalTokens
  • 顶层 src/index.ts 改为直接导出各 SDK 单例和 type-only instance 类型,新增 LlmSDK 导出;WechatMpInstanceOpenAICompatibleInstance 等 instance class 不再作为顶层运行时值导出。
  • 微信 mppublicwebnative access 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:esfix_import.mjs target,oak-domain 迁到 peer ^6.0.1,本仓库 dev 使用 file:../oak-domain
  • 新增 CJS WeChat access token 测试,覆盖永久失败、临时失败重试、并发调用共享同一次刷新。
主要 diff 文件:package.jsonsrc/index.tssrc/LlmSDK.tssrc/types/LLM.tssrc/service/llm/OpenAICompatible.tssrc/WechatSDK.tssrc/service/wechat/*src/utils/fetch/*src/utils/cheerio/index.web.tstest/testWechatAccessToken.cjs

3.0.1(tag:3.0.1,区间:3.0.0..3.0.1

已发布该 tag 主要是版本发布标记和依赖版本对齐,未发现独立的行为变化。

升级注意

运行时导出:3.0.2 顶层入口仍导出 WechatSDKLlmSDK 等 SDK 单例,但 instance class 改为 type-only 导出。运行时代码如果曾从 oak-external-sdk 顶层解构 WechatMpInstanceWechatPublicInstance 等构造类,需要改成使用 SDK 工厂或从具体 service/... 路径引入。
WeChat 刷新函数:externalRefreshFn 现在必须返回 { access_token, expires_in },不再只是 token 字符串。自定义 token 刷新接入需要同步调整返回值。
运行时环境:fetch 依赖 Node 18+ 或宿主已有 globalThis.fetch;web 环境不再提供 cheerio 实现,调用 cheerio.load 会抛出明确异常。
LLM 导入入口:使用 LLM SDK 时优先从公开导出入口 LlmSDKtypes/LLM 引入,避免依赖内部 service/llm 文件路径。

oak-internal-sdk 更新日志

当前 package1.1.5
最新 tag1.1.4
覆盖区间1.1.3..1.1.4 / 1.1.4..HEAD

1.1.5 未发布(区间:1.1.4..HEAD

未发布加密 endpoint、声明输出和 TS6 兼容调整。
  • EncConnector 增加 endpoint 调用能力:前端实现 callEndpointopenEndpointStreamcallSSEEndpointmakeEndpointUrl,并按 routerPrefixes.endpoint 生成 endpoint 地址。
  • 加密 SSE endpoint 打通前后端:服务端 serializeSSEEndpointResult 会在请求头 oak-encrypted: 1 且存在 session key 时,把 SSE data: packet 加密后输出;前端 callSSEEndpoint 会按 oak-encrypted 响应头逐包解密并分发事件。
  • 前端 SSE client 支持 onMessage、按事件名 ononErroronDonecloseevent: error 中携带的 Oak exception 会通过 makeException 还原。
  • src/index.tsSafeConnector 外,新增导出 utils/envadaptor/memoryAdaptor,方便消费端直接拿到加密连接器和内存 adaptor。
  • package.json 增加 oak.es.optimize.includeDeps,显式包含 crypto-js/sha256crypto-js/enc-hex,避免 ESM 优化时漏掉 SafeConnector 需要的 crypto-js 子路径。
  • 移除 lodash / @types/lodash,开发态 oak-domain 改为 file:../oak-domain,并移除 downlevelIteration 以适配当前 TS 构建参数。
  • formate.d.tsBaseEntityDict 引用按 es/lib 产物分别改成 oak-domain/es/indexoak-domain/lib/index,避免声明输出指到不匹配的入口。
  • 编译产物 es/lib/ 已同步更新。
  • connector 错误日志不再直接输出原始错误对象,避免把请求或加密上下文中的敏感细节写入普通日志。
主要 diff 文件:package.jsonsrc/index.tssrc/utils/env/impls/backend.tssrc/utils/env/impls/frontend.tstsconfig*.jsones/*.d.tslib/*.d.ts

1.1.4(tag:1.1.4,区间:1.1.3..1.1.4

已发布i18n、Vite build 和 adaptor 输出整理。
  • 增加 i18n 相关能力。
  • oak-domain 转为 peerDependencies / version reference。
  • 增加 Vite build / alias 支持。
  • memory / redis adaptor 输出整理。
  • exception data debug、clock drift exception、parseRequest array bug 和 class name preservation 修复。

升级注意

peer 依赖:依赖该包的内部服务应对齐 peer 中的 oak-domain 版本,不要再假设它由 internal sdk 直接带入。
SSE 同步升级:加密 SSE endpoint 需要前端 callSSEEndpoint 与服务端 serializeSSEEndpointResult 配套升级。只升级一端时,客户端可能把加密后的 data: 当普通 JSON 解析,或服务端返回未加密流导致敏感数据明文传输。
Endpoint 路径:前端 endpoint URL 现在使用 routerPrefixes.endpoint 或默认 /endpoint。自定义网关、反向代理和 CORS expose headers 需要允许 oak-encryptedoak-nonce 等响应头。

oak-ui 更新日志

当前 package0.1.0
最新 tag无 tag
覆盖区间当前仓库状态

0.1.0 当前状态(无 tag)

未打 tag只能按当前 commit 状态记录,不能判断正式发布边界。
  • 包名切换为 oak-ui,并补齐 Oak package metadata。
  • 重建基础 primitives,并扩展 common primitives。
  • 支持从 SVG assets 生成 icons。
  • 加固 core interactions 和生产行为。
  • 增加 bm-smart 迁移组件。

升级注意

版本边界:由于当前没有 tag,无法按发布区间判断稳定边界。使用该包时应锁定具体 commit 或等待正式 tag。