162 lines
4.8 KiB
Go
162 lines
4.8 KiB
Go
package api
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"log"
|
||
"mime"
|
||
"net/http"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"freight-sfa-server/internal/auth"
|
||
)
|
||
|
||
// Server API 服务
|
||
type Server struct {
|
||
db *sql.DB
|
||
mux *http.ServeMux
|
||
}
|
||
|
||
// New 组装路由与中间件,返回 http.Handler
|
||
func New(db *sql.DB, staticDir string) http.Handler {
|
||
s := &Server{db: db, mux: http.NewServeMux()}
|
||
s.routes(staticDir)
|
||
return s.mux
|
||
}
|
||
|
||
func (s *Server) routes(staticDir string) {
|
||
// ---- 认证(公开) ----
|
||
s.mux.HandleFunc("POST /api/auth/register", s.register)
|
||
s.mux.HandleFunc("POST /api/auth/login", s.login)
|
||
|
||
// ---- 认证(JWT 保护) ----
|
||
s.mux.Handle("GET /api/auth/me", auth.Middleware(http.HandlerFunc(s.me)))
|
||
|
||
// ---- 通用业务记录(client/inquiry/booking/followup) ----
|
||
s.mux.Handle("GET /api/records/{kind}", auth.Middleware(http.HandlerFunc(s.listRecords)))
|
||
s.mux.Handle("POST /api/records/{kind}", auth.Middleware(http.HandlerFunc(s.createRecord)))
|
||
s.mux.Handle("GET /api/records/{kind}/{id}", auth.Middleware(http.HandlerFunc(s.getRecord)))
|
||
s.mux.Handle("PUT /api/records/{kind}/{id}", auth.Middleware(http.HandlerFunc(s.updateRecord)))
|
||
s.mux.Handle("DELETE /api/records/{kind}/{id}", auth.Middleware(http.HandlerFunc(s.deleteRecord)))
|
||
|
||
// ---- 选项字典 ----
|
||
s.mux.Handle("GET /api/options", auth.Middleware(http.HandlerFunc(s.getOptions)))
|
||
s.mux.Handle("PUT /api/options", auth.Middleware(http.HandlerFunc(s.updateOptions)))
|
||
|
||
// ---- 看板聚合 ----
|
||
s.mux.Handle("GET /api/dashboard", auth.Middleware(http.HandlerFunc(s.dashboard)))
|
||
|
||
// ---- 备份/恢复 ----
|
||
s.mux.Handle("GET /api/backup", auth.Middleware(http.HandlerFunc(s.exportBackup)))
|
||
s.mux.Handle("POST /api/backup", auth.Middleware(http.HandlerFunc(s.importBackup)))
|
||
|
||
// ---- 审计日志 ----
|
||
s.mux.Handle("GET /api/audit-logs", auth.Middleware(http.HandlerFunc(s.listAuditLogs)))
|
||
|
||
// ---- 静态资源(前端) ----
|
||
mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
||
mime.AddExtensionType(".js", "text/javascript")
|
||
|
||
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path == "/" {
|
||
http.Redirect(w, r, "/index.html", http.StatusFound)
|
||
return
|
||
}
|
||
serveStatic(w, r, staticDir)
|
||
})
|
||
}
|
||
|
||
// serveStatic 提供 html 目录下的静态文件(含路径遍历防护)
|
||
func serveStatic(w http.ResponseWriter, r *http.Request, staticDir string) {
|
||
rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||
full := filepath.Join(staticDir, filepath.FromSlash(rel))
|
||
|
||
base, err := filepath.Abs(staticDir)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
abs, err := filepath.Abs(full)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
// 规范化后必须仍在 staticDir 内,防目录遍历
|
||
if abs != base && !strings.HasPrefix(abs, base+string(filepath.Separator)) {
|
||
http.Error(w, "forbidden", http.StatusForbidden)
|
||
return
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil || st.IsDir() {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
f, err := os.Open(abs)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
if ctype := mime.TypeByExtension(strings.ToLower(filepath.Ext(abs))); ctype != "" {
|
||
w.Header().Set("Content-Type", ctype)
|
||
}
|
||
http.ServeContent(w, r, filepath.Base(abs), st.ModTime(), f)
|
||
}
|
||
|
||
// ---- 通用工具 ----
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(code)
|
||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||
log.Printf("JSON 序列化失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||
writeJSON(w, code, map[string]string{"error": msg})
|
||
}
|
||
|
||
func decodeJSON(r *http.Request, v any) error {
|
||
dec := json.NewDecoder(r.Body)
|
||
dec.DisallowUnknownFields()
|
||
return dec.Decode(v)
|
||
}
|
||
|
||
func nowStr() string { return time.Now().Format("2006-01-02 15:04") }
|
||
func todayStr() string { return time.Now().Format("2006-01-02") }
|
||
|
||
// genID 生成与前端 uid() 兼容的 ID(前缀 + 时间戳36进制 + 随机数)
|
||
func genID(p string) string {
|
||
var sb strings.Builder
|
||
sb.WriteString(p)
|
||
sb.WriteString("_")
|
||
sb.WriteString(strconv.FormatInt(time.Now().UnixMilli(), 36))
|
||
b := make([]byte, 4)
|
||
if _, err := rand.Read(b); err == nil {
|
||
sb.WriteString(hex.EncodeToString(b))
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
func pathID(r *http.Request, key string) string {
|
||
return strings.TrimSpace(r.PathValue(key))
|
||
}
|
||
|
||
// auditLog 记录审计日志
|
||
func (s *Server) auditLog(r *http.Request, action, entity, entityID, detail string) {
|
||
_, err := s.db.Exec(`INSERT INTO audit_logs (user_id, username, action, entity, entity_id, detail, created_at)
|
||
VALUES (?,?,?,?,?,?,?)`,
|
||
auth.UserID(r), auth.Username(r), action, entity, entityID, detail, nowStr())
|
||
if err != nil {
|
||
log.Printf("写入审计日志失败: %v", err)
|
||
}
|
||
}
|