483 lines
18 KiB
Go
483 lines
18 KiB
Go
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 "平台登录暂时不可用,请稍后重试"
|
||
}
|