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),