项目:X-Pan | 版本:v1.0 | 生成日期:2026-10-05 | 依据:源码分析
本文描述 X-Pan 后端对三端(Web / Flutter / WebDAV 客户端)暴露的 HTTP API 契约。整体部署形态与模块职责见《01-系统架构》——需要再次强调的是:后端没有独立网关进程,所有接口由唯一的 Spring Boot 进程(x-pan-parent/server,端口 8081)直接对外提供,"网关"职责(TLS 终止、静态资源、路径反代、WebSocket 升级)由 Nginx 承担。接口文档在 dev/local profile 下可通过 knife4j 在线查看(http://localhost:8081/doc.html,由 framework/swagger2/Swagger2Config + DocAccessCondition 控制,生产默认关闭)。
X-Pan 的 API 是典型的"前后端分离 REST-ish"风格,但有几条贯穿全部接口的硬约定,阅读端点总表前必须先理解:
R{code, message, data}:定义于 framework/core/.../response/R.java,JSON 序列化时 null 字段直接消失(@JsonInclude(NON_NULL))。业务成功 code=0;所有业务失败(校验失败、权限不足、业务异常)HTTP 状态码仍是 200,靠 code 区分——前端 x-pan-fronted/src/utils/http.ts 与移动端 lib/core/network/http_client.dart 的拦截器都只认 code。唯一例外是静态资源 404(见第 5 节)。fileId/shareId/parentId/taskId 等都不是数据库原始主键,而是 IdUtil.encrypt()(AES-128,密钥 XPAN_AES_KEY/XPAN_AES_IV 环境变量注入)加密后的字符串;VO 出参由 framework/web/serializer/IdEncryptSerializer 自动加密,Controller 入参手工 IdUtil.decrypt() 解密(如 FileController.getFolderTree 中的 context.setFileId(IdUtil.decrypt(fileId)))。防自增/雪花 ID 被遍历。CommonLoginAspect 以包路径切点 execution(* com.xiaoye.pan.server.modules.*.controller..*(..)) 统一拦截,@LoginIgnore 注解放行免登录接口。这是 DEV_GUIDELINES.md 要求 Controller 必须放在 modules/<模块>/controller/ 子包的根本原因——放错包等于接口裸奔。@Validated + JSR-303 注解(如 FileChunkUploadPO.identifier 的 @Pattern(regexp="^[a-fA-F0-9]{32}$") 防路径穿越)→ MapStruct Converter 转 Context → ServiceImpl 内业务校验(归属、配额、保险箱锁)。service/impl/*ServiceImpl(如 UserFileServiceImpl 约 2200 行)。下表按模块归纳全部 HTTP 端点(方法/路径均为相对后端根路径;经 Nginx/Vite 反代时需加 /api 前缀,见第 3 节)。"鉴权"列标注免登录(@LoginIgnore)与分享码(@NeedShareCode)。请求进入 Controller 前的统一处理链与两类凭证的校验路径如下:
| 方法 | 路径 | 用途 | 关键入参 | 响应 data | 鉴权 |
|---|---|---|---|---|---|
| POST | /user/register | 注册(幂等,用户名冲突报错) | UserRegisterPO:username/password/nickname/question/answer | — | 免登录(限流 3 次/小时) |
| POST | /user/login | 登录,返回 token | UserLoginPO:username/password + 请求头 X-Client-Id | accessToken 字符串 | 免登录(限流 5 次/分钟) |
| POST | /user/exit | 登出当前端会话 | 请求头 Authorization(解析 clientId) | — | 登录 |
| POST | /user/username/check | 找回密码第 1 步:取密保问题 | CheckUsernamePO:username | 密保问题 | 免登录 |
| POST | /user/answer/check | 第 2 步:答案换一次性 token | CheckAnswerPO:username/answer | 5 分钟有效 token | 免登录 |
| POST | /user/password/reset | 第 3 步:重置密码 | ResetPasswordPO:username/password/answer/token | — | 免登录 |
| POST | /user/password/change | 在线改密(改后强制全端下线) | ChangePasswordPO:oldPassword/newPassword | — | 登录 |
| GET | /user/ | 当前用户信息(配额/根目录 ID 等) | — | UserInfoVO | 登录 |
| PUT | /user/quota | 调整配额(上限 10TB) | UserQuotaPO:totalSpace | — | 登录 |
| PUT | /user/avatar | 设置头像(URL) | UserAvatarPO | — | 登录 |
| POST | /user/avatar/upload | 上传头像文件 | multipart file | 头像 URL | 登录 |
| GET | /device/list | 登录设备列表 | — | 设备 VO 列表 | 登录 |
| POST | /device/logout | 远程下线指定设备 | clientId | — | 登录 |
| 方法 | 路径 | 用途 | 关键入参 | 响应 data | 鉴权 |
|---|---|---|---|---|---|
| GET | /files | 文件列表(分页/排序/类型筛选) | parentId, fileType(s), orderBy, order, pageNum/pageSize, keyword | PageVO<XPanUserFileVO> | 登录 |
| GET | /files/stats | 用户文件统计 | — | UserFileStatsVO | 登录 |
| POST | /file/folder | 创建文件夹 | CreateFolderPO:parentId/filename | 新文件夹加密 ID | 登录 |
| PUT | /file | 重命名 | UpdateFilenamePO:fileId/newFilename | — | 登录 |
| DELETE | /file | 批量删除(进回收站) | DeleteFilePO:fileIds | — | 登录 |
| POST | /file/sec-upload | 秒传检测(MD5 命中即登记) | SecUploadFilePO:parentId/filename/identifier(三字段,无 totalSize) | 命中 code=0 且无 data;未命中 code=1"文件唯一标识不存在,请手动执行文件上传"(转分片的正常分支,非错误;见《19》第 3 节) | 登录 |
| POST | /file/upload | 单文件直传(小文件) | multipart file + parentId | — | 登录 |
| POST | /file/chunk-upload | 分片上传(5MB/片) | FileChunkUploadPO:filename/identifier/totalChunks/chunkNumber/currentChunkSize/totalSize/file | FileChunkUploadVO{mergeFlag} | 登录 |
| GET | /file/chunk-upload | 查询已传分片(断点续传) | QueryUploadedChunksPO:identifier/totalChunks | UploadedChunksVO{uploadedChunks[]} | 登录 |
| POST | /file/merge | 分片合并 | FileChunkMergePO:filename/identifier/parentId/totalSize | — | 登录 |
| GET | /file/download | 单文件下载(大文件 302 预签名直链) | fileId | 文件流 / 302 | 登录(支持 Cookie 回退) |
| POST | /file/archive-download | 批量打包 zip 下载 | ArchiveDownloadPO:fileIds | zip 流 | 登录 |
| GET | /file/presigned-url | 获取 MinIO 预签名下载 URL | fileId | URL 字符串 | 登录 |
| GET | /file/preview | 文件预览(Range 断点) | fileId | 内容流 | 登录 |
| POST | /file/preview/url | 申请短期预览签名(ptoken,5 分钟) | fileId | ptoken URL | 登录 |
| GET | /file/preview/stream | ptoken 直出内容流(供 img/video 标签) | fileId, ptoken | 内容流 | 免登录(ptoken 验签) |
| GET | /file/folder/tree | 全量文件夹树(移动/复制选择器) | — | List<FolderTreeNodeVO> | 登录 |
| GET | /file/breadcrumbs | 面包屑链 | fileId | List<BreadcrumbVO> | 登录 |
| POST | /file/transfer | 移动(剪切粘贴) | TransferFilePO:fileIds/targetParentId | — | 登录 |
| POST | /file/copy | 复制 | CopyFilePO:fileIds/targetParentId | — | 登录 |
| POST | /file/batch-rename | 批量重命名 | BatchRenamePO | — | 登录 |
| POST | /file/batch-tag | 批量打标签 | BatchTagPO | — | 登录 |
| GET | /file/search | 搜索 + 高级筛选 | FileSearchPO:keyword/fileType/extension/sizeRange/timeRange | List<FileSearchResultVO> | 登录 |
| GET | /file/search/suggest | 搜索联想 | prefix | List<String> | 登录 |
| GET | /file/search/hot | 热搜榜 | limit(默认 10) | List<String> | 登录 |
| 方法 | 路径 | 所属 Controller | 用途 | 关键入参 | 鉴权 |
|---|---|---|---|---|---|
| GET | /file/versions | FileHistoryController | 版本列表 | fileId | 登录 |
| GET | /file/version | 同上 | 版本详情 | fileId, versionId | 登录 |
| POST | /file/rollback | 同上 | 回滚到指定版本 | fileId, versionId | 登录 |
| DELETE | /file/version | 同上 | 删除版本 | fileId, versionId | 登录 |
| GET | /file/thumbnail | ThumbnailController | 图片缩略图(磁盘缓存) | fileId, width/height | 免登录(签名) |
| GET | /file/video-cover | VideoThumbnailController | 视频封面 | fileId | 免登录(签名) |
| GET | /file/video-thumbnails | 同上 | 视频关键帧列表 | fileId | 登录 |
| GET | /file/video-sprite | 同上 | FFmpeg 雪碧图 | fileId | 免登录(签名) |
| GET | /file/dedup/list | FileDedupController | 重复文件分组列表 | 分页参数 | 登录 |
| GET | /file/dedup/stat | 同上 | 去重统计 | — | 登录 |
| POST | /file/dedup/release | 同上 | 释放冗余引用 | fileId 列表 | 登录 |
| POST | /file/auto-tag | FileTagController | AI 自动打标(失败降级规则) | fileId | 登录 |
| GET/POST/DELETE | /file/{fileId}/tags、/file/tags/{tagId} | 同上 | 标签查/设/删 | fileId, tags | 登录 |
| POST | /file/extract | ExtractController | ZIP 异步解压任务 | fileId, targetParentId | 登录 |
| GET | /file/extract/progress | 同上 | 解压进度查询 | taskId | 登录 |
| POST | /preview/office/{fileId} | PreviewController | Office→PDF 转换任务 | 路径 fileId | 登录 |
| GET | /preview/url/{taskId} | 同上 | 轮询转换结果直链 | 路径 taskId | 登录 |
| GET | /file/{fileId}/text-extract | TextExtractController | 提取纯文本(供 AI) | 路径 fileId | 登录 |
| 方法 | 路径 | 所属 Controller | 用途 | 关键入参 | 响应 data | 鉴权 |
|---|---|---|---|---|---|---|
| POST | /share | ShareController | 创建分享 | CreateShareUrlPO:shareFileIds/shareCode/expireDays/downloadLimit... | XPanShareUrlVO(分享链接+提取码) | 登录(限流 30/分) |
| GET | /shares | 同上 | 我的分享列表 | — | List<XPanShareUrlListVO> | 登录 |
| DELETE | /share | 同上 | 取消分享 | CancelSharePO:shareIds | — | 登录 |
| POST | /share/code/check | 同上 | 校验提取码换 Share-Token | CheckShareCodePO:shareId/shareCode | shareToken 字符串 | 免登录 |
| GET | /share/simple | 同上 | 匿名取分享简况(是否需提取码) | shareId | ShareSimpleDetailVO | 免登录 |
| GET | /share | 同上 | 分享详情 | 请求头 Share-Token | ShareDetailVO | 免登录 + 分享码 |
| GET | /share/file/list | 同上 | 分享内容文件列表 | Share-Token + parentId | 文件 VO 列表 | 免登录 + 分享码 |
| POST | /share/save | 同上 | 转存到我的网盘 | Share-Token + ShareSavePO | — | 登录 + 分享码 |
| GET | /share/file/download | 同上 | 分享文件匿名下载 | Share-Token + fileId | 文件流 | 免登录 + 分享码 |
| GET | /recycles | RecycleController | 回收站列表 | — | 文件 VO 列表 | 登录 |
| GET | /stat | 同上 | 回收站统计(数量/字节) | — | RecycleStatVO | 登录 |
| PUT | /recycle/restore | 同上 | 批量还原 | RestorePO:fileIds | — | 登录 |
| DELETE | /recycle | 同上 | 彻底删除 | fileIds | — | 登录 |
| POST | /offline/create | OfflineTaskController | 创建离线下载(SSRF 校验) | url, targetParentId | 任务 VO | 登录(限流 10/分) |
| GET | /offline/list | 同上 | 任务列表 | — | 任务 VO 列表 | 登录 |
| POST | /offline/cancel | 同上 | 取消任务 | taskId | — | 登录 |
| DELETE | /offline | 同上 | 删除任务记录 | taskId | — | 登录 |
| GET | /vault/status | VaultController | 保险箱是否已设置/已解锁 | — | 状态 VO | 登录 |
| POST | /vault/setup | 设置保险箱密码(PBKDF2) | password | — | 登录 | |
| POST | /vault/unlock | 解锁(防爆破锁定,解锁态缓存 30 分钟) | password | — | 登录 | |
| POST | /vault/lock | 重新上锁 | — | — | 登录 | |
| GET | /vault/files | 保险箱文件列表 | 分页参数 | 文件 VO 列表 | 登录(已解锁) | |
| POST | /vault/move | 移入保险箱 | fileIds | — | 登录 | |
| POST | /vault/file/{fileId}/out | 移出保险箱 | 路径 fileId | — | 登录 | |
| DELETE | /vault/file/{fileId} | 保险箱内永久删除 | 路径 fileId | — | 登录 | |
| POST | /favorite/add、/favorite/remove | FavoriteController | 收藏/取消 | fileIds | — | 登录 |
| GET | /favorite/list | 同上 | 收藏列表 | 分页参数 | 文件 VO 列表 | 登录 |
| GET | /notification/list | NotificationController | 通知列表 | 分页参数 | 通知 VO 列表 | 登录 |
| GET | /notification/unread/count | 同上 | 未读数 | — | count | 登录 |
| PUT | /notification/read/{id}、/notification/read/all | 同上 | 标记已读 | 路径 id | — | 登录 |
| 协议 | 路径 | 用途 | 鉴权 | 实现位置 |
|---|---|---|---|---|
| WebSocket | /ws/notification?token=xxx | 实时通知推送(离线下载进度/分享统计/系统通知/上传完成 UPLOAD_FINISHED) | 连接握手时校验 token 与 Redis 登录态(NotificationWebSocketHandler.resolveUserId,兼容 _PREV 旧 token) | common/websocket/ |
| WebDAV | /webdav/** | 协议子集:OPTIONS/GET/HEAD/PUT/DELETE/MKCOL/PROPFIND/MOVE/COPY | HTTP Basic Auth(校验用户名密码后映射为用户会话) | modules/webdav/WebDavController.java(@RequestMapping("/webdav") 单入口按 method 分发,零第三方依赖手写实现) |
| Actuator | /actuator/health,info,metrics,prometheus | 健康检查与指标抓取 | 内网/运维面(application.yaml exposure 配置) | Spring Boot Actuator + Micrometer |
X-Pan 的"路由"是前缀改写规则而非微服务按路径分发(只有一个后端服务,见《01-系统架构》第 1 节)。三条接入路径:
(1)生产环境 Nginx(x-pan-fronted/nginx.conf,容器名 x-pan-backend):
| location | 规则 | 说明 |
|---|---|---|
/ | try_files $uri $uri/ /index.html | SPA fallback,前端路由由 Vue Router 接管 |
| 静态资源正则 | expires 1y; immutable | Vite 构建产物带内容 hash,长缓存 |
/api/ | proxy_pass http://x-pan-backend:8081/ | 去掉 /api 前缀反代;client_max_body_size 10G(放行大文件分片)、proxy_read_timeout 600s |
/ws/ | 独立 location,带 Upgrade/Connection: upgrade 头 | WebSocket 升级直通,proxy_read_timeout 3600s(长连接) |
/health | 直接 return 200 | 容器探活 |
另配安全头(X-Frame-Options SAMEORIGIN 等)与 gzip。注意 /ws/ 不能复用 /api/ 的 location——注释明确说明 WS 握手必须显式携带升级头,否则被 Nginx 当普通 HTTP 处理而失败。
(2)开发环境 Vite 代理(x-pan-fronted/vite.config.ts,dev 端口 5179):/api → http://localhost:8081(rewrite 去 /api 前缀,与 Nginx 规则对齐);/ws → ws://localhost:8081(ws: true)。前端 axios baseURL 由 VITE_API_BASE_URL 控制,默认 /api。
(3)移动端直连:Flutter 端 lib/core/config/app_config.dart 的 apiBaseUrl 默认指向公网生产地址,编译期 --dart-define=X_PAN_API_BASE_URL 覆盖(本地联调用 http://127.0.0.1:8081,Android 模拟器用 http://10.0.2.2:8081),不经 Nginx;Dio 请求路径直接使用上表中的后端根路径(无 /api 前缀)。
认证体系在《01-系统架构》第 5 节有架构级描述,本节从接口契约视角补充端到端细节,全部源码位于 server/common/aspect/CommonLoginAspect.java、framework/core/.../utils/JwtUtil.java、modules/user/service/impl/UserServiceImpl.java:
登录签发:POST /user/login 成功后 UserServiceImpl.generateAndSaveAccessToken 以 JwtUtil.generateToken(username, LOGIN_USER_ID, userId, clientId, ONE_DAY) 签发 HS256 JWT(有效期 24 小时,签名密钥来自环境变量 XPAN_JWT_SECRET,≥32 字节,缺失启动即失败);token 写入 Redis 缓存键 USER_LOGIN_{userId}_{clientId},同时把 clientId 登记到 USER_CLIENT_IDS_{userId} 活跃会话集合(供改密后强制全端下线遍历)。响应 R.data(accessToken)。clientId 由各端生成并保持稳定:Web 存 Cookie,移动端首次生成 app-时间戳36进制 存 Keychain/Keystore(lib/core/storage/token_storage.dart),登出不清除——这是"多端并存、互不踢线"的会话槽位依据。
请求校验(CommonLoginAspect.checkAndSaveUserId):① 从 Authorization 请求头取 token,为空回退读 Cookie login_token(浏览器原生下载跳转自动带同源 Cookie,避免 token 拼进 URL 泄露);② JwtUtil.analyzeToken 验签解析出 userId 与 clientId;③ 与 Redis USER_LOGIN_{userId}_{clientId} 比对(兼容 _PREV 上一版),token 合法但不在 Redis 同样视为未登录——这就是服务端可吊销语义;④ 通过后 UserIdUtil.set(userId) 放入线程上下文,请求结束 finally 清理。任一步失败返回 R.fail(NEED_LOGIN)(code=10)。
滑动续期:JWT 内写有 RENEWAL_TIME claim(签发时间 + 有效期/2,JwtUtil.RENEWAL_DIVISOR=2)。切面发现当前时间超过续期点时,按缓存 key 粒度加锁(RENEW_KEY_LOCKS)签发新 token:锁内二次检查避免并发续期互踢,旧 token 存 _PREV 兼容在途请求,新 token 经响应头 New-Access-Token 下发(并设 Access-Control-Expose-Headers 供跨域读取)。前端 utils/http.ts 与移动端 http_client.dart 的响应拦截器读取该头自动替换本地存储——对业务代码完全透明。
二级鉴权 @NeedShareCode(server/common/aspect/ShareCodeAspect.java):分享相关接口用两层凭证。匿名访客先 POST /share/code/check 提交提取码,服务端校验通过后签发独立的 Share-Token JWT(claim 携带 shareId),客户端存 Cookie 后续请求经 Share-Token 请求头携带;切面验签解析出 shareId 放入 ShareIdUtil 供 Service 使用。POST /share/save 转存接口同时要求登录态与 Share-Token(双重鉴权)。
WebDAV Basic Auth:WebDavController 自己解析 Authorization: Basic 头,用 PasswordUtil 校验密码后把该 HTTP 会话映射到对应 userId 的文件树,与 JWT 体系并行。
业务状态码(framework/core/.../response/ResponseCode.java 全量枚举):
| code | 枚举 | 含义 | 客户端行为 |
|---|---|---|---|
| 0 | SUCCESS | 成功 | 正常处理 data |
| 1 | ERROR | 通用失败(业务异常/系统异常) | toast message |
| 2 | TOKEN_EXPIRE | token 过期 | 引导重登录 |
| 3 | ERROR_PARAM | 参数错误(含 JSR-303 校验失败第一条消息) | 表单提示 |
| 4 | ACCESS_DENIED | 无权限 | 提示无权限 |
| 5 | SHARE_FILE_MISS | 分享的文件丢失 | 分享页错误态 |
| 6 | SHARE_CANCELLED | 分享已被取消 | 分享页错误态 |
| 7 | SHARE_EXPIRE | 分享已过期 | 分享页错误态 |
| 10 | NEED_LOGIN | 需要登录 | 全端统一重登录信号:Web 清 Cookie 跳 /login?redirect=...;Flutter 抛 NeedReloginException 经广播流触发 AuthNotifier.forceRelogin |
异常映射(framework/web/.../exception/WebExceptionHandler.java,@RestControllerAdvice 全局兜底):XPanBusinessException → R.fail(业务 code, message)(业务层用它抛出带语义的失败,如配额不足);MethodArgumentNotValidException / ConstraintViolationException / BindException → code=3 + 第一条校验消息;MissingServletRequestParameterException、IllegalStateException → code=3;XPanFrameworkException → code=1 + 框架消息;NoResourceFoundException → R.fail(404, "资源不存在")(静态资源如 favicon 404 不再误报 500);RuntimeException → code=1 + 固定文案"系统异常,请稍后重试"(避免内部细节外泄,原始堆栈进错误日志,并经 ErrorLogEvent 异步落库 x_pan_error_log,见《01-系统架构》事件机制)。
配套横切:登录、注册、创建分享、创建离线任务在 Service 层调用 RedisRateLimiter.checkRateLimit(scope, ...) 做固定窗口限流(key xpan:rl:{scope}:{userId|ip},超限抛业务异常提示"操作过于频繁";Redis 故障 fail-open 放行,见 common/ratelimit/)。访问日志(HttpLogFilter)、traceId 注入(LogMdcFilter,MDC 的 traceId/spanId 同步到响应头供前端排障)、HTTP 耗时指标(ApiMetricsFilter)对全部接口生效。
POST /file/chunk-upload(FileController.chunkUpload → UserFileServiceImpl,最终经 StorageEngine.storeChunk 写入 MinIO x-pan-chunks 桶):
请求(multipart/form-data,需 Authorization 头):
POST /api/file/chunk-upload HTTP/1.1
Authorization: eyJhbGciOiJIUzI1NiJ9...
Content-Type: multipart/form-data; boundary=----Boundary
filename=report.pdf
identifier=3f5a8c2e91d04b67a8d1c0e9f2b3456a
totalChunks=3
chunkNumber=2
currentChunkSize=5242880
totalSize=12058624
file=(二进制分片体)响应:
{ "code": 0, "message": "SUCCESS", "data": { "mergeFlag": 0 } }要点:identifier 强制 32 位 MD5 十六进制正则校验(会被拼进对象存储路径,防 ../ 路径穿越);totalSize 上限 3GB(@Max(3221225472));simple-uploader 的 testChunks 探测请求不带文件体,故 file 允许为 null(源码注释明确说明);响应的 uploadedChunks(GET 同路径查询,UploadedChunksVO)是断点续传的依据——客户端只补传缺失分片。全部分片完成后 POST /file/merge,服务端以 MinIO composeObject 合并入 x-pan-files 桶并登记 x_pan_file(物理文件)与 x_pan_user_file(逻辑文件),完整时序见《01-系统架构》第 7 节与《05-核心流程》。
POST /share/code/check(ShareController.checkShareCode,免登录 + 限流):
// 请求
{ "shareId": "8f3e2a1b9c0d4e5f6a7b8c9d0e1f2a3b", "shareCode": "x7k2" }
// 响应(成功签发 Share-Token,客户端存 Cookie 供后续 Share-Token 头使用)
{ "code": 0, "message": "SUCCESS", "data": "eyJhbGciOiJIUzI1NiJ9..." }失败时按情形返回 code=6(已取消)/ 7(已过期)/ 5(文件丢失),分享页据此切换错误态。此后 GET /share(详情)、GET /share/file/list(浏览)凭 Share-Token 头访问;POST /share/save 还需叠加登录态 Authorization。访问/保存/下载行为会触发统计与 WebSocket 通知(Notifier 推送 + x_pan_notification 落库)。
三端实现同一契约的关键对齐点,修改接口时必须同步:① Web src/api/ 11 个模块与 Flutter lib/services/ 约 20 个服务文件是一一对应的调用面;② code=10 触发全局重登录、New-Access-Token 自动续期,两端拦截器行为一致(Web:utils/http.ts;移动端:core/network/http_client.dart);③ 排序参数白名单:Web stores/file.ts getOrderBy 与 Flutter file_provider.dart 的 orderBy 映射均对齐后端 SafeOrderBy(仅允许 filename/file_size/update_time/create_time 等白名单字段,防 SQL 注入);④ 分片协议 5MB、并发 3,三端常量对齐(Flutter AppConfig.chunkSize 注释明确"对齐后端 MinIO composeObject 合并限制")。
补充三点调用方容易踩坑的约定。其一是分页与排序的职责划分:后端负责过滤与分页裁剪,但文件列表的排序同时存在前端本地排序与后端 orderBy/order 参数两条路径(Web 端 SortMenu 切换后会带参请求),调用方应始终传白名单 snake_case 字段,传非法字段不会报错但也不会生效。其二是加密 ID 的时效性:AES 密钥由环境变量注入,更换密钥后历史接口里持久化的 ID(如旧分享链接中的 shareId)将无法解密;分享侧已做"查询时按当前配置实时重生成链接"的兜底,其余端则依赖密钥长期稳定,运维上应把 XPAN_AES_KEY/XPAN_AES_IV 当作与数据同等生命周期的资产(见《06-部署运行》)。其三是blob 类响应的短路处理:下载/预览/打包等接口直接输出二进制流而非 R 包装,Web 端 axios 拦截器以 responseType === 'blob' 短路通用错误处理,移动端 Dio 侧以 ResponseType.stream 处理——错误在这类接口上表现为 HTTP 层异常而非 code 字段,两端均已适配。
实时通知走独立于 /api 的通道:客户端连接 ws(s)://host/ws/notification?token=<JWT>。握手阶段由 NotificationWebSocketHandler.resolveUserId 完成鉴权:解析 token 中的 userId 后与 Redis 登录缓存比对(同样兼容 _PREV 上一版 token),失败即拒绝连接并记录告警日志。会话容器是 ConcurrentHashMap<Long, Set<WebSocketSession>>(userId → 该用户全部在线会话,Web 与移动端可同时在线各自收推),每个会话包一层 ConcurrentWebSocketSessionDecorator 限制发送时间与缓冲区,防止慢消费者拖垮 IO 线程。心跳为服务端 30 秒 PING、客户端 PONG,空闲超时会话被主动清理;Web 端 useWebSocket.ts 用 25 秒心跳刻意早于服务端 PING。
服务端推送的业务事件类型与 x_pan_notification.type 字段一致:UPLOAD_FINISHED(上传完成,携带文件 VO)、OFFLINE_TASK_UPDATE(离线下载进度/状态变化)、SHARE_STATS_UPDATE(分享被访问/转存/下载的统计变化,仅推给分享者)、SYSTEM_NOTICE(系统公告)。推送与通知中心落库总是成对发生——Notifier 的各 push 方法先 NotificationWebSocketHandler.pushToUser 实时投递,再经通知服务写入 x_pan_notification,保证离线用户上线后可从 GET /notification/list 补齐历史。需要说明的是,消息帧的精确 JSON 结构以 NotificationWebSocketHandler 的序列化实现为准(⚠️ 待确认:type/data 字段命名细节未在本文逐字段核对)。这条通道替换了轮询方案,是离线下载进度、上传完成提示"实时感"的唯一来源,前端禁用或代理配错时功能降级为手动刷新但不报错。
WebDavController(modules/webdav/WebDavController.java,@RequestMapping("/webdav") 单入口)以一个 Controller 方法内按 HTTP method 分发的方式实现了协议子集:OPTIONS(能力协商)、GET/HEAD(下载,支持 Range)、PUT(上传)、DELETE(删除)、MKCOL(建目录)、PROPFIND(列目录/查属性,客户端挂载后的资源浏览全靠它)、MOVE/COPY(移动/复制,对应网盘的 transfer/copy 语义)。鉴权不走 JWT,而是标准 HTTP Basic Auth:Authorization: Basic base64(username:password),服务端解出用户名密码后经 PasswordUtil 校验并映射到对应用户的文件树——复用与网页端完全相同的存储与配额逻辑,因此 WebDAV 上传的文件在网页/移动端立即可见,反之亦然。
设计上有两点值得注意。第一,零第三方依赖:没有引入 WebDAV 服务库,报文解析与响应拼装全部手写(deploy/WEBDAV.md 记录了协议实现与各客户端兼容性说明),换来的是依赖面最小与行为完全可控,代价是仅覆盖主流客户端实际使用的方法子集。第二,路径即契约:/webdav/ 根映射为用户根目录,子路径逐级对应文件夹,文件树变更不需要任何刷新动作——Finder/RaiDrive 等客户端每次 PROPFIND 都会拿到实时状态。经 Nginx 反代时 WebDAV 与普通 API 共用 /api 之外的独立路径(直通 8081 的 /webdav/**),大文件上传同样受 client_max_body_size 10G 保护。