server: Go 服务器(REST+WSS 网关+JWT+Argon2+配对+素材直链+任务状态机);修复并发下线 send-on-closed-channel、下发查询 SQL 优先级、配对码原子占用、上传体积上限、JWT 默认密钥告警

This commit is contained in:
Qiufeng
2026-08-20 20:38:21 +08:00
parent bf25d8beac
commit cc8845dcec
44 changed files with 4995 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
"everypublish/server/internal/ws"
"everypublish/shared/proto"
)
// AccountHandler 账号台账处理器(凭据仅在 Agent 本机保险库,服务器零凭据)
type AccountHandler struct {
DB *gorm.DB
Hub *ws.Hub
}
var platforms = map[string]bool{
"douyin": true, "kuaishou": true, "xiaohongshu": true, "bilibili": true,
"shipinhao": true, "x": true, "instagram": true, "whatsapp": true, "youtube": true,
}
type accountReq struct {
Platform string `json:"platform" binding:"required"`
Remark string `json:"remark"`
AgentDeviceID uint64 `json:"agentDeviceId"`
AccountName string `json:"accountName"`
}
// List 账号台账(筛选 platform/status)
func (h *AccountHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.DB.Model(&models.Account{}).Where("workspace_id = ?", c.GetUint64("wsid"))
if p := c.Query("platform"); p != "" {
q = q.Where("platform = ?", p)
}
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
var total int64
q.Count(&total)
var list []models.Account
q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list)
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// Create 新增账号(初始 unbound;凭据只登记元数据)
func (h *AccountHandler) Create(c *gin.Context) {
var req accountReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
if !platforms[req.Platform] {
response.Fail(c, http.StatusBadRequest, 1001, "暂不支持该平台")
return
}
account := models.Account{
WorkspaceID: c.GetUint64("wsid"),
Platform: req.Platform,
Remark: req.Remark,
AgentDeviceID: req.AgentDeviceID,
Status: "unbound",
Health: 100,
}
if err := h.DB.Create(&account).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
return
}
response.Audit(c, "account.create", "account:"+itoa(account.ID), gin.H{"platform": req.Platform, "remark": req.Remark})
response.OK(c, account)
}
// Update 修改账号资料(名称/头像/IP画像)
func (h *AccountHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req accountReq
if err = c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
updates := map[string]interface{}{"remark": req.Remark}
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 {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "account.update", "account:"+itoa(id), gin.H{"remark": req.Remark})
response.OK(c, nil)
}
// Delete 删除账号(仅未绑定)
func (h *AccountHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
if account.Status == "active" || account.Status == "binding" {
response.Fail(c, http.StatusConflict, 3001, "已绑定或绑定中的账号不可删除")
return
}
if err = h.DB.Delete(&account).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "删除失败")
return
}
response.Audit(c, "account.delete", "account:"+itoa(id), nil)
response.OK(c, nil)
}
// Bind 发起绑定:创建挑战记录(pending),等待客户端扫码(D4 WSS 联动)
func (h *AccountHandler) Bind(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var account models.Account
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
return
}
if account.Status == "active" {
response.Fail(c, http.StatusConflict, 3002, "账号已绑定")
return
}
challenge := models.Challenge{
WorkspaceID: c.GetUint64("wsid"),
AccountID: account.ID,
Platform: account.Platform,
Kind: "pending",
Status: "active",
Prompt: "等待客户端发起扫码,稍后此处展示二维码",
ExpiresAt: time.Now().Add(30 * time.Minute),
}
err = h.DB.Transaction(func(tx *gorm.DB) error {
if err = tx.Create(&challenge).Error; err != nil {
return err
}
return tx.Model(&account).Update("status", "binding").Error
})
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "发起绑定失败")
return
}
// 定向下发:优先账号指定设备,否则工作空间首个在线设备
if h.Hub != nil {
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)
}
}
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"})
}
+143
View File
@@ -0,0 +1,143 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
"everypublish/server/internal/ws"
)
// AdminHandler 平台管理处理器(AdminRequired 守卫)
type AdminHandler struct {
DB *gorm.DB
Hub *ws.Hub
}
type tenantRow struct {
models.Workspace
OwnerEmail string `json:"ownerEmail"`
MemberCount int64 `json:"memberCount"`
DeviceCount int64 `json:"deviceCount"`
}
// Tenants 租户列表(含 owner 邮箱与成员/设备数)
func (h *AdminHandler) Tenants(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.DB.Model(&models.Workspace{})
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
var total int64
q.Count(&total)
var wsList []models.Workspace
q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&wsList)
rows := make([]tenantRow, 0, len(wsList))
for _, ws := range wsList {
row := tenantRow{Workspace: ws}
var owner models.User
if err := h.DB.First(&owner, ws.OwnerID).Error; err == nil {
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})
}
// TenantStatus 启用/停用租户
func (h *AdminHandler) TenantStatus(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err = c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
if req.Status != "active" && req.Status != "suspended" {
response.Fail(c, http.StatusBadRequest, 1001, "状态不合法")
return
}
var ws models.Workspace
if err = h.DB.First(&ws, id).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "租户不存在")
return
}
if err = h.DB.Model(&ws).Update("status", req.Status).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "admin.tenant.status", "workspace:"+itoa(id), gin.H{"status": req.Status})
response.OK(c, nil)
}
type agentRow struct {
models.AgentDevice
WorkspaceName string `json:"workspaceName"`
}
// Agents 全局 Agent 总览
func (h *AdminHandler) Agents(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
var total int64
h.DB.Model(&models.AgentDevice{}).Count(&total)
var devices []models.AgentDevice
h.DB.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&devices)
rows := make([]agentRow, 0, len(devices))
for _, d := range devices {
row := agentRow{AgentDevice: d}
var ws models.Workspace
if err := h.DB.First(&ws, d.WorkspaceID).Error; err == nil {
row.WorkspaceName = ws.Name
}
rows = append(rows, row)
}
response.OK(c, gin.H{"list": rows, "total": total, "page": page, "size": size})
}
// AgentRevoke 全局强制吊销
func (h *AdminHandler) AgentRevoke(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var device models.AgentDevice
if err = h.DB.First(&device, id).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "设备不存在")
return
}
if err = h.DB.Model(&device).Updates(map[string]interface{}{"revoked": true, "status": "offline"}).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "吊销失败")
return
}
if h.Hub != nil {
h.Hub.KickAgent(device.ID)
}
response.Audit(c, "admin.agent.revoke", "device:"+itoa(id), nil)
response.OK(c, nil)
}
+147
View File
@@ -0,0 +1,147 @@
package handlers
import (
"crypto/rand"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
"everypublish/server/internal/ws"
)
// AgentHandler 设备配对与列表
type AgentHandler struct {
DB *gorm.DB
Hub *ws.Hub
}
const pairAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
var errPairCodeUsed = errors.New("pair code already used")
func randCode(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
for i := range b {
b[i] = pairAlphabet[int(b[i])%len(pairAlphabet)]
}
return string(b)
}
// PairCode 生成配对码(5 分钟一次性)
func (h *AgentHandler) PairCode(c *gin.Context) {
pc := models.PairingCode{
WorkspaceID: c.GetUint64("wsid"),
Code: randCode(6),
ExpiresAt: time.Now().Add(5 * time.Minute),
}
if err := h.DB.Create(&pc).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "生成配对码失败")
return
}
response.Audit(c, "agent.pair_code", "pairing:"+itoa(pc.ID), gin.H{"code": pc.Code})
response.OK(c, gin.H{"code": pc.Code, "expiresAt": pc.ExpiresAt})
}
// Pair 设备配对(无鉴权,凭一次性码;注册 Ed25519 公钥)
func (h *AgentHandler) Pair(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
DeviceName string `json:"deviceName" binding:"required"`
OS string `json:"os"`
Version string `json:"version"`
PublicKey string `json:"publicKey" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var pc models.PairingCode
if err := h.DB.Where("code = ? and used = ?", req.Code, false).First(&pc).Error; err != nil {
response.Fail(c, http.StatusBadRequest, 2006, "配对码无效")
return
}
if time.Now().After(pc.ExpiresAt) {
response.Fail(c, http.StatusGone, 2006, "配对码已过期")
return
}
device := models.AgentDevice{
WorkspaceID: pc.WorkspaceID,
Name: req.DeviceName,
OS: req.OS,
Version: req.Version,
PublicKey: req.PublicKey,
Status: "offline",
PairedAt: time.Now(),
}
err := h.DB.Transaction(func(tx *gorm.DB) error {
// 原子占用:仅当仍 unused 且未过期时置 used=true;RowsAffected==1 才视为抢到,
// 防止并发用同一码配出多台设备。
res := tx.Model(&models.PairingCode{}).
Where("id = ? and used = ?", pc.ID, false).
Update("used", true)
if res.Error != nil {
return res.Error
}
if res.RowsAffected != 1 {
return errPairCodeUsed
}
if err := tx.Create(&device).Error; err != nil {
return err
}
if err := tx.Model(&models.PairingCode{}).Where("id = ?", pc.ID).
Update("device_id", device.ID).Error; err != nil {
return err
}
return nil
})
if err != nil {
if err == errPairCodeUsed {
response.Fail(c, http.StatusBadRequest, 2006, "配对码已被使用")
return
}
response.Fail(c, http.StatusInternalServerError, 5000, "配对失败")
return
}
response.OK(c, gin.H{"deviceId": device.ID, "workspaceId": device.WorkspaceID})
}
// Devices 设备列表(在线状态来自 Hub,此处以 DB 状态兜底)
func (h *AgentHandler) Devices(c *gin.Context) {
var list []models.AgentDevice
h.DB.Where("workspace_id = ?", c.GetUint64("wsid")).Order("id desc").Find(&list)
response.OK(c, gin.H{"list": list})
}
// Revoke 吊销设备并强制断开(owner/admin)
func (h *AgentHandler) Revoke(c *gin.Context) {
if !isAdminRole(c.GetString("mrole")) {
response.Fail(c, http.StatusForbidden, 1003, "仅管理员可吊销设备")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var device models.AgentDevice
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&device).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "设备不存在")
return
}
if err = h.DB.Model(&device).Updates(map[string]interface{}{"revoked": true, "status": "offline"}).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "吊销失败")
return
}
if h.Hub != nil {
h.Hub.KickAgent(device.ID)
}
response.Audit(c, "agent.revoke", "device:"+itoa(id), nil)
response.OK(c, nil)
}
+40
View File
@@ -0,0 +1,40 @@
package handlers
import (
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
)
// AuditHandler 审计日志处理器
type AuditHandler struct {
DB *gorm.DB
}
// List 审计日志(分页 + 筛选)
func (h *AuditHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.DB.Model(&models.AuditLog{}).Where("workspace_id = ?", c.GetUint64("wsid"))
if uid := c.Query("userId"); uid != "" {
q = q.Where("user_id = ?", uid)
}
if action := c.Query("action"); action != "" {
q = q.Where("action = ?", action)
}
var total int64
q.Count(&total)
var list []models.AuditLog
q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list)
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
+235
View File
@@ -0,0 +1,235 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/auth"
"everypublish/server/internal/cache"
"everypublish/server/internal/config"
"everypublish/server/internal/models"
)
// AuthHandler 认证处理器
type AuthHandler struct {
DB *gorm.DB
Rds *cache.Redis
Cfg *config.Config
}
type registerReq struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
Nickname string `json:"nickname"`
}
type loginReq struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
TotpCode string `json:"totpCode"`
}
type refreshReq struct {
RefreshToken string `json:"refreshToken" binding:"required"`
}
type logoutReq struct {
RefreshToken string `json:"refreshToken"`
}
// Register 注册:创建用户 + 默认工作空间 + owner 成员
func (h *AuthHandler) Register(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法:"+err.Error())
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
if !strings.Contains(req.Email, "@") {
response.Fail(c, http.StatusBadRequest, 1001, "邮箱格式不正确")
return
}
if req.Nickname == "" {
req.Nickname = strings.Split(req.Email, "@")[0]
}
var count int64
h.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&count)
if count > 0 {
response.Fail(c, http.StatusConflict, 2001, "邮箱已注册")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "服务内部错误")
return
}
err = h.DB.Transaction(func(tx *gorm.DB) error {
user := models.User{Email: req.Email, PasswordHash: hash, Nickname: req.Nickname, Role: "user", Status: "active"}
if err := tx.Create(&user).Error; err != nil {
return err
}
ws := models.Workspace{Name: req.Nickname + "的工作空间", OwnerID: user.ID, Plan: "free", Status: "active"}
if err := tx.Create(&ws).Error; err != nil {
return err
}
member := models.Member{WorkspaceID: ws.ID, UserID: user.ID, Role: "owner"}
if err := tx.Create(&member).Error; err != nil {
return err
}
c.Set("regUID", user.ID)
c.Set("regWSID", ws.ID)
return nil
})
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "注册失败")
return
}
uid, wsid := c.GetUint64("regUID"), c.GetUint64("regWSID")
access, refresh, err := h.issuePair(uid, wsid, "user", "owner")
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "签发令牌失败")
return
}
var user models.User
h.DB.First(&user, uid)
response.OK(c, gin.H{
"accessToken": access,
"refreshToken": refresh,
"user": userDTO(user),
"workspace": gin.H{"id": wsid, "name": req.Nickname + "的工作空间"},
})
}
// Login 登录(2FA 默认关;开启后需 totpCode)
func (h *AuthHandler) Login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var user models.User
if err := h.DB.Where("email = ?", strings.ToLower(strings.TrimSpace(req.Email))).First(&user).Error; err != nil {
response.Fail(c, http.StatusUnauthorized, 2002, "邮箱或密码错误")
return
}
ok, err := auth.VerifyPassword(req.Password, user.PasswordHash)
if err != nil || !ok {
response.Fail(c, http.StatusUnauthorized, 2002, "邮箱或密码错误")
return
}
if user.TOTPEnabled {
// 二期接入 TOTP 校验;一期默认关闭
response.Fail(c, http.StatusForbidden, 1006, "需要两步验证码")
return
}
if user.Status != "active" {
response.Fail(c, http.StatusForbidden, 2003, "账号已停用")
return
}
var member models.Member
if err := h.DB.Where("user_id = ?", user.ID).Order("id asc").First(&member).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "工作空间数据缺失")
return
}
access, refresh, err := h.issuePair(user.ID, member.WorkspaceID, user.Role, member.Role)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "签发令牌失败")
return
}
c.Set("wsid", member.WorkspaceID)
response.Audit(c, "auth.login", "user:"+itoa(user.ID), gin.H{"email": user.Email})
response.OK(c, gin.H{
"accessToken": access,
"refreshToken": refresh,
"user": userDTO(user),
"workspace": gin.H{"id": member.WorkspaceID},
})
}
// Refresh 刷新令牌轮换:旧 jti 吊销,签发新对
func (h *AuthHandler) Refresh(c *gin.Context) {
var req refreshReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
claims, err := auth.ParseRefresh(h.Cfg.JWTSecret, req.RefreshToken)
if err != nil {
response.Fail(c, http.StatusUnauthorized, 2003, "刷新令牌无效或已过期")
return
}
valid, err := h.Rds.ExistsRefresh(claims.UID, claims.JTI)
if err != nil || !valid {
response.Fail(c, http.StatusUnauthorized, 2003, "刷新令牌已失效")
return
}
if err = h.Rds.DeleteRefresh(claims.UID, claims.JTI); err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "服务内部错误")
return
}
var user models.User
if err = h.DB.First(&user, claims.UID).Error; err != nil {
response.Fail(c, http.StatusUnauthorized, 2003, "用户不存在")
return
}
var member models.Member
if err = h.DB.Where("user_id = ?", user.ID).Order("id asc").First(&member).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "工作空间数据缺失")
return
}
access, refresh, err := h.issuePair(user.ID, member.WorkspaceID, user.Role, member.Role)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "签发令牌失败")
return
}
response.OK(c, gin.H{"accessToken": access, "refreshToken": refresh})
}
// Logout 登出:吊销刷新令牌
func (h *AuthHandler) Logout(c *gin.Context) {
var req logoutReq
_ = c.ShouldBindJSON(&req)
if req.RefreshToken != "" {
if claims, err := auth.ParseRefresh(h.Cfg.JWTSecret, req.RefreshToken); err == nil {
_ = h.Rds.DeleteRefresh(claims.UID, claims.JTI)
}
}
response.Audit(c, "auth.logout", "user:"+itoa(c.GetUint64("uid")), nil)
response.OK(c, nil)
}
// Me 当前用户信息
func (h *AuthHandler) Me(c *gin.Context) {
var user models.User
if err := h.DB.First(&user, c.GetUint64("uid")).Error; err != nil {
response.Fail(c, http.StatusUnauthorized, 1002, "用户不存在")
return
}
response.OK(c, gin.H{"user": userDTO(user), "workspaceId": c.GetUint64("wsid"), "memberRole": c.GetString("mrole")})
}
// 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)
if err != nil {
return "", "", err
}
jti := uuid.NewString()
refresh, err := auth.IssueRefresh(h.Cfg.JWTSecret, uid, jti)
if err != nil {
return "", "", err
}
if err = h.Rds.SaveRefresh(uid, jti, auth.RefreshTTL); err != nil {
return "", "", err
}
return access, refresh, nil
}
func userDTO(u models.User) gin.H {
return gin.H{"id": u.ID, "email": u.Email, "nickname": u.Nickname, "role": u.Role, "totpEnabled": u.TOTPEnabled}
}
+114
View File
@@ -0,0 +1,114 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
)
// ChallengeHandler 二次验证挑战处理器
type ChallengeHandler struct {
DB *gorm.DB
}
// 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")
q := h.DB.Model(&models.Challenge{}).Where("workspace_id = ?", c.GetUint64("wsid"))
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
if a := c.Query("accountId"); a != "" {
q = q.Where("account_id = ?", a)
}
var list []models.Challenge
q.Order("id desc").Limit(100).Find(&list)
response.OK(c, gin.H{"list": list})
}
// Solve 人工完成挑战(验证码/APP确认;扫码由 Agent 完成)
func (h *ChallengeHandler) Solve(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req struct {
Value string `json:"value"`
}
_ = c.ShouldBindJSON(&req)
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, "挑战不存在")
return
}
if challenge.Status != "active" {
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
}
response.Audit(c, "challenge.solve", "challenge:"+itoa(id), gin.H{"kind": challenge.Kind})
response.OK(c, nil)
}
// Resend 一键重发(重置过期时间,实际重发出 Agent 执行)
func (h *ChallengeHandler) Resend(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if 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, "挑战不存在")
return
}
if challenge.Status != "expired" && challenge.Status != "suspended" {
response.Fail(c, http.StatusConflict, 3003, "当前状态不可重发")
return
}
if err = h.DB.Model(&challenge).Updates(map[string]interface{}{
"status": "active", "expires_at": time.Now().Add(30 * time.Minute),
}).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "challenge.resend", "challenge:"+itoa(id), nil)
response.OK(c, nil)
}
// Suspend 挂起(超时/人工)
func (h *ChallengeHandler) Suspend(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if 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, "挑战不存在")
return
}
if challenge.Status != "active" {
response.Fail(c, http.StatusConflict, 3003, "挑战已结束")
return
}
if err = h.DB.Model(&challenge).Update("status", "suspended").Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "challenge.suspend", "challenge:"+itoa(id), nil)
response.OK(c, nil)
}
+201
View File
@@ -0,0 +1,201 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/config"
"everypublish/server/internal/models"
)
// MaterialHandler 素材处理器(服务器本地盘一期;StorageDriver 二期 OSS)
type MaterialHandler struct {
DB *gorm.DB
Cfg *config.Config
}
// List 素材列表(分页 + 筛选)
func (h *MaterialHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.DB.Model(&models.Material{}).Where("workspace_id = ? and status = ?", c.GetUint64("wsid"), "ready")
if k := c.Query("kind"); k != "" {
q = q.Where("kind = ?", k)
}
if g := c.Query("group"); g != "" {
q = q.Where("`group` = ?", g)
}
var total int64
q.Count(&total)
var list []models.Material
q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list)
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// Upload multipart 上传:流式计算 sha256,同工作区按 sha256 去重
func (h *MaterialHandler) Upload(c *gin.Context) {
if h.Cfg.MaxUploadBytes > 0 {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.Cfg.MaxUploadBytes)
}
file, header, err := c.Request.FormFile("file")
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "文件过大或缺少文件字段 file")
return
}
defer file.Close()
wsid := c.GetUint64("wsid")
dir := filepath.Join(h.Cfg.StorageDir, itoa(wsid))
if err = os.MkdirAll(dir, 0o755); err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "存储初始化失败")
return
}
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || len(ext) > 16 {
ext = ".bin"
}
key := filepath.Join(itoa(wsid), uuid.NewString()+ext)
abspath := filepath.Join(h.Cfg.StorageDir, key)
dst, err := os.Create(abspath)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "写盘失败")
return
}
hasher := sha256.New()
size, err := io.Copy(io.MultiWriter(dst, hasher), file)
dst.Close()
if err != nil {
_ = os.Remove(abspath)
response.Fail(c, http.StatusInternalServerError, 5000, "写入失败")
return
}
sum := hex.EncodeToString(hasher.Sum(nil))
// 去重:同工作区同 sha256 直接复用
var existing models.Material
if err = h.DB.Where("workspace_id = ? and sha256 = ? and status = ?", wsid, sum, "ready").First(&existing).Error; err == nil {
_ = os.Remove(abspath)
response.OK(c, gin.H{"material": existing, "dedup": true})
return
}
kind := "image"
if strings.HasPrefix(header.Header.Get("Content-Type"), "video/") {
kind = "video"
}
group := strings.TrimSpace(c.PostForm("group"))
tags := strings.TrimSpace(c.PostForm("tags"))
if tags == "" {
tags = "[]"
}
material := models.Material{
WorkspaceID: wsid,
Name: header.Filename,
Kind: kind,
Size: size,
SHA256: sum,
Mime: header.Header.Get("Content-Type"),
StorageKey: key,
Group: group,
Tags: tags,
Status: "ready",
CreatedBy: c.GetUint64("uid"),
}
if err = h.DB.Create(&material).Error; err != nil {
_ = os.Remove(abspath)
response.Fail(c, http.StatusInternalServerError, 5000, "入库失败")
return
}
response.Audit(c, "material.upload", "material:"+itoa(material.ID), gin.H{"name": header.Filename, "size": size})
response.OK(c, gin.H{"material": material, "dedup": false})
}
// URL 生成签名直链(10 分钟一次性)
func (h *MaterialHandler) URL(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var material models.Material
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&material).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "素材不存在")
return
}
token := models.TransferToken{
MaterialID: material.ID,
Token: uuid.NewString(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
if err = h.DB.Create(&token).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "生成链接失败")
return
}
response.OK(c, gin.H{"url": h.Cfg.BaseURL + "/api/v1/files/" + token.Token, "expiresAt": token.ExpiresAt})
}
// ServeFile 凭 token 取文件(一次性,10 分钟有效)
func (h *MaterialHandler) ServeFile(c *gin.Context) {
tokenStr := c.Param("token")
var token models.TransferToken
if err := h.DB.Where("token = ?", tokenStr).First(&token).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "链接无效")
return
}
if time.Now().After(token.ExpiresAt) {
h.DB.Delete(&token)
response.Fail(c, http.StatusGone, 3004, "链接已过期")
return
}
var material models.Material
if err := h.DB.First(&material, token.MaterialID).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "素材不存在")
return
}
// 一次性:取用即吊销
_ = 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)) {
response.Fail(c, http.StatusForbidden, 1003, "非法路径")
return
}
c.FileAttachment(abspath, material.Name)
}
// Delete 删除素材(含文件)
func (h *MaterialHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var material models.Material
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&material).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "素材不存在")
return
}
abspath := filepath.Join(h.Cfg.StorageDir, material.StorageKey)
_ = os.Remove(abspath)
if err = h.DB.Delete(&material).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "删除失败")
return
}
response.Audit(c, "material.delete", "material:"+itoa(id), nil)
response.OK(c, nil)
}
+190
View File
@@ -0,0 +1,190 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/auth"
"everypublish/server/internal/config"
"everypublish/server/internal/models"
)
// MemberHandler 成员处理器
type MemberHandler struct {
DB *gorm.DB
Cfg *config.Config
}
type inviteReq struct {
Email string `json:"email" binding:"required"`
Role string `json:"role" binding:"required"`
}
type joinReq struct {
Token string `json:"token" binding:"required"`
}
type roleReq struct {
Role string `json:"role" binding:"required"`
}
var memberRoles = map[string]bool{"admin": true, "operator": true, "reviewer": true, "viewer": true}
func isAdminRole(mrole string) bool { return mrole == "owner" || mrole == "admin" }
type memberRow struct {
models.Member
Email string `json:"email"`
Nickname string `json:"nickname"`
}
// List 成员列表(含用户资料)
func (h *MemberHandler) List(c *gin.Context) {
wsid := c.GetUint64("wsid")
var rows []memberRow
h.DB.Table("members").Select("members.*, users.email as email, users.nickname as nickname").
Joins("join users on users.id = members.user_id").
Where("members.workspace_id = ?", wsid).Order("members.id asc").Scan(&rows)
response.OK(c, gin.H{"list": rows})
}
// Invite 生成邀请链接(owner/admin)
func (h *MemberHandler) Invite(c *gin.Context) {
if !isAdminRole(c.GetString("mrole")) {
response.Fail(c, http.StatusForbidden, 1003, "仅管理员可邀请成员")
return
}
var req inviteReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
if !memberRoles[req.Role] {
response.Fail(c, http.StatusBadRequest, 1001, "角色不合法")
return
}
wsid := c.GetUint64("wsid")
var existing memberRow
err := h.DB.Table("members").Select("members.id").
Joins("join users on users.id = members.user_id").
Where("members.workspace_id = ? and users.email = ?", wsid, strings.ToLower(req.Email)).
Scan(&existing).Error
if err == nil && existing.ID > 0 {
response.Fail(c, http.StatusConflict, 2004, "该邮箱已是成员")
return
}
token, err := auth.IssueInvite(h.Cfg.JWTSecret, wsid, strings.ToLower(req.Email), req.Role)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "生成邀请失败")
return
}
response.Audit(c, "member.invite", "workspace:"+itoa(wsid), gin.H{"email": req.Email, "role": req.Role})
response.OK(c, gin.H{"token": token, "link": h.Cfg.PublicBaseURL + "/invite?t=" + token})
}
// Join 接受邀请(需登录,邮箱须与邀请一致)
func (h *MemberHandler) Join(c *gin.Context) {
var req joinReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
claims, err := auth.ParseInvite(h.Cfg.JWTSecret, req.Token)
if err != nil {
response.Fail(c, http.StatusBadRequest, 2005, "邀请链接无效或已过期")
return
}
var user models.User
if err = h.DB.First(&user, c.GetUint64("uid")).Error; err != nil {
response.Fail(c, http.StatusUnauthorized, 1002, "用户不存在")
return
}
if !strings.EqualFold(user.Email, claims.Email) {
response.Fail(c, http.StatusForbidden, 1003, "邀请链接与当前账号邮箱不匹配")
return
}
member := models.Member{WorkspaceID: claims.WorkspaceID, UserID: user.ID, Role: claims.Role}
err = h.DB.Where("workspace_id = ? and user_id = ?", claims.WorkspaceID, user.ID).First(&models.Member{}).Error
if err == nil {
response.Fail(c, http.StatusConflict, 2004, "你已是该工作空间成员")
return
}
if err = h.DB.Create(&member).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "加入失败")
return
}
response.Audit(c, "member.join", "workspace:"+itoa(claims.WorkspaceID), gin.H{"role": claims.Role})
response.OK(c, nil)
}
// UpdateRole 修改成员角色(owner/admin;不可改 owner)
func (h *MemberHandler) UpdateRole(c *gin.Context) {
if !isAdminRole(c.GetString("mrole")) {
response.Fail(c, http.StatusForbidden, 1003, "仅管理员可修改角色")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req roleReq
if err = c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
if !memberRoles[req.Role] && req.Role != "owner" {
response.Fail(c, http.StatusBadRequest, 1001, "角色不合法")
return
}
var member models.Member
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&member).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "成员不存在")
return
}
if member.Role == "owner" {
response.Fail(c, http.StatusForbidden, 1003, "不可修改 owner 角色")
return
}
if err = h.DB.Model(&member).Update("role", req.Role).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "member.role", "member:"+itoa(id), gin.H{"role": req.Role})
response.OK(c, nil)
}
// Remove 移除成员(owner/admin;不可移除 owner)
func (h *MemberHandler) Remove(c *gin.Context) {
if !isAdminRole(c.GetString("mrole")) {
response.Fail(c, http.StatusForbidden, 1003, "仅管理员可移除成员")
return
}
id, 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("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&member).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "成员不存在")
return
}
if member.Role == "owner" {
response.Fail(c, http.StatusForbidden, 1003, "不可移除 owner")
return
}
if err = h.DB.Delete(&member).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "移除失败")
return
}
response.Audit(c, "member.remove", "member:"+itoa(id), nil)
response.OK(c, nil)
}
@@ -0,0 +1,57 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
)
// NotificationHandler 站内通知处理器
type NotificationHandler struct {
DB *gorm.DB
}
// List 我的通知 + 未读数
func (h *NotificationHandler) List(c *gin.Context) {
q := h.DB.Model(&models.Notification{}).Where("workspace_id = ? and user_id = ?", c.GetUint64("wsid"), c.GetUint64("uid"))
if k := c.Query("kind"); k != "" {
q = q.Where("kind = ?", k)
}
var unread int64
q.Where("`read` = ?", false).Count(&unread)
var list []models.Notification
q.Order("id desc").Limit(100).Find(&list)
response.OK(c, gin.H{"list": list, "unread": unread})
}
// Read 标记已读
func (h *NotificationHandler) Read(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
if err = h.DB.Model(&models.Notification{}).
Where("id = ? and user_id = ?", id, c.GetUint64("uid")).
Update("read", true).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.OK(c, nil)
}
// ReadAll 全部已读
func (h *NotificationHandler) ReadAll(c *gin.Context) {
if err := h.DB.Model(&models.Notification{}).
Where("workspace_id = ? and user_id = ?", c.GetUint64("wsid"), c.GetUint64("uid")).
Update("read", true).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.OK(c, nil)
}
+25
View File
@@ -0,0 +1,25 @@
package handlers
import (
"gorm.io/gorm"
"everypublish/server/internal/models"
)
// Notify 写入站内通知(批量用户)
func Notify(db *gorm.DB, wsid uint64, userIDs []uint64, kind, title, content string) error {
if len(userIDs) == 0 {
return nil
}
rows := make([]models.Notification, 0, len(userIDs))
for _, uid := range userIDs {
rows = append(rows, models.Notification{
WorkspaceID: wsid,
UserID: uid,
Kind: kind,
Title: title,
Content: content,
})
}
return db.Create(&rows).Error
}
+279
View File
@@ -0,0 +1,279 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
"everypublish/server/internal/task"
"everypublish/server/internal/ws"
)
// TaskHandler 发布任务处理器(状态机见 internal/task)
type TaskHandler struct {
DB *gorm.DB
Dispatcher *ws.Dispatcher
}
type taskReq struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
Tags []string `json:"tags"`
AccountIDs []uint64 `json:"accountIds"`
MaterialIDs []uint64 `json:"materialIds"`
ScheduleAt *int64 `json:"scheduleAt"`
Priority int `json:"priority"`
}
func marshalJSONArray[T any](arr []T) string {
raw, _ := json.Marshal(arr)
if raw == nil {
return "[]"
}
return string(raw)
}
// List 任务列表(状态筛选 + 分页)
func (h *TaskHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.DB.Model(&models.Task{}).Where("workspace_id = ?", c.GetUint64("wsid"))
if s := c.Query("status"); s != "" {
q = q.Where("status = ?", s)
}
if s := c.Query("schedule"); s != "" {
if s == "scheduled" {
q = q.Where("schedule_at is not null and schedule_at > ?", time.Now())
} else if s == "immediate" {
q = q.Where("schedule_at is null or schedule_at <= ?", time.Now())
}
}
var total int64
q.Count(&total)
var list []models.Task
q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list)
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// Create 新建任务(draft)
func (h *TaskHandler) Create(c *gin.Context) {
var req taskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法:"+err.Error())
return
}
if len(req.AccountIDs) == 0 {
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个平台账号")
return
}
if len(req.MaterialIDs) == 0 {
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个素材")
return
}
priority := req.Priority
if priority < 1 || priority > 10 {
priority = 5
}
row := models.Task{
WorkspaceID: c.GetUint64("wsid"),
Title: req.Title,
Content: req.Content,
Tags: marshalJSONArray(req.Tags),
AccountIDs: marshalJSONArray(req.AccountIDs),
MaterialIDs: marshalJSONArray(req.MaterialIDs),
Status: task.Draft,
Priority: priority,
MaxRetry: 3,
CreatedBy: c.GetUint64("uid"),
}
if req.ScheduleAt != nil {
ts := time.UnixMilli(*req.ScheduleAt)
row.ScheduleAt = &ts
}
if err := h.DB.Create(&row).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
return
}
response.Audit(c, "task.create", "task:"+itoa(row.ID), gin.H{"title": req.Title})
response.OK(c, row)
}
// Get 任务详情
func (h *TaskHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return
}
response.OK(c, row)
}
// Update 编辑草稿(仅 draft/rejected)
func (h *TaskHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req taskReq
if err = c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return
}
if row.Status != task.Draft && row.Status != task.Rejected {
response.Fail(c, http.StatusConflict, 3005, "仅草稿或已驳回任务可编辑")
return
}
updates := map[string]interface{}{
"title": req.Title,
"content": req.Content,
"tags": marshalJSONArray(req.Tags),
"account_ids": marshalJSONArray(req.AccountIDs),
"material_ids": marshalJSONArray(req.MaterialIDs),
}
if req.ScheduleAt != nil {
ts := time.UnixMilli(*req.ScheduleAt)
updates["schedule_at"] = &ts
}
if err = h.DB.Model(&row).Updates(updates).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return
}
response.Audit(c, "task.update", "task:"+itoa(id), gin.H{"title": req.Title})
response.OK(c, nil)
}
// Delete 删除草稿
func (h *TaskHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return
}
if row.Status != task.Draft {
response.Fail(c, http.StatusConflict, 3005, "仅草稿可删除")
return
}
if err = h.DB.Delete(&row).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "删除失败")
return
}
response.Audit(c, "task.delete", "task:"+itoa(id), nil)
response.OK(c, nil)
}
// apply 状态转移并落库
func (h *TaskHandler) apply(c *gin.Context, action task.Action, extra map[string]interface{}) *models.Task {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return nil
}
var row models.Task
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&row).Error; err != nil {
response.Fail(c, http.StatusNotFound, 1004, "任务不存在")
return nil
}
next, ok := task.Next(row.Status, action)
if !ok {
response.Fail(c, http.StatusConflict, 3005, "当前状态("+row.Status+")不允许该操作("+string(action)+")")
return nil
}
updates := map[string]interface{}{"status": next}
for k, v := range extra {
updates[k] = v
}
if err = h.DB.Model(&row).Updates(updates).Error; err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
return nil
}
row.Status = next
return &row
}
// Submit 提交审核
func (h *TaskHandler) Submit(c *gin.Context) {
if row := h.apply(c, task.ActionSubmit, nil); row != nil {
response.Audit(c, "task.submit", "task:"+itoa(row.ID), nil)
response.OK(c, row)
}
}
// Approve 审核通过 → queued
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)
response.Audit(c, "task.approve", "task:"+itoa(row.ID), nil)
if h.Dispatcher != nil {
_ = h.Dispatcher.TryDispatch(row.ID) // 在线设备立即下发
}
response.OK(c, row)
}
}
// Reject 驳回(附批注)
func (h *TaskHandler) Reject(c *gin.Context) {
var req struct {
Note string `json:"note"`
}
_ = 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)
response.Audit(c, "task.reject", "task:"+itoa(row.ID), gin.H{"note": req.Note})
response.OK(c, row)
}
}
// Resubmit 驳回后重新提交
func (h *TaskHandler) Resubmit(c *gin.Context) {
if row := h.apply(c, task.ActionResubmit, nil); row != nil {
response.Audit(c, "task.resubmit", "task:"+itoa(row.ID), nil)
response.OK(c, row)
}
}
// Retry 失败重试
func (h *TaskHandler) Retry(c *gin.Context) {
if row := h.apply(c, task.ActionRetry, map[string]interface{}{"error_message": ""}); row != nil {
response.Audit(c, "task.retry", "task:"+itoa(row.ID), nil)
if h.Dispatcher != nil {
_ = h.Dispatcher.TryDispatch(row.ID)
}
response.OK(c, row)
}
}
// Cancel 取消
func (h *TaskHandler) Cancel(c *gin.Context) {
if row := h.apply(c, task.ActionCancel, nil); row != nil {
response.Audit(c, "task.cancel", "task:"+itoa(row.ID), nil)
response.OK(c, row)
}
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import "strconv"
func itoa(v uint64) string {
return strconv.FormatUint(v, 10)
}
+84
View File
@@ -0,0 +1,84 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"everypublish/server/internal/api/response"
"everypublish/server/internal/models"
)
// WorkspaceHandler 工作空间处理器
type WorkspaceHandler struct {
DB *gorm.DB
}
type workspaceReq struct {
Name string `json:"name" binding:"required"`
}
// Create 创建工作空间(创建者为 owner)
func (h *WorkspaceHandler) Create(c *gin.Context) {
var req workspaceReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var ws models.Workspace
err := h.DB.Transaction(func(tx *gorm.DB) error {
ws = models.Workspace{Name: req.Name, OwnerID: c.GetUint64("uid"), Plan: "free", Status: "active"}
if err := tx.Create(&ws).Error; err != nil {
return err
}
member := models.Member{WorkspaceID: ws.ID, UserID: c.GetUint64("uid"), Role: "owner"}
return tx.Create(&member).Error
})
if err != nil {
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
return
}
response.Audit(c, "workspace.create", "workspace:"+itoa(ws.ID), gin.H{"name": req.Name})
response.OK(c, ws)
}
// List 我所在的工作空间
func (h *WorkspaceHandler) List(c *gin.Context) {
var members []models.Member
h.DB.Where("user_id = ?", c.GetUint64("uid")).Find(&members)
ids := make([]uint64, 0, len(members))
for _, m := range members {
ids = append(ids, m.WorkspaceID)
}
var list []models.Workspace
if len(ids) > 0 {
h.DB.Where("id in ?", ids).Order("id asc").Find(&list)
}
response.OK(c, gin.H{"list": list})
}
// Update 修改工作空间资料(owner/admin)
func (h *WorkspaceHandler) Update(c *gin.Context) {
if !isAdminRole(c.GetString("mrole")) {
response.Fail(c, http.StatusForbidden, 1003, "仅管理员可修改工作空间")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
return
}
var req workspaceReq
if err = c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
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
}
response.Audit(c, "workspace.update", "workspace:"+itoa(id), gin.H{"name": req.Name})
response.OK(c, nil)
}