package creator import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "unicode/utf8" ) type PrivateMessageSendInput struct { RequestID string `json:"request_id"` AccountID string `json:"account_id"` PeerUID string `json:"peer_uid"` Text string `json:"text"` } func privateUID(v string) bool { if len(v) == 0 || len(v) > 20 || v == "0" { return false } for _, r := range v { if r < '0' || r > '9' { return false } } return true } func (i PrivateMessageSendInput) Validate() error { if strings.TrimSpace(i.RequestID) == "" || len(i.RequestID) > 128 || strings.TrimSpace(i.AccountID) == "" || !privateUID(i.PeerUID) || strings.TrimSpace(i.Text) == "" || utf8.RuneCountInString(i.Text) > 1000 { return fmt.Errorf("%w: 账号、对方 UID、发送编号和 1–1000 字的消息不能为空", ErrInvalid) } return nil } type PrivateMessage struct { ID string `json:"id"` AccountID string `json:"account_id"` PeerUID string `json:"peer_uid"` PeerName string `json:"peer_name"` ServerID string `json:"server_id,omitempty"` RequestID string `json:"request_id,omitempty"` Direction string `json:"direction"` MessageType string `json:"message_type"` Text string `json:"text"` State string `json:"state"` Error string `json:"error"` MessageAt *time.Time `json:"message_at"` CreatedAt time.Time `json:"created_at"` } type PrivateMessageReservation struct { Message PrivateMessage New bool Generation string UID string } type PrivateConversation struct { AccountID string `json:"account_id"` AccountName string `json:"account_name"` PeerUID string `json:"peer_uid"` PeerName string `json:"peer_name"` LastText string `json:"last_text"` LastMessageAt *time.Time `json:"last_message_at"` MessageCount int `json:"message_count"` } type PrivatePage[T any] struct { Data []T `json:"data"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } type PrivateSyncStatus struct { AccountID string `json:"account_id"` AccountName string `json:"account_name"` 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 } // The platform timestamp is optional. Reception time is never substituted for it. func privateMessageTime(value string) (*time.Time, error) { if value == "" { return nil, nil } if n, err := strconv.ParseInt(value, 10, 64); err == nil { if n <= 0 { return nil, fmt.Errorf("invalid message time") } var t time.Time if n > 100000000000 { t = time.UnixMilli(n) } else { t = time.Unix(n, 0) } t = t.UTC() return &t, nil } t, err := time.Parse(time.RFC3339Nano, value) if err != nil { return nil, fmt.Errorf("invalid message time: %w", err) } t = t.UTC() return &t, nil } func DecodePrivateInbox(raw []byte, expectedUID string) (*PrivateInbox, error) { var response struct { 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"` ServerID string `json:"server_id"` ClientID string `json:"client_id"` SenderUID string `json:"sender_uid"` MessageType string `json:"message_type"` Content json.RawMessage `json:"content"` CreatedAt *string `json:"created_at"` } `json:"messages"` } if err := json.Unmarshal(raw, &response); err != nil { return nil, fmt.Errorf("decode inbox: %w", err) } if response.Status != "succeeded" || response.AccountUID != expectedUID { return nil, fmt.Errorf("invalid inbox response: status=%s code=%s identity=%s", response.Status, response.Code, response.AccountUID) } if response.Messages == nil { return nil, errors.New("inbox response missing messages; verify running browser gateway version") } if response.Peers == nil { return nil, errors.New("inbox response missing peers; verify running browser gateway version") } 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") } direction := "inbound" if m.SenderUID == expectedUID { direction = "outbound" } content := m.Content var str string if json.Unmarshal(content, &str) == nil { content = []byte(str) } var body struct { Text string `json:"text"` AweType int `json:"aweType"` Type int `json:"type"` } if err := json.Unmarshal(content, &body); err != nil { return nil, fmt.Errorf("message %s content: %w", m.ServerID, err) } kind := "unknown" if m.MessageType == "7" { switch body.AweType { case 700: kind = "text" case 701: kind = "image" case 702: kind = "audio" case 703: kind = "video" case 704: kind = "sticker" } } text := "" if kind == "text" { text = body.Text } rawTime := "" if m.CreatedAt != nil { rawTime = *m.CreatedAt } at, err := privateMessageTime(rawTime) if err != nil { return nil, fmt.Errorf("message %s: %w", m.ServerID, err) } 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 } const privateMessageQuery = `SELECT m.id,a.account_id,m.peer_uid,m.peer_name,COALESCE(m.server_id,''),COALESCE(m.request_id,''),m.direction,m.message_type,m.text,m.state,m.error,m.message_at,m.created_at FROM creator_private_message m JOIN social_account a ON a.id=m.account_id` func scanPrivateMessage(row interface{ Scan(...any) error }) (PrivateMessage, error) { var m PrivateMessage var at sql.NullTime err := row.Scan(&m.ID, &m.AccountID, &m.PeerUID, &m.PeerName, &m.ServerID, &m.RequestID, &m.Direction, &m.MessageType, &m.Text, &m.State, &m.Error, &at, &m.CreatedAt) m.MessageAt = nullableTime(at) return m, rowError(err) } func (s *Store) BeginPrivateMessage(ctx context.Context, input PrivateMessageSendInput) (PrivateMessageReservation, error) { if err := input.Validate(); err != nil { return PrivateMessageReservation{}, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return PrivateMessageReservation{}, err } defer tx.Rollback() var id int64 var generation, uid, login string var enabled bool err = tx.QueryRowContext(ctx, `SELECT a.id,l.enabled,l.generation,COALESCE(a.platform_account_key,''),p.login_status FROM social_account a JOIN creator_account_listener l ON l.account_id=a.id JOIN creator_account_profile p ON p.account_id=a.id WHERE a.account_id=$1 AND a.platform='douyin' FOR UPDATE OF l`, input.AccountID).Scan(&id, &enabled, &generation, &uid, &login) if errors.Is(err, sql.ErrNoRows) || err == nil && (!enabled || login != "logged_in") { return PrivateMessageReservation{}, fmt.Errorf("%w: 只能给已登录且已开启监听的账号发送私信", ErrConflict) } if err != nil { return PrivateMessageReservation{}, err } if uid == input.PeerUID { return PrivateMessageReservation{}, fmt.Errorf("%w: 不能给自己发送私信", ErrInvalid) } var existingID string err = tx.QueryRowContext(ctx, `INSERT INTO creator_private_message(id,account_id,peer_uid,request_id,direction,message_type,text,state) VALUES($1,$2,$3,$4,'outbound','text',$5,'sending') ON CONFLICT(account_id,request_id) DO NOTHING RETURNING id`, newID("dm"), id, input.PeerUID, input.RequestID, input.Text).Scan(&existingID) fresh := true if errors.Is(err, sql.ErrNoRows) { fresh = false } else if err != nil { return PrivateMessageReservation{}, databaseError(err) } message, err := scanPrivateMessage(tx.QueryRowContext(ctx, privateMessageQuery+` WHERE a.account_id=$1 AND m.request_id=$2`, input.AccountID, input.RequestID)) if err != nil { return PrivateMessageReservation{}, err } if message.PeerUID != input.PeerUID || message.Text != input.Text { return PrivateMessageReservation{}, fmt.Errorf("%w: 同一发送编号的账号、收件人和内容不可更改", ErrConflict) } if err := tx.Commit(); err != nil { return PrivateMessageReservation{}, err } return PrivateMessageReservation{Message: message, New: fresh, Generation: generation, UID: uid}, nil } func (s *Store) FinishPrivateMessage(ctx context.Context, id, state, serverID, reason string, at *time.Time) (PrivateMessage, error) { if state != "succeeded" && state != "failed" && state != "unknown" { return PrivateMessage{}, ErrInvalid } if serverID != "" && !privateUID(serverID) { return PrivateMessage{}, ErrInvalid } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return PrivateMessage{}, err } defer tx.Rollback() var accountID int64 if err := tx.QueryRowContext(ctx, `SELECT account_id FROM creator_private_message WHERE id=$1 FOR UPDATE`, id).Scan(&accountID); err != nil { return PrivateMessage{}, rowError(err) } // A concurrent inbox pull may already contain the server-confirmed message. // Merge that copy into the reserved row rather than showing it twice. if serverID != "" { if _, err := tx.ExecContext(ctx, `DELETE FROM creator_private_message WHERE account_id=$1 AND server_id=$2 AND id<>$3`, accountID, serverID, id); err != nil { return PrivateMessage{}, err } } result, err := tx.ExecContext(ctx, `UPDATE creator_private_message SET state=$2,server_id=NULLIF($3,''),error=$4,message_at=COALESCE($5,message_at),updated_at=now() WHERE id=$1 AND state='sending'`, id, state, serverID, reason, at) if err != nil { return PrivateMessage{}, databaseError(err) } n, err := result.RowsAffected() if err != nil { return PrivateMessage{}, err } if n != 1 { return PrivateMessage{}, ErrConflict } m, err := scanPrivateMessage(tx.QueryRowContext(ctx, privateMessageQuery+` WHERE m.id=$1`, id)) if err != nil { return PrivateMessage{}, err } if err := tx.Commit(); err != nil { return PrivateMessage{}, err } return m, nil } func (s *Store) RecoverInterruptedPrivateMessages(ctx context.Context) error { _, err := s.db.ExecContext(ctx, `UPDATE creator_private_message SET state='unknown',error=CASE WHEN state='failed' THEN '聊天客户端网络错误(1008),发送结果未确认;请核实对方是否收到,不自动重发' ELSE '服务重启,发送结果未确认;请核实对方是否收到,不自动重发' END,updated_at=now() WHERE state='sending' OR (state='failed' AND error ~ 'status_code=1008($|[;;])')`) return err } 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 } defer tx.Rollback() var id int64 var enabled bool var current string if err := tx.QueryRowContext(ctx, `SELECT l.account_id,l.enabled,l.generation FROM creator_account_listener l JOIN social_account a ON a.id=l.account_id WHERE a.account_id=$1 FOR UPDATE OF l`, accountID).Scan(&id, &enabled, ¤t); err != nil { return rowError(err) } if !enabled || current != generation { return ErrConflict } for _, m := range inbox.Messages { if !privateUID(m.PeerUID) || !privateUID(m.ServerID) || (m.Direction != "inbound" && m.Direction != "outbound") { return ErrInvalid } _, err = tx.ExecContext(ctx, `INSERT INTO creator_private_message(id,account_id,peer_uid,peer_name,server_id,direction,message_type,text,state,message_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,'succeeded',$9) ON CONFLICT(account_id,server_id) DO UPDATE SET peer_name=COALESCE(NULLIF(EXCLUDED.peer_name,''),creator_private_message.peer_name),message_at=COALESCE(EXCLUDED.message_at,creator_private_message.message_at),updated_at=now()`, newID("dm"), id, m.PeerUID, m.PeerName, m.ServerID, m.Direction, m.MessageType, m.Text, m.MessageAt) if err != nil { return databaseError(err) } } 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() } func (s *Store) UpdatePrivateSyncStatus(ctx context.Context, accountID, generation, reason string) error { result, err := s.db.ExecContext(ctx, `UPDATE creator_account_listener SET dm_sync_error=$3 WHERE account_id=(SELECT id FROM social_account WHERE account_id=$1) AND generation=$2 AND enabled`, accountID, generation, reason) if err != nil { return err } n, err := result.RowsAffected() if err != nil { return err } if n != 1 { return ErrConflict } return nil } // Private messaging needs only the listener switch and generation, not the // unrelated notification collector's delivery/checkpoint state. func (s *Store) ListPrivateListeners(ctx context.Context) ([]ListenerState, error) { rows, err := s.db.QueryContext(ctx, `SELECT a.account_id,l.enabled,l.generation FROM creator_account_listener l JOIN social_account a ON a.id=l.account_id ORDER BY a.id DESC`) if err != nil { return nil, err } defer rows.Close() out := make([]ListenerState, 0) for rows.Next() { var state ListenerState if err := rows.Scan(&state.AccountID, &state.Enabled, &state.Generation); err != nil { return nil, err } out = append(out, state) } return out, rows.Err() } func (s *Store) ListPrivateSyncStatus(ctx context.Context) ([]PrivateSyncStatus, error) { rows, err := s.db.QueryContext(ctx, `SELECT a.account_id,a.name,l.dm_synced_at,l.dm_sync_error FROM creator_account_listener l JOIN social_account a ON a.id=l.account_id WHERE l.enabled ORDER BY a.id DESC`) if err != nil { return nil, err } defer rows.Close() out := make([]PrivateSyncStatus, 0) for rows.Next() { var s PrivateSyncStatus var at sql.NullTime if err := rows.Scan(&s.AccountID, &s.AccountName, &at, &s.Error); err != nil { return nil, err } s.SyncedAt = nullableTime(at) out = append(out, s) } return out, rows.Err() } func privatePage[T any](page, size int) PrivatePage[T] { if page < 1 { page = 1 } if size < 1 { size = 25 } if size > 100 { size = 100 } return PrivatePage[T]{Data: make([]T, 0), Page: page, PageSize: size} } func (s *Store) ListPrivateMessages(ctx context.Context, accountID, peerUID string, page, size int) (PrivatePage[PrivateMessage], error) { out := privatePage[PrivateMessage](page, size) if accountID == "" || !privateUID(peerUID) { return out, ErrInvalid } const where = ` JOIN creator_account_listener l ON l.account_id=a.id WHERE l.enabled AND a.account_id=$1 AND m.peer_uid=$2` if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM creator_private_message m JOIN social_account a ON a.id=m.account_id`+where, accountID, peerUID).Scan(&out.Total); err != nil { return out, err } rows, err := s.db.QueryContext(ctx, privateMessageQuery+where+` ORDER BY COALESCE(m.message_at,m.created_at) DESC,m.created_at DESC,m.id DESC LIMIT $3 OFFSET $4`, accountID, peerUID, out.PageSize, (out.Page-1)*out.PageSize) if err != nil { return out, err } defer rows.Close() for rows.Next() { m, err := scanPrivateMessage(rows) if err != nil { return out, err } out.Data = append(out.Data, m) } return out, rows.Err() } func (s *Store) ListPrivateConversations(ctx context.Context, accountID string, page, size int) (PrivatePage[PrivateConversation], error) { out := privatePage[PrivateConversation](page, size) const base = ` FROM creator_private_message m JOIN social_account a ON a.id=m.account_id JOIN creator_account_listener l ON l.account_id=a.id WHERE l.enabled AND ($1='' OR a.account_id=$1)` if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM (SELECT m.account_id,m.peer_uid`+base+` GROUP BY m.account_id,m.peer_uid) c`, accountID).Scan(&out.Total); err != nil { return out, err } rows, err := s.db.QueryContext(ctx, `SELECT a.account_id,a.name,m.peer_uid,COALESCE((array_agg(NULLIF(m.peer_name,'') ORDER BY m.created_at DESC) FILTER(WHERE m.peer_name<>''))[1],''),(array_agg(m.text ORDER BY m.message_at DESC NULLS LAST,m.created_at DESC,m.id DESC))[1],max(m.message_at),count(*)`+base+` GROUP BY a.id,a.account_id,a.name,m.peer_uid ORDER BY max(m.message_at) DESC NULLS LAST,a.account_id COLLATE "C",m.peer_uid COLLATE "C" LIMIT $2 OFFSET $3`, accountID, out.PageSize, (out.Page-1)*out.PageSize) if err != nil { return out, err } defer rows.Close() for rows.Next() { var c PrivateConversation var at sql.NullTime if err := rows.Scan(&c.AccountID, &c.AccountName, &c.PeerUID, &c.PeerName, &c.LastText, &at, &c.MessageCount); err != nil { return out, err } c.LastMessageAt = nullableTime(at) out.Data = append(out.Data, c) } return out, rows.Err() }