宇恒一号官网

This commit is contained in:
whm
2026-03-17 00:59:32 +08:00
commit eb56519df7
105 changed files with 10783 additions and 0 deletions

45
server/.air.toml Normal file
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,4 @@
package config
// DBName 数据库名,可由环境变量 MONGODB_DB 覆盖
var DBName = "yxd-agent-testing"

56
server/config/database.go Normal file
View 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 连接已关闭")
}
}

View 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
View 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
View 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
View 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
View 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()
}
}

View 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,
})
}

View 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
View 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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
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>
`

View 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": "删除成功"})
}

View 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
View 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": "删除成功"})
}

View 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": "配置已保存"})
}

View 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_idJWT 等可能解码为 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
View 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
}

View 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
View 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": "删除成功"})
}

View 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
View 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
View 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": "删除成功"})
}

View 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
View 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))
// 连接 MongoDBURI 从环境变量 MONGODB_URI 读取,默认 mongodb://localhost:27017SKIP_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())
// CORSALLOWED_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)
}

View 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)
}
}
}
}

View 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
View 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"`
}

View 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
View 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
View 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
View 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"`
}

View 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"`
}

View 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
View 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)
}

View 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))
}

View 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
View File

@@ -0,0 +1 @@

12
server/utils/password.go Normal file
View 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[:])
}