编程闭关教程 · 系列 08–20 · 编制日期 2026-10-14

11 工作台实战:从零到可运行全栈产品

这是全系列最核心的"快速定型"模块(里程碑 M1 的载体)。照本页做:Go 后端(Gin + GORM + 纯 Go SQLite 驱动 + JWT)+ Vue3 前端(登录页 + 任务列表)+ SQLite(可一行切换 MySQL),Windows 本地跑通,交叉编译成单二进制部署到 1GB Linux 服务器跑通。每个文件给完整代码,直接照抄即可运行;常见报错全部内置,不需要跳出教程找任何资料。
本模块承诺两周内(每天 4 小时)交付一个真实跑通的全栈"工作台":本机 curl /healthz 返回 ok、浏览器登录 + 任务增删改查全通、同一二进制在服务器上 systemd 自启、手机可访问。后端测试 go test ./... 全绿。
1
个全栈项目 workbench(Go+Vue3)
12
个后端文件(本页全量给出)
2
个数据库可切换:SQLite / MySQL
1
次交叉编译即部署(免 CGO)
2 周
里程碑 M1 达成(对 08 总纲)
本页是全系列 12 / 16 / 17 / 19 模块引用的唯一工作台定义(契约 v1.0)。版本全部锁定:Go 1.27.x、Gin v1.12.0、GORM v1.31.2、glebarez/sqlite v1.11.0、jwt v5.3.1、Vue 3.5 + Vite 8 + TS 7。
08 总纲 09 环境·版本 10 Go 内核 11 工作台 12 Vue3 13 Flutter 14 Python 15 C/C++ 16 MySQL 17 PostgreSQL 18 Redis 19 SQLite3 20 部署
本页目录
  1. 项目总览:目录结构与技术栈
  2. 两周路线图:每天做什么
  3. 后端起步:go.mod 与初始化命令
  4. 基础三件套:config / log / db
  5. 数据模型与四个 handler(全量代码)
  6. 中间件、路由、入口 main(全量代码)
  7. 后端测试:httptest 表驱动,全绿目标
  8. 前端最小可用版(全量代码)
  9. 本地跑通全流程(照做步骤)
  10. 交叉编译部署到 1GB Linux 服务器
  11. 工程化扩展(阶段二简版)
  12. 常见报错与解决(14 条)
  13. M1 验收清单与 README 版本表
  14. 来源表
01

项目总览:目录结构与技术栈

工作台 = 你人生第一个"从零到上线"的全栈项目。它的结构被全系列锁死(契约 v1.0),本页一次给全,照建即可。

1.1 你最终要得到的目录

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                # 反代 + 静态资源简述

1.2 技术栈锁定表(一个版本号都别改)

层选型锁定版本(2026-10-14 核验)为什么是它
语言Go1.27.x(1.27.2)主线语言;单二进制部署,1GB 服务器无压力
Web 框架Ginv1.12.0最主流 Go Web 框架,学习资源密度最高
ORMGORMv1.31.2(v2 API 线)自动迁移 + 双数据库方言,少写 SQL
SQLite 驱动glebarez/sqlitev1.11.0(内含 modernc.org/sqlite v1.60.1)纯 Go 实现,免 CGO——这是本项目能交叉编译的关键
MySQL 驱动gorm.io/driver/mysqlv1.10.1(封装 go-sql-driver/mysql)DB_DRIVER=mysql 时切换用
认证golang-jwt/jwt/v5v5.3.1JWT 事实标准库
配置joho/godotenvv1.5.1本地 .env 加载;服务器由 systemd 注入
密码哈希golang.org/x/crypto/bcryptv0.57.0密码只存 bcrypt 哈希,绝不存明文
日志标准库 log/slog随 Go 1.27结构化日志,不用第三方库
前端Vue 3 + Vite + TS + Pinia + Router + axiosVue ^3.5 / Vite ^8.3 / TS ^7.0 / Pinia ^3 / Router ^4 / axios ^1卫星栈,pnpm 自动取 2026-10 最新补丁

1.3 API 契约(全系列统一,禁止改)

方法路径说明认证
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。

02

两周路线图:每天做什么

按每天 4 小时排。每天结束对照当日目标跑一次,跑不通不睡觉——这就是闭关节奏。

D1–D2
搭骨架:建好 workbench 目录,写完 go.mod 与 config/log/db 三个基础包(§3–§4),go build 通过。目标:能连数据库、日志能打印。
D3–D4
写模型与 handler:model.go + healthz/auth/users/tasks 四个处理器(§5)。目标:curl 登录能拿到 token。
D5
中间件与入口:JWT/请求日志/Recovery/CORS + router + main.go(§6)。目标:带 token 能增删改查任务。
D6
写测试:handler_test.go 表驱动(§7),go test ./... 全绿。目标:红灯改绿的体验走一遍。
D7–D8
前端:web 目录全量文件(§8),pnpm install && pnpm dev 起前端。目标:浏览器能登录、能加任务。
D9–D10
本地联调:按 §9 全流程走两遍,把报错表(§12)翻一遍眼熟它。
D11–D12
部署:交叉编译、scp 上传、写 systemd unit(§10)。目标:服务器 curl /healthz 返回 ok,重启服务器后服务自启。
D13–D14
收尾验收:Nginx 反代简述配置(§10.5),手机访问;对照 §13 验收清单逐项打勾;把 workbench 复制进四件套 scaffolds/go-scaffold(08 §5)。
03

后端起步:go.mod 与初始化命令

假设你已按 09 模块装好 Go 1.27.x(go version 能看到 go1.27.x)。下面所有命令在 PowerShell 里执行。

3.1 建目录并初始化模块

# 任选一个代码根目录,例如 D:\code
mkdir workbench\server -Force
cd workbench\server
go mod init workbench/server

# 配置国内代理(首次必做,否则拉依赖慢/失败)
go env -w GOPROXY=https://goproxy.cn,direct

3.2 go.mod(完整内容,照抄)

模块名定为 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)。

为什么 SQLite 用 glebarez 而不是 mattn/go-sqlite3:后者是 CGO 绑定,Windows 上要装 gcc,交叉编译到 Linux 时要带完整工具链;glebarez/sqlite 是纯 Go 重写(包装 modernc.org/sqlite),CGO_ENABLED=0 直接编译出 Linux 二进制——这是本项目"一次编译、到处部署"的地基。
04

基础三件套:config / log / db(全量代码)

先写"地基三文件":配置怎么读、日志怎么打、数据库怎么连。它们不包含任何业务,每个项目都长这样——这就是要沉淀进架子(scaffold)的部分。

4.1 internal/config/config.go

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
}

4.2 internal/log/log.go

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
}

4.3 internal/db/db.go

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
}

密码哈希只落库一次,数据库里永远看不到明文密码;第二次启动检测到已有用户就自动跳过,不会重复插入。

05

数据模型与四个 handler(全量代码)

5.1 internal/model/model.go(表结构,契约锁定)

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"`
}

5.2 internal/handler/handler.go(统一响应封装)

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})
}

5.3 internal/handler/healthz.go

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),
	})
}

5.4 internal/handler/auth.go(登录 + 签发 JWT)

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})
}

5.5 internal/handler/user.go

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)
}

5.6 internal/handler/task.go(CRUD 全量)

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})
}
06

中间件、路由、入口 main(全量代码)

6.1 internal/middleware/middleware.go

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()
	}
}

6.2 internal/router/router.go

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
}

6.3 cmd/server/main.go(入口)

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("服务已停止")
}

6.4 .env.example(复制为 .env 后填写)

# 服务端口
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 首次启动自动写入。

07

后端测试:httptest 表驱动,全绿目标

契约基线: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。这就是契约要求的"全绿基线"——以后每改一个功能,先跑它。

08

前端最小可用版(全量代码)

本模块前端只做到"能跑":登录页 + 任务列表页,能调通上面全部 API。深度设计(组件化、表单校验、状态机、UI 打磨)是 12 模块的事。前置:09 模块已装好 Node 24 LTS + pnpm 12。

8.1 web/package.json

{
  "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"
  }
}

8.2 web/vite.config.ts(dev proxy 的关键配置)

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,
      },
    },
  },
})

8.3 web/index.html

<!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>

8.4 web/src/main.ts 与 web/src/App.vue

// 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>

8.5 web/src/api/index.ts(axios 封装 + 拦截器)

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}`),
}

8.6 web/src/stores/user.ts(Pinia)

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 }
})

8.7 web/src/router/index.ts(路由 + 登录守卫)

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

8.8 web/src/views/LoginView.vue

<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>

8.9 web/src/views/WorkbenchView.vue(任务列表 + CRUD)

<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 无报错)。

09

本地跑通全流程(照做步骤)

开两个终端。终端 A 起后端,终端 B 起前端。按顺序执行,每步对照预期。

9.1 终端 A:起后端

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 账号自动写入。

9.2 curl 验证(新开 PowerShell 窗口)

# 健康检查
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

9.3 终端 B:起前端

cd D:\code\workbench\web
pnpm install
pnpm dev

浏览器打开 http://localhost:5173:自动跳登录页 → admin/admin123 登录 → 任务页 → 加一条任务 → 勾选完成 → 删除。全通 = 本地 M1 达成。

9.4 可选:air 热重载(开发提速,不上服务器)

go install github.com/air-verse/air@v1.67.4
# 在 server 目录执行,改 Go 代码自动重启
air
10

交叉编译部署到 1GB Linux 服务器

这是本项目最爽的一步:因为用了纯 Go 的 glebarez/sqlite,不需要 gcc、不需要任何 Linux 工具链,Windows 上一条命令产出 Linux 二进制。

10.1 Windows 上交叉编译

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_ENABLED=0 为什么是关键:如果用 mattn/go-sqlite3,这一步会报 cgo: C compiler "gcc" not found——那是 CGO 驱动的宿命。glebarez/sqlite 是纯 Go,CGO_ENABLED=0 下照常编译,1GB 服务器上连 gcc 都不用装。

10.2 上传到服务器

scp .\workbench-server app@你的服务器IP:/home/app/workbench/
# 同时把 .env 里 JWT_SECRET 改成随机长串,上传为服务器上的 /home/app/workbench/.env

10.3 systemd service(deploy/workbench.service)

[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"...}

10.4 切换 MySQL(一行环境变量)

把 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 模块)。

10.5 Nginx 反代简述(完整版见 20 模块)

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 模块都在这副骨架上加东西。

11

工程化扩展(阶段二简版,W7–W8 做)

M1 是"能跑",M3(W8 末)是"像工程"。本模块先给三样东西的雏形,深度展开见各专题。

11.1 优雅停机(已在 main.go 内置)

main.go 用 signal.NotifyContext 捕获 SIGINT/SIGTERM,srv.Shutdown 给在途请求最多 5 秒收尾。验证:服务器上 sudo systemctl restart workbench,日志应先打"收到停机信号,开始优雅退出"再打"服务已停止",而不是连接被硬断。

11.2 迁移策略(现阶段够用版)

场景做法什么时候升级
加新字段/新表改 model 结构体 → AutoMigrate 自动补列补表(不删列、不改类型)M1–M3 阶段足够
改字段类型/删字段/加索引手写 SQL 迁移脚本,放进 deploy/migrations/,上线前先在备份库演练阶段三(有真实数据后)
数据订正(批量改数)单独写 cmd/migrate 小工具,连一次库跑完即弃同上

11.3 错误码规范(本表进 playbook)

code含义触发场景
0成功一切正常
40001参数错误缺少必填字段、格式不符、id 非法
40101未认证无 token / token 过期 / 密码错
40301无权限M3 后加 RBAC 时启用
40401资源不存在按 id 查无此记录
40901冲突用户名重复、唯一索引冲突
50000服务器内部错DB 错、panic 兜底——日志里必须能看到堆栈线索
12

常见报错与解决(14 条,真实会遇到)

卡住了先翻这张表。每条都是照本教程做时真实会撞上的。

报错原文原因解决
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 dereferenceHandler.DB 是 nil(main 没注入),或 gorm.Open 报错没检查就继续用检查 main.go 里 h := &handler.Handler{DB: gdb...};所有 err 显式 return
bind: address already in use :80808080 端口被占用(旧进程没退)PowerShell: netstat -ano | findstr :8080 找 PID,taskkill /PID 号 /F;或 .env 改 PORT=8081
token is expired / token signature is invalidJWT 过期;或本地 .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:8080api baseURL 保持 '/api/v1' 相对路径走 proxy;后端 CORS() 中间件已兜底
vite proxy 不生效(请求 404 或打到了 5173)改完 vite.config.ts 没重启;或请求写了绝对地址 http://localhost:8080Ctrl+C 后 pnpm dev 重启;baseURL 用相对路径
MySQL: Public Key Retrieval is not allowed / caching_sha2_passwordMySQL 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 后重试
13

M1 验收清单与 README 版本表

13.1 验收清单(对照 08 总纲 M1,逐项打勾)

#验收项通过标准
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服务器 systemdsystemctl status 显示 active;重启服务器后服务自启
8服务器健康检查:curl 127.0.0.1:8080/healthz返回 ok
9手机访问:Nginx 反代后打开 http://服务器IP手机浏览器能登录、能操作任务
10工程基线.env 不入库;JWT_SECRET 生产已改随机串;README 版本表写好

13.2 server/README.md 版本表模板(照抄)

# 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(首次启动自动写入,上线后立刻改)

13.3 下一步往哪走

前端深化

组件拆分、表单校验、UI 打磨——进 12 Vue3 专题。

数据库深化

SQLite WAL/备份(19)→ MySQL 生产化(16)→ PG(17)→ Redis 缓存(18)。

部署生产化

备份脚本、HTTPS、监控、Caddy 替代 Nginx——进 20 部署运维专题。
14

来源表

工作台目录结构 / 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 与作者实战经验的教学化设计,无外部独立来源。)