Files
EveryPublish/server/internal/api/handlers/account.go
T

483 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/credentials"
"everypublish/server/internal/models"
"everypublish/server/internal/platform"
"everypublish/server/internal/ws"
)
// AccountHandler 账号台账处理器(Web-only 本机执行器)。
type AccountHandler struct {
DB *gorm.DB
Hub *ws.Hub
Registry *platform.Registry
Credentials *credentials.Store
ExecutorMode string
PollInterval time.Duration
pollMu sync.Mutex
pollCancels map[uint64]context.CancelFunc
pollGeneration map[uint64]uint64
pollSeq uint64
}
var platforms = map[string]bool{
"douyin": true, "kuaishou": true, "xiaohongshu": true, "bilibili": true,
"shipinhao": true, "x": true, "instagram": true, "whatsapp": true, "youtube": true,
}
type accountReq struct {
Platform string `json:"platform" binding:"required"`
Remark string `json:"remark"`
AccountName string `json:"accountName"`
AvatarURL string `json:"avatarUrl"`
IPProfile string `json:"ipProfile"`
}
type credentialsReq struct {
Cookies string `json:"cookies" binding:"required"`
}
// List 账号台账(筛选 platform/status)
func (h *AccountHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.DB.Model(&models.Account{}).Where("workspace_id = ?", c.GetUint64("wsid"))
if p := c.Query("platform"); p != "" {
q = q.Where("platform = ?", p)
}
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
var total int64
q.Count(&total)
var list []models.Account
q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list)
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// Create 新增账号(初始 unbound;V1 凭据由本机 Web executor 管理)
func (h *AccountHandler) Create(c *gin.Context) {
var req accountReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
if !platforms[req.Platform] {
response.Fail(c, http.StatusBadRequest, 1001, "暂不支持该平台")
return
}
account := models.Account{
WorkspaceID: c.GetUint64("wsid"),
Platform: req.Platform,
AccountName: req.AccountName,
Remark: req.Remark,
AvatarURL: req.AvatarURL,
IPProfile: req.IPProfile,
Status: "unbound",
Health: 100,
}
if err := h.DB.Create(&account).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
return
}
response.Audit(c, "account.create", "account:"+itoa(account.ID), gin.H{"platform": req.Platform, "remark": req.Remark})
response.OK(c, account)
}
// Update 修改账号资料(名称/头像/IP画像)
func (h *AccountHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req struct {
Remark string `json:"remark"`
AccountName string `json:"accountName"`
AvatarURL string `json:"avatarUrl"`
IPProfile string `json:"ipProfile"`
}
if err = c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
updates := map[string]interface{}{"remark": req.Remark, "avatar_url": req.AvatarURL, "ip_profile": req.IPProfile}
if req.AccountName != "" {
updates["account_name"] = req.AccountName
}
if err = h.DB.Model(&account).Updates(updates).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "account.update", "account:"+itoa(id), gin.H{"remark": req.Remark})
response.OK(c, nil)
}
// Delete 删除账号(仅未绑定)
func (h *AccountHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
if account.Status == "active" || account.Status == "binding" {
response.Fail(c, http.StatusConflict, 3001, "已绑定或绑定中的账号不可删除")
return
}
if err = h.DB.Delete(&account).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "删除失败")
return
}
if h.Credentials != nil {
_ = h.Credentials.Delete(account.WorkspaceID, account.ID)
}
response.Audit(c, "account.delete", "account:"+itoa(id), nil)
response.OK(c, nil)
}
// Bind 发起绑定:创建网页端挑战,由当前服务器上的 Web adapter 继续处理。
func (h *AccountHandler) Bind(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
if account.Status == "binding" {
var existing models.Challenge
if findErr := h.DB.Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Order("id desc").First(&existing).Error; findErr == nil {
response.OK(c, gin.H{"challengeId": existing.ID, "account": account.ID, "status": "binding", "qrUrl": existing.QRURL, "qrToken": existing.QRToken, "prompt": existing.Prompt, "expiresAt": existing.ExpiresAt})
return
}
}
var staleChallenges []models.Challenge
_ = h.DB.Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Find(&staleChallenges).Error
for _, stale := range staleChallenges {
h.cancelLoginPoll(stale.ID)
}
if len(staleChallenges) > 0 {
_ = h.DB.Model(&models.Challenge{}).Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Update("status", "suspended").Error
}
// A registered adapter owns the login session. The browser receives one QR
// URL and the server polls it; no second client-side pairing code is needed.
var adapter platform.Adapter
if h.ExecutorMode != "mock" && h.Registry != nil {
adapter = h.Registry.Get(account.Platform)
}
var session platform.LoginSession
if adapter != nil {
var beginErr error
session, beginErr = adapter.BeginLogin(c.Request.Context())
if beginErr != nil {
response.Fail(c, http.StatusBadGateway, 2007, adapterErrorMessage(beginErr))
return
}
}
challenge := models.Challenge{
WorkspaceID: c.GetUint64("wsid"),
AccountID: account.ID,
Platform: account.Platform,
Kind: "qr",
Status: "active",
Prompt: "请在网页中完成 " + account.Platform + " 登录",
QRToken: session.Token,
QRURL: session.URL,
ExpiresAt: time.Now().Add(30 * time.Minute),
}
if adapter == nil {
challenge.QRURL = "mock://everypublish/login/" + strconv.FormatUint(account.ID, 10)
}
err = h.DB.Transaction(func(tx *gorm.DB) error {
if err = tx.Create(&challenge).Error; err != nil {
return err
}
return tx.Model(&account).Update("status", "binding").Error
})
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "发起绑定失败")
return
}
if h.Hub != nil {
h.Hub.BroadcastWS(c.GetUint64("wsid"), "challenge.status", gin.H{
"challengeId": challenge.ID, "accountId": account.ID, "status": challenge.Status,
"kind": challenge.Kind, "platform": challenge.Platform, "qrUrl": challenge.QRURL,
"qrToken": challenge.QRToken, "prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt,
})
}
response.Audit(c, "account.bind", "account:"+itoa(account.ID), gin.H{"challengeId": challenge.ID})
if adapter != nil {
h.StartLoginPoll(challenge, account, c.GetUint64("uid"), adapter)
}
response.OK(c, gin.H{"challengeId": challenge.ID, "account": account.ID, "status": "binding", "qrUrl": challenge.QRURL, "qrToken": challenge.QRToken, "prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt})
}
// ImportCredentials accepts a user-supplied platform cookie string. The value
// is written directly to the encrypted adapter store and never returned or
// included in audit details.
func (h *AccountHandler) ImportCredentials(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req credentialsReq
if err = c.ShouldBindJSON(&req); err != nil || len(strings.TrimSpace(req.Cookies)) < 8 || len(req.Cookies) > 64*1024 {
response.Fail(c, http.StatusBadRequest, 1001, "凭据格式不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
if h.ExecutorMode == "mock" || h.Registry == nil {
response.Fail(c, http.StatusConflict, 3006, "当前 mock 模式不支持真实凭据导入")
return
}
adapter := h.Registry.Get(account.Platform)
if adapter == nil {
response.Fail(c, http.StatusConflict, 3006, "该平台尚未接入真实适配器")
return
}
if err = adapter.SaveCredentials(account.WorkspaceID, account.ID, strings.TrimSpace(req.Cookies)); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, adapterErrorMessage(err))
return
}
now := time.Now()
if err = h.DB.Model(&account).Updates(map[string]interface{}{"status": "active", "last_active_at": &now, "last_error": ""}).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "账号状态更新失败")
return
}
var activeChallenges []models.Challenge
_ = h.DB.Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Find(&activeChallenges).Error
for _, challenge := range activeChallenges {
h.cancelLoginPoll(challenge.ID)
}
_ = h.DB.Model(&models.Challenge{}).Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Update("status", "suspended").Error
_ = Notify(h.DB, account.WorkspaceID, []uint64{c.GetUint64("uid")}, "challenge", "账号凭据已导入", "账号已恢复可用")
if h.Hub != nil {
h.Hub.BroadcastWS(account.WorkspaceID, "account.status", gin.H{"accountId": account.ID, "status": "active", "platform": account.Platform, "prompt": "凭据已导入"})
}
response.Audit(c, "account.credentials_import", "account:"+itoa(account.ID), gin.H{"platform": account.Platform})
response.OK(c, gin.H{"accountId": account.ID, "status": "active", "lastActiveAt": now})
}
func (h *AccountHandler) Check(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
if h.Registry == nil || h.ExecutorMode == "mock" {
response.Fail(c, http.StatusConflict, 3006, "当前模式不支持真实账号检查")
return
}
checker, ok := h.Registry.Get(account.Platform).(platform.CredentialChecker)
if !ok {
response.Fail(c, http.StatusConflict, 3006, "该平台尚未提供账号检查")
return
}
if err = checker.CheckCredentials(c.Request.Context(), account.WorkspaceID, account.ID); err != nil {
message := adapterErrorMessage(err)
_ = h.DB.Model(&account).Updates(map[string]interface{}{"status": "expired", "last_error": message}).Error
response.Fail(c, http.StatusBadGateway, 2007, message)
return
}
now := time.Now()
_ = h.DB.Model(&account).Updates(map[string]interface{}{"status": "active", "last_active_at": &now, "last_error": ""}).Error
response.Audit(c, "account.credentials_check", "account:"+itoa(account.ID), gin.H{"platform": account.Platform})
response.OK(c, gin.H{"accountId": account.ID, "status": "active", "lastActiveAt": now})
}
// Unbind 解绑账号,清除本机执行器关联但保留账号台账记录。
func (h *AccountHandler) Unbind(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
if err = h.DB.Model(&account).Updates(map[string]interface{}{
"status": "unbound", "account_name": "", "last_error": "",
}).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "解绑失败")
return
}
_ = h.DB.Model(&models.Challenge{}).Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Update("status", "suspended").Error
if h.Credentials != nil {
_ = h.Credentials.Delete(account.WorkspaceID, account.ID)
}
response.Audit(c, "account.unbind", "account:"+itoa(id), nil)
response.OK(c, nil)
}
func (h *AccountHandler) pollLogin(ctx context.Context, challengeID uint64, account models.Account, userID uint64, adapter platform.Adapter) {
interval := h.PollInterval
if interval <= 0 {
interval = 2 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
var challenge models.Challenge
if err := h.DB.Where("id = ? and workspace_id = ?", challengeID, account.WorkspaceID).First(&challenge).Error; err != nil || challenge.Status != "active" {
return
}
if time.Now().After(challenge.ExpiresAt) {
h.finishLogin(challenge, account, userID, "expired", "登录二维码已过期")
return
}
poll, err := adapter.PollLogin(ctx, challenge.QRToken)
if err == nil {
switch poll.State {
case platform.LoginPending, platform.LoginScanned:
prompt := poll.Prompt
if prompt == "" {
prompt = challenge.Prompt
}
_ = h.DB.Model(&challenge).Update("prompt", prompt).Error
h.broadcastChallenge(challenge, string(poll.State), prompt)
case platform.LoginConfirmed:
if saveErr := adapter.SaveCredentials(account.WorkspaceID, account.ID, poll.Cookies); saveErr != nil {
h.finishLogin(challenge, account, userID, "suspended", adapterErrorMessage(saveErr))
return
}
now := time.Now()
if h.DB.Model(&challenge).Updates(map[string]interface{}{"status": "solved", "solved_at": &now, "prompt": "登录成功"}).Error != nil {
return
}
_ = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ?", account.ID, account.WorkspaceID).Updates(map[string]interface{}{"status": "active", "last_active_at": &now, "last_error": ""}).Error
_ = Notify(h.DB, account.WorkspaceID, []uint64{userID}, "challenge", "登录挑战已完成", "账号已恢复可用")
h.broadcastChallenge(challenge, "solved", "登录成功")
return
}
} else if errors.Is(err, context.Canceled) {
return
} else if errors.Is(err, context.DeadlineExceeded) {
h.finishLogin(challenge, account, userID, "expired", "登录等待已超时")
return
} else if platform.CategoryOf(err) != platform.Network {
h.finishLogin(challenge, account, userID, "suspended", adapterErrorMessage(err))
return
}
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
h.finishLogin(challenge, account, userID, "expired", "登录等待已超时")
}
return
case <-ticker.C:
}
}
}
// StartLoginPoll is shared with challenge resend so every real QR session has
// exactly one backend worker responsible for polling and credential persistence.
func (h *AccountHandler) StartLoginPoll(challenge models.Challenge, account models.Account, userID uint64, adapter platform.Adapter) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
h.pollMu.Lock()
if h.pollCancels == nil {
h.pollCancels = make(map[uint64]context.CancelFunc)
}
if h.pollGeneration == nil {
h.pollGeneration = make(map[uint64]uint64)
}
if old := h.pollCancels[challenge.ID]; old != nil {
old()
}
h.pollSeq++
generation := h.pollSeq
h.pollCancels[challenge.ID] = cancel
h.pollGeneration[challenge.ID] = generation
h.pollMu.Unlock()
go func() {
h.pollLogin(ctx, challenge.ID, account, userID, adapter)
h.pollMu.Lock()
if h.pollGeneration[challenge.ID] == generation {
delete(h.pollCancels, challenge.ID)
delete(h.pollGeneration, challenge.ID)
}
h.pollMu.Unlock()
}()
}
func (h *AccountHandler) cancelLoginPoll(challengeID uint64) {
h.pollMu.Lock()
cancel := h.pollCancels[challengeID]
h.pollMu.Unlock()
if cancel != nil {
cancel()
}
}
func (h *AccountHandler) finishLogin(challenge models.Challenge, account models.Account, userID uint64, status, message string) {
_ = h.DB.Model(&challenge).Updates(map[string]interface{}{"status": status, "prompt": message}).Error
accountStatus := "expired"
if status == "suspended" {
accountStatus = "suspended"
}
_ = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ?", account.ID, account.WorkspaceID).Updates(map[string]interface{}{"status": accountStatus, "last_error": message}).Error
_ = Notify(h.DB, account.WorkspaceID, []uint64{userID}, "challenge", "登录需要处理", message)
h.broadcastChallenge(challenge, status, message)
}
func (h *AccountHandler) broadcastChallenge(challenge models.Challenge, status, prompt string) {
if h.Hub == nil {
return
}
h.Hub.BroadcastWS(challenge.WorkspaceID, "challenge.status", gin.H{
"challengeId": challenge.ID, "accountId": challenge.AccountID, "status": status,
"kind": challenge.Kind, "platform": challenge.Platform, "prompt": prompt,
})
}
func adapterErrorMessage(err error) string {
var pe *platform.Error
if errors.As(err, &pe) && strings.TrimSpace(pe.Message) != "" {
return pe.Message
}
return "平台登录暂时不可用,请稍后重试"
}