server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物
This commit is contained in:
@@ -1,24 +1,36 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"everypublish/server/internal/api/response"
|
||||
"everypublish/server/internal/credentials"
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/platform"
|
||||
"everypublish/server/internal/ws"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// AccountHandler 账号台账处理器(凭据仅在 Agent 本机保险库,服务器零凭据)
|
||||
// AccountHandler 账号台账处理器(Web-only 本机执行器)。
|
||||
type AccountHandler struct {
|
||||
DB *gorm.DB
|
||||
Hub *ws.Hub
|
||||
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{
|
||||
@@ -27,10 +39,15 @@ var platforms = map[string]bool{
|
||||
}
|
||||
|
||||
type accountReq struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
AgentDeviceID uint64 `json:"agentDeviceId"`
|
||||
AccountName string `json:"accountName"`
|
||||
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)
|
||||
@@ -57,7 +74,7 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// Create 新增账号(初始 unbound;凭据只登记元数据)
|
||||
// Create 新增账号(初始 unbound;V1 凭据由本机 Web executor 管理)
|
||||
func (h *AccountHandler) Create(c *gin.Context) {
|
||||
var req accountReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -69,12 +86,14 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
account := models.Account{
|
||||
WorkspaceID: c.GetUint64("wsid"),
|
||||
Platform: req.Platform,
|
||||
Remark: req.Remark,
|
||||
AgentDeviceID: req.AgentDeviceID,
|
||||
Status: "unbound",
|
||||
Health: 100,
|
||||
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, "创建失败")
|
||||
@@ -91,19 +110,26 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var req accountReq
|
||||
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
|
||||
}
|
||||
updates := map[string]interface{}{"remark": req.Remark}
|
||||
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 req.AgentDeviceID > 0 {
|
||||
updates["agent_device_id"] = req.AgentDeviceID
|
||||
}
|
||||
if err = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).Updates(updates).Error; err != nil {
|
||||
if err = h.DB.Model(&account).Updates(updates).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
@@ -131,11 +157,14 @@ func (h *AccountHandler) Delete(c *gin.Context) {
|
||||
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 发起绑定:创建挑战记录(pending),等待客户端扫码(D4 WSS 联动)
|
||||
// Bind 发起绑定:创建网页端挑战,由当前服务器上的 Web adapter 继续处理。
|
||||
func (h *AccountHandler) Bind(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -147,19 +176,50 @@ func (h *AccountHandler) Bind(c *gin.Context) {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
if account.Status == "active" {
|
||||
response.Fail(c, http.StatusConflict, 3002, "账号已绑定")
|
||||
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: "pending",
|
||||
Kind: "qr",
|
||||
Status: "active",
|
||||
Prompt: "等待客户端发起扫码,稍后此处展示二维码",
|
||||
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
|
||||
@@ -170,26 +230,253 @@ func (h *AccountHandler) Bind(c *gin.Context) {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "发起绑定失败")
|
||||
return
|
||||
}
|
||||
// 定向下发:优先账号指定设备,否则工作空间首个在线设备
|
||||
if h.Hub != nil {
|
||||
target := account.AgentDeviceID
|
||||
if target == 0 || !h.Hub.AgentOnline(target) {
|
||||
if online := h.Hub.OnlineDevices(c.GetUint64("wsid")); len(online) > 0 {
|
||||
target = online[0]
|
||||
}
|
||||
}
|
||||
if target != 0 {
|
||||
env := proto.NewEnvelope(uuid.NewString(), proto.TypeChallenge, proto.Challenge{
|
||||
ChallengeID: strconv.FormatUint(challenge.ID, 10),
|
||||
AccountID: strconv.FormatUint(account.ID, 10),
|
||||
Platform: account.Platform,
|
||||
Kind: "qr",
|
||||
Prompt: "请使用手机客户端扫码登录 " + account.Platform,
|
||||
ExpiresAt: challenge.ExpiresAt.UnixMilli(),
|
||||
})
|
||||
_ = h.Hub.SendToAgent(target, env)
|
||||
}
|
||||
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})
|
||||
response.OK(c, gin.H{"challengeId": challenge.ID, "account": account.ID, "status": "binding"})
|
||||
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 "平台登录暂时不可用,请稍后重试"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/platform"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
gormsqlite "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type loginTestAdapter struct {
|
||||
mu sync.Mutex
|
||||
polls int
|
||||
saved string
|
||||
}
|
||||
|
||||
func (a *loginTestAdapter) Platform() string { return "test-platform" }
|
||||
func (a *loginTestAdapter) BeginLogin(context.Context) (platform.LoginSession, error) {
|
||||
return platform.LoginSession{Token: "token-1", URL: "https://qr.test/1"}, nil
|
||||
}
|
||||
func (a *loginTestAdapter) PollLogin(context.Context, string) (platform.LoginPoll, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.polls++
|
||||
if a.polls == 1 {
|
||||
return platform.LoginPoll{State: platform.LoginPending, Prompt: "等待扫码"}, nil
|
||||
}
|
||||
return platform.LoginPoll{State: platform.LoginConfirmed, Cookies: "cookie-value"}, nil
|
||||
}
|
||||
func (a *loginTestAdapter) SaveCredentials(_ uint64, _ uint64, cookies string) error {
|
||||
a.mu.Lock()
|
||||
a.saved = cookies
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (a *loginTestAdapter) Publish(context.Context, platform.PublishInput) (platform.PublishResult, error) {
|
||||
return platform.PublishResult{}, nil
|
||||
}
|
||||
|
||||
func TestAccountBindPollsAdapterAndActivatesAccount(t *testing.T) {
|
||||
db, err := gorm.Open(gormsqlite.Open(t.TempDir()+"/account.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.Account{}, &models.Challenge{}, &models.Notification{}, &models.AuditLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := models.Account{WorkspaceID: 9, Platform: "test-platform", Status: "unbound"}
|
||||
if err = db.Create(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter := &loginTestAdapter{}
|
||||
registry := platform.NewRegistry()
|
||||
if err = registry.Register(adapter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &AccountHandler{DB: db, Registry: registry, ExecutorMode: "web", PollInterval: 5 * time.Millisecond}
|
||||
gin.SetMode(gin.TestMode)
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/1/bind", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = req
|
||||
ctx.Params = gin.Params{{Key: "id", Value: "1"}}
|
||||
ctx.Set("wsid", uint64(9))
|
||||
ctx.Set("uid", uint64(3))
|
||||
h.Bind(ctx)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("bind status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ChallengeID uint64 `json:"challengeId"`
|
||||
QRURL string `json:"qrUrl"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Data.ChallengeID == 0 || envelope.Data.QRURL != "https://qr.test/1" {
|
||||
t.Fatalf("unexpected bind response: %s", recorder.Body.String())
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var got models.Account
|
||||
_ = db.First(&got, account.ID).Error
|
||||
if got.Status == "active" {
|
||||
adapter.mu.Lock()
|
||||
saved := adapter.saved
|
||||
adapter.mu.Unlock()
|
||||
if saved != "cookie-value" {
|
||||
t.Fatalf("credentials not saved: %q", saved)
|
||||
}
|
||||
var challenge models.Challenge
|
||||
_ = db.First(&challenge, envelope.Data.ChallengeID).Error
|
||||
if challenge.Status != "solved" {
|
||||
t.Fatalf("challenge status=%s", challenge.Status)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("adapter login worker did not activate account")
|
||||
}
|
||||
@@ -20,12 +20,11 @@ type AdminHandler struct {
|
||||
|
||||
type tenantRow struct {
|
||||
models.Workspace
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
DeviceCount int64 `json:"deviceCount"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
}
|
||||
|
||||
// Tenants 租户列表(含 owner 邮箱与成员/设备数)
|
||||
// Tenants 租户列表(含 owner 邮箱与成员数)
|
||||
func (h *AdminHandler) Tenants(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
@@ -51,7 +50,6 @@ func (h *AdminHandler) Tenants(c *gin.Context) {
|
||||
row.OwnerEmail = owner.Email
|
||||
}
|
||||
h.DB.Model(&models.Member{}).Where("workspace_id = ?", ws.ID).Count(&row.MemberCount)
|
||||
h.DB.Model(&models.AgentDevice{}).Where("workspace_id = ?", ws.ID).Count(&row.DeviceCount)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
response.OK(c, gin.H{"list": rows, "total": total, "page": page, "size": size})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -52,18 +55,45 @@ func (h *AgentHandler) PairCode(c *gin.Context) {
|
||||
// Pair 设备配对(无鉴权,凭一次性码;注册 Ed25519 公钥)
|
||||
func (h *AgentHandler) Pair(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
DeviceName string `json:"deviceName" binding:"required"`
|
||||
Code string `json:"code"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
OS string `json:"os"`
|
||||
Version string `json:"version"`
|
||||
PublicKey string `json:"publicKey" binding:"required"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "请求体必须是 JSON")
|
||||
return
|
||||
}
|
||||
req.Code = strings.ToUpper(strings.TrimSpace(req.Code))
|
||||
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
||||
req.PublicKey = strings.TrimSpace(req.PublicKey)
|
||||
if req.Code == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "连接码不能为空")
|
||||
return
|
||||
}
|
||||
if !validPairCode(req.Code) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "配对码应为 6 位大写字母或数字")
|
||||
return
|
||||
}
|
||||
if req.PublicKey == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "设备公钥不能为空,请重启客户端后重试")
|
||||
return
|
||||
}
|
||||
publicKey, keyErr := base64.StdEncoding.DecodeString(req.PublicKey)
|
||||
if keyErr != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "设备公钥格式不合法")
|
||||
return
|
||||
}
|
||||
if req.DeviceName == "" {
|
||||
req.DeviceName = "macOS Agent"
|
||||
}
|
||||
var pc models.PairingCode
|
||||
if err := h.DB.Where("code = ? and used = ?", req.Code, false).First(&pc).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "查询配对码失败")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 2006, "配对码无效")
|
||||
return
|
||||
}
|
||||
@@ -84,7 +114,7 @@ func (h *AgentHandler) Pair(c *gin.Context) {
|
||||
// 原子占用:仅当仍 unused 且未过期时置 used=true;RowsAffected==1 才视为抢到,
|
||||
// 防止并发用同一码配出多台设备。
|
||||
res := tx.Model(&models.PairingCode{}).
|
||||
Where("id = ? and used = ?", pc.ID, false).
|
||||
Where("id = ? and used = ? and expires_at > ?", pc.ID, false, time.Now()).
|
||||
Update("used", true)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
@@ -112,6 +142,18 @@ func (h *AgentHandler) Pair(c *gin.Context) {
|
||||
response.OK(c, gin.H{"deviceId": device.ID, "workspaceId": device.WorkspaceID})
|
||||
}
|
||||
|
||||
func validPairCode(code string) bool {
|
||||
if len(code) != 6 {
|
||||
return false
|
||||
}
|
||||
for _, r := range code {
|
||||
if !strings.ContainsRune(pairAlphabet, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Devices 设备列表(在线状态来自 Hub,此处以 DB 状态兜底)
|
||||
func (h *AgentHandler) Devices(c *gin.Context) {
|
||||
var list []models.AgentDevice
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidPairCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{name: "valid", code: "AB23CD", want: true},
|
||||
{name: "ambiguous characters rejected", code: "AB01CD", want: false},
|
||||
{name: "lowercase rejected before normalization", code: "ab12cd", want: false},
|
||||
{name: "wrong length", code: "AB12C", want: false},
|
||||
{name: "unicode rejected", code: "AB12CD", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validPairCode(tt.code); got != tt.want {
|
||||
t.Fatalf("validPairCode(%q) = %v, want %v", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -42,6 +43,11 @@ type logoutReq struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
type changePasswordReq struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
// Register 注册:创建用户 + 默认工作空间 + owner 成员
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req registerReq
|
||||
@@ -177,8 +183,13 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
response.Fail(c, http.StatusUnauthorized, 2003, "用户不存在")
|
||||
return
|
||||
}
|
||||
workspaceID := claims.WorkspaceID
|
||||
var member models.Member
|
||||
if err = h.DB.Where("user_id = ?", user.ID).Order("id asc").First(&member).Error; err != nil {
|
||||
memberQuery := h.DB.Where("user_id = ?", user.ID)
|
||||
if workspaceID != 0 {
|
||||
memberQuery = memberQuery.Where("workspace_id = ?", workspaceID)
|
||||
}
|
||||
if err = memberQuery.Order("id asc").First(&member).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "工作空间数据缺失")
|
||||
return
|
||||
}
|
||||
@@ -203,6 +214,39 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req changePasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "密码格式不合法")
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := h.DB.First(&user, c.GetUint64("uid")).Error; err != nil {
|
||||
response.Fail(c, http.StatusUnauthorized, 1002, "用户不存在")
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(req.CurrentPassword, user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
response.Fail(c, http.StatusBadRequest, 2002, "当前密码错误")
|
||||
return
|
||||
}
|
||||
if req.CurrentPassword == req.NewPassword {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "新密码不能与当前密码相同")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "密码更新失败")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&user).Update("password_hash", hash).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "密码更新失败")
|
||||
return
|
||||
}
|
||||
response.Audit(c, "auth.password_change", "user:"+itoa(user.ID), nil)
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// Me 当前用户信息
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
var user models.User
|
||||
@@ -213,6 +257,34 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
response.OK(c, gin.H{"user": userDTO(user), "workspaceId": c.GetUint64("wsid"), "memberRole": c.GetString("mrole")})
|
||||
}
|
||||
|
||||
// SwitchWorkspace issues a fresh access/refresh pair for another workspace
|
||||
// where the current user is a member. The old token remains valid until its
|
||||
// normal expiry, but all resource handlers use the newly selected workspace.
|
||||
func (h *AuthHandler) SwitchWorkspace(c *gin.Context) {
|
||||
wsid, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var member models.Member
|
||||
if err = h.DB.Where("workspace_id = ? and user_id = ?", wsid, c.GetUint64("uid")).First(&member).Error; err != nil {
|
||||
response.Fail(c, http.StatusForbidden, 1003, "不是该工作空间成员")
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err = h.DB.First(&user, c.GetUint64("uid")).Error; err != nil || user.Status != "active" {
|
||||
response.Fail(c, http.StatusUnauthorized, 1002, "用户不可用")
|
||||
return
|
||||
}
|
||||
access, refresh, err := h.issuePair(user.ID, wsid, user.Role, member.Role)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
response.Audit(c, "workspace.switch", "workspace:"+itoa(wsid), nil)
|
||||
response.OK(c, gin.H{"accessToken": access, "refreshToken": refresh, "workspace": gin.H{"id": wsid}, "memberRole": member.Role})
|
||||
}
|
||||
|
||||
// issuePair 签发令牌对并把 refresh jti 登记到 Redis
|
||||
func (h *AuthHandler) issuePair(uid, wsid uint64, role, mrole string) (string, string, error) {
|
||||
access, err := auth.IssueAccess(h.Cfg.JWTSecret, uid, wsid, role, mrole)
|
||||
@@ -220,7 +292,7 @@ func (h *AuthHandler) issuePair(uid, wsid uint64, role, mrole string) (string, s
|
||||
return "", "", err
|
||||
}
|
||||
jti := uuid.NewString()
|
||||
refresh, err := auth.IssueRefresh(h.Cfg.JWTSecret, uid, jti)
|
||||
refresh, err := auth.IssueRefresh(h.Cfg.JWTSecret, uid, wsid, jti)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,18 +11,35 @@ import (
|
||||
|
||||
"everypublish/server/internal/api/response"
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/platform"
|
||||
"everypublish/server/internal/ws"
|
||||
)
|
||||
|
||||
// ChallengeHandler 二次验证挑战处理器
|
||||
type ChallengeHandler struct {
|
||||
DB *gorm.DB
|
||||
DB *gorm.DB
|
||||
Hub *ws.Hub
|
||||
Registry *platform.Registry
|
||||
ExecutorMode string
|
||||
StartLoginPoll func(models.Challenge, models.Account, uint64, platform.Adapter)
|
||||
}
|
||||
|
||||
// List 挑战列表(过期软置 expired)
|
||||
func (h *ChallengeHandler) List(c *gin.Context) {
|
||||
h.DB.Model(&models.Challenge{}).
|
||||
Where("workspace_id = ? and status = ? and expires_at < ?", c.GetUint64("wsid"), "active", time.Now()).
|
||||
Update("status", "expired")
|
||||
var expired []models.Challenge
|
||||
h.DB.Where("workspace_id = ? and status = ? and expires_at < ?", c.GetUint64("wsid"), "active", time.Now()).Find(&expired)
|
||||
if len(expired) > 0 {
|
||||
ids := make([]uint64, 0, len(expired))
|
||||
for _, challenge := range expired {
|
||||
ids = append(ids, challenge.ID)
|
||||
}
|
||||
h.DB.Model(&models.Challenge{}).Where("id in ?", ids).Update("status", "expired")
|
||||
accountIDs := make([]uint64, 0, len(expired))
|
||||
for _, challenge := range expired {
|
||||
accountIDs = append(accountIDs, challenge.AccountID)
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).Where("workspace_id = ? and id in ? and status = ?", c.GetUint64("wsid"), accountIDs, "binding").Updates(map[string]interface{}{"status": "expired", "last_error": "登录二维码已过期"}).Error
|
||||
}
|
||||
q := h.DB.Model(&models.Challenge{}).Where("workspace_id = ?", c.GetUint64("wsid"))
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("status = ?", s)
|
||||
@@ -34,7 +52,8 @@ func (h *ChallengeHandler) List(c *gin.Context) {
|
||||
response.OK(c, gin.H{"list": list})
|
||||
}
|
||||
|
||||
// Solve 人工完成挑战(验证码/APP确认;扫码由 Agent 完成)
|
||||
// Solve 完成网页挑战。mock QR 只接受显式联调值;真实平台挑战必须由 adapter
|
||||
// 在用户完成扫码后回写,不能通过空请求把账号标成 active。
|
||||
func (h *ChallengeHandler) Solve(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -44,7 +63,10 @@ func (h *ChallengeHandler) Solve(c *gin.Context) {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err = c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "请输入挑战确认值")
|
||||
return
|
||||
}
|
||||
var challenge models.Challenge
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&challenge).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "挑战不存在")
|
||||
@@ -54,16 +76,35 @@ func (h *ChallengeHandler) Solve(c *gin.Context) {
|
||||
response.Fail(c, http.StatusConflict, 3003, "挑战已结束")
|
||||
return
|
||||
}
|
||||
value := strings.TrimSpace(req.Value)
|
||||
if value == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "挑战确认值不能为空")
|
||||
return
|
||||
}
|
||||
if challenge.Kind == "qr" && !strings.HasPrefix(challenge.QRURL, "mock://") {
|
||||
response.Fail(c, http.StatusConflict, 3003, "真实二维码必须由平台登录适配器确认")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if err = h.DB.Model(&challenge).Updates(map[string]interface{}{"status": "solved", "solved_at": &now}).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).
|
||||
Where("id = ? and workspace_id = ?", challenge.AccountID, c.GetUint64("wsid")).
|
||||
Updates(map[string]interface{}{"status": "active", "last_active_at": &now}).Error
|
||||
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{c.GetUint64("uid")}, "challenge", "登录挑战已完成", "账号已恢复可用")
|
||||
if h.Hub != nil {
|
||||
h.Hub.BroadcastWS(c.GetUint64("wsid"), "challenge.status", gin.H{
|
||||
"challengeId": challenge.ID, "accountId": challenge.AccountID, "status": "solved",
|
||||
})
|
||||
h.Hub.BroadcastWS(c.GetUint64("wsid"), "notification.new", gin.H{"kind": "challenge", "title": "登录挑战已完成", "content": "账号已恢复可用"})
|
||||
}
|
||||
response.Audit(c, "challenge.solve", "challenge:"+itoa(id), gin.H{"kind": challenge.Kind})
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// Resend 一键重发(重置过期时间,实际重发出 Agent 执行)
|
||||
// Resend 一键重发(重置过期时间,交由当前 Web adapter 继续处理)
|
||||
func (h *ChallengeHandler) Resend(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -79,14 +120,57 @@ func (h *ChallengeHandler) Resend(c *gin.Context) {
|
||||
response.Fail(c, http.StatusConflict, 3003, "当前状态不可重发")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&challenge).Updates(map[string]interface{}{
|
||||
var account models.Account
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", challenge.AccountID, c.GetUint64("wsid")).First(&account).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
var adapter platform.Adapter
|
||||
var session platform.LoginSession
|
||||
if h.ExecutorMode != "mock" && h.Registry != nil {
|
||||
adapter = h.Registry.Get(account.Platform)
|
||||
if adapter != nil {
|
||||
session, err = adapter.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadGateway, 2007, adapterErrorMessage(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": "active", "expires_at": time.Now().Add(30 * time.Minute),
|
||||
}).Error; err != nil {
|
||||
"prompt": "请在网页中完成 " + account.Platform + " 登录",
|
||||
}
|
||||
if adapter != nil {
|
||||
updates["qr_token"] = session.Token
|
||||
updates["qr_url"] = session.URL
|
||||
}
|
||||
if err = h.DB.Model(&challenge).Updates(updates).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&account).Updates(map[string]interface{}{"status": "binding", "last_error": ""}).Error
|
||||
if adapter != nil && h.StartLoginPoll != nil {
|
||||
challenge.QRToken = session.Token
|
||||
challenge.QRURL = session.URL
|
||||
challenge.Status = "active"
|
||||
challenge.ExpiresAt = updates["expires_at"].(time.Time)
|
||||
challenge.Prompt = updates["prompt"].(string)
|
||||
h.StartLoginPoll(challenge, account, c.GetUint64("uid"), adapter)
|
||||
} else {
|
||||
challenge.Status = "active"
|
||||
challenge.ExpiresAt = updates["expires_at"].(time.Time)
|
||||
challenge.Prompt = updates["prompt"].(string)
|
||||
}
|
||||
if h.Hub != nil {
|
||||
h.Hub.BroadcastWS(challenge.WorkspaceID, "challenge.status", gin.H{
|
||||
"challengeId": challenge.ID, "accountId": challenge.AccountID, "status": "active",
|
||||
"kind": challenge.Kind, "platform": challenge.Platform, "qrUrl": challenge.QRURL,
|
||||
"prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt,
|
||||
})
|
||||
}
|
||||
response.Audit(c, "challenge.resend", "challenge:"+itoa(id), nil)
|
||||
response.OK(c, nil)
|
||||
response.OK(c, gin.H{"challengeId": challenge.ID, "qrUrl": challenge.QRURL, "qrToken": challenge.QRToken, "prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt})
|
||||
}
|
||||
|
||||
// Suspend 挂起(超时/人工)
|
||||
@@ -109,6 +193,7 @@ func (h *ChallengeHandler) Suspend(c *gin.Context) {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ? and status = ?", challenge.AccountID, c.GetUint64("wsid"), "binding").Updates(map[string]interface{}{"status": "expired", "last_error": "登录挑战已挂起"}).Error
|
||||
response.Audit(c, "challenge.suspend", "challenge:"+itoa(id), nil)
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ func (h *MaterialHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
kind := "image"
|
||||
if strings.HasPrefix(header.Header.Get("Content-Type"), "video/") {
|
||||
contentType := strings.ToLower(header.Header.Get("Content-Type"))
|
||||
if strings.HasPrefix(contentType, "video/") || isVideoExtension(ext) {
|
||||
kind = "video"
|
||||
}
|
||||
group := strings.TrimSpace(c.PostForm("group"))
|
||||
@@ -125,6 +126,15 @@ func (h *MaterialHandler) Upload(c *gin.Context) {
|
||||
response.OK(c, gin.H{"material": material, "dedup": false})
|
||||
}
|
||||
|
||||
func isVideoExtension(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".mp4", ".mov", ".avi", ".mkv", ".m4v", ".webm", ".flv", ".wmv":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// URL 生成签名直链(10 分钟一次性)
|
||||
func (h *MaterialHandler) URL(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -171,10 +181,15 @@ func (h *MaterialHandler) ServeFile(c *gin.Context) {
|
||||
_ = h.DB.Delete(&token).Error
|
||||
abspath := filepath.Join(h.Cfg.StorageDir, material.StorageKey)
|
||||
clean := filepath.Clean(abspath)
|
||||
if !strings.HasPrefix(clean, filepath.Clean(h.Cfg.StorageDir)) {
|
||||
root := filepath.Clean(h.Cfg.StorageDir) + string(os.PathSeparator)
|
||||
if !strings.HasPrefix(clean, root) {
|
||||
response.Fail(c, http.StatusForbidden, 1003, "非法路径")
|
||||
return
|
||||
}
|
||||
if c.Query("inline") == "1" {
|
||||
c.File(abspath)
|
||||
return
|
||||
}
|
||||
c.FileAttachment(abspath, material.Name)
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ func (h *MemberHandler) UpdateRole(c *gin.Context) {
|
||||
response.Fail(c, http.StatusForbidden, 1003, "不可修改 owner 角色")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&member).Update("role", req.Role).Error; err != nil {
|
||||
if err = h.DB.Model(&member).Where("workspace_id = ?", c.GetUint64("wsid")).Update("role", req.Role).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -39,6 +40,54 @@ func marshalJSONArray[T any](arr []T) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) validateTargets(wsid uint64, accountIDs, materialIDs []uint64) error {
|
||||
var accounts []models.Account
|
||||
if err := h.DB.Where("workspace_id = ? and id in ? and status = ?", wsid, accountIDs, "active").Find(&accounts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(accounts) != len(uniqueIDs(accountIDs)) {
|
||||
return fmt.Errorf("账号不存在、不属于当前工作区或尚未完成登录")
|
||||
}
|
||||
var materials []models.Material
|
||||
if err := h.DB.Where("workspace_id = ? and id in ? and status = ?", wsid, materialIDs, "ready").Find(&materials).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(materials) != len(uniqueIDs(materialIDs)) {
|
||||
return fmt.Errorf("素材不存在、未就绪或不属于当前工作区")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueIDs(values []uint64) []uint64 {
|
||||
seen := make(map[uint64]struct{}, len(values))
|
||||
result := make([]uint64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func recordTaskEvent(db *gorm.DB, row *models.Task, eventType, message string, data interface{}) {
|
||||
raw := ""
|
||||
if data != nil {
|
||||
if encoded, err := json.Marshal(data); err == nil {
|
||||
raw = string(encoded)
|
||||
}
|
||||
}
|
||||
_ = db.Create(&models.TaskEvent{
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
TaskID: row.ID,
|
||||
Type: eventType,
|
||||
Status: row.Status,
|
||||
Message: message,
|
||||
Data: raw,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// List 任务列表(状态筛选 + 分页)
|
||||
func (h *TaskHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
@@ -46,7 +95,7 @@ func (h *TaskHandler) List(c *gin.Context) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
if size < 1 || size > 300 {
|
||||
size = 20
|
||||
}
|
||||
q := h.DB.Model(&models.Task{}).Where("workspace_id = ?", c.GetUint64("wsid"))
|
||||
@@ -82,6 +131,10 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if err := h.validateTargets(c.GetUint64("wsid"), req.AccountIDs, req.MaterialIDs); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1004, err.Error())
|
||||
return
|
||||
}
|
||||
priority := req.Priority
|
||||
if priority < 1 || priority > 10 {
|
||||
priority = 5
|
||||
@@ -100,16 +153,36 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
}
|
||||
if req.ScheduleAt != nil {
|
||||
ts := time.UnixMilli(*req.ScheduleAt)
|
||||
if !ts.After(time.Now()) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "定时发布时间必须晚于当前时间")
|
||||
return
|
||||
}
|
||||
row.ScheduleAt = &ts
|
||||
}
|
||||
if err := h.DB.Create(&row).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
|
||||
return
|
||||
}
|
||||
recordTaskEvent(h.DB, &row, "created", "任务已创建", nil)
|
||||
response.Audit(c, "task.create", "task:"+itoa(row.ID), gin.H{"title": req.Title})
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
// Events 返回任务时间线,并强制按 workspace 隔离。
|
||||
func (h *TaskHandler) Events(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var events []models.TaskEvent
|
||||
if err = h.DB.Where("task_id = ? and workspace_id = ?", id, c.GetUint64("wsid")).Order("id asc").Find(&events).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "任务时间线加载失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"list": events})
|
||||
}
|
||||
|
||||
// Get 任务详情
|
||||
func (h *TaskHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -146,6 +219,14 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
response.Fail(c, http.StatusConflict, 3005, "仅草稿或已驳回任务可编辑")
|
||||
return
|
||||
}
|
||||
if len(req.AccountIDs) == 0 || len(req.MaterialIDs) == 0 {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个账号和素材")
|
||||
return
|
||||
}
|
||||
if err := h.validateTargets(c.GetUint64("wsid"), req.AccountIDs, req.MaterialIDs); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1004, err.Error())
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"title": req.Title,
|
||||
"content": req.Content,
|
||||
@@ -155,7 +236,15 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
}
|
||||
if req.ScheduleAt != nil {
|
||||
ts := time.UnixMilli(*req.ScheduleAt)
|
||||
if !ts.After(time.Now()) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "定时发布时间必须晚于当前时间")
|
||||
return
|
||||
}
|
||||
updates["schedule_at"] = &ts
|
||||
} else {
|
||||
// nil represents an explicit immediate schedule in the JSON contract.
|
||||
// The update endpoint has no meaningful partial-update semantics.
|
||||
updates["schedule_at"] = nil
|
||||
}
|
||||
if err = h.DB.Model(&row).Updates(updates).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
@@ -215,6 +304,7 @@ func (h *TaskHandler) apply(c *gin.Context, action task.Action, extra map[string
|
||||
return nil
|
||||
}
|
||||
row.Status = next
|
||||
recordTaskEvent(h.DB, &row, string(action), "状态变更为 "+next, extra)
|
||||
return &row
|
||||
}
|
||||
|
||||
@@ -230,9 +320,15 @@ func (h *TaskHandler) Submit(c *gin.Context) {
|
||||
func (h *TaskHandler) Approve(c *gin.Context) {
|
||||
if row := h.apply(c, task.ActionApprove, nil); row != nil {
|
||||
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{row.CreatedBy}, "task", "任务已通过审核", row.Title)
|
||||
if h.Dispatcher != nil && h.Dispatcher.Hub != nil {
|
||||
h.Dispatcher.Hub.BroadcastWS(row.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务已通过审核", "content": row.Title})
|
||||
}
|
||||
response.Audit(c, "task.approve", "task:"+itoa(row.ID), nil)
|
||||
if h.Dispatcher != nil {
|
||||
_ = h.Dispatcher.TryDispatch(row.ID) // 在线设备立即下发
|
||||
_ = h.Dispatcher.TryDispatch(row.ID)
|
||||
// TryDispatch can synchronously advance a Web-only mock task. Return
|
||||
// the persisted state instead of the stale queued copy from apply.
|
||||
_ = h.DB.Where("id = ? and workspace_id = ?", row.ID, row.WorkspaceID).First(row).Error
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -246,6 +342,9 @@ func (h *TaskHandler) Reject(c *gin.Context) {
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if row := h.apply(c, task.ActionReject, map[string]interface{}{"review_note": req.Note, "reviewed_by": c.GetUint64("uid")}); row != nil {
|
||||
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{row.CreatedBy}, "task", "任务被驳回", req.Note)
|
||||
if h.Dispatcher != nil && h.Dispatcher.Hub != nil {
|
||||
h.Dispatcher.Hub.BroadcastWS(row.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务被驳回", "content": req.Note})
|
||||
}
|
||||
response.Audit(c, "task.reject", "task:"+itoa(row.ID), gin.H{"note": req.Note})
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -273,6 +372,9 @@ func (h *TaskHandler) Retry(c *gin.Context) {
|
||||
// Cancel 取消
|
||||
func (h *TaskHandler) Cancel(c *gin.Context) {
|
||||
if row := h.apply(c, task.ActionCancel, nil); row != nil {
|
||||
if h.Dispatcher != nil {
|
||||
h.Dispatcher.Cancel(row.ID)
|
||||
}
|
||||
response.Audit(c, "task.cancel", "task:"+itoa(row.ID), nil)
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
if id != c.GetUint64("wsid") {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "工作空间不存在")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&models.Workspace{}).Where("id = ?", id).Update("name", req.Name).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user