宇恒一号官网
This commit is contained in:
45
server/.air.toml
Normal file
45
server/.air.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Air 热加载配置(可选,当前使用 CompileDaemon)
|
||||
# 安装: go install github.com/air-verse/air@latest
|
||||
# 运行: air
|
||||
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
bin = "./tmp/main"
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "node_modules"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test\\.go$"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
9
server/.env.example
Normal file
9
server/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# 复制为 .env 或 .env.production 后修改
|
||||
# Go 不会自动加载 .env,需在启动前导出变量(见项目根目录 .env.example 的说明)
|
||||
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
MONGODB_DB=yxd-agent-testing
|
||||
PORT=8080
|
||||
GIN_MODE=release
|
||||
SERVER_DOMAIN=https://api.example.com
|
||||
ALLOWED_ORIGINS=
|
||||
13
server/Dockerfile
Normal file
13
server/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# 需在项目根目录构建: docker build -f server/Dockerfile .
|
||||
FROM golang:1.21-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY server/ ./
|
||||
RUN go mod download && CGO_ENABLED=0 go build -o /app/server .
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --from=builder /app/server .
|
||||
EXPOSE 9527
|
||||
ENTRYPOINT ["./server"]
|
||||
24
server/README.md
Normal file
24
server/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 多站点管理后台 - API 服务
|
||||
|
||||
基于 Gin 的后端 API 服务。
|
||||
|
||||
## MongoDB 连接(SSH 穿透)
|
||||
|
||||
MongoDB 在远程服务器,需先建立 SSH 隧道:
|
||||
|
||||
```bash
|
||||
ssh -p 2223 -L 27017:localhost:27017 yxd@www.yuxindazhineng.com
|
||||
```
|
||||
|
||||
或双击运行 `scripts/start-ssh-tunnel.bat`
|
||||
|
||||
隧道建立后,`mongodb://localhost:27017` 会转发到远程 MongoDB。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
go run main.go
|
||||
```
|
||||
|
||||
默认端口 8080
|
||||
4
server/config/constants.go
Normal file
4
server/config/constants.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package config
|
||||
|
||||
// DBName 数据库名,可由环境变量 MONGODB_DB 覆盖
|
||||
var DBName = "yxd-agent-testing"
|
||||
56
server/config/database.go
Normal file
56
server/config/database.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"yh_web/server/pkg/logger"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var MongoClient *mongo.Client
|
||||
|
||||
// ConnectMongoDB 连接本地 MongoDB
|
||||
func ConnectMongoDB(uri string) error {
|
||||
clientOpts := options.Client().ApplyURI(uri)
|
||||
client, err := mongo.Connect(clientOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证连接
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var result bson.M
|
||||
if err = client.Database("admin").RunCommand(ctx, bson.D{{Key: "ping", Value: 1}}).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MongoClient = client
|
||||
log.Println("MongoDB 连接成功")
|
||||
logger.Log("config/database", "MongoDB 连接成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB 获取指定数据库;未连接 MongoDB 时返回 nil
|
||||
func GetDB(name string) *mongo.Database {
|
||||
if MongoClient == nil {
|
||||
return nil
|
||||
}
|
||||
return MongoClient.Database(name)
|
||||
}
|
||||
|
||||
// CloseMongoDB 关闭连接
|
||||
func CloseMongoDB() {
|
||||
if MongoClient != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = MongoClient.Disconnect(ctx)
|
||||
log.Println("MongoDB 连接已关闭")
|
||||
logger.Log("config/database", "MongoDB 连接已关闭")
|
||||
}
|
||||
}
|
||||
78
server/config/db_structure.go
Normal file
78
server/config/db_structure.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// DBStructure 数据库结构信息
|
||||
type DBStructure struct {
|
||||
Databases []DatabaseInfo `json:"databases"`
|
||||
}
|
||||
|
||||
// DatabaseInfo 数据库信息
|
||||
type DatabaseInfo struct {
|
||||
Name string `json:"name"`
|
||||
Collections []CollInfo `json:"collections"`
|
||||
}
|
||||
|
||||
// CollInfo 集合信息
|
||||
type CollInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
Sample interface{} `json:"sample,omitempty"` // 采样一条文档看结构
|
||||
}
|
||||
|
||||
// GetDBStructure 获取 MongoDB 数据结构
|
||||
func GetDBStructure() (*DBStructure, error) {
|
||||
if MongoClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 列出所有数据库(排除系统库)
|
||||
dbs, err := MongoClient.ListDatabaseNames(ctx, bson.M{
|
||||
"name": bson.M{"$nin": []string{"admin", "config", "local"}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &DBStructure{Databases: make([]DatabaseInfo, 0, len(dbs))}
|
||||
|
||||
for _, dbName := range dbs {
|
||||
db := MongoClient.Database(dbName)
|
||||
colls, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dbInfo := DatabaseInfo{
|
||||
Name: dbName,
|
||||
Collections: make([]CollInfo, 0, len(colls)),
|
||||
}
|
||||
|
||||
for _, collName := range colls {
|
||||
coll := db.Collection(collName)
|
||||
count, _ := coll.CountDocuments(ctx, bson.M{})
|
||||
|
||||
// 采样一条文档
|
||||
var sample bson.M
|
||||
_ = coll.FindOne(ctx, bson.M{}).Decode(&sample)
|
||||
|
||||
dbInfo.Collections = append(dbInfo.Collections, CollInfo{
|
||||
Name: collName,
|
||||
Count: count,
|
||||
Sample: sample,
|
||||
})
|
||||
}
|
||||
|
||||
result.Databases = append(result.Databases, dbInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
8
server/dev.bat
Normal file
8
server/dev.bat
Normal file
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo Starting backend with hot reload...
|
||||
if not exist tmp mkdir tmp
|
||||
|
||||
CompileDaemon -command=tmp\main.exe -build="go build -o tmp\main.exe ."
|
||||
44
server/go.mod
Normal file
44
server/go.mod
Normal file
@@ -0,0 +1,44 @@
|
||||
module yh_web/server
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
go.mongodb.org/mongo-driver v1.17.9
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // 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.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // 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.0.8 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
130
server/go.sum
Normal file
130
server/go.sum
Normal file
@@ -0,0 +1,130 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
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.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
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.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
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.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/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.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
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/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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
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.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=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
199
server/handlers/auth.go
Normal file
199
server/handlers/auth.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
"yh_web/server/pkg/logger"
|
||||
"yh_web/server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const jwtSecret = "yh_web_admin_jwt_secret_change_in_production"
|
||||
const jwtExpire = 24 * time.Hour
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
RoleID int `json:"role_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Login 后台登录,仅 role_id=9527 超级管理员可登录
|
||||
func Login(c *gin.Context) {
|
||||
var input LoginInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("users")
|
||||
var user models.User
|
||||
err := coll.FindOne(ctx, bson.M{"username": input.Username}).Decode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
// 尝试用手机号登录
|
||||
err = coll.FindOne(ctx, bson.M{"mobile": input.Username}).Decode(&user)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
logger.Err("handlers/auth", "[Login] FindOne error: %v", err)
|
||||
resp := gin.H{"error": "登录失败,请稍后重试"}
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
resp["debug"] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 超级管理员(9527)或超级用户(role_id=0, role=admin)可登录后台
|
||||
if user.RoleID != models.RoleIDSuperAdmin && !(user.RoleID == models.RoleIDSuperUser && user.Role == "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
|
||||
return
|
||||
}
|
||||
|
||||
roleID := user.RoleID
|
||||
if roleID == 0 && user.Role == "admin" {
|
||||
roleID = models.RoleIDSuperAdmin
|
||||
}
|
||||
|
||||
hashed := utils.HashPassword(input.Password)
|
||||
if hashed != user.Password {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
UserID: user.ID.Hex(),
|
||||
Username: user.Username,
|
||||
RoleID: roleID,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpire)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString([]byte(jwtSecret))
|
||||
if err != nil {
|
||||
logger.Err("handlers/auth", "JWT SignedString error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": tokenStr,
|
||||
"user": gin.H{
|
||||
"id": user.ID.Hex(),
|
||||
"username": user.Username,
|
||||
"role_id": roleID,
|
||||
"role": user.Role,
|
||||
},
|
||||
"expires_in": int64(jwtExpire.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// AuthRequired 鉴权中间件,要求 role_id=9527
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenStr := c.GetHeader("Authorization")
|
||||
if tokenStr == "" {
|
||||
tokenStr = c.Query("token")
|
||||
}
|
||||
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
||||
tokenStr = tokenStr[7:]
|
||||
}
|
||||
if tokenStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 仅超级管理员或超级用户(role_id=0, role=admin)可访问后台
|
||||
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role_id", claims.RoleID)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SuperUserAuthRequired 超级用户鉴权:仅 role_id=0 且 role=admin 可访问(如短信平台配置)
|
||||
// 集团超级用户 username=admin 只能配置集团信息,不能配置短信
|
||||
func SuperUserAuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenStr := c.GetHeader("Authorization")
|
||||
if tokenStr == "" {
|
||||
tokenStr = c.Query("token")
|
||||
}
|
||||
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
||||
tokenStr = tokenStr[7:]
|
||||
}
|
||||
if tokenStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 仅 role_id=9527 且 role=admin 可配置短信平台
|
||||
if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅超级管理员可配置短信平台"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role_id", claims.RoleID)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
67
server/handlers/conversation.go
Normal file
67
server/handlers/conversation.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetConversations(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("conversations")
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if p := c.Query("page"); p != "" {
|
||||
if v, err := parseInt(p); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
if v, err := parseInt(ps); err == nil && v > 0 && v <= 100 {
|
||||
pageSize = v
|
||||
}
|
||||
}
|
||||
|
||||
skip := (page - 1) * pageSize
|
||||
opts := options.Find().SetSkip(int64(skip)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "_id", Value: -1}})
|
||||
|
||||
filter := bson.M{}
|
||||
if userID := c.Query("user_id"); userID != "" {
|
||||
filter["user_id"] = userID
|
||||
}
|
||||
if workspaceID := c.Query("workspace_id"); workspaceID != "" {
|
||||
filter["workspace_id"] = workspaceID
|
||||
}
|
||||
|
||||
cursor, err := coll.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var list []map[string]interface{}
|
||||
if err = cursor.All(ctx, &list); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, _ := coll.CountDocuments(ctx, filter)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
7
server/handlers/helpers.go
Normal file
7
server/handlers/helpers.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import "strconv"
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
345
server/handlers/homepage.go
Normal file
345
server/handlers/homepage.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const homepageSlug = "index"
|
||||
const officialSiteConfigID = "official_site_id"
|
||||
|
||||
type officialSiteDoc struct {
|
||||
ID string `bson:"_id"`
|
||||
SiteID string `bson:"site_id"`
|
||||
}
|
||||
|
||||
// getOfficialSiteID 从 system_config 读取官网站点 ID;未设置则返回第一个站点的 ID
|
||||
func getOfficialSiteID(ctx context.Context) string {
|
||||
coll := config.GetDB(config.DBName).Collection("system_config")
|
||||
var doc officialSiteDoc
|
||||
err := coll.FindOne(ctx, bson.M{"_id": officialSiteConfigID}).Decode(&doc)
|
||||
if err == nil && doc.SiteID != "" {
|
||||
return doc.SiteID
|
||||
}
|
||||
sitesColl := config.GetDB(config.DBName).Collection("sites")
|
||||
opts := options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||
var site models.Site
|
||||
if err := sitesColl.FindOne(ctx, bson.M{}, opts).Decode(&site); err == nil {
|
||||
return site.ID.Hex()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetWebHomepage 前台:获取官网站点首页数据(无需鉴权)
|
||||
func GetWebHomepage(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
siteID := getOfficialSiteID(ctx)
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusOK, defaultHomepageData())
|
||||
return
|
||||
}
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("pages")
|
||||
var page models.Page
|
||||
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
c.JSON(http.StatusOK, defaultHomepageData())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var data models.HomepageData
|
||||
if page.Content != "" {
|
||||
if err := json.Unmarshal([]byte(page.Content), &data); err != nil {
|
||||
c.JSON(http.StatusOK, defaultHomepageData())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data = defaultHomepageData()
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GetHomepage 获取站点首页数据
|
||||
func GetHomepage(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("pages")
|
||||
var page models.Page
|
||||
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
c.JSON(http.StatusOK, defaultHomepageData())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var data models.HomepageData
|
||||
if page.Content != "" {
|
||||
if err := json.Unmarshal([]byte(page.Content), &data); err != nil {
|
||||
c.JSON(http.StatusOK, defaultHomepageData())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data = defaultHomepageData()
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// UpdateHomepage 更新站点首页数据
|
||||
func UpdateHomepage(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
|
||||
var data models.HomepageData
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("pages")
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
filter := bson.M{"site_id": siteID, "slug": homepageSlug}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"site_id": siteID,
|
||||
"slug": homepageSlug,
|
||||
"title": data.Title,
|
||||
"type": "homepage",
|
||||
"content": string(body),
|
||||
"updated_at": now,
|
||||
},
|
||||
}
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err = coll.UpdateOne(ctx, filter, update, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "保存成功"})
|
||||
}
|
||||
|
||||
// DownloadHomepage 下载首页 HTML
|
||||
func DownloadHomepage(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("pages")
|
||||
var page models.Page
|
||||
var data models.HomepageData
|
||||
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
|
||||
if err == nil && page.Content != "" {
|
||||
_ = json.Unmarshal([]byte(page.Content), &data)
|
||||
}
|
||||
if err != nil || page.Content == "" {
|
||||
data = defaultHomepageData()
|
||||
}
|
||||
|
||||
html := renderHomepageHTML(&data)
|
||||
c.Header("Content-Disposition", "attachment; filename=index.html")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
|
||||
}
|
||||
|
||||
func defaultHomepageData() models.HomepageData {
|
||||
return models.HomepageData{
|
||||
LogoText: "YUHENG ONE",
|
||||
NavLinks: []models.NavLink{{Label: "MISSION", URL: "#"}, {Label: "DOWNLOAD", URL: "#"}, {Label: "CONTACT", URL: "#"}},
|
||||
Title: "宇恒一号",
|
||||
Subtitle: "INTERSTELLAR EXPLORER EDITION",
|
||||
Description: "跨越星际的智能伙伴 · 探索无限可能<br>\n 引领您进入前所未有的数字宇宙",
|
||||
DownloadText: "START EXPLORING",
|
||||
DownloadURL: "#",
|
||||
Platforms: []models.PlatformItem{
|
||||
{Name: "WINDOWS", URL: "#"},
|
||||
{Name: "MACOS", URL: "#"},
|
||||
{Name: "LINUX", URL: "#"},
|
||||
{Name: "IOS", URL: "#"},
|
||||
{Name: "ANDROID", URL: "#"},
|
||||
},
|
||||
Version: "VERSION 3.2.1",
|
||||
LaunchYear: "LAUNCH: 2024",
|
||||
BadgeText: "FREE ACCESS",
|
||||
Features: []models.FeatureItem{
|
||||
{Title: "星际导航", Desc: "先进的AI导航系统,精准定位您的需求,引领探索之旅"},
|
||||
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
|
||||
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
|
||||
},
|
||||
FooterText: "© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE",
|
||||
}
|
||||
}
|
||||
|
||||
// renderHomepageHTML 根据数据生成首页 HTML(简化版,保留原样式与结构)
|
||||
func renderHomepageHTML(d *models.HomepageData) string {
|
||||
if d == nil {
|
||||
d = &models.HomepageData{}
|
||||
}
|
||||
titleChars := splitTitle(d.Title)
|
||||
navHTML := ""
|
||||
for _, l := range d.NavLinks {
|
||||
navHTML += `<a href="` + escape(l.URL) + `" style="color: rgba(255,255,255,0.5); text-decoration: none; transition: color 0.3s;">` + escape(l.Label) + `</a>`
|
||||
}
|
||||
platformsHTML := ""
|
||||
for _, p := range d.Platforms {
|
||||
platformsHTML += `<div class="orbit-platform"><svg viewBox="0 0 24 24"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg><span>` + escape(p.Name) + `</span></div>`
|
||||
}
|
||||
featuresHTML := ""
|
||||
for i, f := range d.Features {
|
||||
iconPath := []string{
|
||||
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z",
|
||||
"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM19 18H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h.71C7.37 7.69 9.48 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3s-1.34 3-3 3z",
|
||||
"M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z",
|
||||
}
|
||||
if i >= len(iconPath) {
|
||||
iconPath = append(iconPath, iconPath[0])
|
||||
}
|
||||
path := iconPath[i%3]
|
||||
featuresHTML += `<div class="feature-space"><div class="feature-icon"><svg viewBox="0 0 24 24"><path d="` + path + `"/></svg></div><h3>` + escape(f.Title) + `</h3><p>` + escape(f.Desc) + `</p></div>`
|
||||
}
|
||||
|
||||
sb := &strings.Builder{}
|
||||
sb.WriteString("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>")
|
||||
sb.WriteString(escape(d.Title))
|
||||
sb.WriteString(" - 星际探索版</title>\n")
|
||||
sb.WriteString(homepageCSS)
|
||||
sb.WriteString("</head>\n<body>\n<div class=\"space-bg\"></div>\n<div class=\"stars\" id=\"stars\"></div>\n<div class=\"planet planet-1\"></div>\n<div class=\"planet planet-2\"></div>\n<nav class=\"navbar\"><div class=\"logo-space\">")
|
||||
sb.WriteString(escape(d.LogoText))
|
||||
sb.WriteString("</div>\n<div style=\"display: flex; gap: 35px; font-family: 'Exo 2', sans-serif; font-size: 12px; letter-spacing: 2px;\">")
|
||||
sb.WriteString(navHTML)
|
||||
sb.WriteString("</div>\n</nav>\n<section class=\"hero\">\n<div class=\"title-container\"><h1 class=\"title-3d\">")
|
||||
for _, ch := range titleChars {
|
||||
sb.WriteString("<span>" + escape(ch) + "</span>")
|
||||
}
|
||||
sb.WriteString("</h1>\n</div>\n<p class=\"subtitle-space\">")
|
||||
sb.WriteString(escape(d.Subtitle))
|
||||
sb.WriteString("</p>\n<p class=\"description-space\">")
|
||||
sb.WriteString(strings.ReplaceAll(escape(d.Description), "\n", "<br>\n"))
|
||||
sb.WriteString("</p>\n<div class=\"download-warp\">\n<div class=\"warp-effect\"></div>\n<a href=\"")
|
||||
sb.WriteString(escape(d.DownloadURL))
|
||||
sb.WriteString("\" class=\"warp-btn\">\n<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z\"/></svg>\n")
|
||||
sb.WriteString(escape(d.DownloadText))
|
||||
sb.WriteString("\n</a>\n</div>\n<div class=\"orbit-platforms\">")
|
||||
sb.WriteString(platformsHTML)
|
||||
sb.WriteString("</div>\n<div class=\"mission-info\">\n<span class=\"mission-badge\">")
|
||||
sb.WriteString(escape(d.Version))
|
||||
sb.WriteString("</span>\n<span>🚀 ")
|
||||
sb.WriteString(escape(d.LaunchYear))
|
||||
sb.WriteString("</span>\n<span>⚡ ")
|
||||
sb.WriteString(escape(d.BadgeText))
|
||||
sb.WriteString("</span>\n</div>\n<div class=\"features-space\">")
|
||||
sb.WriteString(featuresHTML)
|
||||
sb.WriteString("</div>\n</section>\n<footer>\n<p>")
|
||||
sb.WriteString(escape(d.FooterText))
|
||||
sb.WriteString("</p>\n</footer>\n<script>\nconst starsContainer = document.getElementById('stars');\nfor (let i = 0; i < 200; i++) {\nconst star = document.createElement('div');\nstar.className = 'star';\nstar.style.left = Math.random() * 100 + '%';\nstar.style.top = Math.random() * 100 + '%';\nstar.style.width = (Math.random() * 2 + 1) + 'px';\nstar.style.height = star.style.width;\nstar.style.setProperty('--duration', (Math.random() * 3 + 2) + 's');\nstar.style.setProperty('--min-opacity', Math.random() * 0.3 + 0.1);\nstarsContainer.appendChild(star);\n}\n</script>\n</body>\n</html>")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func splitTitle(s string) []string {
|
||||
var out []rune
|
||||
for _, r := range s {
|
||||
out = append(out, r)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"宇", "恒", "一", "号"}
|
||||
}
|
||||
result := make([]string, len(out))
|
||||
for i, r := range out {
|
||||
result[i] = string(r)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func escape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
return s
|
||||
}
|
||||
|
||||
const homepageCSS = `<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@300;400;700;900&family=Noto+Sans+SC:wght@300;400;700&display=swap');
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
:root{--space-dark:#0a0a12;--space-blue:#1e3a5f;--nebula-purple:#4a1a6b;--star-white:#fff;--plasma-cyan:#00d4ff;--plasma-pink:#ff2d95;}
|
||||
body{font-family:'Noto Sans SC',sans-serif;background:var(--space-dark);color:var(--star-white);min-height:100vh;overflow-x:hidden;}
|
||||
.space-bg{position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;background:radial-gradient(ellipse at 20% 80%,rgba(74,26,107,0.3) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,rgba(30,58,95,0.3) 0%,transparent 50%);}
|
||||
.stars{position:fixed;top:0;left:0;width:100%;height:100%;z-index:1;pointer-events:none;}
|
||||
.star{position:absolute;background:#fff;border-radius:50%;animation:twinkle 3s ease-in-out infinite;}
|
||||
@keyframes twinkle{0%,100%{opacity:0.3;transform:scale(1);}50%{opacity:1;transform:scale(1.2);}}
|
||||
.planet{position:fixed;border-radius:50%;z-index:2;pointer-events:none;}
|
||||
.planet-1{width:300px;height:300px;top:10%;right:-100px;background:linear-gradient(135deg,var(--nebula-purple),#1a0a2e);opacity:0.6;}
|
||||
.planet-2{width:150px;height:150px;bottom:20%;left:-50px;background:linear-gradient(135deg,var(--space-blue),#0a1520);opacity:0.5;}
|
||||
.navbar{position:fixed;top:0;left:0;right:0;padding:25px 50px;display:flex;justify-content:space-between;align-items:center;z-index:100;background:linear-gradient(180deg,rgba(10,10,18,0.9) 0%,transparent 100%);}
|
||||
.logo-space{font-family:'Exo 2',sans-serif;font-size:26px;font-weight:900;background:linear-gradient(90deg,var(--plasma-cyan),var(--star-white),var(--plasma-pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:4px;}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:100px 20px;position:relative;z-index:10;}
|
||||
.title-container{perspective:1000px;margin-bottom:30px;}
|
||||
.title-3d{font-family:'Exo 2',sans-serif;font-size:clamp(60px,12vw,150px);font-weight:900;color:var(--star-white);text-shadow:0 0 20px rgba(0,212,255,0.3);}
|
||||
.subtitle-space{font-family:'Exo 2',sans-serif;font-size:clamp(16px,3vw,24px);letter-spacing:12px;color:var(--plasma-cyan);margin-bottom:25px;}
|
||||
.description-space{max-width:650px;text-align:center;color:rgba(255,255,255,0.6);line-height:2;font-size:16px;margin-bottom:50px;}
|
||||
.download-warp{position:relative;display:inline-block;padding:0;background:transparent;border:none;cursor:pointer;}
|
||||
.download-warp .warp-btn{display:flex;align-items:center;gap:15px;padding:22px 45px;font-size:16px;font-weight:700;color:var(--space-dark);background:linear-gradient(135deg,var(--plasma-cyan),var(--plasma-pink));border:none;font-family:'Exo 2',sans-serif;letter-spacing:2px;text-decoration:none;clip-path:polygon(20px 0,100% 0,100% calc(100% - 20px),calc(100% - 20px) 100%,0 100%,0 20px);transition:all 0.4s;}
|
||||
.download-warp .warp-btn:hover{transform:scale(1.05);box-shadow:0 0 30px rgba(0,212,255,0.6);}
|
||||
.warp-effect{position:absolute;top:50%;left:50%;width:0;height:0;background:radial-gradient(circle,rgba(255,255,255,0.8) 0%,transparent 70%);border-radius:50%;transform:translate(-50%,-50%);animation:warp-drive 1.5s ease-out infinite;}
|
||||
@keyframes warp-drive{0%{width:0;height:0;opacity:1;}100%{width:300px;height:300px;opacity:0;}}
|
||||
.orbit-platforms{display:flex;gap:25px;margin-top:70px;flex-wrap:wrap;justify-content:center;}
|
||||
.orbit-platform{width:90px;height:90px;background:rgba(30,58,95,0.3);border:1px solid rgba(0,212,255,0.3);border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;transition:all 0.4s;}
|
||||
.orbit-platform:hover{background:rgba(0,212,255,0.2);border-color:var(--plasma-cyan);transform:translateY(-10px);}
|
||||
.orbit-platform svg{width:28px;height:28px;fill:var(--star-white);}
|
||||
.orbit-platform span{font-size:10px;color:rgba(255,255,255,0.6);}
|
||||
.mission-info{margin-top:60px;display:flex;gap:40px;flex-wrap:wrap;justify-content:center;font-size:13px;color:rgba(255,255,255,0.4);}
|
||||
.mission-info .mission-badge{padding:4px 12px;background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.3);border-radius:20px;color:var(--plasma-cyan);font-size:11px;}
|
||||
.features-space{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:30px;max-width:1100px;margin:80px auto 0;padding:0 20px;}
|
||||
.feature-space{background:linear-gradient(135deg,rgba(30,58,95,0.2),rgba(10,10,18,0.8));border:1px solid rgba(0,212,255,0.1);border-radius:20px;padding:35px;transition:all 0.5s;}
|
||||
.feature-space:hover{transform:translateY(-15px);border-color:rgba(0,212,255,0.4);}
|
||||
.feature-icon{width:55px;height:55px;background:linear-gradient(135deg,var(--plasma-cyan),var(--plasma-pink));border-radius:15px;display:flex;align-items:center;justify-content:center;margin-bottom:20px;}
|
||||
.feature-icon svg{width:28px;height:28px;fill:var(--space-dark);}
|
||||
.feature-space h3{font-family:'Exo 2',sans-serif;font-size:18px;color:var(--star-white);margin-bottom:12px;}
|
||||
.feature-space p{color:rgba(255,255,255,0.5);font-size:14px;line-height:1.7;}
|
||||
footer{padding:40px;text-align:center;border-top:1px solid rgba(255,255,255,0.05);margin-top:80px;}
|
||||
footer p{color:rgba(255,255,255,0.3);font-size:12px;}
|
||||
@media(max-width:768px){.planet{display:none;}}
|
||||
</style>
|
||||
`
|
||||
145
server/handlers/module_upload.go
Normal file
145
server/handlers/module_upload.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const uploadDir = "uploads"
|
||||
|
||||
// ListSiteAssets 站点功能模块/上传文件列表
|
||||
func ListSiteAssets(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var list []models.SiteAsset
|
||||
if err = cursor.All(ctx, &list); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
total, _ := coll.CountDocuments(ctx, bson.M{"site_id": siteID})
|
||||
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// UploadSiteAsset 上传功能模块/文件
|
||||
func UploadSiteAsset(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
|
||||
return
|
||||
}
|
||||
|
||||
baseDir := filepath.Join(uploadDir, "sites", siteID)
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 避免覆盖:加时间戳
|
||||
name := file.Filename
|
||||
ext := filepath.Ext(name)
|
||||
nameNoExt := name[:len(name)-len(ext)]
|
||||
saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||||
relPath := filepath.Join("sites", siteID, saveName)
|
||||
destPath := filepath.Join(uploadDir, relPath)
|
||||
|
||||
if err := c.SaveUploadedFile(file, destPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
doc := models.SiteAsset{
|
||||
SiteID: siteID,
|
||||
Name: file.Filename,
|
||||
FilePath: relPath,
|
||||
Size: file.Size,
|
||||
ContentType: file.Header.Get("Content-Type"),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
|
||||
"site_id": doc.SiteID,
|
||||
"name": doc.Name,
|
||||
"file_path": doc.FilePath,
|
||||
"size": doc.Size,
|
||||
"content_type": doc.ContentType,
|
||||
"created_at": doc.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
|
||||
}
|
||||
|
||||
// DeleteSiteAsset 删除站点资源
|
||||
func DeleteSiteAsset(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
idStr := c.Param("asset_id")
|
||||
if siteID == "" || idStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
oid, err := bson.ObjectIDFromHex(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||
var asset models.SiteAsset
|
||||
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(uploadDir, asset.FilePath)
|
||||
os.Remove(fullPath)
|
||||
|
||||
_, err = coll.DeleteOne(ctx, bson.M{"_id": oid})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
50
server/handlers/official_site.go
Normal file
50
server/handlers/official_site.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetOfficialSite 获取官网站点 ID
|
||||
func GetOfficialSite(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
siteID := getOfficialSiteID(ctx)
|
||||
c.JSON(http.StatusOK, gin.H{"site_id": siteID})
|
||||
}
|
||||
|
||||
// SetOfficialSiteInput 设置官网站点
|
||||
type SetOfficialSiteInput struct {
|
||||
SiteID string `json:"site_id" binding:"required"`
|
||||
}
|
||||
|
||||
// SetOfficialSite 设置官网站点
|
||||
func SetOfficialSite(c *gin.Context) {
|
||||
var input SetOfficialSiteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("system_config")
|
||||
doc := officialSiteDoc{ID: officialSiteConfigID, SiteID: input.SiteID}
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err := coll.UpdateOne(ctx, bson.M{"_id": officialSiteConfigID}, bson.M{"$set": doc}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已设为官网站点", "site_id": input.SiteID})
|
||||
}
|
||||
175
server/handlers/page.go
Normal file
175
server/handlers/page.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPages 网页列表(按站点)
|
||||
func GetPages(c *gin.Context) {
|
||||
siteID := c.Query("site_id")
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("pages")
|
||||
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
|
||||
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var list []models.Page
|
||||
if err = cursor.All(ctx, &list); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
total, _ := coll.CountDocuments(ctx, bson.M{"site_id": siteID})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// GetPageByID 单页
|
||||
func GetPageByID(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
oid, err := bson.ObjectIDFromHex(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var page models.Page
|
||||
err = config.GetDB(config.DBName).Collection("pages").FindOne(ctx, bson.M{"_id": oid}).Decode(&page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
|
||||
// CreatePageInput 创建网页
|
||||
type CreatePageInput struct {
|
||||
SiteID string `json:"site_id" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Type string `json:"type"` // homepage, page
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CreatePage 创建网页
|
||||
func CreatePage(c *gin.Context) {
|
||||
var input CreatePageInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写 site_id、slug、title"})
|
||||
return
|
||||
}
|
||||
if input.Type == "" {
|
||||
input.Type = "page"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("pages")
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
doc := bson.M{
|
||||
"site_id": input.SiteID,
|
||||
"slug": input.Slug,
|
||||
"title": input.Title,
|
||||
"type": input.Type,
|
||||
"content": input.Content,
|
||||
"updated_at": now,
|
||||
}
|
||||
res, err := coll.InsertOne(ctx, doc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "message": "创建成功"})
|
||||
}
|
||||
|
||||
// UpdatePageInput 更新网页
|
||||
type UpdatePageInput struct {
|
||||
Slug *string `json:"slug"`
|
||||
Title *string `json:"title"`
|
||||
Type *string `json:"type"`
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
|
||||
// UpdatePage 更新网页
|
||||
func UpdatePage(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
oid, err := bson.ObjectIDFromHex(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input UpdatePageInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{"updated_at": time.Now().Format(time.RFC3339)}
|
||||
if input.Slug != nil {
|
||||
set["slug"] = *input.Slug
|
||||
}
|
||||
if input.Title != nil {
|
||||
set["title"] = *input.Title
|
||||
}
|
||||
if input.Type != nil {
|
||||
set["type"] = *input.Type
|
||||
}
|
||||
if input.Content != nil {
|
||||
set["content"] = *input.Content
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = config.GetDB(config.DBName).Collection("pages").UpdateOne(ctx, bson.M{"_id": oid}, bson.M{"$set": set})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// DeletePage 删除网页
|
||||
func DeletePage(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
oid, err := bson.ObjectIDFromHex(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = config.GetDB(config.DBName).Collection("pages").DeleteOne(ctx, bson.M{"_id": oid})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
128
server/handlers/payment_config.go
Normal file
128
server/handlers/payment_config.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const paymentConfigDocID = "payment"
|
||||
|
||||
// GetPaymentConfig 获取支付配置(仅 role_id=9527, role=admin)
|
||||
func GetPaymentConfig(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("system_config")
|
||||
var cfg models.PaymentConfig
|
||||
err := coll.FindOne(ctx, bson.M{"_id": paymentConfigDocID}).Decode(&cfg)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
c.JSON(http.StatusOK, models.PaymentConfig{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// PaymentConfigUpdateInput 支付配置更新
|
||||
type PaymentConfigUpdateInput struct {
|
||||
Wechat *WechatPayUpdateInput `json:"wechat"`
|
||||
Alipay *AlipayUpdateInput `json:"alipay"`
|
||||
}
|
||||
|
||||
type WechatPayUpdateInput struct {
|
||||
AppID string `json:"app_id"`
|
||||
MchID string `json:"mch_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
APIKeyV3 string `json:"api_key_v3"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type AlipayUpdateInput struct {
|
||||
AppID string `json:"app_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
AlipayPublicKey string `json:"alipay_public_key"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdatePaymentConfig 更新支付配置
|
||||
func UpdatePaymentConfig(c *gin.Context) {
|
||||
var input PaymentConfigUpdateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("system_config")
|
||||
set := bson.M{}
|
||||
|
||||
if input.Wechat != nil {
|
||||
w := bson.M{}
|
||||
if input.Wechat.AppID != "" {
|
||||
w["app_id"] = input.Wechat.AppID
|
||||
}
|
||||
if input.Wechat.MchID != "" {
|
||||
w["mch_id"] = input.Wechat.MchID
|
||||
}
|
||||
if input.Wechat.APIKey != "" {
|
||||
w["api_key"] = input.Wechat.APIKey
|
||||
}
|
||||
if input.Wechat.APIKeyV3 != "" {
|
||||
w["api_key_v3"] = input.Wechat.APIKeyV3
|
||||
}
|
||||
if input.Wechat.Enabled != nil {
|
||||
w["enabled"] = *input.Wechat.Enabled
|
||||
}
|
||||
for k, v := range w {
|
||||
set["wechat."+k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if input.Alipay != nil {
|
||||
a := bson.M{}
|
||||
if input.Alipay.AppID != "" {
|
||||
a["app_id"] = input.Alipay.AppID
|
||||
}
|
||||
if input.Alipay.PrivateKey != "" {
|
||||
a["private_key"] = input.Alipay.PrivateKey
|
||||
}
|
||||
if input.Alipay.AlipayPublicKey != "" {
|
||||
a["alipay_public_key"] = input.Alipay.AlipayPublicKey
|
||||
}
|
||||
if input.Alipay.Enabled != nil {
|
||||
a["enabled"] = *input.Alipay.Enabled
|
||||
}
|
||||
for k, v := range a {
|
||||
set["alipay."+k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(set) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
|
||||
return
|
||||
}
|
||||
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err := coll.UpdateOne(ctx, bson.M{"_id": paymentConfigDocID}, bson.M{"$set": set}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "配置已保存"})
|
||||
}
|
||||
96
server/handlers/permission.go
Normal file
96
server/handlers/permission.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// roleIDFromContext 从 context 安全取 role_id(JWT 等可能解码为 float64)
|
||||
func roleIDFromContext(c *gin.Context) (int, bool) {
|
||||
roleIDVal, ok := c.Get("role_id")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch v := roleIDVal.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case float64:
|
||||
return int(v), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// getPermissionsByRoleID 从 role_permissions 读取某角色的权限;9527 默认拥有全部
|
||||
func getPermissionsByRoleID(ctx context.Context, roleID int) []string {
|
||||
if roleID == models.RoleIDSuperAdmin {
|
||||
return allPermissionKeys()
|
||||
}
|
||||
coll := config.GetDB(config.DBName).Collection("role_permissions")
|
||||
var doc models.RolePermissionsDoc
|
||||
err := coll.FindOne(ctx, bson.M{"role_id": roleID}).Decode(&doc)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return doc.Permissions
|
||||
}
|
||||
|
||||
func allPermissionKeys() []string {
|
||||
keys := make([]string, 0, len(models.AllPermissions))
|
||||
for _, p := range models.AllPermissions {
|
||||
keys = append(keys, p.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// RequirePermission 要求当前用户拥有指定权限(在 AuthRequired 之后使用)
|
||||
func RequirePermission(permission string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
roleID, ok := roleIDFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权限"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if roleID == models.RoleIDSuperAdmin {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
perms := getPermissionsByRoleID(ctx, roleID)
|
||||
for _, p := range perms {
|
||||
if p == permission {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无此操作权限"})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// GetMyPermissions 返回当前用户权限列表(供前端菜单、按钮显隐)
|
||||
func GetMyPermissions(c *gin.Context) {
|
||||
roleID, ok := roleIDFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusOK, gin.H{"permissions": []string{}})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
perms := getPermissionsByRoleID(ctx, roleID)
|
||||
c.JSON(http.StatusOK, gin.H{"permissions": perms})
|
||||
}
|
||||
294
server/handlers/register.go
Normal file
294
server/handlers/register.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
"yh_web/server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
testVerifyCode = "8888" // 测试验证码(未接入短信时使用)
|
||||
codeExpire = 5 * time.Minute // 验证码有效期
|
||||
)
|
||||
|
||||
var (
|
||||
codeStore = make(map[string]codeEntry)
|
||||
codeStoreMu sync.RWMutex
|
||||
)
|
||||
|
||||
type codeEntry struct {
|
||||
Code string
|
||||
ExpireAt time.Time
|
||||
}
|
||||
|
||||
// SendCodeInput 发送验证码请求
|
||||
type SendCodeInput struct {
|
||||
Mobile string `json:"mobile" binding:"required"`
|
||||
}
|
||||
|
||||
// RegisterInput 手机注册请求
|
||||
type RegisterInput struct {
|
||||
Mobile string `json:"mobile" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Username string `json:"username"` // 可选,默认用手机号
|
||||
Email string `json:"email"` // 可选
|
||||
}
|
||||
|
||||
// SendCode 发送验证码(测试阶段:任意手机号输入 8888 即可)
|
||||
func SendCode(c *gin.Context) {
|
||||
var input SendCodeInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入手机号"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidMobile(input.Mobile) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
// 未接入短信平台,使用测试验证码 8888
|
||||
codeStoreMu.Lock()
|
||||
codeStore[input.Mobile] = codeEntry{
|
||||
Code: testVerifyCode,
|
||||
ExpireAt: time.Now().Add(codeExpire),
|
||||
}
|
||||
codeStoreMu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "验证码已发送(测试环境请输入 8888)",
|
||||
"expire": int(codeExpire.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// Register 手机号注册
|
||||
func Register(c *gin.Context) {
|
||||
var input RegisterInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写手机号、验证码和密码"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidMobile(input.Mobile) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(input.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "密码至少6位"})
|
||||
return
|
||||
}
|
||||
|
||||
if input.Email != "" && !isValidEmail(input.Email) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
// 校验验证码
|
||||
codeStoreMu.RLock()
|
||||
entry, ok := codeStore[input.Mobile]
|
||||
codeStoreMu.RUnlock()
|
||||
|
||||
if !ok || entry.Code != input.Code {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(entry.ExpireAt) {
|
||||
codeStoreMu.Lock()
|
||||
delete(codeStore, input.Mobile)
|
||||
codeStoreMu.Unlock()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码已过期,请重新获取"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除已用验证码
|
||||
codeStoreMu.Lock()
|
||||
delete(codeStore, input.Mobile)
|
||||
codeStoreMu.Unlock()
|
||||
|
||||
username := input.Username
|
||||
if username == "" {
|
||||
username = input.Mobile
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("users")
|
||||
|
||||
// 检查手机号是否已注册
|
||||
var exist models.User
|
||||
err := coll.FindOne(ctx, bson.M{"mobile": input.Mobile}).Decode(&exist)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该手机号已注册"})
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
log.Printf("[Register] FindOne mobile error: %v", err)
|
||||
resp := gin.H{"error": "注册失败,请稍后重试"}
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
resp["debug"] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
err = coll.FindOne(ctx, bson.M{"username": username}).Decode(&exist)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
log.Printf("[Register] FindOne username error: %v", err)
|
||||
resp := gin.H{"error": "注册失败,请稍后重试"}
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
resp["debug"] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 若提供了邮箱,检查是否已注册
|
||||
if input.Email != "" {
|
||||
err = coll.FindOne(ctx, bson.M{"email": input.Email}).Decode(&exist)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已注册"})
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
log.Printf("[Register] FindOne email error: %v", err)
|
||||
resp := gin.H{"error": "注册失败,请稍后重试"}
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
resp["debug"] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
doc := bson.M{
|
||||
"username": username,
|
||||
"mobile": input.Mobile,
|
||||
"password": utils.HashPassword(input.Password),
|
||||
"role": "admin",
|
||||
"role_id": models.RoleIDSuperAdmin,
|
||||
}
|
||||
if input.Email != "" {
|
||||
doc["email"] = input.Email
|
||||
}
|
||||
|
||||
_, err = coll.InsertOne(ctx, doc)
|
||||
if err != nil {
|
||||
log.Printf("[Register] InsertOne error: %v", err)
|
||||
resp := gin.H{"error": "注册失败,请稍后重试"}
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
resp["debug"] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "注册成功",
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPasswordInput 密码找回请求
|
||||
type ResetPasswordInput struct {
|
||||
Mobile string `json:"mobile" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
// ResetPassword 密码找回(手机号+验证码)
|
||||
func ResetPassword(c *gin.Context) {
|
||||
var input ResetPasswordInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写手机号、验证码和新密码"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidMobile(input.Mobile) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(input.NewPassword) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码至少6位"})
|
||||
return
|
||||
}
|
||||
|
||||
// 校验验证码
|
||||
codeStoreMu.RLock()
|
||||
entry, ok := codeStore[input.Mobile]
|
||||
codeStoreMu.RUnlock()
|
||||
|
||||
if !ok || entry.Code != input.Code {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(entry.ExpireAt) {
|
||||
codeStoreMu.Lock()
|
||||
delete(codeStore, input.Mobile)
|
||||
codeStoreMu.Unlock()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码已过期,请重新获取"})
|
||||
return
|
||||
}
|
||||
|
||||
codeStoreMu.Lock()
|
||||
delete(codeStore, input.Mobile)
|
||||
codeStoreMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("users")
|
||||
var user models.User
|
||||
err := coll.FindOne(ctx, bson.M{"mobile": input.Mobile}).Decode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "该手机号未注册"})
|
||||
return
|
||||
}
|
||||
log.Printf("[ResetPassword] FindOne error: %v", err)
|
||||
resp := gin.H{"error": "操作失败"}
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
resp["debug"] = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = coll.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": bson.M{"password": utils.HashPassword(input.NewPassword)}})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码已重置,请登录"})
|
||||
}
|
||||
|
||||
func isValidMobile(mobile string) bool {
|
||||
// 简单校验:11位数字,1开头
|
||||
matched, _ := regexp.MatchString(`^1\d{10}$`, mobile)
|
||||
return matched
|
||||
}
|
||||
|
||||
func isValidEmail(email string) bool {
|
||||
// 简单邮箱格式校验
|
||||
matched, _ := regexp.MatchString(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, email)
|
||||
return matched
|
||||
}
|
||||
109
server/handlers/role_permission.go
Normal file
109
server/handlers/role_permission.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 预定义角色(与 users.role_id 对应)
|
||||
var roleMeta = []struct {
|
||||
RoleID int `json:"role_id"`
|
||||
RoleName string `json:"role_name"`
|
||||
}{
|
||||
{models.RoleIDSuperAdmin, "超级管理员"},
|
||||
{models.RoleIDSuperUser, "超级用户"},
|
||||
{models.RoleIDUser, "普通用户"},
|
||||
}
|
||||
|
||||
// GetRolePermissionsList 返回所有角色及其权限(用于角色权限管理页)
|
||||
func GetRolePermissionsList(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("role_permissions")
|
||||
cursor, err := coll.Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var docs []models.RolePermissionsDoc
|
||||
if err = cursor.All(ctx, &docs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
permMap := make(map[int][]string)
|
||||
for _, d := range docs {
|
||||
permMap[d.RoleID] = d.Permissions
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(roleMeta))
|
||||
for _, r := range roleMeta {
|
||||
perms := permMap[r.RoleID]
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
if r.RoleID == models.RoleIDSuperAdmin {
|
||||
perms = allPermissionKeys()
|
||||
}
|
||||
list = append(list, gin.H{
|
||||
"role_id": r.RoleID,
|
||||
"role_name": r.RoleName,
|
||||
"permissions": perms,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"list": list,
|
||||
"all_permissions": models.AllPermissions,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRolePermissionsInput 更新某角色权限
|
||||
type UpdateRolePermissionsInput struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// UpdateRolePermissions 更新指定角色的权限
|
||||
func UpdateRolePermissions(c *gin.Context) {
|
||||
roleIDStr := c.Param("role_id")
|
||||
roleID, err := strconv.Atoi(roleIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 role_id"})
|
||||
return
|
||||
}
|
||||
if roleID == models.RoleIDSuperAdmin {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "超级管理员权限不可修改"})
|
||||
return
|
||||
}
|
||||
|
||||
var input UpdateRolePermissionsInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("role_permissions")
|
||||
filter := bson.M{"role_id": roleID}
|
||||
update := bson.M{"$set": bson.M{"role_id": roleID, "permissions": input.Permissions}}
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err = coll.UpdateOne(ctx, filter, update, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "保存成功", "role_id": roleID, "permissions": input.Permissions})
|
||||
}
|
||||
165
server/handlers/site.go
Normal file
165
server/handlers/site.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetSites 站点列表
|
||||
func GetSites(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("sites")
|
||||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||
cursor, err := coll.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var list []models.Site
|
||||
if err = cursor.All(ctx, &list); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
total, _ := coll.CountDocuments(ctx, bson.M{})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// GetSiteByID 单个站点
|
||||
func GetSiteByID(c *gin.Context) {
|
||||
idStr := c.Param("site_id")
|
||||
oid, err := bson.ObjectIDFromHex(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var site models.Site
|
||||
err = config.GetDB(config.DBName).Collection("sites").FindOne(ctx, bson.M{"_id": oid}).Decode(&site)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, site)
|
||||
}
|
||||
|
||||
// CreateSiteInput 创建站点
|
||||
type CreateSiteInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Domain string `json:"domain"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CreateSite 创建站点
|
||||
func CreateSite(c *gin.Context) {
|
||||
var input CreateSiteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写站点名称"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("sites")
|
||||
doc := bson.M{
|
||||
"name": input.Name,
|
||||
"domain": input.Domain,
|
||||
"description": input.Description,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
res, err := coll.InsertOne(ctx, doc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "message": "创建成功"})
|
||||
}
|
||||
|
||||
// UpdateSiteInput 更新站点
|
||||
type UpdateSiteInput struct {
|
||||
Name *string `json:"name"`
|
||||
Domain *string `json:"domain"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// UpdateSite 更新站点
|
||||
func UpdateSite(c *gin.Context) {
|
||||
idStr := c.Param("site_id")
|
||||
oid, err := bson.ObjectIDFromHex(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input UpdateSiteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{}
|
||||
if input.Name != nil {
|
||||
set["name"] = *input.Name
|
||||
}
|
||||
if input.Domain != nil {
|
||||
set["domain"] = *input.Domain
|
||||
}
|
||||
if input.Description != nil {
|
||||
set["description"] = *input.Description
|
||||
}
|
||||
if len(set) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = config.GetDB(config.DBName).Collection("sites").UpdateOne(ctx, bson.M{"_id": oid}, bson.M{"$set": set})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteSite 删除站点
|
||||
func DeleteSite(c *gin.Context) {
|
||||
idStr := c.Param("site_id")
|
||||
oid, err := bson.ObjectIDFromHex(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db := config.GetDB(config.DBName)
|
||||
_, err = db.Collection("sites").DeleteOne(ctx, bson.M{"_id": oid})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
db.Collection("pages").DeleteMany(ctx, bson.M{"site_id": idStr})
|
||||
db.Collection("site_assets").DeleteMany(ctx, bson.M{"site_id": idStr})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
94
server/handlers/sms_config.go
Normal file
94
server/handlers/sms_config.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const smsConfigDocID = "sms_platform"
|
||||
|
||||
// GetSMSConfig 获取短信平台配置(仅超级用户 role_id=0, role=admin)
|
||||
func GetSMSConfig(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("system_config")
|
||||
var cfg models.SMSConfig
|
||||
err := coll.FindOne(ctx, bson.M{"_id": smsConfigDocID}).Decode(&cfg)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
c.JSON(http.StatusOK, models.SMSConfig{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// SMSConfigUpdateInput 短信配置更新
|
||||
type SMSConfigUpdateInput struct {
|
||||
Provider string `json:"provider"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
SignName string `json:"sign_name"`
|
||||
TemplateID string `json:"template_id"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdateSMSConfig 更新短信平台配置(仅超级用户)
|
||||
func UpdateSMSConfig(c *gin.Context) {
|
||||
var input SMSConfigUpdateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("system_config")
|
||||
update := bson.M{}
|
||||
if input.Provider != "" {
|
||||
update["provider"] = input.Provider
|
||||
}
|
||||
if input.AccessKey != "" {
|
||||
update["access_key"] = input.AccessKey
|
||||
}
|
||||
if input.SecretKey != "" {
|
||||
update["secret_key"] = input.SecretKey
|
||||
}
|
||||
if input.SignName != "" {
|
||||
update["sign_name"] = input.SignName
|
||||
}
|
||||
if input.TemplateID != "" {
|
||||
update["template_id"] = input.TemplateID
|
||||
}
|
||||
if input.Enabled != nil {
|
||||
update["enabled"] = *input.Enabled
|
||||
}
|
||||
|
||||
if len(update) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
|
||||
return
|
||||
}
|
||||
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err := coll.UpdateOne(ctx, bson.M{"_id": smsConfigDocID}, bson.M{"$set": update}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "配置已保存"})
|
||||
}
|
||||
34
server/handlers/stats.go
Normal file
34
server/handlers/stats.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"yh_web/server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetStats(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db := config.GetDB(config.DBName)
|
||||
|
||||
users, _ := db.Collection("users").CountDocuments(ctx, bson.M{})
|
||||
workspaces, _ := db.Collection("workspaces").CountDocuments(ctx, bson.M{})
|
||||
conversations, _ := db.Collection("conversations").CountDocuments(ctx, bson.M{})
|
||||
messages, _ := db.Collection("messages").CountDocuments(ctx, bson.M{})
|
||||
files, _ := db.Collection("files").CountDocuments(ctx, bson.M{})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": users,
|
||||
"workspaces": workspaces,
|
||||
"conversations": conversations,
|
||||
"messages": messages,
|
||||
"files": files,
|
||||
})
|
||||
}
|
||||
234
server/handlers/user.go
Normal file
234
server/handlers/user.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/models"
|
||||
"yh_web/server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetUsers(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("users")
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if p := c.Query("page"); p != "" {
|
||||
if v, err := parseInt(p); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
if v, err := parseInt(ps); err == nil && v > 0 && v <= 100 {
|
||||
pageSize = v
|
||||
}
|
||||
}
|
||||
|
||||
skip := (page - 1) * pageSize
|
||||
opts := options.Find().SetSkip(int64(skip)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "_id", Value: -1}})
|
||||
|
||||
cursor, err := coll.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var users []models.User
|
||||
if err = cursor.All(ctx, &users); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, _ := coll.CountDocuments(ctx, bson.M{})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"list": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
oid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var user models.User
|
||||
err = config.GetDB(config.DBName).Collection("users").FindOne(ctx, bson.M{"_id": oid}).Decode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func CreateUser(c *gin.Context) {
|
||||
var input models.UserCreateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("users")
|
||||
|
||||
// 检查用户名是否已存在
|
||||
var exist models.User
|
||||
if err := coll.FindOne(ctx, bson.M{"username": input.Username}).Decode(&exist); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
|
||||
return
|
||||
}
|
||||
if err := coll.FindOne(ctx, bson.M{"email": input.Email}).Decode(&exist); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if input.Role == "" {
|
||||
input.Role = "user"
|
||||
}
|
||||
roleID := input.RoleID
|
||||
if roleID == 0 {
|
||||
roleID = models.RoleIDUser
|
||||
}
|
||||
|
||||
doc := bson.M{
|
||||
"username": input.Username,
|
||||
"email": input.Email,
|
||||
"password": utils.HashPassword(input.Password),
|
||||
"role": input.Role,
|
||||
"role_id": roleID,
|
||||
"is_beta": input.IsBeta,
|
||||
}
|
||||
if input.TrialStartDate != "" {
|
||||
doc["trial_start_date"] = input.TrialStartDate
|
||||
}
|
||||
if input.TrialEndDate != "" {
|
||||
doc["trial_end_date"] = input.TrialEndDate
|
||||
}
|
||||
if input.LLM != "" {
|
||||
doc["llm"] = input.LLM
|
||||
}
|
||||
|
||||
res, err := coll.InsertOne(ctx, doc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": res.InsertedID, "message": "创建成功"})
|
||||
}
|
||||
|
||||
func UpdateUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
oid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input models.UserUpdateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
update := bson.M{}
|
||||
if input.Username != nil {
|
||||
update["username"] = *input.Username
|
||||
}
|
||||
if input.Email != nil {
|
||||
update["email"] = *input.Email
|
||||
}
|
||||
if input.Password != nil && *input.Password != "" {
|
||||
update["password"] = utils.HashPassword(*input.Password)
|
||||
}
|
||||
if input.Role != nil {
|
||||
update["role"] = *input.Role
|
||||
}
|
||||
if input.IsBeta != nil {
|
||||
update["is_beta"] = *input.IsBeta
|
||||
}
|
||||
if input.TrialStartDate != nil {
|
||||
update["trial_start_date"] = *input.TrialStartDate
|
||||
}
|
||||
if input.TrialEndDate != nil {
|
||||
update["trial_end_date"] = *input.TrialEndDate
|
||||
}
|
||||
if input.LLM != nil {
|
||||
update["llm"] = *input.LLM
|
||||
}
|
||||
if input.RoleID != nil {
|
||||
update["role_id"] = *input.RoleID
|
||||
}
|
||||
|
||||
if len(update) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无有效更新字段"})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := config.GetDB(config.DBName).Collection("users").UpdateOne(ctx, bson.M{"_id": oid}, bson.M{"$set": update})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
func DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
oid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := config.GetDB(config.DBName).Collection("users").DeleteOne(ctx, bson.M{"_id": oid})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if res.DeletedCount == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
64
server/handlers/workspace.go
Normal file
64
server/handlers/workspace.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"yh_web/server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetWorkspaces(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
coll := config.GetDB(config.DBName).Collection("workspaces")
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if p := c.Query("page"); p != "" {
|
||||
if v, err := parseInt(p); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
if v, err := parseInt(ps); err == nil && v > 0 && v <= 100 {
|
||||
pageSize = v
|
||||
}
|
||||
}
|
||||
|
||||
skip := (page - 1) * pageSize
|
||||
opts := options.Find().SetSkip(int64(skip)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "_id", Value: -1}})
|
||||
|
||||
filter := bson.M{}
|
||||
if userID := c.Query("user_id"); userID != "" {
|
||||
filter["user_id"] = userID
|
||||
}
|
||||
|
||||
cursor, err := coll.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var list []map[string]interface{}
|
||||
if err = cursor.All(ctx, &list); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, _ := coll.CountDocuments(ctx, filter)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
218
server/main.go
Normal file
218
server/main.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/handlers"
|
||||
"yh_web/server/middleware"
|
||||
"yh_web/server/models"
|
||||
"yh_web/server/pkg/logger"
|
||||
"yh_web/server/pkg/schema"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// loadEnv 启动时自动加载 .env(在 server 目录或项目根/server 下),不覆盖已存在的环境变量
|
||||
func loadEnv() {
|
||||
wd, _ := os.Getwd()
|
||||
serverDir := wd
|
||||
if !strings.HasSuffix(filepath.Clean(wd), "server") {
|
||||
serverDir = filepath.Join(wd, "server")
|
||||
}
|
||||
envPath := filepath.Clean(filepath.Join(serverDir, ".env"))
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
if err := godotenv.Load(envPath); err == nil {
|
||||
log.Printf("已加载配置: %s", envPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadEnv()
|
||||
|
||||
// 初始化根目录 logs/server(支持从项目根或 server 目录启动)
|
||||
wd, _ := os.Getwd()
|
||||
baseDir := filepath.Join(wd, "logs", "server")
|
||||
if strings.HasSuffix(filepath.Clean(wd), "server") {
|
||||
baseDir = filepath.Join(wd, "..", "logs", "server")
|
||||
}
|
||||
logger.Init(filepath.Clean(baseDir))
|
||||
|
||||
// 连接 MongoDB;URI 从环境变量 MONGODB_URI 读取,默认 mongodb://localhost:27017;SKIP_MONGODB=1 时可跳过
|
||||
if os.Getenv("SKIP_MONGODB") != "1" {
|
||||
mongoURI := os.Getenv("MONGODB_URI")
|
||||
if mongoURI == "" {
|
||||
mongoURI = "mongodb://localhost:27017"
|
||||
}
|
||||
if dbName := os.Getenv("MONGODB_DB"); dbName != "" {
|
||||
config.DBName = dbName
|
||||
}
|
||||
if err := config.ConnectMongoDB(mongoURI); err != nil {
|
||||
logger.Err("main", "MongoDB 连接失败: %v(若仅需健康检查可设置环境变量 SKIP_MONGODB=1 后启动)", err)
|
||||
log.Fatalf("MongoDB 连接失败: %v(若仅需健康检查可设置环境变量 SKIP_MONGODB=1 后启动)", err)
|
||||
}
|
||||
defer config.CloseMongoDB()
|
||||
|
||||
// 启动时获取线上表结构并生成 sql;缺失的集合在线上创建并生成 created_*.sql
|
||||
projectRoot := wd
|
||||
if strings.HasSuffix(filepath.Clean(wd), "server") {
|
||||
projectRoot = filepath.Join(wd, "..")
|
||||
}
|
||||
projectRoot = filepath.Clean(projectRoot)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
if err := schema.Sync(ctx, projectRoot); err != nil {
|
||||
logger.Err("main", "启动时同步表结构失败: %v", err)
|
||||
log.Printf("警告: 启动时同步表结构失败: %v", err)
|
||||
}
|
||||
cancel()
|
||||
} else {
|
||||
logger.Log("main", "已跳过 MongoDB 连接(SKIP_MONGODB=1),仅 /api/health 等不依赖数据库的接口可用")
|
||||
log.Println("已跳过 MongoDB 连接(SKIP_MONGODB=1),仅 /api/health 等不依赖数据库的接口可用")
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(middleware.ErrorLogger())
|
||||
|
||||
// CORS(ALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
|
||||
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
|
||||
r.Use(func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if allowedOriginsEnv != "" {
|
||||
for _, o := range strings.Split(allowedOriginsEnv, ",") {
|
||||
if strings.TrimSpace(o) == origin {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// 未连接 MongoDB 时,/api/admin 下所有接口返回 503(健康检查不受影响)
|
||||
r.Use(func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/admin") && config.MongoClient == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用,数据库未连接"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// 健康检查
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 登录、注册(无需鉴权)
|
||||
r.POST("/api/admin/login", handlers.Login)
|
||||
r.POST("/api/admin/send-code", handlers.SendCode)
|
||||
r.POST("/api/admin/register", handlers.Register)
|
||||
r.POST("/api/admin/reset-password", handlers.ResetPassword)
|
||||
|
||||
// 后台 API 路由组(需鉴权)
|
||||
admin := r.Group("/api/admin")
|
||||
admin.Use(handlers.AuthRequired())
|
||||
{
|
||||
admin.GET("/info", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "admin api"})
|
||||
})
|
||||
admin.GET("/my-permissions", handlers.GetMyPermissions)
|
||||
admin.GET("/db-structure", func(c *gin.Context) {
|
||||
structure, err := config.GetDBStructure()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, structure)
|
||||
})
|
||||
admin.GET("/stats", handlers.GetStats)
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
|
||||
admin.GET("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.GetUserByID)
|
||||
admin.POST("/users", handlers.RequirePermission(models.PermUserManage), handlers.CreateUser)
|
||||
admin.PUT("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.UpdateUser)
|
||||
admin.DELETE("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.DeleteUser)
|
||||
|
||||
// 工作空间
|
||||
admin.GET("/workspaces", handlers.RequirePermission(models.PermWorkspaceManage), handlers.GetWorkspaces)
|
||||
|
||||
// 对话
|
||||
admin.GET("/conversations", handlers.RequirePermission(models.PermConversationManage), handlers.GetConversations)
|
||||
|
||||
// 站点管理(带子路径的路由放前面;与 :site_id 统一,避免 Gin 路由冲突)
|
||||
admin.GET("/sites/:site_id/homepage/download", handlers.RequirePermission(models.PermHomepageEdit), handlers.DownloadHomepage)
|
||||
admin.GET("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.GetHomepage)
|
||||
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage)
|
||||
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
|
||||
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
|
||||
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
||||
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
||||
admin.GET("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.GetSiteByID)
|
||||
admin.POST("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.CreateSite)
|
||||
admin.PUT("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateSite)
|
||||
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
|
||||
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
|
||||
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
|
||||
|
||||
// 角色权限管理
|
||||
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
|
||||
admin.PUT("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.UpdateRolePermissions)
|
||||
|
||||
// 网页管理(按站点)
|
||||
admin.GET("/pages", handlers.RequirePermission(models.PermPageManage), handlers.GetPages)
|
||||
admin.GET("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.GetPageByID)
|
||||
admin.POST("/pages", handlers.RequirePermission(models.PermPageManage), handlers.CreatePage)
|
||||
admin.PUT("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.UpdatePage)
|
||||
admin.DELETE("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.DeletePage)
|
||||
|
||||
// 短信平台配置
|
||||
smsConfig := admin.Group("/sms-config")
|
||||
smsConfig.Use(handlers.RequirePermission(models.PermSMSConfig))
|
||||
{
|
||||
smsConfig.GET("", handlers.GetSMSConfig)
|
||||
smsConfig.PUT("", handlers.UpdateSMSConfig)
|
||||
}
|
||||
|
||||
// 支付配置(微信、支付宝)
|
||||
paymentConfig := admin.Group("/payment-config")
|
||||
paymentConfig.Use(handlers.RequirePermission(models.PermPaymentConfig))
|
||||
{
|
||||
paymentConfig.GET("", handlers.GetPaymentConfig)
|
||||
paymentConfig.PUT("", handlers.UpdatePaymentConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// 官网站点首页(前台,无需鉴权)
|
||||
r.GET("/api/web/homepage", handlers.GetWebHomepage)
|
||||
|
||||
// 前台 API 路由组
|
||||
web := r.Group("/api/web")
|
||||
{
|
||||
web.GET("/info", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "web api"})
|
||||
})
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
r.Run(":" + port)
|
||||
}
|
||||
39
server/middleware/logger.go
Normal file
39
server/middleware/logger.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"yh_web/server/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// responseBodyWriter 包装 ResponseWriter 以捕获响应体
|
||||
type responseBodyWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w responseBodyWriter) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// ErrorLogger 在 4xx/5xx 时记录响应体中的错误信息
|
||||
func ErrorLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
w := &responseBodyWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
c.Writer = w
|
||||
|
||||
c.Next()
|
||||
|
||||
if w.Status() >= 400 {
|
||||
if body := w.body.String(); body != "" {
|
||||
logger.Err("middleware/logger", "[%d] %s %s | body: %s", w.Status(), c.Request.Method, c.Request.URL.Path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
server/models/conversation.go
Normal file
12
server/models/conversation.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
type Conversation struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
Title string `bson:"title" json:"title"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
|
||||
CreatedAt string `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt string `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
24
server/models/payment.go
Normal file
24
server/models/payment.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
// WechatPayConfig 微信支付配置
|
||||
type WechatPayConfig struct {
|
||||
AppID string `bson:"app_id" json:"app_id"`
|
||||
MchID string `bson:"mch_id" json:"mch_id"`
|
||||
APIKey string `bson:"api_key" json:"api_key"`
|
||||
APIKeyV3 string `bson:"api_key_v3" json:"api_key_v3"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
// AlipayConfig 支付宝配置
|
||||
type AlipayConfig struct {
|
||||
AppID string `bson:"app_id" json:"app_id"`
|
||||
PrivateKey string `bson:"private_key" json:"private_key"`
|
||||
AlipayPublicKey string `bson:"alipay_public_key" json:"alipay_public_key"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
// PaymentConfig 支付配置(微信+支付宝)
|
||||
type PaymentConfig struct {
|
||||
Wechat *WechatPayConfig `bson:"wechat,omitempty" json:"wechat,omitempty"`
|
||||
Alipay *AlipayConfig `bson:"alipay,omitempty" json:"alipay,omitempty"`
|
||||
}
|
||||
38
server/models/permission.go
Normal file
38
server/models/permission.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
// 权限码(与前端、路由 meta.permission 一致)
|
||||
const (
|
||||
PermSiteManage = "site:manage"
|
||||
PermHomepageEdit = "homepage:edit"
|
||||
PermPageManage = "page:manage"
|
||||
PermModuleUpload = "module:upload"
|
||||
PermUserManage = "user:manage"
|
||||
PermWorkspaceManage = "workspace:manage"
|
||||
PermConversationManage = "conversation:manage"
|
||||
PermSMSConfig = "sms_config"
|
||||
PermPaymentConfig = "payment_config"
|
||||
PermRolePermission = "role:permission" // 角色权限管理
|
||||
)
|
||||
|
||||
// AllPermissions 所有可配置权限(用于角色权限管理页)
|
||||
var AllPermissions = []struct {
|
||||
Key string
|
||||
Name string
|
||||
}{
|
||||
{PermSiteManage, "站点管理"},
|
||||
{PermHomepageEdit, "首页编辑"},
|
||||
{PermPageManage, "网页管理"},
|
||||
{PermModuleUpload, "功能模块上传"},
|
||||
{PermUserManage, "用户管理"},
|
||||
{PermWorkspaceManage, "工作空间"},
|
||||
{PermConversationManage, "对话管理"},
|
||||
{PermSMSConfig, "短信配置"},
|
||||
{PermPaymentConfig, "支付配置"},
|
||||
{PermRolePermission, "角色权限管理"},
|
||||
}
|
||||
|
||||
// RolePermissionsDoc MongoDB 文档:角色 ID -> 权限列表
|
||||
type RolePermissionsDoc struct {
|
||||
RoleID int `bson:"role_id" json:"role_id"`
|
||||
Permissions []string `bson:"permissions" json:"permissions"`
|
||||
}
|
||||
66
server/models/site.go
Normal file
66
server/models/site.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package models
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
// Site 站点
|
||||
type Site struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Domain string `bson:"domain" json:"domain"`
|
||||
Description string `bson:"description,omitempty" json:"description,omitempty"`
|
||||
CreatedAt string `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Page 网页(属于某站点)
|
||||
type Page struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
SiteID string `bson:"site_id" json:"site_id"`
|
||||
Slug string `bson:"slug" json:"slug"` // index, about, ...
|
||||
Title string `bson:"title" json:"title"`
|
||||
Type string `bson:"type" json:"type"` // homepage, page
|
||||
Content string `bson:"content" json:"content"` // HTML 或 JSON 字符串
|
||||
UpdatedAt string `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// HomepageData 首页可编辑数据(与 yuheng-download-space 对应)
|
||||
type HomepageData struct {
|
||||
LogoText string `json:"logo_text"` // YUHENG ONE
|
||||
NavLinks []NavLink `json:"nav_links"` // MISSION, DOWNLOAD, CONTACT
|
||||
Title string `json:"title"` // 宇恒一号
|
||||
Subtitle string `json:"subtitle"` // INTERSTELLAR EXPLORER EDITION
|
||||
Description string `json:"description"` // 跨越星际的智能伙伴...
|
||||
DownloadText string `json:"download_text"` // START EXPLORING
|
||||
DownloadURL string `json:"download_url"` // #
|
||||
Platforms []PlatformItem `json:"platforms"` // Windows, macOS, ...
|
||||
Version string `json:"version"` // VERSION 3.2.1
|
||||
LaunchYear string `json:"launch_year"` // LAUNCH: 2024
|
||||
BadgeText string `json:"badge_text"` // FREE ACCESS
|
||||
Features []FeatureItem `json:"features"` // 星际导航等
|
||||
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE
|
||||
}
|
||||
|
||||
type NavLink struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type PlatformItem struct {
|
||||
Name string `json:"name"` // WINDOWS, MACOS, ...
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type FeatureItem struct {
|
||||
Title string `json:"title"`
|
||||
Desc string `json:"desc"`
|
||||
}
|
||||
|
||||
// SiteAsset 站点功能模块/上传文件
|
||||
type SiteAsset struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
SiteID string `bson:"site_id" json:"site_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
FilePath string `bson:"file_path" json:"file_path"` // 相对路径
|
||||
Size int64 `bson:"size" json:"size"`
|
||||
ContentType string `bson:"content_type" json:"content_type"`
|
||||
CreatedAt string `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
11
server/models/sms.go
Normal file
11
server/models/sms.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
// SMSConfig 短信平台配置(仅超级用户 role_id=0, role=admin 可配置)
|
||||
type SMSConfig struct {
|
||||
Provider string `bson:"provider" json:"provider"` // 服务商:aliyun/tencent/...
|
||||
AccessKey string `bson:"access_key" json:"access_key"` // AccessKey
|
||||
SecretKey string `bson:"secret_key" json:"secret_key"` // SecretKey
|
||||
SignName string `bson:"sign_name" json:"sign_name"` // 签名
|
||||
TemplateID string `bson:"template_id" json:"template_id"` // 模板ID
|
||||
Enabled bool `bson:"enabled" json:"enabled"` // 是否已启用
|
||||
}
|
||||
77
server/models/user.go
Normal file
77
server/models/user.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleIDSuperAdmin = 9527 // 超级管理员(后台登录)
|
||||
RoleIDSuperUser = 0 // 超级用户(role_id=0 且 role=admin,可配置短信平台)
|
||||
RoleIDUser = 1 // 普通用户
|
||||
)
|
||||
|
||||
// FlexDate 可解码 BSON DateTime 或 string,统一以 string 输出(兼容库中既有日期类型又有字符串的情况)
|
||||
type FlexDate string
|
||||
|
||||
func (d *FlexDate) UnmarshalBSONValue(typ byte, data []byte) error {
|
||||
rv := bson.RawValue{Type: bson.Type(typ), Value: data}
|
||||
switch bson.Type(typ) {
|
||||
case bson.TypeDateTime:
|
||||
if dt, ok := rv.DateTimeOK(); ok {
|
||||
*d = FlexDate(time.UnixMilli(dt).Format("2006-01-02"))
|
||||
}
|
||||
return nil
|
||||
case bson.TypeString:
|
||||
if s, ok := rv.StringValueOK(); ok {
|
||||
*d = FlexDate(s)
|
||||
}
|
||||
return nil
|
||||
case bson.TypeNull, bson.TypeUndefined:
|
||||
*d = ""
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("FlexDate: cannot decode type %v", typ)
|
||||
}
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||
Username string `bson:"username" json:"username"`
|
||||
Mobile string `bson:"mobile,omitempty" json:"mobile,omitempty"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
Password string `bson:"password" json:"-"` // 不返回给前端
|
||||
Role string `bson:"role" json:"role"`
|
||||
RoleID int `bson:"role_id,omitempty" json:"role_id"` // 9527=超级管理员 1=普通用户
|
||||
IsBeta bool `bson:"is_beta,omitempty" json:"is_beta"`
|
||||
TrialStartDate FlexDate `bson:"trial_start_date,omitempty" json:"trial_start_date,omitempty"`
|
||||
TrialEndDate FlexDate `bson:"trial_end_date,omitempty" json:"trial_end_date,omitempty"`
|
||||
LastLogin string `bson:"lastLogin,omitempty" json:"last_login,omitempty"`
|
||||
LLM string `bson:"llm,omitempty" json:"llm,omitempty"`
|
||||
}
|
||||
|
||||
type UserCreateInput struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
RoleID int `json:"role_id"` // 9527=超级管理员 1=普通用户
|
||||
IsBeta bool `json:"is_beta"`
|
||||
TrialStartDate string `json:"trial_start_date"`
|
||||
TrialEndDate string `json:"trial_end_date"`
|
||||
LLM string `json:"llm"`
|
||||
}
|
||||
|
||||
type UserUpdateInput struct {
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"` // 若提供则更新
|
||||
Role *string `json:"role"`
|
||||
RoleID *int `json:"role_id"`
|
||||
IsBeta *bool `json:"is_beta"`
|
||||
TrialStartDate *string `json:"trial_start_date"`
|
||||
TrialEndDate *string `json:"trial_end_date"`
|
||||
LLM *string `json:"llm"`
|
||||
}
|
||||
10
server/models/workspace.go
Normal file
10
server/models/workspace.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
type Workspace struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
CreatedAt string `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
66
server/pkg/logger/logger.go
Normal file
66
server/pkg/logger/logger.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
baseDir string
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// Init 初始化日志根目录(例如 logs/server),应在 main 中调用
|
||||
func Init(dir string) {
|
||||
baseDir = filepath.Clean(dir)
|
||||
_ = os.MkdirAll(baseDir, 0755)
|
||||
}
|
||||
|
||||
// pathFile 返回 path 对应的 .log 或 .err 的完整路径;path 如 "main" 或 "handlers/auth"
|
||||
func pathFile(path, ext string) string {
|
||||
// 安全化:只保留路径分隔符和字母数字
|
||||
clean := filepath.Clean(path)
|
||||
if clean == "" || clean == "." {
|
||||
clean = "main"
|
||||
}
|
||||
full := filepath.Join(baseDir, clean+ext)
|
||||
dir := filepath.Dir(full)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
return full
|
||||
}
|
||||
|
||||
func appendLine(fpath, line string) {
|
||||
if baseDir == "" || fpath == "" {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
f, err := os.OpenFile(fpath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = f.WriteString(line)
|
||||
}
|
||||
|
||||
func line(level, format string, a ...interface{}) string {
|
||||
return fmt.Sprintf("%s [%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), level, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Log 写普通日志:写入 log.log(简单总日志)和 path 对应的 xxx.log(按路径的详细日志)
|
||||
func Log(path, format string, a ...interface{}) {
|
||||
msg := line("INFO", format, a...)
|
||||
appendLine(filepath.Join(baseDir, "log.log"), msg)
|
||||
appendLine(pathFile(path, ".log"), msg)
|
||||
}
|
||||
|
||||
// Err 写报错:写入 path 对应的 xxx.err(仅报错)、xxx.log 和 log.log
|
||||
func Err(path, format string, a ...interface{}) {
|
||||
msg := line("ERROR", format, a...)
|
||||
appendLine(pathFile(path, ".err"), msg)
|
||||
appendLine(pathFile(path, ".log"), msg)
|
||||
appendLine(filepath.Join(baseDir, "log.log"), msg)
|
||||
}
|
||||
203
server/pkg/schema/sync.go
Normal file
203
server/pkg/schema/sync.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yh_web/server/config"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// 应用需要的集合及索引(与 MongoDB/create_collections.js、sql/init.sql 一致)
|
||||
var requiredCollections = map[string][]indexSpec{
|
||||
"sites": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
|
||||
"pages": {{Keys: bson.D{{Key: "site_id", Value: 1}, {Key: "slug", Value: 1}}, Name: "idx_site_slug", Unique: true}},
|
||||
"site_assets": {{Keys: bson.D{{Key: "site_id", Value: 1}}, Name: "idx_site_id"}},
|
||||
"users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}, {Keys: bson.D{{Key: "mobile", Value: 1}}, Name: "idx_mobile", Sparse: true}},
|
||||
"workspaces": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
|
||||
"conversations": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}, {Keys: bson.D{{Key: "workspace_id", Value: 1}}, Name: "idx_workspace_id"}},
|
||||
"messages": {{Keys: bson.D{{Key: "conversation_id", Value: 1}}, Name: "idx_conversation_id"}},
|
||||
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
|
||||
"system_config": {},
|
||||
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
||||
}
|
||||
|
||||
type indexSpec struct {
|
||||
Keys bson.D
|
||||
Name string
|
||||
Unique bool
|
||||
Sparse bool
|
||||
}
|
||||
|
||||
// 每个集合对应的单表 SQL DDL(仅 CREATE TABLE 段落,用于 created_*.sql);反引号用 \x60 表示
|
||||
var tableDDL = map[string]string{
|
||||
"sites": "CREATE TABLE IF NOT EXISTS \x60sites\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键,与 MongoDB ObjectID 字符串一致',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '站点名称',\n \x60domain\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '域名',\n \x60description\x60 TEXT COMMENT '描述',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点表';",
|
||||
"pages": "CREATE TABLE IF NOT EXISTS \x60pages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60site_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',\n \x60slug\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '路径标识 index, about, ...',\n \x60title\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',\n \x60type\x60 VARCHAR(32) NOT NULL DEFAULT 'page' COMMENT '类型 homepage, page',\n \x60content\x60 LONGTEXT COMMENT 'HTML 或 JSON 字符串',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_site_slug\x60 (\x60site_id\x60, \x60slug\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='网页表';",
|
||||
"site_assets": "CREATE TABLE IF NOT EXISTS \x60site_assets\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60site_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '站点ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名/显示名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '相对路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60content_type\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_site_id\x60 (\x60site_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点资源表';",
|
||||
"users": "CREATE TABLE IF NOT EXISTS \x60users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',\n \x60mobile\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',\n \x60email\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',\n \x60password\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60role\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '角色名',\n \x60role_id\x60 INT NOT NULL DEFAULT 1 COMMENT '9527=超级管理员 1=普通用户',\n \x60is_beta\x60 TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否体验用户',\n \x60trial_start_date\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用开始日期',\n \x60trial_end_date\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT '试用结束日期',\n \x60last_login\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最后登录时间',\n \x60llm\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'LLM 配置',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60),\n KEY \x60idx_mobile\x60 (\x60mobile\x60),\n KEY \x60idx_email\x60 (\x60email\x60),\n KEY \x60idx_role_id\x60 (\x60role_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';",
|
||||
"workspaces": "CREATE TABLE IF NOT EXISTS \x60workspaces\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '名称',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作空间表';",
|
||||
"conversations": "CREATE TABLE IF NOT EXISTS \x60conversations\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60title\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '标题',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60workspace_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '工作空间ID',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60),\n KEY \x60idx_workspace_id\x60 (\x60workspace_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='对话表';",
|
||||
"messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';",
|
||||
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
|
||||
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
|
||||
}
|
||||
|
||||
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
||||
type CollectionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
Indexes []string `json:"indexes"` // 索引名或 key 描述
|
||||
}
|
||||
|
||||
// Sync 连接 MongoDB 后调用:获取线上表结构并生成 sql;缺失的集合在线上创建,并生成本次创建的 sql
|
||||
// projectRoot 为项目根目录(sql/ 所在目录)
|
||||
func Sync(ctx context.Context, projectRoot string) error {
|
||||
db := config.GetDB(config.DBName)
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDir := filepath.Join(projectRoot, "sql")
|
||||
_ = os.MkdirAll(sqlDir, 0755)
|
||||
|
||||
// 1. 获取当前已有集合
|
||||
existing, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("列出集合失败: %w", err)
|
||||
}
|
||||
existingSet := make(map[string]bool)
|
||||
for _, n := range existing {
|
||||
existingSet[n] = true
|
||||
}
|
||||
|
||||
// 2. 缺失的集合在线上创建(集合 + 索引)
|
||||
var created []string
|
||||
for collName, idxSpecs := range requiredCollections {
|
||||
if !existingSet[collName] {
|
||||
if err := db.CreateCollection(ctx, collName); err != nil {
|
||||
return fmt.Errorf("创建集合 %s 失败: %w", collName, err)
|
||||
}
|
||||
created = append(created, collName)
|
||||
coll := db.Collection(collName)
|
||||
for _, spec := range idxSpecs {
|
||||
opts := options.Index().SetName(spec.Name)
|
||||
if spec.Unique {
|
||||
opts.SetUnique(true)
|
||||
}
|
||||
if spec.Sparse {
|
||||
opts.SetSparse(true)
|
||||
}
|
||||
_, _ = coll.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: spec.Keys, Options: opts})
|
||||
}
|
||||
existingSet[collName] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(created)
|
||||
|
||||
// 3. 拉取线上结构:每个集合的名称、文档数、索引列表
|
||||
allNames := make([]string, 0, len(existingSet))
|
||||
for n := range existingSet {
|
||||
allNames = append(allNames, n)
|
||||
}
|
||||
sort.Strings(allNames)
|
||||
|
||||
infos := make([]CollectionInfo, 0, len(allNames))
|
||||
for _, name := range allNames {
|
||||
coll := db.Collection(name)
|
||||
count, _ := coll.CountDocuments(ctx, bson.M{})
|
||||
indexLines := listIndexes(ctx, coll)
|
||||
infos = append(infos, CollectionInfo{Name: name, Count: count, Indexes: indexLines})
|
||||
}
|
||||
|
||||
ts := time.Now().Format("20060102_150405")
|
||||
|
||||
// 4. 生成「线上表结构」的 sql 文件
|
||||
if err := writeOnlineSchemaSQL(sqlDir, config.DBName, ts, infos); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 若有本次创建的集合,生成「需要创建的 sql」并在本地写入(线上已通过上面 CreateCollection 创建)
|
||||
if len(created) > 0 {
|
||||
if err := writeCreatedSQL(sqlDir, ts, created); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listIndexes(ctx context.Context, coll *mongo.Collection) []string {
|
||||
cursor, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var lines []string
|
||||
for cursor.Next(ctx) {
|
||||
var spec bson.M
|
||||
if err := cursor.Decode(&spec); err != nil {
|
||||
continue
|
||||
}
|
||||
name, _ := spec["name"].(string)
|
||||
key, _ := spec["key"].(bson.M)
|
||||
if name != "" {
|
||||
lines = append(lines, fmt.Sprintf("%s: %v", name, key))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func writeOnlineSchemaSQL(sqlDir, dbName, ts string, infos []CollectionInfo) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("-- 线上 MongoDB 表结构快照(对应数据库: " + dbName + ")\n")
|
||||
b.WriteString("-- 生成时间: " + time.Now().Format("2006-01-02 15:04:05") + "\n")
|
||||
b.WriteString("-- 以下为集合与索引说明;等效 MySQL 建表见 sql/init.sql\n\n")
|
||||
b.WriteString("SET NAMES utf8mb4;\n\n")
|
||||
|
||||
for _, info := range infos {
|
||||
b.WriteString("-- -------------------------------\n")
|
||||
b.WriteString("-- 集合: " + info.Name + " (文档数: " + fmt.Sprintf("%d", info.Count) + ")\n")
|
||||
b.WriteString("-- -------------------------------\n")
|
||||
for _, idx := range info.Indexes {
|
||||
b.WriteString("-- 索引: " + idx + "\n")
|
||||
}
|
||||
if len(info.Indexes) == 0 {
|
||||
b.WriteString("-- 索引: _id\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
// 附上等效 CREATE TABLE(便于对照)
|
||||
if ddl, ok := tableDDL[info.Name]; ok {
|
||||
b.WriteString(ddl + "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
fpath := filepath.Join(sqlDir, "online_schema_"+ts+".sql")
|
||||
return os.WriteFile(fpath, []byte(b.String()), 0644)
|
||||
}
|
||||
|
||||
func writeCreatedSQL(sqlDir, ts string, created []string) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("-- 本次启动时在线上缺失并已创建的集合,对应 SQL 建表(MySQL 等效)\n")
|
||||
b.WriteString("-- 生成时间: " + time.Now().Format("2006-01-02 15:04:05") + "\n")
|
||||
b.WriteString("-- 线上 MongoDB 已通过 CreateCollection 创建;本文件供留档与 SQL 环境对照。\n\n")
|
||||
b.WriteString("SET NAMES utf8mb4;\n\n")
|
||||
|
||||
for _, name := range created {
|
||||
if ddl, ok := tableDDL[name]; ok {
|
||||
b.WriteString("-- " + name + "\n")
|
||||
b.WriteString(ddl + "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
fpath := filepath.Join(sqlDir, "created_"+ts+".sql")
|
||||
return os.WriteFile(fpath, []byte(b.String()), 0644)
|
||||
}
|
||||
74
server/scripts/inspect_db.go
Normal file
74
server/scripts/inspect_db.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// 独立运行:go run scripts/inspect_db.go
|
||||
// 查看 MongoDB 数据结构(需先建立 SSH 隧道)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func main() {
|
||||
uri := "mongodb://localhost:27017"
|
||||
client, err := mongo.Connect(options.Client().ApplyURI(uri))
|
||||
if err != nil {
|
||||
log.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
defer client.Disconnect(context.TODO())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 验证连接
|
||||
var result bson.M
|
||||
if err = client.Database("admin").RunCommand(ctx, bson.D{{Key: "ping", Value: 1}}).Decode(&result); err != nil {
|
||||
log.Fatalf("Ping 失败: %v", err)
|
||||
}
|
||||
fmt.Println("MongoDB 连接成功\n")
|
||||
|
||||
// 列出数据库(排除系统库)
|
||||
dbs, err := client.ListDatabaseNames(ctx, bson.M{
|
||||
"name": bson.M{"$nin": []string{"admin", "config", "local"}},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("列出数据库失败: %v", err)
|
||||
}
|
||||
|
||||
output := map[string]interface{}{"databases": []interface{}{}}
|
||||
|
||||
for _, dbName := range dbs {
|
||||
db := client.Database(dbName)
|
||||
colls, _ := db.ListCollectionNames(ctx, bson.M{})
|
||||
|
||||
dbInfo := map[string]interface{}{
|
||||
"name": dbName,
|
||||
"collections": []interface{}{},
|
||||
}
|
||||
|
||||
for _, collName := range colls {
|
||||
coll := db.Collection(collName)
|
||||
count, _ := coll.CountDocuments(ctx, bson.M{})
|
||||
|
||||
var sample bson.M
|
||||
_ = coll.FindOne(ctx, bson.M{}).Decode(&sample)
|
||||
|
||||
dbInfo["collections"] = append(dbInfo["collections"].([]interface{}), map[string]interface{}{
|
||||
"name": collName,
|
||||
"count": count,
|
||||
"sample": sample,
|
||||
})
|
||||
}
|
||||
|
||||
output["databases"] = append(output["databases"].([]interface{}), dbInfo)
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.MarshalIndent(output, "", " ")
|
||||
fmt.Println(string(jsonBytes))
|
||||
}
|
||||
13
server/scripts/start-ssh-tunnel.bat
Normal file
13
server/scripts/start-ssh-tunnel.bat
Normal file
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
REM MongoDB SSH 穿透 - 将远程 27017 映射到本地 27017
|
||||
REM 运行此脚本后再启动 server,即可连接远程 MongoDB
|
||||
|
||||
echo 正在建立 SSH 隧道...
|
||||
echo 远程: www.yuxindazhineng.com:27017 -^> 本地: localhost:27017
|
||||
echo.
|
||||
echo 保持此窗口打开,隧道有效。关闭窗口即断开。
|
||||
echo.
|
||||
|
||||
ssh -p 2223 -L 27017:localhost:27017 yxd@www.yuxindazhineng.com
|
||||
|
||||
pause
|
||||
1
server/uploads/.gitkeep
Normal file
1
server/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
12
server/utils/password.go
Normal file
12
server/utils/password.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// HashPassword 使用 SHA-256 哈希密码(与 Python 实现一致)
|
||||
func HashPassword(password string) string {
|
||||
h := sha256.Sum256([]byte(password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
Reference in New Issue
Block a user