Files
workspace/code/fms/REQUIREMENTS.md
T
2026-07-14 22:08:10 +08:00

23 KiB
Raw Blame History

FMS 后端 API 需求规范

1. 设计定位

FMS 是面向内部用户的多机构 ERP 系统。

当前阶段只确定以下基础原则:

  • 所有 API 请求统一使用 POST。
  • 前端登录时通过请求头传递机构码。
  • 一个机构对应一个独立数据库。
  • 登录成功后机构码绑定到访问令牌,后续请求由后端从令牌获取机构码并选择数据库。
  • 前端不接触数据库连接信息。
  • 后端负责身份认证、机构数据库路由和统一响应。
  • 权限编码只用于前端禁用按钮,暂不作为后端业务接口的授权条件。
  • 普通查询和保存采用结构化数据协议,不要求前端编写参数占位符。
  • 高级只读查询可以通过独立的 load_sql 接口提交 SQL。
  • 新业务在现有数据库表和视图能够支持的情况下,只需开发前端页面,不需要增加后端业务接口。

当前提供以下接口:

方法 路径 用途 是否需要登录
POST /api/auth/login 登录并获取用户及按钮权限 否
POST /api/data/load 通用结构化数据查询 是
POST /api/data/load_sql 高级只读 SQL 查询 是
POST /api/data/save 通用数据保存 是

2. 通用请求规范

2.1 HTTP 方法

  • 所有接口只使用 POST。
  • 不使用 GET、PUT、PATCH 或 DELETE。
  • 查询、创建、修改、删除和业务操作都通过后续定义的 POST 接口完成。

2.2 数据格式

  • 请求和响应使用 application/json; charset=utf-8。
  • 除登录路由使用的 orgid 请求头外,请求参数放在 JSON 请求体中。
  • 需要登录的接口通过 Bearer Token 认证。
  • 响应不能包含数据库连接信息、令牌密钥、密码或服务端调用栈。

需要登录的请求必须包含:

Authorization: Bearer <access_token>
Content-Type: application/json; charset=utf-8

2.3 命名规范

  • API 路径、请求字段和响应字段优先使用简短、明确的全小写名称。
  • 能使用单个单词或常用组合词时,不使用驼峰命名法,例如 username、orgid。
  • 多个单词无法简洁表达时使用 snake_case,例如 access_token、display_name。
  • JSON 字段不使用 camelCase,例如不使用 accessToken、displayName。
  • TypeScript 接口和类型名称遵循语言惯例使用 PascalCase,其字段名与 API JSON 字段保持一致。

2.4 机构标识

机构标识统一命名为 orgid。

规则:

  • orgid 类型为非空字符串。
  • 登录请求必须通过 orgid 请求头传递机构标识。
  • 登录成功后,orgid 保存在访问令牌声明或服务端会话中。
  • 后续业务请求不在请求头或请求体中重复传递 orgid。
  • 后端从访问令牌中获取 orgid 并选择机构数据库。
  • 前端只传机构码,不能传数据库名称、地址、端口、账号或密码。
  • 机构码必须作为完整字段值传递,不能用于拼接数据库连接字符串或 SQL。
  • 机构码不存在、已停用或未配置数据库时,请求失败。

登录请求头示例:

orgid: SH001

2.5 通用成功响应

{
  "code": 0,
  "message": "ok",
  "data": {}
}

2.6 通用失败响应

{
  "code": 1201,
  "message": "organization not found",
  "details": {
    "orgid": "SH001"
  }
}

约定:

  • code = 0 表示成功。
  • HTTP 状态码表示错误类别,code 表示具体错误。
  • data 只在成功且接口有返回数据时出现。
  • details 为可选的结构化错误信息。

3. 机构数据库路由

3.1 数据隔离模型

FMS 采用一个机构一个数据库的隔离方式:

orgid: SH001 -> database: fms_sh001
orgid: BJ001 -> database: fms_bj001
orgid: GZ001 -> database: fms_gz001

上述映射只表示逻辑关系,真实数据库名称和连接信息不能返回给前端。

3.2 后端处理流程

后端收到请求后按以下顺序处理:

  1. 登录时从 orgid 请求头获取机构标识;后续请求从访问令牌获取机构标识。
  2. 校验 orgid 的格式。
  3. 从服务端机构配置中查找机构。
  4. 检查机构是否启用以及数据库配置是否可用。
  5. 获取该机构对应的数据库连接或连接池。
  6. 在该机构数据库中完成认证或后续业务操作。
  7. 返回统一响应,不暴露数据库连接信息。

3.3 登录状态与机构绑定

  • 登录成功后,访问令牌必须绑定当前 orgid 和用户标识。
  • 后续请求只以访问令牌绑定的 orgid 为准。
  • 后续请求不能通过请求头或请求体覆盖令牌中的 orgid。
  • 用户需要切换机构时,必须在登录请求头中传入目标 orgid 并重新登录。
  • 当前不提供 refresh;访问令牌过期后需要重新登录。
  • 当前不提供 logout;客户端退出时删除本地访问令牌。

3.4 连接管理

  • 数据库连接配置由后端集中管理。
  • 后端可以为每个机构维护独立连接池。
  • 同一次请求只能访问一个机构数据库。
  • 机构数据库不可用时,不能自动切换到其他机构数据库。
  • 日志可以记录机构码,但不能记录数据库密码或完整连接字符串。

4. 登录接口

4.1 登录页面

登录页面必须提供以下输入项:

输入项 必填 说明
机构码 是 用户所属机构的 orgid
用户名 是 用户登录账号
密码 是 用户登录密码

用户点击登录后,前端将机构码放入 orgid 请求头,将用户名和密码放入 JSON 请求体。

4.2 请求

POST /api/auth/login
orgid: SH001
Content-Type: application/json; charset=utf-8

请求体:

{
  "username": "zhangsan",
  "password": "password"
}
字段 位置 类型 必填 说明
orgid header string 是 登录页面填写的机构码,用于选择机构数据库
username body string 是 登录账号
password body string 是 登录密码

4.3 处理规则

  • 后端先根据 orgid 选择机构数据库,再验证用户名和密码。
  • 登录失败时不能说明账号是否存在。
  • 密码不能写入日志或错误响应。
  • 登录成功后令牌绑定机构码和用户标识。
  • 当前不签发 refresh token。

4.4 成功响应

{
  "code": 0,
  "message": "ok",
  "data": {
    "access_token": "access-token",
    "token_type": "Bearer",
    "expires_in": 1800,
    "organization": {
      "orgid": "SH001",
      "name": "上海分公司"
    },
    "user": {
      "id": 1001,
      "username": "zhangsan",
      "display_name": "张三"
    },
    "permissions": [
      "sales.order.create",
      "sales.order.approve",
      "finance.payment.view"
    ]
  }
}

5. 前端按钮权限

  • permissions 是当前登录用户拥有的权限编码数组。
  • 权限编码由机构数据库中的用户、角色和权限配置产生。
  • 权限编码建议使用 <模块>.<资源>.<动作> 格式。
  • 前端根据权限编码设置按钮的禁用状态。
  • 用户没有对应权限时,按钮保持可见但不可操作。
  • 当前权限只控制前端交互,不作为后端业务授权条件。

前端判断示例:

const canApprove = permissions.includes('sales.order.approve')

6. 通用加载接口

6.1 设计原则

  • 前端提交数据源、返回字段、过滤条件、排序和分页。
  • 前端不提交 SQL,也不编写参数占位符。
  • 后端根据结构化请求生成查询 SQL,并在后端内部绑定所有数据值。
  • 查询数据源可以是数据库表或视图。
  • 复杂关联、聚合和计算字段优先封装为数据库视图,再通过本接口查询。
  • 后端不为具体业务定义查询接口。

6.2 请求

POST /api/data/load
Authorization: Bearer <access_token>
Content-Type: application/json; charset=utf-8

请求示例:

{
  "source": "v_sales_order_list",
  "fields": [
    "id",
    "order_no",
    "customer_name",
    "status",
    "total_amount",
    "created_at"
  ],
  "filter": {
    "logic": "and",
    "items": [
      {
        "field": "status",
        "op": "eq",
        "value": "pending"
      },
      {
        "logic": "or",
        "items": [
          {
            "field": "customer_name",
            "op": "contains",
            "value": "海运"
          },
          {
            "field": "order_no",
            "op": "starts_with",
            "value": "SO2026"
          }
        ]
      }
    ]
  },
  "sort": [
    {
      "field": "created_at",
      "direction": "desc"
    }
  ],
  "page": {
    "page_no": 1,
    "page_size": 20
  },
  "include_total": true
}
字段 类型 必填 说明
source string 是 数据库表名或视图名
fields string[] 是 返回字段,必须是非空数组
filter object 否 过滤条件组
sort array 否 排序规则
page object 否 分页;省略时使用默认分页
include_total boolean 否 是否计算符合条件的总记录数,默认 false

6.3 标识符规则

  • source、字段名和排序字段只能是数据库标识符,不能包含 SQL 表达式。
  • 标识符必须符合 ^[a-z_][a-z0-9_]*$。
  • source 可以包含一个 schema 前缀,例如 public.v_sales_order_list。
  • 带 schema 的数据源必须符合 <schema>.<object> 格式。
  • 后端必须正确引用标识符,不能把标识符当作数据值拼接。
  • 字段必须真实存在于指定数据源中。
  • fields 只返回前端明确请求的字段,不支持 *。

6.4 过滤条件

过滤条件分为条件项和条件组。

条件项:

{
  "field": "status",
  "op": "eq",
  "value": "pending"
}

条件组:

{
  "logic": "and",
  "items": [
    {
      "field": "status",
      "op": "eq",
      "value": "pending"
    },
    {
      "field": "total_amount",
      "op": "gte",
      "value": 1000
    }
  ]
}

条件组规则:

  • logic 只允许 and 或 or。
  • items 必须是非空数组。
  • 条件组可以嵌套条件组。
  • 所有 value 都由后端内部参数绑定,不作为 SQL 文本执行。

支持的操作符:

op 说明 value 规则
eq 等于 单个值
ne 不等于 单个值
gt 大于 单个值
gte 大于或等于 单个值
lt 小于 单个值
lte 小于或等于 单个值
in 在列表中 非空数组
not_in 不在列表中 非空数组
between 在范围内,包含边界 两个元素的数组
contains 文本包含 string
starts_with 文本开头匹配 string
ends_with 文本结尾匹配 string
is_null 为 NULL 不传 value
is_not_null 不为 NULL 不传 value

补充规则:

  • eq 的值为 null 时按 is_null 处理。
  • ne 的值为 null 时按 is_not_null 处理。
  • contains、starts_with 和 ends_with 中的 %、_ 默认作为普通字符处理。
  • 空数组不能用于 in、not_in 或 between。

6.5 排序和分页

  • direction 只允许 asc 或 desc。
  • 可以指定多个排序字段,后端按数组顺序生成排序规则。
  • page_no 从 1 开始,默认值为 1。
  • page_size 默认值为 20,最大值为 5000。
  • 前端需要稳定分页时,应包含主键或其他唯一字段作为最后一个排序字段。

6.6 成功响应

{
  "code": 0,
  "message": "ok",
  "data": {
    "rows": [
      {
        "id": 128,
        "order_no": "SO20260001",
        "customer_name": "长荣海运",
        "status": "pending",
        "total_amount": 3500,
        "created_at": "2026-07-14T10:30:00+08:00"
      }
    ],
    "row_count": 1,
    "total": 36,
    "page_no": 1,
    "page_size": 20
  }
}

响应规则:

  • rows 始终是数组,没有记录时返回空数组。
  • row_count 是本次实际返回的记录数。
  • total 只在 include_total = true 时返回,否则为 null。
  • total 表示忽略分页后符合过滤条件的记录数。

7. 高级 SQL 加载接口

7.1 适用范围

/api/data/load_sql 用于结构化 /api/data/load 无法表达的高级只读查询,例如复杂关联、CTE、窗口函数和聚合查询。

规则:

  • 该接口是可选的高级能力。
  • 只允许执行单条只读 SQL。
  • 允许 SELECT,以及所有 CTE 都是只读查询的 WITH。
  • 不允许 INSERT、UPDATE、DELETE、MERGE、DDL、权限管理和事务控制语句。
  • 不允许通过分号附加第二条语句。
  • 即使最外层是 SELECT,也不允许在 CTE 中写入数据。
  • SQL 不支持参数占位符,前端不能把最终用户输入直接拼接到 SQL 中。
  • 包含页面动态筛选值的查询应优先使用 /api/data/load。
  • 返回行数和执行超时遵循普通加载接口限制。

7.2 请求

POST /api/data/load_sql
Authorization: Bearer <access_token>
Content-Type: application/json; charset=utf-8

请求示例:

{
  "sql": "SELECT customer_id, customer_name, COUNT(*) AS order_count, SUM(total_amount) AS total_amount FROM v_sales_order_list GROUP BY customer_id, customer_name ORDER BY total_amount DESC LIMIT 100"
}
字段 类型 必填 说明
sql string 是 单条只读 SQL

7.3 成功响应

{
  "code": 0,
  "message": "ok",
  "data": {
    "rows": [
      {
        "customer_id": 42,
        "customer_name": "长荣海运",
        "order_count": 18,
        "total_amount": 63000
      }
    ],
    "row_count": 1
  }
}

响应规则:

  • rows 始终是数组,没有记录时返回空数组。
  • row_count 是本次实际返回的记录数。
  • 排序、分页和总数由 SQL 自身决定。

8. 通用保存接口

8.1 设计原则

  • 保存接口支持在一次请求中新增、修改或删除多条数据。
  • 前端提交目标表、操作类型、数据值和过滤条件。
  • 前端不提交 SQL,也不编写参数占位符。
  • 后端根据结构化请求生成 SQL,并在后端内部绑定所有数据值。
  • 一个请求中的所有变更使用同一个数据库事务。
  • 任一变更失败时,整个请求回滚。
  • 后端不为具体业务定义保存接口。

8.2 请求

POST /api/data/save
Authorization: Bearer <access_token>
Content-Type: application/json; charset=utf-8

请求示例:

{
  "changes": [
    {
      "action": "insert",
      "target": "sales_order",
      "values": {
        "id": "019f62d5-4115-7b22-8c51-8f89e2e8b421",
        "order_no": "SO20260001",
        "customer_id": 42,
        "status": "draft",
        "version": 1
      },
      "returning": [
        "id",
        "order_no"
      ],
      "expect_rows": 1
    },
    {
      "action": "insert",
      "target": "sales_order_item",
      "values": {
        "id": "019f62d5-4115-79db-ab88-99e8c5dc7f21",
        "order_id": "019f62d5-4115-7b22-8c51-8f89e2e8b421",
        "product_id": 301,
        "quantity": 2,
        "unit_price": 1750
      },
      "returning": [
        "id"
      ],
      "expect_rows": 1
    }
  ]
}
字段 类型 必填 说明
changes array 是 按顺序执行的变更,必须是非空数组

8.3 Change 结构

字段 类型 必填 说明
action string 是 insert、update 或 delete
target string 是 目标表或可写视图
values object insert、update 必填 要写入的字段和值
filter object update、delete 必填 确定修改或删除范围的过滤条件
returning string[] 否 保存后需要返回的字段
expect_rows integer 否 实际影响行数必须等于该非负整数

8.4 保存规则

  • target 和字段名遵循查询接口的标识符规则。
  • values 必须是非空对象。
  • values 的值可以是 string、number、boolean、null、array 或 object。
  • array 和 object 作为数据库字段值时,由数据库字段类型决定如何转换。
  • update 和 delete 必须提供非空 filter,不允许无条件修改或删除整表数据。
  • 保存过滤条件使用查询接口定义的条件结构和操作符。
  • returning 省略时返回空数组。
  • expect_rows 不匹配时整个请求失败并回滚。
  • 数据库默认值通过省略对应字段触发。
  • values 不支持 SQL 函数、字段引用或其他原始 SQL 表达式。

8.5 多表保存和主键

保存接口不提供步骤结果占位符,也不支持后续变更引用前序变更的返回值。

多表新增时,前端应提前生成 UUID 主键,并把主键和外键直接写入各个 change 的 values:

前端生成订单 id
-> 前端把订单 id 写入订单数据
-> 前端把同一个订单 id 写入明细的 order_id
-> 一次提交所有 changes
-> 后端在同一事务中保存

如果某个表必须使用数据库生成的主键,则只能在本次请求的 returning 中获取,不能在同一次保存请求的后续 change 中引用。需要多表原子保存时,应优先使用前端生成的 UUID 主键。

8.6 修改和乐观锁示例

{
  "changes": [
    {
      "action": "update",
      "target": "sales_order",
      "values": {
        "status": "approved",
        "version": 4
      },
      "filter": {
        "logic": "and",
        "items": [
          {
            "field": "id",
            "op": "eq",
            "value": "019f62d5-4115-7b22-8c51-8f89e2e8b421"
          },
          {
            "field": "version",
            "op": "eq",
            "value": 3
          }
        ]
      },
      "returning": [
        "id",
        "status",
        "version"
      ],
      "expect_rows": 1
    }
  ]
}

前端读取当前版本号,保存时同时提交旧版本条件和新版本值。当其他用户已经修改记录时,expect_rows 检查失败,整个请求回滚。

8.7 成功响应

{
  "code": 0,
  "message": "ok",
  "data": {
    "results": [
      {
        "index": 0,
        "action": "insert",
        "affected_rows": 1,
        "rows": [
          {
            "id": "019f62d5-4115-7b22-8c51-8f89e2e8b421",
            "order_no": "SO20260001"
          }
        ]
      },
      {
        "index": 1,
        "action": "insert",
        "affected_rows": 1,
        "rows": [
          {
            "id": "019f62d5-4115-79db-ab88-99e8c5dc7f21"
          }
        ]
      }
    ]
  }
}

响应规则:

  • results 顺序与请求中的 changes 一致。
  • rows 始终是数组。
  • affected_rows 表示该变更实际影响的记录数。
  • 事务提交成功后才返回成功响应。

9. 错误规范

HTTP 状态码 code 说明
400 1001 请求 JSON 或字段结构错误
400 1006 请求大小超限
401 1101 机构码、用户名或密码错误
401 1102 访问令牌无效或已过期
404 1201 机构不存在
409 1202 机构已停用
500 1203 机构数据库未配置或配置无效
400 1301 数据源、目标或字段标识符格式错误
400 1302 过滤条件、排序或分页格式错误
400 1303 保存变更结构错误
400 1304 SQL 为空、包含多条语句或不是只读查询
409 2001 expect_rows 与实际影响行数不一致
409 2002 唯一键、外键、非空或检查约束冲突
409 2003 死锁、序列化失败或其他并发冲突
422 3001 数据源、目标或字段不存在
422 3002 数据值无法转换为数据库字段类型
500 3003 数据库执行失败
503 9001 机构数据库不可用
500 9002 服务端内部错误
504 9003 查询或保存执行超时

错误响应不能泄露真实数据库名称、地址、端口、账号、密码或连接字符串。

保存请求执行失败时,错误响应的 details 应包含失败的 change_index。约束冲突可以返回约束名称,但不能返回生成后的完整 SQL。

10. 执行限制

默认限制:

项目 限制
单次请求大小 2 MB
load 最大返回行数 5000 行
load_sql 最大返回行数 5000 行
load 最大过滤条件数 200 个
save 最大变更数 100 个
单个 change 最大字段数 500 个
load 执行超时 30 秒
load_sql 执行超时 30 秒
save 执行超时 60 秒

规则:

  • 超过返回行数限制时请求失败,不静默截断结果。
  • save 超时包含全部变更和事务提交过程。
  • 限制值可以通过后端配置调整。

11. 当前范围

当前文档只定义:

  • 全部请求使用 POST 的通用规范。
  • 机构码传递和机构数据库路由规则。
  • 登录接口。
  • 登录响应中的用户信息和前端按钮权限。
  • 通用结构化查询接口。
  • 高级只读 SQL 查询接口。
  • 通用结构化保存接口。
  • 通用响应和基础错误码。

当前不提供原有的 /api/query 和 /api/transaction。数据访问统一使用 /api/data/load、/api/data/load_sql 和 /api/data/save。

文件、导入、导出、报表以及页面和菜单加载方式将在后续单独设计。

12. TypeScript 类型参考

interface ApiResponse<T> {
  code: number
  message: string
  data?: T
  details?: Record<string, unknown>
}

interface LoginRequest {
  username: string
  password: string
}

interface LoginResult {
  access_token: string
  token_type: 'Bearer'
  expires_in: number
  organization: {
    orgid: string
    name: string
  }
  user: {
    id: string | number
    username: string
    display_name: string
  }
  permissions: string[]
}

type DataScalar = string | number | boolean | null
type DataValue = DataScalar | DataValue[] | { [key: string]: DataValue }

type FilterNode = FilterCondition | FilterGroup

interface FilterCondition {
  field: string
  op:
    | 'eq'
    | 'ne'
    | 'gt'
    | 'gte'
    | 'lt'
    | 'lte'
    | 'in'
    | 'not_in'
    | 'between'
    | 'contains'
    | 'starts_with'
    | 'ends_with'
    | 'is_null'
    | 'is_not_null'
  value?: DataValue
}

interface FilterGroup {
  logic: 'and' | 'or'
  items: FilterNode[]
}

interface LoadRequest {
  source: string
  fields: string[]
  filter?: FilterGroup
  sort?: Array<{
    field: string
    direction: 'asc' | 'desc'
  }>
  page?: {
    page_no: number
    page_size: number
  }
  include_total?: boolean
}

interface LoadResult<T = Record<string, unknown>> {
  rows: T[]
  row_count: number
  total: number | null
  page_no: number
  page_size: number
}

interface LoadSqlRequest {
  sql: string
}

interface LoadSqlResult<T = Record<string, unknown>> {
  rows: T[]
  row_count: number
}

type SaveAction = 'insert' | 'update' | 'delete'

interface SaveChange {
  action: SaveAction
  target: string
  values?: Record<string, DataValue>
  filter?: FilterGroup
  returning?: string[]
  expect_rows?: number
}

interface SaveRequest {
  changes: SaveChange[]
}

interface SaveResult<T = Record<string, unknown>> {
  results: Array<{
    index: number
    action: SaveAction
    affected_rows: number
    rows: T[]
  }>
}