工作台 = 你人生第一个"从零到上线"的全栈项目。它的结构被全系列锁死(契约 v1.0),本页一次给全,照建即可。
workbench/ ├── server/ # Go 后端(本模块给全量代码) │ ├── cmd/server/main.go # 入口:配置→日志→DB→路由→优雅停机 │ ├── internal/ │ │ ├── config/config.go # 环境变量读取(PORT/DB_DRIVER/DB_PATH/JWT_SECRET) │ │ ├── log/log.go # slog:开发=text 人读,生产=json 机器收集 │ │ ├── db/db.go # GORM 连接(sqlite|mysql 切换)+ 自动迁移 + 初始账号 │ │ ├── model/model.go # User、Task 两张表 │ │ ├── handler/ # healthz、auth、users、tasks 处理器 + 测试 │ │ ├── middleware/middleware.go # JWT 认证、请求日志、Recovery、CORS │ │ └── router/router.go # 路由注册 + 中间件装配 │ ├── go.mod / go.sum # 锁文件 = 版本唯一事实来源 │ ├── .env.example # 环境变量样例(复制为 .env 再填) │ └── README.md # 版本表 + 启动说明(§13.2 模板) ├── web/ # Vue3 前端(本模块只做到"能跑",深度设计见 12 模块) │ ├── src/ │ │ ├── api/index.ts # axios 实例 + auth/task 封装 │ │ ├── views/ # LoginView.vue、WorkbenchView.vue │ │ ├── stores/user.ts # Pinia:token + userinfo │ │ ├── router/index.ts # 路由 + 登录守卫 │ │ └── main.ts / App.vue │ ├── vite.config.ts # dev proxy /api → localhost:8080 │ └── package.json └── deploy/ # 部署模板(本模块给雏形,完整版见 20 模块) ├── workbench.service # systemd unit └── nginx.conf # 反代 + 静态资源简述
| 层 | 选型 | 锁定版本(2026-10-14 核验) | 为什么是它 |
|---|---|---|---|
| 语言 | Go | 1.27.x(1.27.2) | 主线语言;单二进制部署,1GB 服务器无压力 |
| Web 框架 | Gin | v1.12.0 | 最主流 Go Web 框架,学习资源密度最高 |
| ORM | GORM | v1.31.2(v2 API 线) | 自动迁移 + 双数据库方言,少写 SQL |
| SQLite 驱动 | glebarez/sqlite | v1.11.0(内含 modernc.org/sqlite v1.60.1) | 纯 Go 实现,免 CGO——这是本项目能交叉编译的关键 |
| MySQL 驱动 | gorm.io/driver/mysql | v1.10.1(封装 go-sql-driver/mysql) | DB_DRIVER=mysql 时切换用 |
| 认证 | golang-jwt/jwt/v5 | v5.3.1 | JWT 事实标准库 |
| 配置 | joho/godotenv | v1.5.1 | 本地 .env 加载;服务器由 systemd 注入 |
| 密码哈希 | golang.org/x/crypto/bcrypt | v0.57.0 | 密码只存 bcrypt 哈希,绝不存明文 |
| 日志 | 标准库 log/slog | 随 Go 1.27 | 结构化日志,不用第三方库 |
| 前端 | Vue 3 + Vite + TS + Pinia + Router + axios | Vue ^3.5 / Vite ^8.3 / TS ^7.0 / Pinia ^3 / Router ^4 / axios ^1 | 卫星栈,pnpm 自动取 2026-10 最新补丁 |
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| GET | /healthz | 健康检查,返回 {"status":"ok","time":"..."} | 无 |
| POST | /api/v1/auth/login | 登录 {username,password} → {token,user} | 无 |
| GET / POST | /api/v1/users | 用户列表 / 新建用户 | JWT |
| GET / POST | /api/v1/tasks | 任务列表(?done=true 可过滤)/ 新建任务 {title,note,done} | JWT |
| PUT / DELETE | /api/v1/tasks/:id | 更新任务(含标记完成)/ 删除任务 | JWT |
统一响应格式:{"code":0,"data":{...},"msg":""};出错时 code 非 0(40001 参数 / 40101 未登录或凭据错 / 40401 不存在 / 40901 冲突 / 50000 服务器错),msg 给人话。认证方式:请求头 Authorization: Bearer <jwt>。/healthz 是例外,按运维约定直接返回 status/time。
按每天 4 小时排。每天结束对照当日目标跑一次,跑不通不睡觉——这就是闭关节奏。
假设你已按 09 模块装好 Go 1.27.x(go version 能看到 go1.27.x)。下面所有命令在 PowerShell 里执行。
# 任选一个代码根目录,例如 D:\code mkdir workbench\server -Force cd workbench\server go mod init workbench/server # 配置国内代理(首次必做,否则拉依赖慢/失败) go env -w GOPROXY=https://goproxy.cn,direct
模块名定为 workbench/server,后面所有 import 都以它开头。把下面内容写进 go.mod:
module workbench/server go 1.27.2 require ( github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.57.0 gorm.io/driver/mysql v1.10.1 gorm.io/gorm v1.31.2 )
然后执行一次:
go mod tidy
这一步会自动下载全部依赖并生成 go.sum(锁文件)。以后 go.sum 不许手改,它是版本唯一事实来源(08 §7 规则 3)。
先写"地基三文件":配置怎么读、日志怎么打、数据库怎么连。它们不包含任何业务,每个项目都长这样——这就是要沉淀进架子(scaffold)的部分。
package config
import "os"
// Config 全项目配置,全部来自环境变量
type Config struct {
Port string // 服务端口,默认 8080
GinMode string // debug | release
Driver string // sqlite | mysql
SQLitePath string // sqlite 单文件路径
MySQLDSN string // mysql 连接串
JWTSecret []byte // JWT 签名密钥
}
// Load 读取环境变量;本地 .env 存在则由 godotenv 注入(在 main 里)
func Load() *Config {
return &Config{
Port: env("PORT", "8080"),
GinMode: env("GIN_MODE", "debug"),
Driver: env("DB_DRIVER", "sqlite"),
SQLitePath: env("DB_PATH", "./data/workbench.db"),
MySQLDSN: env("DB_DSN",
"root:root@tcp(127.0.0.1:3306)/workbench?charset=utf8mb4&parseTime=true&loc=Local"),
JWTSecret: []byte(env("JWT_SECRET", "dev-only-secret-change-me")),
}
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
package log
import (
"log/slog"
"os"
)
// New 初始化全局日志器。
// 开发模式输出 text(终端人读);release 模式输出 json(服务器收集)。
func New(mode string) *slog.Logger {
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
var h slog.Handler
if mode == "release" || mode == "production" {
h = slog.NewJSONHandler(os.Stdout, opts)
} else {
h = slog.NewTextHandler(os.Stdout, opts)
}
l := slog.New(h)
slog.SetDefault(l) // 其他包直接 slog.Info(...) 即可
return l
}
package db
import (
"fmt"
"os"
"path/filepath"
"github.com/glebarez/sqlite" // 纯 Go SQLite 驱动,免 CGO
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"workbench/server/internal/config"
"workbench/server/internal/model"
)
// Open 根据 DB_DRIVER 打开数据库并自动建表
func Open(cfg *config.Config) (*gorm.DB, error) {
var dialector gorm.Dialector
switch cfg.Driver {
case "mysql":
dialector = mysql.Open(cfg.MySQLDSN)
default: // sqlite(默认)
if dir := filepath.Dir(cfg.SQLitePath); dir != "." {
_ = os.MkdirAll(dir, 0o755) // data/ 目录不存在就建
}
dialector = sqlite.Open(cfg.SQLitePath)
}
gdb, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
return nil, err
}
// 自动迁移:按 model 结构建/补表,不删数据
if err := gdb.AutoMigrate(&model.User{}, &model.Task{}); err != nil {
return nil, err
}
return gdb, nil
}
// EnsureSeedUser 首次运行写入默认账号 admin / admin123,方便联调
func EnsureSeedUser(gdb *gorm.DB) error {
var count int64
if err := gdb.Model(&model.User{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil // 已有用户,不重复写
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil {
return err
}
u := model.User{Username: "admin", Password: string(hash)}
if err := gdb.Create(&u).Error; err != nil {
return fmt.Errorf("seed admin: %w", err)
}
return nil
}
密码哈希只落库一次,数据库里永远看不到明文密码;第二次启动检测到已有用户就自动跳过,不会重复插入。
package model
import "time"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:64" json:"username"`
Password string `json:"-"` // 只存 bcrypt 哈希,json:"-" 永不返回前端
CreatedAt time.Time `json:"created_at"`
}
type Task struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:200" json:"title"`
Note string `gorm:"type:text" json:"note"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Handler 持有全局依赖:DB 连接 + JWT 密钥
type Handler struct {
DB *gorm.DB
JWTSecret []byte
}
// OK 成功统一响应:{"code":0,"data":...,"msg":""}
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": data, "msg": ""})
}
// Fail 失败统一响应,bizCode 见 §1.3 错误码表
func Fail(c *gin.Context, httpStatus, bizCode int, msg string) {
c.JSON(httpStatus, gin.H{"code": bizCode, "data": nil, "msg": msg})
}
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// Healthz 健康检查(运维约定格式,不走统一响应)
func (h *Handler) Healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
}
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"workbench/server/internal/model"
)
type loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login POST /api/v1/auth/login
func (h *Handler) Login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, http.StatusBadRequest, 40001, "用户名和密码不能为空")
return
}
var u model.User
if err := h.DB.Where("username = ?", req.Username).First(&u).Error; err != nil {
Fail(c, http.StatusUnauthorized, 40101, "用户名或密码错误")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
Fail(c, http.StatusUnauthorized, 40101, "用户名或密码错误")
return
}
// 签发 24 小时有效的 JWT
claims := jwt.RegisteredClaims{
Subject: u.Username,
ID: strconv.FormatUint(uint64(u.ID), 10),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(h.JWTSecret)
if err != nil {
Fail(c, http.StatusInternalServerError, 50000, "签发令牌失败")
return
}
OK(c, gin.H{"token": token, "user": u})
}
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"workbench/server/internal/model"
)
type createUserReq struct {
Username string `json:"username" binding:"required,min=2,max=64"`
Password string `json:"password" binding:"required,min=6"`
}
// ListUsers GET /api/v1/users
func (h *Handler) ListUsers(c *gin.Context) {
var users []model.User
if err := h.DB.Order("id asc").Find(&users).Error; err != nil {
Fail(c, http.StatusInternalServerError, 50000, "查询用户失败")
return
}
OK(c, gin.H{"list": users, "total": len(users)})
}
// CreateUser POST /api/v1/users
func (h *Handler) CreateUser(c *gin.Context) {
var req createUserReq
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, http.StatusBadRequest, 40001, "用户名 2-64 位,密码至少 6 位")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
Fail(c, http.StatusInternalServerError, 50000, "密码处理失败")
return
}
u := model.User{Username: req.Username, Password: string(hash)}
if err := h.DB.Create(&u).Error; err != nil {
Fail(c, http.StatusConflict, 40901, "用户名已存在")
return
}
OK(c, u)
}
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"workbench/server/internal/model"
)
type taskReq struct {
Title string `json:"title" binding:"required,max=200"`
Note string `json:"note"`
Done bool `json:"done"`
}
// ListTasks GET /api/v1/tasks?done=true|false(可选过滤)
func (h *Handler) ListTasks(c *gin.Context) {
var tasks []model.Task
q := h.DB.Order("id desc")
if done := c.Query("done"); done != "" {
q = q.Where("done = ?", done == "true")
}
if err := q.Find(&tasks).Error; err != nil {
Fail(c, http.StatusInternalServerError, 50000, "查询任务失败")
return
}
OK(c, gin.H{"list": tasks, "total": len(tasks)})
}
// CreateTask POST /api/v1/tasks
func (h *Handler) CreateTask(c *gin.Context) {
var req taskReq
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, http.StatusBadRequest, 40001, "标题必填,最长 200 字")
return
}
t := model.Task{Title: req.Title, Note: req.Note, Done: req.Done}
if err := h.DB.Create(&t).Error; err != nil {
Fail(c, http.StatusInternalServerError, 50000, "创建任务失败")
return
}
OK(c, t)
}
// UpdateTask PUT /api/v1/tasks/:id(整单覆盖,含标记完成)
func (h *Handler) UpdateTask(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
Fail(c, http.StatusBadRequest, 40001, "任务 id 非法")
return
}
var req taskReq
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, http.StatusBadRequest, 40001, "标题必填,最长 200 字")
return
}
var t model.Task
if err := h.DB.First(&t, id).Error; err != nil {
Fail(c, http.StatusNotFound, 40401, "任务不存在")
return
}
t.Title, t.Note, t.Done = req.Title, req.Note, req.Done
if err := h.DB.Save(&t).Error; err != nil {
Fail(c, http.StatusInternalServerError, 50000, "更新任务失败")
return
}
OK(c, t)
}
// DeleteTask DELETE /api/v1/tasks/:id
func (h *Handler) DeleteTask(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
Fail(c, http.StatusBadRequest, 40001, "任务 id 非法")
return
}
if err := h.DB.Delete(&model.Task{}, id).Error; err != nil {
Fail(c, http.StatusInternalServerError, 50000, "删除任务失败")
return
}
OK(c, gin.H{"deleted": id})
}
package middleware
import (
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// JWT 认证中间件:校验 Bearer Token,通过后把用户信息写进 Context
func JWT(secret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized,
gin.H{"code": 40101, "data": nil, "msg": "缺少登录令牌"})
return
}
claims := jwt.RegisteredClaims{}
tok, err := jwt.ParseWithClaims(
strings.TrimPrefix(auth, "Bearer "), &claims,
func(t *jwt.Token) (any, error) {
// 只接受 HMAC 签名算法,防 alg=none 绕过
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return secret, nil
})
if err != nil || !tok.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized,
gin.H{"code": 40101, "data": nil, "msg": "登录令牌无效或已过期"})
return
}
c.Set("user_id", claims.ID)
c.Set("username", claims.Subject)
c.Next()
}
}
// RequestLog 请求日志(slog 结构化)
func RequestLog(log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
log.Info("http",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"latency_ms", time.Since(start).Milliseconds(),
)
}
}
// Recovery panic 兜底:记录后返回统一 500,进程不死
func Recovery(log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if rec := recover(); rec != nil {
log.Error("panic recovered", "error", rec, "path", c.Request.URL.Path)
c.AbortWithStatusJSON(http.StatusInternalServerError,
gin.H{"code": 50000, "data": nil, "msg": "服务器内部错误"})
}
}()
c.Next()
}
}
// CORS 开发期允许前端 dev server 直连;生产走 Nginx 同域代理后本中间件无害
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-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()
}
}
package router
import (
"log/slog"
"github.com/gin-gonic/gin"
"workbench/server/internal/handler"
"workbench/server/internal/middleware"
)
// New 装配路由与全局中间件
func New(h *handler.Handler, log *slog.Logger, ginMode string) *gin.Engine {
gin.SetMode(ginMode)
r := gin.New()
r.Use(middleware.Recovery(log), middleware.RequestLog(log), middleware.CORS())
// 公开路由
r.GET("/healthz", h.Healthz)
api := r.Group("/api/v1")
api.POST("/auth/login", h.Login) // 登录不需要 token
// 以下全部需要 JWT
authed := api.Group("")
authed.Use(middleware.JWT(h.JWTSecret))
{
authed.GET("/users", h.ListUsers)
authed.POST("/users", h.CreateUser)
authed.GET("/tasks", h.ListTasks)
authed.POST("/tasks", h.CreateTask)
authed.PUT("/tasks/:id", h.UpdateTask)
authed.DELETE("/tasks/:id", h.DeleteTask)
}
return r
}
package main
import (
"context"
"errors"
"net/http"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
"workbench/server/internal/config"
"workbench/server/internal/db"
"workbench/server/internal/handler"
applog "workbench/server/internal/log"
"workbench/server/internal/router"
)
func main() {
// 1. 加载本地 .env(服务器上由 systemd 注入,文件不存在自动忽略)
_ = godotenv.Load(".env")
cfg := config.Load()
log := applog.New(cfg.GinMode)
// 2. 连数据库 + 自动迁移 + 写初始账号
gdb, err := db.Open(cfg)
if err != nil {
log.Error("数据库连接失败", "err", err)
return
}
if err := db.EnsureSeedUser(gdb); err != nil {
log.Error("写入初始账号失败", "err", err)
return
}
log.Info("数据库就绪", "driver", cfg.Driver)
// 3. 装配路由
h := &handler.Handler{DB: gdb, JWTSecret: cfg.JWTSecret}
r := router.New(h, log, cfg.GinMode)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadHeaderTimeout: 5 * time.Second, // 防慢速攻击
}
// 4. 优雅停机:捕获 Ctrl+C / kill 信号
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Info("服务启动", "addr", "http://localhost:"+cfg.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("服务异常退出", "err", err)
}
}()
<-ctx.Done()
log.Info("收到停机信号,开始优雅退出...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Error("优雅退出超时", "err", err)
}
log.Info("服务已停止")
}
# 服务端口 PORT=8080 # debug | release(release=生产 JSON 日志) GIN_MODE=debug # sqlite | mysql DB_DRIVER=sqlite # SQLite 单文件路径(DB_DRIVER=sqlite 时生效) DB_PATH=./data/workbench.db # MySQL 连接串(DB_DRIVER=mysql 时取消注释并改密码) # DB_DSN=root:你的密码@tcp(127.0.0.1:3306)/workbench?charset=utf8mb4&parseTime=true&loc=Local # JWT 签名密钥:生产环境必须换成随机长串! JWT_SECRET=change-me-to-a-long-random-string
到此后端 10 个文件齐了:go.mod + main.go + config/log/db/model/handler(×5 文件)/middleware/router。在 server 目录执行 go build ./...,零报错即合格。默认账号 admin / admin123 首次启动自动写入。
契约基线:login 与 tasks CRUD 至少各 1 个表驱动测试;go test ./... 与 go vet ./... 全绿才提交。把下面文件写成 internal/handler/handler_test.go。
package handler
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"workbench/server/internal/middleware"
"workbench/server/internal/model"
)
// newTestRouter 用内存 SQLite 起一套测试专用路由,并预置用户 alice/secret123
func newTestRouter(t *testing.T) (*gin.Engine, *Handler) {
t.Helper()
// cache=shared 保证 gorm 连接池各连接看到的是同一个内存库
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := gdb.AutoMigrate(&model.User{}, &model.Task{}); err != nil {
t.Fatalf("migrate: %v", err)
}
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
gdb.Create(&model.User{Username: "alice", Password: string(hash)})
h := &Handler{DB: gdb, JWTSecret: []byte("test-secret")}
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
g := r.Group("/api/v1")
g.Use(middleware.JWT(h.JWTSecret))
{
g.GET("/tasks", h.ListTasks)
g.POST("/tasks", h.CreateTask)
g.PUT("/tasks/:id", h.UpdateTask)
g.DELETE("/tasks/:id", h.DeleteTask)
}
return r, h
}
func doJSON(r *gin.Engine, method, path, token string, body any) *httptest.ResponseRecorder {
buf, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer " + token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// 表驱动登录测试:三种情况一次写完
func TestLogin(t *testing.T) {
r, _ := newTestRouter(t)
cases := []struct {
name string
username string
password string
wantCode int
}{
{"正确密码", "alice", "secret123", 0},
{"错误密码", "alice", "wrong", 40101},
{"不存在用户", "bob", "secret123", 40101},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := doJSON(r, http.MethodPost, "/api/v1/auth/login", "",
gin.H{"username": tc.username, "password": tc.password})
var resp struct {
Code int `json:"code"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Code != tc.wantCode {
t.Fatalf("code=%d want %d body=%s", resp.Code, tc.wantCode, w.Body.String())
}
if tc.wantCode == 0 && resp.Data.Token == "" {
t.Fatal("登录成功但 token 为空")
}
})
}
}
// 任务 CRUD 全链路:无 token 拦截 → 创建 → 列表 → 标记完成 → 删除
func TestTasksCRUD(t *testing.T) {
r, _ := newTestRouter(t)
// 先登录拿 token
w := doJSON(r, http.MethodPost, "/api/v1/auth/login", "",
gin.H{"username": "alice", "password": "secret123"})
var loginResp struct {
Data struct{ Token string `json:"token"` } `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &loginResp)
token := loginResp.Data.Token
// 无 token 必须 401
if w := doJSON(r, http.MethodGet, "/api/v1/tasks", "", nil); w.Code != http.StatusUnauthorized {
t.Fatalf("无 token 应 401, got %d", w.Code)
}
// 创建
w = doJSON(r, http.MethodPost, "/api/v1/tasks", token,
gin.H{"title": "写工作台代码", "note": "W2", "done": false})
var createResp struct {
Code int `json:"code"`
Data model.Task `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &createResp)
if createResp.Code != 0 || createResp.Data.ID == 0 {
t.Fatalf("创建失败 body=%s", w.Body.String())
}
taskID := createResp.Data.ID
// 列表
if w := doJSON(r, http.MethodGet, "/api/v1/tasks", token, nil); w.Code != http.StatusOK {
t.Fatalf("列表失败: %s", w.Body.String())
}
// 更新(标记完成)
w = doJSON(r, http.MethodPut, fmt.Sprintf("/api/v1/tasks/%d", taskID), token,
gin.H{"title": "写工作台代码", "note": "完成", "done": true})
var updResp struct{ Data model.Task `json:"data"` }
_ = json.Unmarshal(w.Body.Bytes(), &updResp)
if !updResp.Data.Done {
t.Fatal("标记完成失败")
}
// 删除
if w := doJSON(r, http.MethodDelete, fmt.Sprintf("/api/v1/tasks/%d", taskID), token, nil); w.Code != http.StatusOK {
t.Fatalf("删除失败: %s", w.Body.String())
}
}
运行测试:
go test ./... -v go vet ./...
预期输出:TestLogin/正确密码、错误密码、不存在用户 三个子用例 PASS,TestTasksCRUD PASS,全部 ok。这就是契约要求的"全绿基线"——以后每改一个功能,先跑它。
本模块前端只做到"能跑":登录页 + 任务列表页,能调通上面全部 API。深度设计(组件化、表单校验、状态机、UI 打磨)是 12 模块的事。前置:09 模块已装好 Node 24 LTS + pnpm 12。
{
"name": "workbench-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1",
"pinia": "^3",
"vue": "^3.5",
"vue-router": "^4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6",
"typescript": "^7.0",
"vite": "^8.3",
"vue-tsc": "^3"
}
}
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
// 前端 /api 开头的请求,由 vite 转发到 Go 后端 8080——开发期免 CORS
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>工作台 · 编程闭关</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
createApp(App).use(createPinia()).use(router).mount('#app')
// src/App.vue
<template>
<router-view />
</template>
import axios from 'axios'
import { useUserStore } from '../stores/user'
// baseURL 用相对路径 /api/v1:
// 开发期走 vite proxy 到 localhost:8080;
// 生产期由 Nginx 把 /api 反代到 Go 服务(见 §10.5 与 20 模块)。
export const api = axios.create({ baseURL: '/api/v1', timeout: 10000 })
// 请求拦截器:自动带上 JWT
api.interceptors.request.use((cfg) => {
const user = useUserStore()
if (user.token) cfg.headers.Authorization = `Bearer ${user.token}`
return cfg
})
// 响应拦截器:统一拆包 {code,data,msg};401 强制回登录
api.interceptors.response.use(
(resp) => {
const body = resp.data
if (body.code !== 0) throw new Error(body.msg || '请求失败')
return body.data
},
(err) => {
if (err.response?.status === 401) {
const user = useUserStore()
user.logout()
location.href = '/login'
}
return Promise.reject(err)
},
)
export interface Task {
id: number
title: string
note: string
done: boolean
created_at: string
}
export const authApi = {
login: (username: string, password: string) =>
api.post<unknown, { token: string; user: { id: number; username: string } }>(
'/auth/login', { username, password }),
}
export const taskApi = {
list: () => api.get<unknown, { list: Task[]; total: number }>('/tasks'),
create: (t: Partial<Task>) => api.post<unknown, Task>('/tasks', t),
update: (id: number, t: Partial<Task>) => api.put<unknown, Task>(`/tasks/${id}`, t),
remove: (id: number) => api.delete<unknown, { deleted: number }>(`/tasks/${id}`),
}
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authApi } from '../api'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
async function login(name: string, pwd: string) {
const data = await authApi.login(name, pwd)
token.value = data.token
username.value = data.user.username
localStorage.setItem('token', data.token)
localStorage.setItem('username', data.user.username)
}
function logout() {
token.value = ''
username.value = ''
localStorage.removeItem('token')
localStorage.removeItem('username')
}
return { token, username, login, logout }
})
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'
import LoginView from '../views/LoginView.vue'
import WorkbenchView from '../views/WorkbenchView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/tasks' },
{ path: '/login', component: LoginView },
{ path: '/tasks', component: WorkbenchView, meta: { requiresAuth: true } },
],
})
// 登录守卫:未访问受保护页一律踢回 /login
router.beforeEach((to) => {
const user = useUserStore()
if (to.meta.requiresAuth && !user.token) return '/login'
})
export default router
<template>
<div class="wrap">
<h2>登录工作台</h2>
<form @submit.prevent="onLogin">
<input v-model="username" placeholder="用户名" />
<input v-model="password" type="password" placeholder="密码" />
<button type="submit">登录</button>
<p v-if="err" class="err">{{ err }}</p>
<p class="tip">初始账号 admin / admin123(后端首次启动自动写入)</p>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
const username = ref('admin')
const password = ref('admin123')
const err = ref('')
const router = useRouter()
const user = useUserStore()
async function onLogin() {
err.value = ''
try {
await user.login(username.value, password.value)
router.push('/tasks')
} catch (e: any) {
err.value = e?.response?.data?.msg || e.message || '登录失败'
}
}
</script>
<style scoped>
.wrap { max-width: 360px; margin: 80px auto; }
input { display:block; width:100%; margin:8px 0; padding:8px; }
button { width:100%; padding:9px; cursor:pointer; }
.err { color:#C0392B; }
.tip { color:#5d6d7e; font-size:12px; }
</style>
<template>
<div class="wrap">
<header>
<h2>我的工作台任务</h2>
<span>{{ user.username }}</span>
<button @click="onLogout">退出</button>
</header>
<form @submit.prevent="onCreate">
<input v-model="newTitle" placeholder="新任务标题" />
<button type="submit">添加</button>
</form>
<ul>
<li v-for="t in tasks" :key="t.id">
<label>
<input type="checkbox" :checked="t.done" @change="onToggle(t)" />
<span :class="{ done: t.done }">{{ t.title }}</span>
</label>
<button @click="onDelete(t.id)">删除</button>
</li>
</ul>
<p v-if="!tasks.length" class="tip">还没有任务,先加一条。</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { taskApi, type Task } from '../api'
import { useUserStore } from '../stores/user'
const tasks = ref<Task[]>([])
const newTitle = ref('')
const user = useUserStore()
const router = useRouter()
async function load() {
tasks.value = (await taskApi.list()).list
}
async function onCreate() {
if (!newTitle.value.trim()) return
await taskApi.create({ title: newTitle.value.trim() })
newTitle.value = ''
await load()
}
async function onToggle(t: Task) {
await taskApi.update(t.id, { title: t.title, note: t.note, done: !t.done })
await load()
}
async function onDelete(id: number) {
await taskApi.remove(id)
await load()
}
function onLogout() {
user.logout()
router.push('/login')
}
onMounted(load)
</script>
<style scoped>
header { display:flex; align-items:center; gap:10px; }
header span { color:#5d6d7e; margin-left:auto; }
form { display:flex; gap:8px; margin:12px 0; }
li { display:flex; align-items:center; gap:8px; padding:6px 0; }
li button { margin-left:auto; }
.done { text-decoration: line-through; color:#5d6d7e; }
.tip { color:#5d6d7e; }
</style>
文件齐了:package.json / vite.config.ts / index.html / main.ts / App.vue / api/index.ts / stores/user.ts / router/index.ts / views 两个 vue。执行 pnpm install 装依赖。类型检查基线:pnpm build 通过(vue-tsc 无报错)。
开两个终端。终端 A 起后端,终端 B 起前端。按顺序执行,每步对照预期。
cd D:\code\workbench\server
cp .env.example .env # PowerShell 用:Copy-Item .env.example .env
go run ./cmd/server
预期看到(text 日志):
time=... level=INFO msg="数据库就绪" driver=sqlite time=... level=INFO msg="服务启动" addr=http://localhost:8080
data/workbench.db 自动生成,admin 账号自动写入。
# 健康检查 curl.exe http://localhost:8080/healthz # 期望:{"status":"ok","time":"2026-.."} # 登录拿 token(PowerShell 里 curl 是别名,必须写 curl.exe) curl.exe -X POST http://localhost:8080/api/v1/auth/login ` -H "Content-Type: application/json" ` -d "{\"username\":\"admin\",\"password\":\"admin123\"}" # 把返回里的 token 复制下来,下条命令替换 <TOKEN> # 带 token 查任务列表 curl.exe http://localhost:8080/api/v1/tasks -H "Authorization: Bearer <TOKEN>" # 不带 token 应被拒(40101) curl.exe http://localhost:8080/api/v1/tasks
cd D:\code\workbench\web pnpm install pnpm dev
浏览器打开 http://localhost:5173:自动跳登录页 → admin/admin123 登录 → 任务页 → 加一条任务 → 勾选完成 → 删除。全通 = 本地 M1 达成。
go install github.com/air-verse/air@v1.67.4
# 在 server 目录执行,改 Go 代码自动重启
air
这是本项目最爽的一步:因为用了纯 Go 的 glebarez/sqlite,不需要 gcc、不需要任何 Linux 工具链,Windows 上一条命令产出 Linux 二进制。
cd D:\code\workbench\server $env:GOOS="linux" $env:GOARCH="amd64" $env:CGO_ENABLED="0" go build -o workbench-server .\cmd\server # 编译完把环境变量还原,避免影响本机开发 $env:GOOS=""; $env:GOARCH=""; $env:CGO_ENABLED="" dir workbench-server # 一个十几 MB 的单文件,就是整个后端
cgo: C compiler "gcc" not found——那是 CGO 驱动的宿命。glebarez/sqlite 是纯 Go,CGO_ENABLED=0 下照常编译,1GB 服务器上连 gcc 都不用装。scp .\workbench-server app@你的服务器IP:/home/app/workbench/
# 同时把 .env 里 JWT_SECRET 改成随机长串,上传为服务器上的 /home/app/workbench/.env
[Unit] Description=Workbench Go Server After=network.target [Service] Type=simple User=app WorkingDirectory=/home/app/workbench ExecStart=/home/app/workbench/workbench-server Environment=GIN_MODE=release Environment=DB_DRIVER=sqlite Environment=DB_PATH=/home/app/workbench/data/workbench.db Environment=JWT_SECRET=换成你的随机长密钥 Restart=on-failure RestartSec=3 [Install] WantedBy=multi-user.target
服务器上启用:
sudo cp workbench.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now workbench sudo systemctl status workbench # 看 active (running) curl http://127.0.0.1:8080/healthz # {"status":"ok"...}
把 systemd 里两行改成:
Environment=DB_DRIVER=mysql
Environment=DB_DSN=root:你的密码@tcp(127.0.0.1:3306)/workbench?charset=utf8mb4&parseTime=true&loc=Local&allowPublicKeyRetrieval=true
# 然后 sudo systemctl restart workbench
代码一行没动——这就是 DB_DRIVER 开关的意义(MySQL 深度用法见 16 模块)。
server {
listen 80;
server_name 你的域名或服务器IP;
root /home/app/workbench/web/dist; # pnpm build 产物上传到这里
location /api/ { proxy_pass http://127.0.0.1:8080; }
location /healthz { proxy_pass http://127.0.0.1:8080; }
location / { try_files $uri /index.html; } # 前端路由回退
}
重载 Nginx 后,手机浏览器打开 http://你的服务器IP 即可登录使用。前端构建:cd web; pnpm build,把 dist/ 上传服务器。
到这里 M1 全部达成:本地跑通 + 服务器 systemd 自启 + 手机可访问。后面 12/16/17/19 模块都在这副骨架上加东西。
M1 是"能跑",M3(W8 末)是"像工程"。本模块先给三样东西的雏形,深度展开见各专题。
main.go 用 signal.NotifyContext 捕获 SIGINT/SIGTERM,srv.Shutdown 给在途请求最多 5 秒收尾。验证:服务器上 sudo systemctl restart workbench,日志应先打"收到停机信号,开始优雅退出"再打"服务已停止",而不是连接被硬断。
| 场景 | 做法 | 什么时候升级 |
|---|---|---|
| 加新字段/新表 | 改 model 结构体 → AutoMigrate 自动补列补表(不删列、不改类型) | M1–M3 阶段足够 |
| 改字段类型/删字段/加索引 | 手写 SQL 迁移脚本,放进 deploy/migrations/,上线前先在备份库演练 | 阶段三(有真实数据后) |
| 数据订正(批量改数) | 单独写 cmd/migrate 小工具,连一次库跑完即弃 | 同上 |
| code | 含义 | 触发场景 |
|---|---|---|
| 0 | 成功 | 一切正常 |
| 40001 | 参数错误 | 缺少必填字段、格式不符、id 非法 |
| 40101 | 未认证 | 无 token / token 过期 / 密码错 |
| 40301 | 无权限 | M3 后加 RBAC 时启用 |
| 40401 | 资源不存在 | 按 id 查无此记录 |
| 40901 | 冲突 | 用户名重复、唯一索引冲突 |
| 50000 | 服务器内部错 | DB 错、panic 兜底——日志里必须能看到堆栈线索 |
卡住了先翻这张表。每条都是照本教程做时真实会撞上的。
| 报错原文 | 原因 | 解决 |
|---|---|---|
| sql: unknown driver "sqlite" (forgotten import?) | 把 mattn/go-sqlite3 混进来了,或直接 sql.Open("sqlite",...) | 统一只用 gorm.Open(sqlite.Open(path))(glebarez);go.mod 里删掉 mattn 依赖后 go mod tidy |
| cgo: C compiler "gcc" not found / cannot find gcc | 交叉编译时 CGO 默认开启,去找 C 编译器 | 编译前 $env:CGO_ENABLED="0";本项目 glebarez 纯 Go,永远不需要 gcc |
| database is locked (SQLITE_BUSY) | SQLite 写锁冲突:两个进程同时写,或旧连接没释放 | 确认本机只跑一个后端实例;生产单进程天然低频;高频写场景见 19 模块开 WAL |
| runtime error: invalid memory address or nil pointer dereference | Handler.DB 是 nil(main 没注入),或 gorm.Open 报错没检查就继续用 | 检查 main.go 里 h := &handler.Handler{DB: gdb...};所有 err 显式 return |
| bind: address already in use :8080 | 8080 端口被占用(旧进程没退) | PowerShell: netstat -ano | findstr :8080 找 PID,taskkill /PID 号 /F;或 .env 改 PORT=8081 |
| token is expired / token signature is invalid | JWT 过期;或本地 .env 的 JWT_SECRET 与签发时不一致 | 重新登录拿新 token;两端 JWT_SECRET 必须同一个值 |
| 缺少登录令牌 / 40101 | 前端请求头没带 Authorization,或忘了 "Bearer " 前缀 | 检查 axios 请求拦截器拼的是 Bearer ${token} |
| [GIN-debug] panic: ':id' in new path conflicts with existing wildcard | 路由冲突:同一路径组里静态段和参数段撞车(如 /tasks/new 和 /tasks/:id) | 不要在 /tasks/:id 同级注册同名静态路由;把特殊动作改成 query 参数 |
| No 'Access-Control-Allow-Origin' header is present | 前端绕开 vite proxy,直连了 http://localhost:8080 | api baseURL 保持 '/api/v1' 相对路径走 proxy;后端 CORS() 中间件已兜底 |
| vite proxy 不生效(请求 404 或打到了 5173) | 改完 vite.config.ts 没重启;或请求写了绝对地址 http://localhost:8080 | Ctrl+C 后 pnpm dev 重启;baseURL 用相对路径 |
| MySQL: Public Key Retrieval is not allowed / caching_sha2_password | MySQL 8 默认 caching_sha2_password 插件,非 TLS 连接要公钥 | DSN 末尾加 allowPublicKeyRetrieval=true(本地开发可接受;生产见 16 模块配 TLS) |
| unsupported time/location 或 time.Local 相关报错 | DSN 缺 loc 参数,parseTime 解析时区失败 | DSN 固定带 parseTime=true&loc=Local |
| go: go.mod file not found / missing go.sum entry for package | 没在 server 目录跑命令;或 go.mod 手改后 go.sum 没同步 | cd 到 server/ 再跑;执行 go mod tidy |
| go mod tidy 卡住 / 报 dial tcp 超时 | 没配国内代理 | go env -w GOPROXY=https://goproxy.cn,direct 后重试 |
| # | 验收项 | 通过标准 |
|---|---|---|
| 1 | 本机后端:curl http://localhost:8080/healthz | 返回 {"status":"ok","time":"..."} |
| 2 | 浏览器登录:admin/admin123 | 跳转任务页,右上角显示 admin |
| 3 | 任务增删改查 | 添加→列表出现;勾选→划线;删除→消失 |
| 4 | 未登录访问 /tasks | 被守卫踢回 /login |
| 5 | 后端测试:go test ./... | 全部 PASS,无 FAIL |
| 6 | 交叉编译:GOOS=linux CGO_ENABLED=0 | 产出 workbench-server 单二进制 |
| 7 | 服务器 systemd | systemctl status 显示 active;重启服务器后服务自启 |
| 8 | 服务器健康检查:curl 127.0.0.1:8080/healthz | 返回 ok |
| 9 | 手机访问:Nginx 反代后打开 http://服务器IP | 手机浏览器能登录、能操作任务 |
| 10 | 工程基线 | .env 不入库;JWT_SECRET 生产已改随机串;README 版本表写好 |
# workbench/server
全栈工作台后端:Gin + GORM + 纯 Go SQLite(可切 MySQL)+ JWT。
## 本地启动
cp .env.example .env
go run ./cmd/server # http://localhost:8080
## 测试
go test ./... && go vet ./...
## 版本表(2026-10 冻结,唯一事实来源 = go.sum)
| 组件 | 锁定版本 |
|-------------------|-------------------------------------------|
| Go | 1.27.x |
| Gin | v1.12.0 |
| GORM | v1.31.2(v2 API) |
| glebarez/sqlite | v1.11.0(内含 modernc.org/sqlite v1.60.1)|
| golang-jwt/jwt/v5 | v5.3.1 |
| godotenv | v1.5.1 |
| golang.org/x/crypto | v0.57.0(bcrypt) |
| gorm.io/driver/mysql | v1.10.1 |
## 默认账号
admin / admin123(首次启动自动写入,上线后立刻改)
| 工作台目录结构 / API 端点 / 数据模型(契约 v1.0) | 本系列 workbench-contract.md(编者整理,全系列唯一工作台定义) |
| 锁定版本总表(Go 1.27.2 / Gin v1.12.0 / GORM v1.31.2 / glebarez v1.11.0 / jwt v5.3.1 / modernc v1.60.1 / air v1.67.4) | versions.md(2026-10-14 核验)→ https://pkg.go.dev/ 各模块页 |
| godotenv v1.5.1(2026-10-14 核验为最新稳定版) | https://pkg.go.dev/github.com/joho/godotenv |
| golang.org/x/crypto v0.57.0(2026-09-08,2026-10-14 核验为最新稳定版) | https://pkg.go.dev/golang.org/x/crypto |
| Gin v1.12.0 API(路由/中间件/绑定) | https://pkg.go.dev/github.com/gin-gonic/gin |
| GORM v1.31.2 API(AutoMigrate/链式查询/CRUD) | https://pkg.go.dev/gorm.io/gorm |
| glebarez/sqlite v1.11.0 纯 Go 驱动(免 CGO 交叉编译) | https://pkg.go.dev/github.com/glebarez/sqlite |
| golang-jwt/v5 API(RegisteredClaims/ParseWithClaims) | https://pkg.go.dev/github.com/golang-jwt/jwt/v5 |
| gorm.io/driver/mysql v1.10.1(go-sql-driver/mysql,caching_sha2 与 DSN 参数) | https://github.com/go-sql-driver/mysql/releases |
| 前端版本:Vue ^3.5 / Vite ^8.3 / TS ^7.0 / Pinia ^3 / vue-router ^4 / axios ^1(pnpm 主版本线,2026-10 核验) | https://registry.npmjs.org/ 各包页 |
| 里程碑 M1 验收口径(本地跑通 + 服务器部署 + 手机访问) | 08-闭关总纲.html §4 三里程碑(编者整理) |
(编者整理:本页两周路线、步骤编排、报错表为基于契约 v1.0 与作者实战经验的教学化设计,无外部独立来源。)