server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物

This commit is contained in:
Qiufeng
2026-08-21 10:53:32 +08:00
parent 0daa9782c9
commit 3585c39bab
103 changed files with 5842 additions and 3197 deletions
+334 -47
View File
@@ -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")
}
+3 -5
View File
@@ -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})
+47 -5
View File
@@ -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)
}
})
}
}
+74 -2
View File
@@ -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
}
+95 -10
View File
@@ -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)
}
+17 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+104 -2
View File
@@ -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