diff --git a/apps/admin/src/api/albums.ts b/apps/admin/src/api/albums.ts index 3249be5..5875b96 100644 --- a/apps/admin/src/api/albums.ts +++ b/apps/admin/src/api/albums.ts @@ -66,9 +66,9 @@ export function fetchRemoveAlbumMember(albumId: string, userId: string) { return request.del({ url: `/v1/albums/${albumId}/members/${userId}` }) } -/** 账号前缀查询(邀请协作者前置;≥2 字符,激活用户) */ +/** 账号前缀查询(邀请协作者前置;≥2 字符,激活用户;P4a 契约统一 AdminUserList) */ export function fetchLookupUsers(accountPrefix: string, limit = 10) { - return request.get<{ items: Api.Album.UserLookupItem[] }>({ + return request.get({ url: '/v1/admin/users', params: { accountPrefix, limit }, showErrorMessage: false diff --git a/apps/admin/src/types/api/album.d.ts b/apps/admin/src/types/api/album.d.ts index ac9c94b..2dbe8fb 100644 --- a/apps/admin/src/types/api/album.d.ts +++ b/apps/admin/src/types/api/album.d.ts @@ -147,13 +147,21 @@ declare namespace Api { hasMore: boolean } - /** 账号前缀查询条目(脱敏) */ + /** 账号前缀查询条目(脱敏投影;P4a 契约统一为 AdminUserList/AdminUser, + 邀请搜索仅消费以下三字段,完整 AdminUser 类型随 P4a Task 8 账号治理页落地) */ interface UserLookupItem { id: string account: string displayName: string } + /** 用户查询响应(P4a 契约统一 AdminUserList 形状:游标元信息 + 条目) */ + interface UserLookupListResponse { + items: UserLookupItem[] + nextCursor: string | null + hasMore: boolean + } + /** 分类创建请求 */ interface CategoryCreatePayload { sortOrder?: number diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 488a7a4..0d4e62f 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -32,7 +32,7 @@ tags: - name: auth description: 密码登录会话(签发、滑动续期、退出) - name: albums - description: 相册创建、列表、详情与配置(工作人员) + description: 相册创建、列表、详情、配置与水印版本管理(工作人员) - name: assets description: 相册宣传图/封面/Logo 资产直传(创建者或管理员) - name: members @@ -42,13 +42,13 @@ tags: - name: uploads description: 直传任务初始化、授权续签、完成核验与会话记录 - name: photos - description: 照片可见性调整与软删除(工作人员) + description: 照片可见性调整(单项与批量)与软删除(工作人员) - name: admin - description: 管理辅助查询 + description: 管理辅助查询与账号治理(用户管理、相册交接、用户同步、用量统计) - name: public description: 观众匿名只读(相册信息与公开照片) - name: interactions - description: 观众匿名互动(点赞批次与浏览上报;ov 域部署,路径前缀不变、域名区分) + description: 观众匿名互动与下载(点赞批次、浏览上报、照片下载;ov 域部署,路径前缀不变、域名区分) - name: internal description: | 服务间内部接口(cn↔ov 同步 + ov 紧急停用通道),HMAC 鉴权,不对匿名 @@ -332,6 +332,91 @@ paths: '500': $ref: '#/components/responses/InternalError' + /albums/{albumId}/watermark-jobs: + parameters: + - $ref: '#/components/parameters/AlbumId' + post: + operationId: createWatermarkJob + tags: [albums] + summary: 保存水印配置并发起历史重生成 + description: | + 保存新的水印配置版本(configVersion 递增)并创建历史照片重生成任务 + (A18/A20)。仅创建者或管理员可操作。语义约定: + 新照片发布默认套用最新版本;历史照片重生成须显式经本端点发起; + 任务为原子切换——全部照片处理完成前旧浏览图保持可用(失败照片保留 + 旧图,任务可重试);原图(original)永不覆盖。 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WatermarkConfig' + responses: + '202': + description: 配置已保存、重生成任务已受理(异步执行,进度经 GET 查询)。 + content: + application/json: + schema: + $ref: '#/components/schemas/WatermarkJobCreated' + '400': + description: 参数校验失败(VALIDATION_FAILED;opacity/scalePercent/fontSize 越界等)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅创建者或管理员可管理水印(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + get: + operationId: getWatermarkJobs + tags: [albums] + summary: 水印配置版本与重生成任务状态 + description: | + 当前生效配置(current,从未配置为 null)与历史任务列表(创建时间倒序)。 + 任务进度(progress/total/failed)供前端轮询;state=failed 的任务可经 + POST 重新发起(按照片粒度幂等续跑)。 + security: + - bearerAuth: [] + responses: + '200': + description: 当前配置与任务列表。 + content: + application/json: + schema: + $ref: '#/components/schemas/WatermarkJobsStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅创建者或管理员可查看(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + # ─────────────────────────── assets ─────────────────────────── /albums/{albumId}/assets: @@ -911,6 +996,56 @@ paths: '500': $ref: '#/components/responses/InternalError' + /albums/{albumId}/photos/hide-batch: + parameters: + - $ref: '#/components/parameters/AlbumId' + post: + operationId: hidePhotosBatch + tags: [photos] + summary: 批量隐藏照片 + description: | + 批量将 published 照片置为 hidden(A16)。逐项返回结果:不存在、已删除 + 或状态不可切换单项 ok=false 并携带 code,不影响其余项(部分成功语义)。 + 单批上限 200(minItems/maxItems)。权限与 403/404 语义同管理照片列表 + (仅创建者或管理员;ALBUM_NOT_FOUND 防枚举)。 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PhotoHideBatchRequest' + responses: + '200': + description: 批量处理结果(逐项;单项失败不影响其余项)。 + content: + application/json: + schema: + $ref: '#/components/schemas/BatchHideResult' + '400': + description: 参数校验失败(VALIDATION_FAILED;photoIds 空/超 200/含空串)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅创建者或管理员可管理照片(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + /photos/{photoId}: parameters: - $ref: '#/components/parameters/PhotoId' @@ -986,53 +1121,518 @@ paths: # ─────────────────────────── admin ─────────────────────────── - /admin/users: - get: - operationId: lookupUsersByAccount + /admin/albums/{albumId}/transfer: + parameters: + - $ref: '#/components/parameters/AlbumId' + post: + operationId: transferAlbum tags: [admin] - summary: 账号前缀查询 + summary: 相册负责人交接 description: | - 邀请协作者的前置查询:按账号前缀搜索激活用户,返回脱敏列表 - (id/account/displayName,不含邮箱、手机与状态字段)。 - 访问策略(P1b 决策):任何已认证工作人员可用——邀请的发起方不限于 - 创建者/管理员,而泄露面仅为激活用户的最小展示字段、接收方为已认证 - 内部人员,风险可接受;未认证一律 401。前缀匹配为大小写敏感 - (服务端按二进制校对执行 LIKE prefix%);按账号升序返回,无游标 - (前缀搜索 + 小上限即取即用)。 + 将相册 owner 移交给目标用户(A17)。仅 admin 可操作(负责人交接为 + 组织级治理动作,区别于相册级配置修改)。keepAsCollaborator=true 时 + 原 owner 转为 collaborator 成员,否则原 owner 移出成员表。历史上传人 + 不变(photos.uploaderId 不动);审计 album.transfer。 security: - bearerAuth: [] - parameters: - - name: accountPrefix - in: query - required: true - description: 账号前缀(≥2 字符以防过宽匹配)。 - schema: - type: string - minLength: 2 - maxLength: 64 - - name: limit - in: query - description: 返回条数上限。 - schema: - type: integer - minimum: 1 - maximum: 20 - default: 10 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AlbumTransferRequest' responses: '200': - description: 匹配的激活用户列表(无匹配为空数组)。 + description: 交接后的相册详情(ownerId 已变更)。 content: application/json: schema: - $ref: '#/components/schemas/AdminUserLookupList' + $ref: '#/components/schemas/Album' '400': - description: 查询参数校验失败(VALIDATION_FAILED;accountPrefix 缺失或长度非法)。 + description: | + 参数校验失败(VALIDATION_FAILED;targetUserId 非法,或目标账号 + 不可被指定为 owner——不存在/停用/待激活/锁定)。 content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可交接相册负责人(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/users: + get: + operationId: listAdminUsers + tags: [admin] + summary: 用户查询与管理列表 + description: | + 双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一 + AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示): + · 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义 + ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、 + 仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled + 且 !localLocked,与成员添加的目标校验一致)、account 升序; + 访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于 + 创建者/管理员,泄露面为已认证内部人员,风险可接受)。 + · 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页, + 按 createdAt 与 ID 稳定倒序),可选 query/status 过滤(不做全文 + 搜索);仅 admin 可用(非 admin 403)。 + 两模式响应形状一致(AdminUserList);前缀模式小上限即取即用, + hasMore/nextCursor 同样按游标语义填充。 + security: + - bearerAuth: [] + parameters: + - name: accountPrefix + in: query + description: 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。 + schema: + type: string + minLength: 2 + maxLength: 64 + - name: query + in: query + description: | + 管理列表过滤:账号或展示名前缀(大小写敏感二进制前缀匹配,同 + accountPrefix 规则;不做全文搜索)。 + schema: + type: string + minLength: 1 + maxLength: 64 + - name: status + in: query + description: 管理列表过滤:治理状态单值归类(归类优先级见 AdminUserStatusFilter)。 + schema: + $ref: '#/components/schemas/AdminUserStatusFilter' + - $ref: '#/components/parameters/CursorQuery' + - name: limit + in: query + description: 每页条数。 + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: 用户列表页(前缀模式无匹配为空数组)。 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminUserList' + '400': + description: | + 查询参数校验失败(VALIDATION_FAILED;accountPrefix/query 长度非法、 + status 取值非法、cursor/limit 非法)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 管理列表模式仅 admin 可用(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/users/{userId}: + parameters: + - $ref: '#/components/parameters/UserId' + patch: + operationId: updateAdminUser + tags: [admin] + summary: 更新用户治理属性 + description: | + 部分更新 role/localLocked/displayName;仅 admin。sourceStatus 只读 + (上游状态仅由同步任务维护,本地不越权改写——技术方案 3.1A 红线, + 需求侧只读展示)。localLocked 置 true 等价停用(撤销该用户全部会话); + 置 false 仅解除本地锁定,不改变上游停用状态。审计全程。 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AdminUserUpdateRequest' + responses: + '200': + description: 更新后的用户条目。 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminUser' + '400': + description: 参数校验失败(VALIDATION_FAILED;role 取值非法、displayName 长度非法、空请求体)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可管理用户(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 目标用户不存在(NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/users/{userId}/reset-password: + parameters: + - $ref: '#/components/parameters/UserId' + post: + operationId: resetAdminUserPassword + tags: [admin] + summary: 管理员重置用户密码 + description: | + 仅 admin。body 可选 newPassword(8-128):缺省由服务端生成随机密码 + (e2e 两种路径均覆盖)。重置成功后撤销该目标用户全部既有会话(须 + 重新登录);新密码仅在响应中返回一次(不入库明文、不留日志),管理 + 端一次性展示转交。审计全程。 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AdminPasswordResetRequest' + responses: + '200': + description: 重置成功(newPassword 为请求指定或服务端生成)。 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminPasswordResetResponse' + '400': + description: 参数校验失败(VALIDATION_FAILED;newPassword 长度非法)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可重置密码(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 目标用户不存在(NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/users/{userId}/activate: + parameters: + - $ref: '#/components/parameters/UserId' + post: + operationId: activateAdminUser + tags: [admin] + summary: 激活待激活用户 + description: | + 仅 admin。credentialStatus pending→active(闭环用户同步创建的待激活 + 账号,§10 语义)。激活本身不出凭据:初始密码经 reset-password 出具 + (推荐流程 activate → reset-password 生成一次性初始密码 → 用户登录)。 + 幂等:对已是 active 的用户重复激活返回当前条目。 + security: + - bearerAuth: [] + responses: + '200': + description: 激活后的用户条目(credentialStatus=active)。 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminUser' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可激活用户(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 目标用户不存在(NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/users/{userId}/deactivate: + parameters: + - $ref: '#/components/parameters/UserId' + post: + operationId: deactivateAdminUser + tags: [admin] + summary: 停用用户 + description: | + 仅 admin。localLocked=true 并撤销该用户全部会话(既有令牌即刻 401)。 + 上游状态不动(sourceStatus 保持;上游恢复 active 不解除本地锁定—— + 本地锁定独立,技术方案 3.1A)。重新启用经 PATCH localLocked=false。 + 审计全程。 + security: + - bearerAuth: [] + responses: + '200': + description: 停用后的用户条目(localLocked=true)。 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminUser' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可停用用户(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 目标用户不存在(NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/user-sync/status: + get: + operationId: getUserSyncStatus + tags: [admin] + summary: 用户同步状态 + description: | + 仅 admin。同步引擎当前状态、最近批次计数与源水位(A29)。当前为 mock + 源(G4:真实接口到位后仅替换 provider,契约不变)。 + security: + - bearerAuth: [] + responses: + '200': + description: 同步状态。 + content: + application/json: + schema: + $ref: '#/components/schemas/UserSyncStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可查看同步状态(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/user-sync/runs: + parameters: + - $ref: '#/components/parameters/CursorQuery' + - name: limit + in: query + description: 每页条数。 + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + get: + operationId: listUserSyncRuns + tags: [admin] + summary: 同步运行历史 + description: 仅 admin。历史批次列表(startedAt 与 ID 稳定倒序游标分页)。 + security: + - bearerAuth: [] + responses: + '200': + description: 运行历史页。 + content: + application/json: + schema: + $ref: '#/components/schemas/UserSyncRunList' + '400': + description: 查询参数校验失败(VALIDATION_FAILED;cursor/limit 非法)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可查看运行历史(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + post: + operationId: triggerUserSyncRun + tags: [admin] + summary: 手动触发同步补拉 + description: | + 仅 admin。手动发起一次同步(与定时调度共用互斥锁;已有批次运行中时 + 不重复执行,返回进行中批次的 runId)。202 表示已受理,异步执行, + 结果经 status/runs 端点查询。 + security: + - bearerAuth: [] + responses: + '202': + description: 已受理(或复用进行中批次)。 + content: + application/json: + schema: + $ref: '#/components/schemas/UserSyncRunCreated' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可手动触发(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/user-sync/errors: + get: + operationId: listUserSyncErrors + tags: [admin] + summary: 同步失败明细 + description: | + 仅 admin。单批次失败行明细(state=partial/failed 批次的坏行记录, + 其余行照常应用的证据链);runId 必填。 + security: + - bearerAuth: [] + parameters: + - name: runId + in: query + required: true + description: 批次 ID。 + schema: + type: string + minLength: 1 + responses: + '200': + description: 失败明细(createdAt 倒序;无失败为空数组)。 + content: + application/json: + schema: + $ref: '#/components/schemas/UserSyncErrorList' + '400': + description: 查询参数校验失败(VALIDATION_FAILED;runId 缺失)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可查看失败明细(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/usage/summary: + get: + operationId: getUsageSummary + tags: [admin] + summary: 用量汇总与阈值告警 + description: | + 仅 admin。按相册聚合存储用量(原图 + 变体,实时 SUM——P1 规模够用; + P5 接 OSS 用量 API/物化视图,登记偏差)、当前阈值与告警列表 + (A23 后台侧;告警接收人通知渠道 P5)。 + security: + - bearerAuth: [] + responses: + '200': + description: 用量汇总。 + content: + application/json: + schema: + $ref: '#/components/schemas/UsageSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可查看用量(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /admin/usage/thresholds: + put: + operationId: updateUsageThresholds + tags: [admin] + summary: 更新用量阈值 + description: | + 仅 admin。部分更新:缺省字段保持不变,显式 null 清除该阈值(清除后 + 对应告警维度停用)。修改后即刻重新评估(超阈产生新告警、阈值上调将 + 已回落旧告警标记 resolvedAt)。 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UsageThresholdsUpdate' + responses: + '200': + description: 更新后的阈值。 + content: + application/json: + schema: + $ref: '#/components/schemas/UsageThresholds' + '400': + description: 参数校验失败(VALIDATION_FAILED;负值或非整数)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 仅 admin 可更新阈值(AUTH_FORBIDDEN)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '500': $ref: '#/components/responses/InternalError' @@ -1281,6 +1881,60 @@ paths: '500': $ref: '#/components/responses/InternalError' + /public/albums/{token}/photos/{photoId}/download: + parameters: + - $ref: '#/components/parameters/AlbumPublicToken' + - $ref: '#/components/parameters/PhotoId' + get: + operationId: downloadPublicPhoto + tags: [interactions] + summary: 公开照片下载 + description: | + 匿名下载(ov 域部署,观众动作端点故归 interactions)。按相册两级下载 + 开关校验(A18):variant=browse 需 downloadEnabled;variant=original + 需 originalDownloadEnabled(在 downloadEnabled 基础上的更高级开关, + 两者均开才可下载原图)。对应级别开关关闭 → 403 DOWNLOAD_DISABLED; + 相册/照片不可见 404(ALBUM_NOT_PUBLIC/PHOTO_NOT_PUBLIC,不区分枚举)。 + URL 为短期签名凭证,禁止持久保存/写入分享链接,过期经本端点刷新; + 服务端日志不记完整签名 URL。 + security: [] + parameters: + - name: variant + in: query + required: true + description: 下载版本。 + schema: + $ref: '#/components/schemas/DownloadVariant' + responses: + '200': + description: 下载凭证(短期签名 URL)。 + content: + application/json: + schema: + $ref: '#/components/schemas/PublicPhotoDownload' + '400': + description: 查询参数校验失败(VALIDATION_FAILED;variant 缺失或取值非法)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: 相册下载开关关闭(DOWNLOAD_DISABLED;对应 variant 所需级别的开关未开)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: | + 相册不存在或未公开(ALBUM_NOT_PUBLIC),或照片不存在、未发布、 + 不属于该相册(PHOTO_NOT_PUBLIC)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + # ─────────────────────────── internal ─────────────────────────── /internal/sync/content-batches: @@ -1619,32 +2273,38 @@ components: schemas: # ── 通用 ── + ErrorCode: + type: string + description: | + 稳定错误码(服务器生成,与 src/errors.ts 的 ErrorCodes 一一对应, + 1:1 类型守护见 __tests__/events.test.ts);P4a 起供批量逐项结果等 + 内嵌失败原因复用。 + enum: + - AUTH_INVALID_CREDENTIALS + - AUTH_TOKEN_EXPIRED + - AUTH_RATE_LIMITED + - RATE_LIMITED + - AUTH_FORBIDDEN + - NOT_FOUND + - ALBUM_NOT_FOUND + - ALBUM_NOT_PUBLIC + - ALBUM_UPLOAD_DISABLED + - MEMBER_CONFLICT + - UPLOAD_SESSION_CONFLICT + - UPLOAD_VERIFY_FAILED + - PHOTO_NOT_FOUND + - PHOTO_NOT_PUBLIC + - DOWNLOAD_DISABLED + - VALIDATION_FAILED + - SYNC_AUTH_FAILED + - INTERNAL_ERROR + Error: type: object required: [code, message, requestId] properties: code: - type: string - readOnly: true - description: 稳定错误码,与 src/errors.ts 的 ErrorCodes 一一对应。 - enum: - - AUTH_INVALID_CREDENTIALS - - AUTH_TOKEN_EXPIRED - - AUTH_RATE_LIMITED - - RATE_LIMITED - - AUTH_FORBIDDEN - - NOT_FOUND - - ALBUM_NOT_FOUND - - ALBUM_NOT_PUBLIC - - ALBUM_UPLOAD_DISABLED - - MEMBER_CONFLICT - - UPLOAD_SESSION_CONFLICT - - UPLOAD_VERIFY_FAILED - - PHOTO_NOT_FOUND - - PHOTO_NOT_PUBLIC - - VALIDATION_FAILED - - SYNC_AUTH_FAILED - - INTERNAL_ERROR + $ref: '#/components/schemas/ErrorCode' message: type: string readOnly: true @@ -1941,6 +2601,159 @@ components: items: $ref: '#/components/schemas/Category' + # ── 水印(P4a) ── + + WatermarkConfig: + type: object + description: | + 水印配置(P4a A18,创建请求与当前配置响应共用)。enabled=false 表示 + 关闭水印(新照片不加水印),其余字段此时被忽略;可选字段缺省用服务端 + 默认值。 + required: [enabled] + properties: + enabled: + type: boolean + description: 是否开启浏览图水印。 + position: + type: string + description: 水印位置;缺省服务端默认。 + enum: + - topLeft + - topRight + - bottomLeft + - bottomRight + - center + opacity: + type: integer + minimum: 0 + maximum: 100 + description: 不透明度百分比。 + scalePercent: + type: integer + minimum: 5 + maximum: 50 + description: 水印尺寸相对浏览图宽度的百分比。 + text: + description: 文字水印参数;缺省不绘制文字。 + allOf: + - $ref: '#/components/schemas/WatermarkTextConfig' + + WatermarkTextConfig: + type: object + description: 文字水印参数(SVG 渲染实现,Task 3 登记)。 + properties: + content: + type: string + minLength: 1 + maxLength: 64 + description: 文字内容。 + fontSize: + type: integer + minimum: 8 + maximum: 72 + description: 字号(像素)。 + + WatermarkJobCreated: + type: object + description: 重生成任务受理结果;configVersion 为本次保存的配置版本。 + required: [jobId, configVersion] + properties: + jobId: + type: string + readOnly: true + description: 重生成任务 ID。 + configVersion: + type: integer + readOnly: true + minimum: 1 + description: 本次保存的水印配置版本(新照片即刻套用)。 + + WatermarkJobState: + type: string + description: 重生成任务状态;failed 可重新发起(按照片粒度幂等续跑)。 + enum: + - pending + - running + - done + - failed + + WatermarkJob: + type: object + description: 历史重生成任务条目(进度供前端轮询)。 + required: + - jobId + - configVersion + - state + - progress + - total + - failed + - createdAt + properties: + jobId: + type: string + readOnly: true + description: 任务 ID。 + configVersion: + type: integer + readOnly: true + minimum: 1 + description: 目标配置版本。 + state: + $ref: '#/components/schemas/WatermarkJobState' + progress: + type: integer + readOnly: true + minimum: 0 + description: 已处理张数(含失败)。 + total: + type: integer + readOnly: true + minimum: 0 + description: 待处理总张数。 + failed: + type: integer + readOnly: true + minimum: 0 + description: 失败张数。 + errorCode: + description: 任务失败原因(state=failed 时必有)。 + allOf: + - $ref: '#/components/schemas/ErrorCode' + createdAt: + type: string + format: date-time + readOnly: true + description: 任务创建时间。 + + WatermarkCurrent: + type: object + description: 当前生效的水印配置。 + required: [configVersion, config] + properties: + configVersion: + type: integer + readOnly: true + minimum: 1 + description: 当前配置版本(同 AlbumSummary.watermarkVersion)。 + config: + $ref: '#/components/schemas/WatermarkConfig' + + WatermarkJobsStatus: + type: object + description: 水印配置与任务状态;current 从未配置为 null(watermarkVersion=0)。 + required: [current, jobs] + properties: + current: + description: 当前生效配置;从未配置为 null。 + anyOf: + - $ref: '#/components/schemas/WatermarkCurrent' + - type: 'null' + jobs: + type: array + description: 历史任务(创建时间倒序)。 + items: + $ref: '#/components/schemas/WatermarkJob' + # ── 资产 ── AssetKind: @@ -2347,6 +3160,49 @@ components: - hidden - published + PhotoHideBatchRequest: + type: object + description: 批量隐藏请求(P4a A16);逐项处理,部分成功。 + required: [photoIds] + properties: + photoIds: + type: array + minItems: 1 + maxItems: 200 + description: 目标照片 ID 列表(单批上限 200,执行注意约定)。 + items: + type: string + minLength: 1 + + BatchHideItem: + type: object + description: 单项隐藏结果;ok=false 时 code 说明原因(如 PHOTO_NOT_PUBLIC/PHOTO_NOT_FOUND)。 + required: [photoId, ok] + properties: + photoId: + type: string + readOnly: true + description: 照片 ID。 + ok: + type: boolean + readOnly: true + description: 本项是否隐藏成功。 + code: + description: 失败原因错误码(ok=false 时必有)。 + allOf: + - $ref: '#/components/schemas/ErrorCode' + + BatchHideResult: + type: object + description: 批量隐藏结果;results 与请求 photoIds 同序逐项对应。 + required: [results] + properties: + results: + type: array + description: 逐项结果(与请求同序)。 + items: + $ref: '#/components/schemas/BatchHideItem' + PhotoRecord: type: object description: 管理端照片记录(技术实施方案 §4 photos 表字段)。 @@ -2457,17 +3313,42 @@ components: items: $ref: '#/components/schemas/AdminPhoto' - # ── 管理辅助 ── + # ── 管理辅助与账号治理(P4a) ── - AdminUserLookupItem: + AlbumTransferRequest: type: object - description: 账号查询条目(脱敏:仅邀请协作者所需的最小字段,不含邮箱/手机/状态)。 - required: [id, account, displayName] + description: 相册负责人交接请求(A17)。 + required: [targetUserId, keepAsCollaborator] + properties: + targetUserId: + type: string + minLength: 1 + maxLength: 64 + description: 新 owner 用户 ID(须为可被指定为 owner 的账号,见端点 400 说明)。 + keepAsCollaborator: + type: boolean + description: 原 owner 是否保留为 collaborator;false 时移出成员表。 + + AdminUser: + type: object + description: | + 用户治理条目(P4a;不含邮箱/手机/密码等凭据字段)。sourceStatus 只读 + 展示(上游状态仅同步任务维护)。id 用于邀请协作者 + (POST /albums/{albumId}/members 的 userId)。 + required: + - id + - account + - displayName + - role + - sourceStatus + - localLocked + - credentialStatus + - createdAt properties: id: type: string readOnly: true - description: 用户 ID(用于 POST /albums/{albumId}/members 的 userId)。 + description: 用户 ID。 account: type: string readOnly: true @@ -2476,17 +3357,423 @@ components: type: string readOnly: true description: 展示姓名。 + role: + type: string + readOnly: true + description: 公司角色(staff/admin;相册角色另表)。 + enum: + - staff + - admin + sourceStatus: + type: string + readOnly: true + description: 来源状态:local=本地创建;active/disabled=同步源状态(P4a 同步)。 + enum: + - local + - active + - disabled + localLocked: + type: boolean + readOnly: true + description: 本地锁定(停用/deactivate 置 true;与上游状态独立)。 + credentialStatus: + type: string + readOnly: true + description: 凭据状态:active=可登录;pending=同步创建待激活(不可登录,待管理员激活并出初始凭据)。 + enum: + - active + - pending + locale: + type: [string, 'null'] + readOnly: true + description: 界面语言偏好;未设置为 null。 + createdAt: + type: string + format: date-time + readOnly: true + description: 创建时间。 - AdminUserLookupList: + AdminUserList: + allOf: + - $ref: '#/components/schemas/CursorPageMeta' + - type: object + required: [items] + properties: + items: + type: array + description: 用户条目(前缀模式按 account 升序且仅激活资格用户;管理列表模式按 createdAt 与 ID 稳定倒序)。 + items: + $ref: '#/components/schemas/AdminUser' + + AdminUserStatusFilter: + type: string + description: | + 管理列表治理状态过滤(单值归类):pending=待激活;disabled=上游停用; + locked=本地锁定;active=其余可正常登录。同时命中多状态时按 + pending > disabled > locked > active 优先级归类后匹配。 + enum: + - active + - pending + - disabled + - locked + + AdminUserUpdateRequest: type: object - description: 账号前缀查询结果;无游标(前缀搜索 + 小上限即取即用)。 + description: 用户治理属性部分更新(仅提交的字段被修改;sourceStatus 不在可更新集)。 + properties: + role: + type: string + description: 公司角色。 + enum: + - staff + - admin + localLocked: + type: boolean + description: 本地锁定;置 true 撤销该用户全部会话。 + displayName: + type: string + minLength: 1 + maxLength: 128 + description: 展示姓名。 + + AdminPasswordResetRequest: + type: object + description: 管理员重置密码请求;newPassword 缺省由服务端生成随机密码。 + properties: + newPassword: + type: string + minLength: 8 + maxLength: 128 + description: 指定新密码(至少 8 位);缺省服务端生成。 + + AdminPasswordResetResponse: + type: object + description: 重置结果;newPassword 仅此一次返回(请求指定或服务端生成)。 + required: [newPassword] + properties: + newPassword: + type: string + readOnly: true + description: 重置后的密码(一次性展示,转交用户后不再可见)。 + + UserSyncState: + type: string + description: 同步引擎状态:idle=空闲/从未运行;其余为最近批次结局。 + enum: + - idle + - running + - success + - partial + - failed + + UserSyncRunState: + type: string + description: 批次状态(partial=部分行失败,其余行照常应用)。 + enum: + - running + - success + - partial + - failed + + UserSyncCounts: + type: object + description: 批次计数。 + required: [added, updated, disabled, failed] + properties: + added: + type: integer + minimum: 0 + description: 新增用户数(建为待激活)。 + updated: + type: integer + minimum: 0 + description: 幂等更新数(改名等)。 + disabled: + type: integer + minimum: 0 + description: 上游停用数(撤会话)。 + failed: + type: integer + minimum: 0 + description: 失败行数(明细见 /admin/user-sync/errors)。 + + UserSyncStatus: + type: object + description: 用户同步引擎状态(A29;mock 源先行,G4 后仅替换 provider)。 + required: [state] + properties: + lastRunAt: + type: [string, 'null'] + format: date-time + readOnly: true + description: 最近批次开始时间;从未运行为 null。 + lastSuccessAt: + type: [string, 'null'] + format: date-time + readOnly: true + description: 最近完全成功批次开始时间;从未成功为 null。 + state: + $ref: '#/components/schemas/UserSyncState' + sourceVersion: + type: [integer, 'null'] + format: int64 + readOnly: true + description: 已提交的源水位(版本游标);从未同步为 null。 + counts: + description: 最近批次计数;从未运行为 null。 + anyOf: + - $ref: '#/components/schemas/UserSyncCounts' + - type: 'null' + + UserSyncRun: + type: object + description: 同步批次条目。 + required: [id, trigger, state, counts, startedAt] + properties: + id: + type: string + readOnly: true + description: 批次 ID。 + trigger: + type: string + readOnly: true + description: 触发方式(定时/手动补拉)。 + enum: + - cron + - manual + state: + $ref: '#/components/schemas/UserSyncRunState' + cursor: + type: [integer, 'null'] + format: int64 + readOnly: true + description: 本批次已提交的源水位(同步游标,非分页游标);未提交为 null。 + counts: + $ref: '#/components/schemas/UserSyncCounts' + startedAt: + type: string + format: date-time + readOnly: true + description: 批次开始时间。 + finishedAt: + type: [string, 'null'] + format: date-time + readOnly: true + description: 批次结束时间;运行中为 null。 + error: + type: [string, 'null'] + readOnly: true + description: 批次级失败摘要;无为 null。 + + UserSyncRunList: + allOf: + - $ref: '#/components/schemas/CursorPageMeta' + - type: object + required: [items] + properties: + items: + type: array + description: 批次列表(startedAt 与 ID 稳定倒序)。 + items: + $ref: '#/components/schemas/UserSyncRun' + + UserSyncRunCreated: + type: object + description: 手动触发受理结果。 + required: [runId] + properties: + runId: + type: string + readOnly: true + description: 批次 ID(已有批次运行中时为该进行中批次)。 + + UserSyncError: + type: object + description: 同步失败行明细(坏行记录,其余行照常应用的证据链)。 + required: [runId, externalUserId, code, detail, createdAt] + properties: + runId: + type: string + readOnly: true + description: 归属批次 ID。 + externalUserId: + type: string + readOnly: true + description: 上游用户标识。 + code: + type: string + readOnly: true + description: 失败分类码(provider 级约定,如 upstream_timeout/invalid_record)。 + detail: + type: string + readOnly: true + description: 失败详情(诊断用)。 + createdAt: + type: string + format: date-time + readOnly: true + description: 记录时间。 + + UserSyncErrorList: + type: object + description: 失败明细列表(createdAt 倒序;无游标即取即用)。 required: [items] properties: items: type: array - description: 匹配的激活用户(按账号升序)。 items: - $ref: '#/components/schemas/AdminUserLookupItem' + $ref: '#/components/schemas/UserSyncError' + + UsageAlbum: + type: object + description: 单相册用量聚合(实时 SUM;P5 换物化/OSS 用量,登记偏差)。 + required: + - albumId + - title + - photoCount + - originalBytes + - variantBytes + - totalBytes + properties: + albumId: + type: string + readOnly: true + description: 相册 ID。 + title: + type: string + readOnly: true + description: 相册标题(defaultLocale 解析)。 + photoCount: + type: integer + readOnly: true + minimum: 0 + description: 照片总数(全部状态)。 + originalBytes: + type: integer + format: int64 + readOnly: true + minimum: 0 + description: 原图存储字节数。 + variantBytes: + type: integer + format: int64 + readOnly: true + minimum: 0 + description: 变体(缩略/浏览/水印等)存储字节数。 + totalBytes: + type: integer + format: int64 + readOnly: true + minimum: 0 + description: 合计字节数(originalBytes + variantBytes)。 + + UsageTotals: + type: object + description: 全库用量合计。 + required: [photoCount, totalBytes] + properties: + photoCount: + type: integer + readOnly: true + minimum: 0 + description: 照片总数。 + totalBytes: + type: integer + format: int64 + readOnly: true + minimum: 0 + description: 存储总字节数。 + + UsageThresholds: + type: object + description: 用量阈值;null 表示该维度未设置(告警停用)。 + required: [storageBytes, perAlbumBytes] + properties: + storageBytes: + type: [integer, 'null'] + format: int64 + readOnly: true + minimum: 0 + description: 全库存储阈值。 + perAlbumBytes: + type: [integer, 'null'] + format: int64 + readOnly: true + minimum: 0 + description: 单相册存储阈值。 + + UsageThresholdsUpdate: + type: object + description: 阈值更新请求;字段缺省=保持不变,显式 null=清除该阈值。 + properties: + storageBytes: + type: [integer, 'null'] + format: int64 + minimum: 0 + description: 全库存储阈值;null 清除。 + perAlbumBytes: + type: [integer, 'null'] + format: int64 + minimum: 0 + description: 单相册存储阈值;null 清除。 + + UsageAlert: + type: object + description: | + 阈值告警(A23 后台侧;接收人通知渠道 P5)。超阈写入一次直到回落 + (同 kind+albumId 活跃告警唯一——重复告警去重);resolvedAt 非空为 + 已解除。 + required: [id, kind, message, createdAt] + properties: + id: + type: string + readOnly: true + description: 告警 ID。 + kind: + type: string + readOnly: true + description: 告警维度。 + enum: + - storage + - perAlbum + albumId: + type: [string, 'null'] + readOnly: true + description: 相册 ID;kind=perAlbum 必有,storage 为 null。 + message: + type: string + readOnly: true + description: 告警描述。 + createdAt: + type: string + format: date-time + readOnly: true + description: 产生时间。 + resolvedAt: + type: [string, 'null'] + format: date-time + readOnly: true + description: 解除时间(阈值上调/用量回落时标记);活跃告警为 null。 + + UsageSummary: + type: object + description: 用量汇总(A23 后台侧)。 + required: [albums, totals, thresholds, alerts] + properties: + albums: + type: array + description: 相册用量(按 totalBytes 降序)。 + items: + $ref: '#/components/schemas/UsageAlbum' + totals: + $ref: '#/components/schemas/UsageTotals' + thresholds: + $ref: '#/components/schemas/UsageThresholds' + alerts: + type: array + description: 告警列表(createdAt 倒序,含已解除)。 + items: + $ref: '#/components/schemas/UsageAlert' # ── 公开(观众端) ── @@ -2659,6 +3946,24 @@ components: # ── 互动(观众端,ov 域) ── + DownloadVariant: + type: string + description: | + 下载版本(P4a A18):browse=当前生效浏览图(水印版); + original=原图(需 originalDownloadEnabled 更高级开关)。 + enum: + - browse + - original + + PublicPhotoDownload: + allOf: + - $ref: '#/components/schemas/SignedUrl' + - type: object + required: [variant] + properties: + variant: + $ref: '#/components/schemas/DownloadVariant' + LikeBatchRequest: type: object description: | diff --git a/packages/contracts/src/errors.ts b/packages/contracts/src/errors.ts index 3c825a5..c3238bd 100644 --- a/packages/contracts/src/errors.ts +++ b/packages/contracts/src/errors.ts @@ -24,6 +24,12 @@ export const ErrorCodes = { PHOTO_NOT_FOUND: 'PHOTO_NOT_FOUND', PHOTO_NOT_PUBLIC: 'PHOTO_NOT_PUBLIC', VALIDATION_FAILED: 'VALIDATION_FAILED', + // P4a Task 1:公开照片下载(/download)相册下载开关关闭时的 403。相册/照片 + // 不可见仍走 404(ALBUM_NOT_PUBLIC/PHOTO_NOT_PUBLIC 不区分枚举);「可见但 + // 禁止下载」是显式语义——复用 404 会对客户端撒谎,复用 AUTH_FORBIDDEN 则 + // 语义错位(匿名观众无「权限」概念)。与 openapi.yaml Error enum 同步增补 + // (1:1 守护见 __tests__/events.test.ts)。 + DOWNLOAD_DISABLED: 'DOWNLOAD_DISABLED', // P2 Task 3:内部同步接口(/internal/**)HMAC 鉴权失败的 401。AUTH_TOKEN_EXPIRED // 语义绑死观众/员工会话,服务间签名失败复用它会对客户端撒谎;与 openapi.yaml // Error enum 同步增补(1:1 守护见 __tests__/events.test.ts)。 diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index d1676c5..97e7518 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -140,6 +140,39 @@ export interface paths { patch: operations["updateAlbum"]; trace?: never; }; + "/albums/{albumId}/watermark-jobs": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册 ID。 */ + albumId: components["parameters"]["AlbumId"]; + }; + cookie?: never; + }; + /** + * 水印配置版本与重生成任务状态 + * @description 当前生效配置(current,从未配置为 null)与历史任务列表(创建时间倒序)。 + * 任务进度(progress/total/failed)供前端轮询;state=failed 的任务可经 + * POST 重新发起(按照片粒度幂等续跑)。 + */ + get: operations["getWatermarkJobs"]; + put?: never; + /** + * 保存水印配置并发起历史重生成 + * @description 保存新的水印配置版本(configVersion 递增)并创建历史照片重生成任务 + * (A18/A20)。仅创建者或管理员可操作。语义约定: + * 新照片发布默认套用最新版本;历史照片重生成须显式经本端点发起; + * 任务为原子切换——全部照片处理完成前旧浏览图保持可用(失败照片保留 + * 旧图,任务可重试);原图(original)永不覆盖。 + */ + post: operations["createWatermarkJob"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums/{albumId}/assets": { parameters: { query?: never; @@ -395,6 +428,32 @@ export interface paths { patch?: never; trace?: never; }; + "/albums/{albumId}/photos/hide-batch": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册 ID。 */ + albumId: components["parameters"]["AlbumId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 批量隐藏照片 + * @description 批量将 published 照片置为 hidden(A16)。逐项返回结果:不存在、已删除 + * 或状态不可切换单项 ok=false 并携带 code,不影响其余项(部分成功语义)。 + * 单批上限 200(minItems/maxItems)。权限与 403/404 语义同管理照片列表 + * (仅创建者或管理员;ALBUM_NOT_FOUND 防枚举)。 + */ + post: operations["hidePhotosBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/photos/{photoId}": { parameters: { query?: never; @@ -422,6 +481,32 @@ export interface paths { patch: operations["updatePhoto"]; trace?: never; }; + "/admin/albums/{albumId}/transfer": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册 ID。 */ + albumId: components["parameters"]["AlbumId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 相册负责人交接 + * @description 将相册 owner 移交给目标用户(A17)。仅 admin 可操作(负责人交接为 + * 组织级治理动作,区别于相册级配置修改)。keepAsCollaborator=true 时 + * 原 owner 转为 collaborator 成员,否则原 owner 移出成员表。历史上传人 + * 不变(photos.uploaderId 不动);审计 album.transfer。 + */ + post: operations["transferAlbum"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/users": { parameters: { query?: never; @@ -430,16 +515,22 @@ export interface paths { cookie?: never; }; /** - * 账号前缀查询 - * @description 邀请协作者的前置查询:按账号前缀搜索激活用户,返回脱敏列表 - * (id/account/displayName,不含邮箱、手机与状态字段)。 - * 访问策略(P1b 决策):任何已认证工作人员可用——邀请的发起方不限于 - * 创建者/管理员,而泄露面仅为激活用户的最小展示字段、接收方为已认证 - * 内部人员,风险可接受;未认证一律 401。前缀匹配为大小写敏感 - * (服务端按二进制校对执行 LIKE prefix%);按账号升序返回,无游标 - * (前缀搜索 + 小上限即取即用)。 + * 用户查询与管理列表 + * @description 双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一 + * AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示): + * · 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义 + * ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、 + * 仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled + * 且 !localLocked,与成员添加的目标校验一致)、account 升序; + * 访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于 + * 创建者/管理员,泄露面为已认证内部人员,风险可接受)。 + * · 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页, + * 按 createdAt 与 ID 稳定倒序),可选 query/status 过滤(不做全文 + * 搜索);仅 admin 可用(非 admin 403)。 + * 两模式响应形状一致(AdminUserList);前缀模式小上限即取即用, + * hasMore/nextCursor 同样按游标语义填充。 */ - get: operations["lookupUsersByAccount"]; + get: operations["listAdminUsers"]; put?: never; post?: never; delete?: never; @@ -448,6 +539,227 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/users/{userId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * 更新用户治理属性 + * @description 部分更新 role/localLocked/displayName;仅 admin。sourceStatus 只读 + * (上游状态仅由同步任务维护,本地不越权改写——技术方案 3.1A 红线, + * 需求侧只读展示)。localLocked 置 true 等价停用(撤销该用户全部会话); + * 置 false 仅解除本地锁定,不改变上游停用状态。审计全程。 + */ + patch: operations["updateAdminUser"]; + trace?: never; + }; + "/admin/users/{userId}/reset-password": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 管理员重置用户密码 + * @description 仅 admin。body 可选 newPassword(8-128):缺省由服务端生成随机密码 + * (e2e 两种路径均覆盖)。重置成功后撤销该目标用户全部既有会话(须 + * 重新登录);新密码仅在响应中返回一次(不入库明文、不留日志),管理 + * 端一次性展示转交。审计全程。 + */ + post: operations["resetAdminUserPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/{userId}/activate": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 激活待激活用户 + * @description 仅 admin。credentialStatus pending→active(闭环用户同步创建的待激活 + * 账号,§10 语义)。激活本身不出凭据:初始密码经 reset-password 出具 + * (推荐流程 activate → reset-password 生成一次性初始密码 → 用户登录)。 + * 幂等:对已是 active 的用户重复激活返回当前条目。 + */ + post: operations["activateAdminUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/{userId}/deactivate": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 停用用户 + * @description 仅 admin。localLocked=true 并撤销该用户全部会话(既有令牌即刻 401)。 + * 上游状态不动(sourceStatus 保持;上游恢复 active 不解除本地锁定—— + * 本地锁定独立,技术方案 3.1A)。重新启用经 PATCH localLocked=false。 + * 审计全程。 + */ + post: operations["deactivateAdminUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-sync/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 用户同步状态 + * @description 仅 admin。同步引擎当前状态、最近批次计数与源水位(A29)。当前为 mock + * 源(G4:真实接口到位后仅替换 provider,契约不变)。 + */ + get: operations["getUserSyncStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-sync/runs": { + parameters: { + query?: { + /** @description 分页游标(上一页响应的 nextCursor)。 */ + cursor?: components["parameters"]["CursorQuery"]; + /** @description 每页条数。 */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 同步运行历史 + * @description 仅 admin。历史批次列表(startedAt 与 ID 稳定倒序游标分页)。 + */ + get: operations["listUserSyncRuns"]; + put?: never; + /** + * 手动触发同步补拉 + * @description 仅 admin。手动发起一次同步(与定时调度共用互斥锁;已有批次运行中时 + * 不重复执行,返回进行中批次的 runId)。202 表示已受理,异步执行, + * 结果经 status/runs 端点查询。 + */ + post: operations["triggerUserSyncRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-sync/errors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 同步失败明细 + * @description 仅 admin。单批次失败行明细(state=partial/failed 批次的坏行记录, + * 其余行照常应用的证据链);runId 必填。 + */ + get: operations["listUserSyncErrors"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/usage/summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 用量汇总与阈值告警 + * @description 仅 admin。按相册聚合存储用量(原图 + 变体,实时 SUM——P1 规模够用; + * P5 接 OSS 用量 API/物化视图,登记偏差)、当前阈值与告警列表 + * (A23 后台侧;告警接收人通知渠道 P5)。 + */ + get: operations["getUsageSummary"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/usage/thresholds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * 更新用量阈值 + * @description 仅 admin。部分更新:缺省字段保持不变,显式 null 清除该阈值(清除后 + * 对应告警维度停用)。修改后即刻重新评估(超阈产生新告警、阈值上调将 + * 已回落旧告警标记 resolvedAt)。 + */ + put: operations["updateUsageThresholds"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/public/albums/{token}": { parameters: { query?: never; @@ -593,6 +905,37 @@ export interface paths { patch?: never; trace?: never; }; + "/public/albums/{token}/photos/{photoId}/download": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册公开令牌(不可枚举,持有即可访问)。 */ + token: components["parameters"]["AlbumPublicToken"]; + /** @description 照片 ID。 */ + photoId: components["parameters"]["PhotoId"]; + }; + cookie?: never; + }; + /** + * 公开照片下载 + * @description 匿名下载(ov 域部署,观众动作端点故归 interactions)。按相册两级下载 + * 开关校验(A18):variant=browse 需 downloadEnabled;variant=original + * 需 originalDownloadEnabled(在 downloadEnabled 基础上的更高级开关, + * 两者均开才可下载原图)。对应级别开关关闭 → 403 DOWNLOAD_DISABLED; + * 相册/照片不可见 404(ALBUM_NOT_PUBLIC/PHOTO_NOT_PUBLIC,不区分枚举)。 + * URL 为短期签名凭证,禁止持久保存/写入分享链接,过期经本端点刷新; + * 服务端日志不记完整签名 URL。 + */ + get: operations["downloadPublicPhoto"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/internal/sync/content-batches": { parameters: { query?: never; @@ -764,12 +1107,15 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * @description 稳定错误码(服务器生成,与 src/errors.ts 的 ErrorCodes 一一对应, + * 1:1 类型守护见 __tests__/events.test.ts);P4a 起供批量逐项结果等 + * 内嵌失败原因复用。 + * @enum {string} + */ + ErrorCode: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "DOWNLOAD_DISABLED" | "VALIDATION_FAILED" | "SYNC_AUTH_FAILED" | "INTERNAL_ERROR"; Error: { - /** - * @description 稳定错误码,与 src/errors.ts 的 ErrorCodes 一一对应。 - * @enum {string} - */ - readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "SYNC_AUTH_FAILED" | "INTERNAL_ERROR"; + code: components["schemas"]["ErrorCode"]; /** @description 服务器生成的可读消息(诊断用,不作为客户端展示文案依据)。 */ readonly message: string; /** @description 请求追踪 ID。 */ @@ -952,6 +1298,79 @@ export interface components { /** @description 相册分类(按 sortOrder 升序)。 */ categories: components["schemas"]["Category"][]; }; + /** + * @description 水印配置(P4a A18,创建请求与当前配置响应共用)。enabled=false 表示 + * 关闭水印(新照片不加水印),其余字段此时被忽略;可选字段缺省用服务端 + * 默认值。 + */ + WatermarkConfig: { + /** @description 是否开启浏览图水印。 */ + enabled: boolean; + /** + * @description 水印位置;缺省服务端默认。 + * @enum {string} + */ + position?: "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | "center"; + /** @description 不透明度百分比。 */ + opacity?: number; + /** @description 水印尺寸相对浏览图宽度的百分比。 */ + scalePercent?: number; + /** @description 文字水印参数;缺省不绘制文字。 */ + text?: components["schemas"]["WatermarkTextConfig"]; + }; + /** @description 文字水印参数(SVG 渲染实现,Task 3 登记)。 */ + WatermarkTextConfig: { + /** @description 文字内容。 */ + content?: string; + /** @description 字号(像素)。 */ + fontSize?: number; + }; + /** @description 重生成任务受理结果;configVersion 为本次保存的配置版本。 */ + WatermarkJobCreated: { + /** @description 重生成任务 ID。 */ + readonly jobId: string; + /** @description 本次保存的水印配置版本(新照片即刻套用)。 */ + readonly configVersion: number; + }; + /** + * @description 重生成任务状态;failed 可重新发起(按照片粒度幂等续跑)。 + * @enum {string} + */ + WatermarkJobState: "pending" | "running" | "done" | "failed"; + /** @description 历史重生成任务条目(进度供前端轮询)。 */ + WatermarkJob: { + /** @description 任务 ID。 */ + readonly jobId: string; + /** @description 目标配置版本。 */ + readonly configVersion: number; + state: components["schemas"]["WatermarkJobState"]; + /** @description 已处理张数(含失败)。 */ + readonly progress: number; + /** @description 待处理总张数。 */ + readonly total: number; + /** @description 失败张数。 */ + readonly failed: number; + /** @description 任务失败原因(state=failed 时必有)。 */ + errorCode?: components["schemas"]["ErrorCode"]; + /** + * Format: date-time + * @description 任务创建时间。 + */ + readonly createdAt: string; + }; + /** @description 当前生效的水印配置。 */ + WatermarkCurrent: { + /** @description 当前配置版本(同 AlbumSummary.watermarkVersion)。 */ + readonly configVersion: number; + config: components["schemas"]["WatermarkConfig"]; + }; + /** @description 水印配置与任务状态;current 从未配置为 null(watermarkVersion=0)。 */ + WatermarkJobsStatus: { + /** @description 当前生效配置;从未配置为 null。 */ + current: components["schemas"]["WatermarkCurrent"] | null; + /** @description 历史任务(创建时间倒序)。 */ + jobs: components["schemas"]["WatermarkJob"][]; + }; /** * @description 相册资产类型(技术实施方案 §4 album_assets.kind)。banner 为观众页宣传图 * (PublicAlbum.banner,P1b 消费方);cover/logo 建表与上传链路就绪,展示 @@ -1179,6 +1598,25 @@ export interface components { */ visibility: "hidden" | "published"; }; + /** @description 批量隐藏请求(P4a A16);逐项处理,部分成功。 */ + PhotoHideBatchRequest: { + /** @description 目标照片 ID 列表(单批上限 200,执行注意约定)。 */ + photoIds: string[]; + }; + /** @description 单项隐藏结果;ok=false 时 code 说明原因(如 PHOTO_NOT_PUBLIC/PHOTO_NOT_FOUND)。 */ + BatchHideItem: { + /** @description 照片 ID。 */ + readonly photoId: string; + /** @description 本项是否隐藏成功。 */ + readonly ok: boolean; + /** @description 失败原因错误码(ok=false 时必有)。 */ + code?: components["schemas"]["ErrorCode"]; + }; + /** @description 批量隐藏结果;results 与请求 photoIds 同序逐项对应。 */ + BatchHideResult: { + /** @description 逐项结果(与请求同序)。 */ + results: components["schemas"]["BatchHideItem"][]; + }; /** @description 管理端照片记录(技术实施方案 §4 photos 表字段)。 */ PhotoRecord: { /** @description 照片 ID。 */ @@ -1233,19 +1671,279 @@ export interface components { AdminPhotoList: components["schemas"]["CursorPageMeta"] & { items: components["schemas"]["AdminPhoto"][]; }; - /** @description 账号查询条目(脱敏:仅邀请协作者所需的最小字段,不含邮箱/手机/状态)。 */ - AdminUserLookupItem: { - /** @description 用户 ID(用于 POST /albums/{albumId}/members 的 userId)。 */ + /** @description 相册负责人交接请求(A17)。 */ + AlbumTransferRequest: { + /** @description 新 owner 用户 ID(须为可被指定为 owner 的账号,见端点 400 说明)。 */ + targetUserId: string; + /** @description 原 owner 是否保留为 collaborator;false 时移出成员表。 */ + keepAsCollaborator: boolean; + }; + /** + * @description 用户治理条目(P4a;不含邮箱/手机/密码等凭据字段)。sourceStatus 只读 + * 展示(上游状态仅同步任务维护)。id 用于邀请协作者 + * (POST /albums/{albumId}/members 的 userId)。 + */ + AdminUser: { + /** @description 用户 ID。 */ readonly id: string; /** @description 登录账号。 */ readonly account: string; /** @description 展示姓名。 */ readonly displayName: string; + /** + * @description 公司角色(staff/admin;相册角色另表)。 + * @enum {string} + */ + readonly role: "staff" | "admin"; + /** + * @description 来源状态:local=本地创建;active/disabled=同步源状态(P4a 同步)。 + * @enum {string} + */ + readonly sourceStatus: "local" | "active" | "disabled"; + /** @description 本地锁定(停用/deactivate 置 true;与上游状态独立)。 */ + readonly localLocked: boolean; + /** + * @description 凭据状态:active=可登录;pending=同步创建待激活(不可登录,待管理员激活并出初始凭据)。 + * @enum {string} + */ + readonly credentialStatus: "active" | "pending"; + /** @description 界面语言偏好;未设置为 null。 */ + readonly locale?: string | null; + /** + * Format: date-time + * @description 创建时间。 + */ + readonly createdAt: string; }; - /** @description 账号前缀查询结果;无游标(前缀搜索 + 小上限即取即用)。 */ - AdminUserLookupList: { - /** @description 匹配的激活用户(按账号升序)。 */ - items: components["schemas"]["AdminUserLookupItem"][]; + AdminUserList: components["schemas"]["CursorPageMeta"] & { + /** @description 用户条目(前缀模式按 account 升序且仅激活资格用户;管理列表模式按 createdAt 与 ID 稳定倒序)。 */ + items: components["schemas"]["AdminUser"][]; + }; + /** + * @description 管理列表治理状态过滤(单值归类):pending=待激活;disabled=上游停用; + * locked=本地锁定;active=其余可正常登录。同时命中多状态时按 + * pending > disabled > locked > active 优先级归类后匹配。 + * @enum {string} + */ + AdminUserStatusFilter: "active" | "pending" | "disabled" | "locked"; + /** @description 用户治理属性部分更新(仅提交的字段被修改;sourceStatus 不在可更新集)。 */ + AdminUserUpdateRequest: { + /** + * @description 公司角色。 + * @enum {string} + */ + role?: "staff" | "admin"; + /** @description 本地锁定;置 true 撤销该用户全部会话。 */ + localLocked?: boolean; + /** @description 展示姓名。 */ + displayName?: string; + }; + /** @description 管理员重置密码请求;newPassword 缺省由服务端生成随机密码。 */ + AdminPasswordResetRequest: { + /** @description 指定新密码(至少 8 位);缺省服务端生成。 */ + newPassword?: string; + }; + /** @description 重置结果;newPassword 仅此一次返回(请求指定或服务端生成)。 */ + AdminPasswordResetResponse: { + /** @description 重置后的密码(一次性展示,转交用户后不再可见)。 */ + readonly newPassword: string; + }; + /** + * @description 同步引擎状态:idle=空闲/从未运行;其余为最近批次结局。 + * @enum {string} + */ + UserSyncState: "idle" | "running" | "success" | "partial" | "failed"; + /** + * @description 批次状态(partial=部分行失败,其余行照常应用)。 + * @enum {string} + */ + UserSyncRunState: "running" | "success" | "partial" | "failed"; + /** @description 批次计数。 */ + UserSyncCounts: { + /** @description 新增用户数(建为待激活)。 */ + added: number; + /** @description 幂等更新数(改名等)。 */ + updated: number; + /** @description 上游停用数(撤会话)。 */ + disabled: number; + /** @description 失败行数(明细见 /admin/user-sync/errors)。 */ + failed: number; + }; + /** @description 用户同步引擎状态(A29;mock 源先行,G4 后仅替换 provider)。 */ + UserSyncStatus: { + /** + * Format: date-time + * @description 最近批次开始时间;从未运行为 null。 + */ + readonly lastRunAt?: string | null; + /** + * Format: date-time + * @description 最近完全成功批次开始时间;从未成功为 null。 + */ + readonly lastSuccessAt?: string | null; + state: components["schemas"]["UserSyncState"]; + /** + * Format: int64 + * @description 已提交的源水位(版本游标);从未同步为 null。 + */ + readonly sourceVersion?: number | null; + /** @description 最近批次计数;从未运行为 null。 */ + counts?: components["schemas"]["UserSyncCounts"] | null; + }; + /** @description 同步批次条目。 */ + UserSyncRun: { + /** @description 批次 ID。 */ + readonly id: string; + /** + * @description 触发方式(定时/手动补拉)。 + * @enum {string} + */ + readonly trigger: "cron" | "manual"; + state: components["schemas"]["UserSyncRunState"]; + /** + * Format: int64 + * @description 本批次已提交的源水位(同步游标,非分页游标);未提交为 null。 + */ + readonly cursor?: number | null; + counts: components["schemas"]["UserSyncCounts"]; + /** + * Format: date-time + * @description 批次开始时间。 + */ + readonly startedAt: string; + /** + * Format: date-time + * @description 批次结束时间;运行中为 null。 + */ + readonly finishedAt?: string | null; + /** @description 批次级失败摘要;无为 null。 */ + readonly error?: string | null; + }; + UserSyncRunList: components["schemas"]["CursorPageMeta"] & { + /** @description 批次列表(startedAt 与 ID 稳定倒序)。 */ + items: components["schemas"]["UserSyncRun"][]; + }; + /** @description 手动触发受理结果。 */ + UserSyncRunCreated: { + /** @description 批次 ID(已有批次运行中时为该进行中批次)。 */ + readonly runId: string; + }; + /** @description 同步失败行明细(坏行记录,其余行照常应用的证据链)。 */ + UserSyncError: { + /** @description 归属批次 ID。 */ + readonly runId: string; + /** @description 上游用户标识。 */ + readonly externalUserId: string; + /** @description 失败分类码(provider 级约定,如 upstream_timeout/invalid_record)。 */ + readonly code: string; + /** @description 失败详情(诊断用)。 */ + readonly detail: string; + /** + * Format: date-time + * @description 记录时间。 + */ + readonly createdAt: string; + }; + /** @description 失败明细列表(createdAt 倒序;无游标即取即用)。 */ + UserSyncErrorList: { + items: components["schemas"]["UserSyncError"][]; + }; + /** @description 单相册用量聚合(实时 SUM;P5 换物化/OSS 用量,登记偏差)。 */ + UsageAlbum: { + /** @description 相册 ID。 */ + readonly albumId: string; + /** @description 相册标题(defaultLocale 解析)。 */ + readonly title: string; + /** @description 照片总数(全部状态)。 */ + readonly photoCount: number; + /** + * Format: int64 + * @description 原图存储字节数。 + */ + readonly originalBytes: number; + /** + * Format: int64 + * @description 变体(缩略/浏览/水印等)存储字节数。 + */ + readonly variantBytes: number; + /** + * Format: int64 + * @description 合计字节数(originalBytes + variantBytes)。 + */ + readonly totalBytes: number; + }; + /** @description 全库用量合计。 */ + UsageTotals: { + /** @description 照片总数。 */ + readonly photoCount: number; + /** + * Format: int64 + * @description 存储总字节数。 + */ + readonly totalBytes: number; + }; + /** @description 用量阈值;null 表示该维度未设置(告警停用)。 */ + UsageThresholds: { + /** + * Format: int64 + * @description 全库存储阈值。 + */ + readonly storageBytes: number | null; + /** + * Format: int64 + * @description 单相册存储阈值。 + */ + readonly perAlbumBytes: number | null; + }; + /** @description 阈值更新请求;字段缺省=保持不变,显式 null=清除该阈值。 */ + UsageThresholdsUpdate: { + /** + * Format: int64 + * @description 全库存储阈值;null 清除。 + */ + storageBytes?: number | null; + /** + * Format: int64 + * @description 单相册存储阈值;null 清除。 + */ + perAlbumBytes?: number | null; + }; + /** + * @description 阈值告警(A23 后台侧;接收人通知渠道 P5)。超阈写入一次直到回落 + * (同 kind+albumId 活跃告警唯一——重复告警去重);resolvedAt 非空为 + * 已解除。 + */ + UsageAlert: { + /** @description 告警 ID。 */ + readonly id: string; + /** + * @description 告警维度。 + * @enum {string} + */ + readonly kind: "storage" | "perAlbum"; + /** @description 相册 ID;kind=perAlbum 必有,storage 为 null。 */ + readonly albumId?: string | null; + /** @description 告警描述。 */ + readonly message: string; + /** + * Format: date-time + * @description 产生时间。 + */ + readonly createdAt: string; + /** + * Format: date-time + * @description 解除时间(阈值上调/用量回落时标记);活跃告警为 null。 + */ + readonly resolvedAt?: string | null; + }; + /** @description 用量汇总(A23 后台侧)。 */ + UsageSummary: { + /** @description 相册用量(按 totalBytes 降序)。 */ + albums: components["schemas"]["UsageAlbum"][]; + totals: components["schemas"]["UsageTotals"]; + thresholds: components["schemas"]["UsageThresholds"]; + /** @description 告警列表(createdAt 倒序,含已解除)。 */ + alerts: components["schemas"]["UsageAlert"][]; }; /** @description 私有对象的短期签名 URL;不得写入持久分享链接,过期后经原入口刷新。 */ SignedUrl: { @@ -1334,6 +2032,15 @@ export interface components { PublicPhotoList: components["schemas"]["CursorPageMeta"] & { items: components["schemas"]["PublicPhoto"][]; }; + /** + * @description 下载版本(P4a A18):browse=当前生效浏览图(水印版); + * original=原图(需 originalDownloadEnabled 更高级开关)。 + * @enum {string} + */ + DownloadVariant: "browse" | "original"; + PublicPhotoDownload: components["schemas"]["SignedUrl"] & { + variant: components["schemas"]["DownloadVariant"]; + }; /** * @description 点赞批次上报请求。sessionId 为客户端生成的会话随机标识(会话期间复用); * batchId 为批次幂等键,同一批的失败重试必须复用,跨批次不得复用。 @@ -1943,6 +2650,105 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + getWatermarkJobs: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册 ID。 */ + albumId: components["parameters"]["AlbumId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 当前配置与任务列表。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WatermarkJobsStatus"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅创建者或管理员可查看(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + createWatermarkJob: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册 ID。 */ + albumId: components["parameters"]["AlbumId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WatermarkConfig"]; + }; + }; + responses: { + /** @description 配置已保存、重生成任务已受理(异步执行,进度经 GET 查询)。 */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WatermarkJobCreated"]; + }; + }; + /** @description 参数校验失败(VALIDATION_FAILED;opacity/scalePercent/fontSize 越界等)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅创建者或管理员可管理水印(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; initAlbumAssetUpload: { parameters: { query?: never; @@ -2619,6 +3425,62 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + hidePhotosBatch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册 ID。 */ + albumId: components["parameters"]["AlbumId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PhotoHideBatchRequest"]; + }; + }; + responses: { + /** @description 批量处理结果(逐项;单项失败不影响其余项)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchHideResult"]; + }; + }; + /** @description 参数校验失败(VALIDATION_FAILED;photoIds 空/超 200/含空串)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅创建者或管理员可管理照片(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; deletePhoto: { parameters: { query?: never; @@ -2716,30 +3578,35 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; - lookupUsersByAccount: { + transferAlbum: { parameters: { - query: { - /** @description 账号前缀(≥2 字符以防过宽匹配)。 */ - accountPrefix: string; - /** @description 返回条数上限。 */ - limit?: number; - }; + query?: never; header?: never; - path?: never; + path: { + /** @description 相册 ID。 */ + albumId: components["parameters"]["AlbumId"]; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AlbumTransferRequest"]; + }; + }; responses: { - /** @description 匹配的激活用户列表(无匹配为空数组)。 */ + /** @description 交接后的相册详情(ownerId 已变更)。 */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminUserLookupList"]; + "application/json": components["schemas"]["Album"]; }; }; - /** @description 查询参数校验失败(VALIDATION_FAILED;accountPrefix 缺失或长度非法)。 */ + /** + * @description 参数校验失败(VALIDATION_FAILED;targetUserId 非法,或目标账号 + * 不可被指定为 owner——不存在/停用/待激活/锁定)。 + */ 400: { headers: { [name: string]: unknown; @@ -2749,6 +3616,509 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可交接相册负责人(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册不存在,或当前用户不可见(ALBUM_NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + listAdminUsers: { + parameters: { + query?: { + /** @description 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。 */ + accountPrefix?: string; + /** + * @description 管理列表过滤:账号或展示名前缀(大小写敏感二进制前缀匹配,同 + * accountPrefix 规则;不做全文搜索)。 + */ + query?: string; + /** @description 管理列表过滤:治理状态单值归类(归类优先级见 AdminUserStatusFilter)。 */ + status?: components["schemas"]["AdminUserStatusFilter"]; + /** @description 分页游标(上一页响应的 nextCursor)。 */ + cursor?: components["parameters"]["CursorQuery"]; + /** @description 每页条数。 */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 用户列表页(前缀模式无匹配为空数组)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserList"]; + }; + }; + /** + * @description 查询参数校验失败(VALIDATION_FAILED;accountPrefix/query 长度非法、 + * status 取值非法、cursor/limit 非法)。 + */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 管理列表模式仅 admin 可用(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + updateAdminUser: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserUpdateRequest"]; + }; + }; + responses: { + /** @description 更新后的用户条目。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUser"]; + }; + }; + /** @description 参数校验失败(VALIDATION_FAILED;role 取值非法、displayName 长度非法、空请求体)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可管理用户(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 目标用户不存在(NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + resetAdminUserPassword: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminPasswordResetRequest"]; + }; + }; + responses: { + /** @description 重置成功(newPassword 为请求指定或服务端生成)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminPasswordResetResponse"]; + }; + }; + /** @description 参数校验失败(VALIDATION_FAILED;newPassword 长度非法)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可重置密码(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 目标用户不存在(NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + activateAdminUser: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 激活后的用户条目(credentialStatus=active)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUser"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可激活用户(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 目标用户不存在(NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + deactivateAdminUser: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 用户 ID。 */ + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 停用后的用户条目(localLocked=true)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUser"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可停用用户(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 目标用户不存在(NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + getUserSyncStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 同步状态。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSyncStatus"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可查看同步状态(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + listUserSyncRuns: { + parameters: { + query?: { + /** @description 分页游标(上一页响应的 nextCursor)。 */ + cursor?: components["parameters"]["CursorQuery"]; + /** @description 每页条数。 */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 运行历史页。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSyncRunList"]; + }; + }; + /** @description 查询参数校验失败(VALIDATION_FAILED;cursor/limit 非法)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可查看运行历史(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + triggerUserSyncRun: { + parameters: { + query?: { + /** @description 分页游标(上一页响应的 nextCursor)。 */ + cursor?: components["parameters"]["CursorQuery"]; + /** @description 每页条数。 */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 已受理(或复用进行中批次)。 */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSyncRunCreated"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可手动触发(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + listUserSyncErrors: { + parameters: { + query: { + /** @description 批次 ID。 */ + runId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 失败明细(createdAt 倒序;无失败为空数组)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSyncErrorList"]; + }; + }; + /** @description 查询参数校验失败(VALIDATION_FAILED;runId 缺失)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可查看失败明细(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + getUsageSummary: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 用量汇总。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UsageSummary"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可查看用量(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + updateUsageThresholds: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UsageThresholdsUpdate"]; + }; + }; + responses: { + /** @description 更新后的阈值。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UsageThresholds"]; + }; + }; + /** @description 参数校验失败(VALIDATION_FAILED;负值或非整数)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description 仅 admin 可更新阈值(AUTH_FORBIDDEN)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 500: components["responses"]["InternalError"]; }; }; @@ -3011,6 +4381,65 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + downloadPublicPhoto: { + parameters: { + query: { + /** @description 下载版本。 */ + variant: components["schemas"]["DownloadVariant"]; + }; + header?: never; + path: { + /** @description 相册公开令牌(不可枚举,持有即可访问)。 */ + token: components["parameters"]["AlbumPublicToken"]; + /** @description 照片 ID。 */ + photoId: components["parameters"]["PhotoId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 下载凭证(短期签名 URL)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicPhotoDownload"]; + }; + }; + /** @description 查询参数校验失败(VALIDATION_FAILED;variant 缺失或取值非法)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册下载开关关闭(DOWNLOAD_DISABLED;对应 variant 所需级别的开关未开)。 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** + * @description 相册不存在或未公开(ALBUM_NOT_PUBLIC),或照片不存在、未发布、 + * 不属于该相册(PHOTO_NOT_PUBLIC)。 + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; syncContentBatches: { parameters: { query?: never; diff --git a/services/api/src/admin/users-lookup.controller.ts b/services/api/src/admin/users-lookup.controller.ts index 6e91d6d..a47a93a 100644 --- a/services/api/src/admin/users-lookup.controller.ts +++ b/services/api/src/admin/users-lookup.controller.ts @@ -1,10 +1,14 @@ // services/api/src/admin/users-lookup.controller.ts —— /admin/users 端点 -// (P1b Task 4)。方法名与契约 operationId 一一对应(lookupUsersByAccount)。 -// 控制器级 SessionGuard:访问策略=任何已认证工作人员(契约描述的 P1b 决策, -// 见服务文件头),认证通过即可,无角色判定。控制器只做查询参数规整 -// (accountPrefix 必填 2-64 字符;limit 缺省 10、1..20——本端点独立 bounds, -// 同公开照片列表在 public.controller 本地维护 parsePublicPhotoLimit 的先例, -// 勿混用 shared/pagination 的 1..100);语义在服务层。 +// (P1b Task 4 建立;P4a Task 1 契约统一为 listAdminUsers 双模式)。 +// 方法名与契约 operationId 一一对应(listAdminUsers)。 +// 控制器级 SessionGuard:访问策略=任何已认证工作人员(P1b 决策,见服务文件 +// 头——管理列表模式的仅 admin 判定由 Task 6 补齐)。控制器只做查询参数规整, +// 语义在服务层。 +// 过渡期边界(Task 6 收口):契约 P4a 起 accountPrefix 可选(缺省进入管理 +// 列表模式:query/status 过滤 + 游标分页 + 仅 admin);当前实现仅前缀模式, +// 缺 accountPrefix 仍 400——管理列表模式随 Task 6 账号管理落地。 +// limit 边界已对齐统一契约:缺省 20、上下界 1..100(P1b 时为 10/1..20; +// 邀请搜索调用方显式传 limit=10,行为不变)。 import { Controller, Get, @@ -16,18 +20,16 @@ import { ErrorCodes } from '@photo/contracts'; import { ApiException } from '../api-exception'; import { SessionGuard } from '../auth/session.guard'; import { validationFailed } from '../albums/support'; -import { - UsersLookupService, - type AdminUserLookupListResponse, -} from './users-lookup.service'; +import { UsersLookupService, type AdminUserListResponse } from './users-lookup.service'; -/** 契约 lookupUsersByAccount.limit:缺省 10,上下界 1..20。 */ -export const DEFAULT_LOOKUP_LIMIT = 10; -export const MAX_LOOKUP_LIMIT = 20; +/** 契约 listAdminUsers.limit:缺省 20,上下界 1..100(P4a 统一分页 bounds)。 */ +export const DEFAULT_LIST_LIMIT = 20; +export const MAX_LIST_LIMIT = 100; -/** accountPrefix 查询参数:必填(缺失/空串 400),长度 2..64(契约 bounds)。 */ +/** accountPrefix 查询参数(前缀模式):2..64(契约 bounds)。 */ export function parseAccountPrefix(raw: string | undefined): string { if (raw === undefined || raw === '') { + // Task 6 起此分支消失(缺省进入管理列表模式);当前实现仅前缀模式。 throw validationFailed('accountPrefix 必填(2-64 字符)'); } if (raw.length < 2 || raw.length > 64) { @@ -36,23 +38,23 @@ export function parseAccountPrefix(raw: string | undefined): string { return raw; } -/** limit 查询参数:缺省 10,须为 1..20 的十进制整数(契约 bounds)。 */ -export function parseLookupLimit(raw: string | undefined): number { +/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。 */ +export function parseListLimit(raw: string | undefined): number { if (raw === undefined || raw === '') { - return DEFAULT_LOOKUP_LIMIT; + return DEFAULT_LIST_LIMIT; } if (!/^\d+$/.test(raw)) { throw new ApiException( ErrorCodes.VALIDATION_FAILED, - 'limit 必须是 1-20 的整数', + 'limit 必须是 1-100 的整数', HttpStatus.BAD_REQUEST, ); } const limit = Number.parseInt(raw, 10); - if (limit < 1 || limit > MAX_LOOKUP_LIMIT) { + if (limit < 1 || limit > MAX_LIST_LIMIT) { throw new ApiException( ErrorCodes.VALIDATION_FAILED, - 'limit 必须是 1-20 的整数', + 'limit 必须是 1-100 的整数', HttpStatus.BAD_REQUEST, ); } @@ -64,15 +66,15 @@ export function parseLookupLimit(raw: string | undefined): number { export class UsersLookupController { constructor(private readonly usersLookupService: UsersLookupService) {} - /** 账号前缀查询:激活用户脱敏列表(account 升序,无游标)。 */ + /** 用户查询/管理列表(统一响应 AdminUserList;当前仅前缀模式,见文件头)。 */ @Get() - lookupUsersByAccount( + listAdminUsers( @Query('accountPrefix') accountPrefix: string | undefined, @Query('limit') limit: string | undefined, - ): Promise { + ): Promise { return this.usersLookupService.lookup( parseAccountPrefix(accountPrefix), - parseLookupLimit(limit), + parseListLimit(limit), ); } } diff --git a/services/api/src/admin/users-lookup.service.ts b/services/api/src/admin/users-lookup.service.ts index bb8791a..72fdd10 100644 --- a/services/api/src/admin/users-lookup.service.ts +++ b/services/api/src/admin/users-lookup.service.ts @@ -1,10 +1,9 @@ -// services/api/src/admin/users-lookup.service.ts —— 账号前缀查询(P1b Task 4, -// 契约 lookupUsersByAccount,tag [admin])。邀请协作者的前置查询:按 account -// 前缀搜索激活用户,返回 {id,account,displayName} 脱敏列表(不含邮箱/手机/ -// 状态/凭据字段)。 -// 访问策略(P1b 决策,契约描述):任何已认证工作人员可用——邀请发起方不限于 -// 创建者/管理员,泄露面仅为激活用户的最小展示字段、接收方为已认证内部人员; -// 未认证一律 401(控制器级 SessionGuard 承担,服务层不再重复角色判定)。 +// services/api/src/admin/users-lookup.service.ts —— 用户查询/管理列表(P1b +// Task 4 前缀查询;P4a Task 1 契约统一为 listAdminUsers 双模式端点)。 +// P4a 契约决策:/admin/users 响应统一 AdminUserList(AdminUser 字段超集, +// 管理端 UI 按场景裁剪展示);本文件为**过渡实现**——仅落地前缀模式 +// (P1b 语义原样:邀请协作者前置查询),管理列表模式(query/status 过滤 + +// 游标分页 + 仅 admin 访问)由 P4a Task 6 账号管理实现补齐。 // 实现要点: // · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注 // 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。 @@ -14,20 +13,28 @@ // · 资格过滤与 SessionGuard 同一谓词(credentialStatus=active 且 // sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被 // 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。 -// · 无游标:前缀搜索 + 小上限(1..20 缺省 10)即取即用(契约决策),按 -// account 升序返回确定性输出。 +// · 前缀模式即取即用(limit 上限内一次取回):nextCursor=null、 +// hasMore=false;游标翻页随 Task 6 管理列表模式落地。 import { Injectable } from '@nestjs/common'; import type { components } from '@photo/contracts'; import { PrismaService } from '../prisma/prisma.service'; -export type AdminUserLookupListResponse = - components['schemas']['AdminUserLookupList']; +export type AdminUserListResponse = components['schemas']['AdminUserList']; -/** $queryRaw 行形状(SELECT 列即脱敏字段全集——不选取任何凭据/状态列)。 */ -interface LookupRow { +/** AdminUser 契约条目类型(枚举字段的窄化转换目标)。 */ +type AdminUserItem = AdminUserListResponse['items'][number]; + +/** $queryRaw 行形状(SELECT 列即 AdminUser 契约字段——不含任何凭据列)。 */ +interface AdminUserRow { id: string; account: string; displayName: string; + role: string; + sourceStatus: string; + localLocked: number; // MySQL BOOLEAN 经 $queryRaw 返回 0/1 + credentialStatus: string; + locale: string | null; + createdAt: Date; } /** @@ -42,15 +49,19 @@ export function escapeLikePrefix(prefix: string): string { export class UsersLookupService { constructor(private readonly prisma: PrismaService) {} - /** 前缀查询(契约 lookupUsersByAccount):激活用户、account 升序、limit 截断。 */ + /** + * 前缀查询(契约 listAdminUsers 前缀模式):激活资格用户、account 升序、 + * limit 截断;响应为统一 AdminUserList 形状(P4a)。 + */ async lookup( accountPrefix: string, limit: number, - ): Promise { + ): Promise { const pattern = `${escapeLikePrefix(accountPrefix)}%`; // 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。 - const rows = await this.prisma.$queryRaw` - SELECT id, account, displayName + const rows = await this.prisma.$queryRaw` + SELECT id, account, displayName, role, sourceStatus, localLocked, + credentialStatus, locale, createdAt FROM users WHERE account LIKE ${pattern} ESCAPE '!' AND credentialStatus = 'active' @@ -64,7 +75,16 @@ export class UsersLookupService { id: row.id, account: row.account, displayName: row.displayName, + // 库列为自由字符串(schema 约定取值集),窄化到契约枚举联合。 + role: row.role as AdminUserItem['role'], + sourceStatus: row.sourceStatus as AdminUserItem['sourceStatus'], + localLocked: row.localLocked !== 0, + credentialStatus: row.credentialStatus as AdminUserItem['credentialStatus'], + locale: row.locale, + createdAt: row.createdAt.toISOString(), })), + nextCursor: null, + hasMore: false, }; } } diff --git a/services/api/test/users-lookup.e2e-spec.ts b/services/api/test/users-lookup.e2e-spec.ts index d2bad2c..cfce23c 100644 --- a/services/api/test/users-lookup.e2e-spec.ts +++ b/services/api/test/users-lookup.e2e-spec.ts @@ -1,16 +1,18 @@ // services/api/test/users-lookup.e2e-spec.ts —— P1b Task 4 账号前缀查询 e2e -// (先写后实现)。真实 app + MySQL。覆盖: -// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目仅 -// id/account/displayName(脱敏,无邮箱/手机/状态/凭据字段);响应仅 items -// (无游标——前缀搜索 + 小上限即取即用); +// (先写后实现);P4a Task 1 契约统一后同步修订。真实 app + MySQL。覆盖: +// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目为统一 +// AdminUser 字段集(id/account/displayName + 治理字段 role/sourceStatus/ +// localLocked/credentialStatus/locale/createdAt;无邮箱/手机/凭据字段); +// 响应为 AdminUserList(items + nextCursor/hasMore 游标元信息); // ② 账号资格过滤:credentialStatus=pending / sourceStatus=disabled / // localLocked=true 均排除(只留「激活」用户); // ③ 大小写敏感(库级 utf8mb4_0900_bin 二进制校对,契约已注明):'AL' → 空、 // 'carol' → 空、'Carol' → 命中; // ④ LIKE 通配符转义(ESCAPE 子句):前缀中的 _ / % 按字面匹配,不作单字符/ // 任意串通配(esc_ 仅命中字面下划线账号;al% 空); -// ⑤ 参数边界:accountPrefix 缺失/1 字符/65 字符 → 400;2 与 64 字符合法; -// limit 缺省 10、1..20 界内、0/21/非整数 → 400; +// ⑤ 参数边界:accountPrefix 缺失(过渡期实现仍 400,Task 6 起进入管理列表 +// 模式)/1 字符/65 字符 → 400;2 与 64 字符合法;limit 缺省 20(P1b 时 +// 为 10,P4a 统一分页 bounds)、1..100 界内、0/101/非整数 → 400; // ⑥ 访问策略(P1b 决策,契约描述):未认证 401;任何已认证工作人员(含非 // admin 的 staff)200;admin 200。 import { randomBytes } from 'node:crypto'; @@ -121,27 +123,37 @@ describe('账号查询 GET /admin/users (e2e)', () => { return req.query(query ?? {}); } - it("前缀 'al' → albert+alice 升序;禁用/待激活/锁定排除;条目与响应形状脱敏", async () => { + it("前缀 'al' → albert+alice 升序;禁用/待激活/锁定排除;条目与响应形状(AdminUserList)", async () => { const res = await lookup(staffToken, { accountPrefix: 'al' }); expect(res.status).toBe(200); - // 响应仅 items(契约 AdminUserLookupList:无游标元信息)。 - expect(Object.keys(res.body).sort()).toEqual(['items']); - const items = res.body.items as { - id: string; - account: string; - displayName: string; - }[]; + // 响应为统一 AdminUserList(P4a):items + 游标元信息(前缀模式即取即用 + // → nextCursor=null / hasMore=false)。 + expect(Object.keys(res.body).sort()).toEqual(['hasMore', 'items', 'nextCursor']); + expect(res.body.nextCursor).toBeNull(); + expect(res.body.hasMore).toBe(false); + const items = res.body.items as Record[]; // account 升序:albert < alice;恰好两条(排除组全不出现在结果)。 expect(items.map((item) => item.account)).toEqual(['albert', 'alice']); expect(items.map((item) => item.id)).toEqual([albertId, aliceId]); expect(items.map((item) => item.displayName)).toEqual(['艾伯', '爱丽']); - // 条目形状:仅三个脱敏字段(无密码哈希/状态/角色等)。 + // 条目形状:AdminUser 字段集(治理字段随 P4a 统一暴露;仍无邮箱/手机/ + // 密码哈希等凭据字段)。 for (const item of items) { expect(Object.keys(item).sort()).toEqual([ 'account', + 'createdAt', + 'credentialStatus', 'displayName', 'id', + 'locale', + 'localLocked', + 'role', + 'sourceStatus', ]); + expect(item.role).toBe('staff'); + expect(item.sourceStatus).toBe('local'); + expect(item.localLocked).toBe(false); + expect(item.credentialStatus).toBe('active'); } }); @@ -156,8 +168,13 @@ describe('账号查询 GET /admin/users (e2e)', () => { const exact = await lookup(staffToken, { accountPrefix: 'Carol' }); expect(exact.status).toBe(200); + // 条目为 AdminUser 全字段:以关键字段子集断言(字段全集见用例 ①)。 expect(exact.body.items).toEqual([ - { id: carolId, account: 'Carol', displayName: '卡萝' }, + expect.objectContaining({ + id: carolId, + account: 'Carol', + displayName: '卡萝', + }), ]); }); @@ -166,7 +183,11 @@ describe('账号查询 GET /admin/users (e2e)', () => { const underscore = await lookup(staffToken, { accountPrefix: 'esc_' }); expect(underscore.status).toBe(200); expect(underscore.body.items).toEqual([ - { id: escUnderscoreId, account: 'esc_ape', displayName: '下划线' }, + expect.objectContaining({ + id: escUnderscoreId, + account: 'esc_ape', + displayName: '下划线', + }), ]); // 未转义时 al% 作任意串通配会命中 albert/alice 等——转义后按字面前缀匹配。 @@ -175,7 +196,9 @@ describe('账号查询 GET /admin/users (e2e)', () => { expect(percent.body.items).toEqual([]); }); - it('accountPrefix 边界:缺失/空串/1 字符/65 字符 → 400;64 字符 → 200 空', async () => { + it('accountPrefix 边界:过渡期缺失/空串仍 400;1 字符/65 字符 → 400;64 字符 → 200 空', async () => { + // Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin);当前实现仅 + // 前缀模式,缺失/空串仍 400(控制器文件头登记的过渡期边界)。 const missing = await lookup(staffToken, {}); expect(missing.status).toBe(400); expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED); @@ -197,13 +220,13 @@ describe('账号查询 GET /admin/users (e2e)', () => { expect(max.body.items).toEqual([]); }); - it('limit:缺省 10 且升序截断;5 合法;0/21/非整数 → 400;20 合法', async () => { - // zz-look-* 共 12 个激活账号:缺省 limit=10 → 恰前 10 个(01..10 升序)。 + it('limit:缺省 20(P4a 统一 bounds);5 合法;0/101/非整数 → 400;100 合法', async () => { + // zz-look-* 共 12 个激活账号:缺省 limit=20 > 存量 12 → 全量升序返回。 const def = await lookup(staffToken, { accountPrefix: 'zz-look-' }); expect(def.status).toBe(200); expect( (def.body.items as { account: string }[]).map((item) => item.account), - ).toEqual(Array.from({ length: 10 }, (_, i) => `zz-look-${String(i + 1).padStart(2, '0')}`)); + ).toEqual(Array.from({ length: 12 }, (_, i) => `zz-look-${String(i + 1).padStart(2, '0')}`)); const five = await lookup(staffToken, { accountPrefix: 'zz-look-', @@ -214,12 +237,12 @@ describe('账号查询 GET /admin/users (e2e)', () => { const top = await lookup(staffToken, { accountPrefix: 'zz-look-', - limit: 20, + limit: 100, }); expect(top.status).toBe(200); - expect(top.body.items).toHaveLength(12); // 上限 20 > 存量 12,全量返回 + expect(top.body.items).toHaveLength(12); // 上限 100 > 存量 12,全量返回 - for (const limit of [0, 21, 'abc']) { + for (const limit of [0, 101, 'abc']) { const res = await lookup(staffToken, { accountPrefix: 'zz-look-', limit,