新增头部导航链接配置与多语言支持
- 迁移 v10 新增 nav_links 与 nav_link_translations 两张表,文案按语言存储 - 公开 GET /api/nav-links 返回启用链接(按 sort/id 排序);管理员可增删改查与启停 - URL 仅允许站内路径、http(s) 与 mailto,拒绝 javascript: 等危险协议;译文替换与校验 - 前端删除写死的“主页”按钮,头部按当前语言渲染动态链接,新窗口加 rel=noopener noreferrer - 后台管理页新增“头部导航链接”卡片(三语文案、URL、打开方式、排序、状态),改动即时生效 - 补充 nav 接口与迁移测试、三语文案,重新生成 Swagger 文档
This commit is contained in:
19 files changed
+2198
-13
No files matched your search
+8
-1
@@ -15,13 +15,14 @@ import (
|
||||
"rill/internal/config"
|
||||
"rill/internal/database"
|
||||
"rill/internal/file"
|
||||
"rill/internal/nav"
|
||||
"rill/internal/note"
|
||||
"rill/internal/site"
|
||||
"rill/internal/user"
|
||||
"rill/internal/usergroup"
|
||||
)
|
||||
|
||||
// RegisterRoutes 注册 API 路由。health、swagger、auth、站点信息与文件查看公开;notes、个人资料与文件上传删除需登录;站点信息更新、用户与用户组管理仅限管理员。
|
||||
// RegisterRoutes 注册 API 路由。health、swagger、auth、站点信息、文件查看与头部导航读取公开;notes、个人资料与文件上传删除需登录;站点信息更新、导航维护、用户与用户组管理仅限管理员。
|
||||
func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
authn := auth.NewAuthenticator(cfg)
|
||||
|
||||
@@ -43,6 +44,7 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
|
||||
rg.GET("/site", site.Get(db))
|
||||
rg.GET("/files/:id", file.View(db, cfg))
|
||||
rg.GET("/nav-links", nav.List(db))
|
||||
|
||||
authed := rg.Group("", authn.RequireAuth(db))
|
||||
{
|
||||
@@ -69,6 +71,11 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
admin.PUT("/site/logo", site.UploadLogo(db, cfg))
|
||||
admin.DELETE("/site/logo", site.DeleteLogo(db, cfg))
|
||||
|
||||
admin.GET("/nav-links/list", nav.ListAll(db))
|
||||
admin.POST("/nav-links", nav.Create(db))
|
||||
admin.PUT("/nav-links/:id", nav.Update(db))
|
||||
admin.DELETE("/nav-links/:id", nav.Delete(db))
|
||||
|
||||
users := admin.Group("/users")
|
||||
{
|
||||
users.GET("", user.List(db))
|
||||
|
||||
@@ -80,6 +80,12 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
|
||||
if !db.Migrator().HasTable(&model.SiteSetting{}) {
|
||||
t.Error("site_settings 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.NavLink{}) {
|
||||
t.Error("nav_links 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.NavLinkTranslation{}) {
|
||||
t.Error("nav_link_translations 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&schemaMigration{}) {
|
||||
t.Error("schema_migrations 表未创建")
|
||||
}
|
||||
|
||||
@@ -118,6 +118,13 @@ var migrations = []Migration{
|
||||
return tx.AutoMigrate(&model.FileOperation{})
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: 10,
|
||||
Name: "create_nav_links",
|
||||
Up: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.NavLink{}, &model.NavLinkTranslation{})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// schemaMigration 记录已应用的迁移版本。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 导航链接状态。
|
||||
const (
|
||||
NavLinkStatusDisabled int8 = 0
|
||||
NavLinkStatusEnabled int8 = 1
|
||||
)
|
||||
|
||||
// NavLink 头部导航链接,文案按语言存放在 NavLinkTranslation。
|
||||
type NavLink struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
OpenInNewWindow bool `gorm:"not null;default:false" json:"open_in_new_window"`
|
||||
Sort int `gorm:"not null;default:0" json:"sort"`
|
||||
Status int8 `gorm:"not null" json:"status"`
|
||||
Translations []NavLinkTranslation `gorm:"-" json:"translations"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NavLinkTranslation 导航链接的多语言文案,同一链接同一语言唯一。
|
||||
type NavLinkTranslation struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
NavLinkID uint `gorm:"uniqueIndex:idx_nav_link_locale;not null" json:"nav_link_id"`
|
||||
Locale string `gorm:"size:10;uniqueIndex:idx_nav_link_locale;not null" json:"locale"`
|
||||
Label string `gorm:"size:100;not null" json:"label"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Package nav 提供头部导航链接的公开读取与管理员维护接口。
|
||||
package nav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rill/internal/httpx"
|
||||
"rill/internal/model"
|
||||
)
|
||||
|
||||
// TranslationRequest 导航链接的多语言文案。
|
||||
type TranslationRequest struct {
|
||||
Locale string `json:"locale" binding:"required,max=10" example:"zh-CN"`
|
||||
Label string `json:"label" binding:"required,max=100" example:"首页"`
|
||||
}
|
||||
|
||||
// Request 创建/更新导航链接请求。
|
||||
type Request struct {
|
||||
URL string `json:"url" binding:"required,max=512" example:"/profile"`
|
||||
OpenInNewWindow bool `json:"open_in_new_window" example:"false"`
|
||||
Sort int `json:"sort" example:"0"`
|
||||
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
|
||||
Translations []TranslationRequest `json:"translations" binding:"required,min=1,dive"`
|
||||
}
|
||||
|
||||
// @Summary List nav links
|
||||
// @Description Public header navigation links (status enabled), ordered by sort ASC then id ASC, with translations.
|
||||
// @Tags public
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.NavLink
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /nav-links [get]
|
||||
func List(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
links, err := queryLinks(c.Request.Context(), db, false)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, links)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary List all nav links
|
||||
// @Description Admin only. List all header navigation links including disabled ones, with translations.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.NavLink
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /nav-links/list [get]
|
||||
func ListAll(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
links, err := queryLinks(c.Request.Context(), db, true)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, links)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Create a nav link
|
||||
// @Description Admin only. Create a header navigation link. url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param link body nav.Request true "Nav link payload"
|
||||
// @Success 201 {object} model.NavLink
|
||||
// @Failure 400 {object} httpx.ErrorResponse "invalid request"
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /nav-links [post]
|
||||
func Create(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
req, ok := bindRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
status := int8(1)
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
link := model.NavLink{
|
||||
URL: strings.TrimSpace(req.URL),
|
||||
OpenInNewWindow: req.OpenInNewWindow,
|
||||
Sort: req.Sort,
|
||||
Status: status,
|
||||
}
|
||||
err := db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceTranslations(tx, link.ID, req.Translations)
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
translations, err := translationsFor(c.Request.Context(), db, link.ID)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
link.Translations = translations
|
||||
c.JSON(http.StatusCreated, link)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Update a nav link
|
||||
// @Description Admin only. Update a header navigation link; translations are replaced by the provided list.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Nav link ID" example(1)
|
||||
// @Param link body nav.Request true "Nav link payload"
|
||||
// @Success 200 {object} model.NavLink
|
||||
// @Failure 400 {object} httpx.ErrorResponse "invalid request or id"
|
||||
// @Failure 404 {object} httpx.ErrorResponse "record not found"
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /nav-links/{id} [put]
|
||||
func Update(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, ok := httpx.ParseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := bindRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var link model.NavLink
|
||||
if err := db.WithContext(ctx).First(&link, id).Error; err != nil {
|
||||
httpx.RespondGetError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
link.URL = strings.TrimSpace(req.URL)
|
||||
link.OpenInNewWindow = req.OpenInNewWindow
|
||||
link.Sort = req.Sort
|
||||
if req.Status != nil {
|
||||
link.Status = *req.Status
|
||||
}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceTranslations(tx, link.ID, req.Translations)
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
translations, err := translationsFor(ctx, db, link.ID)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
link.Translations = translations
|
||||
c.JSON(http.StatusOK, link)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Delete a nav link
|
||||
// @Description Admin only. Delete a header navigation link and its translations.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Param id path int true "Nav link ID" example(1)
|
||||
// @Success 204 "Deleted"
|
||||
// @Failure 400 {object} httpx.ErrorResponse "invalid id"
|
||||
// @Failure 404 {object} httpx.ErrorResponse "record not found"
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /nav-links/{id} [delete]
|
||||
func Delete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, ok := httpx.ParseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var link model.NavLink
|
||||
if err := db.WithContext(ctx).First(&link, id).Error; err != nil {
|
||||
httpx.RespondGetError(c, err)
|
||||
return
|
||||
}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("nav_link_id = ?", link.ID).Delete(&model.NavLinkTranslation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&link).Error
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// bindRequest 绑定并校验请求,失败时已写入响应。
|
||||
func bindRequest(c *gin.Context) (Request, bool) {
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
|
||||
return Request{}, false
|
||||
}
|
||||
if !validURL(strings.TrimSpace(req.URL)) {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: url must start with /, http://, https:// or mailto:"})
|
||||
return Request{}, false
|
||||
}
|
||||
if err := validateTranslations(req.Translations); err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
|
||||
return Request{}, false
|
||||
}
|
||||
return req, true
|
||||
}
|
||||
|
||||
// validURL 仅允许站内路径、http(s) 与 mailto,拒绝 javascript: 等危险协议。
|
||||
func validURL(value string) bool {
|
||||
if strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range []string{"http://", "https://", "mailto:"} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateTranslations 校验翻译项数量与内容。
|
||||
func validateTranslations(items []TranslationRequest) error {
|
||||
if len(items) == 0 {
|
||||
return errors.New("at least one translation is required")
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
locale := strings.TrimSpace(item.Locale)
|
||||
label := strings.TrimSpace(item.Label)
|
||||
if locale == "" || label == "" {
|
||||
return errors.New("translation locale and label are required")
|
||||
}
|
||||
if seen[locale] {
|
||||
return errors.New("duplicate translation locale: " + locale)
|
||||
}
|
||||
seen[locale] = true
|
||||
if len([]rune(label)) > 100 {
|
||||
return errors.New("translation label is too long")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceTranslations 在事务中整体替换链接的翻译。
|
||||
func replaceTranslations(tx *gorm.DB, linkID uint, items []TranslationRequest) error {
|
||||
if err := tx.Where("nav_link_id = ?", linkID).Delete(&model.NavLinkTranslation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
translation := model.NavLinkTranslation{
|
||||
NavLinkID: linkID,
|
||||
Locale: strings.TrimSpace(item.Locale),
|
||||
Label: strings.TrimSpace(item.Label),
|
||||
}
|
||||
if err := tx.Create(&translation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// translationsFor 查询单个链接的多语言文案。
|
||||
func translationsFor(ctx context.Context, db *gorm.DB, linkID uint) ([]model.NavLinkTranslation, error) {
|
||||
translations := make([]model.NavLinkTranslation, 0)
|
||||
if err := db.WithContext(ctx).Where("nav_link_id = ?", linkID).
|
||||
Order("locale ASC").Find(&translations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
// queryLinks 查询链接并按需附带翻译;includeDisabled 为 true 时包含禁用项。
|
||||
func queryLinks(ctx context.Context, db *gorm.DB, includeDisabled bool) ([]model.NavLink, error) {
|
||||
query := db.WithContext(ctx).Order("sort ASC").Order("id ASC")
|
||||
if !includeDisabled {
|
||||
query = query.Where("status = ?", model.NavLinkStatusEnabled)
|
||||
}
|
||||
var links []model.NavLink
|
||||
if err := query.Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachTranslations(ctx, db, links); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// attachTranslations 批量填充链接的多语言文案。
|
||||
func attachTranslations(ctx context.Context, db *gorm.DB, links []model.NavLink) error {
|
||||
for i := range links {
|
||||
links[i].Translations = make([]model.NavLinkTranslation, 0)
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(links))
|
||||
positions := make(map[uint][]int, len(links))
|
||||
for i := range links {
|
||||
ids = append(ids, links[i].ID)
|
||||
positions[links[i].ID] = append(positions[links[i].ID], i)
|
||||
}
|
||||
|
||||
var translations []model.NavLinkTranslation
|
||||
if err := db.WithContext(ctx).Where("nav_link_id IN ?", ids).
|
||||
Order("locale ASC").Find(&translations).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, translation := range translations {
|
||||
for _, i := range positions[translation.NavLinkID] {
|
||||
links[i].Translations = append(links[i].Translations, translation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package nav_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rill/internal/model"
|
||||
"rill/internal/testutil"
|
||||
)
|
||||
|
||||
func registerUser(t *testing.T, env *testutil.Env) model.User {
|
||||
t.Helper()
|
||||
w := testutil.Call(t, env.Router(""), http.MethodPost, "/api/auth/register", map[string]string{
|
||||
"username": "navuser", "email": "navuser@example.com", "password": "secret123",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("注册普通用户失败: %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
return testutil.DecodeUser(t, w)
|
||||
}
|
||||
|
||||
func decodeLinks(t *testing.T, body []byte) []model.NavLink {
|
||||
t.Helper()
|
||||
var links []model.NavLink
|
||||
if err := json.Unmarshal(body, &links); err != nil {
|
||||
t.Fatalf("解析链接响应失败: %v, body=%s", err, body)
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func createLink(t *testing.T, r http.Handler, payload map[string]any) model.NavLink {
|
||||
t.Helper()
|
||||
w := testutil.Call(t, r, http.MethodPost, "/api/nav-links", payload)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("创建链接状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
||||
}
|
||||
var link model.NavLink
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &link); err != nil {
|
||||
t.Fatalf("解析链接失败: %v, body=%s", err, w.Body.String())
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
func labelsOf(link model.NavLink) map[string]string {
|
||||
labels := make(map[string]string, len(link.Translations))
|
||||
for _, translation := range link.Translations {
|
||||
labels[translation.Locale] = translation.Label
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func TestNavLinkCRUD(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
admin := env.AdminRouter()
|
||||
public := env.Router("")
|
||||
|
||||
if w := testutil.Call(t, public, http.MethodGet, "/api/nav-links", nil); w.Code != http.StatusOK {
|
||||
t.Fatalf("公开列表状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
} else if links := decodeLinks(t, w.Body.Bytes()); len(links) != 0 {
|
||||
t.Fatalf("初始链接应为空: %+v", links)
|
||||
}
|
||||
if w := testutil.Call(t, public, http.MethodPost, "/api/nav-links", map[string]any{}); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("匿名创建状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
normal := env.Router(env.Sign(registerUser(t, env).ID))
|
||||
if w := testutil.Call(t, normal, http.MethodPost, "/api/nav-links", map[string]any{}); w.Code != http.StatusForbidden {
|
||||
t.Errorf("普通用户创建状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
if w := testutil.Call(t, normal, http.MethodGet, "/api/nav-links/list", nil); w.Code != http.StatusForbidden {
|
||||
t.Errorf("普通用户管理列表状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
home := createLink(t, admin, map[string]any{
|
||||
"url": "/", "sort": 20, "status": 1,
|
||||
"translations": []map[string]string{
|
||||
{"locale": "zh-CN", "label": "首页"},
|
||||
{"locale": "en-US", "label": "Home"},
|
||||
},
|
||||
})
|
||||
if labels := labelsOf(home); labels["zh-CN"] != "首页" || labels["en-US"] != "Home" {
|
||||
t.Errorf("创建译文异常: %+v", home.Translations)
|
||||
}
|
||||
if home.OpenInNewWindow || home.Status != int8(1) {
|
||||
t.Errorf("创建字段异常: %+v", home)
|
||||
}
|
||||
|
||||
external := createLink(t, admin, map[string]any{
|
||||
"url": "https://example.com", "sort": 10, "open_in_new_window": true,
|
||||
"translations": []map[string]string{{"locale": "zh-CN", "label": "关于"}},
|
||||
})
|
||||
disabled := createLink(t, admin, map[string]any{
|
||||
"url": "/hidden", "sort": 0, "status": 0,
|
||||
"translations": []map[string]string{{"locale": "zh-CN", "label": "隐藏"}},
|
||||
})
|
||||
|
||||
// 公开列表只返回启用项,按 sort 升序。
|
||||
w := testutil.Call(t, public, http.MethodGet, "/api/nav-links", nil)
|
||||
links := decodeLinks(t, w.Body.Bytes())
|
||||
if len(links) != 2 {
|
||||
t.Fatalf("公开链接数 = %d, 期望 2: %+v", len(links), links)
|
||||
}
|
||||
if links[0].ID != external.ID || links[1].ID != home.ID {
|
||||
t.Errorf("公开排序异常: %d, %d", links[0].ID, links[1].ID)
|
||||
}
|
||||
if !links[0].OpenInNewWindow {
|
||||
t.Errorf("外链应标记新窗口: %+v", links[0])
|
||||
}
|
||||
|
||||
// 管理列表包含禁用项并按 sort 升序。
|
||||
w = testutil.Call(t, admin, http.MethodGet, "/api/nav-links/list", nil)
|
||||
all := decodeLinks(t, w.Body.Bytes())
|
||||
if len(all) != 3 || all[0].ID != disabled.ID || all[1].ID != external.ID || all[2].ID != home.ID {
|
||||
t.Fatalf("管理列表异常: %+v", all)
|
||||
}
|
||||
|
||||
// 更新:整体替换译文并修改字段。
|
||||
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/nav-links/%d", home.ID), map[string]any{
|
||||
"url": "/profile", "sort": 5, "status": 0, "open_in_new_window": true,
|
||||
"translations": []map[string]string{
|
||||
{"locale": "zh-CN", "label": "我的"},
|
||||
{"locale": "en-US", "label": "Profile"},
|
||||
{"locale": "ja-JP", "label": "マイページ"},
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
updated := model.NavLink{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("解析更新响应失败: %v", err)
|
||||
}
|
||||
if labels := labelsOf(updated); labels["zh-CN"] != "我的" || labels["ja-JP"] != "マイページ" || len(labels) != 3 {
|
||||
t.Errorf("更新译文异常: %+v", updated.Translations)
|
||||
}
|
||||
if updated.URL != "/profile" || !updated.OpenInNewWindow || updated.Status != int8(0) {
|
||||
t.Errorf("更新字段异常: %+v", updated)
|
||||
}
|
||||
|
||||
var translationCount int64
|
||||
if err := env.DB.Model(&model.NavLinkTranslation{}).Where("nav_link_id = ?", home.ID).Count(&translationCount).Error; err != nil {
|
||||
t.Fatalf("统计译文失败: %v", err)
|
||||
}
|
||||
if translationCount != 3 {
|
||||
t.Errorf("更新后译文数 = %d, 期望 3(旧译文应被替换)", translationCount)
|
||||
}
|
||||
|
||||
// 删除:链接与译文一并删除。
|
||||
if w := testutil.Call(t, admin, http.MethodDelete, fmt.Sprintf("/api/nav-links/%d", external.ID), nil); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("删除状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusNoContent, w.Body.String())
|
||||
}
|
||||
if err := env.DB.First(&model.NavLink{}, external.ID).Error; err == nil {
|
||||
t.Error("链接未删除")
|
||||
}
|
||||
if err := env.DB.Model(&model.NavLinkTranslation{}).Where("nav_link_id = ?", external.ID).Count(&translationCount).Error; err != nil {
|
||||
t.Fatalf("统计译文失败: %v", err)
|
||||
}
|
||||
if translationCount != 0 {
|
||||
t.Errorf("译文未删除: count=%d", translationCount)
|
||||
}
|
||||
if w := testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/nav-links/%d", external.ID), map[string]any{
|
||||
"url": "/", "translations": []map[string]string{{"locale": "zh-CN", "label": "首页"}},
|
||||
}); w.Code != http.StatusNotFound {
|
||||
t.Errorf("删除后更新状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavLinkValidation(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
admin := env.AdminRouter()
|
||||
|
||||
valid := map[string]string{"locale": "zh-CN", "label": "首页"}
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"缺少地址", map[string]any{"translations": []map[string]string{valid}}},
|
||||
{"缺少译文", map[string]any{"url": "/"}},
|
||||
{"空译文数组", map[string]any{"url": "/", "translations": []map[string]string{}}},
|
||||
{"危险协议", map[string]any{"url": "javascript:alert(1)", "translations": []map[string]string{valid}}},
|
||||
{"协议相对地址", map[string]any{"url": "//evil.com", "translations": []map[string]string{valid}}},
|
||||
{"重复语言", map[string]any{"url": "/", "translations": []map[string]string{valid, valid}}},
|
||||
{"空白文案", map[string]any{"url": "/", "translations": []map[string]string{{"locale": "zh-CN", "label": " "}}}},
|
||||
{"文案超长", map[string]any{"url": "/", "translations": []map[string]string{{"locale": "zh-CN", "label": strings.Repeat("字", 101)}}}},
|
||||
{"语言超长", map[string]any{"url": "/", "translations": []map[string]string{{"locale": strings.Repeat("a", 11), "label": "x"}}}},
|
||||
{"状态非法", map[string]any{"url": "/", "status": 2, "translations": []map[string]string{valid}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := testutil.Call(t, admin, http.MethodPost, "/api/nav-links", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
if w := testutil.Call(t, admin, http.MethodPut, "/api/nav-links/abc", map[string]any{
|
||||
"url": "/", "translations": []map[string]string{valid},
|
||||
}); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user