This commit is contained in:
oneao committed 2026-10-12 22:25:41 +08:00
1 parent ae3ca3cfa1
commit 7bd5f1dde9
45 files changed
+3414 -229

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
---
title: 数据查询
description: describe / loaddata / page / loaddatabysql / nextid / nextcode
pageClass: g3-api
---
# 数据查询
六个只读接口。除了 `nextid`(纯取号,不碰数据库),其余都会落到**当前机构自己的数据库**上执行。
共用规则:
- **表名、视图名、列名都会先过标识符校验**(只允许 `[A-Za-z_][A-Za-z0-9_]*`,可带限定名如 `dbo.v_x`),不会做字符串拼接 —— 所以别指望能塞入非法标识符。
- `search_condition` 是**原样拼进 `WHERE` 的 SQL 条件片段**;拼好的整条 SQL 之后会过只读校验(禁分号、禁注释、禁写关键字),所以它只能是布尔表达式。
- `order_by` 由后端校验并加 `[]`:支持 `字段 [ASC|DESC]`,或 `CASE ... END` 表达式,多列用逗号分隔。
- 返回的行是普通对象,键是**库里的原始列名**。
## `POST` `/data/describe`
探测表或视图的列结构,用于动态列、字段同步。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `table` | string | 是 | 表或视图名 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"table": "v_b_othercompany"
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [
{ "name": "b_id", "type": "varchar", "nullable": false, "size": 32 },
{ "name": "b_name", "type": "nvarchar", "nullable": true, "size": 128 }
]
}
```
</G3ApiPayload>
`type` 是数据库原生类型名(`varchar` / `nvarchar` / `decimal` / `datetime2` / `rowversion` …)。表不存在或没有可识别字段时返回 `1000`。
## `POST` `/data/loaddata`
按视图 + 条件 + 排序 + 字段列表查询。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `view_name` | string | 是 | 表或视图名 |
| `search_columns` | string[] | | 只取这些列;省略等价于 `*` |
| `search_condition` | string | | `WHERE` 片段 |
| `order_by` | string | | 排序,多列逗号分隔 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"view_name": "v_b_othercompany",
"search_columns": ["b_id", "b_name", "b_canuse"],
"search_condition": "b_canuse = 1",
"order_by": "b_id ASC, b_name DESC"
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [{ "b_id": "C001", "b_name": "XX 公司", "b_canuse": 1 }]
}
```
</G3ApiPayload>
> **没有行数上限。** `loaddata` 会把命中的行全部返回;列表页请用 `/data/page`。它的典型用途是取一个小字典(下拉选项)或按主键取单行。
## `POST` `/data/page`
分页查询。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `view_name` | string | 是 | 表或视图名 |
| `order_by` | string | 是 | 分页必须有序(SQL Server 的 `OFFSET / FETCH` 要求) |
| `search_columns` | string[] | | 只取这些列 |
| `search_condition` | string | | `WHERE` 片段 |
| `page_no` | number | | 默认 `1`,从 1 开始 |
| `page_size` | number | | 默认 `20` |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"view_name": "v_b_othercompany",
"search_condition": "b_canuse = 1",
"order_by": "b_id ASC",
"page_no": 2,
"page_size": 20
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"rows": [{ "b_id": "C021", "b_name": "XX 公司" }],
"row_count": 20,
"total": 137,
"page_no": 2,
"page_size": 20
}
}
```
</G3ApiPayload>
<G3ApiParams title="data 字段">
| 字段 | 说明 |
|---|---|
| `rows` | 当前页数据 |
| `row_count` | **当前页**的行数(等于 `rows.length`) |
| `total` | 命中总行数 |
| `page_no` / `page_size` | 回显请求里的分页参数 |
</G3ApiParams>
> `row_count` 不是总行数,**总行数是 `total`**。算总页数用 `total`,别用 `row_count`。
>
> `page_no` 或 `page_size` 小于 1、或不是整数,都会得到 `1000`(`page_no 必须是大于 0 的整数`)。
翻到最后一页之后(`page_no` 超出范围)`rows` 为空,但 `total` 仍然正确 —— 所以判断「没数据」和「翻过了头」要靠 `total`。
## `POST` `/data/loaddatabysql`
配置表达不出来的复杂只读查询走这里。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sql` | string | 是 | 必须以 `SELECT` 或 `WITH` 开头 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"sql": "SELECT TOP 10 b_id, b_name FROM v_b_othercompany ORDER BY b_id"
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [{ "b_id": "C001", "b_name": "XX 公司" }]
}
```
</G3ApiPayload>
被拒的情况(都是 `1000`):不是 `SELECT` / `WITH` 开头、包含 `;`、包含注释(`--` 或 `/* */`)、出现 `insert` `update` `delete` `drop` `truncate` `alter` `exec` `grant` 等关键字。
> 判定前会先把**字符串字面量整体掩掉**,所以在 `WHERE b_name = 'insert'` 这类写法里,字面量里的关键字不会误判。反过来也说明:不能在字面量之外藏写操作。
## `POST` `/data/nextid`
取雪花 ID。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `count` | number | | 默认 `1`;`<= 1` 时 `data` 是单个 id,否则是数组 |
</G3ApiParams>
请求体**可以整个省略** —— 不带 body 或传 `{}` 是等价的,`count` 都取默认的 `1`:
<G3ApiPayload title="请求体">
```json
{}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": "1873456789012345678"
}
```
</G3ApiPayload>
`count: 3` 时:
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [
"1873456789012345678",
"1873456789012345679",
"1873456789012345680"
]
}
```
</G3ApiPayload>
> 返回的是**字符串**(见 [快速开始](/api/getting-started) 里的序列化约定),不要转成数字。
## `POST` `/data/nextcode`
按模块配置的自动编码规则取业务编码(规则来自 `s_autocode`:前缀、日期格式、序号位数、分隔符、重置周期等)。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `moduleId` | string | 是 | 模块编码(注意是 camelCase 的 `moduleId`) |
| `count` | number | | 默认 `1`;`<= 1` 时返回单个字符串,否则返回数组 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"moduleId": "othercompany",
"count": 1
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": "HT20261012001"
}
```
</G3ApiPayload>
行为要点:
- **会占用号段** —— 服务端取号后立即把序号写回 `s_autocode`,所以连续调用得到的是不同编码。别拿它做「探测一下看看格式」的试调,试出来的号就废了。
- 生成时会**检查目标表里该编码是否已存在**,已被占用(比如人工插过一条)就自动往后顺延,所以不一定严格等于「当前序号 + 1」。
- 模块没有配置启用的编码规则、或没配保存表 → `1000`(`当前模块未配置启用的自动编码规则` / `模块未配置保存表,无法生成自动编号`)。
- 生成的编码超过目标字段长度 → `1000`(`自动编号长度超过目标字段长度`)。
@@ -1,10 +1,200 @@
---
title: 快速开始
description: 前后端接口的接入说明
description: 接口约定、鉴权与联调步骤
pageClass: g3-api
---
# 快速开始
本页说明前后端接口的接入方式,内容包括接口约定、鉴权与联调步骤。
后端提供的是一套**元数据驱动的通用数据接口**:前端不拼业务 SQL,只声明「查哪张视图 / 存哪张表 + 条件 + 字段」,由后端按 `s_module` / `s_field` 里的配置生成参数化 SQL 并执行。除登录外,所有接口共用同一套鉴权、机构数据源路由与统一响应格式。
> 内容待补充。
## 基础约定
<G3ApiParams variant="bare" :title="null">
| 项 | 约定 |
|---|---|
| 基础路径 | `/api`(`server.servlet.context-path`),例如 `http://localhost:8088/api/data/page` |
| 方法 | **全部是 `POST`**;唯一的 `GET` 是文件静态访问 `/api/file/static/{fileName}` |
| 请求体 | `application/json`,参数是**一个对象或数组**,不是表单 |
| 编码 | UTF-8 |
| HTTP 状态码 | **始终是 200** —— 成败一律看响应体里的 `code`,不要用 HTTP 状态码判断 |
</G3ApiParams>
最后一条最容易踩:鉴权失败、参数错误、系统异常**都不会**返回 4xx / 5xx,而是 200 + `code != 0`。所以不要依赖 `axios` 的错误拦截分支来处理业务失败。
## 统一响应格式
所有接口返回同一个外壳:
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {}
}
```
</G3ApiPayload>
<G3ApiParams variant="bare" title="响应字段">
| 字段 | 说明 |
|---|---|
| `code` | `0` 成功,非 0 见下表 |
| `message` | 可直接展示给用户的文案;出错时是原因 |
| `data` | 成功时的载荷;失败时一般为 `null` |
</G3ApiParams>
## 错误码
<G3ApiParams variant="bare" :title="null">
| code | 含义 | 触发场景 | 前端应对 |
|---|---|---|---|
| `0` | 成功 | —— | —— |
| `401` | 未登录 / 凭证失效 | 缺少或无效的 `Authorization`;账号密码错误 | 清 token,回登录页 |
| `40101` | 账号已在其他设备登录 | 同一账号在别处登录,本端会话被顶 | 清 token,提示后回登录页 |
| `1000` | 业务错误(默认) | 参数缺失或类型不对、标识符非法、只读 SQL 校验不通过、目标表没有可识别字段等 | 直接用 `message` 提示 |
| `2001` | 保存失败 | 写入时的数据库错误,`data` 带 `table` / `action` / `index` / `cause` | 提示 `message`;`data.index` 可定位到第几行 |
| `2002` | 保存前置条件不成立 | 请求声明的 guard 条件不满足,`data` 带 `table` / `action` / `index` | 直接用 `message`(那是写条件的人给的业务文案) |
| `2003` | 并发编辑冲突 | `$original` 原值或 `b_row_version` 不匹配,`data` 带 `fields` | 提示后**重新拉取该行**,标出 `fields` 里的字段 |
| `500` | 系统异常 | 未预期的异常 | 提示「系统异常,请稍后重试」 |
</G3ApiParams>
参数类错误都是 `1000`,`message` 形如 `view_name 不能为空`、`page_no 必须是大于 0 的整数`、`参数类型不正确`、`非法数据库标识符: xxx` —— 通常已经说清是哪个参数错了。
## 鉴权
### 登录
<G3ApiPayload title="请求">
```bash
curl -X POST http://localhost:8088/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"orgid":"G3HD","userid":"admin","password":"******"}'
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9...",
"orgid": "G3HD",
"user": { "id": "g3soft", "account": "g3soft", "name": "G3Soft" }
}
}
```
</G3ApiPayload>
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `orgid` | string | 是 | 机构码,决定连哪个机构的数据库 |
| `userid` | string | 是 | 账号;字符串业务键方案下 `b_user.b_id` 就是账号本身 |
| `password` | string | | 与库里存的密码比对 |
</G3ApiParams>
机构码或账号为空 → `1000`(`机构码不能为空` / `用户ID不能为空`);账号、密码、机构任一对不上 → 统一 `401` + `机构码、账号或密码错误`(刻意不区分是哪一个错)。
### 带上凭证
除 `/api/auth/login` 与 `OPTIONS` 预检外,**所有接口都要带**:
<G3ApiPayload title="请求头">
```
Authorization: Bearer <token>
```
</G3ApiPayload>
> **机构码不用在业务接口里传。** token 里带着 `orgId`,服务端据此把请求路由到该机构自己的数据库,前端只管带 token。
>
> token 默认 24 小时过期(`erp.auth.jwt-expiration`)。同一账号再次登录会让旧 token 失效并返回 `40101`。
## 最小可跑示例
<G3ApiPayload title="示例代码">
```js
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('g3erp:token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// HTTP 200 也可能是业务失败,所以要在这里统一判 code
api.interceptors.response.use((res) => {
const { code, message } = res.data
if (code === 401 || code === 40101) {
localStorage.removeItem('g3erp:token')
location.href = '/login'
}
return res.data
})
// 分页查询
const { data } = await api.post('/data/page', {
view_name: 'v_b_othercompany',
search_columns: ['b_id', 'b_name'],
search_condition: 'b_canuse = 1',
order_by: 'b_id ASC',
page_no: 1,
page_size: 20,
})
// data.rows / data.total / data.page_no / data.page_size
```
</G3ApiPayload>
## 一个必须知道的序列化约定
**所有长整型(`Long`)都以 JSON 字符串输出**(后端 `JacksonConfig` 给 `Long` / `long` 注册了 `ToStringSerializer`):
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": "1873456789012345678"
}
```
</G3ApiPayload>
原因是雪花 ID 是 64 位,超过 JS 的 `Number.MAX_SAFE_INTEGER`(2⁵³−1),按数字解析会**静默丢精度**。所以 `data.nextid`、行里的各种 id 一律当 `string` 处理,不要 `Number()` / `parseInt()`,也不要参与算术。
## 接口一览
- **数据查询** —— [数据查询](/api/data):`/data/describe`、`/data/loaddata`、`/data/page`、`/data/loaddatabysql`、`/data/nextid`、`/data/nextcode`
- **数据写入** —— [数据写入](/api/save):`/data/saveobjt`、`/data/delete`
- **鉴权** —— 本页:`/auth/login`
- **文件** —— 暂无专页:`/file/config`、`/file/upload`、`/file/upload-credential`、`/file/save-record`、`/file/delete`、`GET /file/static/{fileName}`
- **导出** —— 暂无专页:`/export/list`、`/export/count`、`/export/totals`(**返回文件流,不是 `ApiResponse`**)
> `/file/static/{fileName}` 同样受鉴权过滤(只有 `/auth/login` 被放行),所以不能用 `<img src="...">` 直接访问,需要按 blob 取回再用 `URL.createObjectURL`。
## 联调步骤
1. 起后端:`cd server && ./mvnw spring-boot:run`(默认 8088);前端 dev server 的 `/api` 代理到它。
2. 调 `/api/auth/login` 拿 token,存进 `localStorage`(键名沿用组件库示例里的 `g3erp:token`)。
3. 用 `/api/data/describe` 看目标表或视图有哪些列,再调 `/api/data/page` 取数据。
4. 拿到 `code != 0` 先查上面的错误码表;`1000` 的 `message` 基本能直接告诉你哪个参数不对。
+213
View File
@@ -0,0 +1,213 @@
---
title: 数据写入
description: saveobjt / delete,含 $original、$expr 与 guards
pageClass: g3-api
---
# 数据写入
## `POST` `/data/saveobjt`
请求体是一个**数组**:一次可提交多个表的增、改、删,**全部在同一个事务里** —— 任何一项失败整批回滚,不会留下半套数据。
### 请求字段
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `table` | string | 是 | 保存目标表 |
| `key_field` | string | 是 | 主键列名,用于定位要改/删的行 |
| `updates` | object[] | | 要更新的行,主键值必须在行里 |
| `inserts` | object[] | | 要新增的行 |
| `deletes` | object[] | | 要删除的行(用 `key_field` 定位) |
| `delete_target` | string | | 声明 `deletes` 走哪条路径:`module`(按模块配置联动删子表,需 `moduleCode`)或 `direct`(直接删,需 `table` / `key_field`) |
| `guards` | object | | 前置条件,见下 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
[
{
"table": "b_othercompany",
"key_field": "b_id",
"updates": [{ "b_id": "C001", "b_name": "新名字" }],
"inserts": [{ "b_id": "C002", "b_name": "新公司" }],
"deletes": [{ "b_id": "C003" }]
}
]
```
</G3ApiPayload>
成功时 `data` 为 `null`,`message` 为 `保存成功`:
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "保存成功",
"data": null
}
```
</G3ApiPayload>
> **没传 `delete_target` 时,`deletes` 是逐行按主键直删**,不会触发模块配置里的联动删除。需要联动就把 `delete_target` 声明成 `module` 并带上 `moduleCode`。
### 行内的两个保留键
键名以 `$` 开头的不会被当成业务列:
<G3ApiParams title="行内保留键">
| 键 | 用途 | 说明 |
|---|---|---|
| `$original` | 乐观锁:声明「我改动之前看到的值」 | 这些字段**只进 `WHERE` 不进 `SET`**;对不上就是 `2003`。值为 `null` 时生成 `IS NULL` |
| `$expr` | 字段自增:`{"$expr": "b_amount + 5"}` | 只支持「本表列 ± 数值常量」,列必须是数值类型 |
</G3ApiParams>
<G3ApiPayload title="行内示例">
```json
{
"b_id": "C001",
"b_amount": { "$expr": "b_amount + 5" },
"$original": { "b_name": "旧名字", "b_row_version": 3 }
}
```
</G3ApiPayload>
- `$original` 里带 `b_row_version` 时走**整行版本锁**:服务端校验通过后自动 `+1`,请求里同名的普通值不生效(避免 `SET` 里出现两次该列)。
- `inserts` 的行**不允许**带 `$original` / `$expr` —— 新增的行没有原值可比,带了会直接报 `1000`。
- `$expr` 的字段不存在、不可写、不是数值类型、格式不对,都会在**任何一行写入之前**报 `1000`(这类参数错误不会等到写到第 N 行才暴露)。
### guards:写入前置条件
让「判断」和「写入」发生在**同一条 SQL** 里,避免先查后写的竞态。
<G3ApiPayload title="带 guards 的请求体">
```json
{
"table": "b_othercompany",
"key_field": "b_id",
"updates": [{ "b_id": "C001", "b_name": "新名字" }],
"guards": {
"updates": [
{
"condition": "{b_state} = 'draft'",
"refs": ["b_othercompany"],
"message": "只有草稿状态才能改名",
"retryable": false
}
]
}
}
```
</G3ApiPayload>
`guards` 下的三个分节键按动作区分 —— `inserts` / `updates` / **`deletes`**(注意是 `deletes`):
<G3ApiParams title="guards 字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `condition` | string | 是 | SQL 布尔表达式;`{列名}` 是受控占位符,替换为 `t.[列名]`;**不允许注释** |
| `refs` | string[] | | 声明引用的表,只进日志 |
| `message` | string | | 条件不成立时给用户看的文案 |
| `retryable` | boolean | | 默认 `true`;`false` 时前端文案不追加「请刷新后重试」 |
</G3ApiParams>
条件不成立时返回 `2002`,`message` 就是上面写的 `message`,`data` 带 `table` / `action` / `index`。
两条互斥规则(同时用会报 `1000`):
- `guards.deletes` 与 `delete_target` 互斥;
- `guards.deletes` 与 `dependent_deletes` 互斥。
原因是带条件的删除只能逐行唯一定位,而 `delete_target` / 依赖删除那两条旁路拿不到 guards —— 与其让条件被静默忽略,不如直接拒绝。
### 三个错误码怎么分
<G3ApiParams title="错误码" variant="bare">
| code | 含义 | 典型原因 | `data` 里的定位信息 |
|---|---|---|---|
| `2001` | 保存失败 | 字段不存在、类型不匹配、数据库约束冲突等 | `table` / `action` / `index` / `cause` |
| `2002` | 前置条件不成立 | `guards` 里的条件没满足 | `table` / `action` / `index` |
| `2003` | 并发编辑冲突 | `$original` 的原值已被别人改过、`b_row_version` 不匹配 | `table` / `action` / `index` / `fields` |
</G3ApiParams>
`2003` 的正确处理不是重试,而是:**重新拉取该行**,用 `data.fields` 标出哪些字段已被他人修改,让用户决定覆盖还是放弃。
`action` 的取值是 `update` / `insert` / `delete`,`index` 是请求数组里的下标(从 `0` 开始)—— 批量保存失败时靠它定位到具体哪一行。
## `POST` `/data/delete`
独立删除接口。和 `saveobjt` 的区别是它**不进保存事务**、也不涉及增改。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `target` | string | 是 | `module` 或 `direct`,其它值报 `1000` |
| `moduleCode` | string | 是(`target=module`) | 模块编码(`s_module.b_id`) |
| `table` | string | 是(`target=direct`) | 表名 |
| `key_field` | string | 是(`target=direct`) | 主键列名 |
| `ids` | any[] | | 要删除的主键值;`direct` 下为空数组会直接返回 `{}` |
</G3ApiParams>
按模块删除(会按模块配置联动删除子表):
<G3ApiPayload title="请求体">
```json
{
"target": "module",
"moduleCode": "othercompany",
"ids": ["C001", "C002"]
}
```
</G3ApiPayload>
指定表直接删除:
<G3ApiPayload title="请求体">
```json
{
"target": "direct",
"table": "b_othercompany",
"key_field": "b_id",
"ids": ["C001"]
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": { "b_othercompany": 2 }
}
```
</G3ApiPayload>
`data` 是 **`{表名: 实际删除行数}`** —— `direct` 只有一项;`module` 会带出所有被联动删除的表,所以可能有多项。整个删除同样在单个事务里完成。
> `target` 必须显式声明。后端**刻意不做**「没传 `moduleCode` 就当直删」这种隐式分支,避免漏传参数时静默绕过模块规则。