Files
creator-hub/internal/creator/private_messages.go
T
rogee e50bed9ad8
douyin-release-gate / verify (push) Failing after 18m54s
feat: add account events and private message management
Add Douyin notification polling, event details, and manual multi-account private messaging.

Refine environment memory settings, account operations, login collection recovery, and message UI; update tests and documentation.
2026-10-07 10:23:05 +08:00

426 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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) ([]PrivateInboxMessage, error) {
var response struct {
Status string `json:"status"`
Code string `json:"code"`
AccountUID string `json:"account_uid"`
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 || response.Messages == 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))
for _, m := range *response.Messages {
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 = append(result, PrivateInboxMessage{PeerUID: m.PeerUID, PeerName: m.PeerName, 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, items []PrivateInboxMessage) 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, &current); err != nil {
return rowError(err)
}
if !enabled || current != generation {
return ErrConflict
}
for _, m := range items {
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)
}
}
if _, err := tx.ExecContext(ctx, `UPDATE creator_account_listener SET dm_synced_at=now(),dm_sync_error='' WHERE account_id=$1`, id); 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 COALESCE(m.message_at,m.created_at) DESC,m.created_at DESC,m.id DESC))[1],(array_agg(m.message_at ORDER BY COALESCE(m.message_at,m.created_at) DESC,m.created_at DESC,m.id DESC))[1],count(*)`+base+` GROUP BY a.id,a.account_id,a.name,m.peer_uid ORDER BY max(COALESCE(m.message_at,m.created_at)) DESC,a.id DESC,m.peer_uid 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()
}