# 键值数据存储 (KV)

> 分类代号：C.8  本章共 **5 个接口**

vjmap 内置了一个 KV 数据表（SQLite），用来存用户配置、缩略图、缓存数据等零碎东西。注意它是**全局共享**的——后端不接受 `workspace` 参数，多工作区部署时用 key 前缀自行区分。"零碎"并不代表"不好用"——二开里非常常用，比如存每个用户的图层可见性、自定义地图标注等。

**存储位置**：后端 `data/customData.db`。  
**自动前缀**：所有用户可见 key 都加了隐形 `data_` 前缀，本 SDK 已屏蔽此细节。  
**TTL**：可选过期时间（秒），到期自动清理。  
**批量 key 分隔符**：路径里 `;;`（双分号）分隔多个 key。

> ⚠️ **key 字符限制**：key **不能含 `/`**——`saveCustomData('ws_a/theme')` 无论编码 `%2F` 还是原样都 404（路由 `data/{key}` 是单段匹配）。命名空间分隔**推荐 `:`**，如 `ws_a:theme`；不要用 `_` / `%` 当分隔符（前缀查询/删除里它们是 SQL 通配符，见下），也不要含 `'`。
>
> ⚠️ **TTL 到期语义**：到期后 GET **不是**返回 `{"status":"keyNoExist"}`，而是 **HTTP 403 `{code:7,msg:"key ttl expire"}`**——SDK 会当 HTTP 错误**抛异常**，必须 `try/catch`，不能 `if (d.status==='keyNoExist')`。残留键仍可 `deleteCustomData` 正常删除。
>
> ⚠️ **前缀匹配是 SQL `LIKE`**（后端拼成 `key like '<data_ + prefix>%'`，字符串直接拼进 SQL）：
> - 无分隔符保护：`deleteCustomData('ws_a', {isPrefix:true})` 会把 `ws_ax`、`ws_a2` 一并删掉——前缀要自带分隔尾缀（如 `ws_a:`）
> - `_` 匹配任意单字符、`%` 匹配任意串：前缀 `a_b:` 也会命中 `axb:...`
> - SQLite `LIKE` 对 ASCII **不区分大小写**：前缀 `ABC:` 会命中 `abc:...`
> - 前缀里含 `'` 会破坏 SQL（报错）
>
> `datakeys?prefix=` 列键与 `isPrefix` 删除同受影响。

---

## 快速索引

| 序号 | SDK 方法 | URL |
|----:|---------|-----|
| 53 | `get_custom_data` / `getCustomData` | `GET /api/v1/service/data/{key}` |
| 54 | `save_custom_data` / `saveCustomData` | `POST /api/v1/service/data/{key}` |
| 55 | `delete_custom_data` / `deleteCustomData` | `DELETE /api/v1/service/data/{key}` |
| 56 | `save_multiple_custom_data` / `saveMultipleCustomData` | `POST /api/v1/service/datas` |
| 57 | `get_custom_data_keys` / `getCustomDataKeys` | `GET /api/v1/service/datakeys?prefix=` |

---

## 53 · 获取值 `data/{key}` GET

### 查询参数

| 字段 | 取值 | 说明 |
|------|------|------|
| `retDataType` | `""` / `"value"` / `"prop"` | 默认返回 `{data, prop}`；`value` 只要 `data`；`prop` 只要 `prop` |
| `contentType` | `""` / `"image"` | `image` 时后端把 base64 dataURL 解出，直接返回 PNG 二进制（不是 JSON）|

批量：`key` 路径段用 `;;` 拼接多 key，SDK 传字符串列表即可。

### 响应

- 存在：`{ "status": "ok", "data": <value>, "prop": <prop> }`
- 不存在：`{ "status": "keyNoExist" }`（**不是 404**）
- 批量：`{ "values": [...], "props": [...], "status": [...] }`

### 实采

```json
// get_custom_data.json
{
  "data": "hello-vjmap",
  "prop": "text/plain",
  "status": "ok"
}
```

```json
// get_missing.json —— 一定要判断 status
{ "status": "keyNoExist" }
```

### Python

```python
d = svc.get_custom_data("myproj:theme")
if d["status"] == "keyNoExist":
    ...
else:
    theme = d["data"]
```

### Node.js

```javascript
const d = await svc.getCustomData('myproj:theme');
if (d.status === 'keyNoExist') { /* ... */ }
```

### 批量读

```python
svc.get_custom_data(["a", "b"])
# { "values": ["va","vb"], "props":["pa","pb"], "status":["ok","ok"] }
```

---

## 54 · 保存值 `data/{key}` POST

### 查询参数

| 字段 | 说明 |
|------|------|
| `ttl` | 秒；到点清除。省略或 `0` = 长期 |

### Body

`application/json`：`{"value": "...", "prop": "..."}`；`prop` 可存元数据（MIME、扩展字段）。

> ⚠️ **不要用 raw body**：后端先 `ShouldBindJSON`，失败才 `GetRawData()`——但 body 已被 JSON 解码器读走，取到的只是剩余部分：小 body **存成 `""`**，大 body **被截掉开头**（20000 字节只剩 19488），响应却照样 `{"status":"ok"}`。同理 `value`/`prop` 必须是 **string**（Go `Value string` 绑定）：传对象/数字会绑定失败 → 走 raw 分支 → **静默存成 `""`**。对象请先 `JSON.stringify`。
>
> 二进制请 base64 后放进 `value`：SDK `saveCustomData(key, {binary, mime})` / `save_custom_data(key, binary=..., mime=...)` 自动 base64，并把 `prop` 记为 `base64;<mime>`（缺省 `application/octet-stream`）；读回后 `Buffer.from(d.data, 'base64')` / `base64.b64decode(d["data"])`。

### 响应（实采）

```json
{
  "data": "nb_kv_a_1776832185",
  "status": "ok"
}
```

### Python

```python
svc.save_custom_data("myproj:theme",
                    value='{"mode":"dark"}',
                    prop="application/json")

# 带 TTL
svc.save_custom_data("tmp:token",
                    value="eyJ...", ttl=3600)

# 二进制（SDK 转 base64 存进 value，prop="base64;image/png"）
svc.save_custom_data("user42:avatar",
                    binary=open("avatar.png", "rb").read(), mime="image/png")
png = base64.b64decode(svc.get_custom_data("user42:avatar")["data"])
```

### Node.js

```javascript
await svc.saveCustomData('myproj:theme', {
  value: JSON.stringify({ mode: 'dark' }),
  prop: 'application/json',
});

await svc.saveCustomData('tmp:token', { value: tokenStr, ttl: 3600 });

await svc.saveCustomData('user42:avatar', { binary: fs.readFileSync('avatar.png'), mime: 'image/png' });
const png = Buffer.from((await svc.getCustomData('user42:avatar')).data, 'base64');
```

---

## 55 · 删除值 `data/{key}` DELETE

### 查询参数

| 字段 | 说明 |
|------|------|
| `isPrefix` | `"true"` 时把 `key` 当前缀，批量清除；否则只删单条 |

### 响应

- 单条（实采）：`{"data":"nb_kv_a_...","status":"ok"}`
- 前缀（实采）：`{"data":"nb_prefix_...","isPrefix":true,"status":"ok"}`

### Python

```python
svc.delete_custom_data("myproj:theme")

# 前缀批量
svc.delete_custom_data("myproj:user42:", is_prefix=True)

# 列表批量（走 ;; 分隔）
svc.delete_custom_data(["a", "b", "c"])
```

---

## 56 · 批量存值 `datas` POST

**URL**：`POST /api/v1/service/datas`

### Body

`[{"key":..,"value":..,"prop":..,"ttl":"60"}]`，注意 `ttl` 是**字符串**秒数。

### 响应（实采）

```json
{ "status": ["ok"] }
```

### Python

```python
svc.save_multiple_custom_data([
    {"key": "cfg:a", "value": "1"},
    {"key": "cfg:b", "value": "2", "ttl": "3600"},
])
```

### Node.js

```javascript
await svc.saveMultipleCustomData([
  { key: 'cfg:a', value: '1' },
  { key: 'cfg:b', value: '2', ttl: '3600' },
]);
```

> ⚠️ **403 多半是 body 绑定失败，不是权限**：后端用 `ShouldBindJSON` 绑 `[{key,value,prop,ttl}]`，四个字段都是 Go `string`——`ttl: 3600`（数字）或非字符串 value 会绑定失败，走失败响应 → `code:7` + **HTTP 403**（网关的失败响应一律映射 403），`msg` 是 `json: cannot unmarshal number into Go struct field DataPostReqType.ttl of type string` 这类。同 token 下全字符串 body 正常返回 `{"status":["ok"]}`。真权限问题的 `msg` 是 `权限不足`。

---

## 57 · 按前缀列键 `datakeys` GET

### 查询参数

| 字段 | 说明 |
|------|------|
| `prefix` | 前缀（自动加 `data_`；SQL `LIKE` 匹配，注意 `_`/`%` 通配与大小写不敏感，见文首） |
| `retDataType` | `value` / `prop` / `valueprop` —— 同时取出值，省一次往返 |
| `isSys` | `true` 时 prefix **不补** `data_`，且返回的是**整行对象**数组 `{"keys":[{"Key","Value","Prop","Ttl"}]}`（`Key` 为库内原始键如 `data_xxx`，`Ttl` 为到期 Unix 秒、0=永久；不做过期过滤；`retDataType` 不生效） |

### 响应

- 仅 keys（实采）：

```json
{ "keys": ["nb_kv_a_1776832185", "nb_kv_b_1776832185"] }
```

- 带值（`retDataType=valueprop`，实采）：

```json
{
  "keys":   ["nb_kv_a_1776832185", "nb_kv_b_1776832185"],
  "props":  ["text/plain",          "bp-1"],
  "values": ["hello-vjmap",         "batch-1"],
  "status": ["ok",                  "ok"]
}
```

### Python

```python
keys = svc.get_custom_data_keys(prefix="myproj:")["keys"]

# 同时取值
bundle = svc.get_custom_data_keys(prefix="myproj:", ret_data_type="valueprop")
for k, v, p in zip(bundle["keys"], bundle["values"], bundle["props"]):
    print(k, v, p)
```

---

## 最佳实践

1. **前缀分层**：用 `:` 建立命名空间（**不能**用 `/`，路由不吃斜杠；`_`/`%` 在前缀匹配里是 LIKE 通配符，别当分隔符），如 `myproj:user42:tool-state`，前缀统一以 `:` 结尾再批量删除、列出。
2. **大文件不要存 KV**：value 单条一般 < 1 MB；大图/DWG 用 `/map/uploadmap` 或独立对象存储。
3. **TTL 清理**：用 `ttl` 让后端帮忙过期；别自己写 crontab。注意到期读取是 **HTTP 403 `key ttl expire`**（抛异常），不是 `keyNoExist`。
4. **`keyNoExist` ≠ 错误**：200 正常响应里 `status == "keyNoExist"` 表示没值，而不是失败。

---
