From 5428ab9e0b49bac572a19164bb9a50a39911e09f Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 7 Oct 2026 11:36:28 +0800 Subject: [PATCH] fix(messages): sync and display real peer nicknames --- AGENTS.md | 2 +- browser_gateway/platform/douyin.py | 28 +++++- browser_gateway/test_private_messages.py | 37 +++++++- internal/controlplane/api/private_messages.go | 5 + .../api/private_messages_worker_test.go | 29 +++++- internal/creator/private_messages.go | 59 ++++++++++-- internal/creator/private_messages_test.go | 93 ++++++++++++++++++- web/src/pages/creator/messages/index.tsx | 4 +- web/tests/private-messages-page.test.cjs | 17 +++- 9 files changed, 247 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68dd197..e0df833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ 事件聚合资料:互动用户展示真实昵称与 UID,以平台返回的 secUID 链接主页,不使用数字 UID 猜测主页;发生时间在前、接收时间紧邻其后,所属账号及筛选选项仅显示昵称。对应作品展示 48px 小封面并链接抖音作品详情,图文使用 note 地址;缺封面保留作品入口,缺资料明确标示。通知中的作品封面复用 `<作者 UID>/<作品 ID>.<图片扩展名>` 本地缓存,不创建占位账号或作品,不回退展示远程图片。历史核对可补齐已保存事件的用户与作品资料,但只更新同一互动 UID、同一作品 ID 的资料,不改原事件时间、内容、历史标记或去重规则;封面下载失败单独记录和展示,不能阻断通知保存,也不能伪装成已缓存。 -私信管理:仅支持已开启监听的自有抖音账号,独立聊天标签共享原浏览器资料,不干扰采集或主页面登录。展示聊天客户端已加载会话的最近 50 条消息,不宣称完整历史;只支持手动发送文字,不自动回复、不群发。聊天界面采用 Ant Design X Conversations、Bubble.List 和 Sender:左侧合并联系人并明确所属账号,同一联系人在不同账号下为独立会话,草稿按账号与联系人隔离。专用标签从已登录的个人中心“消息”入口进入,沿用现有登录,不增加私信扫码;直接 /chat 的二维码不能作为账号未登录的依据。仅以聊天客户端的实际 UID 与已绑定 UID 一致确认私信身份,不创建账号或改变绑定。发送请求先持久化并按请求 ID 去重;超时、重启中断及 SDK 网络错误 1008 标记“结果未确认”,不自动重发,不伪造送达或已读。同步记录须持久化后推进检查点;关闭监听阻止旧代次写入。 +私信管理:仅支持已开启监听的自有抖音账号,独立聊天标签共享原浏览器资料,不干扰采集或主页面登录。展示聊天客户端已加载会话的最近 50 条消息,不宣称完整历史;只支持手动发送文字,不自动回复、不群发。聊天界面采用 Ant Design X Conversations、Bubble.List 和 Sender:左侧合并联系人并明确所属账号,同一联系人在不同账号下为独立会话,草稿按账号与联系人隔离。会话列表与消息区联系人展示抖音用户资料中的真实昵称,不用 UID 或会话标题占位;未获取昵称显示“昵称未获取”。昵称独立于新消息同步,已有会话可补齐及更新昵称;核验资料 UID 与会话对方一致,读取失败明确显示错误但不阻断消息保存,不改发送对象或聊天记录。专用标签从已登录的个人中心“消息”入口进入,沿用现有登录,不增加私信扫码;直接 /chat 的二维码不能作为账号未登录的依据。仅以聊天客户端的实际 UID 与已绑定 UID 一致确认私信身份,不创建账号或改变绑定。发送请求先持久化并按请求 ID 去重;超时、重启中断及 SDK 网络错误 1008 标记“结果未确认”,不自动重发,不伪造送达或已读。同步记录须持久化后推进检查点;关闭监听阻止旧代次写入。 评论聚合:独立页面通过“我的作品评论”“竞品作品评论”两个 TAB 严格区分来源,各 TAB 保留筛选与页码。列表展示评论发布时间、评论者、内容、所属账号名称及对应作品的小封面;所属账号列与筛选下拉均不展示 UID,缺名仅显示前端“未命名账号”,评论者显示不变;作品使用本地封面,点击在新标签页打开抖音作品页面,缺图明确提示,不展示作品标题、不使用远程图片兜底。支持所属账号筛选,以及最近 1/6/12 小时、1/3/5/7 天筛选,默认最近 1 天。按评论发布时间计算范围,最新评论在前,同时间按评论 ID 倒序;未记录发布时间、未来时间及未采集评论不参与。筛选先作用于全部已采集评论再分页,条件或每页数量变化回到第一页;只读展示,不新增采集机制。 diff --git a/browser_gateway/platform/douyin.py b/browser_gateway/platform/douyin.py index 07dd670..8f00e2a 100644 --- a/browser_gateway/platform/douyin.py +++ b/browser_gateway/platform/douyin.py @@ -1349,6 +1349,30 @@ INBOX_SCRIPT = r"""(async()=>{ for(const conversation of conversations){ const peer=String(conversation.toParticipantUserId||""); if(!/^[1-9][0-9]{0,19}$/.test(peer)||peer===expected)fail("CONVERSATION_IDENTITY_INVALID"); + } + let cache=window.__creatorhubPrivatePeerProfiles; + if(!cache||cache.sdk!==sdk||cache.uid!==expected){cache={sdk,uid:expected,profiles:new Map()};window.__creatorhubPrivatePeerProfiles=cache;} + const peerIds=[...new Set(conversations.map(c=>String(c.toParticipantUserId)))]; + const peers=await Promise.all(peerIds.map(async peer=>{ + const saved=cache.profiles.get(peer);if(saved&&saved.expiresAt>Date.now())return saved.value; + let value; + try{ + const qs=new URLSearchParams({device_platform:'webapp',aid:'6383',channel:'channel_pc_web',user_id:peer}); + const response=await fetch(location.origin+'/aweme/v1/web/user/profile/other/?'+qs,{credentials:'include',signal:AbortSignal.timeout(10000)}); + if(!response.ok)throw Error('昵称读取 HTTP '+response.status); + const data=await response.json();if(data.status_code!==0)throw Error('昵称读取平台错误 '+data.status_code); + const user=data.user; + if(!user||String(user.uid||'')!==peer)throw Error('昵称资料 UID 与会话对方不一致'); + if(typeof user.nickname!=='string'||!user.nickname.trim())throw Error('昵称资料缺少真实昵称'); + value={peer_uid:peer,peer_name:user.nickname.trim(),error:''}; + }catch(error){value={peer_uid:peer,peer_name:'',error:'私信昵称读取失败(UID '+peer+'):'+String(error)};} + cache.profiles.set(peer,{value,expiresAt:Date.now()+(value.error?30000:300000)}); + return value; + })); + if(String(sdk.ctx?.option?.userId||'')!==expected)fail('ACCOUNT_IDENTITY_MISMATCH'); + const peerNames=new Map(peers.map(peer=>[peer.peer_uid,peer.peer_name])); + for(const conversation of conversations){ + const peer=String(conversation.toParticipantUserId); const latest=String(conversation.lastMessage?.serverId||"");next[peer]=latest; if(latest&&checkpoints[peer]===latest)continue; const list=conversation.getMessageList();if(!Array.isArray(list))fail("MESSAGE_HISTORY_INVALID"); @@ -1358,10 +1382,10 @@ INBOX_SCRIPT = r"""(async()=>{ const sender=String(m.sender||m.ext?.["s:sender_uid"]||""); if(sender!==peer&&sender!==expected)fail("MESSAGE_IDENTITY_INVALID"); const rawTime=m.ext?.["s:server_message_create_time"]; - messages.push({peer_uid:peer,peer_name:String(conversation.coreInfo?.name||""),server_id:server,client_id:String(m.clientId||""),sender_uid:sender,message_type:String(m.type),content:m.content,created_at:/^[0-9]+$/.test(String(rawTime||""))?String(rawTime):null}); + messages.push({peer_uid:peer,peer_name:peerNames.get(peer),server_id:server,client_id:String(m.clientId||""),sender_uid:sender,message_type:String(m.type),content:m.content,created_at:/^[0-9]+$/.test(String(rawTime||""))?String(rawTime):null}); } } - return {status:"succeeded",account_uid:expected,messages,checkpoints:next}; + return {status:"succeeded",account_uid:expected,messages,peers,checkpoints:next}; }catch(e){ if(mode==="identity"&&e.message==="IM_SDK_NOT_READY")return {status:"manual_login",reason:"awaiting_login"}; return {status:"failed",code:String(e.message||"IM_INBOX_FAILED")}; diff --git a/browser_gateway/test_private_messages.py b/browser_gateway/test_private_messages.py index 020595b..68bd63d 100644 --- a/browser_gateway/test_private_messages.py +++ b/browser_gateway/test_private_messages.py @@ -106,7 +106,7 @@ assert.equal((await vm.runInNewContext(SOURCE,context)).ready,true);assert.equal self.assertIn(text, script) self.assertNotIn('getMessagesByConversation', script) self.assertNotIn('sendMessage(', script) - self.assertNotIn('Date.now()', script) + self.assertNotIn('created_at:Date.now()', script) self.assertIn('123', script) def test_sdk_network_error_does_not_claim_definitive_rejection(self): @@ -135,16 +135,18 @@ const vm=require('node:vm'),assert=require('node:assert/strict'); const source=SOURCE; let seen=0; const message={type:7,serverId:'100',sender:'456',content:'{"aweType":700,"text":"你好"}',ext:{'s:server_message_create_time':'1700000000000'}}; -const conversation={type:1,toParticipantUserId:'456',lastMessage:message,coreInfo:{name:'好友'},getMessageList(){seen++;return [message]}}; +const conversation={type:1,toParticipantUserId:'456',lastMessage:message,coreInfo:{name:'456'},getMessageList(){seen++;return [message]}}; const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation]}; const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:{imSdkManager:{getImSdkInstance:()=>sdk}}}}}}}}; const chunks=[];chunks.push=function(args){args[2](req)}; -const context={location:{origin:'https://www.douyin.com'},fetch:async()=>{throw Error('inbox polling must not make repeated profile requests')},AbortSignal:{timeout:()=>null},crypto:{randomUUID:()=> 'id'},window:{'@pc-im/im:version':chunks}}; +let profileCalls=0; +const context={URLSearchParams,location:{origin:'https://www.douyin.com'},fetch:async url=>{profileCalls++;assert.ok(url.includes('/user/profile/other/'));assert.ok(url.includes('user_id=456'));return {ok:true,json:async()=>({status_code:0,user:{uid:'456',nickname:'真实昵称'}})}},AbortSignal:{timeout:()=>null},crypto:{randomUUID:()=> 'id'},window:{'@pc-im/im:version':chunks}}; (async()=>{ let result=await vm.runInNewContext(source,context); assert.equal(result.status,'succeeded');assert.equal(result.messages[0].server_id,'100');assert.equal(result.checkpoints['456'],'100');assert.equal(seen,1); + assert.equal(result.messages[0].peer_name,'真实昵称');assert.equal(result.peers[0].peer_name,'真实昵称');assert.equal(profileCalls,1); result=await vm.runInNewContext(source.replace('const expected="123", checkpoints={}', 'const expected="123", checkpoints={"456":"100"}'),context); - assert.equal(result.messages.length,0);assert.equal(seen,1); + assert.equal(result.messages.length,0);assert.equal(seen,1);assert.equal(profileCalls,1);assert.equal(result.peers[0].peer_name,'真实昵称'); sdk.ctx.option.userId='999';result=await vm.runInNewContext(source,context);assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH');sdk.ctx.option.userId='123'; message.sender='777';result=await vm.runInNewContext(source,context);assert.equal(result.code,'MESSAGE_IDENTITY_INVALID'); conversation.toParticipantUserId='bad';result=await vm.runInNewContext(source,context);assert.equal(result.code,'CONVERSATION_IDENTITY_INVALID'); @@ -154,6 +156,33 @@ const context={location:{origin:'https://www.douyin.com'},fetch:async()=>{throw result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10) self.assertEqual(result.returncode,0,result.stderr) + def test_inbox_profiles_refresh_without_messages_and_report_failures(self): + script = ''' +const vm=require('node:vm'),assert=require('node:assert/strict'); +const source=SOURCE;let now=1000,calls=0,profile={uid:'456',nickname:'初始昵称'},status=0,ok=true,network=false; +const conversation={type:1,toParticipantUserId:'456',lastMessage:{serverId:'100'},coreInfo:{name:'不是用户昵称'},getMessageList(){throw Error('unchanged checkpoint must not read messages')}}; +const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation]}; +const service={imSdkManager:{getImSdkInstance:()=>sdk}}; +const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:service}}}}}}; +const chunks=[];chunks.push=function(args){args[2](req)}; +const context={URLSearchParams,location:{origin:'https://www.douyin.com'},fetch:async()=>{calls++;if(network)throw Error('offline');return {ok,status:ok?200:503,json:async()=>({status_code:status,user:profile})}},AbortSignal:{timeout:()=>null},crypto:{randomUUID:()=> 'id'},Date:{now:()=>now},window:{'@pc-im/im:version':chunks}}; +const read=()=>vm.runInNewContext(source,context); +(async()=>{ + let result=await read();assert.equal(result.messages.length,0);assert.equal(result.peers[0].peer_name,'初始昵称');assert.equal(calls,1); + profile.nickname='改名后的昵称';now+=300001;result=await read();assert.equal(result.peers[0].peer_name,'改名后的昵称');assert.equal(calls,2); + for(const failure of ['mismatch','missing','http','business','network']){ + now+=300001;profile={uid:failure==='mismatch'?'789':'456',nickname:failure==='missing'?'':'错误昵称'};ok=failure!=='http';status=failure==='business'?8:0;network=failure==='network'; + result=await read();assert.equal(result.status,'succeeded');assert.equal(result.messages.length,0);assert.equal(result.peers[0].peer_name,'');assert.match(result.peers[0].error,/昵称/); + const count=calls;await read();assert.equal(calls,count); + } + profile={uid:'456',nickname:'恢复昵称'};network=false;status=0;ok=true;now+=30001;result=await read();assert.equal(result.peers[0].peer_name,'恢复昵称');assert.equal(result.peers[0].error,''); + context.fetch=async()=>{sdk.ctx.option.userId='999';return {ok:true,json:async()=>({status_code:0,user:{uid:'456',nickname:'不可保存'}})}}; + now+=300001;result=await read();assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH'); +})().catch(e=>{console.error(e);process.exitCode=1}); +'''.replace('SOURCE',json.dumps(inbox_expression('123', {'456':'100'}))) + result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10) + self.assertEqual(result.returncode,0,result.stderr) + def test_action_ownership_write_returns_a_cdp_value(self): browser=object.__new__(DouyinBrowser) browser._evaluate=Mock() diff --git a/internal/controlplane/api/private_messages.go b/internal/controlplane/api/private_messages.go index 79646a3..2b44ca6 100644 --- a/internal/controlplane/api/private_messages.go +++ b/internal/controlplane/api/private_messages.go @@ -269,5 +269,10 @@ func syncPrivateInbox(ctx context.Context, accounts HubStore, store *creator.Sto if err := store.SavePrivateInbox(ctx, listener.AccountID, listener.Generation, items); err != nil { return nil, err } + for _, peer := range items.Peers { + if peer.Error != "" { + logrus.WithFields(logrus.Fields{"account_id": listener.AccountID, "generation": listener.Generation, "peer_uid": peer.PeerUID, "reason": peer.Error}).Warn("private message peer nickname lookup failed") + } + } return response.Checkpoints, nil } diff --git a/internal/controlplane/api/private_messages_worker_test.go b/internal/controlplane/api/private_messages_worker_test.go index 16107b0..c7faed1 100644 --- a/internal/controlplane/api/private_messages_worker_test.go +++ b/internal/controlplane/api/private_messages_worker_test.go @@ -65,7 +65,15 @@ func TestPrivateGatewaySendAndSync(t *testing.T) { w.Write([]byte(`{"status":"failed","code":"IM_SDK_NOT_READY"}`)) return } - w.Write([]byte(`{"status":"succeeded","account_uid":"123","messages":[{"peer_uid":"456","server_id":"101","sender_uid":"456","message_type":"7","content":"{\"aweType\":700,\"text\":\"你好\"}","created_at":"1700000000000"}],"checkpoints":{"456":"101"}}`)) + if mode.Load() == 4 { + w.Write([]byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[{"peer_uid":"456","peer_name":"改名后昵称","error":""}],"checkpoints":{"456":"101"}}`)) + return + } + if mode.Load() == 5 { + w.Write([]byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[{"peer_uid":"456","peer_name":"","error":"昵称读取失败"}],"checkpoints":{"456":"101"}}`)) + return + } + w.Write([]byte(`{"status":"succeeded","account_uid":"123","peers":[{"peer_uid":"456","peer_name":"真实昵称","error":""}],"messages":[{"peer_uid":"456","server_id":"101","sender_uid":"456","message_type":"7","content":"{\"aweType\":700,\"text\":\"你好\"}","created_at":"1700000000000"}],"checkpoints":{"456":"101"}}`)) })) defer server.Close() accounts := listenerAccountsStub{gateway: hub.Gateway{Endpoint: server.URL, Token: "token"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser", Gateway: "gateway"}, RuntimeID: "runtime", RuntimeNetworkID: "network", BindingVersion: 7}} @@ -92,6 +100,23 @@ func TestPrivateGatewaySendAndSync(t *testing.T) { t.Fatalf("accepted mode %d", m) } } + mode.Store(4) + refreshed, err := syncPrivateInbox(ctx, accounts, store, state, checkpoints) + if err != nil || refreshed["456"] != "101" { + t.Fatalf("nickname-only sync=%+v %v", refreshed, err) + } + conversations, err := store.ListPrivateConversations(ctx, state.AccountID, 1, 25) + if err != nil || conversations.Data[0].PeerName != "改名后昵称" || conversations.Data[0].MessageCount != 2 { + t.Fatalf("nickname refresh without new messages=%+v %v", conversations, err) + } + mode.Store(5) + if refreshed, err = syncPrivateInbox(ctx, accounts, store, state, checkpoints); err != nil || refreshed["456"] != "101" { + t.Fatalf("profile error must not block checkpoint=%+v %v", refreshed, err) + } + statuses, err := store.ListPrivateSyncStatus(ctx) + if err != nil || statuses[0].Error != "昵称读取失败" { + t.Fatalf("profile error persistence=%+v %v", statuses, err) + } mode.Store(1) result = gatewayPrivateMessageSender(accounts, store)(ctx, input, reservation) if result.State != "unknown" { @@ -130,7 +155,7 @@ func TestPrivateSyncWorkerRecoveryAndErrors(t *testing.T) { w.WriteHeader(502) return } - w.Write([]byte(`{"status":"succeeded","account_uid":"123","messages":[],"checkpoints":{}}`)) + w.Write([]byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[],"checkpoints":{}}`)) })) defer server.Close() accounts := listenerAccountsStub{gateway: hub.Gateway{Endpoint: server.URL}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser", Gateway: "gateway"}, RuntimeID: "runtime", RuntimeNetworkID: "network", BindingVersion: 7}} diff --git a/internal/creator/private_messages.go b/internal/creator/private_messages.go index 556c6e5..8f5bd88 100644 --- a/internal/creator/private_messages.go +++ b/internal/creator/private_messages.go @@ -79,6 +79,15 @@ type PrivateSyncStatus struct { SyncedAt *time.Time `json:"synced_at"` Error string `json:"error"` } +type PrivateInboxPeer struct { + PeerUID string `json:"peer_uid"` + PeerName string `json:"peer_name"` + Error string `json:"error"` +} +type PrivateInbox struct { + Messages []PrivateInboxMessage + Peers []PrivateInboxPeer +} type PrivateInboxMessage struct { PeerUID, PeerName, ServerID, ClientID, Direction, MessageType, Text string MessageAt *time.Time @@ -109,11 +118,12 @@ func privateMessageTime(value string) (*time.Time, error) { t = t.UTC() return &t, nil } -func DecodePrivateInbox(raw []byte, expectedUID string) ([]PrivateInboxMessage, error) { +func DecodePrivateInbox(raw []byte, expectedUID string) (*PrivateInbox, error) { var response struct { - Status string `json:"status"` - Code string `json:"code"` - AccountUID string `json:"account_uid"` + Status string `json:"status"` + Code string `json:"code"` + AccountUID string `json:"account_uid"` + Peers *[]PrivateInboxPeer `json:"peers"` Messages *[]struct { PeerUID string `json:"peer_uid"` PeerName string `json:"peer_name"` @@ -128,11 +138,28 @@ func DecodePrivateInbox(raw []byte, expectedUID string) ([]PrivateInboxMessage, if err := json.Unmarshal(raw, &response); err != nil { return nil, fmt.Errorf("decode inbox: %w", err) } - if response.Status != "succeeded" || response.AccountUID != expectedUID || response.Messages == nil { + if response.Status != "succeeded" || response.AccountUID != expectedUID || response.Messages == nil || response.Peers == nil { return nil, fmt.Errorf("invalid inbox response: status=%s code=%s identity=%s", response.Status, response.Code, response.AccountUID) } - result := make([]PrivateInboxMessage, 0, len(*response.Messages)) + peerNames := make(map[string]string, len(*response.Peers)) + for i := range *response.Peers { + p := &(*response.Peers)[i] + p.PeerName = strings.TrimSpace(p.PeerName) + p.Error = strings.TrimSpace(p.Error) + if !privateUID(p.PeerUID) || p.PeerUID == expectedUID || (p.PeerName == "") == (p.Error == "") { + return nil, fmt.Errorf("invalid inbox peer profile: uid=%s", p.PeerUID) + } + if _, exists := peerNames[p.PeerUID]; exists { + return nil, fmt.Errorf("duplicate inbox peer profile: uid=%s", p.PeerUID) + } + peerNames[p.PeerUID] = p.PeerName + } + result := &PrivateInbox{Messages: make([]PrivateInboxMessage, 0, len(*response.Messages)), Peers: *response.Peers} for _, m := range *response.Messages { + name, exists := peerNames[m.PeerUID] + if !exists { + return nil, fmt.Errorf("inbox message has no peer profile: uid=%s", m.PeerUID) + } if !privateUID(m.PeerUID) || m.PeerUID == expectedUID || !privateUID(m.ServerID) || (m.SenderUID != expectedUID && m.SenderUID != m.PeerUID) { return nil, fmt.Errorf("invalid inbox message identity or server id") } @@ -180,7 +207,7 @@ func DecodePrivateInbox(raw []byte, expectedUID string) ([]PrivateInboxMessage, if err != nil { return nil, fmt.Errorf("message %s: %w", m.ServerID, err) } - result = append(result, PrivateInboxMessage{PeerUID: m.PeerUID, PeerName: m.PeerName, ServerID: m.ServerID, ClientID: m.ClientID, Direction: direction, MessageType: kind, Text: text, MessageAt: at}) + result.Messages = append(result.Messages, PrivateInboxMessage{PeerUID: m.PeerUID, PeerName: name, ServerID: m.ServerID, ClientID: m.ClientID, Direction: direction, MessageType: kind, Text: text, MessageAt: at}) } return result, nil } @@ -285,7 +312,7 @@ func (s *Store) RecoverInterruptedPrivateMessages(ctx context.Context) error { return err } -func (s *Store) SavePrivateInbox(ctx context.Context, accountID, generation string, items []PrivateInboxMessage) error { +func (s *Store) SavePrivateInbox(ctx context.Context, accountID, generation string, inbox *PrivateInbox) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err @@ -300,7 +327,7 @@ func (s *Store) SavePrivateInbox(ctx context.Context, accountID, generation stri if !enabled || current != generation { return ErrConflict } - for _, m := range items { + for _, m := range inbox.Messages { if !privateUID(m.PeerUID) || !privateUID(m.ServerID) || (m.Direction != "inbound" && m.Direction != "outbound") { return ErrInvalid } @@ -309,7 +336,19 @@ func (s *Store) SavePrivateInbox(ctx context.Context, accountID, generation stri return databaseError(err) } } - if _, err := tx.ExecContext(ctx, `UPDATE creator_account_listener SET dm_synced_at=now(),dm_sync_error='' WHERE account_id=$1`, id); err != nil { + var profileErrors []string + for _, peer := range inbox.Peers { + if !privateUID(peer.PeerUID) { + return ErrInvalid + } + if _, err := tx.ExecContext(ctx, `UPDATE creator_private_message SET peer_name=$3,updated_at=now() WHERE account_id=$1 AND peer_uid=$2 AND peer_name IS DISTINCT FROM $3`, id, peer.PeerUID, peer.PeerName); err != nil { + return databaseError(err) + } + if peer.Error != "" { + profileErrors = append(profileErrors, peer.Error) + } + } + if _, err := tx.ExecContext(ctx, `UPDATE creator_account_listener SET dm_synced_at=now(),dm_sync_error=$2 WHERE account_id=$1`, id, strings.Join(profileErrors, ";")); err != nil { return err } return tx.Commit() diff --git a/internal/creator/private_messages_test.go b/internal/creator/private_messages_test.go index b3320b5..0109d08 100644 --- a/internal/creator/private_messages_test.go +++ b/internal/creator/private_messages_test.go @@ -32,21 +32,83 @@ func TestPrivateMessageInputValidation(t *testing.T) { } func TestDecodePrivateInbox(t *testing.T) { - raw := []byte(`{"status":"succeeded","account_uid":"123","messages":[{"peer_uid":"456","peer_name":"好友","server_id":"1001","sender_uid":"456","message_type":"7","content":"{\"aweType\":700,\"type\":0,\"text\":\"你好\"}","created_at":"1700000000123"},{"peer_uid":"456","server_id":"1002","sender_uid":"123","message_type":"7","content":"{\"aweType\":701,\"type\":1,\"resource_url\":\"https://example.invalid\"}","created_at":null}]}`) - items, err := DecodePrivateInbox(raw, "123") + raw := []byte(`{"status":"succeeded","account_uid":"123","peers":[{"peer_uid":"456","peer_name":"真实昵称","error":""}],"messages":[{"peer_uid":"456","peer_name":"UID占位","server_id":"1001","sender_uid":"456","message_type":"7","content":"{\"aweType\":700,\"type\":0,\"text\":\"你好\"}","created_at":"1700000000123"},{"peer_uid":"456","server_id":"1002","sender_uid":"123","message_type":"7","content":"{\"aweType\":701,\"type\":1,\"resource_url\":\"https://example.invalid\"}","created_at":null}]}`) + inbox, err := DecodePrivateInbox(raw, "123") if err != nil { t.Fatal(err) } + items := inbox.Messages + if len(inbox.Peers) != 1 || items[0].PeerName != "真实昵称" || items[1].PeerName != "真实昵称" { + t.Fatalf("nickname source=%+v", inbox) + } if len(items) != 2 || items[0].Direction != "inbound" || items[0].Text != "你好" || items[0].MessageAt == nil || items[0].MessageAt.UnixMilli() != 1700000000123 || items[1].Direction != "outbound" || items[1].MessageType != "image" || items[1].MessageAt != nil { t.Fatalf("items=%+v", items) } - for _, raw := range []string{`{}`, `{"status":"failed","code":"IM_SDK_NOT_READY"}`, `{"status":"succeeded","account_uid":"999","messages":[]}`, `{"status":"succeeded","account_uid":"123"}`, `{"status":"succeeded","account_uid":"123","messages":[{"peer_uid":"456","server_id":"0","sender_uid":"456","message_type":"7","content":"{}"}]}`, `{"status":"succeeded","account_uid":"123","messages":[{"peer_uid":"456","server_id":"1","sender_uid":"777","message_type":"7","content":"{}"}]}`} { + for _, raw := range []string{`{}`, `{"status":"failed","code":"IM_SDK_NOT_READY"}`, `{"status":"succeeded","account_uid":"999","messages":[]}`, `{"status":"succeeded","account_uid":"123"}`, `{"status":"succeeded","account_uid":"123","peers":[{"peer_uid":"456","peer_name":"昵称"}],"messages":[{"peer_uid":"456","server_id":"0","sender_uid":"456","message_type":"7","content":"{}"}]}`, `{"status":"succeeded","account_uid":"123","peers":[{"peer_uid":"456","peer_name":"昵称"}],"messages":[{"peer_uid":"456","server_id":"1","sender_uid":"777","message_type":"7","content":"{}"}]}`, `{"status":"succeeded","account_uid":"123","peers":[],"messages":[{"peer_uid":"456","server_id":"1","sender_uid":"456","message_type":"7","content":"{}"}]}`} { if _, err := DecodePrivateInbox([]byte(raw), "123"); err == nil { t.Fatalf("accepted %s", raw) } } } +func TestDecodePrivateInboxPeersWithoutNewMessages(t *testing.T) { + inbox, err := DecodePrivateInbox([]byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[{"peer_uid":"456","peer_name":" 更新后的昵称 ","error":""}]}`), "123") + if err != nil || len(inbox.Messages) != 0 || inbox.Peers[0].PeerName != "更新后的昵称" { + t.Fatalf("inbox=%+v err=%v", inbox, err) + } + for _, peers := range []string{`null`, `[{"peer_uid":"123","peer_name":"自己"}]`, `[{"peer_uid":"x","peer_name":"错误"}]`, `[{"peer_uid":"456","peer_name":""}]`, `[{"peer_uid":"456","peer_name":"假名","error":"失败"}]`, `[{"peer_uid":"456","peer_name":"昵称"},{"peer_uid":"456","peer_name":"重复"}]`} { + raw := fmt.Sprintf(`{"status":"succeeded","account_uid":"123","messages":[],"peers":%s}`, peers) + if _, err := DecodePrivateInbox([]byte(raw), "123"); err == nil { + t.Fatalf("accepted %s", raw) + } + } + inbox, err = DecodePrivateInbox([]byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[{"peer_uid":"456","peer_name":"","error":"昵称读取失败"}]}`), "123") + if err != nil || inbox.Peers[0].Error != "昵称读取失败" { + t.Fatalf("lookup error=%+v %v", inbox, err) + } +} + +func TestPrivateInboxNicknameRefreshIsAccountScoped(t *testing.T) { + store, accounts, ctx := openCreatorIntegrationStore(t) + var ids, generations []string + for _, key := range []string{"nickname-owner", "nickname-other"} { + id := createIntegrationAccount(t, ctx, accounts, key) + profile, err := store.GetAccountProfile(ctx, id) + if err != nil { + t.Fatal(err) + } + if _, err := store.RecordVerifiedLoginResult(ctx, id, profile.PlatformAccountKey); err != nil { + t.Fatal(err) + } + listener, err := store.SetListenerEnabled(ctx, id, true) + if err != nil { + t.Fatal(err) + } + inbox := &PrivateInbox{Messages: []PrivateInboxMessage{{PeerUID: "456", PeerName: key, ServerID: "101", Direction: "inbound", MessageType: "text", Text: "已有消息"}}} + if err := store.SavePrivateInbox(ctx, id, listener.Generation, inbox); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + generations = append(generations, listener.Generation) + } + inbox := &PrivateInbox{Peers: []PrivateInboxPeer{{PeerUID: "456", PeerName: "更新后的真实昵称"}}} + if err := store.SavePrivateInbox(ctx, ids[0], generations[0], inbox); err != nil { + t.Fatal(err) + } + for i, expected := range []string{"更新后的真实昵称", "nickname-other"} { + page, err := store.ListPrivateMessages(ctx, ids[i], "456", 1, 25) + if err != nil || page.Total != 1 || page.Data[0].PeerName != expected || page.Data[0].Text != "已有消息" { + t.Fatalf("account=%s messages=%+v error=%v", ids[i], page, err) + } + } + if _, err := store.SetListenerEnabled(ctx, ids[0], false); err != nil { + t.Fatal(err) + } + if err := store.SavePrivateInbox(ctx, ids[0], generations[0], inbox); !errors.Is(err, ErrConflict) { + t.Fatalf("stale profile refresh accepted: %v", err) + } +} + func TestPrivateListenerStateDoesNotDependOnNoticeProgress(t *testing.T) { store, accounts, ctx := openCreatorIntegrationStore(t) id := createIntegrationAccount(t, ctx, accounts, "independent-listener") @@ -138,7 +200,7 @@ func TestPrivateMessagesLifecycle(t *testing.T) { t.Fatalf("self=%v", err) } at := time.Now().UTC() - batch := []PrivateInboxMessage{{PeerUID: "456", PeerName: "好友", ServerID: "100", Direction: "outbound", MessageType: "text", Text: "你好", MessageAt: &at}, {PeerUID: "456", PeerName: "好友", ServerID: "101", Direction: "inbound", MessageType: "text", Text: "你好呀", MessageAt: &at}} + batch := &PrivateInbox{Messages: []PrivateInboxMessage{{PeerUID: "456", PeerName: "好友", ServerID: "100", Direction: "outbound", MessageType: "text", Text: "你好", MessageAt: &at}, {PeerUID: "456", PeerName: "好友", ServerID: "101", Direction: "inbound", MessageType: "text", Text: "你好呀", MessageAt: &at}}, Peers: []PrivateInboxPeer{{PeerUID: "456", PeerName: "好友"}}} if err := store.SavePrivateInbox(ctx, id, listener.Generation, batch); err != nil { t.Fatal(err) } @@ -157,10 +219,31 @@ func TestPrivateMessagesLifecycle(t *testing.T) { if err != nil || conv.Total != 1 || conv.Data[0].MessageCount != 2 { t.Fatalf("conversations=%+v %v", conv, err) } + refresh := &PrivateInbox{Peers: []PrivateInboxPeer{{PeerUID: "456", PeerName: "正式昵称"}}} + if err := store.SavePrivateInbox(ctx, id, listener.Generation, refresh); err != nil { + t.Fatal(err) + } + conv, err = store.ListPrivateConversations(ctx, id, 1, 25) + if err != nil || conv.Data[0].PeerName != "正式昵称" || conv.Data[0].MessageCount != 2 { + t.Fatalf("nickname-only refresh=%+v %v", conv, err) + } + page, err = store.ListPrivateMessages(ctx, id, "456", 1, 25) + if err != nil || page.Total != 2 || page.Data[0].PeerName != "正式昵称" || page.Data[1].PeerName != "正式昵称" || page.Data[0].MessageAt == nil || !page.Data[0].MessageAt.Equal(at.Truncate(time.Microsecond)) { + t.Fatalf("preserved messages=%+v %v", page, err) + } + refresh.Peers[0] = PrivateInboxPeer{PeerUID: "456", Error: "昵称读取失败"} + if err := store.SavePrivateInbox(ctx, id, listener.Generation, refresh); err != nil { + t.Fatal(err) + } + conv, err = store.ListPrivateConversations(ctx, id, 1, 25) + statuses, statusErr := store.ListPrivateSyncStatus(ctx) + if err != nil || statusErr != nil || conv.Data[0].PeerName != "" || conv.Data[0].MessageCount != 2 || statuses[0].Error != "昵称读取失败" { + t.Fatalf("nickname error=%+v %+v %v %v", conv, statuses, err, statusErr) + } if err := store.UpdatePrivateSyncStatus(ctx, id, listener.Generation, "network failed"); err != nil { t.Fatal(err) } - statuses, err := store.ListPrivateSyncStatus(ctx) + statuses, err = store.ListPrivateSyncStatus(ctx) if err != nil || len(statuses) != 1 || statuses[0].Error != "network failed" { t.Fatalf("status=%+v %v", statuses, err) } diff --git a/web/src/pages/creator/messages/index.tsx b/web/src/pages/creator/messages/index.tsx index 1420699..9a05f24 100644 --- a/web/src/pages/creator/messages/index.tsx +++ b/web/src/pages/creator/messages/index.tsx @@ -323,7 +323,7 @@ export default function PrivateMessagesPage() { items={visibleContacts.map((contact) => ({ key: conversationKey(contact), label: - {contact.peer_name?.trim() || contact.peer_uid} + {contact.peer_name?.trim() || '昵称未获取'} {accountName(accounts.find((account) => account.id === contact.account_id)?.name)} {contact.last_text || '暂无消息'} , @@ -339,7 +339,7 @@ export default function PrivateMessagesPage() { {active ? <> - {active.peer_name?.trim() || active.peer_uid} + {active.peer_name?.trim() || '昵称未获取'} 发送账号:{accountName(selectedAccount ? selectedAccount.name : active.account_name)} {!canSend && } diff --git a/web/tests/private-messages-page.test.cjs b/web/tests/private-messages-page.test.cjs index 503ada0..18bd2e7 100644 --- a/web/tests/private-messages-page.test.cjs +++ b/web/tests/private-messages-page.test.cjs @@ -202,13 +202,28 @@ test('same peer on different accounts has distinct stable keys, source names and assert.equal(items[0].label.type, h.antd.Flex); assert.equal(items[0].label.props.vertical, true); assert.deepEqual(React.Children.toArray(items[0].label.props.children).map(text), ['联系人', '账号甲', '甲的最近消息']); - assert.match(text(items[1].label), /100.*未命名账号.*乙的最近消息/); + assert.match(text(items[1].label), /昵称未获取.*未命名账号.*乙的最近消息/); tree = await choose(h, 'b:100'); assert.match(text(tree), /发送账号:未命名账号/); const query = new URLSearchParams(h.calls.filter((p) => p.startsWith('/creator/private-messages/messages?')).at(-1).split('?')[1]); assert.equal(query.get('account_id'), 'b'); assert.equal(query.get('peer_uid'), '100'); }); +test('conversation list and message header show real nicknames and never use UID as a missing-name placeholder', async (t) => { + const h = harness({ contacts: [{ ...contacts[0], peer_name: '真实昵称' }, { ...contacts[1], peer_name: '' }] }); + t.after(h.dispose); + let tree = await h.flush(); + const labels = conversations(h, tree).props.items.map((item) => text(item.label)); + assert.match(labels[0], /真实昵称/); + assert.match(labels[1], /昵称未获取/); + assert.doesNotMatch(labels[1], /100/); + tree = await choose(h, 'b:100'); + assert.equal(all(tree, (node) => node.type === h.antd.Typography.Text && node.props.strong && text(node) === '昵称未获取').length, 1); + assert.equal(all(tree, (node) => node.type === h.antd.Typography.Text && node.props.strong && text(node) === '100').length, 0); + tree = await choose(h, 'a:100'); + assert.equal(all(tree, (node) => node.type === h.antd.Typography.Text && node.props.strong && text(node) === '真实昵称').length, 1); +}); + test('three-line conversation items grow with content, keep vertical padding and never shrink in a long list', async (t) => { const rows = Array.from({ length: 50 }, (_, index) => ({ ...contacts[0], peer_uid: String(100 + index),