feat: 官网 + 管理端 + Gin/GORM 后端首个完整版本
- web/: 产品官网(Hero 事件流终端、功能矩阵、架构、快速开始、下载、博客 + Markdown 详情页),青瓷绿双主题 - admin/: 内容管理(JWT 登录、文章分页/搜索/CRUD、草稿与发布),embed 挂 /admin - server/: Gin + GORM,MySQL(DSN 走 .env,SQLite 兜底);规范落地:sundynix_ 表前缀、字符串雪花主键、snake_case 列名、统一响应信封、公共分页参数 - 双 SPA embed 单二进制部署,make build 一键出包 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/auth"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// AdminAuthHandler 管理端登录。
|
||||
// 账号密码走环境变量 SUNDYNIX_ADMIN_USER / SUNDYNIX_ADMIN_PASS。
|
||||
type AdminAuthHandler struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func NewAdminAuthHandler() *AdminAuthHandler {
|
||||
u := os.Getenv("SUNDYNIX_ADMIN_USER")
|
||||
p := os.Getenv("SUNDYNIX_ADMIN_PASS")
|
||||
if u == "" {
|
||||
u = "admin"
|
||||
}
|
||||
if p == "" {
|
||||
p = "admin123"
|
||||
log.Println("[WARN] SUNDYNIX_ADMIN_PASS 未设置,使用开发默认密码 admin123,生产环境务必配置")
|
||||
}
|
||||
return &AdminAuthHandler{username: u, password: p}
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// Login POST /api/admin/login
|
||||
func (h *AdminAuthHandler) Login(c *gin.Context) {
|
||||
var body loginReq
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.BadRequest(c, "用户名和密码不能为空")
|
||||
return
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(body.Username), []byte(h.username)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(body.Password), []byte(h.password)) == 1
|
||||
if !userOK || !passOK {
|
||||
resp.Unauthorized(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := auth.Sign(body.Username)
|
||||
if err != nil {
|
||||
resp.ServerError(c, "签发 token 失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"token": token, "username": body.Username})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/req"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// AdminPostHandler 管理端文章 CRUD(含草稿)。
|
||||
type AdminPostHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAdminPostHandler(db *gorm.DB) *AdminPostHandler {
|
||||
return &AdminPostHandler{db: db}
|
||||
}
|
||||
|
||||
type postForm struct {
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
// List GET /api/admin/posts — 分页 + 关键词,含草稿
|
||||
func (h *AdminPostHandler) List(c *gin.Context) {
|
||||
var q req.PageQuery
|
||||
if err := c.ShouldBindQuery(&q); err != nil {
|
||||
resp.BadRequest(c, "分页参数不合法")
|
||||
return
|
||||
}
|
||||
q.Normalize()
|
||||
|
||||
tx := h.db.Model(&model.Post{})
|
||||
if q.Keyword != "" {
|
||||
kw := "%" + q.Keyword + "%"
|
||||
tx = tx.Where("title LIKE ? OR slug LIKE ? OR category LIKE ?", kw, kw, kw)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
var items []model.PostListItem
|
||||
err := tx.Order("created_at DESC").
|
||||
Offset(q.Offset()).Limit(q.PageSize).
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.Page(c, items, total, q.Page, q.PageSize)
|
||||
}
|
||||
|
||||
// Get GET /api/admin/posts/:id
|
||||
func (h *AdminPostHandler) Get(c *gin.Context) {
|
||||
var post model.Post
|
||||
err := h.db.First(&post, "id = ?", c.Param("id")).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
|
||||
// Create POST /api/admin/posts
|
||||
func (h *AdminPostHandler) Create(c *gin.Context) {
|
||||
var form postForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
resp.BadRequest(c, "slug 和标题不能为空")
|
||||
return
|
||||
}
|
||||
post := model.Post{
|
||||
Slug: form.Slug,
|
||||
Title: form.Title,
|
||||
Category: form.Category,
|
||||
Summary: form.Summary,
|
||||
Content: form.Content,
|
||||
}
|
||||
if form.Published {
|
||||
now := time.Now()
|
||||
post.PublishedAt = &now
|
||||
}
|
||||
if err := h.db.Create(&post).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
resp.BadRequest(c, "slug 已存在")
|
||||
return
|
||||
}
|
||||
resp.ServerError(c, "创建失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
|
||||
// Update PUT /api/admin/posts/:id
|
||||
func (h *AdminPostHandler) Update(c *gin.Context) {
|
||||
var form postForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
resp.BadRequest(c, "slug 和标题不能为空")
|
||||
return
|
||||
}
|
||||
var post model.Post
|
||||
err := h.db.First(&post, "id = ?", c.Param("id")).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
post.Slug = form.Slug
|
||||
post.Title = form.Title
|
||||
post.Category = form.Category
|
||||
post.Summary = form.Summary
|
||||
post.Content = form.Content
|
||||
// 发布状态切换:首次发布记时间,撤回清空,重复发布保留原时间
|
||||
if form.Published && post.PublishedAt == nil {
|
||||
now := time.Now()
|
||||
post.PublishedAt = &now
|
||||
} else if !form.Published {
|
||||
post.PublishedAt = nil
|
||||
}
|
||||
|
||||
if err := h.db.Save(&post).Error; err != nil {
|
||||
resp.ServerError(c, "更新失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
|
||||
// Delete DELETE /api/admin/posts/:id
|
||||
func (h *AdminPostHandler) Delete(c *gin.Context) {
|
||||
res := h.db.Delete(&model.Post{}, "id = ?", c.Param("id"))
|
||||
if res.Error != nil {
|
||||
resp.ServerError(c, "删除失败")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// PostHandler 面向用户端的公开文章接口。
|
||||
type PostHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPostHandler(db *gorm.DB) *PostHandler {
|
||||
return &PostHandler{db: db}
|
||||
}
|
||||
|
||||
// List GET /api/posts — 已发布文章列表(不含正文)
|
||||
func (h *PostHandler) List(c *gin.Context) {
|
||||
var items []model.PostListItem
|
||||
err := h.db.Model(&model.Post{}).
|
||||
Where("published_at IS NOT NULL").
|
||||
Order("published_at DESC").
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, items)
|
||||
}
|
||||
|
||||
// Get GET /api/posts/:slug — 文章详情(含 Markdown 正文)
|
||||
func (h *PostHandler) Get(c *gin.Context) {
|
||||
var post model.Post
|
||||
err := h.db.
|
||||
Where("slug = ? AND published_at IS NOT NULL", c.Param("slug")).
|
||||
First(&post).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/auth"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// Auth 校验 Authorization: Bearer <token>,通过后把用户名放进上下文。
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.GetHeader("Authorization")
|
||||
token, ok := strings.CutPrefix(h, "Bearer ")
|
||||
if !ok || token == "" {
|
||||
resp.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
username, err := auth.Parse(token)
|
||||
if err != nil {
|
||||
resp.Unauthorized(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 开发期跨域放行;生产同源部署(embed)时不会触发预检。
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/idgen"
|
||||
)
|
||||
|
||||
// BaseModel 所有表的公共字段:字符串雪花主键 + 时间戳。
|
||||
// 列名由 GORM NamingStrategy 统一转 snake_case。
|
||||
type BaseModel struct {
|
||||
ID string `gorm:"primaryKey;size:20" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate 主键为空时自动生成雪花 ID。
|
||||
func (m *BaseModel) BeforeCreate(*gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = idgen.Next()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Post 博客文章,表名 sundynix_post(NamingStrategy 统一加前缀)。
|
||||
// Content 为 Markdown 原文,渲染交给前端;PublishedAt 为空即草稿。
|
||||
type Post struct {
|
||||
BaseModel
|
||||
Slug string `gorm:"uniqueIndex;size:128" json:"slug"`
|
||||
Title string `gorm:"size:256" json:"title"`
|
||||
Category string `gorm:"size:64;index" json:"category"`
|
||||
Summary string `gorm:"size:512" json:"summary"`
|
||||
Content string `gorm:"type:text" json:"content,omitempty"`
|
||||
PublishedAt *time.Time `gorm:"index" json:"published_at"`
|
||||
}
|
||||
|
||||
// PostListItem 列表项投影,不带正文。
|
||||
type PostListItem struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package auth JWT 签发与校验。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var secret []byte
|
||||
|
||||
// Init 读取 JWT 密钥;未配置时使用开发默认值并告警。
|
||||
func Init() {
|
||||
s := os.Getenv("SUNDYNIX_JWT_SECRET")
|
||||
if s == "" {
|
||||
s = "sundynix-dev-secret-change-me"
|
||||
log.Println("[WARN] SUNDYNIX_JWT_SECRET 未设置,使用开发默认密钥,生产环境务必配置")
|
||||
}
|
||||
secret = []byte(s)
|
||||
}
|
||||
|
||||
// Sign 为用户名签发 24h 有效期的 token。
|
||||
func Sign(username string) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: username,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
Issuer: "sundynix-site",
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
}
|
||||
|
||||
// Parse 校验 token 并返回用户名。
|
||||
func Parse(token string) (string, error) {
|
||||
t, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("非法签名算法")
|
||||
}
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil || !t.Valid {
|
||||
return "", errors.New("token 无效或已过期")
|
||||
}
|
||||
claims, ok := t.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok {
|
||||
return "", errors.New("token 载荷异常")
|
||||
}
|
||||
return claims.Subject, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package idgen 全局雪花 ID 生成器,主键统一用字符串形式。
|
||||
package idgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
)
|
||||
|
||||
var node *snowflake.Node
|
||||
|
||||
// Init 初始化雪花节点;nodeID 取 SUNDYNIX_NODE_ID,默认 1。
|
||||
func Init() error {
|
||||
nodeID := int64(1)
|
||||
if v := os.Getenv("SUNDYNIX_NODE_ID"); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID = n
|
||||
}
|
||||
n, err := snowflake.NewNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node = n
|
||||
return nil
|
||||
}
|
||||
|
||||
// Next 生成一个字符串雪花 ID。
|
||||
func Next() string {
|
||||
return node.Generate().String()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package req 公共请求参数。
|
||||
package req
|
||||
|
||||
// PageQuery 分页 + 关键词,所有列表接口通用。
|
||||
type PageQuery struct {
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=10"`
|
||||
Keyword string `form:"keyword"`
|
||||
}
|
||||
|
||||
// Normalize 约束分页边界。
|
||||
func (q *PageQuery) Normalize() {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize < 1 {
|
||||
q.PageSize = 10
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// Offset 计算偏移量。
|
||||
func (q *PageQuery) Offset() int {
|
||||
return (q.Page - 1) * q.PageSize
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package resp 统一结果响应:{code, message, data}。
|
||||
// code = 0 表示成功,非 0 为业务错误码。
|
||||
package resp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeOK = 0
|
||||
CodeBadRequest = 40000
|
||||
CodeUnauthorized = 40100
|
||||
CodeNotFound = 40400
|
||||
CodeServerError = 50000
|
||||
)
|
||||
|
||||
type Body struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// PageResult 分页数据统一结构。
|
||||
type PageResult struct {
|
||||
List any `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, Body{Code: CodeOK, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func Page(c *gin.Context, list any, total int64, page, pageSize int) {
|
||||
OK(c, PageResult{List: list, Total: total, Page: page, PageSize: pageSize})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, Body{Code: CodeBadRequest, Message: message})
|
||||
}
|
||||
|
||||
func Unauthorized(c *gin.Context, message string) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, Body{Code: CodeUnauthorized, Message: message})
|
||||
}
|
||||
|
||||
func NotFound(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusNotFound, Body{Code: CodeNotFound, Message: message})
|
||||
}
|
||||
|
||||
func ServerError(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, Body{Code: CodeServerError, Message: message})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/handler"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/middleware"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/webfs"
|
||||
)
|
||||
|
||||
func New(db *gorm.DB) (*gin.Engine, error) {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||
|
||||
// ── 公开 API ──
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/healthz", func(c *gin.Context) {
|
||||
resp.OK(c, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
posts := handler.NewPostHandler(db)
|
||||
api.GET("/posts", posts.List)
|
||||
api.GET("/posts/:slug", posts.Get)
|
||||
}
|
||||
|
||||
// ── 管理端 API ──
|
||||
adminAuth := handler.NewAdminAuthHandler()
|
||||
api.POST("/admin/login", adminAuth.Login)
|
||||
|
||||
adminAPI := api.Group("/admin", middleware.Auth())
|
||||
{
|
||||
posts := handler.NewAdminPostHandler(db)
|
||||
adminAPI.GET("/posts", posts.List)
|
||||
adminAPI.POST("/posts", posts.Create)
|
||||
adminAPI.GET("/posts/:id", posts.Get)
|
||||
adminAPI.PUT("/posts/:id", posts.Update)
|
||||
adminAPI.DELETE("/posts/:id", posts.Delete)
|
||||
}
|
||||
|
||||
// ── 静态站点:/admin → 管理端,其余 → 用户端 ──
|
||||
webDist, err := webfs.WebDist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adminDist, err := webfs.AdminDist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webServer := http.FileServer(http.FS(webDist))
|
||||
adminServer := http.StripPrefix("/admin", http.FileServer(http.FS(adminDist)))
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
resp.NotFound(c, "接口不存在")
|
||||
return
|
||||
}
|
||||
if path == "/admin" || strings.HasPrefix(path, "/admin/") {
|
||||
serveSPA(c, adminDist, adminServer, strings.TrimPrefix(path, "/admin"), "/admin/")
|
||||
return
|
||||
}
|
||||
serveSPA(c, webDist, webServer, path, "/")
|
||||
})
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// serveSPA 真实文件直接吐,否则回退 index.html 交给前端路由。
|
||||
func serveSPA(c *gin.Context, dist fs.FS, server http.Handler, rel, fallback string) {
|
||||
p := strings.TrimPrefix(rel, "/")
|
||||
if p != "" {
|
||||
if _, err := fs.Stat(dist, p); err == nil {
|
||||
server.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Request.URL.Path = fallback
|
||||
server.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||
)
|
||||
|
||||
// Open 打开数据库并完成迁移与种子数据。
|
||||
// DSN 含 "@tcp(" 走 MySQL,否则按 SQLite 文件路径处理(本地开发兜底)。
|
||||
// 命名规范:表前缀 sundynix_、单数表名、snake_case 列名。
|
||||
func Open(dsn string) (*gorm.DB, error) {
|
||||
var dialector gorm.Dialector
|
||||
if strings.Contains(dsn, "@tcp(") {
|
||||
dialector = mysql.Open(dsn)
|
||||
} else {
|
||||
dialector = sqlite.Open(dsn)
|
||||
}
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
TranslateError: true, // 唯一索引冲突 → gorm.ErrDuplicatedKey
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "sundynix_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Post{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := seed(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// seed 空库时写入示例文章,方便前端联调。
|
||||
func seed(db *gorm.DB) error {
|
||||
var count int64
|
||||
if err := db.Model(&model.Post{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
at := func(s string) *time.Time {
|
||||
t, _ := time.Parse("2006-01-02", s)
|
||||
return &t
|
||||
}
|
||||
|
||||
posts := []model.Post{
|
||||
{
|
||||
Slug: "v0-1-2-release",
|
||||
Title: "sundynix agentix v0.1.2 发布:应用内更新与团队视图",
|
||||
Category: "RELEASE",
|
||||
Summary: "v0.1.2 带来应用内更新横幅、卡通办公室团队视图,以及发布流水线自动出包。",
|
||||
PublishedAt: at("2026-07-02"),
|
||||
Content: `## 亮点
|
||||
|
||||
- **应用内更新**:新版本发布后,工作台顶部出现更新横幅,一键升级。
|
||||
- **团队视图**:多智能体协作时,AI 角色会在卡通办公室里走动干活。
|
||||
- **发布流水线**:GitHub Actions 自动产出 macOS universal .app 与 Windows .exe。
|
||||
|
||||
## 升级方式
|
||||
|
||||
桌面版直接点更新横幅;自托管用户:
|
||||
|
||||
` + "```bash\ndocker compose pull && docker compose up -d\n```" + `
|
||||
|
||||
完整变更见 Releases 页面。`,
|
||||
},
|
||||
{
|
||||
Slug: "hybrid-retrieval",
|
||||
Title: "三路混合检索是怎么工作的:vector + fulltext + graph",
|
||||
Category: "ENGINEERING",
|
||||
Summary: "向量召回语义、全文召回关键词、图谱召回关系,RRF 把三路结果融成一路。",
|
||||
PublishedAt: at("2026-06-18"),
|
||||
Content: `## 为什么一路不够
|
||||
|
||||
单靠向量检索,专有名词和精确匹配经常翻车;单靠全文检索,又抓不到语义近邻。
|
||||
|
||||
我们的做法是三路并发:
|
||||
|
||||
| 通路 | 引擎 | 擅长 |
|
||||
|------|------|------|
|
||||
| 向量 | Milvus | 语义相似 |
|
||||
| 全文 | Bleve | 关键词精确匹配 |
|
||||
| 图谱 | Neo4j | 实体关系跳跃 |
|
||||
|
||||
三路结果用 **RRF(Reciprocal Rank Fusion)** 融合,再过一遍 rerank。
|
||||
|
||||
## 调试
|
||||
|
||||
检索控制台会展示每一路的召回与得分,坏 case 一眼定位。`,
|
||||
},
|
||||
{
|
||||
Slug: "why-event-driven",
|
||||
Title: "为什么我们选择事件驱动:NATS 零拷贝骨干网设计记",
|
||||
Category: "ARCHITECTURE",
|
||||
Summary: "Agent 的一切都是流:token、执行轨迹、工具调用。事件总线是最自然的骨架。",
|
||||
PublishedAt: at("2026-05-30"),
|
||||
Content: `## 流式优先
|
||||
|
||||
LLM 的输出天生是 token 流,Agent 的执行天生是事件序列。与其在 HTTP 请求-响应模型上硬凑,不如让整个系统跑在消息总线上。
|
||||
|
||||
## 主题设计
|
||||
|
||||
` + "```\nsundynix.tasks.* # 任务派发\nsundynix.streams.<id> # token 流\nsundynix.tools.go.* # Go 工具调用\nsundynix.tools.py.* # Python 工具调用\n```" + `
|
||||
|
||||
网关订阅流主题直接转 SSE/WS,中间零拷贝。
|
||||
|
||||
## 演进
|
||||
|
||||
单体先行(Monolith First):现在所有模块跑在一个进程里,但彼此只通过总线说话——拆微服务时只需要把订阅者搬走(Morph B)。`,
|
||||
},
|
||||
}
|
||||
return db.Create(&posts).Error
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>sundynix admin — 内容管理</title>
|
||||
<script>
|
||||
// 避免主题闪烁:React 挂载前先落 dark class
|
||||
;(function () {
|
||||
var t = localStorage.getItem('sundynix-theme')
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
((t === null || t === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-B2HotWD6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BFElCR1f.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="sundynix agentix — 事件驱动的 AI Agent 工作台。画布编排智能体,多 Agent 团队研究、检索、生成真正的 Word 报告。"
|
||||
/>
|
||||
<title>sundynix agentix — 事件驱动的 AI Agent 工作台</title>
|
||||
<script>
|
||||
// 避免主题闪烁:React 挂载前先落 dark class
|
||||
;(function () {
|
||||
var t = localStorage.getItem('sundynix-theme')
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
((t === null || t === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BpINEBaL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nl4Y73rF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package webfs 把前端构建产物打进二进制。
|
||||
// dist/(用户端)与 admin_dist/(管理端)默认只有占位页;
|
||||
// make build 会先构建 web/ 和 admin/ 并拷贝产物到这里,再编译 Go。
|
||||
package webfs
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var embeddedWeb embed.FS
|
||||
|
||||
//go:embed all:admin_dist
|
||||
var embeddedAdmin embed.FS
|
||||
|
||||
// WebDist 用户端静态文件系统(挂 /)。
|
||||
func WebDist() (fs.FS, error) {
|
||||
return fs.Sub(embeddedWeb, "dist")
|
||||
}
|
||||
|
||||
// AdminDist 管理端静态文件系统(挂 /admin)。
|
||||
func AdminDist() (fs.FS, error) {
|
||||
return fs.Sub(embeddedAdmin, "admin_dist")
|
||||
}
|
||||
Reference in New Issue
Block a user