Files
portal_page/docs/system_design.md
T
kevin c16a8dfbc4 feat: 门户网站初始提交
- Go + Gin + html/template 服务端渲染
- 主页:Google 风格搜索框 + 导航卡片
- 后台:卡片 CRUD、搜索引擎配置、主页背景/标题配置
- 图片上传:支持 jpg/jpeg/png/gif,自动压缩,缩略图参数 ?thumb=1
- 安全:登录日志、修改密码、IP 自动封禁、IP 白名单
- 访问统计:主页访问/卡片点击/搜索追踪、实时流量、IP 统计
- SQLite 存储(modernc.org/sqlite,纯 Go)
- 内存 Session + bcrypt 密码哈希
2026-05-28 13:54:07 +08:00

23 KiB
Raw Blame History

Simple Portal — 系统架构设计文档

Part A: 系统设计


1. 实现方案分析

核心技术挑战

挑战 解决方案
纯服务端渲染,无 SPA 路由 Gin 路由 + html/template 模板继承(layout 嵌套子模板),表单 POST 提交后 302 重定向
SQLite 并发写入 单文件 SQLite + WAL 模式,Go 侧使用全局 *sql.DB 连接池(SetMaxOpenConns(1) 保证串行写)
Session 管理(无需外部依赖) 内存 map[string]*Session + sync.RWMutexCookie 存储 session ID,重启失效可接受
卡片排序 sort 字段整数排序,上移/下移通过交换相邻卡片的 sort 值实现
搜索引擎占位符 settings 表存储引擎 URL 模板(%s 占位符),前端 JS 拼接跳转,后端验证模板合法性

框架与库选型

组件 选型 理由
HTTP 框架 github.com/gin-gonic/gin PRD 指定
SQLite 驱动 modernc.org/sqlite 纯 Go 实现,无需 CGO,交叉编译友好
密码哈希 golang.org/x/crypto/bcrypt PRD 指定,工业标准
模板引擎 html/template PRD 指定,Go 原生
CSS 单文件 style.css PRD 约束,无 CSS 框架

架构模式

采用 经典 MVC 分层

Request → Gin Router → Middleware(auth) → Handler → Model → SQLite
                                    ↓
                              Template Rendering → HTML Response
  • Model:纯数据访问层,封装 SQL 操作
  • HandlerController):处理 HTTP 请求,调用 Model,渲染模板
  • TemplateView):html/template 模板文件
  • Middleware:鉴权拦截,未登录重定向到登录页
  • Session:独立包,提供 Create/Get/Delete 操作

2. 文件列表

Portal_page/
├── main.go                          # 程序入口:初始化 DB、路由、启动服务
├── go.mod                           # Go 模块定义
├── go.sum                           # 依赖校验
│
├── database/
│   └── db.go                        # DB 初始化、表创建、全局 *sql.DB
│
├── models/
│   ├── card.go                      # Card CRUD + 排序操作
│   ├── setting.go                   # Setting 读写(搜索引擎配置等)
│   └── admin.go                     # Admin 用户操作 + 密码验证
│
├── handlers/
│   ├── home.go                      # 主页渲染(卡片列表 + 搜索框)
│   ├── admin.go                     # 登录/登出 + 后台首页
│   ├── cards.go                     # 卡片增删改查 + 启用/禁用 + 排序
│   └── settings.go                  # 搜索引擎配置页面 + 保存
│
├── middleware/
│   └── auth.go                      # 登录鉴权中间件
│
├── session/
│   └── session.go                   # 内存 Session 管理
│
├── templates/
│   ├── layout.html                  # 基础布局(head/nav/footer
│   ├── home.html                    # 主页模板
│   └── admin/
│       ├── login.html               # 登录页
│       ├── index.html               # 后台首页(仪表盘)
│       ├── cards.html               # 卡片列表管理
│       ├── card_form.html           # 卡片新增/编辑表单
│       └── settings.html            # 搜索引擎设置
│
└── static/
    └── style.css                    # 全局样式

各文件职责说明

文件 职责
main.go 加载配置、初始化 DB、注册路由和中间件、启动 HTTP 服务
database/db.go InitDB() 打开 SQLite 连接、建表、插入默认管理员和默认搜索引擎配置
models/card.go Card structGetAllCards()/GetEnabledCards()/CreateCard()/UpdateCard()/DeleteCard()/ToggleCard()/MoveCardUp()/MoveCardDown()
models/setting.go Setting structGetSetting(key)/SetSetting(key, value);预定义搜索引擎常量
models/admin.go Admin structGetAdminByUsername()/CreateAdmin()/VerifyPassword()/ChangePassword()
handlers/home.go HomeHandler:查询启用卡片 + 当前搜索引擎配置,渲染主页
handlers/admin.go LoginGet/LoginPost/Logout/AdminIndex:登录表单、认证、登出、后台首页
handlers/cards.go CardsList/CardCreate/CardEdit/CardDelete/CardToggle/CardMoveUp/CardMoveDown
handlers/settings.go SettingsGet/SettingsPost:展示/保存搜索引擎配置
middleware/auth.go AuthRequired():检查 Cookie 中的 session_id,未登录则 302 到 /admin/login
session/session.go SessionStore:内存 map + RWMutexCreate()/Get()/Delete()
templates/layout.html HTML 骨架:{{define "layout"}}...{{template "content" .}}{{end}}
templates/home.html 搜索框 + 卡片网格
templates/admin/*.html 后台各页面
static/style.css 全局 CSS:卡片网格、搜索框、表单、后台布局

3. 数据结构与接口

classDiagram
    direction LR

    class Card {
        +int ID
        +string Icon
        +string Title
        +string Subtitle
        +string URL
        +int Sort
        +bool Enabled
        +time Time CreatedAt
    }

    class Setting {
        +string Key
        +string Value
    }

    class Admin {
        +int ID
        +string Username
        +string Password
    }

    class SessionData {
        +int AdminID
        +string Username
        +time Time CreatedAt
    }

    class SessionStore {
        -map~string-SessionData~ store
        -sync.RWMutex mu
        +Create(adminID int, username string) string
        +Get(sessionID string) *SessionData
        +Delete(sessionID string)
    }

    class CardModel {
        +GetAllCards() []Card
        +GetEnabledCards() []Card
        +GetCardByID(id int) *Card
        +CreateCard(card *Card) error
        +UpdateCard(card *Card) error
        +DeleteCard(id int) error
        +ToggleCard(id int) error
        +MoveCardUp(id int) error
        +MoveCardDown(id int) error
    }

    class SettingModel {
        +GetSetting(key string) string
        +SetSetting(key string, value string) error
        +GetSearchEngines() map~string-string
        +SetSearchEngine(name string, url string) error
    }

    class AdminModel {
        +GetAdminByUsername(username string) *Admin
        +CreateAdmin(username string, password string) error
        +VerifyPassword(username string, password string) bool
        +ChangePassword(adminID int, newPassword string) error
    }

    class HandlerHome {
        +HomeHandler(c *gin.Context)
    }

    class HandlerAdmin {
        +LoginGet(c *gin.Context)
        +LoginPost(c *gin.Context)
        +Logout(c *gin.Context)
        +AdminIndex(c *gin.Context)
    }

    class HandlerCards {
        +CardsList(c *gin.Context)
        +CardCreateGet(c *gin.Context)
        +CardCreatePost(c *gin.Context)
        +CardEditGet(c *gin.Context)
        +CardEditPost(c *gin.Context)
        +CardDelete(c *gin.Context)
        +CardToggle(c *gin.Context)
        +CardMoveUp(c *gin.Context)
        +CardMoveDown(c *gin.Context)
    }

    class HandlerSettings {
        +SettingsGet(c *gin.Context)
        +SettingsPost(c *gin.Context)
    }

    class AuthMiddleware {
        +AuthRequired() gin.HandlerFunc
    }

    CardModel --> Card : returns
    SettingModel --> Setting : returns
    AdminModel --> Admin : returns
    SessionStore --> SessionData : stores
    HandlerHome --> CardModel : uses
    HandlerHome --> SettingModel : uses
    HandlerAdmin --> AdminModel : uses
    HandlerAdmin --> SessionStore : uses
    HandlerCards --> CardModel : uses
    HandlerSettings --> SettingModel : uses
    AuthMiddleware --> SessionStore : uses

路由表

方法 路径 Handler 中间件 说明
GET / HomeHandler 主页
GET /admin/login LoginGet 登录页
POST /admin/login LoginPost 登录提交
POST /admin/logout Logout AuthRequired 登出
GET /admin AdminIndex AuthRequired 后台首页
GET /admin/cards CardsList AuthRequired 卡片列表
GET /admin/cards/new CardCreateGet AuthRequired 新增卡片表单
POST /admin/cards CardCreatePost AuthRequired 新增卡片提交
GET /admin/cards/:id/edit CardEditGet AuthRequired 编辑卡片表单
POST /admin/cards/:id CardEditPost AuthRequired 编辑卡片提交
POST /admin/cards/:id/delete CardDelete AuthRequired 删除卡片
POST /admin/cards/:id/toggle CardToggle AuthRequired 启用/禁用卡片
POST /admin/cards/:id/move-up CardMoveUp AuthRequired 卡片上移
POST /admin/cards/:id/move-down CardMoveDown AuthRequired 卡片下移
GET /admin/settings SettingsGet AuthRequired 搜索引擎设置页
POST /admin/settings SettingsPost AuthRequired 保存搜索引擎设置

4. 程序调用流程

4.1 主页加载

sequenceDiagram
    participant U as 用户浏览器
    participant G as Gin Router
    participant H as HomeHandler
    participant CM as CardModel
    participant SM as SettingModel
    participant T as html/template

    U->>G: GET /
    G->>H: HomeHandler(c)
    H->>CM: GetEnabledCards()
    CM->>CM: SELECT * FROM cards WHERE enabled=1 ORDER BY sort
    CM-->>H: []Card
    H->>SM: GetSetting("search_engine")
    SM->>SM: SELECT value FROM settings WHERE key='search_engine'
    SM-->>H: search URL template
    H->>T: ExecuteTemplate("home.html", data)
    T-->>U: HTML (搜索框 + 卡片网格)

4.2 搜索跳转

sequenceDiagram
    participant U as 用户浏览器
    participant JS as 前端 JavaScript
    participant SE as 搜索引擎

    U->>JS: 输入关键词,按回车/点击搜索
    JS->>JS: 读取隐藏域 search_url_template
    JS->>JS: fmt.Sprintf(template, query)
    JS->>U: window.location.href = 拼接后的URL
    U->>SE: HTTP 请求到搜索引擎

注意:搜索跳转纯前端 JS 完成,不经过后端路由。

4.3 后台登录

sequenceDiagram
    participant U as 用户浏览器
    participant G as Gin Router
    participant H as HandlerAdmin
    participant AM as AdminModel
    participant SS as SessionStore

    U->>G: GET /admin/login
    G->>H: LoginGet(c)
    H-->>U: 登录页 HTML

    U->>G: POST /admin/login {username, password}
    G->>H: LoginPost(c)
    H->>AM: VerifyPassword(username, password)
    AM->>AM: bcrypt.CompareHashAndPassword()
    AM-->>H: true/false

    alt 验证成功
        H->>SS: Create(adminID, username)
        SS-->>H: sessionID
        H->>H: c.SetCookie("session_id", sessionID, 86400, "/", "", false, true)
        H-->>U: 302 Redirect → /admin
    else 验证失败
        H-->>U: 200 + 登录页(错误提示)
    end

4.4 卡片增删改查

sequenceDiagram
    participant U as 管理员浏览器
    participant G as Gin Router
    participant MW as AuthMiddleware
    participant H as HandlerCards
    participant CM as CardModel

    Note over U,CM: 卡片列表
    U->>G: GET /admin/cards
    G->>MW: AuthRequired()
    MW->>MW: 检查 Cookie session_id
    alt 未登录
        MW-->>U: 302 → /admin/login
    else 已登录
        MW->>H: CardsList(c)
        H->>CM: GetAllCards()
        CM-->>H: []Card
        H-->>U: 卡片列表 HTML
    end

    Note over U,CM: 新增卡片
    U->>G: GET /admin/cards/new
    G->>H: CardCreateGet(c)
    H-->>U: 空表单 HTML

    U->>G: POST /admin/cards {icon, title, subtitle, url}
    G->>H: CardCreatePost(c)
    H->>CM: CreateCard(&Card{...})
    CM->>CM: INSERT INTO cards(...)
    CM-->>H: nil
    H-->>U: 302 Redirect → /admin/cards

    Note over U,CM: 编辑卡片
    U->>G: GET /admin/cards/:id/edit
    G->>H: CardEditGet(c)
    H->>CM: GetCardByID(id)
    CM-->>H: *Card
    H-->>U: 预填表单 HTML

    U->>G: POST /admin/cards/:id {icon, title, subtitle, url}
    G->>H: CardEditPost(c)
    H->>CM: UpdateCard(&Card{...})
    CM->>CM: UPDATE cards SET ... WHERE id=?
    CM-->>H: nil
    H-->>U: 302 Redirect → /admin/cards

    Note over U,CM: 删除卡片
    U->>G: POST /admin/cards/:id/delete
    G->>H: CardDelete(c)
    H->>CM: DeleteCard(id)
    CM->>CM: DELETE FROM cards WHERE id=?
    CM-->>H: nil
    H-->>U: 302 Redirect → /admin/cards

4.5 卡片排序(上移/下移)

sequenceDiagram
    participant U as 管理员浏览器
    participant G as Gin Router
    participant H as HandlerCards
    participant CM as CardModel

    U->>G: POST /admin/cards/:id/move-up
    G->>H: CardMoveUp(c)
    H->>CM: MoveCardUp(id)
    CM->>CM: 获取当前卡片 sort 值
    CM->>CM: 获取 sort 值仅次于当前卡片的上一张卡片
    CM->>CM: 交换两者 sort 值 (UPDATE)
    CM-->>H: nil
    H-->>U: 302 Redirect → /admin/cards

    Note over U,CM: 下移同理,方向相反

4.6 卡片启用/禁用

sequenceDiagram
    participant U as 管理员浏览器
    participant G as Gin Router
    participant H as HandlerCards
    participant CM as CardModel

    U->>G: POST /admin/cards/:id/toggle
    G->>H: CardToggle(c)
    H->>CM: ToggleCard(id)
    CM->>CM: UPDATE cards SET enabled = CASE WHEN enabled=1 THEN 0 ELSE 1 END WHERE id=?
    CM-->>H: nil
    H-->>U: 302 Redirect → /admin/cards

4.7 搜索引擎配置

sequenceDiagram
    participant U as 管理员浏览器
    participant G as Gin Router
    participant H as HandlerSettings
    participant SM as SettingModel

    U->>G: GET /admin/settings
    G->>H: SettingsGet(c)
    H->>SM: GetSetting("search_engine")
    SM-->>H: 当前搜索引擎 URL 模板
    H-->>U: 设置页 HTML(预设选项 + 自定义输入框)

    U->>G: POST /admin/settings {engine: "google" | "bing" | "baidu" | "custom", custom_url: "..."}
    G->>H: SettingsPost(c)
    H->>H: 根据选项确定 URL 模板
    alt 预设引擎
        H->>H: 映射到固定 URL 模板
    else 自定义
        H->>H: 使用 custom_url,验证含 %s
    end
    H->>SM: SetSetting("search_engine", urlTemplate)
    SM->>SM: INSERT OR REPLACE INTO settings(key, value) VALUES(...)
    SM-->>H: nil
    H-->>U: 302 Redirect → /admin/settings

5. 不确定事项与假设

编号 问题 假设/默认决策
UNC-1 管理员是否支持多账号? PRD 已定:初始 admin/admin123 硬编码,暂不考虑多账号管理 UI,但数据模型支持
UNC-2 搜索引擎配置是否需要多个? 当前仅支持配置一个默认搜索引擎,settings 表 key=search_engine
UNC-3 卡片图标是否需要文件上传? PRD 已定:仅支持 emoji 文字和 favicon URL,通过文本输入框填写
UNC-4 CSRF 防护 简易项目暂不实现 CSRF Token(所有写操作需登录 + Cookie HttpOnly
UNC-5 HTTPS 不在应用层处理,由部署环境(反向代理)负责
UNC-6 端口配置 默认监听 :8080,可通过环境变量 PORT 覆盖

Part B: 任务分解


6. 依赖包列表

github.com/gin-gonic/gin@v1.10.0          # HTTP 框架
modernc.org/sqlite@latest                   # 纯 Go SQLite 驱动
golang.org/x/crypto@latest                  # bcrypt 密码哈希

仅 3 个直接依赖,保持极简。


7. 任务列表

T01: 项目基础设施

说明:创建项目骨架,包括模块初始化、数据库初始化、会话管理、全局布局模板、静态文件服务和程序入口。

源文件

  • go.mod, go.sum
  • main.go
  • database/db.go
  • session/session.go
  • middleware/auth.go
  • templates/layout.html
  • static/style.css(基础框架样式:布局、导航、表单通用样式)

具体内容

  1. go mod init + 添加依赖
  2. database/db.goInitDB() — 打开 SQLite 文件、建表(cards/settings/admins)、插入默认管理员(admin/admin123 bcrypt hash)、插入默认搜索引擎(Google)
  3. session/session.goSessionStore 结构体 + Create/Get/Delete 方法
  4. middleware/auth.goAuthRequired() 中间件,从 Cookie 读取 session_id,校验失败 302 到 /admin/login
  5. templates/layout.htmlHTML 骨架({{define "layout"}} 含 head/nav/footer{{template "content" .}} 插槽)
  6. static/style.css:CSS 变量定义、reset、布局容器、导航栏、表单基础样式
  7. main.go:初始化 DB、创建 SessionStore、注册所有路由(此阶段 handler 只返回空字符串占位)、启动服务

依赖:无

优先级P0


T02: 数据模型层

说明:实现所有数据模型的 CRUD 操作,与 SQLite 交互。

源文件

  • models/card.go
  • models/setting.go
  • models/admin.go

具体内容

  1. models/card.go
    • Card struct(映射 cards 表)
    • GetAllCards() — 查询全部卡片(后台用),按 sort ASC
    • GetEnabledCards() — 仅查询启用的卡片(主页用),按 sort ASC
    • GetCardByID(id int) — 单卡片查询
    • CreateCard(card *Card) — INSERTsort 自动取 MAX(sort)+1
    • UpdateCard(card *Card) — UPDATE by ID
    • DeleteCard(id int) — DELETE by ID
    • ToggleCard(id int) — 切换 enabled 字段
    • MoveCardUp(id int) — 交换 sort 值上移
    • MoveCardDown(id int) — 交换 sort 值下移
  2. models/setting.go
    • Setting struct
    • 预定义搜索引擎常量:SearchEngineGoogle = "https://www.google.com/search?q=%s"
    • GetSetting(key string) (string, error)
    • SetSetting(key, value string) error
  3. models/admin.go
    • Admin struct
    • GetAdminByUsername(username string) (*Admin, error)
    • CreateAdmin(username, hashedPassword string) error
    • VerifyPassword(username, password string) (bool, *Admin, error) — bcrypt 校验
    • ChangePassword(adminID int, newHashedPassword string) error

依赖T01(需要 database/db.go 中初始化的 *sql.DB

优先级P0


T03: 后台管理 — 登录 + 卡片管理

说明:实现后台核心功能:管理员登录/登出、卡片 CRUD、启用/禁用、排序。

源文件

  • handlers/admin.go
  • handlers/cards.go
  • templates/admin/login.html
  • templates/admin/index.html
  • templates/admin/cards.html
  • templates/admin/card_form.html

具体内容

  1. handlers/admin.go
    • LoginGet — 渲染登录页
    • LoginPost — 表单验证 → AdminModel.VerifyPassword() → 创建 Session → 写 Cookie → 302 到 /admin
    • Logout — 删除 Session → 清 Cookie → 302 到 /admin/login
    • AdminIndex — 渲染后台首页
  2. handlers/cards.go
    • CardsList — 调用 GetAllCards(),渲染列表页
    • CardCreateGet / CardCreatePost — 新增卡片
    • CardEditGet / CardEditPost — 编辑卡片
    • CardDelete — 删除卡片
    • CardToggle — 切换启用/禁用
    • CardMoveUp / CardMoveDown — 排序
  3. 模板文件:登录页、后台首页、卡片列表(含操作按钮)、卡片表单(新增/编辑共用)

依赖:T01(路由和中间件)、T02(数据模型)

优先级P0


T04: 主页 + 搜索引擎配置

说明:实现面向用户的主页(搜索框 + 导航卡片)和后台搜索引擎配置。

源文件

  • handlers/home.go
  • handlers/settings.go
  • templates/home.html
  • templates/admin/settings.html

具体内容

  1. handlers/home.go
    • HomeHandler — 获取启用卡片 + 当前搜索引擎 URL 模板,渲染主页
    • 搜索框前端 JS:读取隐藏域中的 URL 模板,拼接 %s 占位符,window.location.href 跳转
    • 搜索框自动聚焦(autofocus 属性)
  2. handlers/settings.go
    • SettingsGet — 读取当前配置,渲染设置页
    • SettingsPost — 接收引擎选择(预设/自定义),自定义 URL 需包含 %s,保存到 settings 表
  3. templates/home.html:搜索框(大尺寸居中)+ 卡片网格(图标/标题/副标题,可点击跳转)
  4. templates/admin/settings.html:预设引擎单选 + 自定义 URL 输入

依赖T01、T02

优先级P0


T05: 样式完善 + 集成调试

说明:完善所有页面 CSS 样式,端到端集成测试,修复问题。

源文件

  • static/style.css(补充完善所有页面样式)
  • templates/layout.html(可能微调)
  • main.go(最终路由确认)

具体内容

  1. 完善主页样式:卡片网格(CSS Grid/Flexbox)、hover 效果、搜索框美观
  2. 完善后台样式:表单对齐、按钮样式、卡片操作按钮布局
  3. 响应式基础:卡片网格自适应列数
  4. 端到端手动验证:
    • 首次启动 → 默认管理员 → 登录成功
    • 新增/编辑/删除/排序卡片 → 主页正确展示
    • 切换搜索引擎 → 主页搜索跳转正确
    • 未登录访问后台 → 重定向到登录页
  5. 修复发现的问题

依赖T03、T04

优先级P1


8. 跨文件共享约定

- 全局 *sql.DB 实例:通过 database 包导出的 DB 变量访问,models 包直接引用 database.DB
- Session Cookie 名称:"session_id"HttpOnly=trueSameSite=LaxMaxAge=8640024小时)
- Session Store 实例:main.go 中创建,通过闭包注入到 middleware 和 handlers
- 模板数据传递:所有 handler 通过 gin.H 传递,模板中统一使用 .Cards / .Card / .SearchEngine / .Error / .Message
- 模板继承:所有页面通过 {{template "layout" .}} 包裹,子页面 {{define "content"}}...{{end}}
- 错误处理:handler 层 c.HTML(500, ...) 或 302 重定向,不向用户暴露内部错误
- 表单提交:所有写操作使用 POST + 302 重定向(PRG 模式),防止重复提交
- URL 模板占位符:%sGo fmt.Sprintf 兼容)
- 静态文件路径:/static/ → ./static/gin.Static
- SQLite 文件路径:./data/portal.db(自动创建 data 目录)
- 卡片 sort 值:数字越小越靠前,新建卡片 sort=MAX(sort)+1
- 搜索引擎配置 key"search_engine"value 为完整 URL 模板
- 管理员默认账号:admin / admin123bcrypt hash 存储于 DB
- 所有日期字段使用 SQLite CURRENT_TIMESTAMPRFC 3339 格式)
- 后台路由统一前缀:/admin/
- 模板文件路径:相对于项目根目录的 templates/ 目录
- CSS 类命名:BEM 风格简化版,如 .card-grid / .card-item / .card-item__icon / .search-box

9. 任务依赖图

graph TD
    T01[T01: 项目基础设施<br/>go.mod, main.go, db, session,<br/>middleware, layout, style.css]
    T02[T02: 数据模型层<br/>models/card.go, setting.go, admin.go]
    T03[T03: 后台管理<br/>handlers/admin.go, cards.go,<br/>templates/admin/*]
    T04[T04: 主页 + 搜索引擎配置<br/>handlers/home.go, settings.go,<br/>templates/home.html, admin/settings.html]
    T05[T05: 样式完善 + 集成调试<br/>style.css 补充, 全流程验证]

    T01 --> T02
    T01 --> T03
    T01 --> T04
    T02 --> T03
    T02 --> T04
    T03 --> T05
    T04 --> T05

T03 和 T04 可并行开发(均依赖 T01 + T02),最终汇聚到 T05 集成。