新增头部导航链接配置与多语言支持

- 迁移 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:
2026-09-21 21:26:30 +08:00
parent 067ade546f
commit 4449250e97
19 files changed
+2198 -13

No files matched your search

+8 -1
View File
@@ -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))
+6
View File
@@ -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 表未创建")
}
+7
View File
@@ -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 记录已应用的迁移版本。
+31
View File
@@ -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:"-"`
}
+348
View File
@@ -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
}
+203
View File
@@ -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)
}
}