更新列表

This commit is contained in:
李琦
2026-06-23 13:17:26 +08:00
parent 1f7f84bdb6
commit 6868eebc62
6 changed files with 1411 additions and 182 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/.opencode/
/signature-server
/uploads/

View File

@@ -0,0 +1,120 @@
# 计划:签名列表 + 登录 + 下载功能
## 背景
当前系统:单文件 Go 服务器 (`main.go`),内嵌 `index.html` 前端。签名文件保存在 `uploads/<姓名>/<姓名>+<日期>.png`。没有认证、没有列表、没有下载。
**目标**:添加登录验证功能,在登录后可以查看签名列表并下载,同时保持原有签名功能不变。
---
## 架构决策
**保持单文件方式**:所有改动都在 `main.go`(后端路由)和 `index.html`(前端单页应用)中完成。运行时只需额外创建 `pwd.json` 文件。
**认证方式**:简单的 Token 认证 —— 后端登录成功后生成 Token,前端存入 `localStorage`,请求受保护路由时携带 Token。无需复杂的 Session 框架。
**文件存储不变**:签名文件仍保存在 `uploads/<姓名>/<姓名>+<日期>.png`,列表接口扫描该目录。
---
## 具体改动
### 1. 后端 (`main.go`)
#### 新增路由:
| 路由 | 方法 | 说明 |
|------|------|------|
| `/login` | POST | 根据 `pwd.json` 验证账号密码,返回 `{success, token}` |
| `/api/signatures` | GET | 列出所有签名文件(需要有效 Token) |
| `/download` | GET | 下载签名图片(需要有效 Token,`?path=` 参数) |
| `/api/check-auth` | GET | 验证 Token 是否有效,返回 `{authenticated}` |
#### 实现细节:
**`pwd.json` 加载**:
- 从可执行文件目录读取:`filepath.Join(filepath.Dir(os.Args[0]), "pwd.json")`
- 如果找不到则从当前工作目录读取
- 格式:`{"account": "liqi", "password": "qiqi991012"}`
- 启动时加载一次到内存中
**Token 系统**:
- 登录成功后生成 Token = base64(账号 + 时间戳)
- 用 `map[string]string` 存储有效 Token(Token → 账号),带过期时间
- Token 24 小时后过期
- 内存存储即可,无需持久化
**`/api/signatures` 处理函数**:
- 遍历 `uploads/` 目录,收集所有 `.png` 文件
- 返回 JSON 数组:`[{name, path, date, size}]`
- 按修改时间倒序排列
- 需要 `Authorization: Bearer <token>` 请求头
**`/download` 处理函数**:
- 接受 `?path=uploads/<姓名>/<文件名>.png` 查询参数
- 验证路径在 `uploads/` 目录内(防止路径穿越攻击)
- 流式返回文件,设置 `Content-Disposition: attachment` 头
- 需要有效 Token
### 2. 前端 (`index.html`)
#### 核心逻辑变更:
**默认进入签名页面(无需登录)**,只有点击"签名列表"时才检查登录状态。
#### 新增区域(SPA 风格,无页面刷新):
**登录视图** (`#loginView`):
- 居中卡片,包含账号密码输入框
- "登录"按钮
- 登录失败显示错误提示
- 登录成功后自动跳转到签名列表
**签名列表视图** (`#listView`):
- 卡片列表显示所有签名
- 列:签署人、日期、文件大小、下载按钮
- "返回签名"按钮回到签名页面
- 空状态显示"暂无签名记录"
**导航栏**:
- "签名列表"按钮始终可见
- 点击时检查登录状态,未登录则跳转登录页
- 登录后显示"注销"按钮
#### 状态管理:
- `currentView`:'sign' | 'login' | 'list'
- `authToken`:存储在 `localStorage`
- 通过显示/隐藏区域切换视图
### 3. `pwd.json`(运行时文件)
```json
{
"account": "liqi",
"password": "qiqi991012"
}
```
放在可执行文件旁边(或 `go run` 时的工作目录)。
---
## 修改文件清单
| 文件 | 操作 | 范围 |
|------|------|------|
| `main.go` | 编辑 | 添加路由、认证逻辑、文件扫描 |
| `index.html` | 编辑 | 添加登录视图、列表视图、导航、认证状态 |
| `pwd.json` | 新建 | 账号密码(运行时) |
---
## 验证步骤
1. **启动服务器**:`go run main.go`
2. **测试默认进入**:打开 `http://localhost:10081`,直接进入签名页面(无需登录)
3. **测试签名**:签名并提交,验证保存到 `uploads/`
4. **测试列表入口**:点击"签名列表"按钮,验证跳转到登录页面
5. **测试登录**:输入账号密码,登录后跳转到签名列表
6. **测试下载**:点击下载按钮,验证 PNG 文件正确下载
7. **测试返回**:点击"返回签名",验证回到签名页面
8. **测试注销**:点击"注销",再次点击"签名列表"需重新登录

31
AGENTS.md Normal file
View File

@@ -0,0 +1,31 @@
# AGENTS.md
## Project Overview
Single-file Go HTTP server for electronic signature capture and archiving. Frontend HTML is embedded into the Go binary via `//go:embed`.
## Build & Run
```bash
# Local development
go run main.go
# Server starts on port 10081
# Cross-compile (Windows/Linux)
build.bat
# Outputs to dist/ with timestamped filenames
```
## Architecture
- `main.go` — Entire backend. Embeds `index.html`, serves it at `/`, handles `POST /upload` to save base64 PNGs to `uploads/<name>/<name>+<date>.png`
- `index.html` — Standalone frontend (Tailwind CDN, Phosphor Icons, canvas-based signature)
- `uploads/` — Runtime signature storage (gitignored)
## Key Constraints
- No `go.mod` present — initialize one if adding dependencies
- No tests exist
- Frontend uses CDN resources (Tailwind, Phosphor Icons) — no local build step
- Upload handler validates name non-empty, strips base64 prefix, saves as PNG
- Port is hardcoded to `:10081`

File diff suppressed because it is too large Load Diff

502
main.go
View File

@@ -5,39 +5,129 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 1. 引入 embed 包,并将 index.html 嵌入到变量 content 中
//
// ============================================================================
// 嵌入前端文件
// ============================================================================
//go:embed index.html
var content embed.FS
// UploadRequest 定义前端发送的JSON数据结构
// ============================================================================
// 数据结构定义
// ============================================================================
// UploadRequest 前端发送的签名上传请求结构
type UploadRequest struct {
Name string `json:"name"`
Image string `json:"image"`
Name string `json:"name"` // 签署人姓名
Image string `json:"image"` // Base64 编码的签名图片
}
// UploadResponse 定义返回给前端的响应结构
// UploadResponse 服务器返回的响应结构
type UploadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Path string `json:"path"`
Success bool `json:"success"` // 是否成功
Message string `json:"message"` // 提示信息
Path string `json:"path"` // 文件保存路径
}
// LoginRequest 登录请求结构
type LoginRequest struct {
Account string `json:"account"` // 账号
Password string `json:"password"` // 密码
}
// LoginResponse 登录响应结构
type LoginResponse struct {
Success bool `json:"success"` // 是否成功
Token string `json:"token"` // 认证令牌
Message string `json:"message"` // 提示信息
}
// SignatureItem 签名文件信息结构
type SignatureItem struct {
Name string `json:"name"` // 签署人姓名
Path string `json:"path"` // 文件路径
Date string `json:"date"` // 签名日期
Size int64 `json:"size"` // 文件大小(字节)
}
// ============================================================================
// 全局变量
// ============================================================================
// 存储账号密码信息
var accountConfig struct {
Account string `json:"account"` // 账号
Password string `json:"password"` // 密码
}
// 存储有效的认证令牌,格式:令牌 -> 账号
var validTokens = make(map[string]string)
// Token 过期时间(24小时)
const tokenExpireDuration = 24 * time.Hour
// 存储令牌的创建时间
var tokenCreateTimes = make(map[string]time.Time)
// uploads 目录的绝对路径
var uploadsDir string
// ============================================================================
// 主函数
// ============================================================================
func main() {
// 2. 修改路由处理:直接从内存(嵌入的文件系统)中读取页面,而不是从磁盘读取
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 设置正确的 Content-Type
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// 获取可执行文件所在目录
execPath, err := os.Executable()
if err != nil {
log.Printf("获取可执行文件路径失败: %v,使用当前目录", err)
execPath = filepath.Join(".", "placeholder")
}
execDir := filepath.Dir(execPath)
// 从嵌入的文件系统中读取 index.html
// 初始化 uploads 目录的绝对路径
uploadsDir = filepath.Join(execDir, "uploads")
// 加载账号密码配置文件
loadAccountConfig(execDir)
// 注册路由
setupRoutes()
// 启动服务器
port := ":10081"
fmt.Println("========================================")
fmt.Println(" 电子签名归档系统 v2.0")
fmt.Println("========================================")
fmt.Printf("监听端口: %s\n", port)
fmt.Printf("签名存储: %s\n", uploadsDir)
fmt.Printf("访问地址: http://localhost%s\n", port)
fmt.Println("========================================")
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatal("服务器启动失败:", err)
}
}
// ============================================================================
// 路由设置
// ============================================================================
// setupRoutes 注册所有 HTTP 路由
func setupRoutes() {
// 主页面 - 从嵌入的文件系统提供前端页面
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data, err := content.ReadFile("index.html")
if err != nil {
http.Error(w, "系统错误: 页面文件丢失", 500)
@@ -46,79 +136,358 @@ func main() {
w.Write(data)
})
// 设置上传接口
// 签名上传接口(无需认证)
http.HandleFunc("/upload", uploadHandler)
// 启动服务器
port := ":10081"
fmt.Printf("Linux 服务启动中...\n")
fmt.Printf("监听端口: %s\n", port)
fmt.Println("文件保存路径: ./uploads")
// 登录接口
http.HandleFunc("/login", loginHandler)
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatal("服务器启动失败:", err)
// 验证登录状态接口
http.HandleFunc("/api/check-auth", checkAuthHandler)
// 获取签名列表接口(需要认证)
http.HandleFunc("/api/signatures", signaturesHandler)
// 下载签名文件接口(需要认证)
http.HandleFunc("/download", downloadHandler)
}
// ============================================================================
// 认证相关函数
// ============================================================================
// loadAccountConfig 从 pwd.json 加载账号密码配置
// 优先从可执行文件目录加载,如果找不到则从当前工作目录加载
func loadAccountConfig(execDir string) {
// 尝试从可执行文件目录加载
configPath := filepath.Join(execDir, "pwd.json")
if err := loadConfigFromFile(configPath); err != nil {
// 如果失败,尝试从当前工作目录加载
configPath = filepath.Join(".", "pwd.json")
if err := loadConfigFromFile(configPath); err != nil {
log.Printf("警告: 未找到 pwd.json 配置文件,登录功能将不可用")
return
}
}
log.Printf("已加载账号配置: %s", configPath)
}
// loadConfigFromFile 从指定路径加载配置文件
func loadConfigFromFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, &accountConfig)
}
// generateToken 生成认证令牌
// 格式:base64(账号:时间戳),简单但足够用于本地场景
func generateToken(account string) string {
// 组合账号和时间戳
raw := fmt.Sprintf("%s:%d", account, time.Now().UnixNano())
// Base64 编码
token := base64.StdEncoding.EncodeToString([]byte(raw))
// 记录令牌创建时间
validTokens[token] = account
tokenCreateTimes[token] = time.Now()
return token
}
// validateToken 验证令牌是否有效
// 返回:是否有效、对应的账号
func validateToken(token string) (bool, string) {
if token == "" {
return false, ""
}
// 检查令牌是否存在
account, exists := validTokens[token]
if !exists {
return false, ""
}
// 检查令牌是否过期
createTime, ok := tokenCreateTimes[token]
if !ok || time.Since(createTime) > tokenExpireDuration {
// 令牌已过期,删除它
delete(validTokens, token)
delete(tokenCreateTimes, token)
return false, ""
}
return true, account
}
// cleanExpiredTokens 清理过期的令牌
func cleanExpiredTokens() {
now := time.Now()
for token, createTime := range tokenCreateTimes {
if now.Sub(createTime) > tokenExpireDuration {
delete(validTokens, token)
delete(tokenCreateTimes, token)
}
}
}
// getAuthTokenFromRequest 从请求中提取认证令牌
// 支持从 Authorization 头或查询参数获取
func getAuthTokenFromRequest(r *http.Request) string {
// 首先尝试从 Authorization 头获取
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
// 然后尝试从查询参数获取
return r.URL.Query().Get("token")
}
// ============================================================================
// 路由处理函数
// ============================================================================
// loginHandler 处理登录请求
// POST /login - 验证账号密码,返回认证令牌
func loginHandler(w http.ResponseWriter, r *http.Request) {
// 只允许 POST 方法
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 检查是否配置了账号密码
if accountConfig.Account == "" {
sendJSONResponse(w, false, "系统未配置登录账号", "")
return
}
// 解析请求体
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSONResponse(w, false, "无效的请求数据", "")
return
}
// 去除前后空格
req.Account = strings.TrimSpace(req.Account)
req.Password = strings.TrimSpace(req.Password)
// 验证账号密码
if req.Account == "" || req.Password == "" {
sendJSONResponse(w, false, "账号和密码不能为空", "")
return
}
// 对比账号密码
if req.Account != accountConfig.Account || req.Password != accountConfig.Password {
log.Printf("登录失败: 账号 %s 密码错误", req.Account)
sendJSONResponse(w, false, "账号或密码错误", "")
return
}
// 登录成功,生成令牌
token := generateToken(req.Account)
log.Printf("登录成功: 账号 %s", req.Account)
// 返回成功响应(使用 LoginResponse 结构)
w.Header().Set("Content-Type", "application/json")
resp := LoginResponse{
Success: true,
Token: token,
Message: "登录成功",
}
json.NewEncoder(w).Encode(resp)
}
// checkAuthHandler 检查认证状态
// GET /api/check-auth - 验证令牌是否有效
func checkAuthHandler(w http.ResponseWriter, r *http.Request) {
token := getAuthTokenFromRequest(r)
valid, account := validateToken(token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"authenticated": valid,
"account": account,
})
}
// uploadHandler 处理签名上传
// POST /upload - 接收 Base64 编码的签名图片并保存
func uploadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 解析请求体
var req UploadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, false, "无效的请求数据", "")
sendJSONResponse(w, false, "无效的请求数据", "")
return
}
// 验证姓名不为空
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
jsonResponse(w, false, "名字不能为空", "")
return
}
if req.Image == "" {
jsonResponse(w, false, "签名数据为空", "")
sendJSONResponse(w, false, "名字不能为空", "")
return
}
// 验证签名数据不为空
if req.Image == "" {
sendJSONResponse(w, false, "签名数据为空", "")
return
}
// 去除 Base64 前缀(data:image/png;base64,)
b64data := req.Image
if idx := strings.Index(b64data, ","); idx != -1 {
b64data = b64data[idx+1:]
}
// 解码 Base64 数据
imgBytes, err := base64.StdEncoding.DecodeString(b64data)
if err != nil {
jsonResponse(w, false, "图片数据解码失败", "")
sendJSONResponse(w, false, "图片数据解码失败", "")
return
}
// 使用当前服务器时间
// 生成安全的文件名
dateStr := time.Now().Format("2006-1-2")
safeName := filepath.Base(req.Name)
// Linux下路径分隔符自动处理
// 创建保存目录
saveDir := filepath.Join("uploads", safeName)
if err := os.MkdirAll(saveDir, 0755); err != nil {
log.Printf("创建目录失败: %v", err)
jsonResponse(w, false, "服务器存储错误", "")
sendJSONResponse(w, false, "服务器存储错误", "")
return
}
// 生成完整的文件路径
fileName := fmt.Sprintf("%s+%s.png", safeName, dateStr)
fullPath := filepath.Join(saveDir, fileName)
// 写入文件
if err := os.WriteFile(fullPath, imgBytes, 0644); err != nil {
log.Printf("写入文件失败: %v", err)
jsonResponse(w, false, "保存文件失败", "")
sendJSONResponse(w, false, "保存文件失败", "")
return
}
log.Printf("已保存签名: %s", fullPath) // 记录日志方便Linux查看
jsonResponse(w, true, "签名保存成功!", fullPath)
log.Printf("已保存签名: %s", fullPath)
sendJSONResponse(w, true, "签名保存成功!", fullPath)
}
func jsonResponse(w http.ResponseWriter, success bool, message string, path string) {
// signaturesHandler 获取签名列表
// GET /api/signatures - 返回所有签名文件的信息(需要认证)
func signaturesHandler(w http.ResponseWriter, r *http.Request) {
// 验证认证
token := getAuthTokenFromRequest(r)
valid, _ := validateToken(token)
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "未登录或登录已过期",
})
return
}
// 收集所有签名文件
signatures := collectSignatureFiles()
// 返回结果
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"signatures": signatures,
})
}
// downloadHandler 下载签名文件
// GET /download?path=xxx - 下载指定的签名图片(需要认证)
func downloadHandler(w http.ResponseWriter, r *http.Request) {
// 验证认证
token := getAuthTokenFromRequest(r)
valid, _ := validateToken(token)
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "未登录或登录已过期",
})
return
}
// 获取文件路径参数
filePath := r.URL.Query().Get("path")
if filePath == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "缺少文件路径参数",
})
return
}
// 安全检查:确保路径在 uploads 目录内,防止路径穿越攻击
absPath, err := filepath.Abs(filePath)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "无效的文件路径",
})
return
}
// 获取 uploads 目录的绝对路径
uploadsAbs, _ := filepath.Abs("uploads")
if !strings.HasPrefix(absPath, uploadsAbs) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "无权访问该文件",
})
return
}
// 检查文件是否存在
if _, err := os.Stat(absPath); os.IsNotExist(err) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "文件不存在",
})
return
}
// 设置响应头,触发浏览器下载
fileName := filepath.Base(absPath)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
w.Header().Set("Content-Type", "image/png")
// 提供文件
http.ServeFile(w, r, absPath)
}
// ============================================================================
// 辅助函数
// ============================================================================
// sendJSONResponse 发送 JSON 格式的响应(上传接口专用)
func sendJSONResponse(w http.ResponseWriter, success bool, message string, path string) {
w.Header().Set("Content-Type", "application/json")
resp := UploadResponse{
Success: success,
@@ -127,3 +496,64 @@ func jsonResponse(w http.ResponseWriter, success bool, message string, path stri
}
json.NewEncoder(w).Encode(resp)
}
// collectSignatureFiles 收集 uploads 目录下所有签名文件
// 返回按修改时间倒序排列的签名文件列表
func collectSignatureFiles() []SignatureItem {
var signatures []SignatureItem
// 检查 uploads 目录是否存在
if _, err := os.Stat("uploads"); os.IsNotExist(err) {
return signatures
}
// 遍历 uploads 目录下的所有子目录(每个子目录对应一个签署人)
err := filepath.Walk("uploads", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return nil // 跳过错误的文件
}
// 只处理 .png 文件
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".png") {
// 从文件路径中提取签署人姓名(目录名)
relPath, _ := filepath.Rel("uploads", path)
parts := strings.SplitN(relPath, string(filepath.Separator), 2)
name := ""
if len(parts) > 0 {
name = parts[0]
}
// 从文件名中提取日期
// 文件名格式:<姓名>+<日期>.png
baseName := strings.TrimSuffix(info.Name(), ".png")
datePart := ""
if idx := strings.LastIndex(baseName, "+"); idx != -1 {
datePart = baseName[idx+1:]
}
signatures = append(signatures, SignatureItem{
Name: name,
Path: path,
Date: datePart,
Size: info.Size(),
})
}
return nil
})
if err != nil {
log.Printf("遍历签名目录失败: %v", err)
}
// 按修改时间倒序排列
sort.Slice(signatures, func(i, j int) bool {
fileInfoI, _ := os.Stat(signatures[i].Path)
fileInfoJ, _ := os.Stat(signatures[j].Path)
if fileInfoI == nil || fileInfoJ == nil {
return false
}
return fileInfoI.ModTime().After(fileInfoJ.ModTime())
})
return signatures
}

4
pwd.json Normal file
View File

@@ -0,0 +1,4 @@
{
"account": "liqi",
"password": "qiqi991012"
}