fix(messages): sync and display real peer nicknames

This commit is contained in:
2026-10-07 11:36:28 +08:00
parent 579ca1d609
commit 5428ab9e0b
9 changed files with 247 additions and 27 deletions
+1 -1
View File
@@ -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 倒序;未记录发布时间、未来时间及未采集评论不参与。筛选先作用于全部已采集评论再分页,条件或每页数量变化回到第一页;只读展示,不新增采集机制。
+26 -2
View File
@@ -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")};
+33 -4
View File
@@ -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()
@@ -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
}
@@ -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}}
+49 -10
View File
@@ -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()
+88 -5
View File
@@ -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)
}
+2 -2
View File
@@ -323,7 +323,7 @@ export default function PrivateMessagesPage() {
items={visibleContacts.map((contact) => ({
key: conversationKey(contact),
label: <Flex vertical gap={2}>
<Typography.Text strong ellipsis={{ tooltip: true }}>{contact.peer_name?.trim() || contact.peer_uid}</Typography.Text>
<Typography.Text strong ellipsis={{ tooltip: true }}>{contact.peer_name?.trim() || '昵称未获取'}</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: true }}>{accountName(accounts.find((account) => account.id === contact.account_id)?.name)}</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: true }}>{contact.last_text || '暂无消息'}</Typography.Text>
</Flex>,
@@ -339,7 +339,7 @@ export default function PrivateMessagesPage() {
<Flex vertical gap={12} style={{ height: '100%', padding: 16 }}>
{active ? <>
<Flex justify="space-between" align="center" gap={12}>
<Typography.Text strong>{active.peer_name?.trim() || active.peer_uid}</Typography.Text>
<Typography.Text strong>{active.peer_name?.trim() || '昵称未获取'}</Typography.Text>
<Typography.Text type="secondary">发送账号:{accountName(selectedAccount ? selectedAccount.name : active.account_name)}</Typography.Text>
</Flex>
{!canSend && <Alert type="warning" showIcon title="该账号监听已关闭,无法发送私信" description="草稿与发送错误仍保留;请在“我的账号”开启监听。" />}
+16 -1
View File
@@ -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),