396 lines
15 KiB
Go
396 lines
15 KiB
Go
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) VALUES ($1)
|
|
ON CONFLICT (account_id) DO NOTHING`, accountID)
|
|
return databaseError(err)
|
|
}
|
|
|
|
func accountProfileQuery() string {
|
|
return `
|
|
SELECT a.id, a.name, a.platform, a.platform_account_key,
|
|
a.authorization_kind, a.authorization_status, 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
|
|
FROM social_account a
|
|
JOIN creator_account_profile p ON p.account_id = a.id
|
|
WHERE a.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.AuthorizationKind, &result.AuthorizationStatus, &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,
|
|
); 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 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.id = $1", "ORDER BY a.created_at DESC, a.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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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 = stat.WorkCount, stat.LatestPublishedAt
|
|
}
|
|
views = append(views, view)
|
|
}
|
|
return views, nil
|
|
}
|
|
|
|
type ownedWorkStat struct {
|
|
WorkCount int64
|
|
LatestPublishedAt *time.Time
|
|
}
|
|
|
|
// ownedWorkStats 按 source_id 聚合 owned 作品数与最近发布时间(creator_work_source 与 creator_work 已保持一致)。
|
|
func (s *Store) ownedWorkStats(ctx context.Context) (map[string]ownedWorkStat, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT ws.source_id, COUNT(*) AS work_count, MAX(w.published_at) AS latest_published_at
|
|
FROM creator_work_source ws JOIN creator_work w ON w.id = ws.work_id
|
|
WHERE ws.source_type = 'owned'
|
|
GROUP BY ws.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); err != nil {
|
|
return nil, err
|
|
}
|
|
stat.LatestPublishedAt = nullableTime(latest)
|
|
stats[sourceID] = stat
|
|
}
|
|
return stats, rows.Err()
|
|
}
|
|
|
|
// AccountCollectionStatus 自有账号采集状态(checkpoint 形态,对齐竞品的 sync_status 展示语义)。
|
|
type AccountCollectionStatus struct {
|
|
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{}
|
|
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 id FROM social_account WHERE 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 WHERE account_id=$1 FOR UPDATE`, 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 = $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) VALUES ($1,$2,$3) 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 == "" {
|
|
return LoginResult{}, ErrInvalid
|
|
}
|
|
return s.recordLoginResult(ctx, accountID, "logged_in", "", actualKey)
|
|
}
|
|
|
|
func (s *Store) recordLoginResult(ctx context.Context, accountID, status, reason, actualKey string) (LoginResult, error) {
|
|
status = strings.TrimSpace(status)
|
|
reason = strings.TrimSpace(reason)
|
|
if status != "logged_in" && status != "needs_login" && status != "failed" && status != "manual_required" {
|
|
return LoginResult{}, ErrInvalid
|
|
}
|
|
if len(actualKey) > 128 || utf8.RuneCountInString(reason) > 1000 {
|
|
return LoginResult{}, ErrInvalid
|
|
}
|
|
profile, err := s.GetAccountProfile(ctx, accountID)
|
|
if err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
if status == "logged_in" && (actualKey == "" || actualKey != profile.PlatformAccountKey) {
|
|
return LoginResult{}, ErrConflict
|
|
}
|
|
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 = $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 = $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)
|
|
}
|