package creator import ( "context" "database/sql" "errors" "fmt" "strings" "time" "unicode/utf8" ) func (s *Store) EnsureAccountProfile(ctx context.Context, accountID string) error { if strings.TrimSpace(accountID) == "" { return ErrInvalid } _, err := s.db.ExecContext(ctx, ` INSERT INTO creator_account_profile (account_id) SELECT account.id FROM social_account account WHERE account.account_id = $1 ON CONFLICT (account_id) DO NOTHING`, accountID) return databaseError(err) } func accountProfileQuery() string { return ` SELECT a.account_id, a.name, a.platform, COALESCE(a.platform_account_key, ''), a.status, p.login_username, p.password_configured, p.real_name_status, p.real_name, p.identity_number, p.note, p.business_status, p.big_account, p.reply_requirements, p.login_status, p.login_reason, p.login_checked_at, p.cooldown_seconds, p.updated_at, p.avatar_url, p.douyin_number FROM social_account a JOIN creator_account_profile p ON p.account_id = a.id WHERE a.account_id = $1` } func scanAccountProfile(scanner interface{ Scan(...any) error }) (AccountProfile, error) { var result AccountProfile var checkedAt sql.NullTime if err := scanner.Scan( &result.ID, &result.Name, &result.Platform, &result.PlatformAccountKey, &result.RuntimeStatus, &result.LoginUsername, &result.PasswordConfigured, &result.RealNameStatus, &result.RealName, &result.IdentityNumber, &result.Note, &result.BusinessStatus, &result.BigAccount, &result.ReplyRequirements, &result.LoginStatus, &result.LoginReason, &checkedAt, &result.CooldownSeconds, &result.UpdatedAt, &result.AvatarURL, &result.DouyinNumber, ); err != nil { return AccountProfile{}, err } result.LoginCheckedAt = nullableTime(checkedAt) return result, nil } func (s *Store) GetAccountProfile(ctx context.Context, accountID string) (AccountProfile, error) { if err := s.EnsureAccountProfile(ctx, accountID); err != nil { return AccountProfile{}, err } result, err := scanAccountProfile(s.db.QueryRowContext(ctx, accountProfileQuery(), accountID)) return result, rowError(err) } func (s *Store) UpdateAccountTags(ctx context.Context, accountID string, tags []string) ([]string, error) { accountID = strings.TrimSpace(accountID) if accountID == "" || validateCreatorTags(tags) != nil { return nil, ErrInvalid } if tags == nil { tags = []string{} } result, err := s.db.ExecContext(ctx, ` UPDATE social_account SET tags = $2, updated_at = now() WHERE account_id = $1`, accountID, tags) if err != nil { return nil, databaseError(err) } affected, err := result.RowsAffected() if err != nil { return nil, databaseError(err) } if affected != 1 { return nil, ErrNotFound } return tags, nil } func (s *Store) ListAccountProfiles(ctx context.Context) ([]AccountProfile, error) { if _, err := s.db.ExecContext(ctx, ` INSERT INTO creator_account_profile (account_id) SELECT id FROM social_account ON CONFLICT (account_id) DO NOTHING`); err != nil { return nil, databaseError(err) } rows, err := s.db.QueryContext(ctx, strings.Replace(accountProfileQuery(), "WHERE a.account_id = $1", "ORDER BY a.created_at DESC, a.account_id", 1)) if err != nil { return nil, fmt.Errorf("list creator account profiles: %w", err) } defer rows.Close() profiles := make([]AccountProfile, 0) for rows.Next() { profile, err := scanAccountProfile(rows) if err != nil { return nil, fmt.Errorf("decode creator account profile: %w", err) } profiles = append(profiles, profile) } return profiles, rows.Err() } // AccountMonitorView 自有账号监控列表视图:账号画像 + 作品聚合统计(作品数/最近发布)+ 最新画像指标快照 + 自有作品评论累计。 type AccountMonitorView struct { AccountProfile WorkCount int64 `json:"work_count"` LatestPublishedAt *time.Time `json:"latest_published_at,omitempty"` FollowerCount *int64 `json:"follower_count,omitempty"` FollowingCount *int64 `json:"following_count,omitempty"` TotalFavorited *int64 `json:"total_favorited,omitempty"` AwemeCount *int64 `json:"aweme_count,omitempty"` FriendCount *int64 `json:"friend_count,omitempty"` CommentTotal int64 `json:"comment_total"` } // ListAccountMonitorViews 自有账号监控列表:画像 + owned 作品聚合,形态对齐竞品的 ListCompetitorsWithProfile。 func (s *Store) ListAccountMonitorViews(ctx context.Context) ([]AccountMonitorView, error) { profiles, err := s.ListAccountProfiles(ctx) if err != nil { return nil, err } stats, err := s.ownedWorkStats(ctx) if err != nil { return nil, err } latest, err := s.latestAccountMetrics(ctx) if err != nil { return nil, err } views := make([]AccountMonitorView, 0, len(profiles)) for _, profile := range profiles { view := AccountMonitorView{AccountProfile: profile} if stat, exists := stats[profile.ID]; exists { view.WorkCount, view.LatestPublishedAt, view.CommentTotal = stat.WorkCount, stat.LatestPublishedAt, stat.CommentTotal } if metric, exists := latest[profile.ID]; exists { view.FollowerCount, view.FollowingCount, view.TotalFavorited, view.AwemeCount, view.FriendCount = metric.FollowerCount, metric.FollowingCount, metric.TotalFavorited, metric.AwemeCount, metric.FriendCount } views = append(views, view) } return views, nil } type ownedWorkStat struct { WorkCount int64 LatestPublishedAt *time.Time CommentTotal int64 } // latestAccountMetricRow 最新快照内一行非空计数(无对应快照时不返回行)。 type latestAccountMetricRow struct { FollowerCount *int64 FollowingCount *int64 TotalFavorited *int64 AwemeCount *int64 FriendCount *int64 } // latestAccountMetrics 每账号取最新非空画像计数:按采集时间倒序回填,缺失维度保留上一轮的值(详情页同口径)。 func (s *Store) latestAccountMetrics(ctx context.Context) (map[string]latestAccountMetricRow, error) { rows, err := s.db.QueryContext(ctx, ` SELECT account.account_id, metric.follower_count, metric.following_count, metric.total_favorited, metric.aweme_count, metric.friend_count FROM creator_account_metric metric JOIN social_account account ON account.id = metric.account_id ORDER BY account.account_id, metric.collected_at`) if err != nil { return nil, databaseError(err) } defer rows.Close() latest := make(map[string]latestAccountMetricRow) for rows.Next() { var accountID string var follower, following, favorited, aweme, friend sql.NullInt64 if err := rows.Scan(&accountID, &follower, &following, &favorited, &aweme, &friend); err != nil { return nil, err } current := latest[accountID] if follower.Valid { value := follower.Int64 current.FollowerCount = &value } if following.Valid { value := following.Int64 current.FollowingCount = &value } if favorited.Valid { value := favorited.Int64 current.TotalFavorited = &value } if aweme.Valid { value := aweme.Int64 current.AwemeCount = &value } if friend.Valid { value := friend.Int64 current.FriendCount = &value } latest[accountID] = current } return latest, rows.Err() } // AccountCollectionStatus 自有账号采集状态(checkpoint 形态,对齐竞品的 sync_status 展示语义)。 func (s *Store) ownedWorkStats(ctx context.Context) (map[string]ownedWorkStat, error) { rows, err := s.db.QueryContext(ctx, ` SELECT w.source_id, COUNT(*) AS work_count, MAX(w.published_at) AS latest_published_at, COALESCE(SUM(w.comments_count), 0) AS comment_total FROM creator_work w WHERE w.source_type = 'owned' GROUP BY w.source_id`) if err != nil { return nil, databaseError(err) } defer rows.Close() stats := make(map[string]ownedWorkStat) for rows.Next() { var stat ownedWorkStat var sourceID string var latest sql.NullTime if err := rows.Scan(&sourceID, &stat.WorkCount, &latest, &stat.CommentTotal); err != nil { return nil, err } stat.LatestPublishedAt = nullableTime(latest) stats[sourceID] = stat } return stats, rows.Err() } // AccountCollectionStatus 自有账号采集状态(checkpoint 形态,对齐竞品的 sync_status 展示语义)。 type AccountCollectionStatus struct { WorkCount int64 `json:"work_count"` AwemeCount *int64 `json:"aweme_count"` Works AccountCheckpointStatus `json:"works"` Comments AccountCheckpointStatus `json:"comments"` } type AccountCheckpointStatus struct { Status string `json:"status"` LastCompletedAt *time.Time `json:"last_completed_at,omitempty"` LastError string `json:"last_error,omitempty"` WindowEnd *time.Time `json:"window_end,omitempty"` NextWindowStart *time.Time `json:"next_window_start,omitempty"` NextWindowEnd *time.Time `json:"next_window_end,omitempty"` } // GetAccountCollectionStatus 返回自有账号作品/评论两条采集 checkpoint 的状态与下次采集窗口。 func (s *Store) GetAccountCollectionStatus(ctx context.Context, accountID string) (AccountCollectionStatus, error) { accountID = strings.TrimSpace(accountID) if accountID == "" { return AccountCollectionStatus{}, ErrInvalid } status := AccountCollectionStatus{} var total sql.NullInt64 err := s.db.QueryRowContext(ctx, ` SELECT (SELECT COUNT(*) FROM creator_work WHERE source_type = $1 AND source_id = $2), (SELECT metric.aweme_count FROM creator_account_metric metric JOIN social_account account ON account.id = metric.account_id WHERE account.account_id = $2 AND metric.aweme_count IS NOT NULL ORDER BY metric.collected_at DESC LIMIT 1)`, SourceOwned, accountID).Scan(&status.WorkCount, &total) if err != nil { return AccountCollectionStatus{}, databaseError(err) } if total.Valid { status.AwemeCount = &total.Int64 } for _, kind := range []struct { name string pointer *AccountCheckpointStatus }{ {name: "works", pointer: &status.Works}, {name: "comments", pointer: &status.Comments}, } { var state AccountCheckpointStatus var completed, windowEnd sql.NullTime err := s.db.QueryRowContext(ctx, ` SELECT status, last_completed_at, last_error, window_end FROM creator_collection_checkpoint WHERE source_type = $1 AND source_id = $2 AND collection_kind = $3`, SourceOwned, accountID, kind.name).Scan(&state.Status, &completed, &state.LastError, &windowEnd) if errors.Is(err, sql.ErrNoRows) { // 尚未建立 checkpoint:账号还没被调度器采集过。 *kind.pointer = AccountCheckpointStatus{Status: "pending"} continue } if err != nil { return AccountCollectionStatus{}, databaseError(err) } state.LastCompletedAt = nullableTime(completed) state.WindowEnd = nullableTime(windowEnd) if state.Status == "succeeded" && windowEnd.Valid { // 下次采集窗口按固定间隔网格推进(与调度器 NextCollectionWindow 同口径)。 settings, settingsErr := s.GetSettings(ctx) if settingsErr != nil { return AccountCollectionStatus{}, settingsErr } nextEnd := NextFixedRun(windowEnd.Time.UTC(), time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second) start := nextEnd.Add(-time.Duration(settings.LookbackDays) * 24 * time.Hour) state.NextWindowStart, state.NextWindowEnd = &start, &nextEnd } *kind.pointer = state } return status, nil } func validateProfileUpdate(input AccountProfileUpdate) error { if input.RealNameStatus != "unknown" && input.RealNameStatus != "not_real_name" && input.RealNameStatus != "recorded" { return ErrInvalid } if input.BusinessStatus != "normal" && input.BusinessStatus != "muted" && input.BusinessStatus != "banned" && input.BusinessStatus != "deleted" { return ErrInvalid } if !validCooldownSeconds(input.CooldownSeconds) || utf8.RuneCountInString(input.LoginUsername) > 255 || utf8.RuneCountInString(input.RealName) > 100 || utf8.RuneCountInString(input.IdentityNumber) > 64 || utf8.RuneCountInString(input.Note) > 1000 || utf8.RuneCountInString(input.ReplyRequirements) > 4000 { return ErrInvalid } return nil } func (s *Store) UpdateAccountProfile(ctx context.Context, accountID string, input AccountProfileUpdate) (profile AccountProfile, returnErr error) { input.LoginUsername = strings.TrimSpace(input.LoginUsername) input.RealName = strings.TrimSpace(input.RealName) input.IdentityNumber = strings.TrimSpace(input.IdentityNumber) input.Note = strings.TrimSpace(input.Note) input.ReplyRequirements = strings.TrimSpace(input.ReplyRequirements) if err := validateProfileUpdate(input); err != nil { return AccountProfile{}, err } if err := s.EnsureAccountProfile(ctx, accountID); err != nil { return AccountProfile{}, err } passwordConfigured := false var secret SecretReference var secretKey, oldSecretID, oldSecretKey string committed := false if input.Password != "" { if s.secrets == nil { return AccountProfile{}, ErrUnavailable } secret = SecretReference{ID: newID("password"), Provider: "os_keyring"} secretKey = "creatorhub/" + accountID + "/password/" + secret.ID if err := s.secrets.Store(ctx, secret, secretKey, input.Password); err != nil { return AccountProfile{}, fmt.Errorf("store account password: %w", err) } passwordConfigured = true defer func() { if !committed && returnErr != nil { if cleanupErr := s.secrets.Delete(ctx, secret, secretKey); cleanupErr != nil { returnErr = errors.Join(returnErr, cleanupErr) } } }() } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return AccountProfile{}, fmt.Errorf("begin account profile update: %w", err) } defer tx.Rollback() var lockedID string if err := tx.QueryRowContext(ctx, `SELECT account_id FROM social_account WHERE account_id=$1 FOR UPDATE`, accountID).Scan(&lockedID); err != nil { return AccountProfile{}, rowError(err) } if input.Password != "" { err := tx.QueryRowContext(ctx, `SELECT secret_reference_id,secret_key FROM creator_account_password p JOIN social_account account ON account.id = p.account_id WHERE account.account_id=$1 FOR UPDATE OF p`, accountID).Scan(&oldSecretID, &oldSecretKey) if err != nil && !errors.Is(err, sql.ErrNoRows) { return AccountProfile{}, databaseError(err) } } _, err = tx.ExecContext(ctx, ` UPDATE creator_account_profile SET login_username = $2, password_configured = CASE WHEN $3 THEN true ELSE password_configured END, real_name_status = $4, real_name = $5, identity_number = $6, note = $7, business_status = $8, big_account = $9, reply_requirements = $10, cooldown_seconds = $11, updated_at = now() WHERE account_id = (SELECT account.id FROM social_account account WHERE account.account_id = $1)`, accountID, input.LoginUsername, passwordConfigured, input.RealNameStatus, input.RealName, input.IdentityNumber, input.Note, input.BusinessStatus, input.BigAccount, input.ReplyRequirements, input.CooldownSeconds) if err != nil { if passwordConfigured { if deleteErr := s.secrets.Delete(ctx, secret, secretKey); deleteErr != nil { return AccountProfile{}, fmt.Errorf("save account profile: %w; remove password after failure: %v", databaseError(err), deleteErr) } } return AccountProfile{}, databaseError(err) } if input.Password != "" { if _, err := tx.ExecContext(ctx, `INSERT INTO creator_account_password (account_id,secret_reference_id,secret_key) SELECT account.id, $2, $3 FROM social_account account WHERE account.account_id = $1 ON CONFLICT (account_id) DO UPDATE SET secret_reference_id=EXCLUDED.secret_reference_id,secret_key=EXCLUDED.secret_key,updated_at=now()`, accountID, secret.ID, secretKey); err != nil { return AccountProfile{}, databaseError(err) } } if err := tx.Commit(); err != nil { return AccountProfile{}, fmt.Errorf("commit account profile update: %w", err) } committed = true if input.Password != "" && oldSecretID != "" && (oldSecretID != secret.ID || oldSecretKey != secretKey) { if err := s.secrets.Delete(ctx, SecretReference{ID: oldSecretID, Provider: "os_keyring"}, oldSecretKey); err != nil { return AccountProfile{}, fmt.Errorf("replace account password: remove old secret: %w", err) } } return s.GetAccountProfile(ctx, accountID) } func (s *Store) RecordLoginResult(ctx context.Context, accountID, status, reason, actualKey string) (LoginResult, error) { status = strings.TrimSpace(status) if status == "logged_in" { return LoginResult{}, ErrConflict } return s.recordLoginResult(ctx, accountID, status, reason, actualKey) } func (s *Store) RecordVerifiedLoginResult(ctx context.Context, accountID, actualKey string) (LoginResult, error) { actualKey = strings.TrimSpace(actualKey) if actualKey == "" || len(actualKey) > 128 { return LoginResult{}, ErrInvalid } if err := s.EnsureAccountProfile(ctx, accountID); err != nil { return LoginResult{}, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return LoginResult{}, fmt.Errorf("begin account UID verification: %w", err) } defer tx.Rollback() var id int64 var boundUID sql.NullString if err := tx.QueryRowContext(ctx, `SELECT id, platform_account_key FROM social_account WHERE account_id = $1 FOR UPDATE`, accountID).Scan(&id, &boundUID); err != nil { return LoginResult{}, databaseError(err) } if boundUID.Valid && boundUID.String != actualKey { return LoginResult{}, fmt.Errorf("%w: 已绑定 UID %s,当前浏览器登录 UID %s;请登录已绑定账号", ErrConflict, boundUID.String, actualKey) } if !boundUID.Valid { if _, err := tx.ExecContext(ctx, `UPDATE social_account SET platform_account_key = $2, version = version + 1, updated_at = now() WHERE id = $1`, id, actualKey); err != nil { if errors.Is(databaseError(err), ErrConflict) { return LoginResult{}, fmt.Errorf("%w: UID %s 已绑定到其他账号,请使用已有账号记录", ErrConflict, actualKey) } return LoginResult{}, databaseError(err) } } now := time.Now().UTC() if _, err := tx.ExecContext(ctx, `UPDATE creator_account_profile SET login_status = 'logged_in', login_reason = '', login_checked_at = $2, updated_at = $2 WHERE account_id = $1`, id, now); err != nil { return LoginResult{}, databaseError(err) } if err := tx.Commit(); err != nil { return LoginResult{}, fmt.Errorf("commit account UID verification: %w", err) } return LoginResult{AccountID: accountID, Status: "logged_in", ActualKey: actualKey, CheckedAt: now}, nil } func (s *Store) recordLoginResult(ctx context.Context, accountID, status, reason, actualKey string) (LoginResult, error) { status = strings.TrimSpace(status) reason = strings.TrimSpace(reason) if status != "needs_login" && status != "failed" && status != "manual_required" { return LoginResult{}, ErrInvalid } if len(actualKey) > 128 || utf8.RuneCountInString(reason) > 1000 { return LoginResult{}, ErrInvalid } if _, err := s.GetAccountProfile(ctx, accountID); err != nil { return LoginResult{}, err } now := time.Now().UTC() _, err := s.db.ExecContext(ctx, ` UPDATE creator_account_profile SET login_status = $2, login_reason = $3, login_checked_at = $4, updated_at = $4 WHERE account_id = (SELECT account.id FROM social_account account WHERE account.account_id = $1)`, accountID, status, reason, now) if err != nil { return LoginResult{}, databaseError(err) } return LoginResult{AccountID: accountID, Status: status, Reason: reason, ActualKey: actualKey, CheckedAt: now}, nil } func (s *Store) SetBigAccount(ctx context.Context, accountID string, enabled bool) (AccountProfile, error) { if accountID == "" { return AccountProfile{}, ErrInvalid } if err := s.EnsureAccountProfile(ctx, accountID); err != nil { return AccountProfile{}, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return AccountProfile{}, fmt.Errorf("begin creator big-account update: %w", err) } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `UPDATE creator_account_profile SET big_account = $2, updated_at = now() WHERE account_id = (SELECT account.id FROM social_account account WHERE account.account_id = $1)`, accountID, enabled); err != nil { return AccountProfile{}, databaseError(err) } if err := tx.Commit(); err != nil { return AccountProfile{}, fmt.Errorf("commit creator big-account update: %w", err) } return s.GetAccountProfile(ctx, accountID) } func (s *Store) AccountWriteCheck(ctx context.Context, accountID string, automatic bool, action string) (AccountProfile, error) { profile, err := s.GetAccountProfile(ctx, accountID) if err != nil { return AccountProfile{}, err } return profile, CanWrite(profile, automatic, action) }