douyin-release-gate / verify (push) Failing after 18m54s
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.
426 lines
17 KiB
Go
426 lines
17 KiB
Go
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, ¤t); 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()
|
||
}
|