Files
creator-hub/internal/creator/accounts.go
T

292 lines
11 KiB
Go

package creator
import (
"context"
"database/sql"
"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_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.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) 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()
}
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 input.CooldownSeconds <= 0 || 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) (AccountProfile, 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 string
if input.Password != "" {
if s.secrets == nil {
return AccountProfile{}, ErrUnavailable
}
secret = SecretReference{ID: accountID + "-password", Provider: "os_keyring"}
secretKey = "creatorhub/" + accountID + "/password"
if err := s.secrets.Store(ctx, secret, secretKey, input.Password); err != nil {
return AccountProfile{}, fmt.Errorf("store account password: %w", err)
}
passwordConfigured = true
}
_, err := s.db.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)
}
return s.GetAccountProfile(ctx, accountID)
}
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" && strings.TrimSpace(actualKey) != "" && actualKey != profile.PlatformAccountKey {
status = "manual_required"
reason = "登录身份与登记账号不一致"
}
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 enabled {
var isSmall bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, accountID).Scan(&isSmall); err != nil {
return AccountProfile{}, databaseError(err)
}
if isSmall {
return AccountProfile{}, ErrConflict
}
}
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 !enabled {
if _, err := tx.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1`, accountID); 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) ListRelations(ctx context.Context, bigAccountID string) ([]Relation, error) {
query := `SELECT big_account_id, small_account_id FROM creator_relation ORDER BY created_at, small_account_id`
args := []any{}
if bigAccountID != "" {
query = `SELECT big_account_id, small_account_id FROM creator_relation WHERE big_account_id = $1 ORDER BY created_at, small_account_id`
args = append(args, bigAccountID)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]Relation, 0)
for rows.Next() {
var relation Relation
if err := rows.Scan(&relation.BigAccountID, &relation.SmallAccountID); err != nil {
return nil, err
}
result = append(result, relation)
}
return result, rows.Err()
}
func (s *Store) SetRelation(ctx context.Context, bigAccountID, smallAccountID string, enabled bool) error {
if bigAccountID == "" || smallAccountID == "" || bigAccountID == smallAccountID {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin creator relation: %w", err)
}
defer tx.Rollback()
var bigPlatform, smallPlatform string
if err := tx.QueryRowContext(ctx, `SELECT platform FROM social_account WHERE id = $1`, bigAccountID).Scan(&bigPlatform); err != nil {
return rowError(err)
}
if err := tx.QueryRowContext(ctx, `SELECT platform FROM social_account WHERE id = $1`, smallAccountID).Scan(&smallPlatform); err != nil {
return rowError(err)
}
if !ValidatePlatform(bigPlatform) || bigPlatform != smallPlatform {
return ErrInvalid
}
var bigEnabled bool
if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT big_account FROM creator_account_profile WHERE account_id=$1), false)`, bigAccountID).Scan(&bigEnabled); err != nil {
return databaseError(err)
}
if enabled && !bigEnabled {
return ErrConflict
}
if enabled {
var smallIsBig, bigIsSmall bool
if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT big_account FROM creator_account_profile WHERE account_id = $1), false)`, smallAccountID).Scan(&smallIsBig); err != nil {
return rowError(err)
}
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, bigAccountID).Scan(&bigIsSmall); err != nil {
return databaseError(err)
}
if smallIsBig || bigIsSmall {
return ErrConflict
}
if _, err := tx.ExecContext(ctx, `INSERT INTO creator_relation (big_account_id, small_account_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, bigAccountID, smallAccountID); err != nil {
return databaseError(err)
}
} else {
if _, err := tx.ExecContext(ctx, `DELETE FROM creator_relation WHERE big_account_id = $1 AND small_account_id = $2`, bigAccountID, smallAccountID); err != nil {
return databaseError(err)
}
if _, err := tx.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 AND execution_account_id=$2`, bigAccountID, smallAccountID); err != nil {
return databaseError(err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit creator relation: %w", err)
}
return nil
}
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)
}