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