# 后台管理 API

[文档首页](./README.md) · [通用协议](./protocol.md)

基地址 `https://wristhyme.tolike.cn`，页面 `/admin`，接口 `/admin/api/*`。本文只使用占位凭据；不提供真实密码、Cookie、CSRF 或内部密钥。

## 1. 鉴权与请求格式

| 请求 | 必需条件 |
| --- | --- |
| POST login | `Origin: https://wristhyme.tolike.cn`、JSON对象，不要求已登录或CSRF |
| 其他GET | 有效管理Cookie |
| 其他POST | 有效Cookie + 完全相等的Origin + `X-CSRF-Token` |

会话Cookie名 `__Secure-wristhyme_admin`，属性 `Path=/admin; Secure; HttpOnly; SameSite=Strict; Max-Age=28800`。8小时固定会话，服务端最多保存20个；不是JWT，不通过Authorization头传递。

除登录外写接口先校验会话/同源/CSRF，再检查写频率并解析body。错误：401未登录/过期；403Origin或CSRF不匹配。GET也应同源使用，但后端读接口没有与POST相同的Origin检查。

POST必须application/json、一个Content-Length、JSON对象，不支持Transfer-Encoding；普通body最多1450000字节，登录4096字节。即使logout等无字段操作也发送`{}`，不能零长度body。

通用错误为`{error:string}`；成功修改通常`{ok:true}`，不返回修改后对象或新生成ID。操作后通过catalog/dashboard读取结果。后台没有异步jobID或统一幂等键；网络失败可能已经完成写入，重复提交前先读状态。

文本字段通常要求string、去两端空白、禁止控制字符；长度检查在strip前，未注明可选的文本不能为空。enabled/trust/hours等要求真实JSON类型，字符串false/24不等价。

12并发槽、单任务导入；同会话每分钟80个POST。没有通用事务批量更新或资源版本/If-Match保护，不假定并发管理员编辑能自动避免覆盖。

## 2. 登录、会话与密码

### 2.1 `POST /admin/api/login`

body：`{username:string,password:string}`。

```json
{"username":"admin","password":"<ADMIN_PASSWORD>"}
```

200：`{csrf:string,username:string}`，并Set-Cookie。username取服务器配置；示例admin不能代替配置。密码校验采用scrypt，登录输入长度12..256，非法长度最终表现为登录失败。

401账号或密码不正确；403登录Origin不符；429每IP15分钟8次或全局80次限制；400请求结构无效；503服务失败。

### 2.2 `GET /admin/api/session`

200：`{csrf:string,username:string}`。页面刷新可恢复会话与CSRF，不必再传密码。不会自动延长固定会话；无效会话401。

### 2.3 `POST /admin/api/logout`

body `{}`；200 `{ok:true}`，清除当前服务端会话并Set-Cookie Max-Age=0。仅退出当前Cookie，不是注销所有管理员会话。

### 2.4 `POST /admin/api/password`

| 字段 | 类型 | 必需 | 校验 |
| --- | --- | --- | --- |
| `old` | string | 是 | 原密码，最多256字符 |
| `new` | string | 是 | 新密码16..128字符 |

200：`{ok:true,relogin:true}`。成功后全部管理会话失效，需要重新登录；请求响应不保证立即清当前浏览器Cookie，但服务端不再接受它。

400原密码错误或新密码长度不符；其余通用鉴权/频率错误。修改的是独立后台密码，不是SSH或平台音乐账号密码。

## 3. 查询

### 3.1 `GET /admin/api/dashboard`

query `page`可选默认1；转整数后钳制1..1000，非整数400。每页最多100条，当前接口不接受服务端search/variant/online筛选；UI筛选只是当前已取回数据。

200：

```json
{
  "stats":{
    "device":{"retained":10,"today_active":4,"online":2},
    "legacy_ip":{"retained":8,"today_active":3,"online":1}
  },
  "devices":[
    {"kind":"device","id":"11111111111111111111111111111111","ip":"192.0.2.10","first":1790810000,"last":1790812800,"requests":12,"version":"2.2.2","variant":"phone","online":true}
  ],
  "page":1,
  "online_window_seconds":300,
  "retention_days":30,
  "notice":"设备=自愿注册的匿名安装实例，不是人数/物理设备；旧客户端仅记录独立IP，不能据此统计用户。在线=最近5分钟API活动。无硬件指纹、关键词或播放内容采集。"
}
```

统计和示例IP均为合成说明数据。`first/last`为Unix秒，`requests`是合并上报活动次数，不是音乐播放/下载次数。`today_active`代码实现是滚动86400秒，**不是北京时间自然日零点起算**。

`legacy_ip`是历史类型名，现在也用于未提供有效凭证的访问；不得全部标作旧客户端。响应没有总页数、记录总数或has_more，不能假定UI下一页按钮有精准总量。

### 3.2 `GET /admin/api/catalog`

无参数。200返回：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `default_plugin` | string | 原始配置默认ID，可空 |
| `effective_default_plugin` | string | 当前有效默认ID，可空 |
| `plugins` | AdminPlugin[] | 所有后台插件，包含禁用项；创建时间降序 |
| `sources` | AdminSource[] | 所有音源，包含禁用项；内置优先 |
| `bans` | Ban[] | 创建时间降序，最多500条，可能含已过期项 |
| `audit` | Audit[] | 最新最多100条 |

**AdminPlugin：**

| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `id` | string | 16位SHA256前缀 |
| `name` / `version` | string | 展示名称/脚本版本 |
| `sha256` | string | 完整64位内容哈希 |
| `filename` | string | 服务端保存文件名，不是客户端下载URL |
| `managed` | integer 0/1 | 受管导入文件还是只读原始文件 |
| `enabled` | integer 0/1 | SQLite行原始启用值；注意不同于写入时boolean |
| `origin` | string | 原始本地/local-upload/固定GitHub来源 |
| `created` | integer | Unix秒 |

**AdminSource：**`{id,name,backend,plugin,enabled,builtin}`。enabled/builtin为SQLite整数0/1，plugin可空。AdminSource和公开Source不完全相同。

**Ban：**`{id,kind,target,reason,created,expires}`。id为16位随机hex，kind=device/ip，target为设备ID或规范化CIDR；时间Unix秒，expires=0永久。过期项可留在目录中但不再生效。

**Audit：**`{id,at,action,target,detail}`。id整数，at Unix秒。action包含login/logout/password_changed、ban_device/ban_ip/unban、plugin_update/plugin_import、source_save/source_delete、forget_statistics/device_statistics_optout/default_plugin及迁移审计标记；它不是封闭枚举。审计最多10000条，并周期清理30天前记录。

该目录含运营IP、脚本来源等私有信息，不应像公开plugins目录一样发送给客户端或公开网站。

## 4. 封禁及统计清理

### 4.1 `POST /admin/api/ban`

| 字段 | 类型 | 必需 | 约束 |
| --- | --- | --- | --- |
| `kind` | string | 是 | device/ip |
| `target` | string | 是 | 最多100字符；device为32位小写hex，ip可为地址或CIDR |
| `reason` | string | 否 | 默认空，最多200字符 |
| `hours` | integer | 否 | 默认24，0..8760；0永久，不接受boolean |

IP由ip_network(strict=false)规范化；IPv4网段至少/16，IPv6至少/32，拒绝过大网段。示例：

```json
{"kind":"ip","target":"192.0.2.10","reason":"示例限制","hours":24}
```

200 `{ok:true}`。同kind/target已有封禁会被替换，并生成新封禁id；最大2000条。条数检查在替换前执行，满额时同target替换也可能被拒绝。

限制音乐API，不等于限制后台登录或静态发布下载。隐私说明和持有效凭证的设备forget例外仍开放。400格式/期限/容量错误；其他鉴权/频率错误见通用规则。

### 4.2 `POST /admin/api/unban`

body `{id:string}`：**封禁记录ID**，不是设备ID/IP，文本最大32。200 `{ok:true}`，解除后投影策略；不存在的记录也可能成功，不保证404。

### 4.3 `POST /admin/api/forget`

body `{kind:"device"|"legacy_ip",id:string}`，id为32位小写hex活动记录ID。200 `{ok:true}`。

只清活动记录，不解除封禁，不删除插件/音源，不写用户撤回标记；后续活动可以再次建记录。不存在的匹配活动可能也成功。

## 5. 插件管理

### 5.1 `POST /admin/api/plugin`

| 字段 | 类型 | 必需 | 约束 |
| --- | --- | --- | --- |
| `id` | string | 是 | 已存在插件ID，最多16字符 |
| `name` | string | 是 | 非空、最多80字符 |
| `enabled` | boolean | 是 | 不接受数字/string替代 |
| `trust` | boolean | 由禁用转启用时需要 | 必须true，确认信任第三方脚本 |

200 `{ok:true}`。修改名称只改展示元数据，不重写原始脚本；恢复后的运行核心仍使用文件stem作为currentScriptInfo.name语义。

启用时检查文件存在、非符号链接、完整SHA256一致。禁用会隐藏公开目录项并阻止之后的解析/已有票据请求；不表示已开始的流能立即被强制中断。

400插件不存在、trust缺失、名称不合法或文件/哈希错误。当前没有公开插件删除接口；不要把enabled=false当删除。

### 5.2 `POST /admin/api/default-plugin`

body `{id:string}`，最多16字符，允许空字符串清除配置。非空必须是已启用插件且文件/哈希验证有效。

200 `{ok:true}`，随后GET catalog确认配置值/有效值。禁用原默认时可回退，重新启用原默认时恢复；具体算法见协议文档。

此设置并不强制覆盖旧客户端手动插件选择，也不使 `/api/media` 自动变为插件流。当前Android2.2.2会在解析时读有效默认并使用稳定别名。请求失败时不要以另一个插件成功掩盖错误。

### 5.3 `POST /admin/api/import`

后台插件管理开放本地文件上传与GitHub固定提交导入。导入默认禁用，需另行启用/trust；导入不会执行脚本或证明其安全、兼容、可播放。

共同可选字段 `sha256`：非空时必须64位hex并和内容哈希一致，大小写可接受。

**方式A：上传**

```json
{"method":"upload","filename":"example.js","content_base64":"<BASE64_UTF8_JAVASCRIPT>","sha256":"<EXPECTED_SHA256>"}
```

filename为普通.js名称，1..160字符前缀，不能有路径分隔、控制字符；content_base64必须严格标准Base64，字符串不超过1400000字符。解码后文件1字节..1MiB，UTF-8/UTF-8 BOM，可包含非ASCII名称/内容；拒绝NUL、常见HTML/XML。允许任意JavaScript内容，不要求LX字符串标识，不在导入时执行或判定语法兼容性；运行时仍仅支持LX协议，不自动兼容Node/CommonJS/ES模块或其他音源协议。

**方式B：GitHub固定文件**

```json
{"method":"github","url":"https://github.com/OWNER/REPO/blob/<40_HEX_COMMIT>/path/example.js","sha256":"<EXPECTED_SHA256>"}
```

或raw.githubusercontent.com/OWNER/REPO/完整commit/path/example.js。地址最多1500字符；只HTTPS，端口默认/443，无凭证/query/fragment。每段路径只允许普通ASCII字母数字`_.-`且1..100字符，禁止`.`/`..`、转义、分支main/master、目录/仓库/压缩包链接。commit必须完整40位hex，末尾.js。

服务器规范化为固定raw地址，通过公共DNS/SSRF策略获取，不接受跳转或压缩响应，超时8秒；未知HTTP/下载失败按400或503等分支报告，不保证统一“网络错误码”。

新导入200：

```json
{"id":"0123456789abcdef","name":"示例插件","version":"1.0.0","sha256":"<SHA256>","enabled":false,"duplicate":false,"validation":"仅UTF-8、大小、格式线索和哈希检查，未执行脚本；不代表安全或兼容"}
```

重复同内容200：`{id,sha256,duplicate:true,enabled:boolean}`，字段比新导入少，不覆盖名称、启用状态或原文件。ID为SHA256前16位；短ID冲突拒绝覆盖。插件最多100个，导入文件按完整哈希保存。

400内容、地址、哈希或容量校验失败；429已有导入/写频率/并发；503未完成异常。只有GitHub方式涉及网络，离线测试通过不代表真实GitHub取回成功。

## 6. 音源目录

### 6.1 `POST /admin/api/source`

| 字段 | 类型 | 必需 | 校验 |
| --- | --- | --- | --- |
| `id` | string | 否 | 无/空时生成custom_+12位hex；存在时[a-z0-9_]{1,48} |
| `name` | string | 是 | 非空、最多80字符 |
| `backend` | string | 是 | 已实现基础平台之一 |
| `plugin` | string | 否 | 默认空；非空须为已有插件，允许目录关联暂时禁用插件 |
| `enabled` | boolean | 是 | 真正boolean |

200 `{ok:true}`。无id新增后通过catalog找生成ID；不返回新source。基础平台只能现有十二种；新增自定义项是平台别名/展示配置，**不是新平台适配器或任意URL数据源**。

内置平台ID不能改backend；可改名、启停、关联插件。新增时builtin=0，已有内置项保持builtin身份。音源最多100项。自定义项启用但基础平台禁用时不会在公开sources显示。

400未知平台、enabled类型错误、关联插件不存在、内置身份变更或容量/文本错误。

### 6.2 `POST /admin/api/source/delete`

body `{id:string}`。200 `{ok:true}`。只删除自定义项；内置音源400“只能禁用或改名”。不存在的ID可能正常成功。删除目录别名不等于删除歌曲、本地库或基础平台适配代码。

后台音源目录开放新增表单，可创建现有平台别名、选择基础平台和关联已启用插件。前端新增默认禁用，需管理员随后手动启用。

## 7. 错误分支与运维

后台通用400文案可能为“请求参数无效，请核对格式、期限、脚本地址或校验值”；已认证/同源验证后的业务ValueError会返回更具体的文本，最多200字符。未预期异常503“操作未完成”，私有服务日志用于诊断，不应向客户端暴露配置或脚本token。

后台写入SQLite并投影只读策略/运行时注册表。出现网络错误或投影失败时，不保证数据库操作完全未发生。重新GET catalog/dashboard核对状态后再重试，尤其是注册、新增、导入和封禁。

后台设备/IP数据、数据库备份、password_hash/internal_secret、真实Cookie和管理导入来源都应按私有运营数据处理。文档不应随着发布源码包上传这些数据。

## 8. API 运维

### `GET /admin/api/maintenance`

返回 `{enabled:boolean,message:string,retry_after:integer,paths:string[]}`。paths为空表示全局维护。

### `POST /admin/api/maintenance`

同样结构，message为1..200字符，retry_after为30..86400秒，paths仅允许已知音乐接口路径，不接受通配符/管理接口。SQLite持久化并投影策略；新请求返回503、`code: maintenance`和Retry-After，健康/隐私/撤回不阻断，已有流不强制终止。示例域名的Caddy硬性403不会被模拟维护开关打开。

## 9. 发布管理

自建后台提供真实上传/发布功能。示例后台仅浏览器内模拟，任意非空密码可进入，实际`/admin/api/*`均403；上传文件不发送到服务器，刷新页面丢弃演示状态。自建真实后台不接受任意密码。

### `GET /admin/api/releases`

200 `{files:[],feeds:{}}`。file包含filename、size、sha256、uploaded_at及url，feed按phone/watch/desktop/ios键组织。

### `POST /admin/api/releases/begin`

JSON `{filename,size,sha256?}`：普通ASCII文件名1..160，大小1字节..512MiB，apk/ipa/zip/exe/msi/dmg/deb/rpm/appimage后缀；同名不覆盖。最多4个未完成上传、100个发行文件，未完成上传24小时清理。返回 `{upload_id,chunk_size:4194304,offset:0}`。

`backend-source.zip`为对应源码保留名，不允许发行上传覆盖；上传和最终落盘会检查可用存储空间。私有与公开文件可分处不同卷，先在目标卷复制、fsync再原子发布，不跨卷直接重命名。

### `POST /admin/api/releases/chunk`

query `id`为上传ID，`offset`为当前偏移；Content-Type为application/octet-stream，单块1字节..4MiB，明确Content-Length，同源Cookie/CSRF仍必需。返回 `{offset}`，偏移错位400。每会话每分钟最多1024块，最多单文件512MiB；代理max_size应至少5MB。

### `POST /admin/api/releases/finish`

JSON `{upload_id}`。校验总长度、SHA256和APK/IPA/ZIP容器，APK检查AndroidManifest.xml存在；不验证应用ID、实际版本、证书或恶意内容，发布者需自行审核。返回 `{ok,file,validation}`；不完整/哈希不符400，文件仍可取消。

### `POST /admin/api/releases/cancel`

JSON `{upload_id}`。删除该未完成上传，返回ok，不删除已完成发行文件。

### `POST /admin/api/releases/publish`

JSON `{channel,filename,versionName,versionCode,minSdk?,notes?}`，versionName为三段数字，versionCode正整数且大于当前渠道值，notes最多4000字。Android固定包名，phone文件名Wristhyme-Phone-VERSION.apk、watch为Wristhyme-VERSION.apk，minSdk23..100。发布前人工确认包名/签名，界面提供确认弹窗。

原子manifest是渠道唯一数据源；公开GET `/downloads/latest-{channel}.json`由admin生成，apkUrl/URL使用ADMIN_ORIGIN和发行文件路径，Android文件位于`/downloads/NAME.apk`。保持历史客户端字段合同，但用户应用仍需改base/更新策略才能接新实例。

### `POST /admin/api/releases/delete`

JSON `{filename}`，只删除已记录且未被当前feed引用的文件，禁止删正在发布的包。未提供自动降版本回滚，需独立受控操作。

发行数据在独立releases卷，私有分块文件在data/uploads；需备份两者。公开目录只由文件服务提供，不执行文件，不允许上传JS/HTML到发行目录。所有写操作要求真实管理员Cookie、Origin/CSRF，不更改旧SSH账号或其他项目。
