This commit is contained in:
owenchen committed 2026-10-21 20:50:55 +08:00
1 parent 211d2cd253
commit 7fb4503e0e
27 files changed
+2218 -1202

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
# 文件地址与访问线路(图片 / 音频 / 视频 URL 的归一与回落)
适用范围:`src/utils/fileUrl.ts`、`src/common/apiEndpoint.ts`、`src/composables/useMediaShow.ts`、`src/pages/tab/media.vue`,以及后端 `CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`CloudBuilder.Web.Entry/Program.cs`、`appsettings.json` 中 `ApplicationUrl` / `FileServiceSettings` 两组配置。
本文只讲「后端下发的文件地址怎么变成端上能直接加载的 URL」,接口地址分层见 [environment.md](./environment.md),请求错误口径见 [error-handling.md](./error-handling.md)。
---
## 1. 一句话结论
后端下发的文件地址有**三种形态**,端上不能直接塞进 `<image src>` / 播放器,必须统一过 `absoluteFileUrl()`;加载失败还要给一次公网回落 `wanFileUrl()`。
## 2. 后端地址的三种形态
以相册明细 `PublishPhotoDetailDisplayData` 为例(`PublishPhoto/FindByKey`):
| 形态 | 什么时候出现 | 样例 | 端上直接用的后果 |
| --- | --- | --- | --- |
| 相对路径 | 线上(`Debugger.IsAttached = false`) | `/FileServer/Scale/DmsFile/003/jpg/x.jpg` | App 当成本地资源路径,加载失败(灰块) |
| 回环绝对地址 | VS 调试,`appsettings.json` 的 `ApplicationUrl = http://localhost:5235` | `http://localhost:5235/FileServer/Scale/...` | 手机上的 localhost 指向手机自己,必然失败 |
| 正常绝对地址 | 音频 / 视频明细、或后端已拼好域名时 | `https://uat.youjiala.com/FileServer/...` | 原样返回即可 |
根因链:
- `PackImageScareFileName(isAttached, fileName)` 里 `isAttached ? ServiceSettingsPolicy 的 ApplicationUrl : ""`,即**只有调试态才拼前缀**;
- 同一方法会把 `Backup` 目录替换成 `Scale`(缩略图),`FindByKey` 里另有一次 `PackImageFileName` 保留原图路径;
- 后端本身是能出静态文件的:`Program.cs` 用 `UseStaticFiles` 把 `FileServiceSettings.PhysicalFileDirectory`(`D:\Net8\FileServer`)挂到 `RequestPath`(`/FileServer`)。所以问题是**地址前缀不对**,不是后端不提供文件。
## 3. 为什么 H5 正常、App 不正常
| 端 | 接口线路 | 图片地址 | 结果 |
| --- | --- | --- | --- |
| H5 dev | `http://localhost:5235`(VS 调试) | 后端拼的 `http://localhost:5235/FileServer/...` | 浏览器与后端同机,localhost 成立,显示正常 |
| App 内网直连 | `http://172.16.253.53:5235`(同一台开发机的局域网地址) | 后端拼的 `http://localhost:5235/FileServer/...` | 手机上 localhost 不可达,灰块 |
| App 公网 | `https://uat.youjiala.com`(非调试) | 相对路径 `/FileServer/...` | 未补全则灰块;补全后正常 |
结论:**H5 的“正常”是假象**,它恰好和调试后端同机;判断修复是否生效要在 App 上验证。
## 4. 归一规则(`src/utils/fileUrl.ts`)
```
stripLoopbackHost(url) 把 localhost / 127.0.0.1 / 0.0.0.0 剥成纯路径;非回环返回 null
withBase(base, path) 相对路径拼到 base;已绝对 / data: / blob: 原样返回(幂等)
absoluteFileUrl(path) stripLoopbackHost 之后按 apiBaseUrl() 重拼 —— 当前线路地址
wanFileUrl(path) 把任意地址的 path+query 换到 WebSitePolicy.WebApiRoot —— 公网地址
```
`apiBaseUrl()` 来自访问线路(`api-endpoint-setting`):`lan` 模式返回用户在「我的 → 访问线路」填的内网地址,否则返回公网(App 开发构建取 `WebApiRootDevApp`,发布构建取 `WebApiRoot`)。
实测映射(Node 侧验证过同一套逻辑):
| 输入 | `absoluteFileUrl`(内网 172.16.253.53:5235) | `wanFileUrl` |
| --- | --- | --- |
| `http://localhost:5235/FileServer/Scale/...` | `http://172.16.253.53:5235/FileServer/Scale/...` | `https://uat.youjiala.com/FileServer/Scale/...` |
| `/FileServer/Scale/...` | `http://172.16.253.53:5235/FileServer/Scale/...` | `https://uat.youjiala.com/FileServer/Scale/...` |
| `http://172.16.253.53:5235/...?v=1` | 原样 | `https://uat.youjiala.com/...?v=1` |
| `https://uat.youjiala.com/...` | 原样 | 原样(调用方判重后不再重试) |
| `""` / `undefined` | `""` | `""` |
## 5. 消费面:哪些地方必须过
| 位置 | 处理 |
| --- | --- |
| `pages/tab/media.vue` 专辑明细缩略图 | `coverOf()` 过 `absoluteFileUrl`;`@error` 时用 `coverFallback[DetailId] = wanFileUrl(...)` 再试一次,仍失败才 `coverFailed` 显示占位图标 |
| `composables/useMediaShow.ts` `loadPhotos` 的 list 分支与相册明细分支 | `url` / `thumb` / `full` 全部过 `absoluteFileUrl` |
| `composables/useMediaShow.ts` `loadTracks` / `loadVideos` | `url`、`pic`、`lrc`、`m3u8` 全部过 `absoluteFileUrl`(调试态下它们同样是 localhost 前缀) |
纪律:**凡是渲染后端文件地址的字段,一律先过 `absoluteFileUrl`;可交互/可预览的图片再挂一层 `@error` 回落 `wanFileUrl`。** 不要把 `FormattedPic` / `FileName` / `FormattedUrl` 直接绑到 `src`。
## 6. 新增字段时的固定步骤
1. 确认接口返回的字段是路径还是完整 URL(看 uat 实际响应,不要按字段名猜);
2. 渲染前套 `absoluteFileUrl`;
3. 图片类再加 `@error` → `wanFileUrl` 一次回落 → 仍失败才占位;
4. `npx vue-tsc --noEmit` 退出码 0;
5. App 端(尤其内网线路)实际看一次,H5 正常不代表 App 正常。
## 7. 排查清单
- 图片灰块:先打印/抓包看实际 `src`,判断属于上面三种形态中的哪一种;
- 断言图片存在:对 uat 地址做 `HEAD`(`https://uat.youjiala.com/FileServer/Scale/...`、`.../Backup/...` 均应为 200);
- 内网线路取不到:确认手机与开发机同一 Wi-Fi、后端监听局域网地址(不是只监听 `127.0.0.1`)、`D:\Net8\FileServer` 下有该文件;
- 回落到公网仍失败:检查 `WebSitePolicy.WebApiRoot`(`.env` 的 `VITE_WEB_API_ROOT`)与 uat 上文件是否存在。
## 8. 可选的后端根治
当前前端已经兜住三种形态,后端改不改都能用。若要少一层兜底,任一即可:
- 把 `appsettings.json` 的 `ApplicationUrl` 从 `http://localhost:5235` 改成开发机的局域网地址(如 `http://172.16.253.53:5235`),调试态直接给手机可达地址;
- 或让 `PackImageScareFileName` 按当前请求的 Scheme+Host 拼前缀(与 `FileTypePolicy.BuildApplicationUrl` 的口径一致),线上也返回绝对地址;
- 多实例部署时让各实例的 `PhysicalFileDirectory` 指向同一份共享存储,否则文件只会留在上传它的那一台上。
+154
View File
@@ -0,0 +1,154 @@
# 多媒体专辑权限(相册 / 音乐 / 影视)
> 定稿 2026-10:三层角色(所有者 / 群管理员 / 普通群成员)+ 旁观者只读。相册(photo)、音乐(audio)、影视(video)三套逻辑同构,仅实体/仓储类型不同;前后端口径一致,后端为最终裁决。
## 一、角色定义与判定
后端 `PublishXxxService.FindByKey` 恒定下发三个布尔标记,前端一切显隐只认它们:
| 角色 | 判定条件 | 后端标记 | 说明 |
|---|---|---|---|
| **所有者** | `publish_xxx.owner_name == 当前登录 UserName` | `IsOwner=true`(同时 `IsMember/IsManager=true`) | 建单人的全权身份 |
| **群管理员** | 该专辑 `publish_xxx_family_group` 任一 `FamilyName`,在 `family_group_manager` 中存在 `UserName=自己 且 ValidIndc=Y 且 ManagerIndc=Y` | `IsManager=true` | 发布到的群组中任一群的管理员即可 |
| **普通群成员** | 同上但 `ManagerIndc=N`(只要 `ValidIndc=Y`) | `IsMember=true` | 含通过权限继承加入群组的有效成员 |
| **旁观者** | 以上都不是;专辑 `OpenIndc=Y` 时仅可读 | 三者均 false | 私有专辑 `FindByKey` 直接抛「没有权限.」 |
注意:
- 所有者分支不查群表,`IsMember/IsManager` 直接置 true;
- 「权限继承加入的群组」落地为 `family_group_manager.ValidIndc=Y` 的有效成员行,`ManagerIndc` 决定管理员身份;
- 旧后端不返回三标记时,前端 `isOwner` 回退按 `OwnerName` 比对(仅兼容,不应依赖)。
## 二、能力矩阵
| 操作 | 所有者 | 群管理员 | 普通群成员 | 旁观者 |
|---|:---:|:---:|:---:|:---:|
| 查看详情 | ✓ | ✓ | ✓ | 仅 `OpenIndc=Y` |
| 改专辑信息(名称 / 描述 / 公开 / 排序 / 过期) | ✓ | ✓ | ✗ | ✗ |
| 改「设为默认专辑」 | ✓ | ✗ | ✗ | ✗ |
| 明细编辑(上移 / 下移 / 描述 / 删除) | ✓ | ✓ | ✓ | ✗ |
| 群组增删(发布去向) | ✓ | ✓ | ✗ | ✗ |
| 删除整张专辑 | ✓ | ✓ | ✗ | ✗ |
| 展览(播放器) | ✓ | ✓ | ✓ | ✓ |
群管理员具备明细编辑权,因为其 `IsMember` 同时为 true。
## 三、后端实现
三个文件同构:`CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`PublishAudioService.auto.cs`、`PublishVideoService.auto.cs`。
### 3.1 FindByKey — 身份计算与访问闸门
1. 按 `PublishName` 取专辑,取 `FamilyGroups`(join `family_group` 的展示行);
2. `IsOwner = owner_name == 当前用户`,并先令 `IsMember = IsManager = IsOwner`;
3. 非所有者且 `FamilyGroups` 非空时,取所有 `FamilyName`,查 `family_group_manager` 中 `UserName=自己 且 ValidIndc=Y` 的行:
- 有行 → `IsMember=true`;
- 其中任一行 `ManagerIndc=Y` → `IsManager=true`;
4. 访问闸门:`!IsOwner && OpenIndc != 'Y' && !IsMember` → `ValidatedException("没有权限.")`。
### 3.2 Update — 按角色回写保护字段
1. 按 `PublishName` 查库中原记录,不存在 → 「记录不存在.」;
2. `isOwner = original.OwnerName == 当前用户`;
3. 非所有者:查该专辑群组 `FamilyName` 集合 → 查自己的有效成员行(`ValidIndc=Y`)→ `isMember/isManager`;群组集合为空或无成员行 → 「没有权限.」;
4. 防伪造:无条件回写 `OwnerName / CreatedBy / CreatedDatetime`(以库中为准);
5. **`!isOwner && !isManager`** → 回写展示字段 `PublishLocalName / Notice / OpenIndc / ExpireDatetime / OrderBy`(普通成员只能改明细);
6. **`!isOwner`** → 回写 `DefaultIndc`(默认专辑是所有者个人偏好,群管理员也不能改);
7. 所有者且 `DefaultIndc=Y` → `CancelDefaultIndc`(同一用户其它专辑取消默认);
8. `SavePublishXxxDetails` 所有正式成员都会执行(明细落库);
9. `SavePublishXxxFamilyGroup` 仅 `isOwner || isManager` 执行(普通成员提交的群组数组被忽略)。
> 历史坑:第 5 步条件若写成 `!isManager`,所有者在非所有者分支外 `isManager` 恒为 false,会导致所有者的公开/名称/描述/排序被库中旧值回写(保存成功但值弹回)。三套 Service 必须保持 `!isOwner && !isManager`。
### 3.3 Delete — 所有者或群管理员
1. 按 key 查专辑,不存在 → 「记录不存在.」;
2. `OwnerName != 当前用户 && !IsGroupManagerOfPublish(key)` → 「没有权限.」;
3. 删除主表 + 全部明细 + 全部群组关联行。
### 3.4 IsGroupManagerOfPublish
- 取 `publish_xxx_family_group` 中该专辑的 `FamilyName` 集合;
- 集合为空 → false;
- 在 `family_group_manager` 中查 `UserName=自己 且 ValidIndc=Y 且 ManagerIndc=Y && FamilyName 命中` → 任一存在即 true。
## 四、前端映射
### 4.1 composable 开关(`src/composables/usePublishAlbums.ts`)
```text
identity = detail 上的 IsOwner / IsManager / IsMember(旧后端回退 OwnerName)
isOwner = creating || (IsOwner 是布尔 ? IsOwner : !notOwner)
isGroupManager = !creating && IsManager
isGroupMember = !creating && IsMember
canManageAlbum = inDetail && (isOwner || isGroupManager) // 改信息 / 管群组 / 删专辑
canAmendDetails = inDetail && (isOwner || isGroupMember) // 改明细
canDelete = inDetail && (isOwner || isGroupManager)
```
`canAmend` 保留为 `canAmendDetails` 的别名,兼容旧调用。
### 4.2 页面入口显隐(`src/pages/tab/media.vue`)
| UI 入口 | 显示条件 |
|---|---|
| 概览卡「编辑」文字链 + 底部弹层(名称/描述) | `canManageAlbum` |
| 概览卡「对所有人公开」开关 | `canManageAlbum`(否则显示公开/私有文本) |
| 概览卡「设为默认专辑」开关 | `isOwner`(否则显示默认/非默认文本) |
| 明细行操作区(上移 / 下移 / 编辑 / 删除) | `canAmendDetails` |
| 明细描述编辑弹层保存 | 从操作区进入,本质同 `canAmendDetails` |
| 群组页签「+ 添加群组」与行「删除」 | `canManageAlbum` |
| 头卡删除按钮 | `canDelete` |
| 头卡展览按钮 | 所有人 |
### 4.3 自动保存门控
- `commitNow` / `commitSoon` 在 `!album || creating || !canAmendDetails` 时直接返回;
- 普通群成员改动明细后提交的是整包 `Update`,后端按角色只落明细,展示字段与默认标记强制回写;
- 概览卡两个开关直改后走同一防抖通道(250ms 合并),默认开关由后端取消其它专辑默认。
## 五、列表可见性与访问闸门
`FindBySearchCriteria` 的 where(正常列表,`IsModal=false`):
```text
and (owner_name = 当前用户 or open_indc = 'Y')
```
- 结论:**私有专辑(`OpenIndc=N`)即使发布给群组,也不会出现在多媒体列表**;群成员只能从已公开的专辑进入,再按身份获得改写权;
- `FindByKey` 对私有专辑放行所有者与有效群成员,因此从其他入口(如播放器携带 key)仍可打开;
- 若需要「私有群共享专辑也出现在成员列表」,需在该 where 上追加 `EXISTS` 子查询(`publish_xxx_family_group` + `family_group_manager`),目前未实现。
## 六、涉及文件
| 端 | 文件 | 职责 |
|---|---|---|
| 后端 | `CloudBuilder.Master/Data/PublishPhotoData.cs`、`PublishAudioData.cs`、`PublishVideoData.cs` | 新增 `IsOwner / IsManager / IsMember` 三标记 |
| 后端 | `CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`PublishAudioService.auto.cs`、`PublishVideoService.auto.cs` | FindByKey 身份计算、Update 分角色回写、Delete 放行群管理员、IsGroupManagerOfPublish |
| 前端 | `src/composables/usePublishAlbums.ts` | 权限 computed、保存门控数据链 |
| 前端 | `src/pages/tab/media.vue` | 入口显隐、概览开关直改、明细编辑弹层 |
| 生成 | `src/data/auto/publishPhotoData.ts`、`publishAudioData.ts`、`publishVideoData.ts` | 代码生成器由 Data 类同步,勿手改 |
## 七、测试账号验证口径
以「想家」`PP26101400001`(owner=002,发布群 FM000001,群内 002 为管理员、001 为普通成员)为例:
| 账号 | 列表可见 | 概览「编辑」 | 公开开关 | 默认开关 | 明细操作 | 群组增删 | 删除专辑 |
|---|---|:---:|:---:|:---:|:---:|:---:|:---:|
| 002(所有者) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| 002 以外的群管理员 | 仅公开专辑 | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ |
| 001(普通成员) | 仅公开专辑 | ✗ | ✗(文本) | ✗(文本) | ✓ | ✗ | ✗ |
| 003(非成员) | 仅公开专辑 | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
## 八、变更注意
1. 三套 Service 必须同步改动,任何一套漏改都会造成 photo/audio/video 行为不一致;
2. 「普通成员锁展示字段」条件固定为 `!isOwner && !isManager`,不要简化为 `!isManager`;
3. 前端入口显隐必须与后端校验一致:可见即应成功,不可见即不应发请求;
4. 明细的增删排序没有独立接口,全部随整包 `Update` 落库;群组增删同样走整包提交;
5. `CloudBuilder.Master` 是 DLL 引用链,改动 Data/Service 后按 Master → Master.Service → Web.Entry 顺序重新生成,前端 `data/auto` 才会同步。
## 相关文档
- [permission.md](./permission.md):页面与功能级权限体系(登录态 PermissionContext)
- [family-tree-permission.md](./family-tree-permission.md):族谱权限;群管理员身份的上游 `family_group_manager` 行由其成员关系产生