commit 7049d7b1a54d5afb2f3ecf5ec5613789aacbe8ff Author: 李琦 Date: Fri Jun 26 14:31:24 2026 +0800 初始化 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/excel-api.iml b/.idea/excel-api.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/excel-api.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9712a88 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..b188864 --- /dev/null +++ b/config.yaml @@ -0,0 +1,42 @@ +# 应用配置文件 +# 本文件用于配置数据库连接、文件存储、服务器端口等 + +# 服务器配置 +server: + port: 16702 # 后端API服务端口,与前端Vite代理配置保持一致 + ws_port: 16703 # WebSocket服务端口(独立端口,用于在线协作) + +# 数据库配置(MySQL 8+) +database: + host: "127.0.0.1" # 数据库主机地址 + port: 3306 # 数据库端口 + username: "root" # 数据库用户名 + password: "123456" # 数据库密码 + dbname: "excel_t" # 数据库名称 + charset: "utf8mb4" # 字符集 + +# 文件存储配置 +storage: + # 存储类型:local(本地磁盘)/ qiniu(七牛云)/ tencent(腾讯云)/ aliyun(阿里云) + type: "local" + # 本地存储配置 + local: + path: "./uploads" # 本地文件存储目录 + # 七牛云配置(storage.type 为 qiniu 时生效) + qiniu: + access_key: "" + secret_key: "" + bucket: "" + domain: "" + # 腾讯云配置(storage.type 为 tencent 时生效) + tencent: + secret_id: "" + secret_key: "" + bucket: "" + region: "" + # 阿里云配置(storage.type 为 aliyun 时生效) + aliyun: + access_key_id: "" + access_key_secret: "" + bucket: "" + endpoint: "" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..21faa0f --- /dev/null +++ b/config/config.go @@ -0,0 +1,93 @@ +// Package config 负责加载和管理应用程序配置 +// 从 config.yaml 文件中读取配置,并提供全局访问接口 +package config + +import ( + "os" + + "gopkg.in/yaml.v2" +) + +// Config 应用程序全局配置结构体 +type Config struct { + Server ServerConfig `yaml:"server"` // 服务器配置 + Database DatabaseConfig `yaml:"database"` // 数据库配置 + Storage StorageConfig `yaml:"storage"` // 文件存储配置 +} + +// ServerConfig 服务器配置 +type ServerConfig struct { + Port int `yaml:"port"` // API服务监听端口 + WsPort int `yaml:"ws_port"` // WebSocket服务监听端口 +} + +// DatabaseConfig 数据库连接配置 +type DatabaseConfig struct { + Host string `yaml:"host"` // 数据库主机地址 + Port int `yaml:"port"` // 数据库端口 + Username string `yaml:"username"` // 数据库用户名 + Password string `yaml:"password"` // 数据库密码 + DBName string `yaml:"dbname"` // 数据库名称 + Charset string `yaml:"charset"` // 字符集 +} + +// StorageConfig 文件存储配置 +type StorageConfig struct { + Type string `yaml:"type"` // 存储类型:local/qiniu/tencent/aliyun + Local LocalConfig `yaml:"local"` // 本地存储配置 + Qiniu CloudConfig `yaml:"qiniu"` // 七牛云配置 + Tencent CloudConfig `yaml:"tencent"` // 腾讯云配置 + Aliyun CloudConfig `yaml:"aliyun"` // 阿里云配置 +} + +// LocalConfig 本地存储配置 +type LocalConfig struct { + Path string `yaml:"path"` // 本地文件存储目录 +} + +// CloudConfig 云存储通用配置 +type CloudConfig struct { + AccessKey string `yaml:"access_key"` // 访问密钥ID + SecretKey string `yaml:"secret_key"` // 访问密钥Secret + Bucket string `yaml:"bucket"` // 存储桶名称 + Domain string `yaml:"domain"` // 域名(七牛云) + Region string `yaml:"region"` // 区域(腾讯云) + SecretID string `yaml:"secret_id"` // SecretID(腾讯云) + AccessKeyID string `yaml:"access_key_id"` // AccessKeyID(阿里云) + AccessKeySecret string `yaml:"access_key_secret"` // AccessKeySecret(阿里云) + Endpoint string `yaml:"endpoint"` // Endpoint(阿里云) +} + +// App 全局配置实例 +var App *Config + +// Init 从 config.yaml 文件加载配置到全局变量 App +// 如果文件不存在或读取失败,返回错误 +func Init() error { + App = &Config{} + data, err := os.ReadFile("config.yaml") + if err != nil { + return err + } + err = yaml.Unmarshal(data, App) + if err != nil { + return err + } + // 设置默认值 + if App.Server.Port == 0 { + App.Server.Port = 16702 + } + if App.Server.WsPort == 0 { + App.Server.WsPort = 16703 + } + if App.Database.Charset == "" { + App.Database.Charset = "utf8mb4" + } + if App.Storage.Type == "" { + App.Storage.Type = "local" + } + if App.Storage.Local.Path == "" { + App.Storage.Local.Path = "./uploads" + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9d6e118 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module excel-api + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/gorilla/websocket v1.5.3 + gopkg.in/yaml.v2 v2.4.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6676352 --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handlers/cell.go b/handlers/cell.go new file mode 100644 index 0000000..8e6f7eb --- /dev/null +++ b/handlers/cell.go @@ -0,0 +1,153 @@ +// Package handlers 实现单元格相关的API请求处理器 +package handlers + +import ( + "excel-api/models" + "excel-api/utils" + + "github.com/gin-gonic/gin" +) + +// CellRequest 单元格更新请求结构体 +type CellRequest struct { + Row int `json:"row" binding:"required"` // 行号(0-based),必填 + Col int `json:"col" binding:"required"` // 列号(0-based),必填 + CellType string `json:"cellType"` // 单元格类型 + Value string `json:"value"` // 文本值 + Formula string `json:"formula"` // 公式表达式 + FileURL string `json:"fileUrl"` // 文件URL + FileName string `json:"fileName"` // 文件名 + FileSize int64 `json:"fileSize"` // 文件大小 + MimeType string `json:"mimeType"` // MIME类型 +} + +// GetCells 获取工作表的单元格数据 +// 请求方式: GET +// 路径: /api/sheets/:id/cells +// 路径参数: id - 工作表ID +// 查询参数: row(起始行), col(起始列), endRow(结束行), endCol(结束列) +func GetCells(c *gin.Context) { + id := c.Param("id") + + // 验证工作表是否存在 + var sheet models.Sheet + if result := models.DB.First(&sheet, id); result.Error != nil { + utils.Error(c, 404, "工作表不存在") + return + } + + // 获取查询参数(可选范围查询) + query := models.DB.Where("sheet_id = ?", id) + + // 如果提供了范围参数,则按范围过滤 + if row := c.Query("row"); row != "" { + query = query.Where("row >= ?", row) + } + if col := c.Query("col"); col != "" { + query = query.Where("col >= ?", col) + } + if endRow := c.Query("endRow"); endRow != "" { + query = query.Where("row <= ?", endRow) + } + if endCol := c.Query("endCol"); endCol != "" { + query = query.Where("col <= ?", endCol) + } + + var cells []models.Cell + result := query.Find(&cells) + if result.Error != nil { + utils.Error(c, 500, "查询单元格数据失败") + return + } + + utils.Success(c, cells) +} + +// BatchUpdateCells 批量更新单元格数据 +// 请求方式: POST +// 路径: /api/sheets/:id/cells +// 路径参数: id - 工作表ID +// 请求体: { "cells": [{ "row": 0, "col": 0, "value": "Hello" }, ...] } +func BatchUpdateCells(c *gin.Context) { + id := c.Param("id") + + // 验证工作表是否存在 + var sheet models.Sheet + if result := models.DB.First(&sheet, id); result.Error != nil { + utils.Error(c, 404, "工作表不存在") + return + } + + var req struct { + Cells []CellRequest `json:"cells" binding:"required"` // 单元格更新列表,必填 + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Error(c, 400, "参数错误: "+err.Error()) + return + } + + // 使用事务批量更新单元格 + tx := models.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + for _, cellReq := range req.Cells { + // 查找已存在的单元格 + var cell models.Cell + result := tx.Where("sheet_id = ? AND row = ? AND col = ?", id, cellReq.Row, cellReq.Col).First(&cell) + + if result.Error != nil { + // 单元格不存在,创建新记录 + cell = models.Cell{ + SheetID: sheet.ID, + Row: cellReq.Row, + Col: cellReq.Col, + CellType: cellReq.CellType, + Value: cellReq.Value, + Formula: cellReq.Formula, + FileURL: cellReq.FileURL, + FileName: cellReq.FileName, + FileSize: cellReq.FileSize, + MimeType: cellReq.MimeType, + } + // 设置默认类型 + if cell.CellType == "" { + cell.CellType = "text" + } + if err := tx.Create(&cell).Error; err != nil { + tx.Rollback() + utils.Error(c, 500, "创建单元格失败") + return + } + } else { + // 单元格已存在,更新字段 + if cellReq.CellType != "" { + cell.CellType = cellReq.CellType + } + cell.Value = cellReq.Value + cell.Formula = cellReq.Formula + cell.FileURL = cellReq.FileURL + cell.FileName = cellReq.FileName + cell.FileSize = cellReq.FileSize + cell.MimeType = cellReq.MimeType + + if err := tx.Save(&cell).Error; err != nil { + tx.Rollback() + utils.Error(c, 500, "更新单元格失败") + return + } + } + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + utils.Error(c, 500, "提交事务失败") + return + } + + utils.SuccessWithMessage(c, "批量更新单元格成功", nil) +} diff --git a/handlers/sheet.go b/handlers/sheet.go new file mode 100644 index 0000000..c7cbb0f --- /dev/null +++ b/handlers/sheet.go @@ -0,0 +1,174 @@ +// Package handlers 实现工作表相关的API请求处理器 +package handlers + +import ( + "excel-api/models" + "excel-api/utils" + "strconv" + + "github.com/gin-gonic/gin" +) + +// GetSheets 获取工作簿下的工作表列表 +// 请求方式: GET +// 路径: /api/workbooks/:wid/sheets +// 路径参数: wid - 工作簿ID +func GetSheets(c *gin.Context) { + wid := c.Param("wid") + + // 验证工作簿是否存在 + var workbook models.Workbook + if result := models.DB.First(&workbook, wid); result.Error != nil { + utils.Error(c, 404, "工作簿不存在") + return + } + + var sheets []models.Sheet + // 查询该工作簿下的所有工作表,按索引排序 + result := models.DB.Where("workbook_id = ?", wid).Order("index ASC").Find(&sheets) + if result.Error != nil { + utils.Error(c, 500, "查询工作表列表失败") + return + } + + utils.Success(c, sheets) +} + +// CreateSheet 在工作簿下创建新工作表 +// 请求方式: POST +// 路径: /api/workbooks/:wid/sheets +// 路径参数: wid - 工作簿ID +// 请求体: { "name": "工作表名称" } +func CreateSheet(c *gin.Context) { + wid := c.Param("wid") + + // 验证工作簿是否存在 + var workbook models.Workbook + if result := models.DB.First(&workbook, wid); result.Error != nil { + utils.Error(c, 404, "工作簿不存在") + return + } + + var req struct { + Name string `json:"name" binding:"required"` // 工作表名称,必填 + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Error(c, 400, "参数错误: "+err.Error()) + return + } + + // 获取当前工作簿下最大的排序索引 + var maxIndex int + models.DB.Model(&models.Sheet{}).Where("workbook_id = ?", wid).Select("COALESCE(MAX(index), -1)").Scan(&maxIndex) + + widNum, _ := strconv.Atoi(wid) + sheet := models.Sheet{ + WorkbookID: uint(widNum), + Name: req.Name, + Index: maxIndex + 1, + ColCount: 26, + RowCount: 100, + } + + result := models.DB.Create(&sheet) + if result.Error != nil { + utils.Error(c, 500, "创建工作表失败") + return + } + + utils.SuccessWithMessage(c, "创建工作表成功", sheet) +} + +// GetSheet 获取工作表详情 +// 请求方式: GET +// 路径: /api/sheets/:id +// 路径参数: id - 工作表ID +func GetSheet(c *gin.Context) { + id := c.Param("id") + var sheet models.Sheet + + result := models.DB.First(&sheet, id) + if result.Error != nil { + utils.Error(c, 404, "工作表不存在") + return + } + + utils.Success(c, sheet) +} + +// UpdateSheet 更新工作表信息 +// 请求方式: PUT +// 路径: /api/sheets/:id +// 路径参数: id - 工作表ID +// 请求体: { "name": "新名称", "index": 0, "colCount": 26, "rowCount": 100 } +func UpdateSheet(c *gin.Context) { + id := c.Param("id") + var sheet models.Sheet + + result := models.DB.First(&sheet, id) + if result.Error != nil { + utils.Error(c, 404, "工作表不存在") + return + } + + var req struct { + Name string `json:"name"` // 工作表名称 + Index int `json:"index"` // 排序索引 + ColCount int `json:"colCount"` // 列数量 + RowCount int `json:"rowCount"` // 行数量 + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Error(c, 400, "参数错误: "+err.Error()) + return + } + + if req.Name != "" { + sheet.Name = req.Name + } + if req.Index >= 0 { + sheet.Index = req.Index + } + if req.ColCount > 0 { + sheet.ColCount = req.ColCount + } + if req.RowCount > 0 { + sheet.RowCount = req.RowCount + } + + result = models.DB.Save(&sheet) + if result.Error != nil { + utils.Error(c, 500, "更新工作表失败") + return + } + + utils.SuccessWithMessage(c, "更新工作表成功", sheet) +} + +// DeleteSheet 删除工作表(软删除) +// 请求方式: DELETE +// 路径: /api/sheets/:id +// 路径参数: id - 工作表ID +func DeleteSheet(c *gin.Context) { + id := c.Param("id") + var sheet models.Sheet + + result := models.DB.First(&sheet, id) + if result.Error != nil { + utils.Error(c, 404, "工作表不存在") + return + } + + // 软删除工作表及其关联的单元格 + result = models.DB.Delete(&sheet) + if result.Error != nil { + utils.Error(c, 500, "删除工作表失败") + return + } + + // 同时删除关联的单元格 + models.DB.Where("sheet_id = ?", id).Delete(&models.Cell{}) + + utils.SuccessWithMessage(c, "删除工作表成功", nil) +} diff --git a/handlers/upload.go b/handlers/upload.go new file mode 100644 index 0000000..ad16004 --- /dev/null +++ b/handlers/upload.go @@ -0,0 +1,130 @@ +// Package handlers 实现文件上传相关的API请求处理器 +// 支持图片、视频、音频、文档等文件上传 +package handlers + +import ( + "excel-api/config" + "excel-api/utils" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// allowedExtensions 允许上传的文件扩展名白名单 +var allowedExtensions = map[string]bool{ + // 图片格式 + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".svg": true, + // 视频格式 + ".mp4": true, ".webm": true, ".mov": true, + // 音频格式 + ".mp3": true, ".wav": true, ".ogg": true, ".flac": true, + // 压缩包格式 + ".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, + // 文档格式 + ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, + ".ppt": true, ".pptx": true, ".txt": true, ".csv": true, +} + +// DetectFileType 根据文件扩展名自动检测文件类型 +// 返回值: image/video/audio/archive/file +func DetectFileType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": + return "image" + case ".mp4", ".webm", ".mov": + return "video" + case ".mp3", ".wav", ".ogg", ".flac": + return "audio" + case ".zip", ".rar", ".7z", ".tar", ".gz": + return "archive" + default: + return "file" + } +} + +// UploadResponse 文件上传响应结构体 +type UploadResponse struct { + URL string `json:"url"` // 文件访问URL + Name string `json:"name"` // 原始文件名 + Size int64 `json:"size"` // 文件大小(字节) + MimeType string `json:"mimeType"` // 文件MIME类型 + FileType string `json:"fileType"` // 自动检测的文件类型 +} + +// Upload 处理文件上传请求 +// 请求方式: POST +// 路径: /api/upload +// 请求体: multipart/form-data,字段名为 "file" +func Upload(c *gin.Context) { + // 获取上传的文件 + file, err := c.FormFile("file") + if err != nil { + utils.Error(c, 400, "请选择要上传的文件") + return + } + + // 验证文件扩展名 + ext := strings.ToLower(filepath.Ext(file.Filename)) + if !allowedExtensions[ext] { + utils.Error(c, 400, "不支持的文件格式: "+ext) + return + } + + // 限制文件大小(最大 100MB) + const maxFileSize = 100 * 1024 * 1024 + if file.Size > maxFileSize { + utils.Error(c, 400, "文件大小超过限制(最大100MB)") + return + } + + // 确定存储路径 + storagePath := config.App.Storage.Local.Path + if storagePath == "" { + storagePath = "./uploads" + } + + // 按日期创建子目录:uploads/2024/01/ + dateDir := time.Now().Format("2006/01") + uploadDir := filepath.Join(storagePath, dateDir) + + // 创建存储目录(如果不存在) + if err := os.MkdirAll(uploadDir, 0755); err != nil { + utils.Error(c, 500, "创建上传目录失败") + return + } + + // 生成唯一文件名:时间戳 + 随机数 + 原始扩展名 + filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + + // 保存文件到磁盘 + if err := c.SaveUploadedFile(file, filePath); err != nil { + utils.Error(c, 500, "保存文件失败") + return + } + + // 检测文件MIME类型 + mimeType := file.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "application/octet-stream" + } + + // 自动检测文件类型 + fileType := DetectFileType(file.Filename) + + // 返回文件访问URL(相对路径) + url := fmt.Sprintf("/uploads/%s/%s", dateDir, filename) + + utils.Success(c, UploadResponse{ + URL: url, + Name: file.Filename, + Size: file.Size, + MimeType: mimeType, + FileType: fileType, + }) +} diff --git a/handlers/workbook.go b/handlers/workbook.go new file mode 100644 index 0000000..decc4ba --- /dev/null +++ b/handlers/workbook.go @@ -0,0 +1,205 @@ +// Package handlers 实现API请求处理器 +// 包含工作簿(Excel文档)的增删改查操作 +// 工作簿通过 documents 表存储元数据,workbooks 表存储Excel特化数据 +package handlers + +import ( + "excel-api/models" + "excel-api/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// GetWorkbooks 获取工作簿列表 +// 支持分页查询,通过JOIN关联文档表获取完整信息 +// 请求方式: GET +// 路径: /api/workbooks +// 查询参数: page(页码,默认1), pageSize(每页条数,默认20) +func GetWorkbooks(c *gin.Context) { + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + var workbooks []models.Workbook + var total int64 + + // 查询总数(关联文档表) + models.DB.Model(&models.Workbook{}).Joins("JOIN documents ON documents.id = workbooks.document_id").Count(&total) + // 分页查询工作簿列表,预加载关联的工作表 + result := models.DB. + Joins("JOIN documents ON documents.id = workbooks.document_id"). + Preload("Sheets"). + Offset((page - 1) * pageSize).Limit(pageSize). + Order("documents.created_at DESC"). + Find(&workbooks) + if result.Error != nil { + utils.Error(c, 500, "查询工作簿列表失败") + return + } + + utils.SuccessPage(c, workbooks, total, page, pageSize) +} + +// CreateWorkbook 创建新工作簿 +// 同时创建对应的文档记录和默认工作表 +// 请求方式: POST +// 路径: /api/workbooks +// 请求体: { "title": "文档标题", "description": "描述" } +func CreateWorkbook(c *gin.Context) { + var req struct { + Title string `json:"title" binding:"required"` // 文档标题,必填 + Description string `json:"description"` // 文档描述,可选 + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Error(c, 400, "参数错误: "+err.Error()) + return + } + + // 创建文档记录(excel类型) + document := models.Document{ + Type: "excel", + Title: req.Title, + Description: req.Description, + CreatorID: 1, // 前期默认用户ID + Status: 1, + } + result := models.DB.Create(&document) + if result.Error != nil { + utils.Error(c, 500, "创建文档失败") + return + } + + // 创建工作簿记录 + workbook := models.Workbook{ + DocumentID: document.ID, + } + result = models.DB.Create(&workbook) + if result.Error != nil { + utils.Error(c, 500, "创建工作簿失败") + return + } + + // 为新工作簿创建默认工作表 + defaultSheet := models.Sheet{ + WorkbookID: workbook.ID, + Name: "Sheet1", + Index: 0, + ColCount: 26, + RowCount: 100, + } + models.DB.Create(&defaultSheet) + + // 返回完整信息 + workbook.Document = document + utils.SuccessWithMessage(c, "创建工作簿成功", workbook) +} + +// GetWorkbook 获取工作簿详情 +// 请求方式: GET +// 路径: /api/workbooks/:id +// 路径参数: id - 工作簿ID +func GetWorkbook(c *gin.Context) { + id := c.Param("id") + var workbook models.Workbook + + // 查询工作簿并预加载关联的工作表和文档 + result := models.DB.Preload("Sheets").Preload("Document").First(&workbook, id) + if result.Error != nil { + utils.Error(c, 404, "工作簿不存在") + return + } + + utils.Success(c, workbook) +} + +// UpdateWorkbook 更新工作簿信息 +// 更新关联的文档表信息 +// 请求方式: PUT +// 路径: /api/workbooks/:id +// 路径参数: id - 工作簿ID +// 请求体: { "title": "新标题", "description": "新描述" } +func UpdateWorkbook(c *gin.Context) { + id := c.Param("id") + var workbook models.Workbook + + // 查找要更新的工作簿 + result := models.DB.First(&workbook, id) + if result.Error != nil { + utils.Error(c, 404, "工作簿不存在") + return + } + + var req struct { + Title string `json:"title"` // 文档标题 + Description string `json:"description"` // 文档描述 + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Error(c, 400, "参数错误: "+err.Error()) + return + } + + // 更新关联的文档表 + var document models.Document + if err := models.DB.First(&document, workbook.DocumentID).Error; err == nil { + if req.Title != "" { + document.Title = req.Title + } + document.Description = req.Description + models.DB.Save(&document) + } + + utils.SuccessWithMessage(c, "更新工作簿成功", workbook) +} + +// DeleteWorkbook 删除工作簿(软删除) +// 同时删除关联的文档、工作表和单元格 +// 请求方式: DELETE +// 路径: /api/workbooks/:id +// 路径参数: id - 工作簿ID +func DeleteWorkbook(c *gin.Context) { + id := c.Param("id") + var workbook models.Workbook + + // 查找要删除的工作簿 + result := models.DB.First(&workbook, id) + if result.Error != nil { + utils.Error(c, 404, "工作簿不存在") + return + } + + // 获取关联的工作表IDs + var sheetIDs []uint + models.DB.Model(&models.Sheet{}).Where("workbook_id = ?", id).Pluck("id", &sheetIDs) + + // 软删除单元格 + if len(sheetIDs) > 0 { + models.DB.Where("sheet_id IN ?", sheetIDs).Delete(&models.Cell{}) + } + + // 软删除工作表 + models.DB.Where("workbook_id = ?", id).Delete(&models.Sheet{}) + + // 软删除工作簿 + models.DB.Delete(&workbook) + + // 软删除关联的文档 + models.DB.Delete(&models.Document{}, workbook.DocumentID) + + utils.SuccessWithMessage(c, "删除工作簿成功", nil) +} + +// HandleOptions 处理OPTIONS预检请求 +// 用于CORS跨域请求的预检 +func HandleOptions(c *gin.Context) { + c.Status(http.StatusNoContent) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cec54f0 --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +// Package main 应用程序入口 +// 初始化配置、数据库、WebSocket Hub,启动HTTP服务 +// API服务运行在16702端口,WebSocket服务运行在16703端口 +package main + +import ( + "fmt" + "log" + "net/http" + + "excel-api/config" + "excel-api/models" + "excel-api/router" + ws "excel-api/websocket" +) + +func main() { + // 第一步:加载配置文件 + if err := config.Init(); err != nil { + log.Fatalf("加载配置文件失败: %v", err) + } + log.Println("配置文件加载成功") + + // 第二步:初始化数据库连接 + if err := models.InitDB(); err != nil { + log.Fatalf("初始化数据库失败: %v", err) + } + + // 第三步:创建并启动WebSocket Hub + hub := ws.NewHub() + go hub.Run() + log.Println("WebSocket Hub 已启动") + + // 第四步:启动WebSocket独立服务(端口16703) + wsAddr := fmt.Sprintf(":%d", config.App.Server.WsPort) + wsRouter := http.NewServeMux() + wsRouter.HandleFunc("/ws", ws.HandleWebSocketHTTP(hub)) + go func() { + log.Printf("WebSocket服务启动,监听端口 %s", wsAddr) + if err := http.ListenAndServe(wsAddr, wsRouter); err != nil { + log.Printf("WebSocket服务启动失败: %v", err) + } + }() + + // 第五步:配置路由并启动API HTTP服务(端口16702) + r := router.Setup(hub) + apiAddr := fmt.Sprintf(":%d", config.App.Server.Port) + log.Printf("API服务启动,监听端口 %s", apiAddr) + if err := r.Run(apiAddr); err != nil { + log.Fatalf("API服务启动失败: %v", err) + } +} diff --git a/models/db.go b/models/db.go new file mode 100644 index 0000000..491be9c --- /dev/null +++ b/models/db.go @@ -0,0 +1,83 @@ +// Package models 提供数据库初始化和连接管理功能 +package models + +import ( + "fmt" + "log" + + "excel-api/config" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// DB 全局数据库连接实例 +var DB *gorm.DB + +// InitDB 初始化数据库连接 +// 读取配置文件中的数据库连接信息,建立MySQL连接并自动迁移表结构 +func InitDB() error { + cfg := config.App.Database + // 构建MySQL DSN(Data Source Name)连接字符串 + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", + cfg.Username, + cfg.Password, + cfg.Host, + cfg.Port, + cfg.DBName, + cfg.Charset, + ) + var err error + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return fmt.Errorf("数据库连接失败: %w", err) + } + + // 获取底层 *sql.DB 用于配置连接池 + sqlDB, err := DB.DB() + if err != nil { + return fmt.Errorf("获取数据库连接池失败: %w", err) + } + + // 设置连接池参数 + sqlDB.SetMaxIdleConns(10) // 最大空闲连接数 + sqlDB.SetMaxOpenConns(100) // 最大打开连接数 + + // 自动迁移:根据模型结构体创建/更新数据库表 + // 注意:AutoMigrate 仅创建表和添加缺失列,不会删除已有列 + // 完整的表结构变更请参照根目录 init.sql 文件 + err = DB.AutoMigrate( + &User{}, + &Role{}, + &Permission{}, + &RolePermission{}, + &UserRole{}, + &Document{}, + &Workbook{}, + &Sheet{}, + &Cell{}, + &DocumentRole{}, + &UserDocument{}, + &WorkbookPermission{}, + &OperationLog{}, + &ShareLink{}, + &Comment{}, + &Notification{}, + &Tag{}, + &DocumentTag{}, + &Folder{}, + &FileUpload{}, + &Version{}, + &CollaborationSession{}, + &ExportHistory{}, + &Setting{}, + &ApiKey{}, + &AuditLog{}, + ) + if err != nil { + return fmt.Errorf("数据库迁移失败: %w", err) + } + + log.Println("数据库连接成功,表结构迁移完成") + return nil +} diff --git a/models/model.go b/models/model.go new file mode 100644 index 0000000..7fd8d76 --- /dev/null +++ b/models/model.go @@ -0,0 +1,449 @@ +// Package models 定义数据库模型 +// 包含用户、角色、权限、文档、工作簿、工作表、单元格等核心数据模型 +// 所有模型结构必须与根目录 init.sql 保持一致 +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// ============================================================ +// 用户相关模型 +// ============================================================ + +// User 用户模型 +// 存储系统用户信息,前期不强制登录 +type User struct { + ID uint `json:"id" gorm:"primaryKey"` // 用户ID,主键自增 + Username string `json:"username" gorm:"size:64;uniqueIndex"` // 用户名(登录名) + Password string `json:"-" gorm:"size:255"` // 密码哈希(JSON不输出) + Nickname string `json:"nickname" gorm:"size:64"` // 昵称/显示名称 + Email string `json:"email" gorm:"size:128"` // 邮箱 + Phone string `json:"phone" gorm:"size:20"` // 手机号 + Avatar string `json:"avatar" gorm:"size:512"` // 头像URL + Status int `json:"status" gorm:"default:1"` // 状态: 0=禁用 1=正常 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 + Roles []Role `json:"roles,omitempty" gorm:"many2many:user_roles"` // 关联角色列表 +} + +// Role 角色模型 +// 系统角色定义,区分前台角色和后台角色 +type Role struct { + ID uint `json:"id" gorm:"primaryKey"` // 角色ID,主键自增 + Name string `json:"name" gorm:"size:64;not null"` // 角色名称 + Code string `json:"code" gorm:"size:64;uniqueIndex"` // 角色编码(唯一标识) + Type string `json:"type" gorm:"size:16;default:'frontend'"` // 角色类型: frontend=前台 backend=后台 + Description string `json:"description" gorm:"size:255"` // 角色描述 + SortOrder int `json:"sortOrder" gorm:"default:0"` // 排序序号 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 + Permissions []Permission `json:"permissions,omitempty" gorm:"many2many:role_permissions"` // 关联权限列表 +} + +// Permission 权限模型 +// 系统权限定义,区分前台权限和后台权限 +type Permission struct { + ID uint `json:"id" gorm:"primaryKey"` // 权限ID,主键自增 + Name string `json:"name" gorm:"size:64;not null"` // 权限名称 + Code string `json:"code" gorm:"size:128;uniqueIndex"` // 权限编码(如 workbook:create) + Type string `json:"type" gorm:"size:16;default:'frontend'"` // 权限类型: frontend=前台 backend=后台 + Path string `json:"path" gorm:"size:255"` // 关联的API路径 + Method string `json:"method" gorm:"size:10"` // HTTP方法: GET/POST/PUT/DELETE + Description string `json:"description" gorm:"size:255"` // 权限描述 + SortOrder int `json:"sortOrder" gorm:"default:0"` // 排序序号 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// RolePermission 角色-权限关联模型 +type RolePermission struct { + ID uint `json:"id" gorm:"primaryKey"` // 主键ID + RoleID uint `json:"roleId" gorm:"uniqueIndex:uk_role_permission"` // 角色ID + PermissionID uint `json:"permissionId" gorm:"uniqueIndex:uk_role_permission"` // 权限ID + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// UserRole 用户-角色关联模型 +type UserRole struct { + ID uint `json:"id" gorm:"primaryKey"` // 主键ID + UserID uint `json:"userId" gorm:"uniqueIndex:uk_user_role"` // 用户ID + RoleID uint `json:"roleId" gorm:"uniqueIndex:uk_user_role"` // 角色ID + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// ============================================================ +// 文档相关模型 +// ============================================================ + +// Document 文档模型 +// 顶层文档表,支持多种文档类型(excel/word/pdf) +type Document struct { + ID uint `json:"id" gorm:"primaryKey"` // 文档ID,主键自增 + Type string `json:"type" gorm:"size:16;not null;index"` // 文档类型: excel/word/pdf + Title string `json:"title" gorm:"size:255;not null"` // 文档标题 + Description string `json:"description" gorm:"size:500"` // 文档描述 + CreatorID uint `json:"creatorId" gorm:"index;not null"` // 创建者用户ID + Cover string `json:"cover" gorm:"size:512"` // 封面图URL + FileSize int64 `json:"fileSize" gorm:"default:0"` // 文件大小(字节) + SortOrder int `json:"sortOrder" gorm:"default:0"` // 排序序号 + Status int `json:"status" gorm:"default:1"` // 状态: 0=草稿 1=正常 2=归档 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// Workbook 工作簿模型 +// Excel文档的工作簿数据,通过 document_id 关联文档表 +type Workbook struct { + ID uint `json:"id" gorm:"primaryKey"` // 工作簿ID,主键自增 + DocumentID uint `json:"documentId" gorm:"uniqueIndex;not null"` // 关联文档ID + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 + Document Document `json:"document,omitempty" gorm:"foreignKey:DocumentID"` // 关联的文档 + Sheets []Sheet `json:"sheets,omitempty" gorm:"foreignKey:WorkbookID"` // 关联的工作表列表 +} + +// Sheet 工作表/数据表模型 +// 每个工作表属于一个工作簿,拥有独立的列配置和布局 +type Sheet struct { + ID uint `json:"id" gorm:"primaryKey"` // 工作表ID,主键自增 + WorkbookID uint `json:"workbookId" gorm:"index;not null"` // 所属工作簿ID + Name string `json:"name" gorm:"size:255;not null"` // 工作表名称 + Index int `json:"index" gorm:"default:0"` // 工作表排序索引 + ColCount int `json:"colCount" gorm:"default:26"` // 列数量 + RowCount int `json:"rowCount" gorm:"default:100"` // 行数量 + ColWidths string `json:"colWidths" gorm:"type:text"` // 列宽配置(JSON字符串) + RowHeights string `json:"rowHeights" gorm:"type:text"` // 行高配置(JSON字符串) + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// Cell 单元格模型 +// 每个单元格属于一个工作表,通过行号和列号定位 +type Cell struct { + ID uint `json:"id" gorm:"primaryKey"` // 单元格ID,主键自增 + SheetID uint `json:"sheetId" gorm:"index;not null"` // 所属工作表ID + Row int `json:"row" gorm:"index;not null"` // 行号(0-based) + Col int `json:"col" gorm:"index;not null"` // 列号(0-based) + CellType string `json:"cellType" gorm:"size:20;default:'text'"` // 单元格类型 + Value string `json:"value" gorm:"type:text"` // 文本值或计算结果 + Formula string `json:"formula" gorm:"type:text"` // 公式表达式(仅formula类型) + FileURL string `json:"fileUrl" gorm:"type:text"` // 文件URL + FileName string `json:"fileName" gorm:"size:255"` // 文件名 + FileSize int64 `json:"fileSize" gorm:"default:0"` // 文件大小(字节) + MimeType string `json:"mimeType" gorm:"size:128"` // MIME类型 + Style string `json:"style" gorm:"type:text"` // 单元格样式(JSON字符串) + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// ============================================================ +// 权限相关模型 +// ============================================================ + +// DocumentRole 文档角色模型 +// 定义用户对特定文档的角色(创建者/编辑者/访问者) +type DocumentRole struct { + ID uint `json:"id" gorm:"primaryKey"` // 主键ID + DocumentID uint `json:"documentId" gorm:"uniqueIndex:uk_document_user"` // 文档ID + UserID uint `json:"userId" gorm:"uniqueIndex:uk_document_user"` // 用户ID + RoleType string `json:"roleType" gorm:"size:16;not null"` // 角色类型: creator/editor/viewer + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// UserDocument 用户-文档关联模型 +// 记录用户访问/收藏/最近打开的文档关系 +type UserDocument struct { + ID uint `json:"id" gorm:"primaryKey"` // 主键ID + UserID uint `json:"userId" gorm:"uniqueIndex:uk_user_document"` // 用户ID + DocumentID uint `json:"documentId" gorm:"uniqueIndex:uk_user_document"` // 文档ID + IsFavorite int `json:"isFavorite" gorm:"default:0"` // 是否收藏: 0=否 1=是 + LastOpenAt *time.Time `json:"lastOpenAt"` // 最近打开时间 + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// WorkbookPermission 工作簿权限模型 +// 工作簿级别的细粒度权限控制 +type WorkbookPermission struct { + ID uint `json:"id" gorm:"primaryKey"` // 主键ID + WorkbookID uint `json:"workbookId" gorm:"uniqueIndex:uk_workbook_user"` // 工作簿ID + UserID uint `json:"userId" gorm:"uniqueIndex:uk_workbook_user"` // 用户ID + CanCreate int `json:"canCreate" gorm:"default:0"` // 是否可创建工作表 + CanEdit int `json:"canEdit" gorm:"default:0"` // 是否可编辑单元格 + CanDelete int `json:"canDelete" gorm:"default:0"` // 是否可删除 + CanShare int `json:"canShare" gorm:"default:0"` // 是否可分享 + CanExport int `json:"canExport" gorm:"default:0"` // 是否可导出 + CanPrint int `json:"canPrint" gorm:"default:0"` // 是否可打印 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// ============================================================ +// 操作历史模型 +// ============================================================ + +// OperationLog 编辑历史模型 +// 记录用户在工作表上的所有操作,用于撤销/重做和操作审计 +type OperationLog struct { + ID uint `json:"id" gorm:"primaryKey"` // 操作ID,主键自增 + SheetID uint `json:"sheetId" gorm:"index;not null"` // 工作表ID + UserID uint `json:"userId" gorm:"default:0"` // 操作用户ID(0=系统操作) + Operation string `json:"operation" gorm:"size:32;not null"` // 操作类型 + TargetType string `json:"targetType" gorm:"size:32;default:'cell'"` // 操作目标类型: cell/row/col/sheet + TargetRange string `json:"targetRange" gorm:"size:128"` // 操作目标范围(如 A1:B5) + BeforeData string `json:"beforeData" gorm:"type:json"` // 操作前的数据快照(JSON) + AfterData string `json:"afterData" gorm:"type:json"` // 操作后的数据快照(JSON) + Description string `json:"description" gorm:"size:512"` // 操作描述(人类可读) + IPAddress string `json:"ipAddress" gorm:"size:64"` // 操作者IP地址 + CreatedAt time.Time `json:"createdAt"` // 操作时间 +} + +// ============================================================ +// 分享链接模型 +// ============================================================ + +// ShareLink 分享链接模型 +// 支持生成公开分享链接,可设置密码、有效期、访问次数 +type ShareLink struct { + ID uint `json:"id" gorm:"primaryKey"` // 主键ID + DocumentID uint `json:"documentId" gorm:"index;not null"` // 文档ID + UserID uint `json:"userId" gorm:"index;not null"` // 创建者用户ID + Token string `json:"token" gorm:"size:64;uniqueIndex"` // 分享令牌(唯一标识) + Title string `json:"title" gorm:"size:255"` // 分享标题(可自定义) + Password string `json:"password" gorm:"size:255"` // 访问密码(加密存储) + Permission string `json:"permission" gorm:"size:16;default:'view'"` // 访问权限: view/edit + ExpireAt *time.Time `json:"expireAt"` // 过期时间(NULL=永不过期) + MaxAccess int `json:"maxAccess" gorm:"default:0"` // 最大访问次数(0=不限) + AccessCount int `json:"accessCount" gorm:"default:0"` // 已访问次数 + IsActive int `json:"isActive" gorm:"default:1"` // 是否启用 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// ============================================================ +// 评论模型 +// ============================================================ + +// Comment 评论模型 +// 支持单元格/文档级评论,支持回复和@提及 +type Comment struct { + ID uint `json:"id" gorm:"primaryKey"` // 评论ID + DocumentID uint `json:"documentId" gorm:"index;not null"` // 文档ID + SheetID *uint `json:"sheetId" gorm:"index"` // 工作表ID(NULL=文档级评论) + ParentID *uint `json:"parentId" gorm:"index"` // 父评论ID(NULL=顶级评论) + UserID uint `json:"userId" gorm:"index;not null"` // 评论者用户ID + CellRange string `json:"cellRange" gorm:"size:128"` // 关联的单元格范围 + Content string `json:"content" gorm:"type:text;not null"` // 评论内容 + Mentions string `json:"mentions" gorm:"type:json"` // @提及的用户ID列表(JSON) + Status int `json:"status" gorm:"default:1"` // 状态: 0=已隐藏 1=正常 2=已解决 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// ============================================================ +// 通知模型 +// ============================================================ + +// Notification 通知模型 +// 系统通知:文档分享、评论、协作邀请等 +type Notification struct { + ID uint `json:"id" gorm:"primaryKey"` // 通知ID + UserID uint `json:"userId" gorm:"index;not null"` // 接收者用户ID + SenderID *uint `json:"senderId"` // 发送者用户ID(NULL=系统通知) + Type string `json:"type" gorm:"size:32;not null"` // 通知类型 + Title string `json:"title" gorm:"size:255;not null"` // 通知标题 + Content string `json:"content" gorm:"type:text"` // 通知内容 + TargetType string `json:"targetType" gorm:"size:32"` // 关联目标类型 + TargetID *uint `json:"targetId"` // 关联目标ID + IsRead int `json:"isRead" gorm:"default:0"` // 是否已读 + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// ============================================================ +// 标签模型 +// ============================================================ + +// Tag 标签模型 +type Tag struct { + ID uint `json:"id" gorm:"primaryKey"` // 标签ID + UserID uint `json:"userId" gorm:"not null"` // 创建者用户ID + Name string `json:"name" gorm:"size:64;not null"` // 标签名称 + Color string `json:"color" gorm:"size:16;default:'#1890ff'"` // 标签颜色 + SortOrder int `json:"sortOrder" gorm:"default:0"` // 排序序号 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// DocumentTag 文档-标签关联模型 +type DocumentTag struct { + ID uint `json:"id" gorm:"primaryKey"` // 主键ID + DocumentID uint `json:"documentId" gorm:"uniqueIndex:uk_document_tag"` // 文档ID + TagID uint `json:"tagId" gorm:"uniqueIndex:uk_document_tag"` // 标签ID + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// ============================================================ +// 文件夹模型 +// ============================================================ + +// Folder 文件夹模型 +// 支持多层级文件夹组织结构 +type Folder struct { + ID uint `json:"id" gorm:"primaryKey"` // 文件夹ID + ParentID *uint `json:"parentId"` // 父文件夹ID(NULL=根目录) + UserID uint `json:"userId" gorm:"index;not null"` // 创建者用户ID + Name string `json:"name" gorm:"size:255;not null"` // 文件夹名称 + Icon string `json:"icon" gorm:"size:64;default:'folder'"` // 图标名称 + SortOrder int `json:"sortOrder" gorm:"default:0"` // 排序序号 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// ============================================================ +// 文件上传模型 +// ============================================================ + +// FileUpload 文件上传记录模型 +// 集中管理所有上传的文件,支持文件版本控制 +type FileUpload struct { + ID uint `json:"id" gorm:"primaryKey"` // 文件ID + UserID uint `json:"userId" gorm:"index;not null"` // 上传者用户ID + DocumentID *uint `json:"documentId"` // 关联文档ID(可选) + OriginalName string `json:"originalName" gorm:"size:512;not null"` // 原始文件名 + StorageName string `json:"storageName" gorm:"size:512;not null"` // 存储文件名 + FilePath string `json:"filePath" gorm:"size:1024;not null"` // 文件存储路径 + FileSize int64 `json:"fileSize" gorm:"default:0"` // 文件大小(字节) + MimeType string `json:"mimeType" gorm:"size:128"` // MIME类型 + FileType string `json:"fileType" gorm:"size:32;default:'file'"` // 文件类型 + MD5Hash string `json:"md5Hash" gorm:"size:64"` // 文件MD5哈希(用于去重) + Version int `json:"version" gorm:"default:1"` // 版本号 + ParentID *uint `json:"parentId"` // 父版本ID + Status int `json:"status" gorm:"default:1"` // 状态: 0=已删除 1=正常 + CreatedAt time.Time `json:"createdAt"` // 上传时间 +} + +// ============================================================ +// 版本历史模型 +// ============================================================ + +// Version 文档版本历史模型 +// 支持版本对比和回滚 +type Version struct { + ID uint `json:"id" gorm:"primaryKey"` // 版本ID + DocumentID uint `json:"documentId" gorm:"uniqueIndex:uk_doc_version;not null"` // 文档ID + UserID uint `json:"userId" gorm:"index;not null"` // 操作者用户ID + VersionNumber int `json:"versionNumber" gorm:"uniqueIndex:uk_doc_version;not null"` // 版本号 + Title string `json:"title" gorm:"size:255"` // 版本标题 + Description string `json:"description" gorm:"size:500"` // 版本描述 + Snapshot string `json:"snapshot" gorm:"type:json;not null"` // 文档数据快照(JSON) + Diff string `json:"diff" gorm:"type:json"` // 与上一版本的差异(JSON) + IsAuto int `json:"isAuto" gorm:"default:0"` // 是否自动保存 + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// ============================================================ +// 协作会话模型 +// ============================================================ + +// CollaborationSession 协作会话模型 +// 记录实时协作的会话信息,跟踪在线用户状态 +type CollaborationSession struct { + ID uint `json:"id" gorm:"primaryKey"` // 会话ID + DocumentID uint `json:"documentId" gorm:"index;not null"` // 文档ID + UserID uint `json:"userId" gorm:"index;not null"` // 用户ID + SessionID string `json:"sessionId" gorm:"size:64;index"` // WebSocket会话ID + CursorRow *int `json:"cursorRow"` // 当前光标行位置 + CursorCol *int `json:"cursorCol"` // 当前光标列位置 + Selection string `json:"selection" gorm:"size:128"` // 当前选区范围 + IsActive int `json:"isActive" gorm:"default:1"` // 是否在线 + JoinedAt time.Time `json:"joinedAt"` // 加入时间 + LastActiveAt time.Time `json:"lastActiveAt"` // 最后活跃时间 +} + +// ============================================================ +// 导出历史模型 +// ============================================================ + +// ExportHistory 导出历史模型 +type ExportHistory struct { + ID uint `json:"id" gorm:"primaryKey"` // 导出ID + DocumentID uint `json:"documentId" gorm:"index;not null"` // 文档ID + UserID uint `json:"userId" gorm:"index;not null"` // 导出者用户ID + Format string `json:"format" gorm:"size:16;not null"` // 导出格式: xlsx/csv/pdf + FilePath string `json:"filePath" gorm:"size:1024"` // 导出文件路径 + FileSize int64 `json:"fileSize" gorm:"default:0"` // 导出文件大小 + Status int `json:"status" gorm:"default:0"` // 状态: 0=处理中 1=完成 2=失败 + ErrorMsg string `json:"errorMsg" gorm:"size:512"` // 错误信息 + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// ============================================================ +// 设置模型 +// ============================================================ + +// Setting 设置模型 +// 用户个人设置和系统全局配置 +type Setting struct { + ID uint `json:"id" gorm:"primaryKey"` // 设置ID + UserID *uint `json:"userId"` // 用户ID(NULL=系统设置) + Category string `json:"category" gorm:"size:32;not null"` // 设置分类 + Key string `json:"key" gorm:"size:128;not null"` // 设置键名 + Value string `json:"value" gorm:"type:text"` // 设置值 + Description string `json:"description" gorm:"size:255"` // 设置描述 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// ============================================================ +// API密钥模型 +// ============================================================ + +// ApiKey API密钥模型 +type ApiKey struct { + ID uint `json:"id" gorm:"primaryKey"` // 密钥ID + UserID uint `json:"userId" gorm:"index;not null"` // 用户ID + Name string `json:"name" gorm:"size:128;not null"` // 密钥名称 + KeyHash string `json:"keyHash" gorm:"size:255;index"` // 密钥哈希 + KeyPrefix string `json:"keyPrefix" gorm:"size:16"` // 密钥前缀 + Permissions string `json:"permissions" gorm:"type:json"` // 权限范围(JSON) + RateLimit int `json:"rateLimit" gorm:"default:1000"` // 速率限制 + LastUsedAt *time.Time `json:"lastUsedAt"` // 最后使用时间 + ExpireAt *time.Time `json:"expireAt"` // 过期时间 + IsActive int `json:"isActive" gorm:"default:1"` // 是否启用 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除时间戳 +} + +// ============================================================ +// 审计日志模型 +// ============================================================ + +// AuditLog 审计日志模型 +type AuditLog struct { + ID uint `json:"id" gorm:"primaryKey"` // 日志ID + UserID *uint `json:"userId"` // 操作者用户ID + Action string `json:"action" gorm:"size:64;not null"` // 操作类型 + ResourceType string `json:"resourceType" gorm:"size:32;not null"` // 资源类型 + ResourceID *uint `json:"resourceId"` // 资源ID + OldValue string `json:"oldValue" gorm:"type:json"` // 操作前的值(JSON) + NewValue string `json:"newValue" gorm:"type:json"` // 操作后的值(JSON) + IPAddress string `json:"ipAddress" gorm:"size:64"` // 操作者IP地址 + UserAgent string `json:"userAgent" gorm:"size:512"` // 浏览器User-Agent + Status int `json:"status" gorm:"default:1"` // 状态: 0=失败 1=成功 + ErrorMsg string `json:"errorMsg" gorm:"size:512"` // 错误信息 + CreatedAt time.Time `json:"createdAt"` // 操作时间 +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..5cd468a --- /dev/null +++ b/router/router.go @@ -0,0 +1,74 @@ +// Package router 配置API路由和中间件 +// 定义所有HTTP接口路径、处理函数和CORS等中间件 +package router + +import ( + "excel-api/handlers" + ws "excel-api/websocket" + + "github.com/gin-gonic/gin" +) + +// Setup 初始化并返回配置好的Gin引擎 +// 包含所有API路由、中间件配置 +// hub: WebSocket连接管理中心实例 +func Setup(hub *ws.Hub) *gin.Engine { + r := gin.Default() + + // 配置CORS中间件,允许前端跨域请求 + r.Use(CORSMiddleware()) + + // 静态文件服务:上传的文件通过 /uploads/ 路径访问 + r.Static("/uploads", "./uploads") + + // API路由组 + api := r.Group("/api") + { + // ========== 工作簿相关接口 ========== + api.GET("/workbooks", handlers.GetWorkbooks) // 获取工作簿列表 + api.POST("/workbooks", handlers.CreateWorkbook) // 创建新工作簿 + api.GET("/workbooks/:id", handlers.GetWorkbook) // 获取工作簿详情 + api.PUT("/workbooks/:id", handlers.UpdateWorkbook) // 更新工作簿 + api.DELETE("/workbooks/:id", handlers.DeleteWorkbook) // 删除工作簿 + + // ========== 工作表相关接口 ========== + api.GET("/workbooks/:wid/sheets", handlers.GetSheets) // 获取工作簿下的工作表列表 + api.POST("/workbooks/:wid/sheets", handlers.CreateSheet) // 在工作簿下创建新工作表 + api.GET("/sheets/:id", handlers.GetSheet) // 获取工作表详情 + api.PUT("/sheets/:id", handlers.UpdateSheet) // 更新工作表 + api.DELETE("/sheets/:id", handlers.DeleteSheet) // 删除工作表 + + // ========== 单元格相关接口 ========== + api.GET("/sheets/:id/cells", handlers.GetCells) // 获取单元格数据(支持范围查询) + api.POST("/sheets/:id/cells", handlers.BatchUpdateCells) // 批量更新单元格 + + // ========== 文件上传接口 ========== + api.POST("/upload", handlers.Upload) // 上传文件 + + // ========== WebSocket接口 ========== + api.GET("/ws", ws.HandleWebSocket(hub)) // WebSocket连接端点 + + // ========== 预检请求处理 ========== + api.OPTIONS("/*path", handlers.HandleOptions) // 处理CORS预检请求 + } + + return r +} + +// CORSMiddleware 返回CORS跨域中间件 +// 允许前端开发服务器(端口8888)跨域访问后端API +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} diff --git a/utils/response.go b/utils/response.go new file mode 100644 index 0000000..48b25d2 --- /dev/null +++ b/utils/response.go @@ -0,0 +1,87 @@ +// Package utils 提供通用工具函数 +// 包括统一的 JSON 响应格式化、错误处理等 +package utils + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 统一API响应结构体 +type Response struct { + Code int `json:"code"` // 业务状态码:0=成功,非0=失败 + Message string `json:"message"` // 提示信息 + Data interface{} `json:"data"` // 响应数据,可以是任意类型 +} + +// Success 返回成功响应 +// c: Gin上下文 +// data: 响应数据 +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: "success", + Data: data, + }) +} + +// SuccessWithMessage 返回带自定义消息的成功响应 +// c: Gin上下文 +// message: 自定义提示信息 +// data: 响应数据 +func SuccessWithMessage(c *gin.Context, message string, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: message, + Data: data, + }) +} + +// Error 返回错误响应 +// c: Gin上下文 +// code: 业务错误码(非0) +// message: 错误提示信息 +func Error(c *gin.Context, code int, message string) { + c.JSON(http.StatusOK, Response{ + Code: code, + Message: message, + Data: nil, + }) +} + +// ErrorWithHTTP 返回带HTTP状态码的错误响应 +// c: Gin上下文 +// httpCode: HTTP状态码 +// code: 业务错误码 +// message: 错误提示信息 +func ErrorWithHTTP(c *gin.Context, httpCode int, code int, message string) { + c.JSON(httpCode, Response{ + Code: code, + Message: message, + Data: nil, + }) +} + +// PageResult 分页查询结果结构体 +type PageResult struct { + List interface{} `json:"list"` // 数据列表 + Total int64 `json:"total"` // 总记录数 + Page int `json:"page"` // 当前页码 + PageSize int `json:"pageSize"` // 每页条数 +} + +// SuccessPage 返回分页查询成功响应 +// c: Gin上下文 +// list: 数据列表 +// total: 总记录数 +// page: 当前页码 +// pageSize: 每页条数 +func SuccessPage(c *gin.Context, list interface{}, total int64, page, pageSize int) { + Success(c, PageResult{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + }) +} diff --git a/websocket/handler.go b/websocket/handler.go new file mode 100644 index 0000000..fb9a119 --- /dev/null +++ b/websocket/handler.go @@ -0,0 +1,95 @@ +// Package websocket 提供WebSocket HTTP升级处理器 +package websocket + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// upgrader 用于将HTTP连接升级为WebSocket连接 +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, // 读缓冲区大小 + WriteBufferSize: 1024, // 写缓冲区大小 + // 允许所有来源的连接(开发环境) + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// HandleWebSocket 处理WebSocket连接升级请求(Gin版本) +// 客户端通过此接口建立WebSocket连接,加入Hub进行消息通信 +// 路径: GET /api/ws +func HandleWebSocket(hub *Hub) gin.HandlerFunc { + return func(c *gin.Context) { + handleWS(hub, c.Writer, c.Request) + } +} + +// HandleWebSocketHTTP 处理WebSocket连接升级请求(标准net/http版本) +// 用于独立的WebSocket服务(端口16703) +func HandleWebSocketHTTP(hub *Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + handleWS(hub, w, r) + } +} + +// handleWS WebSocket连接处理的核心逻辑 +func handleWS(hub *Hub, w http.ResponseWriter, r *http.Request) { + // 将HTTP连接升级为WebSocket连接 + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket升级失败: %v", err) + return + } + + // 创建客户端实例并注册到Hub + client := &Client{ + hub: hub, + conn: conn, + send: make(chan []byte, 256), + } + hub.register <- client + + // 启动读写goroutine + go client.writePump() + go client.readPump() +} + +// readPump 从WebSocket连接读取消息 +// 持续监听客户端发送的消息,遇到错误或关闭时断开连接 +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + for { + // 读取消息(目前仅用于检测连接状态,后续可扩展协作协议) + _, message, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("WebSocket读取错误: %v", err) + } + break + } + // 收到客户端消息后广播给其他客户端(协作基础) + c.hub.BroadcastMessage(message) + } +} + +// writePump 向WebSocket连接写入消息 +// 从send通道读取消息并发送给客户端 +func (c *Client) writePump() { + defer c.conn.Close() + + for message := range c.send { + err := c.conn.WriteMessage(websocket.TextMessage, message) + if err != nil { + log.Printf("WebSocket写入错误: %v", err) + break + } + } +} diff --git a/websocket/hub.go b/websocket/hub.go new file mode 100644 index 0000000..ac39138 --- /dev/null +++ b/websocket/hub.go @@ -0,0 +1,89 @@ +// Package websocket 实现WebSocket连接管理和消息广播 +// 为在线协作功能预留,支持多客户端实时通信 +package websocket + +import ( + "log" + "sync" + + "github.com/gorilla/websocket" +) + +// Hub WebSocket连接管理中心 +// 负责维护所有活跃的WebSocket连接,处理消息广播 +type Hub struct { + clients map[*Client]bool // 所有已连接的客户端 + broadcast chan []byte // 待广播的消息通道 + register chan *Client // 客户端注册通道 + unregister chan *Client // 客户端注销通道 + mu sync.RWMutex // 读写锁,保护 clients map +} + +// Client 单个WebSocket客户端连接 +type Client struct { + hub *Hub // 所属的Hub + conn *websocket.Conn // WebSocket连接实例 + send chan []byte // 待发送的消息队列 +} + +// NewHub 创建并返回一个新的Hub实例 +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]bool), + broadcast: make(chan []byte, 256), + register: make(chan *Client), + unregister: make(chan *Client), + } +} + +// Run 启动Hub的主循环 +// 在独立的goroutine中运行,处理客户端注册、注销和消息广播 +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + // 新客户端连接注册 + h.mu.Lock() + h.clients[client] = true + h.mu.Unlock() + log.Printf("WebSocket客户端已连接,当前在线: %d", len(h.clients)) + + case client := <-h.unregister: + // 客户端断开连接注销 + h.mu.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + log.Printf("WebSocket客户端已断开,当前在线: %d", len(h.clients)) + + case message := <-h.broadcast: + // 向所有客户端广播消息 + h.mu.RLock() + for client := range h.clients { + select { + case client.send <- message: + default: + // 发送缓冲区已满,断开该客户端 + close(client.send) + delete(h.clients, client) + } + } + h.mu.RUnlock() + } + } +} + +// BroadcastMessage 向所有连接的客户端广播消息 +// message: 要广播的字节数据 +func (h *Hub) BroadcastMessage(message []byte) { + h.broadcast <- message +} + +// ClientCount 返回当前在线客户端数量 +func (h *Hub) ClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +}