Skip to content

Commit e06523e

Browse files
zeweihanclaude
andauthored
fix(i18n): 本机用户中文名的两处快照式泄漏——变量库创建者与版本时间线署名(dev-board#351) (#688)
v0.30.0 修掉了直接读 User.displayName 的五处,但两处把名字**快照**写进了别处, 读时本地化那一招够不着: 1. 变量库创建者:建变量时把 displayName 抄进 project_variables.creator_name, 单机模式抄到的是中文哨兵「本机用户」,英文界面的变量面板照旧显示中文。 修法是出参侧只读视图 ProjectVariableController.VariableView。**刻意不就地改实体**: ProjectVariable 在 OSIV 会话里是受管对象,改它一旦被 Hibernate 脏检查刷回库, 库里就成了「Local user」,中文界面反过来看到英文,且不可逆。 2. 版本时间线署名:提交作者名随提交写进了 Git 对象,VersionTimeline/VersionNodeDetail 直接渲染它。历史永不重写、作者名还派生了提交邮箱,所以只在 ProjectRepoService.toEntry() 出参侧按界面语言替换,**写入侧和 Git 对象一字节没动**——改写入侧等于同一个人在 中英文界面下往版本库里留下两种署名。 两处都只认哨兵值精确相等,真实用户名(含云端协作方署名)原样透传;null 也原样传出, VariablePanel.vue 的 `creatorName || 'Project'` 回退不受影响。 刻意没动:WorkSessionService.email() 派生的 `本机用户@aiworkdeck.local`。VersionEntry 不带 email,UI 全链路读不到它,改了反而会把已有历史的作者身份劈成两半。 验证:新增 ProjectVariableViewTest(6 条,含「映射不得改动实体」与实体字段全覆盖护栏) 与 ProjectRepoHistoryTest 两条(同一仓库中/英/中读三遍,证明提交对象没被改写); 先做还原病灶对照——把两处 displayNameOf 摘掉后 3 条断言转红,锚点唯一性已校验。 backend 全量 mvn test 2967 通过 / 0 失败。 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent cb71301 commit e06523e

6 files changed

Lines changed: 254 additions & 4 deletions

File tree

.claude/agents/licensing-billing.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ description: 授权与计费领域。任务涉及解锁门(试用码/账户 Ke
4747
- `backend/src/main/java/com/checkba/config/LocalModeAccessFilter.java` — 免登模式的每请求准入闸(回环校验 + 反代痕迹拒绝 + 跨站 Origin 硬拦截)。
4848
- `backend/src/main/java/com/checkba/config/LocalModeLoopbackGuard.java` — 启动强不变式:local-mode 必须绑回环地址,否则拒绝启动。
4949
- `AuthController.getUserIdFromSession()` 在 local-mode 下把任何请求解析为本机用户(90 余处调用方一行未改)。
50+
- **「本机用户」是库里恒存的中文哨兵,界面语言只在读出来时替换**`LocalIdentityService.displayNameOf`
51+
v0.30.0 + dev-board#351)。库里存的值一个字都不动——改写入侧会让同一个人在中英文界面下留下两种身份。
52+
出口全集(新增读出口要跟着补,别再漏):`/api/auth/me``/api/local-identity/{status,candidates}`
53+
项目成员与 owner(`ProjectMemberController`)、项目列表 `managerName``ProjectService`)、
54+
变量库创建者(`ProjectVariableController.VariableView`)、版本时间线署名(`ProjectRepoService.toEntry`)。
55+
后两处是**快照**——名字被抄进了 project_variables / Git 提交对象,读时本地化是唯一够得着的修法。
56+
**不许就地改实体的 `creatorName`**:实体在 OSIV 会话里受管,一旦被脏检查刷回库,库里就成了
57+
「Local user」,中文界面反过来看到英文,且不可逆——所以那条路走的是只读视图 `VariableView`
5058

5159
**账户连接(PR-B)**
5260
- `backend/src/main/java/com/checkba/service/account/AccountService.java` — 与官网账户的唯一连接方式是 `awdk_` Key。

.claude/agents/version-control.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ description: 项目级版本记录领域。任务涉及版本记录/工作段(
154154

155155
`unionApply` 保留两参版本(`base` 隐式传 `null`,委托三参版本)——v1 语义原样不变,`TreeManifestSyncTest.unionApplyRestoresFromRecycleBinButNeverSendsAnActiveRowThere` 是它的护栏,护栏测试 `UnionReviveGuardTest``handDeletedFileSurvivesSessionEndMerge` 是被否决场景的直接回归;`draftCreatedFileStillRevivesOnAdopt` 是 v1 关键场景不回归的回归;`genuineReviveByPeerIsApplied`/`twoArgUnionApplyKeepsV1Semantics` 分别钉矩阵与两参路径)。
156156

157+
**时间线署名是读时本地化的,不是存储值**(dev-board#351):`ProjectRepoService.toEntry()`
158+
`c.getAuthorIdent().getName()` 过一道 `LocalIdentityService.displayNameOf` 再放进 `VersionEntry.authorName`
159+
单机模式的提交作者名就是库里那个中文哨兵「本机用户」,它随提交写进了 Git 对象;历史永不重写,
160+
作者名还派生了提交邮箱(`WorkSessionService.email`),所以**只能在出参侧换**,写入侧一字不动——
161+
否则同一个人在中英文界面下会往版本库里留下两种署名。真实用户名(含云端协作方)原样透传。
162+
护栏测试:`ProjectRepoHistoryTest.localUserAuthorIsLocalizedOnReadWithoutRewritingHistory`
163+
(同一个仓库中/英/中读三遍,值必须来回切得回去,证明提交对象没被改写)。
164+
157165
## 已知地雷
158166

159167
1. **历史永不重写**——硬不变量,理由与 Git 自己一致,为将来推云端仓库(v2)打基础。唯一例外是删除从未合并进主线的工作段分支(`discardSession`/`deleteBranch(force=true)`)。护栏测试:`RepoMaintenanceTest.gcPreservesEveryReachableVersion`,GC 前后逐条比对每个 `VersionEntry.sha()`

backend/src/main/java/com/checkba/controller/ProjectVariableController.java

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import com.checkba.model.entity.ProjectVariable;
44
import com.checkba.service.LangText;
5+
import com.checkba.service.LocalIdentityService;
56
import com.checkba.service.ProjectMemberService;
67
import com.checkba.service.ProjectVariableService;
78
import org.springframework.beans.factory.annotation.Autowired;
89
import org.springframework.http.ResponseEntity;
910
import org.springframework.web.bind.annotation.*;
1011

12+
import java.time.LocalDateTime;
1113
import java.util.List;
1214

1315
@RestController
@@ -30,12 +32,56 @@ private Long requireMember(String sessionId, Long projectId) {
3032
return userId;
3133
}
3234

35+
/**
36+
* 变量的对外形态。字段与 {@link ProjectVariable} 逐一对应,JSON 形状一字不变。
37+
*
38+
* <p>为什么不直接回实体:{@code creatorName} 是**写时快照**——建变量那一刻把
39+
* {@code User.displayName} 抄了一份存进 project_variables,单机模式下抄到的就是中文
40+
* 哨兵「本机用户」,英文界面照样显示中文(dev-board#351)。同族的读时本地化那一招
41+
* ({@code LocalIdentityService.displayNameOf})要落在这个字段上,而实体在 OSIV 会话里
42+
* 是受管对象:就地改它一旦被 Hibernate 脏检查刷回库,库里存的就成了「Local user」,
43+
* 中文界面反过来看到英文,是不可逆的数据污染。多一个只读视图,改动只落在出参上。
44+
* 同族已修的另外几处(/api/auth/me、项目成员、项目列表 managerName)本来就是往新
45+
* 对象里 put,天然没有这个问题——这里补齐的是唯一一处直接回实体的。
46+
*/
47+
public record VariableView(
48+
Long id,
49+
Long projectId,
50+
String name,
51+
String value,
52+
String variableGroup,
53+
String resolvedValue,
54+
String type,
55+
Long creatorId,
56+
String creatorName,
57+
LocalDateTime createdAt,
58+
LocalDateTime updatedAt) {
59+
60+
public static VariableView of(ProjectVariable v) {
61+
return new VariableView(
62+
v.getId(),
63+
v.getProjectId(),
64+
v.getName(),
65+
v.getValue(),
66+
v.getVariableGroup(),
67+
v.getResolvedValue(),
68+
v.getType(),
69+
v.getCreatorId(),
70+
// 库里恒存中文哨兵,按当前界面语言替换;真实用户名一个字都不动
71+
LocalIdentityService.displayNameOf(v.getCreatorName()),
72+
v.getCreatedAt(),
73+
v.getUpdatedAt());
74+
}
75+
}
76+
3377
@GetMapping("/project/{projectId}")
34-
public ResponseEntity<List<ProjectVariable>> getVariables(
78+
public ResponseEntity<List<VariableView>> getVariables(
3579
@PathVariable Long projectId,
3680
@RequestHeader(value = "X-Session-Id", required = false) String sessionId) {
3781
requireMember(sessionId, projectId);
38-
return ResponseEntity.ok(service.getVariablesByProject(projectId));
82+
return ResponseEntity.ok(service.getVariablesByProject(projectId).stream()
83+
.map(VariableView::of)
84+
.toList());
3985
}
4086

4187
@PostMapping
@@ -49,7 +95,7 @@ public ResponseEntity<?> saveVariable(
4995
String username = AuthController.getUsernameFromSession(sessionId);
5096
variable.setCreatorName(username != null ? username : "Unknown");
5197
}
52-
return ResponseEntity.ok(service.createOrUpdateVariable(variable));
98+
return ResponseEntity.ok(VariableView.of(service.createOrUpdateVariable(variable)));
5399
}
54100

55101
@DeleteMapping("/{id}")

backend/src/main/java/com/checkba/version/ProjectRepoService.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.checkba.version;
22

3+
import com.checkba.service.LocalIdentityService;
34
import com.checkba.storage.ProjectStorageResolver;
45
import org.eclipse.jgit.api.CreateBranchCommand;
56
import org.eclipse.jgit.api.DiffCommand;
@@ -364,7 +365,13 @@ private VersionEntry toEntry(RevCommit c, Map<String, String> milestones) {
364365
return new VersionEntry(
365366
c.getName(),
366367
c.getShortMessage(),
367-
c.getAuthorIdent().getName(),
368+
// 提交作者名是**写进 Git 历史的快照**,读时本地化只能落在出参上:历史永不
369+
// 重写,作者名还派生了提交邮箱(WorkSessionService.email),改写入侧等于同一个
370+
// 人在中英文界面下留下两种署名,把版本库里的身份劈成两半。单机模式下作者名
371+
// 就是库里那个中文哨兵「本机用户」,英文界面的时间线会照原样显示中文
372+
// (dev-board#351);这里按当前界面语言替换后再交给 UI,真实用户名(含云端
373+
// 协作方的署名)一个字都不动,Git 对象一字节都没碰。
374+
LocalIdentityService.displayNameOf(c.getAuthorIdent().getName()),
368375
Instant.ofEpochSecond(c.getCommitTime()),
369376
kind == null ? "auto" : kind,
370377
note,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.checkba.controller;
2+
3+
import com.checkba.model.entity.ProjectVariable;
4+
import com.checkba.service.AppLanguageService;
5+
import com.checkba.service.LangText;
6+
import com.checkba.service.LocalIdentityService;
7+
import org.junit.jupiter.api.AfterEach;
8+
import org.junit.jupiter.api.Test;
9+
10+
import java.lang.reflect.Field;
11+
import java.lang.reflect.Modifier;
12+
import java.lang.reflect.RecordComponent;
13+
import java.time.LocalDateTime;
14+
import java.util.Arrays;
15+
import java.util.Set;
16+
import java.util.stream.Collectors;
17+
18+
import static org.junit.jupiter.api.Assertions.*;
19+
import static org.mockito.Mockito.mock;
20+
import static org.mockito.Mockito.when;
21+
22+
/**
23+
* 变量库创建者名的读时本地化(dev-board#351)。
24+
*
25+
* <p>{@code ProjectVariable.creatorName} 是写时快照:建变量那一刻把 User.displayName 抄进
26+
* project_variables,单机模式下抄到的是中文哨兵「本机用户」。v0.30.0 的读时本地化只覆盖了
27+
* 直接读 User 的五处,够不着这张表,英文界面的变量面板照旧显示中文。修法是出参侧映射,
28+
* **不碰库里存的值**——这也是本测试最后两条断言守的东西。
29+
*/
30+
class ProjectVariableViewTest {
31+
32+
@AfterEach
33+
void resetLangText() {
34+
LangText.reset();
35+
}
36+
37+
private ProjectVariable localUserVariable() {
38+
ProjectVariable v = new ProjectVariable();
39+
v.setId(11L);
40+
v.setProjectId(7L);
41+
v.setName("甲方");
42+
v.setValue("某某科技有限公司");
43+
v.setVariableGroup("当事人");
44+
v.setResolvedValue("某某科技有限公司");
45+
v.setType("TEXT");
46+
v.setCreatorId(3L);
47+
v.setCreatorName(LocalIdentityService.LOCAL_DISPLAY_NAME);
48+
v.setCreatedAt(LocalDateTime.of(2026, 9, 1, 10, 0));
49+
v.setUpdatedAt(LocalDateTime.of(2026, 9, 1, 11, 0));
50+
return v;
51+
}
52+
53+
private void useEnglish() {
54+
AppLanguageService en = mock(AppLanguageService.class);
55+
when(en.isEnglish()).thenReturn(true);
56+
LangText.register(en);
57+
}
58+
59+
@Test
60+
void sentinelCreatorIsLocalizedInEnglishUi() {
61+
useEnglish();
62+
assertEquals("Local user",
63+
ProjectVariableController.VariableView.of(localUserVariable()).creatorName());
64+
}
65+
66+
@Test
67+
void sentinelCreatorStaysChineseInChineseUi() {
68+
LangText.reset(); // 未登记 = 中文,与既有默认态一致
69+
assertEquals(LocalIdentityService.LOCAL_DISPLAY_NAME,
70+
ProjectVariableController.VariableView.of(localUserVariable()).creatorName());
71+
}
72+
73+
@Test
74+
void realCreatorNamePassesThroughEvenInEnglishUi() {
75+
useEnglish();
76+
ProjectVariable v = localUserVariable();
77+
v.setCreatorName("韩泽伟");
78+
assertEquals("韩泽伟", ProjectVariableController.VariableView.of(v).creatorName());
79+
}
80+
81+
@Test
82+
void nullCreatorNameSurvivesSoFrontendFallbackStillFires() {
83+
// VariablePanel.vue 的 `it.creatorName || 'Project'` 回退依赖 null 原样传出
84+
useEnglish();
85+
ProjectVariable v = localUserVariable();
86+
v.setCreatorName(null);
87+
assertNull(ProjectVariableController.VariableView.of(v).creatorName());
88+
}
89+
90+
/** 本地化只发生在出参上:实体一个字段都不许被改(改了会被 OSIV 会话刷回库)。 */
91+
@Test
92+
void mappingDoesNotMutateTheEntity() {
93+
useEnglish();
94+
ProjectVariable v = localUserVariable();
95+
96+
ProjectVariableController.VariableView view = ProjectVariableController.VariableView.of(v);
97+
98+
assertEquals("Local user", view.creatorName());
99+
assertEquals(LocalIdentityService.LOCAL_DISPLAY_NAME, v.getCreatorName(),
100+
"实体的 creatorName 被就地改写了——Hibernate 脏检查会把英文刷回库,中文界面反过来看到英文");
101+
}
102+
103+
/** 出参逐字段照抄,且新加的实体字段不能被这层视图静默吞掉。 */
104+
@Test
105+
void viewCarriesEveryEntityField() {
106+
ProjectVariable v = localUserVariable();
107+
ProjectVariableController.VariableView view = ProjectVariableController.VariableView.of(v);
108+
109+
assertEquals(v.getId(), view.id());
110+
assertEquals(v.getProjectId(), view.projectId());
111+
assertEquals(v.getName(), view.name());
112+
assertEquals(v.getValue(), view.value());
113+
assertEquals(v.getVariableGroup(), view.variableGroup());
114+
assertEquals(v.getResolvedValue(), view.resolvedValue());
115+
assertEquals(v.getType(), view.type());
116+
assertEquals(v.getCreatorId(), view.creatorId());
117+
assertEquals(v.getCreatedAt(), view.createdAt());
118+
assertEquals(v.getUpdatedAt(), view.updatedAt());
119+
120+
Set<String> viewFields = Arrays.stream(ProjectVariableController.VariableView.class.getRecordComponents())
121+
.map(RecordComponent::getName)
122+
.collect(Collectors.toSet());
123+
Set<String> entityFields = Arrays.stream(ProjectVariable.class.getDeclaredFields())
124+
.filter(f -> !f.isSynthetic() && !Modifier.isStatic(f.getModifiers()))
125+
.map(Field::getName)
126+
.collect(Collectors.toSet());
127+
assertEquals(entityFields, viewFields,
128+
"ProjectVariable 加了新字段就要同步加进 VariableView,否则接口静默少一个字段");
129+
}
130+
}

backend/src/test/java/com/checkba/version/ProjectRepoHistoryTest.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.checkba.version;
22

3+
import com.checkba.service.AppLanguageService;
4+
import com.checkba.service.LangText;
5+
import com.checkba.service.LocalIdentityService;
36
import com.checkba.storage.StorageProperties;
7+
import org.junit.jupiter.api.AfterEach;
48
import org.junit.jupiter.api.Test;
59
import org.junit.jupiter.api.io.TempDir;
610

@@ -10,6 +14,8 @@
1014
import java.util.List;
1115

1216
import static org.junit.jupiter.api.Assertions.*;
17+
import static org.mockito.Mockito.mock;
18+
import static org.mockito.Mockito.when;
1319

1420
class ProjectRepoHistoryTest {
1521

@@ -172,4 +178,49 @@ void readBlobAtCommitReturnsHistoricBytes(@TempDir Path root) throws Exception {
172178
assertEquals("初稿", new String(old, StandardCharsets.UTF_8));
173179
assertNull(s.readBlobAtCommit(7L, first, "不存在.txt"));
174180
}
181+
182+
// ==================== 时间线署名的读时本地化(dev-board#351)====================
183+
// 单机模式的提交作者名就是库里那个中文哨兵「本机用户」,它随提交写进了 Git 历史。
184+
// 历史永不重写,作者名还派生了提交邮箱,所以只能在**读出来交给 UI 时**按界面语言换,
185+
// Git 对象一字节都不许动——下面两条用同一个仓库分别以中/英文读一遍来钉死这一点。
186+
187+
@AfterEach
188+
void resetLangText() {
189+
LangText.reset();
190+
}
191+
192+
private void useEnglish() {
193+
AppLanguageService en = mock(AppLanguageService.class);
194+
when(en.isEnglish()).thenReturn(true);
195+
LangText.register(en);
196+
}
197+
198+
@Test
199+
void localUserAuthorIsLocalizedOnReadWithoutRewritingHistory(@TempDir Path root) throws Exception {
200+
Files.createDirectories(root.resolve("projects/7"));
201+
Files.writeString(root.resolve("projects/7/合同.txt"), "初稿");
202+
ProjectRepoService s = svc(root);
203+
// 提交侧照旧写哨兵值:写入语义不动,历史里永远是同一个署名
204+
s.init(7L, LocalIdentityService.LOCAL_DISPLAY_NAME, "本机用户@aiworkdeck.local");
205+
206+
LangText.reset();
207+
assertEquals(LocalIdentityService.LOCAL_DISPLAY_NAME, s.log(7L, "HEAD", 1).get(0).authorName(),
208+
"中文界面下时间线署名应保持哨兵原值");
209+
210+
useEnglish();
211+
assertEquals("Local user", s.log(7L, "HEAD", 1).get(0).authorName(),
212+
"英文界面下时间线署名应本地化");
213+
214+
LangText.reset();
215+
assertEquals(LocalIdentityService.LOCAL_DISPLAY_NAME, s.log(7L, "HEAD", 1).get(0).authorName(),
216+
"切回中文又变了 = 提交对象被改写了,本地化必须只作用于出参");
217+
}
218+
219+
@Test
220+
void realAuthorNameIsNeverTouchedEvenInEnglishUi(@TempDir Path root) throws Exception {
221+
ProjectRepoService s = seeded(root); // 作者是真实姓名「韩泽伟」
222+
useEnglish();
223+
assertEquals("韩泽伟", s.log(7L, "HEAD", 1).get(0).authorName(),
224+
"云端协作方的真实署名在英文界面下也一个字都不能动");
225+
}
175226
}

0 commit comments

Comments
 (0)