feat: 脚本创作+发布+市场体系
- 数据模型新增: title(必填), description(可选), status(draft/published) - 新增 API: POST /scripts/:id/publish, GET /api/market (搜索+分页+runtime过滤) - 前端首页重构: 选语言 → CodeMirror 编辑器(8种语言语法高亮) → 标题/描述 → 草稿/发布 - 新增 /market 页面: 浏览已发布脚本, 搜索+过滤+分页 - 详情页新增: 发布按钮(草稿→市场), title/description 展示 - Shell 类运行时显示 source 命令(继承环境变量) - backend GetSourceCommand 支持 bash/zsh/sh/fish 四种 shell 格式 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -35,3 +35,24 @@ func GetRuntime(name string) (RuntimeConfig, bool) {
|
||||
rt, ok := RuntimeMap[name]
|
||||
return rt, ok
|
||||
}
|
||||
|
||||
func IsShellRuntime(name string) bool {
|
||||
switch name {
|
||||
case "bash", "zsh", "sh", "fish":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetSourceCommand(url, runtime string) string {
|
||||
switch runtime {
|
||||
case "bash", "zsh":
|
||||
return "source <(curl " + url + ")"
|
||||
case "sh":
|
||||
return ". <(curl " + url + ")"
|
||||
case "fish":
|
||||
return "curl " + url + " | source"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -11,9 +12,12 @@ import (
|
||||
)
|
||||
|
||||
type createRequest struct {
|
||||
Content string `json:"content" binding:"required,max=16384"`
|
||||
Runtime string `json:"runtime" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in" binding:"required,oneof=1h 24h 7d 30d"`
|
||||
Title string `json:"title" binding:"required,max=128"`
|
||||
Description string `json:"description" binding:"max=512"`
|
||||
Content string `json:"content" binding:"required,max=16384"`
|
||||
Runtime string `json:"runtime" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in" binding:"required,oneof=1h 24h 7d 30d"`
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
|
||||
func CreateScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
@@ -30,13 +34,16 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
result, err := svc.Create(service.CreateInput{
|
||||
Content: req.Content,
|
||||
Runtime: req.Runtime,
|
||||
ExpiresIn: req.ExpiresIn,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Content: req.Content,
|
||||
Runtime: req.Runtime,
|
||||
ExpiresIn: req.ExpiresIn,
|
||||
Publish: req.Publish,
|
||||
}, scheme, c.Request.Host)
|
||||
|
||||
if errors.Is(err, service.ErrInvalidRuntime) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime, supported: bash, zsh, sh, fish, python3, node, ruby, php"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -45,12 +52,16 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": result.Script.ID,
|
||||
"admin_token": result.AdminToken,
|
||||
"url": result.RawURL,
|
||||
"command": result.Command,
|
||||
"runtime": result.Script.Runtime,
|
||||
"expires_at": result.Script.ExpiresAt,
|
||||
"id": result.Script.ID,
|
||||
"title": result.Script.Title,
|
||||
"description": result.Script.Description,
|
||||
"admin_token": result.AdminToken,
|
||||
"url": result.RawURL,
|
||||
"command": result.Command,
|
||||
"source_command": result.SourceCmd,
|
||||
"runtime": result.Script.Runtime,
|
||||
"status": result.Script.Status,
|
||||
"expires_at": result.Script.ExpiresAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -71,16 +82,89 @@ func GetScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": script.ID,
|
||||
"title": script.Title,
|
||||
"description": script.Description,
|
||||
"runtime": script.Runtime,
|
||||
"content": script.Content,
|
||||
"content_length": len(script.Content),
|
||||
"status": script.Status,
|
||||
"created_at": script.CreatedAt,
|
||||
"expires_at": script.ExpiresAt,
|
||||
"published_at": script.PublishedAt,
|
||||
"expired": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func PublishScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token query parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
script, err := svc.Publish(id, token)
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "script not found or invalid token"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrAlreadyPublished) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "script already published"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": script.ID,
|
||||
"status": script.Status,
|
||||
"published_at": script.PublishedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ListMarket(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
runtime := c.Query("runtime")
|
||||
search := c.Query("search")
|
||||
|
||||
result, err := svc.ListMarket(service.MarketQuery{
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Runtime: runtime,
|
||||
Search: search,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, len(result.Items))
|
||||
for i, s := range result.Items {
|
||||
items[i] = gin.H{
|
||||
"id": s.ID,
|
||||
"title": s.Title,
|
||||
"description": s.Description,
|
||||
"runtime": s.Runtime,
|
||||
"published_at": s.PublishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": result.Total,
|
||||
"page": result.Page,
|
||||
"per_page": result.PerPage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
@@ -100,4 +184,4 @@ func DeleteScript(svc *service.ScriptService) gin.HandlerFunc {
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,14 @@ package model
|
||||
import "time"
|
||||
|
||||
type Script struct {
|
||||
ID string `gorm:"primaryKey;size:8"`
|
||||
Content string `gorm:"type:text;not null"`
|
||||
Runtime string `gorm:"size:16;not null;index"`
|
||||
AdminToken string `gorm:"size:64;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime"`
|
||||
}
|
||||
ID string `gorm:"primaryKey;size:8"`
|
||||
Title string `gorm:"size:128;not null"`
|
||||
Description string `gorm:"size:512"`
|
||||
Content string `gorm:"type:text;not null"`
|
||||
Runtime string `gorm:"size:16;not null;index"`
|
||||
AdminToken string `gorm:"size:64;not null"`
|
||||
Status string `gorm:"size:16;not null;default:draft;index"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime"`
|
||||
PublishedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
var (
|
||||
ErrInvalidRuntime = errors.New("invalid runtime")
|
||||
ErrNotFound = errors.New("script not found or expired")
|
||||
ErrAlreadyPublished = errors.New("script already published")
|
||||
)
|
||||
|
||||
type ScriptService struct {
|
||||
@@ -25,15 +26,19 @@ func NewScriptService(db *gorm.DB) *ScriptService {
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Content string
|
||||
Runtime string
|
||||
ExpiresIn string
|
||||
Title string
|
||||
Description string
|
||||
Content string
|
||||
Runtime string
|
||||
ExpiresIn string
|
||||
Publish bool
|
||||
}
|
||||
|
||||
type CreateResult struct {
|
||||
Script model.Script
|
||||
AdminToken string
|
||||
Command string
|
||||
SourceCmd string
|
||||
RawURL string
|
||||
}
|
||||
|
||||
@@ -66,13 +71,25 @@ func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := "draft"
|
||||
var publishedAt *time.Time
|
||||
if input.Publish {
|
||||
status = "published"
|
||||
now := time.Now()
|
||||
publishedAt = &now
|
||||
}
|
||||
|
||||
script := model.Script{
|
||||
ID: id,
|
||||
Content: input.Content,
|
||||
Runtime: input.Runtime,
|
||||
AdminToken: adminToken,
|
||||
ExpiresAt: time.Now().Add(d),
|
||||
CreatedAt: time.Now(),
|
||||
ID: id,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Content: input.Content,
|
||||
Runtime: input.Runtime,
|
||||
AdminToken: adminToken,
|
||||
Status: status,
|
||||
ExpiresAt: time.Now().Add(d),
|
||||
CreatedAt: time.Now(),
|
||||
PublishedAt: publishedAt,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&script).Error; err != nil {
|
||||
@@ -81,11 +98,13 @@ func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateR
|
||||
|
||||
rawURL := scheme + "://" + host + "/raw/" + id
|
||||
command := "curl " + rawURL + " | " + input.Runtime
|
||||
sourceCmd := config.GetSourceCommand(rawURL, input.Runtime)
|
||||
|
||||
return &CreateResult{
|
||||
Script: script,
|
||||
AdminToken: adminToken,
|
||||
Command: command,
|
||||
SourceCmd: sourceCmd,
|
||||
RawURL: rawURL,
|
||||
}, nil
|
||||
}
|
||||
@@ -99,6 +118,79 @@ func (s *ScriptService) GetByID(id string) (*model.Script, error) {
|
||||
return &script, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) Publish(id, token string) (*model.Script, error) {
|
||||
var script model.Script
|
||||
err := s.db.Where("id = ? AND admin_token = ? AND expires_at > ?", id, token, time.Now()).First(&script).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if script.Status == "published" {
|
||||
return nil, ErrAlreadyPublished
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
script.Status = "published"
|
||||
script.PublishedAt = &now
|
||||
|
||||
if err := s.db.Save(&script).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &script, nil
|
||||
}
|
||||
|
||||
type MarketQuery struct {
|
||||
Page int
|
||||
PerPage int
|
||||
Runtime string
|
||||
Search string
|
||||
}
|
||||
|
||||
type MarketResult struct {
|
||||
Items []model.Script
|
||||
Total int64
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
func (s *ScriptService) ListMarket(q MarketQuery) (*MarketResult, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PerPage < 1 || q.PerPage > 50 {
|
||||
q.PerPage = 20
|
||||
}
|
||||
|
||||
query := s.db.Model(&model.Script{}).
|
||||
Where("status = ? AND expires_at > ?", "published", time.Now())
|
||||
|
||||
if q.Runtime != "" && config.IsValidRuntime(q.Runtime) {
|
||||
query = query.Where("runtime = ?", q.Runtime)
|
||||
}
|
||||
if q.Search != "" {
|
||||
search := "%" + q.Search + "%"
|
||||
query = query.Where("title LIKE ? OR description LIKE ?", search, search)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var scripts []model.Script
|
||||
err := query.Order("published_at DESC").
|
||||
Offset((q.Page - 1) * q.PerPage).
|
||||
Limit(q.PerPage).
|
||||
Find(&scripts).Error
|
||||
|
||||
return &MarketResult{
|
||||
Items: scripts,
|
||||
Total: total,
|
||||
Page: q.Page,
|
||||
PerPage: q.PerPage,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (s *ScriptService) Delete(id, token string) error {
|
||||
result := s.db.Where("id = ? AND admin_token = ?", id, token).Delete(&model.Script{})
|
||||
if result.RowsAffected == 0 {
|
||||
@@ -110,4 +202,4 @@ func (s *ScriptService) Delete(id, token string) error {
|
||||
func (s *ScriptService) CleanupExpired() (int64, error) {
|
||||
result := s.db.Where("expires_at <= ?", time.Now()).Delete(&model.Script{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user