Files
creator-hub/internal/controlplane/api/accounts_operations.go
T
rogee eab193007c
douyin-release-gate / verify (push) Failing after 3m43s
refactor(accounts): 移除「授权状态」概念——账号收敛为启用/暂停单一状态机
产品已收敛为自有账号管理,撤销授权在 UI 无入口、状态恒为 authorized,属废弃语义:
- migration 1045:social_account DROP authorization_status/revoked_at/authorization_kind
- 删除 revoke API 路由与 RevokeAccount/disableAccount 状态机分支(PauseAccount 独立)
- accountRunnable/就绪判定/采集过滤/登录校验删除 authorization_status 检查
- EnvironmentContext/AccountProfile 契约删字段;前端删「已授权/已撤销」展示与 readiness 分支
- 测试同步:revoke 流程/409 用例删除,seed 语句去列;dev 库测试遗留 revoked 账号待 UI 删除
2026-09-30 14:18:34 +08:00

323 lines
12 KiB
Go

package api
import (
"bytes"
"encoding/json"
"errors"
"io"
"strconv"
"strings"
"time"
accountdomain "git.ipao.vip/rogee/creator-hub/internal/account"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
)
type accountRequest struct {
Name string `json:"name"`
Platform string `json:"platform"`
PlatformAccountKey string `json:"platform_account_key"`
Tags []string `json:"tags"`
Cookies string `json:"cookies"`
Fingerprint hub.Fingerprint `json:"fingerprint"`
}
// RegisterAccountRoutes exposes account lifecycle routes (create with auto-binding/list/detail/补建/start/pause/resume/revoke + audit).
func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeStore HubStore, credentials accountdomain.CredentialBridge) {
app.Post("/api/phase-a/accounts", func(c fiber.Ctx) error {
var input accountRequest
if err := decodePhaseA(c, &input); err != nil {
return phaseAError(c, err)
}
// 指纹表单校验前置:非法值直接 400,避免账号已建、环境绑定失败的中间态。
input.Fingerprint.ProxyServer = ""
input.Fingerprint.DisableNonProxiedUDP = false
if err := input.Fingerprint.Validate(); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "fingerprint: " + err.Error()})
}
tags := input.Tags
if tags == nil {
tags = []string{}
}
for index := range tags {
tags[index] = strings.TrimSpace(tags[index])
}
accountID := accountdomain.NewAccountID()
account := accountdomain.Account{
ID: accountID, Name: strings.TrimSpace(input.Name), Platform: strings.TrimSpace(input.Platform),
PlatformAccountKey: strings.TrimSpace(input.PlatformAccountKey), Tags: tags, Cookies: strings.TrimSpace(input.Cookies),
CredentialReference: accountdomain.CredentialReference{ID: accountID + "-cookies", Provider: "os_keyring"},
CredentialKey: "creatorhub/" + accountID + "/cookies",
RuntimeStatus: "paused", Version: 1,
}
if err := store.CreateAccount(c.Context(), account, credentials); err != nil {
if errors.Is(err, accountdomain.ErrAccountCreationUnknown) {
return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{
"error": "account creation result is unknown", "reason_code": "account_creation_result_unknown", "account_id": accountID,
})
}
return phaseAError(c, err)
}
// 账号即环境:创建即绑定(幂等)。绑定失败透传原因与账号 ID,客户端可用幂等补建端点重试。
if runtimeStore != nil {
if _, _, err := ensureAccountEnvironment(c.Context(), runtimeStore, accountID, input.Fingerprint); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{
"error": err.Error(), "reason_code": "environment_binding_failed", "account_id": accountID,
})
}
}
return c.Status(fiber.StatusCreated).JSON(account)
})
app.Get("/api/phase-a/accounts", func(c fiber.Ctx) error {
accounts, err := store.ListAccounts(c.Context())
if err != nil {
return phaseAError(c, err)
}
return c.JSON(accounts)
})
app.Get("/api/phase-a/accounts/:id", accountDetailPayload(store, runtimeStore))
app.Post("/api/phase-a/accounts/:id/pause", func(c fiber.Ctx) error {
unlock, err := lockAccountResources(c.Context(), runtimeStore, c.Params("id"))
if err != nil {
return hubError(c, err)
}
defer unlock()
if err := store.PauseAccount(c.Context(), c.Params("id")); err != nil {
return phaseAError(c, err)
}
if runtimeStore != nil {
if err := stopAccountRuntime(c.Context(), runtimeStore, c.Params("id")); err != nil {
return hubError(c, err)
}
}
return c.SendStatus(fiber.StatusNoContent)
})
app.Post("/api/phase-a/accounts/:id/resume", func(c fiber.Ctx) error {
unlock, err := lockAccountResources(c.Context(), runtimeStore, c.Params("id"))
if err != nil {
return hubError(c, err)
}
defer unlock()
if err := store.ResumeAccount(c.Context(), c.Params("id")); err != nil {
if errors.Is(err, accountdomain.ErrConflict) {
return accountResumeConflict(c, store, runtimeStore, c.Params("id"))
}
return phaseAError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
})
// 账号即环境:幂等补建运行环境(网关缺失/多网关透传错误)。
app.Post("/api/phase-a/accounts/:id/environment", func(c fiber.Ctx) error {
if runtimeStore == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{"error": "environment store unavailable"})
}
// 幂等补建:空体保持零值指纹(seed 仍由账号派生);可携带创建时未落库的指纹表单重试。
var rebind struct {
Fingerprint hub.Fingerprint `json:"fingerprint"`
}
if len(c.Body()) > 0 {
if err := decodePhaseA(c, &rebind); err != nil {
return phaseAError(c, err)
}
}
rebind.Fingerprint.ProxyServer = ""
rebind.Fingerprint.DisableNonProxiedUDP = false
if err := rebind.Fingerprint.Validate(); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "fingerprint: " + err.Error()})
}
unlock, err := lockAccountResources(c.Context(), runtimeStore, c.Params("id"))
if err != nil {
return hubError(c, err)
}
defer unlock()
environment, created, err := ensureAccountEnvironment(c.Context(), runtimeStore, c.Params("id"), rebind.Fingerprint)
if err != nil {
return hubError(c, err)
}
return c.JSON(map[string]any{"alias": environment.Alias, "gateway": environment.Gateway, "created": created})
})
// 账号即环境:start = resume + 启动环境;停止沿用 pause(已联动停环境)。
app.Post("/api/phase-a/accounts/:id/start", func(c fiber.Ctx) error {
if runtimeStore == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{"error": "environment store unavailable"})
}
unlock, err := lockAccountResources(c.Context(), runtimeStore, c.Params("id"))
if err != nil {
return hubError(c, err)
}
defer unlock()
if err := store.ResumeAccount(c.Context(), c.Params("id")); err != nil {
if errors.Is(err, accountdomain.ErrConflict) {
return accountResumeConflict(c, store, runtimeStore, c.Params("id"))
}
return phaseAError(c, err)
}
if err := startAccountEnvironment(c.Context(), runtimeStore, c.Params("id")); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
})
app.Get("/api/phase-a/audit", func(c fiber.Ctx) error {
filter, err := auditFilter(c)
if err != nil {
return phaseAError(c, err)
}
events, err := store.ListAudit(c.Context(), filter)
if err != nil {
return phaseAError(c, err)
}
return c.JSON(events)
})
}
func auditFilter(c fiber.Ctx) (accountdomain.AuditFilter, error) {
filter := accountdomain.AuditFilter{
AccountID: c.Query("account_id"),
BrowserEnvAlias: c.Query("browser_env_alias"), NetworkExitID: c.Query("network_exit_id"),
EventType: c.Query("event_type"), Page: 1, PageSize: 25,
}
for _, field := range []struct {
value string
target *int
}{{c.Query("page"), &filter.Page}, {c.Query("page_size"), &filter.PageSize}} {
value, target := field.value, field.target
if value == "" {
continue
}
parsed, err := strconv.Atoi(value)
if err != nil {
return accountdomain.AuditFilter{}, accountdomain.ErrInvalid
}
*target = parsed
}
for _, field := range []struct {
value string
target **time.Time
}{{c.Query("from"), &filter.From}, {c.Query("to"), &filter.To}} {
value, target := field.value, field.target
if value == "" {
continue
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return accountdomain.AuditFilter{}, accountdomain.ErrInvalid
}
*target = &parsed
}
return filter, nil
}
func resumeBlockReason(account accountdomain.Account, environment hub.EnvironmentContext, bindingFound bool) string {
switch {
case !bindingFound:
return "binding_missing"
case environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy":
return "network_exit_unhealthy"
case environment.RuntimeCleanupPending:
return "runtime_stop_pending"
case environment.RuntimeID != "":
return "runtime_active"
default:
return "account_conflict"
}
}
func accountResumeConflict(c fiber.Ctx, store *accountdomain.Store, runtimeStore RuntimeStopStore, accountID string) error {
account, err := store.GetAccount(c.Context(), accountID)
if err != nil {
return phaseAError(c, err)
}
var environment hub.EnvironmentContext
bindingFound := false
if runtimeStore != nil {
environment, err = runtimeStore.GetEnvironmentContextForAccount(c.Context(), accountID)
bindingFound = err == nil
if err != nil && !errors.Is(err, hub.ErrNotFound) {
return phaseAError(c, err)
}
}
return c.Status(fiber.StatusConflict).JSON(map[string]string{
"error": accountdomain.ErrConflict.Error(), "reason_code": resumeBlockReason(account, environment, bindingFound), "readiness": "blocked",
})
}
func decodePhaseA(c fiber.Ctx, destination any) error {
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return accountdomain.ErrInvalid
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return accountdomain.ErrInvalid
}
return nil
}
func phaseAError(c fiber.Ctx, err error) error {
status := fiber.StatusInternalServerError
message := "phase A operation failed"
var readiness *accountdomain.ReadinessError
switch {
case errors.As(err, &readiness):
status, message = fiber.StatusConflict, "phase A version or account state changed"
if readiness.Unavailable {
status, message = fiber.StatusServiceUnavailable, "phase A resources are not ready"
}
return c.Status(status).JSON(map[string]string{"error": message, "reason_code": readiness.Reason})
case errors.Is(err, accountdomain.ErrInvalid):
status, message = fiber.StatusBadRequest, accountdomain.ErrInvalid.Error()
case errors.Is(err, accountdomain.ErrConflict):
status, message = fiber.StatusConflict, accountdomain.ErrConflict.Error()
case errors.Is(err, accountdomain.ErrNotFound):
status, message = fiber.StatusNotFound, accountdomain.ErrNotFound.Error()
}
return c.Status(status).JSON(map[string]string{"error": message})
}
// PhaseAError maps account errors to the existing HTTP contract.
func PhaseAError(c fiber.Ctx, err error) error {
return phaseAError(c, err)
}
// accountEnvironmentView 账号详情/出口重绑共用:账号即环境下内联展示的环境视图(schedule_block_reason 取值同 envView)。
func accountEnvironmentView(environment hub.EnvironmentContext) map[string]any {
scheduleStatus, scheduleBlockReason := environmentScheduleReadiness(environment)
return map[string]any{
"alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway,
"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID,
"runtime_cleanup_pending": environment.RuntimeCleanupPending,
"network_exit_id": environment.Exit.ID, "network_exit_health": environment.Exit.HealthStatus,
"schedule_status": scheduleStatus, "schedule_block_reason": scheduleBlockReason,
}
}
// accountDetailPayload 账号详情:环境信息内联在响应中(账号即环境),前端无需单独请求 /browsers。
func accountDetailPayload(store *accountdomain.Store, runtimeStore HubStore) fiber.Handler {
return func(c fiber.Ctx) error {
account, err := store.GetAccount(c.Context(), c.Params("id"))
if err != nil {
return phaseAError(c, err)
}
payload := map[string]any{
"id": account.ID, "name": account.Name, "platform": account.Platform,
"platform_account_key": account.PlatformAccountKey, "tags": account.Tags,
"runtime_status": account.RuntimeStatus,
"version": account.Version,
}
if runtimeStore != nil {
if environment, envErr := runtimeStore.GetEnvironmentContextForAccount(c.Context(), account.ID); envErr == nil {
payload["environment"] = accountEnvironmentView(environment)
}
}
return c.JSON(payload)
}
}