更新列表
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/.opencode/
|
||||
/signature-server
|
||||
/uploads/
|
||||
|
||||
120
.mimocode/plans/1782182188576-happy-harbor.md
Normal file
120
.mimocode/plans/1782182188576-happy-harbor.md
Normal 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
31
AGENTS.md
Normal 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`
|
||||
935
index.html
935
index.html
File diff suppressed because it is too large
Load Diff
502
main.go
502
main.go
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user