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