diff --git a/.claude/agents/licensing-billing.md b/.claude/agents/licensing-billing.md index e79df7834..bb4927906 100644 --- a/.claude/agents/licensing-billing.md +++ b/.claude/agents/licensing-billing.md @@ -47,6 +47,10 @@ description: 授权与计费领域。任务涉及解锁门(试用码/账户 Ke - `backend/src/main/java/com/checkba/config/LocalModeAccessFilter.java` — 免登模式的每请求准入闸(回环校验 + 反代痕迹拒绝 + 跨站 Origin 硬拦截)。 - `backend/src/main/java/com/checkba/config/LocalModeLoopbackGuard.java` — 启动强不变式:local-mode 必须绑回环地址,否则拒绝启动。 - `AuthController.getUserIdFromSession()` 在 local-mode 下把任何请求解析为本机用户(90 余处调用方一行未改)。 +- **`LocalIdentityService` 从不给真实账号改名,但展示名会随官网刷新**(spec 2026-09-10 §5): + `commit()` 那处改名只收窄到 `username=admin`(系统默认账号,「管理员」不是用户自己起的名字); + 真实账号的 `displayName` 由 `AccountIdentitySync` 按**官网这个唯一权威源**刷新, + 不是「一个字都不动」而是「不被本机改名,只随权威源刷新」。两者不冲突:一个是本机替用户做主,一个是随权威源同步。 - **「本机用户」是库里恒存的中文哨兵,界面语言只在读出来时替换**(`LocalIdentityService.displayNameOf`, v0.30.0 + dev-board#351)。库里存的值一个字都不动——改写入侧会让同一个人在中英文界面下留下两种身份。 出口全集(新增读出口要跟着补,别再漏):`/api/auth/me`、`/api/local-identity/{status,candidates}`、 @@ -58,11 +62,16 @@ description: 授权与计费领域。任务涉及解锁门(试用码/账户 Ke **账户连接(PR-B)** - `backend/src/main/java/com/checkba/service/account/AccountService.java` — 与官网账户的唯一连接方式是 `awdk_` Key。 - `~/.aiworkdeck/account.json`(0600);`connect/disconnect/status/fetchProfile/fetchEntitlements/fetchLedger/fetchAiUsage/fetchAiKey/currentKeyOrNull`。 + `~/.aiworkdeck/account.json`(0600,字段 `key/username/accountId/displayName/connectedAt/lastSyncAt`); + `connect/disconnect/status/fetchProfile/fetchEntitlements/fetchLedger/fetchAiUsage/fetchAiKey/currentKeyOrNull`, + 以及个人档案那一组 `profileIdentity/updateDisplayName/uploadAvatar/deleteAvatar`(见下方「身份展示」一节)。 - `service/account/AccountTransport.java` + `HttpAccountTransport.java` — 出站 HTTP 缝(单测打桩不依赖网络);固定 HTTP/1.1。 + `sendMultipart(method,url,bearer,Multipart)` 是头像上传那条路,**默认实现直接抛**—— + 给个默认返回值会让「桩根本没接这条路」在测试里表现成一次成功的上传。 - `service/account/AccountEndpoint.java` — 授权服务器地址的协议校验(https,回环 http 例外),`LicenseService` 与 `AccountService` 共用。 - `service/account/AccountException.java` — `Kind`:NETWORK / UNAUTHORIZED / CONFLICT / NOT_CONNECTED / MALFORMED。 -- `backend/src/main/java/com/checkba/controller/AccountController.java` — `/api/account/{status,connect,disconnect,usage}`。 +- `backend/src/main/java/com/checkba/controller/AccountController.java` — `/api/account/{status,connect,disconnect,usage}` + 以及个人档案 `/api/account/profile`(GET/PUT)与 `/api/account/avatar`(POST multipart/DELETE)。 - `service/account/AccountSwitchCleanup.java` — **换账户后作废动作的唯一出口**(`afterConnect` / `afterDisconnect`): 权益缓存 + 平台 AI 密钥缓存 + 余额判定 + 用量基线四样一起清,disconnect 还要 `demotePlatformProvider()`。 连接账户有**两个**入口(设置页 `AccountController.connect`、解锁页 `LicenseController.activate` 粘 `awdk_`), @@ -447,7 +456,11 @@ security.license.trial-code.legacy-grace-until: "2026-09-30" | 端点 | 鉴权 | 桌面调用点 | |---|---|---| | `POST /api/license/verify-key` → `{valid, plan}` | 匿名 | `LicenseService.callVerifyKey` | -| `GET /api/account/me` → `{username, displayName, balanceCents, plan, createdAt}` | Bearer | `connect()` 校验 Key、`fetchProfile()` 取余额 | +| `GET /api/account/me` → `{accountId, username, displayName, balanceCents, plan, createdAt, avatarUpdatedAt, displayNameIsDefault}` | Bearer | `connect()` 校验 Key、`fetchProfile()` 取余额、`profileIdentity()` 取身份视图 | +| `PATCH /api/account/profile` `{displayName?, bio?}` → `{displayName, bio}` | Cookie 或 Bearer | `updateDisplayName()`;400 `invalid_display_name` | +| `POST /api/account/avatar` multipart `file` → `{avatarUpdatedAt}` | Cookie 或 Bearer | `uploadAvatar()`;4xx `too_large`/`invalid_image`/`unsupported_format` | +| `DELETE /api/account/avatar` → `{avatarUpdatedAt:null}` | Cookie 或 Bearer | `deleteAvatar()` | +| `GET /api/avatar/{accountId}?v=` | 匿名 | 头像地址由桌面端按 `accountId` + `avatarUpdatedAt` 拼好下发 | | `GET /api/account/entitlements` → `{entitlements:[{feature, purchasedAt, orderId}]}` | Bearer | `fetchEntitlements()` | | `GET /api/account/ledger?limit=50` → `{entries:[...]}` | Bearer | `fetchLedger()`,桌面只挑 `kind=ai_alloc` 显示 | | `GET /api/account/ai-usage`(*) → `{configured, hasKey, limitUsd, usageUsd, remainingUsd, keyMasked}` | Bearer | `fetchAiUsage()` | @@ -524,7 +537,9 @@ cost 为 null 原样保留 —— 对账未完成时显示「待结算」,绝 选择结果存 `SystemSetting` 而不是 `~/.aiworkdeck/identity.json`:存的是指向同一个库里 user 表的外键, 放进被指向的库才能与数据同生共死(还原旧库时指针跟着回退);license/account 描述的是机器授权,独立于库是刻意的。 测试账号前缀白名单(`qa_bot_` / `claude-e2e` / `e2e_keepalive`)**保守到只排除脚手架自造账号**, - 真实账号一个都不许被误排。`displayName` 改名收窄到 `username=admin`——改真实账号的名字会让用户在选择页里认不出自己。 + 真实账号一个都不许被误排。`displayName` 改名收窄到 `username=admin`——本机替用户改名会让他在选择页里认不出自己。 + 注意口径(2026-09-10 改写):真实账号的展示名**不被 `LocalIdentityService` 改名,但会随官网刷新** + (`AccountIdentitySync`,官网是唯一权威源)。这条不是「一个字都不动」。 5. **免费额度只隐藏、不删数据**。这是本领域最硬的一条红线:剪贴板是**查询侧过滤**(超出的行留在库里, 解锁后原样可见),缓存区是**移入时拒绝**(区内已有文件一个都不动,移出方向永不拦截)。 全 diff 里没有任何 delete/purge 路径被额度逻辑触发,任何「顺手清理超额记录」的改动都是回归。 @@ -1118,6 +1133,44 @@ return 404 兜底,云后端从 127.0.0.1 直连 Next。云侧唯一出口 失败绝不免费放行)。**没有新增 ledger kind**(service_spend + meta.service=transfer)。 细节见 mobile-sync.md「跨设备文件传输」节与官网仓 DEPLOY.md §7.4。 +## 身份展示:展示名与头像以官网为唯一权威源(2026-09-10,dev-board#564-#567) + +设计 `docs/superpowers/specs/2026-09-10-identity-display-source-design.md`。治的是这个病: +手机号注册的官网账户自动生成用户名 `u`+随机串、展示名打码手机号,而桌面端 `account.json` 与案件库 +`awd_` 用户在连接/桥接那一刻抄一份展示名之后**永不刷新**,头像各存各的。 + +**唯一权威源是官网**,本机与案件库都只读、随官网刷新;用户名退成内部标识(**不改名**、任何界面不当名字显示)。 + +桌面侧(local-mode)四个端点,一律转发官网 + 把结果同步回本机 `User` 行: + +| 方法 | 本机路径 | 出站 | 回什么 | +|---|---|---|---| +| GET | `/api/account/profile` | `GET /api/account/me` | `{accountId, displayName, avatarUrl, displayNameIsDefault}`,**不回 username** | +| PUT | `/api/account/profile` | `PATCH /api/account/profile` | 官网回包 `{displayName, bio}` 原样透传 | +| POST | `/api/account/avatar` | `POST /api/account/avatar`(multipart `file`) | `{avatarUpdatedAt, avatarUrl}` | +| DELETE | `/api/account/avatar` | `DELETE /api/account/avatar` | `{avatarUpdatedAt:null, avatarUrl:null}` | + +要点: + +- **本机 PUT / 出站 PATCH** 是刻意偏差,与团队那组同源(uni.request 的 method 枚举里没有 PATCH)。 +- `avatarUrl` 由桌面端按 `accountId` + `avatarUpdatedAt` 拼成 `{base}/api/avatar/{accountId}?v=...`, + **没传过头像(`avatarUpdatedAt` 为 null)就回 null**——硬拼一个必然 404 的地址只会让界面白等一次网络请求。 + `accountId` 连接时落进 `account.json`,老盘没有这一项时补拉一次 `/me` 回填。 +- **同步唯一出口是 `service/account/AccountIdentitySync`**(`refresh` / `refreshQuietly` / + `applyDisplayName` / `applyAvatarUrl`),落点四处:连接账户时、每次 `GET /api/account/status` + (应用启动会拉)、`GET /api/account/profile`、三个写动作之后。 + 两条红线:**未连接 / 官网不可达时不动本机行也不报错**;**非 local-mode 整条短路** + (团队案件库与插件云上账户是机器级状态,按它改某一个租户的 `User` 行是张冠李戴,同 `TeamUsageUploadService` 第二道闸)。 +- `LocalIdentityService` 那条「真实账号 displayName 不动」的纪律**改写为**:不被本机改名, + 但随官网这个权威源刷新(见「关键文件地图」与地雷 4)。 +- 既有 `POST /api/users/avatar`(本机上传落本机表)**保留给自建服务器**,local-mode 下前端不再调它。 +- `GET /api/account/status` 的回包多一个 `accountId`(就是公开头像地址里的那个 id,不是凭据)。 +- 案件库侧(server,profile `case`)四处一并改:`AwdkLoginService.resolveUser` 每次桥接刷新展示名、 + `CollaboratorAdmission` 名录回查时刷新、`ProjectMemberController.getMembers` 的头像改走 + `ProjectMemberService.avatarUrlFor`、`VersionController.userName` 改用展示名(见 version-control.md)。 + `UserService.refreshDisplayNameFromWebsite` 是这三处共用的唯一写入点:**非空且不同才写,username 一个字不动**。 +- 官网侧的 `displayNameIsDefault` 只用于引导「填写你的姓名」,桌面端不据它做任何拦截。 + ## 窄权限内部口第三条:同事名录(2026-09-10,dev-board#550 #551) 官网内部口现在有**三条**,形状完全一致(同机 127.0.0.1 直连 Next、头 `X-Internal-Secret`、 @@ -1141,6 +1194,7 @@ return 404 兜底,云后端从 127.0.0.1 直连 Next。云侧唯一出口 `service/LicenseServiceTest`、`service/TrialCodeVerifierTest`、`service/LocalIdentityServiceTest`、 `service/LocalIdentityRealShapeIntegrationTest`(真机形态种子库跑选择链路)、 `service/account/AccountServiceTest`、`service/account/AccountEndpointTest`、 + `service/account/AccountIdentitySyncTest`、`controller/AccountControllerProfileTest`(身份展示那一组), `service/entitlement/EntitlementServiceTest`、`service/entitlement/FeatureCatalogTest`、 `service/quota/StageQuotaServiceTest`、`service/storage/StorageLocationServiceTest`、 `service/ClipboardQuotaTest`、`service/ai/PlatformUsageAccountantTest`、`service/ai/ChatModelFactoryTest`、 diff --git a/.claude/agents/version-control.md b/.claude/agents/version-control.md index 6cf04fb2b..564ecb332 100644 --- a/.claude/agents/version-control.md +++ b/.claude/agents/version-control.md @@ -202,6 +202,27 @@ description: 项目级版本记录领域。任务涉及版本记录/工作段( 护栏测试:`ProjectRepoHistoryTest.localUserAuthorIsLocalizedOnReadWithoutRewritingHistory` (同一个仓库中/英/中读三遍,值必须来回切得回去,证明提交对象没被改写)。 +**新写入的署名是展示名,不是用户名**(spec 2026-09-10 §4,dev-board#564-#567): +`VersionController.userName(userId)` 由「`username`,取不到才回『用户』」改成 +「`displayName` → 空则 `username` → 都空才回『用户』」。以前手机号注册的同事在时间线与文档修订里 +就是一串 `awd_upoxwcdtg`——用户名现在是内部标识,任何界面都不再当名字显示。 +**已写入的历史 `authorName` 不回填**(Git 提交对象不重写,同上一条纪律)。 +护栏测试 `version/VersionAuthorNameTest`(两条:展示名优先、空展示名回落用户名)。 + +**参与人列表的头像与展示名也随官网刷新**(同一份 spec §4): +`ProjectMemberController.getMembers`(成员行与 owner 行)的 `avatarUrl` 改走 +**public** 的 `ProjectMemberService.avatarUrlFor(user)`——本机上传过就用本机那份(自建服务器的人工账号), +否则按 `account_binding` 拼 `{ai.account.base-url}/api/avatar/{accountId}`,两样都没有才是 null, +与「加同事」确认卡同一个口径。桥接进来的同事本机表里根本没有头像,旧的 `user.getAvatarUrl()` +在参与人列表里就是一片空白首字母。`username` 字段**保留一版**给老客户端,前端不再读它。 +展示名的刷新点有两处:`AwdkLoginService.resolveUser`(每次桥接)与 `CollaboratorAdmission` +(每次名录回查,用 `DirectoryReply.account().displayName`),共用唯一写入点 +`UserService.refreshDisplayNameFromWebsite`(**非空且不同才写,username 一个字不动**)。 +`CloudSyncService.proxyMembers` 透传不改。 +护栏测试:`service/ProjectMemberAvatarSourceTest`(真 service + 真 controller 跑 getMembers 四条)、 +`service/account/AwdkLoginServiceTest` 的展示名三条、`service/collab/CollaboratorAdmissionTest` 的刷新两条。 +桌面侧的编辑入口与同步落点见 licensing-billing.md「身份展示」一节。 + ## 已知地雷 1. **历史永不重写**——硬不变量,理由与 Git 自己一致,为将来推云端仓库(v2)打基础。唯一例外是删除工作段/稿的分支引用(`deleteBranch(force=true)`)——**删的是引用不是历史**:从未合并的那种(`discardSession`/`abandonDraft`)连内容一起丢是本来的语义,已经合并进主线的那种(`endSession` 两条路径、`adoptDraft`)每一笔提交都还从 master 可达,删掉只是不再留一条对律师本就不可见的分支名。护栏测试:`RepoMaintenanceTest.gcPreservesEveryReachableVersion`,GC 前后逐条比对每个 `VersionEntry.sha()`。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09bb66159..35c5556df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,9 @@ jobs: - name: Member invite lookup + case-library share rules working-directory: frontend run: npm run test:member-invite + - name: Display-name-only identity contract (dev-board#564–#567) + working-directory: frontend + run: npm run test:identity - name: Desensitize pane custom-words entry (dev-board#531) working-directory: frontend run: npm run test:desensitize diff --git a/backend/src/main/java/com/checkba/controller/AccountController.java b/backend/src/main/java/com/checkba/controller/AccountController.java index 0e48e24e6..44281d793 100644 --- a/backend/src/main/java/com/checkba/controller/AccountController.java +++ b/backend/src/main/java/com/checkba/controller/AccountController.java @@ -6,6 +6,7 @@ import com.checkba.model.entity.TokenUsage; import com.checkba.repository.TokenUsageRepository; import com.checkba.service.account.AccountException; +import com.checkba.service.account.AccountIdentitySync; import com.checkba.service.account.AccountService; import com.checkba.service.account.AccountSwitchCleanup; import com.checkba.service.account.MachineAccountGuard; @@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; import java.util.Comparator; @@ -43,6 +45,8 @@ *
  • POST /api/account/connect {"key":"awdk_..."} 校验并落盘
  • *
  • POST /api/account/disconnect 断开并清空权益缓存、平台 AI 密钥缓存
  • *
  • GET /api/account/usage 平台结算(官网)+ 本地统计(TokenUsage)两套口径
  • + *
  • GET/PUT /api/account/profile 个人档案(展示名/头像地址),PUT 出站到官网是 PATCH
  • + *
  • POST/DELETE /api/account/avatar 头像上传与删除(multipart 转发官网)
  • *
  • GET /api/account/balance 轻端点,供顶栏高频轮询(内部带 TTL 缓存)
  • *
  • GET /api/account/membership 会员等级/积分全量转发
  • *
  • POST /api/account/recharge {"amountCents":N} 发起充值,转发官网 payment/create
  • @@ -76,6 +80,7 @@ public class AccountController { private final com.checkba.service.team.TeamUsageSettings teamUsageSettings; private final com.checkba.service.team.TeamUsageUploadService teamUsageUploadService; private final com.checkba.service.team.TeamSettingsCache teamSettingsCache; + private final AccountIdentitySync identitySync; public AccountController(AccountService accountService, PlatformAiChannel platformAiChannel, @@ -85,7 +90,8 @@ public AccountController(AccountService accountService, EntitlementService entitlementService, com.checkba.service.team.TeamUsageSettings teamUsageSettings, com.checkba.service.team.TeamUsageUploadService teamUsageUploadService, - com.checkba.service.team.TeamSettingsCache teamSettingsCache) { + com.checkba.service.team.TeamSettingsCache teamSettingsCache, + AccountIdentitySync identitySync) { this.accountService = accountService; this.platformAiChannel = platformAiChannel; this.accountSwitchCleanup = accountSwitchCleanup; @@ -95,6 +101,7 @@ public AccountController(AccountService accountService, this.teamUsageSettings = teamUsageSettings; this.teamUsageUploadService = teamUsageUploadService; this.teamSettingsCache = teamSettingsCache; + this.identitySync = identitySync; } /** @@ -110,6 +117,9 @@ private void requireUser(String sessionId) { public Map status( @RequestHeader(value = "X-Session-Id", required = false) String sessionId) { requireUser(sessionId); + // 应用启动会拉这条:顺手把官网的展示名/头像刷到本机 User 行与 account.json + // (spec 2026-09-10 §5)。未连接账户或官网不可达时静默跳过,绝不因此让状态查询失败。 + identitySync.refreshQuietly(); Map data = new LinkedHashMap<>(accountService.status()); // 平台 AI 通道是否可选(未连接账户时前端不展示该供应商) data.put("platformAiAvailable", platformAiChannel.isAvailable()); @@ -126,6 +136,9 @@ public Map connect( // 换账户后旧账户的权益、平台密钥、余额判定与用量基线必须立刻作废,不能等下一次刷新。 // 解锁页那条连接路径共用这一处(AccountSwitchCleanup),别在这里再抄一遍动作 accountSwitchCleanup.afterConnect(); + // 新账户的展示名/头像立刻落到本机行:不然刚连上账户的那一刻,参与人列表里 + // 还是「本机用户」,得等下一次启动才对(spec 2026-09-10 §5) + identitySync.refreshQuietly(); return ok(status); } @@ -321,6 +334,88 @@ public Map purchaseSku( return ok(data); } + // ==================== 个人档案与头像(spec 2026-09-10 §5) ==================== + // + // 官网的展示名与头像是唯一权威源,这四个端点只是它的一个编辑入口:一律转发官网, + // 写成功之后把结果同步回本机 User 行({@link AccountIdentitySync})。 + // 既有的 POST /api/users/avatar(本机上传)保留给自建服务器,local-mode 下前端不再调它。 + + /** 头像大小上限,与官网 2MB 同——本机先拦一道,省得把一个大文件白传一趟。 */ + private static final long MAX_AVATAR_BYTES = 2L * 1024 * 1024; + + /** + * 身份视图 {@code {accountId, displayName, avatarUrl, displayNameIsDefault}}。 + * 不回 username:用户名退成内部标识,任何界面都不再当名字显示。 + * 顺手把这份身份同步到本机 User 行。 + */ + @GetMapping("/profile") + public Map profile( + @RequestHeader(value = "X-Session-Id", required = false) String sessionId) { + requireUser(sessionId); + return ok(identitySync.refresh()); + } + + /** + * 改昵称:body {@code {displayName}}。本机是 PUT,出站到官网是 PATCH—— + * 前端只有 uni.request 一个出口,它的 method 枚举里没有 PATCH(同团队那组的刻意偏差)。 + * 长度与字符的判据在官网,这里只拦「空」。 + */ + @PutMapping("/profile") + public Map updateProfile( + @RequestBody(required = false) Map body, + @RequestHeader(value = "X-Session-Id", required = false) String sessionId) { + requireUser(sessionId); + String displayName = body == null ? null : body.get("displayName"); + if (displayName == null || displayName.isBlank()) { + // 文案不含「登录/未授权/请先」,不会被 api.js 误判成掉线 + throw new IllegalArgumentException(LangText.of("昵称不能为空", "The display name cannot be empty")); + } + Map updated = accountService.updateDisplayName(displayName); + Object next = updated.get("displayName"); + identitySync.applyDisplayName(next == null ? displayName.trim() : String.valueOf(next)); + return ok(updated); + } + + /** 传头像:multipart 字段 {@code file},原样转发官网;回 {@code {avatarUpdatedAt, avatarUrl}}。 */ + @PostMapping("/avatar") + public Map uploadAvatar( + @RequestParam("file") MultipartFile file, + @RequestHeader(value = "X-Session-Id", required = false) String sessionId) { + requireUser(sessionId); + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException(LangText.of("请选择一张图片", "Pick an image first")); + } + if (file.getSize() > MAX_AVATAR_BYTES) { + // 复用官网那张错误码表,两边说同一句话 + throw new AccountException(AccountException.Kind.REJECTED, + AccountService.rejectedMessage("too_large"), "too_large"); + } + byte[] content; + try { + content = file.getBytes(); + } catch (java.io.IOException e) { + throw new IllegalArgumentException(LangText.of("读取图片失败,换一张再试", "Could not read that image, try another one")); + } + Map uploaded = accountService.uploadAvatar( + content, file.getOriginalFilename(), file.getContentType()); + identitySync.applyAvatarUrl(str(uploaded.get("avatarUrl"))); + return ok(uploaded); + } + + /** 删头像:转发官网;回 {@code {avatarUpdatedAt:null, avatarUrl:null}},本机行的头像一并清空。 */ + @DeleteMapping("/avatar") + public Map deleteAvatar( + @RequestHeader(value = "X-Session-Id", required = false) String sessionId) { + requireUser(sessionId); + Map deleted = accountService.deleteAvatar(); + identitySync.applyAvatarUrl(str(deleted.get("avatarUrl"))); + return ok(deleted); + } + + private static String str(Object value) { + return value == null ? null : String.valueOf(value); + } + // ==================== 团队(dev-board#496) ==================== // // 与上面的会员/充值同一模板:requireUser 过身份闸 → accountService 带 Bearer awdk_ 打官网 diff --git a/backend/src/main/java/com/checkba/controller/ProjectMemberController.java b/backend/src/main/java/com/checkba/controller/ProjectMemberController.java index 1bca988ea..2c8b303db 100644 --- a/backend/src/main/java/com/checkba/controller/ProjectMemberController.java +++ b/backend/src/main/java/com/checkba/controller/ProjectMemberController.java @@ -53,10 +53,13 @@ public Map getMembers( map.put("role", member.getRole()); map.put("joinedAt", member.getJoinedAt()); if (user != null) { + // username 保留一版给老客户端,前端不再读它(spec 2026-09-10 §4/§5: + // 用户名退成内部标识,任何界面都不再当名字显示) map.put("username", user.getUsername()); // local-mode 下项目 owner/成员可能是库里存了中文哨兵值的本机用户,按界面语言本地化 map.put("displayName", LocalIdentityService.displayNameOf(user.getDisplayName())); - map.put("avatarUrl", user.getAvatarUrl()); + // 本机有就本机,否则官网 /api/avatar/{accountId}——与「加同事」确认卡同一个口径 + map.put("avatarUrl", projectMemberService.avatarUrlFor(user)); } return map; }).collect(Collectors.toList()); @@ -72,7 +75,7 @@ public Map getMembers( ownerMap.put("joinedAt", null); ownerMap.put("username", owner.getUsername()); ownerMap.put("displayName", LocalIdentityService.displayNameOf(owner.getDisplayName())); - ownerMap.put("avatarUrl", owner.getAvatarUrl()); + ownerMap.put("avatarUrl", projectMemberService.avatarUrlFor(owner)); resultList.add(0, ownerMap); // Add to top } } diff --git a/backend/src/main/java/com/checkba/service/ProjectMemberService.java b/backend/src/main/java/com/checkba/service/ProjectMemberService.java index 4faf118a0..b1a9ae8f9 100644 --- a/backend/src/main/java/com/checkba/service/ProjectMemberService.java +++ b/backend/src/main/java/com/checkba/service/ProjectMemberService.java @@ -303,8 +303,12 @@ private String currentRoleOrNull(Long projectId, Long userId) { * 头像地址:本机上传过就用本机那份(自建服务器的人工账号),否则按官网账户绑定 * 拼出官网的公开头像地址。没有绑定就给 null——硬拼一个必然 404 的地址只会让 * 界面白等一次网络请求,前端本来就有首字母降级。 + * + *

    public:查人卡片与参与人列表({@code ProjectMemberController.getMembers},含 owner 那行) + * 共用这一处口径(spec 2026-09-10 §4)。桥接进来的同事本机表里根本没有头像, + * 直接回 {@code user.avatarUrl} 的那一版在参与人列表里就是一片空白首字母。 */ - private String avatarUrlFor(User user) { + public String avatarUrlFor(User user) { if (user.getAvatarUrl() != null && !user.getAvatarUrl().isBlank()) { return user.getAvatarUrl(); } diff --git a/backend/src/main/java/com/checkba/service/UserService.java b/backend/src/main/java/com/checkba/service/UserService.java index 0fc913b5d..4491bb2b8 100644 --- a/backend/src/main/java/com/checkba/service/UserService.java +++ b/backend/src/main/java/com/checkba/service/UserService.java @@ -294,6 +294,26 @@ public Optional getUserByUsername(String username) { return userRepository.findByUsername(username); } + /** + * 展示名随官网刷新(spec 2026-09-10 §4/§5:官网的展示名是唯一权威源)。 + * + *

    只在官网给了非空值、且与本地这行不同时才写;username 一个字都不动—— + * 它是内部标识,改名会断掉 {@code /u/用户名} 与 Skill 归属链接。 + * 官网给空(老账户还没填名字)时保留本地已有的那份,不清成空白。 + * + *

    这条与 {@code LocalIdentityService.commit} 的「真实账号的 displayName 不动」不冲突: + * 那条禁的是本机按 admin 心智替用户改名,这里是随权威源刷新。 + */ + public User refreshDisplayNameFromWebsite(User user, String websiteDisplayName) { + if (user == null) return null; + String next = websiteDisplayName == null ? "" : websiteDisplayName.trim(); + if (next.isEmpty() || next.equals(user.getDisplayName())) return user; + if (next.length() > 128) next = next.substring(0, 128); // 列宽 128 + user.setDisplayName(next); + user.setUpdatedAt(LocalDateTime.now()); + return userRepository.save(user); + } + /** * 更新用户头像 */ diff --git a/backend/src/main/java/com/checkba/service/account/AccountIdentitySync.java b/backend/src/main/java/com/checkba/service/account/AccountIdentitySync.java new file mode 100644 index 000000000..bebdc0e69 --- /dev/null +++ b/backend/src/main/java/com/checkba/service/account/AccountIdentitySync.java @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2026 北京京微资易科技有限公司 and AI WorkDeck contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package com.checkba.service.account; + +import com.checkba.model.entity.User; +import com.checkba.service.LocalIdentityService; +import com.checkba.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Objects; + +/** + * 官网身份 → 本机 {@code User} 行的**唯一同步点**(spec 2026-09-10 §5)。 + * + *

    官网的展示名与头像是唯一权威源,本机那份只是它的一份可刷新的副本。落地点有四处: + * 连接账户时、每次 {@code GET /api/account/status}(应用启动会拉)、 + * {@code GET /api/account/profile},以及个人设置里三个写动作之后。 + * + *

    与 {@code LocalIdentityService.commit} 那条「真实账号的 displayName 一个字都不动」不冲突: + * 那条禁的是本机按 admin 心智替用户改名,这里是随权威源刷新。 + * + *

    红线: + *

      + *
    • 未连接账户 / 官网不可达:不动本机行,也不报错——同步是顺手动作, + * 为它把应用启动或设置页搞失败不划算;
    • + *
    • 非 local-mode 整条短路:团队案件库与插件云上账户是机器级状态, + * 按它去改某一个租户的 User 行是张冠李戴(同 TeamUsageUploadService 的第二道闸)。
    • + *
    + */ +@Service +@Slf4j +public class AccountIdentitySync { + + private final AccountService accountService; + private final LocalIdentityService localIdentityService; + private final UserService userService; + + public AccountIdentitySync(AccountService accountService, + LocalIdentityService localIdentityService, + UserService userService) { + this.accountService = accountService; + this.localIdentityService = localIdentityService; + this.userService = userService; + } + + /** + * 拉官网档案、同步到本机行,返回身份视图 + * {@code {accountId, displayName, avatarUrl, displayNameIsDefault}}。 + * + * @throws AccountException 未连接(NOT_CONNECTED)/ 官网不可达 —— 调用方要么上抛给用户, + * 要么用 {@link #refreshQuietly()} + */ + public Map refresh() { + Map view = accountService.profileIdentity(); + write(str(view.get("displayName")), true, str(view.get("avatarUrl"))); + return view; + } + + /** + * 静默版:连接账户时与 {@code GET /api/account/status}(应用启动会拉)用。失败返回 null。 + * + *

    非 local-mode 直接短路,连出站都不发:那里同步无处可落, + * 而 status 是个会被反复调用的端点,白打一趟官网没有任何意义。 + */ + public Map refreshQuietly() { + if (!localIdentityService.isLocalMode() || !accountService.isConnected()) { + return null; + } + try { + return refresh(); + } catch (RuntimeException e) { + log.debug("身份同步跳过(官网不可达或返回异常): {}", e.getMessage()); + return null; + } + } + + /** 改完昵称之后:官网已经写成功了,本机行跟着走。只刷展示名,不碰头像。 */ + public void applyDisplayName(String displayName) { + write(displayName, false, null); + } + + /** 传/删完头像之后。{@code null} 表示删掉了——官网没有就是没有。只刷头像,不碰展示名。 */ + public void applyAvatarUrl(String avatarUrl) { + write(null, true, avatarUrl); + } + + /** + * 唯一写入口。{@code displayName} 为空表示这一项不动(官网也可能确实没有名字, + * 那种情况同样保留本机已有的那份,不清成空白);头像要不要动由 {@code touchAvatar} 说了算, + * 因为「清成空」本身就是一种合法的新值。 + */ + private void write(String displayName, boolean touchAvatar, String avatarUrl) { + if (!localIdentityService.isLocalMode()) { + return; + } + try { + Long userId = localIdentityService.localUserId(); + if (userId == null) return; + User user = userService.getUserById(userId); + if (user == null) return; + if (displayName != null && !displayName.isBlank()) { + userService.refreshDisplayNameFromWebsite(user, displayName); + } + if (touchAvatar && !Objects.equals(user.getAvatarUrl(), avatarUrl)) { + userService.updateAvatar(userId, avatarUrl); + } + } catch (RuntimeException e) { + // 本机行同步失败不该冒泡:官网那边已经是对的,下一次启动还会再同步一次 + log.warn("本机身份行同步失败(不影响官网侧结果): {}", e.toString()); + } + } + + private static String str(Object value) { + return value == null ? null : String.valueOf(value); + } +} diff --git a/backend/src/main/java/com/checkba/service/account/AccountService.java b/backend/src/main/java/com/checkba/service/account/AccountService.java index 751784c60..e65c3ae57 100644 --- a/backend/src/main/java/com/checkba/service/account/AccountService.java +++ b/backend/src/main/java/com/checkba/service/account/AccountService.java @@ -74,6 +74,8 @@ private String baseUrl() { static class State { public String key; public String username; + /** 官网的稳定账户 id。头像地址 {@code /api/avatar/{accountId}} 要用,省得每次再拉一趟 /me。 */ + public String accountId; public String displayName; public String connectedAt; public String lastSyncAt; @@ -102,6 +104,7 @@ public synchronized Map connect(String awdkKey) { State state = new State(); state.key = key; state.username = str(me.get("username")); + state.accountId = str(me.get("accountId")); state.displayName = str(me.get("displayName")); state.connectedAt = Instant.now().toString(); state.lastSyncAt = state.connectedAt; @@ -137,6 +140,7 @@ public synchronized Map status() { result.put("connected", connected); if (connected) { result.put("username", state.username); + result.put("accountId", state.accountId); result.put("displayName", state.displayName); result.put("connectedAt", state.connectedAt); result.put("lastSyncAt", state.lastSyncAt); @@ -293,6 +297,132 @@ public Map fetchAiKeyWith(String awdkKey) { return body; } + // ==================== 个人档案与头像(spec 2026-09-10 §5) ==================== + // + // 官网的展示名与头像是**唯一权威源**,桌面端只是它的一个编辑入口:本机不存第二份真相, + // 每一次写都直接打官网,写完把结果同步回本机 User 行(那一步在 AccountIdentitySync)。 + // 本机 `POST /api/users/avatar` 留给自建服务器,local-mode 下前端不再调它。 + + /** + * GET /api/account/me 的**身份视图**:{@code {accountId, displayName, avatarUrl, displayNameIsDefault}}。 + * + *

    不含 username:用户名退成内部标识,任何界面都不再当名字显示(spec §2 裁决 5)。 + * 走 60 秒 profile 缓存(与顶栏余额端点同一份)——应用启动的 status 与个人设置页 + * 打开的一瞬间会连着问好几次,没必要每次都出网。 + */ + public synchronized Map profileIdentity() { + String owner = accountFingerprintOrNull(); + if (owner == null) { + // 未连接时 requireKey() 抛 NOT_CONNECTED;连着而指纹算不出来(SHA-256 不可用)时兜个哨兵 + requireKey(); + owner = "unknown"; + } + Map me = cachedProfile(owner); + rememberIdentity(str(me.get("accountId")), str(me.get("displayName"))); + return identityView(str(me.get("accountId")), str(me.get("displayName")), + str(me.get("avatarUpdatedAt")), Boolean.TRUE.equals(me.get("displayNameIsDefault"))); + } + + /** + * PATCH /api/account/profile —— 改昵称。 + * + *

    本机那一跳是 PUT(uni.request 的 method 枚举里没有 PATCH),出站到官网仍是 PATCH, + * 与团队那组同一处刻意偏差(护栏 {@code AccountServiceTest.teamHierarchyOutboundShape})。 + * 长度/字符的判据在官网(400 {@code invalid_display_name}),这里不抄一份。 + */ + public Map updateDisplayName(String displayName) { + Map body = new LinkedHashMap<>(); + body.put("displayName", displayName == null ? "" : displayName.trim()); + Map res = sendJson("PATCH", "/api/account/profile", body); + String next = str(res.get("displayName")); + rememberIdentity(null, next); + // 缓存里那份 displayName 已经旧了;下一次 profileIdentity() 必须看到新名字 + clearBalanceCache(); + return res; + } + + /** + * POST /api/account/avatar —— multipart 转发头像。 + * 回 {@code {avatarUpdatedAt, avatarUrl}}:官网只回版本号,地址在这里按 accountId 拼好, + * 省得前端为了一个地址再拉一趟 /me。2MB 上限与格式判据都在官网({@code too_large} 等)。 + */ + public Map uploadAvatar(byte[] content, String filename, String contentType) { + String key = requireKey(); + AccountTransport.Reply reply = transport.sendMultipart( + "POST", baseUrl() + "/api/account/avatar", key, + new AccountTransport.Multipart("file", filename, contentType, content)); + if (reply.networkFailure()) { + throw networkError(); + } + Map body = handle(reply); + String updatedAt = str(body.get("avatarUpdatedAt")); + Map result = new LinkedHashMap<>(); + result.put("avatarUpdatedAt", updatedAt); + result.put("avatarUrl", avatarUrl(accountIdOrFetch(), updatedAt)); + return result; + } + + /** DELETE /api/account/avatar —— 删头像,回 {@code {avatarUpdatedAt:null, avatarUrl:null}}。 */ + public Map deleteAvatar() { + sendJson("DELETE", "/api/account/avatar", null); + Map result = new LinkedHashMap<>(); + result.put("avatarUpdatedAt", null); + result.put("avatarUrl", null); + return result; + } + + /** 身份视图的唯一拼法,profileIdentity 与写入回包共用。 */ + private Map identityView(String accountId, String displayName, + String avatarUpdatedAt, boolean displayNameIsDefault) { + Map view = new LinkedHashMap<>(); + view.put("accountId", accountId); + view.put("displayName", displayName); + view.put("avatarUrl", avatarUrl(accountId, avatarUpdatedAt)); + view.put("displayNameIsDefault", displayNameIsDefault); + return view; + } + + /** + * 官网公开头像地址。{@code avatarUpdatedAt} 为空表示这个账户根本没传过头像—— + * 回 null 而不是一个必然 404 的地址(前端本来就有首字母降级)。 + * {@code ?v=} 是缓存版本号,改了头像地址就变,浏览器不会拿旧的那张。 + */ + private String avatarUrl(String accountId, String avatarUpdatedAt) { + if (accountId == null || accountId.isBlank()) return null; + if (avatarUpdatedAt == null || avatarUpdatedAt.isBlank() || "null".equals(avatarUpdatedAt)) return null; + return baseUrl() + "/api/avatar/" + java.net.URLEncoder.encode(accountId, StandardCharsets.UTF_8) + + "?v=" + java.net.URLEncoder.encode(avatarUpdatedAt, StandardCharsets.UTF_8); + } + + /** account.json 里的 accountId;老版本落的盘没有这一项时补拉一次 /me。 */ + private String accountIdOrFetch() { + State state = loadState(); + if (state.accountId != null && !state.accountId.isBlank()) return state.accountId; + Map me = fetchProfile(); + String accountId = str(me.get("accountId")); + rememberIdentity(accountId, str(me.get("displayName"))); + return accountId; + } + + /** + * 把官网那份身份落回 account.json。null 表示这一项不动 + * (改昵称只知道新名字,不该顺手把 accountId 清掉)。 + */ + private synchronized void rememberIdentity(String accountId, String displayName) { + State state = loadState(); + if (state.key == null || state.key.isBlank()) return; + boolean changed = false; + if (accountId != null && !accountId.isBlank() && !accountId.equals(state.accountId)) { + state.accountId = accountId; + changed = true; + } + if (displayName != null && !displayName.isBlank() && !displayName.equals(state.displayName)) { + state.displayName = displayName; + changed = true; + } + if (changed) saveState(state); + } + // ==================== 会员与充值(dev-board#183/#184) ==================== /** @@ -808,7 +938,7 @@ static String businessErrorCode(String body) { * 业务机器码的人话(团队端点契约见官网 doc/desktop-contract.md「团队」节)。 * 文案红线同 {@link #unauthorizedMessage()}:不得含「登录」「未授权」「请先」。 */ - static String rejectedMessage(String code) { + public static String rejectedMessage(String code) { return switch (code) { case "phone_required" -> LangText.of("需要在官网账户绑定手机号后,才能创建或加入团队", "Bind a phone number to your website account before creating or joining a team"); case "already_in_team" -> LangText.of("这个账户已经在一个团队里了", "This account already belongs to a team"); @@ -823,6 +953,11 @@ static String rejectedMessage(String code) { case "bad_role" -> LangText.of("角色不合法", "That role is not allowed"); case "bad_name" -> LangText.of("名称不能为空", "The name cannot be empty"); case "rate_limited" -> LangText.of("操作太频繁,稍后再试", "Too many requests, try again shortly"); + // 个人档案与头像(spec 2026-09-10 §5,官网 /api/account/profile 与 /api/account/avatar) + case "invalid_display_name" -> LangText.of("昵称不合规,换一个(1-24 个字)", "That display name is not allowed; try another one (1-24 characters)"); + case "too_large" -> LangText.of("图片太大,换一张 2MB 以内的", "That image is too large; pick one under 2MB"); + case "invalid_image" -> LangText.of("这不是一张能识别的图片", "That file is not a readable image"); + case "unsupported_format" -> LangText.of("图片格式不支持,用 JPG / PNG / WebP", "That image format is not supported; use JPG, PNG or WebP"); case "payload_too_large" -> LangText.of("上报数据过大", "The upload is too large"); default -> LangText.of("官网拒绝了这次操作(", "The website rejected this request (") + code + LangText.of(")", ")"); }; diff --git a/backend/src/main/java/com/checkba/service/account/AccountTransport.java b/backend/src/main/java/com/checkba/service/account/AccountTransport.java index c90e85a24..a7a3a66e7 100644 --- a/backend/src/main/java/com/checkba/service/account/AccountTransport.java +++ b/backend/src/main/java/com/checkba/service/account/AccountTransport.java @@ -27,4 +27,17 @@ public boolean networkFailure() { * @param jsonBody 请求体,null 表示无体 */ Reply send(String method, String url, String bearerKey, String jsonBody); + + /** multipart 里的那一个文件部件(头像上传只有一个,没必要做成通用多部件)。 */ + record Multipart(String fieldName, String filename, String contentType, byte[] content) {} + + /** + * multipart/form-data 出站(当前只有头像上传一处)。 + * + *

    默认实现直接抛:绝大多数打桩 transport 只关心 JSON 那条路,给个默认值 + * (比如返回 200)会让「桩根本没接这条路」在测试里表现成一次成功的上传。 + */ + default Reply sendMultipart(String method, String url, String bearerKey, Multipart part) { + throw new UnsupportedOperationException("本 transport 不支持 multipart 出站"); + } } diff --git a/backend/src/main/java/com/checkba/service/account/AwdkLoginService.java b/backend/src/main/java/com/checkba/service/account/AwdkLoginService.java index baf480f2c..361aa79b4 100644 --- a/backend/src/main/java/com/checkba/service/account/AwdkLoginService.java +++ b/backend/src/main/java/com/checkba/service/account/AwdkLoginService.java @@ -285,6 +285,10 @@ private User resolveUser(String accountId, String externalUsername, String displ AccountBinding binding = existing.get(); try { User user = userService.getUserById(binding.getUserId()); + // 展示名以官网为唯一权威源(spec 2026-09-10 §4):桥接那一刻抄一份、 + // 之后永不刷新,会让手机号注册的同事在参与人列表与时间线里一直挂着 + // 官网早就改掉的打码手机号。username 不动——它是内部标识。 + user = userService.refreshDisplayNameFromWebsite(user, displayName); binding.setLastLoginAt(LocalDateTime.now()); bindingRepository.save(binding); return user; diff --git a/backend/src/main/java/com/checkba/service/account/HttpAccountTransport.java b/backend/src/main/java/com/checkba/service/account/HttpAccountTransport.java index fbcf0bc41..8c553d2d9 100644 --- a/backend/src/main/java/com/checkba/service/account/HttpAccountTransport.java +++ b/backend/src/main/java/com/checkba/service/account/HttpAccountTransport.java @@ -60,4 +60,58 @@ public Reply send(String method, String url, String bearerKey, String jsonBody) return new Reply(Reply.NETWORK_FAILURE, null); } } + + /** + * multipart/form-data 出站(头像上传,spec 2026-09-10 §5)。 + * + *

    JDK HttpClient 没有 multipart 编码器,这里手工拼一份**单部件**报文。 + * 分隔符用随机串:内容是用户挑的图片二进制,固定分隔符万一在图片里出现, + * 服务端会在半截处截断,表现成一张莫名其妙损坏的头像。 + */ + @Override + public Reply sendMultipart(String method, String url, String bearerKey, Multipart part) { + try { + String boundary = "----awdk" + java.util.UUID.randomUUID().toString().replace("-", ""); + String filename = part.filename() == null || part.filename().isBlank() ? "avatar" : part.filename(); + // 文件名里的引号/换行会破坏头部结构;只留一个安全的形态 + filename = filename.replaceAll("[\r\n\"\\\\]", "_"); + String contentType = part.contentType() == null || part.contentType().isBlank() + ? "application/octet-stream" : part.contentType(); + String head = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"" + part.fieldName() + "\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: " + contentType + "\r\n\r\n"; + String tail = "\r\n--" + boundary + "--\r\n"; + byte[] body = concat(head.getBytes(StandardCharsets.UTF_8), + part.content() == null ? new byte[0] : part.content(), + tail.getBytes(StandardCharsets.UTF_8)); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(TIMEOUT) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .method(method, HttpRequest.BodyPublishers.ofByteArray(body)); + if (bearerKey != null) { + builder.header("Authorization", "Bearer " + bearerKey); + } + HttpResponse response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new Reply(response.statusCode(), response.body()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new Reply(Reply.NETWORK_FAILURE, null); + } catch (Exception e) { + log.debug("账户 multipart 请求失败 {} {}: {}", method, url, e.toString()); + return new Reply(Reply.NETWORK_FAILURE, null); + } + } + + private static byte[] concat(byte[]... parts) { + int total = 0; + for (byte[] p : parts) total += p.length; + byte[] out = new byte[total]; + int at = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, at, p.length); + at += p.length; + } + return out; + } } diff --git a/backend/src/main/java/com/checkba/service/collab/CollaboratorAdmission.java b/backend/src/main/java/com/checkba/service/collab/CollaboratorAdmission.java index a428d7474..410639e9c 100644 --- a/backend/src/main/java/com/checkba/service/collab/CollaboratorAdmission.java +++ b/backend/src/main/java/com/checkba/service/collab/CollaboratorAdmission.java @@ -6,6 +6,7 @@ import com.checkba.model.entity.AccountBinding; import com.checkba.model.entity.User; import com.checkba.repository.AccountBindingRepository; +import com.checkba.service.UserService; import com.checkba.service.account.AwdkLoginService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -35,15 +36,18 @@ public class CollaboratorAdmission { private final CollaborationPolicy policy; private final AccountBindingRepository bindingRepository; private final AwdkLoginService awdkLoginService; + private final UserService userService; public CollaboratorAdmission(AccountDirectoryClient directory, CollaborationPolicy policy, AccountBindingRepository bindingRepository, - AwdkLoginService awdkLoginService) { + AwdkLoginService awdkLoginService, + UserService userService) { this.directory = directory; this.policy = policy; this.bindingRepository = bindingRepository; this.awdkLoginService = awdkLoginService; + this.userService = userService; } /** 准入结果:要么给出可以加进案卷的人,要么给出一个说得清下一步的拒绝理由。 */ @@ -81,8 +85,12 @@ public Admission admit(Optional local, String identifier, Long requesterId } DirectoryReply reply = directory.lookupByAccountId(requesterAccountId, candidateAccountId); Verdict verdict = policy.check(reply.requester(), reply.candidate()); - return verdict.allowed() ? new Admission(local.get(), null) - : new Admission(null, verdict.denial()); + if (!verdict.allowed()) { + return new Admission(null, verdict.denial()); + } + // 每次名录回查顺手刷新展示名(spec 2026-09-10 §4):官网是唯一权威源, + // 而桥接那一刻抄下来的那份可能已经是几个月前的打码手机号了。 + return new Admission(refreshDisplayName(local.get(), reply), null); } DirectoryReply reply = directory.lookupByIdentifier(requesterAccountId, identifier); @@ -100,6 +108,19 @@ public Admission admit(Optional local, String identifier, Long requesterId return new Admission(bridged, null); } + /** + * 用名录回包里的展示名刷新本机这行。名录 {@code found:true} 时总带 {@code account}, + * 但缺了也不该炸——身份行本身是好的,只是名字没能刷新。 + */ + private User refreshDisplayName(User user, DirectoryReply reply) { + DirectoryAccount account = reply.account(); + if (account == null) { + return user; + } + User refreshed = userService.refreshDisplayNameFromWebsite(user, account.displayName()); + return refreshed != null ? refreshed : user; + } + private String externalAccountId(Long userId) { if (userId == null) { return null; diff --git a/backend/src/main/java/com/checkba/version/VersionController.java b/backend/src/main/java/com/checkba/version/VersionController.java index f1a53b0de..8281c76a0 100644 --- a/backend/src/main/java/com/checkba/version/VersionController.java +++ b/backend/src/main/java/com/checkba/version/VersionController.java @@ -644,12 +644,23 @@ private Long requireAdminMember(Long projectId, String sessionId) { return userId; } + /** + * 版本记录里的署名(spec 2026-09-10 §4):**展示名优先,空了才回落用户名**。 + * + *

    以前直接写 username,手机号注册的同事在时间线与文档修订里就是一串 + * {@code awd_upoxwcdtg}——用户名现在是内部标识,任何界面都不再当名字显示。 + * 已经写进 Git 提交对象的历史 authorName 不回填。 + */ private String userName(Long userId) { try { var u = userService.getUserById(userId); - if (u != null && u.getUsername() != null) return u.getUsername(); + if (u != null) { + String display = u.getDisplayName(); + if (display != null && !display.isBlank()) return display.trim(); + if (u.getUsername() != null) return u.getUsername(); + } } catch (Exception e) { - log.warn("取用户名失败: userId={}", userId, e); + log.warn("取署名失败: userId={}", userId, e); } return LangText.of("用户", "User"); } diff --git a/backend/src/test/java/com/checkba/controller/AccountControllerMachineScopeTest.java b/backend/src/test/java/com/checkba/controller/AccountControllerMachineScopeTest.java index ff0eeee51..968c1f4e1 100644 --- a/backend/src/test/java/com/checkba/controller/AccountControllerMachineScopeTest.java +++ b/backend/src/test/java/com/checkba/controller/AccountControllerMachineScopeTest.java @@ -86,7 +86,8 @@ void setUp() { entitlementService, mock(com.checkba.service.team.TeamUsageSettings.class), mock(com.checkba.service.team.TeamUsageUploadService.class), - mock(com.checkba.service.team.TeamSettingsCache.class)); + mock(com.checkba.service.team.TeamSettingsCache.class), + mock(com.checkba.service.account.AccountIdentitySync.class)); entitlementController = new EntitlementController(entitlementService, guard); } diff --git a/backend/src/test/java/com/checkba/controller/AccountControllerProfileTest.java b/backend/src/test/java/com/checkba/controller/AccountControllerProfileTest.java new file mode 100644 index 000000000..30480e1fc --- /dev/null +++ b/backend/src/test/java/com/checkba/controller/AccountControllerProfileTest.java @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2026 北京京微资易科技有限公司 and AI WorkDeck contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package com.checkba.controller; + +import com.checkba.repository.TokenUsageRepository; +import com.checkba.service.account.AccountIdentitySync; +import com.checkba.service.account.AccountService; +import com.checkba.service.account.AccountSwitchCleanup; +import com.checkba.service.account.MachineAccountGuard; +import com.checkba.service.ai.PlatformAiChannel; +import com.checkba.service.entitlement.EntitlementService; +import com.checkba.service.team.TeamSettingsCache; +import com.checkba.service.team.TeamUsageSettings; +import com.checkba.service.team.TeamUsageUploadService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * 个人档案与头像的本机透传层(spec 2026-09-10 §5)。 + * + *

    四个端点一律「转发官网 + 把结果同步回本机 User 行」,本机不存第二份真相。 + * 动词上有一处刻意偏差:本机是 PUT,出站到官网是 PATCH(uni.request 没有 PATCH), + * 与团队那组同源。 + */ +class AccountControllerProfileTest { + + AccountController controller; + AccountService accountService; + AccountIdentitySync identitySync; + + @BeforeEach + void setUp() { + AuthController.registerLocalIdentityService(null); + accountService = mock(AccountService.class); + identitySync = mock(AccountIdentitySync.class); + controller = new AccountController(accountService, mock(PlatformAiChannel.class), + mock(AccountSwitchCleanup.class), mock(TokenUsageRepository.class), + mock(MachineAccountGuard.class), mock(EntitlementService.class), + mock(TeamUsageSettings.class), mock(TeamUsageUploadService.class), + mock(TeamSettingsCache.class), identitySync); + } + + private static Map view() { + Map v = new LinkedHashMap<>(); + v.put("accountId", "acc_9f3a"); + v.put("displayName", "185****5325"); + v.put("avatarUrl", null); + v.put("displayNameIsDefault", true); + return v; + } + + @SuppressWarnings("unchecked") + private static Map data(Map envelope) { + assertEquals(0, envelope.get("code")); + return (Map) envelope.get("data"); + } + + @Test + @DisplayName("GET /profile:回身份视图,顺手同步本机行;**不回 username**") + void getProfileReturnsTheIdentityViewAndSyncs() { + when(identitySync.refresh()).thenReturn(view()); + + Map data = data(controller.profile(null)); + + assertEquals("acc_9f3a", data.get("accountId")); + assertEquals("185****5325", data.get("displayName")); + assertTrue(data.containsKey("avatarUrl")); + assertEquals(Boolean.TRUE, data.get("displayNameIsDefault")); + assertFalse(data.containsKey("username"), + "用户名退成内部标识,任何界面都不再当名字显示"); + verify(identitySync).refresh(); + } + + @Test + @DisplayName("PUT /profile:出站改昵称,成功后把新名字同步到本机行") + void putProfileForwardsAndSyncs() { + when(accountService.updateDisplayName("韩泽伟")) + .thenReturn(Map.of("displayName", "韩泽伟")); + + Map data = data(controller.updateProfile(Map.of("displayName", "韩泽伟"), null)); + + assertEquals("韩泽伟", data.get("displayName")); + verify(accountService).updateDisplayName("韩泽伟"); + verify(identitySync).applyDisplayName("韩泽伟"); + } + + @Test + @DisplayName("PUT /profile 昵称为空:业务错误,一次出站都不发") + void putProfileRejectsBlankName() { + assertThrows(IllegalArgumentException.class, + () -> controller.updateProfile(Map.of("displayName", " "), null)); + + verify(accountService, never()).updateDisplayName(any()); + verifyNoInteractions(identitySync); + } + + @Test + @DisplayName("POST /avatar:multipart 原样转发,成功后把新头像地址同步到本机行") + void postAvatarForwardsAndSyncs() throws Exception { + Map uploaded = new LinkedHashMap<>(); + uploaded.put("avatarUpdatedAt", "2026-09-10T09:00:00.000Z"); + uploaded.put("avatarUrl", "https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=x"); + when(accountService.uploadAvatar(any(), eq("me.png"), eq("image/png"))).thenReturn(uploaded); + MockMultipartFile file = new MockMultipartFile("file", "me.png", "image/png", + "PNGDATA".getBytes()); + + Map data = data(controller.uploadAvatar(file, null)); + + assertEquals("https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=x", data.get("avatarUrl")); + verify(identitySync).applyAvatarUrl("https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=x"); + } + + @Test + @DisplayName("POST /avatar 空文件:业务错误,不把一个 0 字节的东西传上去") + void postAvatarRejectsEmptyFile() { + MockMultipartFile empty = new MockMultipartFile("file", "me.png", "image/png", new byte[0]); + + assertThrows(IllegalArgumentException.class, () -> controller.uploadAvatar(empty, null)); + + verifyNoInteractions(identitySync); + } + + @Test + @DisplayName("DELETE /avatar:出站删除,本机行的头像跟着清空") + void deleteAvatarForwardsAndSyncs() { + Map deleted = new LinkedHashMap<>(); + deleted.put("avatarUpdatedAt", null); + deleted.put("avatarUrl", null); + when(accountService.deleteAvatar()).thenReturn(deleted); + + Map data = data(controller.deleteAvatar(null)); + + assertTrue(data.containsKey("avatarUrl")); + assertNull(data.get("avatarUrl")); + verify(identitySync).applyAvatarUrl(null); + } + + @Test + @DisplayName("GET /status:应用启动那一拉顺手把官网身份刷到本机行,官网不可达也不报错") + void statusRefreshesIdentityQuietly() { + when(accountService.status()).thenReturn(new LinkedHashMap<>(Map.of("connected", true))); + when(identitySync.refreshQuietly()).thenReturn(null); + + Map data = data(controller.status(null)); + + assertEquals(Boolean.TRUE, data.get("connected")); + verify(identitySync).refreshQuietly(); + } +} diff --git a/backend/src/test/java/com/checkba/controller/AccountControllerPurchaseSkuTest.java b/backend/src/test/java/com/checkba/controller/AccountControllerPurchaseSkuTest.java index 26a4e9f32..1eb0f9849 100644 --- a/backend/src/test/java/com/checkba/controller/AccountControllerPurchaseSkuTest.java +++ b/backend/src/test/java/com/checkba/controller/AccountControllerPurchaseSkuTest.java @@ -50,7 +50,8 @@ void setUp() { entitlementService, mock(com.checkba.service.team.TeamUsageSettings.class), mock(com.checkba.service.team.TeamUsageUploadService.class), - mock(com.checkba.service.team.TeamSettingsCache.class)); + mock(com.checkba.service.team.TeamSettingsCache.class), + mock(com.checkba.service.account.AccountIdentitySync.class)); } // ==================== 白名单 ==================== diff --git a/backend/src/test/java/com/checkba/controller/AccountControllerRechargeTest.java b/backend/src/test/java/com/checkba/controller/AccountControllerRechargeTest.java index a9bb5050e..76c5c534f 100644 --- a/backend/src/test/java/com/checkba/controller/AccountControllerRechargeTest.java +++ b/backend/src/test/java/com/checkba/controller/AccountControllerRechargeTest.java @@ -52,7 +52,8 @@ void setUp() { mock(com.checkba.service.entitlement.EntitlementService.class), mock(com.checkba.service.team.TeamUsageSettings.class), mock(com.checkba.service.team.TeamUsageUploadService.class), - mock(com.checkba.service.team.TeamSettingsCache.class)); + mock(com.checkba.service.team.TeamSettingsCache.class), + mock(com.checkba.service.account.AccountIdentitySync.class)); } // ==================== recharge 参数校验:拒绝但绝不 4xx/4010 ==================== diff --git a/backend/src/test/java/com/checkba/controller/AccountControllerTeamTest.java b/backend/src/test/java/com/checkba/controller/AccountControllerTeamTest.java index a284cced1..26ec9db32 100644 --- a/backend/src/test/java/com/checkba/controller/AccountControllerTeamTest.java +++ b/backend/src/test/java/com/checkba/controller/AccountControllerTeamTest.java @@ -57,7 +57,8 @@ void setUp() { controller = new AccountController(accountService, mock(PlatformAiChannel.class), mock(AccountSwitchCleanup.class), mock(TokenUsageRepository.class), mock(MachineAccountGuard.class), mock(EntitlementService.class), - teamUsageSettings, teamUsageUploadService, teamSettingsCache); + teamUsageSettings, teamUsageUploadService, teamSettingsCache, + mock(com.checkba.service.account.AccountIdentitySync.class)); } // ==================== 透传 ==================== diff --git a/backend/src/test/java/com/checkba/controller/AccountControllerUsageMalformedLedgerTest.java b/backend/src/test/java/com/checkba/controller/AccountControllerUsageMalformedLedgerTest.java index 863a2580a..4a87be3e7 100644 --- a/backend/src/test/java/com/checkba/controller/AccountControllerUsageMalformedLedgerTest.java +++ b/backend/src/test/java/com/checkba/controller/AccountControllerUsageMalformedLedgerTest.java @@ -42,7 +42,8 @@ void setUp() { mock(com.checkba.service.entitlement.EntitlementService.class), mock(com.checkba.service.team.TeamUsageSettings.class), mock(com.checkba.service.team.TeamUsageUploadService.class), - mock(com.checkba.service.team.TeamSettingsCache.class)); + mock(com.checkba.service.team.TeamSettingsCache.class), + mock(com.checkba.service.account.AccountIdentitySync.class)); } @SuppressWarnings("unchecked") diff --git a/backend/src/test/java/com/checkba/service/ProjectMemberAddByContactTest.java b/backend/src/test/java/com/checkba/service/ProjectMemberAddByContactTest.java index 4131e6bf3..d57019ba2 100644 --- a/backend/src/test/java/com/checkba/service/ProjectMemberAddByContactTest.java +++ b/backend/src/test/java/com/checkba/service/ProjectMemberAddByContactTest.java @@ -160,7 +160,7 @@ private AwdkLoginService injectAdmission(DirectoryReply reply) { when(bindings.findByUserId(any())).thenReturn(Optional.empty()); AwdkLoginService awdk = mock(AwdkLoginService.class); service.setCollaboratorAdmissionForTest(new CollaboratorAdmission( - directory, new SameFirmOrTeamPolicy(), bindings, awdk)); + directory, new SameFirmOrTeamPolicy(), bindings, awdk, mock(UserService.class))); return awdk; } diff --git a/backend/src/test/java/com/checkba/service/ProjectMemberAvatarSourceTest.java b/backend/src/test/java/com/checkba/service/ProjectMemberAvatarSourceTest.java new file mode 100644 index 000000000..da8d7990b --- /dev/null +++ b/backend/src/test/java/com/checkba/service/ProjectMemberAvatarSourceTest.java @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 北京京微资易科技有限公司 and AI WorkDeck contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package com.checkba.service; + +import com.checkba.model.entity.AccountBinding; +import com.checkba.model.entity.Project; +import com.checkba.model.entity.ProjectMember; +import com.checkba.model.entity.User; +import com.checkba.repository.AccountBindingRepository; +import com.checkba.repository.ProjectInvitationRepository; +import com.checkba.repository.ProjectMemberRepository; +import com.checkba.repository.ProjectRepository; +import com.checkba.repository.UserRepository; +import com.checkba.controller.AuthController; +import com.checkba.controller.ProjectMemberController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * 参与人列表的头像来源(spec 2026-09-10 §4):桥接进来的同事本机表里根本没有头像, + * 直接回 {@code user.avatarUrl} 就是一片空白首字母。改走 + * {@link ProjectMemberService#avatarUrlFor}——本机有就本机,否则按官网账户绑定 + * 拼出官网公开头像地址,与「加同事」确认卡同一个口径。 + * + *

    {@code username} 字段**保留一版**给老客户端,前端不再读它。 + */ +class ProjectMemberAvatarSourceTest { + + private static final long PROJECT = 7L; + private static final long OWNER = 1L; + private static final long COLLEAGUE = 4242L; + private static final String ACCOUNT_BASE = "https://www.aiworkdeck.com"; + + private ProjectMemberRepository memberRepository; + private UserRepository userRepository; + private ProjectRepository projectRepository; + private AccountBindingRepository bindingRepository; + private ProjectMemberController controller; + + @BeforeEach + void setUp() { + memberRepository = mock(ProjectMemberRepository.class); + userRepository = mock(UserRepository.class); + projectRepository = mock(ProjectRepository.class); + bindingRepository = mock(AccountBindingRepository.class); + ProjectMemberService service = new ProjectMemberService(memberRepository, userRepository, + projectRepository, mock(ProjectInvitationRepository.class)); + service.setAccountLookupForTest(bindingRepository, ACCOUNT_BASE); + controller = new ProjectMemberController(service, mock(ClientInvitationService.class), + mock(AuthAbuseGuard.class)); + + Project project = new Project(); + project.setId(PROJECT); + project.setUserId(OWNER); + when(projectRepository.findById(PROJECT)).thenReturn(Optional.of(project)); + when(userRepository.findById(OWNER)).thenReturn(Optional.of(owner())); + when(bindingRepository.findByUserId(any())).thenReturn(Optional.empty()); + } + + private static User owner() { + User u = new User(); + u.setId(OWNER); + u.setUsername("hanzewei"); + u.setDisplayName("韩泽伟"); + return u; + } + + /** 手机号注册、由「加同事」预建出来的桥接用户:本机没有头像,官网有。 */ + private static User colleague() { + User u = new User(); + u.setId(COLLEAGUE); + u.setUsername("awd_upoxwcdtg"); + u.setDisplayName("李思"); + return u; + } + + private void hasMember(User user) { + ProjectMember member = new ProjectMember(); + member.setId(11L); + member.setProjectId(PROJECT); + member.setUserId(user.getId()); + member.setRole("PARTICIPANT"); + when(memberRepository.findByProjectId(PROJECT)).thenReturn(List.of(member)); + when(userRepository.findAllById(any())).thenReturn(List.of(user)); + } + + @SuppressWarnings("unchecked") + private List> members() { + try (MockedStatic auth = mockStatic(AuthController.class)) { + auth.when(() -> AuthController.getUserIdFromSession("sess")).thenReturn(OWNER); + Map res = controller.getMembers(PROJECT, "sess"); + assertEquals(0, res.get("code")); + return (List>) res.get("data"); + } + } + + private static Map row(List> rows, long userId) { + return rows.stream().filter(r -> Long.valueOf(userId).equals(r.get("userId"))) + .findFirst().orElseThrow(() -> new AssertionError("名单里没有 userId=" + userId)); + } + + @Test + @DisplayName("桥接同事本机没头像:回官网 /api/avatar/{accountId},不是 null") + void bridgedColleagueGetsTheWebsiteAvatar() { + User lisi = colleague(); + hasMember(lisi); + AccountBinding binding = new AccountBinding(); + binding.setUserId(COLLEAGUE); + binding.setExternalAccountId("acc-9f"); + when(bindingRepository.findByUserId(COLLEAGUE)).thenReturn(Optional.of(binding)); + + Map row = row(members(), COLLEAGUE); + + assertEquals(ACCOUNT_BASE + "/api/avatar/acc-9f", row.get("avatarUrl")); + assertEquals("李思", row.get("displayName")); + assertEquals("awd_upoxwcdtg", row.get("username"), "username 保留一版给老客户端"); + } + + @Test + @DisplayName("本机上传过头像(自建服务器的人工账号):本机那份优先") + void localAvatarWins() { + User lisi = colleague(); + lisi.setAvatarUrl("http://127.0.0.1:5269/api/users/avatar/4242_1.png"); + hasMember(lisi); + AccountBinding binding = new AccountBinding(); + binding.setUserId(COLLEAGUE); + binding.setExternalAccountId("acc-9f"); + when(bindingRepository.findByUserId(COLLEAGUE)).thenReturn(Optional.of(binding)); + + assertEquals("http://127.0.0.1:5269/api/users/avatar/4242_1.png", + row(members(), COLLEAGUE).get("avatarUrl")); + } + + @Test + @DisplayName("负责人那一行同享这条:owner 也走官网头像,不是本机的 null") + void ownerRowUsesTheSameSource() { + hasMember(colleague()); + AccountBinding binding = new AccountBinding(); + binding.setUserId(OWNER); + binding.setExternalAccountId("acc-me"); + when(bindingRepository.findByUserId(OWNER)).thenReturn(Optional.of(binding)); + + Map row = row(members(), OWNER); + + assertEquals(ACCOUNT_BASE + "/api/avatar/acc-me", row.get("avatarUrl")); + assertEquals("ADMIN", row.get("role")); + } + + @Test + @DisplayName("没有官网绑定也没有本机头像:null,不硬拼一个必然 404 的地址") + void noBindingNoAvatar() { + hasMember(colleague()); + assertNull(row(members(), COLLEAGUE).get("avatarUrl")); + } +} diff --git a/backend/src/test/java/com/checkba/service/ProjectMemberLookupTest.java b/backend/src/test/java/com/checkba/service/ProjectMemberLookupTest.java index 546d34bde..6fb252e16 100644 --- a/backend/src/test/java/com/checkba/service/ProjectMemberLookupTest.java +++ b/backend/src/test/java/com/checkba/service/ProjectMemberLookupTest.java @@ -214,7 +214,8 @@ private DirectoryReply answer() { } }; service.setCollaboratorAdmissionForTest(new CollaboratorAdmission( - directory, new SameFirmOrTeamPolicy(), bindingRepository, awdkLoginService)); + directory, new SameFirmOrTeamPolicy(), bindingRepository, awdkLoginService, + mock(UserService.class))); } @Test diff --git a/backend/src/test/java/com/checkba/service/account/AccountIdentitySyncTest.java b/backend/src/test/java/com/checkba/service/account/AccountIdentitySyncTest.java new file mode 100644 index 000000000..6e49b82a1 --- /dev/null +++ b/backend/src/test/java/com/checkba/service/account/AccountIdentitySyncTest.java @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2026 北京京微资易科技有限公司 and AI WorkDeck contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package com.checkba.service.account; + +import com.checkba.model.entity.User; +import com.checkba.service.LocalIdentityService; +import com.checkba.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * 官网身份 → 本机 User 行的唯一同步点(spec 2026-09-10 §5)。 + * + *

    三条不能退的线: + *

      + *
    • **未连接账户 / 官网不可达:不动本机行,也不报错**——同步是顺手动作, + * 为它把设置页或应用启动搞失败不划算;
    • + *
    • 非 local-mode(团队案件库、插件云)整条短路:那里的账户是机器级状态, + * 按它去改某一个租户的 User 行是张冠李戴;
    • + *
    • 官网展示名为空时保留本机那份,头像则以官网为准(没传过就是没有)。
    • + *
    + */ +class AccountIdentitySyncTest { + + private static final long LOCAL_USER = 42L; + + private AccountService accountService; + private LocalIdentityService localIdentityService; + private UserService userService; + private AccountIdentitySync sync; + private User localUser; + + @BeforeEach + void setUp() { + accountService = mock(AccountService.class); + localIdentityService = mock(LocalIdentityService.class); + userService = mock(UserService.class); + sync = new AccountIdentitySync(accountService, localIdentityService, userService); + + localUser = new User(); + localUser.setId(LOCAL_USER); + localUser.setUsername("hanzewei"); + localUser.setDisplayName("本机用户"); + + lenient().when(localIdentityService.isLocalMode()).thenReturn(true); + lenient().when(localIdentityService.localUserId()).thenReturn(LOCAL_USER); + lenient().when(userService.getUserById(LOCAL_USER)).thenReturn(localUser); + lenient().when(accountService.isConnected()).thenReturn(true); + } + + private static Map view(String displayName, String avatarUrl) { + Map v = new LinkedHashMap<>(); + v.put("accountId", "acc_9f3a"); + v.put("displayName", displayName); + v.put("avatarUrl", avatarUrl); + v.put("displayNameIsDefault", false); + return v; + } + + @Test + @DisplayName("已连接:官网的展示名与头像一起刷到本机行,视图原样返回") + void refreshWritesBothFields() { + when(accountService.profileIdentity()) + .thenReturn(view("韩泽伟", "https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=1")); + + Map got = sync.refresh(); + + assertEquals("韩泽伟", got.get("displayName")); + verify(userService).refreshDisplayNameFromWebsite(localUser, "韩泽伟"); + verify(userService).updateAvatar(LOCAL_USER, "https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=1"); + } + + @Test + @DisplayName("官网没有头像:本机那份也清掉——官网是唯一权威源") + void refreshClearsAvatarWhenTheWebsiteHasNone() { + localUser.setAvatarUrl("http://127.0.0.1:5269/api/users/avatar/42_1.png"); + when(accountService.profileIdentity()).thenReturn(view("韩泽伟", null)); + + sync.refresh(); + + verify(userService).updateAvatar(LOCAL_USER, null); + } + + @Test + @DisplayName("头像没变:不白写一次库") + void unchangedAvatarIsNotRewritten() { + localUser.setAvatarUrl("https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=1"); + when(accountService.profileIdentity()) + .thenReturn(view("韩泽伟", "https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=1")); + + sync.refresh(); + + verify(userService, never()).updateAvatar(anyLong(), any()); + } + + @Test + @DisplayName("未连接账户:不出网、不动本机行、不报错") + void notConnectedIsAQuietNoop() { + when(accountService.isConnected()).thenReturn(false); + + assertNull(sync.refreshQuietly()); + + verify(accountService, never()).profileIdentity(); + verifyNoInteractions(userService); + } + + @Test + @DisplayName("官网不可达:静默版返回 null,本机行一个字不动") + void unreachableWebsiteLeavesTheRowAlone() { + when(accountService.profileIdentity()).thenThrow( + new AccountException(AccountException.Kind.NETWORK, "无法连接 AI WorkDeck 服务器")); + + assertNull(sync.refreshQuietly()); + + verifyNoInteractions(userService); + } + + @Test + @DisplayName("非 local-mode(团队案件库/插件云):账户是机器级状态,绝不按它改某个租户的行") + void serverModeNeverTouchesAnyRow() { + when(localIdentityService.isLocalMode()).thenReturn(false); + when(accountService.profileIdentity()).thenReturn(view("韩泽伟", null)); + + assertNotNull(sync.refresh(), "视图照常返回,只是不落到本机行"); + assertNull(sync.refreshQuietly(), "status 那条路直接短路,连出站都不发"); + + verifyNoInteractions(userService); + verify(accountService, times(1)).profileIdentity(); + } + + @Test + @DisplayName("改完昵称:只刷展示名,不碰头像") + void applyDisplayNameOnlyTouchesTheName() { + sync.applyDisplayName("韩律师"); + + verify(userService).refreshDisplayNameFromWebsite(localUser, "韩律师"); + verify(userService, never()).updateAvatar(anyLong(), any()); + } + + @Test + @DisplayName("传/删完头像:只刷头像,不碰展示名") + void applyAvatarOnlyTouchesTheAvatar() { + localUser.setAvatarUrl("https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=1"); + + sync.applyAvatarUrl(null); + + verify(userService).updateAvatar(LOCAL_USER, null); + verify(userService, never()).refreshDisplayNameFromWebsite(any(), any()); + } +} diff --git a/backend/src/test/java/com/checkba/service/account/AccountServiceTest.java b/backend/src/test/java/com/checkba/service/account/AccountServiceTest.java index 6db765dc2..1b8f05b87 100644 --- a/backend/src/test/java/com/checkba/service/account/AccountServiceTest.java +++ b/backend/src/test/java/com/checkba/service/account/AccountServiceTest.java @@ -61,6 +61,21 @@ public Reply send(String method, String url, String bearerKey, String jsonBody) } return replies.poll(); } + + /** 最近一次 multipart 出站的那一份,供头像上传的形状断言用。 */ + Multipart lastPart; + + @Override + public Reply sendMultipart(String method, String url, String bearerKey, Multipart part) { + calls.add(method + " " + url); + bodies.add(""); + lastBearer = bearerKey; + lastPart = part; + if (replies.isEmpty()) { + throw new AssertionError("桩没有为 " + method + " " + url + " 准备响应"); + } + return replies.poll(); + } } private StubTransport transport; @@ -73,7 +88,8 @@ private AccountService service() { private AccountService connected() { transport = new StubTransport().enqueue(200, - "{\"username\":\"hanzewei\",\"displayName\":\"韩泽伟\",\"balanceCents\":1980,\"plan\":\"paid\"}"); + "{\"accountId\":\"acc_9f3a\",\"username\":\"hanzewei\",\"displayName\":\"韩泽伟\"," + + "\"balanceCents\":1980,\"plan\":\"paid\"}"); AccountService service = service(); service.connect(KEY); return service; @@ -447,7 +463,7 @@ void accountMessagesDoNotLookLikeAuthErrors() { transport.enqueue(409, "{\"error\":\"no_allocation\"}"); AccountException noAllocationEx = assertThrows(AccountException.class, service::fetchAiKey); - var accountController = new com.checkba.controller.AccountController(null, null, null, null, null, null, null, null, null); + var accountController = new com.checkba.controller.AccountController(null, null, null, null, null, null, null, null, null, null); var keyController = new com.checkba.controller.PlatformAiKeyController(null, null); for (AccountException e : new AccountException[] {notConnectedEx, noAllocationEx}) { assertEquals(1, accountController.handleAccountException(e).getBody().get("code"), @@ -824,6 +840,163 @@ void teamSummaryCarriesScope() { transport.calls.get(2)); } + /** 文案红线:不得含「登录」「未授权」「请先」——api.js 用这三个子串判掉线并清会话。 */ + private static void assertNotMistakenForLogout(String message) { + assertNotNull(message); + assertFalse(message.contains("登录"), "文案不得含「登录」: " + message); + assertFalse(message.contains("未授权"), "文案不得含「未授权」: " + message); + assertFalse(message.contains("请先"), "文案不得含「请先」: " + message); + } + + // ==================== 个人档案与头像(spec 2026-09-10 §5) ==================== + + private static final String PROFILE_ME = + "{\"accountId\":\"acc_9f3a\",\"username\":\"upoxwcdtg\",\"displayName\":\"185****5325\"," + + "\"avatarUpdatedAt\":\"2026-09-10T03:04:05.000Z\",\"displayNameIsDefault\":true}"; + + @Test + @DisplayName("身份视图:一次 GET /me 拉齐,avatarUrl 按 accountId + 版本拼,**不回 username**") + void profileIdentityShape() { + AccountService service = connected(); + transport.enqueue(200, PROFILE_ME); + + Map view = service.profileIdentity(); + + assertEquals("GET https://www.aiworkdeck.com/api/account/me", transport.calls.get(1)); + assertEquals("acc_9f3a", view.get("accountId")); + assertEquals("185****5325", view.get("displayName")); + assertEquals("https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=2026-09-10T03%3A04%3A05.000Z", + view.get("avatarUrl")); + assertEquals(Boolean.TRUE, view.get("displayNameIsDefault")); + assertFalse(view.containsKey("username"), + "用户名退成内部标识,身份视图里一个字都不回(spec 2026-09-10 §2 裁决 5)"); + } + + @Test + @DisplayName("没传过头像:avatarUpdatedAt 为 null → avatarUrl 为 null,不拼一个必然 404 的地址") + void profileIdentityWithoutAvatar() { + AccountService service = connected(); + transport.enqueue(200, "{\"accountId\":\"acc_9f3a\",\"displayName\":\"韩泽伟\",\"avatarUpdatedAt\":null}"); + + Map view = service.profileIdentity(); + + assertNull(view.get("avatarUrl")); + assertEquals(Boolean.FALSE, view.get("displayNameIsDefault"), "缺字段按 false,不当成默认名去骚扰用户"); + } + + @Test + @DisplayName("改昵称:本机 PUT,出站到官网必须是 PATCH /api/account/profile(uni.request 没有 PATCH)") + void updateDisplayNameOutboundShape() { + AccountService service = connected(); + transport.enqueue(200, "{\"displayName\":\"韩泽伟\",\"bio\":null}"); + + Map res = service.updateDisplayName(" 韩泽伟 "); + + assertEquals("PATCH https://www.aiworkdeck.com/api/account/profile", transport.calls.get(1)); + assertTrue(transport.bodies.get(1).contains("韩泽伟"), transport.bodies.get(1)); + assertFalse(transport.bodies.get(1).contains(" 韩泽伟"), "首尾空白要去掉"); + assertEquals("韩泽伟", res.get("displayName")); + assertEquals("韩泽伟", service.status().get("displayName"), "account.json 里那份也要跟着刷新"); + } + + @Test + @DisplayName("昵称不合规:官网 400 invalid_display_name 要说人话,别折叠成「Key 无效」") + void invalidDisplayNameIsABusinessRejection() { + AccountService service = connected(); + transport.enqueue(400, "{\"error\":\"invalid_display_name\"}"); + + AccountException e = assertThrows(AccountException.class, () -> service.updateDisplayName("x".repeat(99))); + + assertEquals(AccountException.Kind.REJECTED, e.getKind()); + assertEquals("invalid_display_name", e.getReason()); + assertTrue(e.getMessage().contains("昵称"), e.getMessage()); + assertNotMistakenForLogout(e.getMessage()); + } + + @Test + @DisplayName("传头像:multipart 字段名固定 file,出站带 Bearer,回包给出新的 avatarUrl") + void uploadAvatarOutboundShape() { + AccountService service = connected(); + transport.enqueue(200, "{\"avatarUpdatedAt\":\"2026-09-10T09:00:00.000Z\"}"); + + Map res = service.uploadAvatar("PNGDATA".getBytes(java.nio.charset.StandardCharsets.UTF_8), + "me.png", "image/png"); + + assertEquals("POST https://www.aiworkdeck.com/api/account/avatar", transport.calls.get(1)); + assertEquals(KEY, transport.lastBearer); + assertEquals("file", transport.lastPart.fieldName(), "官网收的字段名就是 file"); + assertEquals("me.png", transport.lastPart.filename()); + assertEquals("image/png", transport.lastPart.contentType()); + assertEquals("2026-09-10T09:00:00.000Z", res.get("avatarUpdatedAt")); + assertEquals("https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=2026-09-10T09%3A00%3A00.000Z", + res.get("avatarUrl")); + } + + @Test + @DisplayName("老 account.json 里没有 accountId:补拉一次 /me,不把头像地址拼成半截") + void uploadAvatarBackfillsAccountIdForLegacyState() { + // 本次改造之前落的盘只有 key/username/displayName + transport = new StubTransport().enqueue(200, "{\"username\":\"hanzewei\",\"displayName\":\"韩泽伟\"}"); + AccountService service = service(); + service.connect(KEY); + transport.enqueue(200, "{\"avatarUpdatedAt\":\"2026-09-10T09:00:00.000Z\"}") + .enqueue(200, PROFILE_ME); + + Map res = service.uploadAvatar(new byte[]{1}, "me.png", "image/png"); + + assertEquals("GET https://www.aiworkdeck.com/api/account/me", transport.calls.get(2)); + assertEquals("https://www.aiworkdeck.com/api/avatar/acc_9f3a?v=2026-09-10T09%3A00%3A00.000Z", + res.get("avatarUrl")); + assertEquals("acc_9f3a", service.status().get("accountId"), "补拉到的 accountId 要落盘,下次不用再问"); + } + + @Test + @DisplayName("图片太大:官网 413 too_large 也要说人话") + void oversizedAvatarIsABusinessRejection() { + AccountService service = connected(); + transport.enqueue(413, "{\"error\":\"too_large\"}"); + + AccountException e = assertThrows(AccountException.class, + () -> service.uploadAvatar(new byte[]{1}, "big.png", "image/png")); + + assertEquals(AccountException.Kind.REJECTED, e.getKind()); + assertEquals("too_large", e.getReason()); + assertNotMistakenForLogout(e.getMessage()); + } + + @Test + @DisplayName("删头像:DELETE 到官网,回 avatarUpdatedAt=null / avatarUrl=null") + void deleteAvatarOutboundShape() { + AccountService service = connected(); + transport.enqueue(200, "{\"avatarUpdatedAt\":null}"); + + Map res = service.deleteAvatar(); + + assertEquals("DELETE https://www.aiworkdeck.com/api/account/avatar", transport.calls.get(1)); + assertTrue(res.containsKey("avatarUpdatedAt")); + assertNull(res.get("avatarUpdatedAt")); + assertTrue(res.containsKey("avatarUrl")); + assertNull(res.get("avatarUrl")); + } + + @Test + @DisplayName("未连接账户:四个动作一律 NOT_CONNECTED,一次出站都不发") + void profileActionsRequireAConnectedAccount() { + transport = new StubTransport(); + AccountService service = service(); + + assertEquals(AccountException.Kind.NOT_CONNECTED, + assertThrows(AccountException.class, service::profileIdentity).getKind()); + assertEquals(AccountException.Kind.NOT_CONNECTED, + assertThrows(AccountException.class, () -> service.updateDisplayName("张三")).getKind()); + assertEquals(AccountException.Kind.NOT_CONNECTED, + assertThrows(AccountException.class, + () -> service.uploadAvatar(new byte[]{1}, "a.png", "image/png")).getKind()); + assertEquals(AccountException.Kind.NOT_CONNECTED, + assertThrows(AccountException.class, service::deleteAvatar).getKind()); + assertTrue(transport.calls.isEmpty(), "未连接时不该有任何出站: " + transport.calls); + } + @Test @DisplayName("尾部斜杠归一化:不能拼出 //api/account/me") void trailingSlashStripped() { diff --git a/backend/src/test/java/com/checkba/service/account/AwdkLoginServiceTest.java b/backend/src/test/java/com/checkba/service/account/AwdkLoginServiceTest.java index 77a46842a..f42381ccd 100644 --- a/backend/src/test/java/com/checkba/service/account/AwdkLoginServiceTest.java +++ b/backend/src/test/java/com/checkba/service/account/AwdkLoginServiceTest.java @@ -286,6 +286,54 @@ void deletedUserGetsRecreated() { assertEquals(second.userId(), bindingsByAccountId.get("acc_9f3a").getUserId()); } + // ==================== 展示名随官网刷新(spec 2026-09-10 §4) ==================== + + @Test + @DisplayName("再次桥接:官网改过的展示名刷到本机行,username 不动") + void repeatLoginRefreshesDisplayNameFromWebsite() { + transport.enqueue(200, ME_OK) + .enqueue(200, "{\"accountId\":\"acc_9f3a\",\"username\":\"hanzewei\",\"displayName\":\"韩律师\"}"); + AwdkLoginService svc = service(true); + + AwdkLoginService.BridgeSession first = svc.login(KEY); + assertEquals("韩泽伟", usersById.get(first.userId()).getDisplayName()); + + AwdkLoginService.BridgeSession second = svc.login(KEY); + + assertEquals(first.userId(), second.userId(), "还是同一行,不许因为改名另建一个人"); + assertEquals("韩律师", usersById.get(second.userId()).getDisplayName()); + assertEquals("韩律师", second.displayName(), "回包里也得是新名字"); + assertEquals("awd_hanzewei", usersById.get(second.userId()).getUsername(), + "username 是内部标识,改名会断 /u/用户名 与 Skill 归属链接"); + } + + @Test + @DisplayName("官网展示名为空:保留本机已有的那份,不清成空白") + void blankWebsiteDisplayNameNeverClobbersLocal() { + transport.enqueue(200, ME_OK) + .enqueue(200, "{\"accountId\":\"acc_9f3a\",\"username\":\"hanzewei\",\"displayName\":\" \"}"); + AwdkLoginService svc = service(true); + + AwdkLoginService.BridgeSession first = svc.login(KEY); + AwdkLoginService.BridgeSession second = svc.login(KEY); + + assertEquals(first.userId(), second.userId()); + assertEquals("韩泽伟", usersById.get(second.userId()).getDisplayName()); + } + + @Test + @DisplayName("ensureBridgedUser 同享这条刷新:名录回来的展示名也要落到已有的那行") + void ensureBridgedUserRefreshesDisplayName() { + transport.enqueue(200, ME_OK); + AwdkLoginService svc = service(true); + AwdkLoginService.BridgeSession first = svc.login(KEY); + + User again = svc.ensureBridgedUser("acc_9f3a", "hanzewei", "韩律师", null); + + assertEquals(first.userId(), again.getId()); + assertEquals("韩律师", usersById.get(first.userId()).getDisplayName()); + } + // ==================== per-user 平台 AI key(2026-08-07) ==================== @Test diff --git a/backend/src/test/java/com/checkba/service/collab/CollaboratorAdmissionTest.java b/backend/src/test/java/com/checkba/service/collab/CollaboratorAdmissionTest.java index fec9771f8..627b25058 100644 --- a/backend/src/test/java/com/checkba/service/collab/CollaboratorAdmissionTest.java +++ b/backend/src/test/java/com/checkba/service/collab/CollaboratorAdmissionTest.java @@ -6,6 +6,7 @@ import com.checkba.model.entity.AccountBinding; import com.checkba.model.entity.User; import com.checkba.repository.AccountBindingRepository; +import com.checkba.service.UserService; import com.checkba.service.account.AwdkLoginService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -64,6 +65,7 @@ public DirectoryReply lookupByAccountId(String requesterAccountId, String candid private StubDirectory directory; private AccountBindingRepository bindingRepository; private AwdkLoginService awdkLoginService; + private UserService userService; private CollaboratorAdmission admission; @BeforeEach @@ -72,8 +74,9 @@ void setUp() { bindingRepository = mock(AccountBindingRepository.class); when(bindingRepository.findByUserId(anyLong())).thenReturn(Optional.empty()); awdkLoginService = mock(AwdkLoginService.class); + userService = mock(UserService.class); admission = new CollaboratorAdmission(directory, new SameFirmOrTeamPolicy(), - bindingRepository, awdkLoginService); + bindingRepository, awdkLoginService, userService); } private static User user(long id, String name) { @@ -151,6 +154,38 @@ void localUserWithoutBindingNeverGoesOut() { "没有官网身份的人不可能在任何团队里,理由要落在对方身上,不能让律师去查自己的团队"); } + @Test + @DisplayName("名录回查命中本地已有用户:顺手把官网的展示名刷到本机行(spec 2026-09-10 §4)") + void localUserDisplayNameIsRefreshedFromTheDirectoryReply() { + bind(REQUESTER, "acc-me"); + bind(CANDIDATE, "acc-9f"); + directory.reply = found("team-a", "firm-1", "team-b", "firm-1"); + User local = user(CANDIDATE, "awd_lisi"); + local.setDisplayName("138****8000"); // 手机号注册时官网自动生成的打码名 + when(userService.refreshDisplayNameFromWebsite(local, "李思")).thenAnswer(inv -> { + local.setDisplayName("李思"); + return local; + }); + + CollaboratorAdmission.Admission a = + admission.admit(Optional.of(local), "13800138000", REQUESTER); + + assertSame(local, a.user()); + assertEquals("李思", a.user().getDisplayName()); + verify(userService).refreshDisplayNameFromWebsite(local, "李思"); + } + + @Test + @DisplayName("没有官网绑定那一支:不出网,也就没有可刷新的展示名") + void localUserWithoutBindingIsNeverRefreshed() { + bind(REQUESTER, "acc-me"); + User local = user(CANDIDATE, "admin"); + + admission.admit(Optional.of(local), "admin", REQUESTER); + + verifyNoInteractions(userService); + } + // ==================== 本地没有账号 ==================== @Test @@ -226,7 +261,7 @@ void directoryFailureIsNotSwallowed() { } }; CollaboratorAdmission a = new CollaboratorAdmission(broken, new SameFirmOrTeamPolicy(), - bindingRepository, awdkLoginService); + bindingRepository, awdkLoginService, userService); assertThrows(DirectoryUnavailableException.class, () -> a.admit(Optional.empty(), "13800138000", REQUESTER)); @@ -237,7 +272,7 @@ void directoryFailureIsNotSwallowed() { @DisplayName("open 策略:官网找到就允许,照样预建桥接用户") void openPolicyAdmitsAnyoneTheDirectoryKnows() { CollaboratorAdmission open = new CollaboratorAdmission(directory, new OpenPolicy(), - bindingRepository, awdkLoginService); + bindingRepository, awdkLoginService, userService); directory.reply = found(null, null, null, null); User bridged = user(77L, "awd_lisi"); when(awdkLoginService.ensureBridgedUser(anyString(), any(), any(), any())).thenReturn(bridged); diff --git a/backend/src/test/java/com/checkba/version/VersionAuthorNameTest.java b/backend/src/test/java/com/checkba/version/VersionAuthorNameTest.java new file mode 100644 index 000000000..a3619bfd1 --- /dev/null +++ b/backend/src/test/java/com/checkba/version/VersionAuthorNameTest.java @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 北京京微资易科技有限公司 and AI WorkDeck contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package com.checkba.version; + +import com.checkba.controller.AuthController; +import com.checkba.model.entity.User; +import com.checkba.service.ProjectFileService; +import com.checkba.service.ProjectMemberService; +import com.checkba.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.*; + +/** + * 版本时间线的署名(spec 2026-09-10 §4):写进 Git 提交对象的作者名以前是 **username**, + * 手机号注册的同事在时间线与文档修订里就是一串 {@code awd_upoxwcdtg}。 + * 改成展示名,展示名为空才回落用户名。已写入的历史 authorName 不回填。 + */ +@ExtendWith(MockitoExtension.class) +class VersionAuthorNameTest { + + private static final long PROJECT = 7L; + private static final long USER = 1L; + + @Mock private ProjectRepoService repoService; + @Mock private WorkSessionService sessionService; + @Mock private ProjectMemberService projectMemberService; + @Mock private UserService userService; + @Mock private ProjectFileService projectFileService; + @Mock private com.checkba.service.telemetry.TelemetryService telemetryService; + @Mock private VersionLifecycleService lifecycleService; + + @InjectMocks private VersionController controller; + + @BeforeEach + void allowWrites() { + when(projectMemberService.hasReadPermission(PROJECT, USER)).thenReturn(true); + when(projectMemberService.hasWritePermission(PROJECT, USER)).thenReturn(true); + } + + private static User user(String username, String displayName) { + User u = new User(); + u.setId(USER); + u.setUsername(username); + u.setDisplayName(displayName); + return u; + } + + private void enableAs(User u) { + when(userService.getUserById(USER)).thenReturn(u); + try (MockedStatic auth = mockStatic(AuthController.class)) { + auth.when(() -> AuthController.getUserIdFromSession("sess")).thenReturn(USER); + controller.enable(PROJECT, "sess"); + } + } + + @Test + @DisplayName("署名用展示名,不用桥接自动生成的用户名") + void authorNameIsTheDisplayName() { + enableAs(user("awd_upoxwcdtg", "李思")); + verify(sessionService).enableVersionRecording(eq(PROJECT), eq("李思"), anyString()); + } + + @Test + @DisplayName("展示名为空:回落用户名,不留一条没有作者的记录") + void blankDisplayNameFallsBackToUsername() { + enableAs(user("awd_upoxwcdtg", " ")); + verify(sessionService).enableVersionRecording(eq(PROJECT), eq("awd_upoxwcdtg"), anyString()); + } +} diff --git a/docs/superpowers/specs/2026-09-10-identity-display-source-design.md b/docs/superpowers/specs/2026-09-10-identity-display-source-design.md new file mode 100644 index 000000000..bb378dff1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-identity-display-source-design.md @@ -0,0 +1,96 @@ +# 身份展示统一:展示名与头像以官网为唯一权威源,用户名退成内部标识(dev-board#564 #565 #566 #567) + +日期:2026-09-10。涉及官网仓 `aiworkdeck_website` 与本仓(案件库 server、桌面端后端与前端)。 + +## 1. 问题 + +手机号注册的官网账户自动生成:用户名 `u`+随机串(如 `upoxwcdtg`),展示名打码手机号(`185****5325`)。 +线上 25 个账户 14 个如此。现状: + +- 用户名两边都改不了;展示名只有官网账户页能改;头像官网能改、桌面端也能改但传到本机表,与官网无关。 +- 桌面端 `account.json` 与案件库 `awd_` 用户在连接/桥接那一刻抄一份展示名,之后永不刷新。 +- 同事看到的字段不一致:加人确认卡用展示名 + 官网头像(对);参与人列表 `displayName || username`、头像用本机那份(桥接用户没有); + **版本时间线作者与文档批注/修订作者直接写用户名**——手机号注册的同事在时间线和修订里就是一串随机字母。 + +## 2. 维护者裁决(2026-09-10) + +1. 官网的展示名与头像是**唯一权威源**,其他地方只读、随官网刷新。 +2. 案件库每次桥接、每次名录回查刷新展示名;参与人列表、版本作者、批注/修订作者一律展示名;头像统一按 accountId 取官网。 +3. 桌面端「个人设置」可改昵称、传头像,**写到官网**;官网补 Bearer awdk_ 版接口;桌面端本机头像上传只留给自建服务器。 +4. 手机号注册默认展示名仍是打码号时,官网与桌面端引导「填写你的姓名」。 +5. 用户名按 uid 处理:**不改名**(改名断 `/u/用户名` 与 Skill 归属链接)、**任何界面不再当名字显示**(含 `@用户名` 小字), + 用契约测试守住。用户分不清用户名与展示名,那就只给他看一个。 + +## 3. 官网侧契约(`aiworkdeck_website`) + +| 方法 | 路径 | 鉴权 | 变化 | +|---|---|---|---| +| GET | `/api/account/me` | Bearer awdk_ | 新增 `avatarUpdatedAt: string \| null`、`displayNameIsDefault: boolean` | +| PATCH | `/api/account/profile` | **Cookie 或 Bearer**(`resolveSessionOrKeyUser`) | body `{displayName?, bio?}` 不变;回 `{displayName, bio}` | +| POST | `/api/account/avatar` | Cookie 或 Bearer | multipart `file`,不变;回 `{avatarUpdatedAt}` | +| DELETE | `/api/account/avatar` | Cookie 或 Bearer | 回 `{avatarUpdatedAt: null}` | +| GET | `/api/avatar/{accountId}?v=` | 匿名 | 不变 | + +`displayNameIsDefault` 的判定放 `lib/users.ts` 单一函数 `isDefaultDisplayName(user)`:展示名为空、等于 `maskPhoneForName(phone)`、 +等于 `maskEmailForName(email)`、或等于 `username`(密码注册留空时的默认)之一。官网账户页 `ProfileCard` 在为真时顶部给一条 +「填写你的姓名,同事在案卷里看到的就是它」横幅,点开即现有 `ProfileDialog`。 + +`doc/desktop-contract.md` 与 `scripts/contract-check.mts` 同步:`/me` 两个新字段;profile / avatar 三个端点进契约并标「Cookie 或 Bearer」。 +verify 脚本:`verify-profile.mts` 加 Bearer 路径用例(PATCH 昵称、POST/DELETE 头像、`/me` 两字段、默认名判定的四种形态)。 + +用户名:官网侧**不新增改名接口**;`ProfileCard` 的 `@username · email` 一行去掉 `@username`(邮箱保留,手机号注册的邮箱为空时整行不显示); +公开主页 `/u/[username]` 的 `@handle` 小字去掉,URL 不动。 + +## 4. 案件库 server 侧(本仓 backend,profile `case`) + +- `AwdkLoginService.resolveUser`:binding 已存在时,若官网给的 `displayName` 非空且与本地不同,**更新本地 displayName**(username 不动)。 + `login`、`ensureBridgedUser` 同享。`CollaboratorAdmission` 命中本地已有用户走 `lookupByAccountId` 时,也用回包的 `account.displayName` 刷新 + (`DirectoryReply.account` 在 `found:true` 时总带)。 +- `ProjectMemberController.getMembers`(含 owner):`displayName` 照旧,`avatarUrl` 改用 `ProjectMemberService.avatarUrlFor(user)` + (本机有就本机,否则官网 `/api/avatar/{accountId}`),**`username` 字段保留一版**给老客户端,前端不再读。 +- `VersionController.userName(userId)`:改为 `displayName`,空则 `username`。已写入的历史 `authorName` 不回填。 +- `CloudSyncService.proxyMembers` 透传不改。 + +## 5. 桌面端后端(本仓 backend,local-mode) + +新增 `AccountProfileController`(或并入既有账户控制器,按 licensing-billing.md 的布局): + +| 方法 | 本机路径 | 出站到官网 | 说明 | +|---|---|---|---| +| GET | `/api/account/profile` | `GET /me` | 回 `{accountId, displayName, avatarUrl, displayNameIsDefault}`;`avatarUrl` = `{base}/api/avatar/{accountId}?v={avatarUpdatedAt}`,无头像为 null。**不回 username** | +| PUT | `/api/account/profile` | `PATCH /api/account/profile` | body `{displayName}`;uni.request 无 PATCH,本机用 PUT(同团队接口先例,`AccountServiceTest.teamHierarchyOutboundShape` 那条纪律) | +| POST | `/api/account/avatar` | `POST /api/account/avatar` | multipart 转发;2MB 上限沿用官网的错误码 | +| DELETE | `/api/account/avatar` | `DELETE /api/account/avatar` | | + +每次成功写入后、以及 **连接账户时与每次 `GET /api/account/status`(应用启动会拉)** 时,把官网 `displayName`/`avatarUrl` 同步到本机 `User` 行 +(`LocalIdentityService` 的「真实账号 displayName 一个字不动」纪律只针对本机 admin 改名,这里是随权威源刷新,在 licensing-billing.md 改写那条说明)。 +`account.json` 里的 `displayName` 一并刷新。未连接账户或官网不可达:不动本机行,不报错。 + +既有 `POST /api/users/avatar`(本机上传)保留给自建服务器(非 local-mode);local-mode 下前端不再调它。 + +## 6. 桌面端前端(本仓 frontend) + +- **个人设置**(`PersonalSettingsPanel.vue`「基本信息」):头像可点上传/删除,昵称可编辑(上限 24,与官网一致)。 + local-mode 且已连接账户 → 走 §5 四个端点;自建服务器 → 昵称不可编辑(服务器侧无接口,本期不做)、头像走既有 `POST /api/users/avatar`。 +- **姓名引导**:`displayNameIsDefault` 为真时,个人设置「基本信息」顶部与设置「账户」分区各给一条「填写你的姓名」内联提示, + 点了就聚焦昵称输入;首次连接账户成功后弹一次(`uni.showModal` 级别即可,可「稍后」)。 +- **用户名清零**:`AdminPane.vue` 侧栏用户卡去掉 `@username` 一行;`CollabDialog.vue` 参与人行 `m.displayName || m.username` 改为 + `m.displayName || $t(...)`(用既有的兜底文案键,找不到就加 `version.unnamedColleague`「同事」);`LibreOfficeEditor.currentAuthorName` + 改为 `displayName || nickname || name`,**不再回落 username**(空则回 `''`,让引擎用默认作者);`VersionTimeline.vue` 不改(吃后端 authorName)。 +- **契约测试** `frontend/tests/identity/display-name-only.test.mjs`:扫描 `src/**/*.vue`,凡模板插值或 `||` 回落里出现 `.username` + 即失败;允许名单只放登录表单输入、后台用户管理列表、以及 `AdminPane` 账户分区那一处(如仍需展示登录名给自建服务器用户)——名单写在测试旁的 + `display-name-only.allowlist.json`,每条带理由。 + +## 7. 顺序与发版 + +1. 官网 PR 先合(契约先落地、自动部署)。 +2. 本仓一个 PR 承载 §4 §5 §6;合并后案件库换 jar(§4 立刻生效)。桌面端改动随集中发版。 +3. 复测:手机号注册的同事被加进案卷后,参与人列表、时间线新条目、批注作者都显示展示名;官网改名后重开桌面端两处都跟着变; + 桌面端个人设置改昵称、传头像,官网账户页立刻一致;默认名时两端都看到「填写你的姓名」。 + +## 8. 非目标 + +- 不做用户名改名;不改 `/u/[username]` 路由。 +- 不回填历史版本条目与既有文档里的作者名。 +- 自建服务器(非 local-mode)用户的昵称编辑本期不做。 +- 不改手机号注册的默认生成规则本身(先引导填写,够用)。 diff --git a/frontend/package.json b/frontend/package.json index 8f287003a..6ad9b63c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -75,6 +75,7 @@ "test:capabilities": "node --test tests/capabilities/*.test.mjs", "test:team": "node --test tests/team/*.test.mjs", "test:member-invite": "node --test tests/member-invite/*.test.mjs", + "test:identity": "node --test tests/identity/*.test.mjs", "test:optional-components": "node --test tests/optional-components/*.test.mjs", "test:desensitize": "node --test tests/desensitize/*.test.mjs" }, diff --git a/frontend/src/components/LibreOfficeEditor.vue b/frontend/src/components/LibreOfficeEditor.vue index 7a711a7b3..3267450e1 100644 --- a/frontend/src/components/LibreOfficeEditor.vue +++ b/frontend/src/components/LibreOfficeEditor.vue @@ -161,12 +161,14 @@ import { guestPointToHost } from '@/utils/insightPopup.js' let seq = 0 -// 当前登录用户名。两个地方要用同一个串:随 load_document 传给引擎(用户本人的 +// 当前用户的展示名。两个地方要用同一个串:随 load_document 传给引擎(用户本人的 // 修订以此署名)与审阅面板的「我」这一桶(dev-board#377)。抽成一处,免得哪天 // 一边加了兜底另一边没加,用户自己的修订被归成「其他人」。 +// **不许回落 username**(Spec §6):手机号注册的用户名是 `u`+随机串,落进批注与修订 +// 就是永久的——历史条目不回填。取不到名字宁可给空串,让引擎用它自己的默认作者。 function currentAuthorName() { const u = getCurrentUser() || {} - return String(u.name || u.nickname || u.username || '') + return String(u.displayName || u.nickname || u.name || '') } export default { diff --git a/frontend/src/components/admin/AdminPane.vue b/frontend/src/components/admin/AdminPane.vue index 1c9b2d58c..aa7493b6c 100644 --- a/frontend/src/components/admin/AdminPane.vue +++ b/frontend/src/components/admin/AdminPane.vue @@ -21,7 +21,8 @@ + 头像可点:local-mode 且已连接账户时写官网(展示名与头像的唯一权威源), + 自建服务器仍走原来那条 uni.chooseImage + uploadAvatar。 --> {{ userInfo.displayName || $t('account.defaultUserName') }} - @{{ userInfo.username || userInfo.id || 'unknown' }} {{ $t('account.standardUserRole') }} @@ -391,17 +391,22 @@ {{ $t('admin.accountConnectedSubtitle') }} - + 唯一编排:摘账户连接,账户模式顺带清授权票据,回启动页重跑分流)。 + 用户名一行已按 Spec §6 去掉:它是 uid,不是名字,用户分不清两者。 -->