first commit

This commit is contained in:
Awuqing
2026-03-17 13:29:09 +08:00
commit eadd3f8961
219 changed files with 22394 additions and 0 deletions

BIN
server/.DS_Store vendored Normal file

Binary file not shown.

14
server/Makefile Normal file
View File

@@ -0,0 +1,14 @@
APP_NAME=backupx
BUILD_DIR=./bin
.PHONY: build run test
build:
mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
run:
go run ./cmd/backupx
test:
go test ./...

View File

@@ -0,0 +1,50 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"backupx/server/internal/app"
"backupx/server/internal/config"
)
var version = "dev"
func main() {
var configPath string
var showVersion bool
flag.StringVar(&configPath, "config", "", "path to config file")
flag.BoolVar(&showVersion, "version", false, "print version")
flag.Parse()
if showVersion {
fmt.Println(version)
return
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "load config: %v\n", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
application, err := app.New(ctx, cfg, version)
if err != nil {
fmt.Fprintf(os.Stderr, "bootstrap app: %v\n", err)
os.Exit(1)
}
defer application.Close()
if err := application.Run(ctx); err != nil {
application.Logger().Error("application exited with error", app.ErrorField(err))
os.Exit(1)
}
}

View File

@@ -0,0 +1,24 @@
# config.yaml
server:
host: "0.0.0.0"
port: 8340
mode: "release" # debug | release
database:
path: "./data/backupx.db" # SQLite 数据库路径
security:
jwt_secret: "" # 留空则自动生成
jwt_expire: "24h"
encryption_key: "" # AES 加密密钥,留空自动生成
backup:
temp_dir: "./data/tmp/backupx" # 临时文件目录
max_concurrent: 2 # 最大并发备份数
log:
level: "info" # debug | info | warn | error
file: "./data/backupx.log"
max_size: 100 # MB
max_backups: 3
max_age: 30 # 天

96
server/go.mod Normal file
View File

@@ -0,0 +1,96 @@
module backupx/server
go 1.25.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.3
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0
github.com/studio-b12/gowebdav v0.12.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.33.0
golang.org/x/oauth2 v0.25.0
google.golang.org/api v0.215.0
gorm.io/gorm v1.25.12
)
require (
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
google.golang.org/grpc v1.67.3 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

223
server/go.sum Normal file
View File

@@ -0,0 +1,223 @@
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
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.5.0/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/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8=
google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

188
server/internal/app/app.go Normal file
View File

@@ -0,0 +1,188 @@
package app
import (
"context"
"errors"
"fmt"
stdhttp "net/http"
"time"
"backupx/server/internal/backup"
backupretention "backupx/server/internal/backup/retention"
"backupx/server/internal/config"
"backupx/server/internal/database"
aphttp "backupx/server/internal/http"
"backupx/server/internal/logger"
"backupx/server/internal/notify"
"backupx/server/internal/repository"
"backupx/server/internal/scheduler"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/internal/storage/googledrive"
"backupx/server/internal/storage/localdisk"
storageAliyun "backupx/server/internal/storage/aliyun"
storageTencent "backupx/server/internal/storage/tencent"
storageQiniu "backupx/server/internal/storage/qiniu"
storageS3 "backupx/server/internal/storage/s3"
storageWebDAV "backupx/server/internal/storage/webdav"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Application struct {
cfg config.Config
version string
logger *zap.Logger
db *gorm.DB
httpServer *stdhttp.Server
scheduler *scheduler.Service
}
func New(ctx context.Context, cfg config.Config, version string) (*Application, error) {
appLogger, err := logger.New(cfg.Log)
if err != nil {
return nil, fmt.Errorf("init logger: %w", err)
}
db, err := database.Open(cfg.Database, appLogger)
if err != nil {
return nil, fmt.Errorf("init database: %w", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
storageTargetRepo := repository.NewStorageTargetRepository(db)
backupTaskRepo := repository.NewBackupTaskRepository(db)
backupRecordRepo := repository.NewBackupRecordRepository(db)
notificationRepo := repository.NewNotificationRepository(db)
oauthSessionRepo := repository.NewOAuthSessionRepository(db)
resolvedSecurity, err := service.ResolveSecurity(ctx, cfg.Security, systemConfigRepo)
if err != nil {
return nil, fmt.Errorf("resolve security config: %w", err)
}
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
storageRegistry := storage.NewRegistry(
localdisk.NewFactory(),
storageS3.NewFactory(),
storageWebDAV.NewFactory(),
googledrive.NewFactory(),
storageAliyun.NewFactory(),
storageTencent.NewFactory(),
storageQiniu.NewFactory(),
)
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
logHub := backup.NewLogHub()
retentionService := backupretention.NewService(backupRecordRepo)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent)
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
backupTaskService.SetScheduler(schedulerService)
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
// Cluster: Node management
nodeRepo := repository.NewNodeRepository(db)
nodeService := service.NewNodeService(nodeRepo)
if err := nodeService.EnsureLocalNode(ctx); err != nil {
appLogger.Warn("failed to ensure local node", zap.Error(err))
}
router := aphttp.NewRouter(aphttp.RouterDependencies{
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
NodeService: nodeService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
httpServer := &stdhttp.Server{
Addr: cfg.Address(),
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
return &Application{
cfg: cfg,
version: version,
logger: appLogger,
db: db,
httpServer: httpServer,
scheduler: schedulerService,
}, nil
}
func (a *Application) Run(ctx context.Context) error {
if a.scheduler != nil {
if err := a.scheduler.Start(context.Background()); err != nil {
return fmt.Errorf("start scheduler: %w", err)
}
}
errCh := make(chan error, 1)
go func() {
a.logger.Info("http server listening", zap.String("addr", a.cfg.Address()), zap.String("version", a.version))
if err := a.httpServer.ListenAndServe(); err != nil && !errors.Is(err, stdhttp.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
a.logger.Info("shutdown signal received")
if err := a.httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown http server: %w", err)
}
if a.scheduler != nil {
if err := a.scheduler.Stop(context.Background()); err != nil {
return fmt.Errorf("stop scheduler: %w", err)
}
}
return nil
case err := <-errCh:
if err != nil {
return fmt.Errorf("serve http: %w", err)
}
return nil
}
}
func (a *Application) Close() {
if a.logger != nil {
_ = a.logger.Sync()
}
}
func (a *Application) Logger() *zap.Logger {
return a.logger
}
func ErrorField(err error) zap.Field {
return zap.Error(err)
}

View File

@@ -0,0 +1,55 @@
package apperror
import "net/http"
type AppError struct {
Status int
Code string
Message string
Err error
}
func (e *AppError) Error() string {
if e == nil {
return ""
}
if e.Err != nil {
return e.Err.Error()
}
return e.Message
}
func (e *AppError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func New(status int, code, message string, err error) *AppError {
return &AppError{Status: status, Code: code, Message: message, Err: err}
}
func BadRequest(code, message string, err error) *AppError {
return New(http.StatusBadRequest, code, message, err)
}
func Unauthorized(code, message string, err error) *AppError {
return New(http.StatusUnauthorized, code, message, err)
}
func Forbidden(code, message string, err error) *AppError {
return New(http.StatusForbidden, code, message, err)
}
func Conflict(code, message string, err error) *AppError {
return New(http.StatusConflict, code, message, err)
}
func TooManyRequests(code, message string, err error) *AppError {
return New(http.StatusTooManyRequests, code, message, err)
}
func Internal(code, message string, err error) *AppError {
return New(http.StatusInternalServerError, code, message, err)
}

View File

@@ -0,0 +1,189 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func CreateTarGz(ctx context.Context, sourcePath string, excludePatterns []string, destinationPath string, logger LogWriter) (int64, error) {
sourcePath = filepath.Clean(strings.TrimSpace(sourcePath))
if sourcePath == "" {
return 0, fmt.Errorf("source path is required")
}
if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil {
return 0, fmt.Errorf("create destination directory: %w", err)
}
file, err := os.Create(destinationPath)
if err != nil {
return 0, fmt.Errorf("create archive file: %w", err)
}
defer file.Close()
gzipWriter, err := gzip.NewWriterLevel(file, gzip.DefaultCompression)
if err != nil {
return 0, fmt.Errorf("create gzip writer: %w", err)
}
defer gzipWriter.Close()
tarWriter := tar.NewWriter(gzipWriter)
defer tarWriter.Close()
baseParent := filepath.Dir(sourcePath)
walkErr := filepath.Walk(sourcePath, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
rel, err := filepath.Rel(baseParent, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if shouldExcludeArchive(rel, excludePatterns) {
if info.IsDir() {
return filepath.SkipDir
}
if logger != nil {
logger.WriteLine(fmt.Sprintf("跳过排除路径:%s", rel))
}
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return fmt.Errorf("build tar header: %w", err)
}
header.Name = rel
if info.IsDir() && !strings.HasSuffix(header.Name, "/") {
header.Name += "/"
}
if err := tarWriter.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header: %w", err)
}
if info.IsDir() {
return nil
}
input, err := os.Open(path)
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
defer input.Close()
if _, err := io.Copy(tarWriter, input); err != nil {
return fmt.Errorf("write tar body: %w", err)
}
return nil
})
if walkErr != nil {
return 0, walkErr
}
if err := tarWriter.Close(); err != nil {
return 0, fmt.Errorf("close tar writer: %w", err)
}
if err := gzipWriter.Close(); err != nil {
return 0, fmt.Errorf("close gzip writer: %w", err)
}
if err := file.Close(); err != nil {
return 0, fmt.Errorf("close archive file: %w", err)
}
info, err := os.Stat(destinationPath)
if err != nil {
return 0, fmt.Errorf("stat archive file: %w", err)
}
return info.Size(), nil
}
func ExtractTarGz(ctx context.Context, archivePath string, destinationDir string, logger LogWriter) error {
archivePath = filepath.Clean(archivePath)
destinationDir = filepath.Clean(destinationDir)
file, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("open archive file: %w", err)
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("open gzip reader: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
targetPath, err := secureJoin(destinationDir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, 0o755); err != nil {
return fmt.Errorf("create restore directory: %w", err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create restore parent directory: %w", err)
}
output, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create restore file: %w", err)
}
if _, err := io.Copy(output, tarReader); err != nil {
output.Close()
return fmt.Errorf("write restore file: %w", err)
}
if err := output.Close(); err != nil {
return fmt.Errorf("close restore file: %w", err)
}
if logger != nil {
logger.WriteLine(fmt.Sprintf("已恢复文件:%s", targetPath))
}
}
}
}
func shouldExcludeArchive(rel string, patterns []string) bool {
rel = filepath.ToSlash(strings.TrimSpace(rel))
base := filepath.Base(rel)
for _, pattern := range patterns {
trimmed := strings.TrimSpace(pattern)
if trimmed == "" {
continue
}
if matched, _ := filepath.Match(trimmed, rel); matched {
return true
}
if matched, _ := filepath.Match(trimmed, base); matched {
return true
}
if strings.Contains(rel, trimmed) {
return true
}
}
return false
}
func secureJoin(root string, relative string) (string, error) {
root = filepath.Clean(root)
target := filepath.Clean(filepath.Join(root, filepath.FromSlash(relative)))
rootWithSep := root + string(filepath.Separator)
if target != root && !strings.HasPrefix(target, rootWithSep) {
return "", fmt.Errorf("archive entry escapes destination: %s", relative)
}
return target, nil
}

View File

@@ -0,0 +1,41 @@
package backup
import (
"context"
"io"
"os"
"os/exec"
)
type CommandOptions struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Env []string
}
type CommandExecutor interface {
LookPath(file string) (string, error)
Run(ctx context.Context, name string, args []string, options CommandOptions) error
}
type OSCommandExecutor struct{}
func NewOSCommandExecutor() *OSCommandExecutor {
return &OSCommandExecutor{}
}
func (e *OSCommandExecutor) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (e *OSCommandExecutor) Run(ctx context.Context, name string, args []string, options CommandOptions) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = options.Stdin
cmd.Stdout = options.Stdout
cmd.Stderr = options.Stderr
if len(options.Env) > 0 {
cmd.Env = append(os.Environ(), options.Env...)
}
return cmd.Run()
}

View File

@@ -0,0 +1,37 @@
//go:build ignore
package backup
import (
"context"
"io"
"os"
"os/exec"
)
type CommandExecutor interface {
LookPath(file string) (string, error)
Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error
}
type OSCommandExecutor struct{}
func NewOSCommandExecutor() *OSCommandExecutor {
return &OSCommandExecutor{}
}
func (e *OSCommandExecutor) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (e *OSCommandExecutor) Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
command := exec.CommandContext(ctx, name, args...)
command.Stdin = stdin
command.Stdout = stdout
command.Stderr = stderr
command.Env = os.Environ()
for key, value := range env {
command.Env = append(command.Env, key+"="+value)
}
return command.Run()
}

View File

@@ -0,0 +1,16 @@
package backup
import "strings"
func normalizeDatabaseNames(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
for _, part := range strings.Split(item, ",") {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
}
return result
}

View File

@@ -0,0 +1,106 @@
package backup
import (
"bytes"
"context"
"errors"
"io"
"os"
"testing"
)
type fakeCommandExecutor struct {
lastName string
lastArgs []string
env []string
lookupErr error
runFunc func(name string, args []string, options CommandOptions) error
}
func (f *fakeCommandExecutor) LookPath(string) (string, error) {
if f.lookupErr != nil {
return "", f.lookupErr
}
return "/usr/bin/fake", nil
}
func (f *fakeCommandExecutor) Run(_ context.Context, name string, args []string, options CommandOptions) error {
f.lastName = name
f.lastArgs = append([]string{}, args...)
f.env = append([]string{}, options.Env...)
if f.runFunc != nil {
return f.runFunc(name, args, options)
}
return nil
}
func TestMySQLRunnerUsesExpectedCommands(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
if options.Stdout != nil {
_, _ = io.WriteString(options.Stdout, "mysql dump")
}
return nil
}}
runner := NewMySQLRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{Name: "mysql", Type: "mysql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 3306, User: "root", Password: "secret", Names: []string{"app, audit"}}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if executor.lastName != "mysqldump" {
t.Fatalf("expected mysqldump, got %s", executor.lastName)
}
if len(executor.lastArgs) == 0 || executor.lastArgs[len(executor.lastArgs)-2] != "app" || executor.lastArgs[len(executor.lastArgs)-1] != "audit" {
t.Fatalf("unexpected mysql args: %#v", executor.lastArgs)
}
if _, err := os.Stat(result.ArtifactPath); err != nil {
t.Fatalf("artifact file missing: %v", err)
}
}
func TestPostgreSQLRunnerRestoreUsesPsql(t *testing.T) {
executor := &fakeCommandExecutor{}
runner := NewPostgreSQLRunner(executor)
artifact := filepathJoinTempFile(t, "restore.sql", "select 1;")
if err := runner.Restore(context.Background(), TaskSpec{Name: "postgres", Type: "postgresql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "secret"}}, artifact, NopLogWriter{}); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if executor.lastName != "psql" {
t.Fatalf("expected psql, got %s", executor.lastName)
}
}
func TestMySQLRunnerReturnsLookupError(t *testing.T) {
runner := NewMySQLRunner(&fakeCommandExecutor{lookupErr: errors.New("missing")})
_, err := runner.Run(context.Background(), TaskSpec{Name: "mysql", Type: "mysql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 3306, User: "root", Password: "secret", Names: []string{"app"}}}, NopLogWriter{})
if err == nil {
t.Fatal("expected error when mysqldump is missing")
}
}
func filepathJoinTempFile(t *testing.T, name string, content string) string {
t.Helper()
filePath := t.TempDir() + "/" + name
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
return filePath
}
func TestPostgreSQLRunnerRunAppendsMultipleDatabaseDumps(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
_, _ = io.Copy(options.Stdout, bytes.NewBufferString(args[len(args)-1]))
return nil
}}
runner := NewPostgreSQLRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{Name: "pg", Type: "postgresql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "secret", Names: []string{"app", "audit"}}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
content, err := os.ReadFile(result.ArtifactPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !bytes.Contains(content, []byte("app")) || !bytes.Contains(content, []byte("audit")) {
t.Fatalf("unexpected pg dump content: %s", string(content))
}
}

View File

@@ -0,0 +1,191 @@
package backup
import (
"archive/tar"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
)
type FileRunner struct{}
func NewFileRunner() *FileRunner {
return &FileRunner{}
}
func (r *FileRunner) Type() string {
return "file"
}
func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
sourcePath := filepath.Clean(strings.TrimSpace(task.SourcePath))
if sourcePath == "" {
return nil, fmt.Errorf("source path is required")
}
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("stat source path: %w", err)
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "tar")
if err != nil {
return nil, err
}
artifactFile, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create tar artifact: %w", err)
}
defer artifactFile.Close()
tw := tar.NewWriter(artifactFile)
defer tw.Close()
baseParent := filepath.Dir(sourcePath)
excludes := normalizeExcludePatterns(task.ExcludePatterns)
writer.WriteLine(fmt.Sprintf("开始打包文件备份:%s", sourcePath))
fileCount := 0
dirCount := 0
walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error {
if walkErr != nil {
writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr))
return nil
}
relPath, err := filepath.Rel(baseParent, currentPath)
if err != nil {
return err
}
archiveName := filepath.ToSlash(relPath)
if shouldExcludeEntry(archiveName, currentInfo.IsDir(), excludes) {
if currentInfo.IsDir() {
writer.WriteLine(fmt.Sprintf("跳过排除目录 %s", archiveName))
return filepath.SkipDir
}
return nil
}
if currentPath == sourcePath && currentInfo.IsDir() {
return nil
}
if currentInfo.IsDir() {
dirCount++
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
}
header, err := tar.FileInfoHeader(currentInfo, "")
if err != nil {
return err
}
header.Name = archiveName
if err := tw.WriteHeader(header); err != nil {
return err
}
if currentInfo.Mode().IsRegular() {
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
return err
}
fileCount++
if fileCount%100 == 0 {
writer.WriteLine(fmt.Sprintf("已打包 %d 个文件...", fileCount))
}
}
return nil
})
if walkErr != nil {
return nil, fmt.Errorf("walk source path: %w", walkErr)
}
if info.IsDir() {
writer.WriteLine(fmt.Sprintf("目录打包完成(%d 个目录,%d 个文件)", dirCount, fileCount))
} else {
writer.WriteLine("文件打包完成")
}
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
}
func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
artifactFile, err := os.Open(artifactPath)
if err != nil {
return fmt.Errorf("open tar artifact: %w", err)
}
defer artifactFile.Close()
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(task.SourcePath)))
if err := os.MkdirAll(targetParent, 0o755); err != nil {
return fmt.Errorf("create restore parent: %w", err)
}
tr := tar.NewReader(artifactFile)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
cleanName := path.Clean(strings.TrimSpace(header.Name))
if cleanName == "." || cleanName == "" {
continue
}
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(cleanName)))
parentWithSep := filepath.Clean(targetParent) + string(filepath.Separator)
if targetPath != filepath.Clean(targetParent) && !strings.HasPrefix(targetPath, parentWithSep) {
return fmt.Errorf("tar entry escapes restore path")
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
return fmt.Errorf("create restore dir: %w", err)
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create restore parent dir: %w", err)
}
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
return fmt.Errorf("create restore file: %w", err)
}
if _, err := io.Copy(file, tr); err != nil {
file.Close()
return fmt.Errorf("write restore file: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close restore file: %w", err)
}
}
}
writer.WriteLine("文件恢复完成")
return nil
}
func normalizeExcludePatterns(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
result = append(result, filepath.ToSlash(trimmed))
}
}
return result
}
func shouldExcludeEntry(relPath string, isDir bool, patterns []string) bool {
relPath = filepath.ToSlash(relPath)
base := path.Base(relPath)
for _, pattern := range patterns {
if matched, _ := path.Match(pattern, relPath); matched {
return true
}
if matched, _ := path.Match(pattern, base); matched {
return true
}
if isDir && strings.TrimSuffix(pattern, "/") == base {
return true
}
}
return false
}

View File

@@ -0,0 +1,69 @@
package backup
import (
"archive/tar"
"context"
"os"
"path/filepath"
"testing"
)
type bufferWriter struct{ lines []string }
func (w *bufferWriter) WriteLine(message string) { w.lines = append(w.lines, message) }
func TestFileRunnerRunAndRestore(t *testing.T) {
tempDir := t.TempDir()
sourceDir := filepath.Join(tempDir, "site")
if err := os.MkdirAll(filepath.Join(sourceDir, "node_modules"), 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "app.log"), []byte("skip"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "node_modules", "pkg.json"), []byte("skip-dir"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
runner := NewFileRunner()
writer := &bufferWriter{}
result, err := runner.Run(context.Background(), TaskSpec{Name: "site files", Type: "file", SourcePath: sourceDir, ExcludePatterns: []string{"*.log", "node_modules"}}, writer)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
archiveFile, err := os.Open(result.ArtifactPath)
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer archiveFile.Close()
reader := tar.NewReader(archiveFile)
entries := map[string]bool{}
for {
header, err := reader.Next()
if err != nil {
break
}
entries[header.Name] = true
}
if !entries["site/index.html"] {
t.Fatalf("expected site/index.html in archive, got %#v", entries)
}
if entries["site/app.log"] || entries["site/node_modules/pkg.json"] {
t.Fatalf("unexpected excluded entries: %#v", entries)
}
if err := os.RemoveAll(sourceDir); err != nil {
t.Fatalf("RemoveAll returned error: %v", err)
}
if err := runner.Restore(context.Background(), TaskSpec{Name: "site files", Type: "file", SourcePath: sourceDir}, result.ArtifactPath, writer); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "index.html"))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "ok" {
t.Fatalf("unexpected restored content: %s", string(content))
}
}

View File

@@ -0,0 +1,41 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func createTempArtifact(baseDir, taskName string, extension string) (string, string, error) {
tempDir, err := os.MkdirTemp(baseDir, "backupx-run-*")
if err != nil {
return "", "", fmt.Errorf("create temp dir: %w", err)
}
base := sanitizeFileName(taskName)
if base == "" {
base = "backup"
}
fileName := fmt.Sprintf("%s_%s.%s", base, time.Now().UTC().Format("20060102T150405"), strings.TrimPrefix(extension, "."))
return tempDir, filepath.Join(tempDir, fileName), nil
}
func sanitizeFileName(value string) string {
builder := strings.Builder{}
for _, char := range strings.TrimSpace(value) {
switch {
case char >= 'a' && char <= 'z':
builder.WriteRune(char)
case char >= 'A' && char <= 'Z':
builder.WriteRune(char + ('a' - 'A'))
case char >= '0' && char <= '9':
builder.WriteRune(char)
case char == '-' || char == '_':
builder.WriteRune(char)
case char == ' ' || char == '.':
builder.WriteRune('_')
}
}
return strings.Trim(builder.String(), "_")
}

View File

@@ -0,0 +1,110 @@
package backup
import (
"sync"
"time"
)
type LogHub struct {
mu sync.RWMutex
streams map[uint]*logStreamState
}
type logStreamState struct {
nextSequence int64
events []LogEvent
subscribers map[int]chan LogEvent
nextSubID int
completed bool
status string
}
func NewLogHub() *LogHub {
return &LogHub{streams: make(map[uint]*logStreamState)}
}
func (h *LogHub) Append(recordID uint, level, message string) LogEvent {
h.mu.Lock()
defer h.mu.Unlock()
state := h.ensureState(recordID)
state.nextSequence++
event := LogEvent{RecordID: recordID, Sequence: state.nextSequence, Level: level, Message: message, Timestamp: time.Now().UTC(), Status: state.status}
state.events = append(state.events, event)
for _, subscriber := range state.subscribers {
select {
case subscriber <- event:
default:
}
}
return event
}
func (h *LogHub) Snapshot(recordID uint) []LogEvent {
h.mu.RLock()
defer h.mu.RUnlock()
state, ok := h.streams[recordID]
if !ok {
return nil
}
result := make([]LogEvent, len(state.events))
copy(result, state.events)
return result
}
func (h *LogHub) Subscribe(recordID uint, buffer int) (<-chan LogEvent, func()) {
if buffer <= 0 {
buffer = 32
}
h.mu.Lock()
defer h.mu.Unlock()
state := h.ensureState(recordID)
state.nextSubID++
id := state.nextSubID
channel := make(chan LogEvent, buffer)
state.subscribers[id] = channel
for _, event := range state.events {
channel <- event
}
cancel := func() {
h.mu.Lock()
defer h.mu.Unlock()
stream, ok := h.streams[recordID]
if !ok {
return
}
subscriber, ok := stream.subscribers[id]
if !ok {
return
}
delete(stream.subscribers, id)
close(subscriber)
}
return channel, cancel
}
func (h *LogHub) Complete(recordID uint, status string) {
h.mu.Lock()
defer h.mu.Unlock()
state := h.ensureState(recordID)
state.completed = true
state.status = status
state.nextSequence++
event := LogEvent{RecordID: recordID, Sequence: state.nextSequence, Level: "info", Message: "stream completed", Timestamp: time.Now().UTC(), Completed: true, Status: status}
state.events = append(state.events, event)
for _, subscriber := range state.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func (h *LogHub) ensureState(recordID uint) *logStreamState {
state, ok := h.streams[recordID]
if ok {
return state
}
state = &logStreamState{subscribers: make(map[int]chan LogEvent), status: "running"}
h.streams[recordID] = state
return state
}

View File

@@ -0,0 +1,26 @@
package backup
import "testing"
func TestLogHubAppendSubscribeAndComplete(t *testing.T) {
hub := NewLogHub()
channel, cancel := hub.Subscribe(1, 4)
defer cancel()
first := hub.Append(1, "info", "started")
if first.Sequence != 1 || first.Message != "started" {
t.Fatalf("unexpected first event: %#v", first)
}
snapshot := hub.Snapshot(1)
if len(snapshot) != 1 {
t.Fatalf("expected snapshot size 1, got %d", len(snapshot))
}
event := <-channel
if event.Message != "started" {
t.Fatalf("unexpected streamed event: %#v", event)
}
hub.Complete(1, "success")
completeEvent := <-channel
if !completeEvent.Completed || completeEvent.Status != "success" {
t.Fatalf("unexpected completion event: %#v", completeEvent)
}
}

View File

@@ -0,0 +1,56 @@
package backup
import (
"fmt"
"strings"
"sync"
)
type ExecutionLogger struct {
recordID uint
hub *LogHub
mu sync.Mutex
buffer strings.Builder
}
func NewExecutionLogger(recordID uint, hub *LogHub) *ExecutionLogger {
return &ExecutionLogger{recordID: recordID, hub: hub}
}
func (l *ExecutionLogger) Write(level, message string) {
trimmed := strings.TrimSpace(message)
if trimmed == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.buffer.Len() > 0 {
l.buffer.WriteByte('\n')
}
l.buffer.WriteString(trimmed)
if l.hub != nil {
l.hub.Append(l.recordID, level, trimmed)
}
}
func (l *ExecutionLogger) Infof(format string, args ...any) {
l.Write("info", fmt.Sprintf(format, args...))
}
func (l *ExecutionLogger) Errorf(format string, args ...any) {
l.Write("error", fmt.Sprintf(format, args...))
}
func (l *ExecutionLogger) Warnf(format string, args ...any) {
l.Write("warn", fmt.Sprintf(format, args...))
}
func (l *ExecutionLogger) WriteLine(message string) {
l.Infof("%s", message)
}
func (l *ExecutionLogger) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.buffer.String()
}

View File

@@ -0,0 +1,163 @@
package backup
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type MySQLRunner struct {
executor CommandExecutor
}
func NewMySQLRunner(executor CommandExecutor) *MySQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &MySQLRunner{executor: executor}
}
func (r *MySQLRunner) Type() string {
return "mysql"
}
func (r *MySQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
if _, err := r.executor.LookPath("mysqldump"); err != nil {
return nil, fmt.Errorf("未找到 mysqldump 命令 (请确保服务器已安装 mysql-client 或 mariadb-client)")
}
startedAt := task.StartedAt
if startedAt.IsZero() {
startedAt = time.Now().UTC()
}
tempDir, err := CreateTaskTempDir(task.Name, startedAt)
if err != nil {
return nil, err
}
fileName := BuildArtifactName(task.Name, startedAt, "sql")
artifactPath := filepath.Join(tempDir, fileName)
file, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create mysql dump file: %w", err)
}
defer file.Close()
dbNames := normalizeDatabaseNames(task.Database.Names)
if len(dbNames) == 0 {
return nil, fmt.Errorf("mysql database names are required")
}
args := []string{
"--host", task.Database.Host,
"--port", strconv.Itoa(task.Database.Port),
"--user", task.Database.User,
"--single-transaction",
"--quick",
"--routines",
"--triggers",
"--events",
"--no-tablespaces",
"--net-buffer-length=32768",
"--databases",
}
args = append(args, dbNames...)
writer.WriteLine(fmt.Sprintf("连接到 MySQL: %s:%d", task.Database.Host, task.Database.Port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", ")))
stderrWriter := newLogLineWriter(writer, "mysqldump")
writer.WriteLine("开始执行 mysqldump")
if err := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run mysqldump: %w: %s", err, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat mysql dump file: %w", err)
}
writer.WriteLine(fmt.Sprintf("MySQL 导出完成(文件大小: %s", formatFileSize(info.Size())))
return &RunResult{ArtifactPath: artifactPath, FileName: fileName, TempDir: tempDir, Size: info.Size(), StorageKey: BuildStorageKey("mysql", startedAt, fileName)}, nil
}
func (r *MySQLRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
if _, err := r.executor.LookPath("mysql"); err != nil {
return fmt.Errorf("未找到 mysql 命令 (请确保服务器已安装 mysql-client 或 mariadb-client)")
}
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open mysql restore file: %w", err)
}
defer input.Close()
stderr := &bytes.Buffer{}
args := []string{"--host", task.Database.Host, "--port", strconv.Itoa(task.Database.Port), "--user", task.Database.User}
writer.WriteLine("开始执行 mysql 恢复")
if err := r.executor.Run(ctx, "mysql", args, CommandOptions{Stdin: input, Stderr: stderr, Env: mysqlEnv(task.Database.Password)}); err != nil {
return fmt.Errorf("run mysql restore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
writer.WriteLine("MySQL 恢复完成")
return nil
}
func mysqlEnv(password string) []string {
if strings.TrimSpace(password) == "" {
return nil
}
return []string{"MYSQL_PWD=" + password}
}
// logLineWriter streams each line of output to a LogWriter in real-time.
type logLineWriter struct {
writer LogWriter
prefix string
buf bytes.Buffer
}
func newLogLineWriter(w LogWriter, prefix string) *logLineWriter {
return &logLineWriter{writer: w, prefix: prefix}
}
func (w *logLineWriter) Write(p []byte) (int, error) {
n := len(p)
w.buf.Write(p)
scanner := bufio.NewScanner(strings.NewReader(w.buf.String()))
var remaining string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line))
}
}
// Keep any partial last line (no newline yet)
lastNl := bytes.LastIndexByte(p, '\n')
if lastNl >= 0 {
remaining = w.buf.String()[w.buf.Len()-(len(p)-lastNl-1):]
w.buf.Reset()
w.buf.WriteString(remaining)
}
return n, nil
}
func (w *logLineWriter) collected() string {
return strings.TrimSpace(w.buf.String())
}
func formatFileSize(size int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case size >= GB:
return fmt.Sprintf("%.2f GB", float64(size)/float64(GB))
case size >= MB:
return fmt.Sprintf("%.2f MB", float64(size)/float64(MB))
case size >= KB:
return fmt.Sprintf("%.2f KB", float64(size)/float64(KB))
default:
return fmt.Sprintf("%d B", size)
}
}

View File

@@ -0,0 +1,171 @@
//go:build ignore
package backup
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
type PostgreSQLRunner struct {
executor CommandExecutor
}
func NewPostgreSQLRunner(executor CommandExecutor) *PostgreSQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &PostgreSQLRunner{executor: executor}
}
func (r *PostgreSQLRunner) Type() string {
return "postgresql"
}
func (r *PostgreSQLRunner) Run(ctx context.Context, spec TaskSpec, logger LogSink) (*Result, error) {
if _, err := r.executor.LookPath("pg_dump"); err != nil {
return nil, fmt.Errorf("pg_dump is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return nil, fmt.Errorf("postgresql database name is required")
}
tempDir, err := CreateTaskTempDir(spec.TaskName, spec.StartedAt)
if err != nil {
return nil, err
}
if len(databases) == 1 {
return r.dumpSingleDatabase(ctx, spec, databases[0], tempDir, logger)
}
multiDumpDir := filepath.Join(tempDir, "postgres-dumps")
if err := os.MkdirAll(multiDumpDir, 0o755); err != nil {
return nil, fmt.Errorf("create postgres multi dump directory: %w", err)
}
for _, databaseName := range databases {
if _, err := r.dumpDatabaseToFile(ctx, spec, databaseName, filepath.Join(multiDumpDir, sanitizeDumpName(databaseName)+".sql"), logger); err != nil {
return nil, err
}
}
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "tar.gz")
artifactPath := filepath.Join(tempDir, fileName)
size, err := CreateTarGz(ctx, multiDumpDir, nil, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) Restore(ctx context.Context, spec TaskSpec, artifactPath string, logger LogSink) error {
if _, err := r.executor.LookPath("psql"); err != nil {
return fmt.Errorf("psql is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return fmt.Errorf("postgresql database name is required")
}
if strings.HasSuffix(strings.ToLower(artifactPath), ".tar.gz") {
restoreDir, err := CreateTaskTempDir(spec.TaskName+"-restore", spec.StartedAt)
if err != nil {
return err
}
if err := ExtractTarGz(ctx, artifactPath, restoreDir, logger); err != nil {
return err
}
for _, databaseName := range databases {
filePath := filepath.Join(restoreDir, filepath.Base(restoreDir), sanitizeDumpName(databaseName)+".sql")
if _, err := os.Stat(filePath); err != nil {
fallback := filepath.Join(restoreDir, "postgres-dumps", sanitizeDumpName(databaseName)+".sql")
filePath = fallback
}
if err := r.restoreDatabaseFromFile(ctx, spec, databaseName, filePath, logger); err != nil {
return err
}
}
return nil
}
return r.restoreDatabaseFromFile(ctx, spec, databases[0], artifactPath, logger)
}
func (r *PostgreSQLRunner) dumpSingleDatabase(ctx context.Context, spec TaskSpec, databaseName string, tempDir string, logger LogSink) (*Result, error) {
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "sql")
artifactPath := filepath.Join(tempDir, fileName)
size, err := r.dumpDatabaseToFile(ctx, spec, databaseName, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) dumpDatabaseToFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) (int64, error) {
output, err := os.Create(filepath.Clean(artifactPath))
if err != nil {
return 0, fmt.Errorf("create postgres dump file: %w", err)
}
defer output.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName, "--no-owner", "--no-privileges"}
if logger != nil {
logger.Infof("开始执行 pg_dump%s", databaseName)
}
if err := r.executor.Run(ctx, "pg_dump", args, postgresEnv(spec.DBPassword), nil, output, stderr); err != nil {
return 0, fmt.Errorf("run pg_dump: %w: %s", err, strings.TrimSpace(stderr.String()))
}
info, err := output.Stat()
if err != nil {
return 0, fmt.Errorf("stat postgres dump file: %w", err)
}
return info.Size(), nil
}
func (r *PostgreSQLRunner) restoreDatabaseFromFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) error {
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open postgres restore file: %w", err)
}
defer input.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName}
if logger != nil {
logger.Infof("开始执行 psql 恢复:%s", databaseName)
}
if err := r.executor.Run(ctx, "psql", args, postgresEnv(spec.DBPassword), input, nil, stderr); err != nil {
return fmt.Errorf("run psql restore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func postgresEnv(password string) map[string]string {
if strings.TrimSpace(password) == "" {
return nil
}
return map[string]string{"PGPASSWORD": password}
}
func splitDatabaseNames(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}
func sanitizeDumpName(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
trimmed = strings.ReplaceAll(trimmed, " ", "-")
trimmed = strings.ReplaceAll(trimmed, "/", "-")
trimmed = strings.ReplaceAll(trimmed, "\\", "-")
trimmed = strings.Trim(trimmed, "-._")
if trimmed == "" {
return "database"
}
return trimmed
}

View File

@@ -0,0 +1,80 @@
package backup
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type PostgreSQLRunner struct {
executor CommandExecutor
}
func NewPostgreSQLRunner(executor CommandExecutor) *PostgreSQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &PostgreSQLRunner{executor: executor}
}
func (r *PostgreSQLRunner) Type() string {
return "postgresql"
}
func (r *PostgreSQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
if _, err := r.executor.LookPath("pg_dump"); err != nil {
return nil, fmt.Errorf("未找到 pg_dump 命令 (请确保服务器已安装 postgresql-client)")
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "sql")
if err != nil {
return nil, err
}
file, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create postgresql dump file: %w", err)
}
defer file.Close()
dbNames := normalizeDatabaseNames(task.Database.Names)
if len(dbNames) == 0 {
return nil, fmt.Errorf("postgresql database names are required")
}
writer.WriteLine(fmt.Sprintf("连接到 PostgreSQL: %s:%d", task.Database.Host, task.Database.Port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", ")))
stderrWriter := newLogLineWriter(writer, "pg_dump")
for index, name := range dbNames {
args := []string{"--clean", "--if-exists", "--create", "--format=plain", "-h", task.Database.Host, "-p", strconv.Itoa(task.Database.Port), "-U", task.Database.User, "--dbname", name}
writer.WriteLine(fmt.Sprintf("开始导出数据库 [%d/%d]: %s", index+1, len(dbNames), name))
if err := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run pg_dump for %s: %w", name, err)
}
writer.WriteLine(fmt.Sprintf("数据库 %s 导出完成", name))
if index < len(dbNames)-1 {
if _, err := file.WriteString("\n\n"); err != nil {
return nil, fmt.Errorf("write dump separator: %w", err)
}
}
}
info, _ := file.Stat()
sizeStr := "未知"
if info != nil {
sizeStr = formatFileSize(info.Size())
}
writer.WriteLine(fmt.Sprintf("PostgreSQL 导出完成(文件大小: %s", sizeStr))
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
}
func (r *PostgreSQLRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
if _, err := r.executor.LookPath("psql"); err != nil {
return fmt.Errorf("未找到 psql 命令 (请确保服务器已安装 postgresql-client)")
}
writer.WriteLine("开始执行 psql 恢复")
args := []string{"-h", task.Database.Host, "-p", strconv.Itoa(task.Database.Port), "-U", task.Database.User, "-d", "postgres", "-f", artifactPath}
if err := r.executor.Run(ctx, "psql", args, CommandOptions{Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}); err != nil {
return fmt.Errorf("run psql restore: %w", err)
}
writer.WriteLine("PostgreSQL 恢复完成")
return nil
}

View File

@@ -0,0 +1,62 @@
package backup
import (
"fmt"
"sort"
"strings"
"sync"
)
type Registry struct {
mu sync.RWMutex
runners map[string]BackupRunner
}
func NewRegistry(runners ...BackupRunner) *Registry {
registry := &Registry{runners: make(map[string]BackupRunner)}
for _, runner := range runners {
registry.Register(runner)
}
return registry
}
func (r *Registry) Register(runner BackupRunner) {
if runner == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.runners == nil {
r.runners = make(map[string]BackupRunner)
}
r.runners[normalizeTaskType(runner.Type())] = runner
}
func (r *Registry) Runner(taskType string) (BackupRunner, error) {
r.mu.RLock()
defer r.mu.RUnlock()
runner, ok := r.runners[normalizeTaskType(taskType)]
if !ok {
return nil, fmt.Errorf("unsupported backup task type: %s", taskType)
}
return runner, nil
}
func (r *Registry) Types() []string {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]string, 0, len(r.runners))
for key := range r.runners {
items = append(items, key)
}
sort.Strings(items)
return items
}
func normalizeTaskType(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "pgsql" {
return "postgresql"
}
return normalized
}

View File

@@ -0,0 +1,23 @@
package backup
import (
"context"
"testing"
)
type stubRunner struct{ taskType string }
func (r stubRunner) Type() string { return r.taskType }
func (r stubRunner) Run(context.Context, TaskSpec, LogWriter) (*RunResult, error) { return nil, nil }
func (r stubRunner) Restore(context.Context, TaskSpec, string, LogWriter) error { return nil }
func TestRegistryResolvesNormalizedType(t *testing.T) {
registry := NewRegistry(stubRunner{taskType: "postgresql"})
runner, err := registry.Runner("pgsql")
if err != nil {
t.Fatalf("Runner returned error: %v", err)
}
if runner.Type() != "postgresql" {
t.Fatalf("unexpected runner type: %s", runner.Type())
}
}

View File

@@ -0,0 +1,82 @@
package retention
import (
"context"
"fmt"
"strings"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
)
type CleanupResult struct {
DeletedRecords int
DeletedObjects int
Warnings []string
}
type Service struct {
records repository.BackupRecordRepository
now func() time.Time
}
func NewService(records repository.BackupRecordRepository) *Service {
return &Service{records: records, now: func() time.Time { return time.Now().UTC() }}
}
func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider storage.StorageProvider) (*CleanupResult, error) {
if task == nil {
return nil, fmt.Errorf("backup task is required")
}
records, err := s.records.ListSuccessfulByTask(ctx, task.ID)
if err != nil {
return nil, fmt.Errorf("list successful records: %w", err)
}
candidates := selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
result := &CleanupResult{}
for _, record := range candidates {
if strings.TrimSpace(record.StoragePath) != "" {
if provider == nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("record %d missing storage provider for cleanup", record.ID))
continue
}
if err := provider.Delete(ctx, record.StoragePath); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete storage object %s failed: %v", record.StoragePath, err))
continue
}
result.DeletedObjects++
}
if err := s.records.Delete(ctx, record.ID); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete backup record %d failed: %v", record.ID, err))
continue
}
result.DeletedRecords++
}
return result, nil
}
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
selected := make(map[uint]model.BackupRecord)
if maxBackups > 0 && len(records) > maxBackups {
for _, record := range records[maxBackups:] {
selected[record.ID] = record
}
}
if retentionDays > 0 {
cutoff := now.AddDate(0, 0, -retentionDays)
for _, record := range records {
if record.CompletedAt != nil && record.CompletedAt.Before(cutoff) {
selected[record.ID] = record
}
}
}
result := make([]model.BackupRecord, 0, len(selected))
for _, record := range records {
if selectedRecord, ok := selected[record.ID]; ok {
result = append(result, selectedRecord)
}
}
return result
}

View File

@@ -0,0 +1,115 @@
package retention
import (
"context"
"fmt"
"io"
"testing"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
)
type fakeRecordRepository struct {
records []model.BackupRecord
deleted []uint
deleteErrs map[uint]error
}
func (r *fakeRecordRepository) List(context.Context, repository.BackupRecordListOptions) ([]model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) FindByID(context.Context, uint) (*model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) Create(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Update(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error {
if err := r.deleteErrs[id]; err != nil {
return err
}
r.deleted = append(r.deleted, id)
return nil
}
func (r *fakeRecordRepository) ListRecent(context.Context, int) ([]model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
return r.records, nil
}
func (r *fakeRecordRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSuccessSince(context.Context, time.Time) (int64, error) {
return 0, nil
}
func (r *fakeRecordRepository) SumFileSize(context.Context) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) TimelineSince(context.Context, time.Time) ([]repository.BackupTimelinePoint, error) {
return nil, nil
}
func (r *fakeRecordRepository) StorageUsage(context.Context) ([]repository.BackupStorageUsageItem, error) {
return nil, nil
}
type fakeProvider struct {
deleted []string
failKey string
}
func (p *fakeProvider) Type() string { return storage.ProviderTypeLocalDisk }
func (p *fakeProvider) TestConnection(context.Context) error { return nil }
func (p *fakeProvider) Upload(context.Context, string, io.Reader, int64, map[string]string) error {
return nil
}
func (p *fakeProvider) Download(context.Context, string) (io.ReadCloser, error) { return nil, nil }
func (p *fakeProvider) Delete(_ context.Context, objectKey string) error {
if objectKey == p.failKey {
return fmt.Errorf("delete failed")
}
p.deleted = append(p.deleted, objectKey)
return nil
}
func (p *fakeProvider) List(context.Context, string) ([]storage.ObjectInfo, error) { return nil, nil }
func TestSelectRecordsToDelete(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)
completedOld := now.Add(-15 * 24 * time.Hour)
records := []model.BackupRecord{
{ID: 3, CompletedAt: &completedNew},
{ID: 2, CompletedAt: &completedNew},
{ID: 1, CompletedAt: &completedOld},
}
selected := selectRecordsToDelete(records, 7, 2, now)
if len(selected) != 1 || selected[0].ID != 1 {
t.Fatalf("unexpected selected records: %#v", selected)
}
}
func TestCleanupDeletesExpiredRecords(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)
completedOld := now.Add(-15 * 24 * time.Hour)
repo := &fakeRecordRepository{records: []model.BackupRecord{
{ID: 3, TaskID: 1, StoragePath: "records/3", CompletedAt: &completedNew},
{ID: 2, TaskID: 1, StoragePath: "records/2", CompletedAt: &completedNew},
{ID: 1, TaskID: 1, StoragePath: "records/1", CompletedAt: &completedOld},
}}
provider := &fakeProvider{}
service := NewService(repo)
service.now = func() time.Time { return now }
result, err := service.Cleanup(context.Background(), &model.BackupTask{ID: 1, RetentionDays: 7, MaxBackups: 2}, provider)
if err != nil {
t.Fatalf("Cleanup returned error: %v", err)
}
if result.DeletedRecords != 1 || result.DeletedObjects != 1 {
t.Fatalf("unexpected cleanup result: %#v", result)
}
if len(repo.deleted) != 1 || repo.deleted[0] != 1 {
t.Fatalf("unexpected deleted records: %#v", repo.deleted)
}
if len(provider.deleted) != 1 || provider.deleted[0] != "records/1" {
t.Fatalf("unexpected deleted objects: %#v", provider.deleted)
}
}

View File

@@ -0,0 +1,74 @@
package backup
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type SQLiteRunner struct{}
func NewSQLiteRunner() *SQLiteRunner {
return &SQLiteRunner{}
}
func (r *SQLiteRunner) Type() string {
return "sqlite"
}
func (r *SQLiteRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
dbPath := filepath.Clean(strings.TrimSpace(task.Database.Path))
if dbPath == "" {
return nil, fmt.Errorf("sqlite database path is required")
}
if _, err := os.Stat(dbPath); err != nil {
return nil, fmt.Errorf("stat sqlite database: %w", err)
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, strings.TrimPrefix(filepath.Ext(dbPath), "."))
if err != nil {
return nil, err
}
if filepath.Ext(artifactPath) == "." || filepath.Ext(artifactPath) == "" {
artifactPath += ".sqlite"
}
if err := copyFile(dbPath, artifactPath); err != nil {
return nil, err
}
writer.WriteLine("SQLite 备份文件已复制")
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
}
func (r *SQLiteRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
dbPath := filepath.Clean(strings.TrimSpace(task.Database.Path))
if dbPath == "" {
return fmt.Errorf("sqlite database path is required")
}
if err := copyFile(artifactPath, dbPath); err != nil {
return err
}
writer.WriteLine("SQLite 数据库已恢复")
return nil
}
func copyFile(sourcePath string, targetPath string) error {
source, err := os.Open(sourcePath)
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
defer source.Close()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create target directory: %w", err)
}
target, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create target file: %w", err)
}
defer target.Close()
if _, err := io.Copy(target, source); err != nil {
return fmt.Errorf("copy file content: %w", err)
}
return nil
}

View File

@@ -0,0 +1,34 @@
package backup
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestSQLiteRunnerRunAndRestore(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "data.db")
if err := os.WriteFile(dbPath, []byte("sqlite-data"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
runner := NewSQLiteRunner()
result, err := runner.Run(context.Background(), TaskSpec{Name: "sqlite backup", Type: "sqlite", Database: DatabaseSpec{Path: dbPath}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if err := os.WriteFile(dbPath, []byte("mutated"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := runner.Restore(context.Background(), TaskSpec{Name: "sqlite backup", Type: "sqlite", Database: DatabaseSpec{Path: dbPath}}, result.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
content, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "sqlite-data" {
t.Fatalf("unexpected restored content: %s", string(content))
}
}

View File

@@ -0,0 +1,64 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var fileNameCleaner = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func EnsureTempRoot() (string, error) {
root := filepath.Join(os.TempDir(), "backupx")
if err := os.MkdirAll(root, 0o755); err != nil {
return "", fmt.Errorf("create backup temp root: %w", err)
}
return root, nil
}
func CreateTaskTempDir(taskName string, startedAt time.Time) (string, error) {
root, err := EnsureTempRoot()
if err != nil {
return "", err
}
name := sanitizeTaskName(taskName)
if name == "" {
name = "backup"
}
path := filepath.Join(root, fmt.Sprintf("%s_%s", name, startedAt.UTC().Format("20060102_150405")))
if err := os.MkdirAll(path, 0o755); err != nil {
return "", fmt.Errorf("create task temp dir: %w", err)
}
return path, nil
}
func BuildArtifactName(taskName string, startedAt time.Time, extension string) string {
name := sanitizeTaskName(taskName)
if name == "" {
name = "backup"
}
ext := strings.TrimSpace(extension)
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return fmt.Sprintf("%s_%s%s", name, startedAt.UTC().Format("20060102_150405"), ext)
}
func BuildStorageKey(backupType string, startedAt time.Time, fileName string) string {
typeName := strings.TrimSpace(strings.ToLower(backupType))
if typeName == "" {
typeName = "file"
}
return filepath.ToSlash(filepath.Join("BackupX", typeName, startedAt.UTC().Format("060102"), fileName))
}
func sanitizeTaskName(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
trimmed = strings.ReplaceAll(trimmed, " ", "-")
trimmed = fileNameCleaner.ReplaceAllString(trimmed, "-")
trimmed = strings.Trim(trimmed, "-._")
return trimmed
}

View File

@@ -0,0 +1,73 @@
package backup
import (
"context"
"time"
)
type DatabaseSpec struct {
Host string
Port int
User string
Password string
Names []string
Path string
}
type TaskSpec struct {
ID uint
Name string
Type string
SourcePath string
ExcludePatterns []string
Database DatabaseSpec
StorageTargetID uint
StorageTargetType string
Compression string
Encrypt bool
RetentionDays int
MaxBackups int
StartedAt time.Time
TempDir string
}
type RunResult struct {
ArtifactPath string
FileName string
TempDir string
Size int64
StorageKey string
}
type LogEvent struct {
RecordID uint `json:"recordId"`
Sequence int64 `json:"sequence"`
Level string `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Completed bool `json:"completed"`
Status string `json:"status"`
}
type LogWriter interface {
WriteLine(message string)
}
type LogSink interface {
Infof(format string, args ...any)
Warnf(format string, args ...any)
Errorf(format string, args ...any)
}
type NopLogWriter struct{}
func (NopLogWriter) WriteLine(string) {}
func (NopLogWriter) Infof(string, ...any) {}
func (NopLogWriter) Warnf(string, ...any) {}
func (NopLogWriter) Errorf(string, ...any) {}
type BackupRunner interface {
Type() string
Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error)
Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error
}

View File

@@ -0,0 +1,143 @@
package config
import (
"fmt"
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Security SecurityConfig `mapstructure:"security"`
Backup BackupConfig `mapstructure:"backup"`
Log LogConfig `mapstructure:"log"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
}
type DatabaseConfig struct {
Path string `mapstructure:"path"`
}
type SecurityConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
JWTExpire string `mapstructure:"jwt_expire"`
EncryptionKey string `mapstructure:"encryption_key"`
}
type BackupConfig struct {
TempDir string `mapstructure:"temp_dir"`
MaxConcurrent int `mapstructure:"max_concurrent"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
File string `mapstructure:"file"`
MaxSize int `mapstructure:"max_size"`
MaxBackups int `mapstructure:"max_backups"`
MaxAge int `mapstructure:"max_age"`
}
func Load(configPath string) (Config, error) {
v := viper.New()
applyDefaults(v)
v.SetConfigType("yaml")
v.SetEnvPrefix("BACKUPX")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if configPath != "" {
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
return Config{}, fmt.Errorf("read config: %w", err)
}
} else {
v.SetConfigName("config")
v.AddConfigPath(".")
v.AddConfigPath("./server")
v.AddConfigPath("/etc/backupx")
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return Config{}, fmt.Errorf("read config: %w", err)
}
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return Config{}, fmt.Errorf("decode config: %w", err)
}
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8340
}
if cfg.Server.Mode == "" {
cfg.Server.Mode = "release"
}
if cfg.Database.Path == "" {
cfg.Database.Path = "./data/backupx.db"
}
if cfg.Security.JWTExpire == "" {
cfg.Security.JWTExpire = "24h"
}
if cfg.Backup.TempDir == "" {
cfg.Backup.TempDir = "/tmp/backupx"
}
if cfg.Backup.MaxConcurrent <= 0 {
cfg.Backup.MaxConcurrent = 2
}
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
if cfg.Log.File == "" {
cfg.Log.File = "./data/backupx.log"
}
if cfg.Log.MaxSize <= 0 {
cfg.Log.MaxSize = 100
}
if cfg.Log.MaxBackups <= 0 {
cfg.Log.MaxBackups = 3
}
if cfg.Log.MaxAge <= 0 {
cfg.Log.MaxAge = 30
}
return cfg, nil
}
func MustJWTDuration(cfg SecurityConfig) time.Duration {
duration, err := time.ParseDuration(cfg.JWTExpire)
if err != nil {
return 24 * time.Hour
}
return duration
}
func (c Config) Address() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
func applyDefaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8340)
v.SetDefault("server.mode", "release")
v.SetDefault("database.path", "./data/backupx.db")
v.SetDefault("security.jwt_expire", "24h")
v.SetDefault("backup.temp_dir", "/tmp/backupx")
v.SetDefault("backup.max_concurrent", 2)
v.SetDefault("log.level", "info")
v.SetDefault("log.file", "./data/backupx.log")
v.SetDefault("log.max_size", 100)
v.SetDefault("log.max_backups", 3)
v.SetDefault("log.max_age", 30)
}

View File

@@ -0,0 +1,20 @@
package config
import "testing"
func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
cfg, err := Load("")
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Fatalf("expected default host, got %s", cfg.Server.Host)
}
if cfg.Server.Port != 8340 {
t.Fatalf("expected default port, got %d", cfg.Server.Port)
}
if cfg.Database.Path != "./data/backupx.db" {
t.Fatalf("expected default database path, got %s", cfg.Database.Path)
}
}

View File

@@ -0,0 +1,32 @@
package database
import (
"fmt"
"os"
"path/filepath"
"backupx/server/internal/config"
"backupx/server/internal/model"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
if err := os.MkdirAll(filepath.Dir(cfg.Path), 0o755); err != nil {
return nil, fmt.Errorf("create database dir: %w", err)
}
db, err := gorm.Open(sqlite.Open(cfg.Path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}); err != nil {
return nil, fmt.Errorf("migrate schema: %w", err)
}
logger.Info("database initialized", zap.String("path", cfg.Path))
return db, nil
}

View File

@@ -0,0 +1,91 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) SetupStatus(c *gin.Context) {
initialized, err := h.authService.SetupStatus(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"initialized": initialized})
}
func (h *AuthHandler) Setup(c *gin.Context) {
var input service.SetupInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_SETUP_INVALID", "初始化参数不合法", err))
return
}
payload, err := h.authService.Setup(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) Login(c *gin.Context) {
var input service.LoginInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
return
}
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) Profile(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
user, err := h.authService.GetCurrentUser(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.ChangePasswordInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_PASSWORD_INVALID", "参数不合法", err))
return
}
if err := h.authService.ChangePassword(c.Request.Context(), subject, input); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"changed": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
response.Success(c, gin.H{"loggedOut": true})
}

View File

@@ -0,0 +1,189 @@
package http
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupRecordHandler struct {
service *service.BackupRecordService
}
func NewBackupRecordHandler(recordService *service.BackupRecordService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService}
}
func (h *BackupRecordHandler) List(c *gin.Context) {
filter, err := buildRecordFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *BackupRecordHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
detail, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
events := detail.LogEvents
completed := detail.Status != "running"
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
if err != nil {
response.Error(c, err)
return
}
defer cancel()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("BACKUP_RECORD_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
return
}
for _, event := range events {
if err := writeSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
}
if completed {
return
}
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-channel:
if !ok {
return
}
if err := writeSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
if event.Completed {
return
}
}
}
}
func (h *BackupRecordHandler) Download(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
result, err := h.service.Download(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
defer result.Reader.Close()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", result.FileName))
c.Header("Content-Type", "application/octet-stream")
_, _ = io.Copy(c.Writer, result.Reader)
}
func (h *BackupRecordHandler) Restore(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Restore(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"restored": true})
}
func (h *BackupRecordHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
var filter service.BackupRecordListInput
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
parsed, ok := parseUintString(taskIDValue)
if !ok {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "taskId 不合法", nil)
}
filter.TaskID = &parsed
}
filter.Status = strings.TrimSpace(c.Query("status"))
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
parsed, err := time.Parse(time.RFC3339, dateFrom)
if err != nil {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
filter.DateFrom = &parsed
}
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
parsed, err := time.Parse(time.RFC3339, dateTo)
if err != nil {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
filter.DateTo = &parsed
}
return filter, nil
}
func writeSSEEvent(writer io.Writer, event backup.LogEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
return err
}
func parseUintString(value string) (uint, bool) {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, false
}
return uint(parsed), true
}

View File

@@ -0,0 +1,28 @@
package http
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupRunHandler struct {
service *service.BackupExecutionService
}
func NewBackupRunHandler(executionService *service.BackupExecutionService) *BackupRunHandler {
return &BackupRunHandler{service: executionService}
}
func (h *BackupRunHandler) Run(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
record, err := h.service.RunTaskByID(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, record)
}

View File

@@ -0,0 +1,109 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupTaskHandler struct {
service *service.BackupTaskService
}
func NewBackupTaskHandler(taskService *service.BackupTaskService) *BackupTaskHandler {
return &BackupTaskHandler{service: taskService}
}
func (h *BackupTaskHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *BackupTaskHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupTaskHandler) Create(c *gin.Context) {
var input service.BackupTaskUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_INVALID", "备份任务参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupTaskHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.BackupTaskUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_INVALID", "备份任务参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupTaskHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func (h *BackupTaskHandler) Toggle(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.BackupTaskToggleInput
if err := c.ShouldBindJSON(&input); err != nil && err.Error() != "EOF" {
response.Error(c, apperror.BadRequest("BACKUP_TASK_TOGGLE_INVALID", "备份任务启停参数不合法", err))
return
}
current, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
enabled := !current.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
}
item, err := h.service.Toggle(c.Request.Context(), id, enabled)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}

View File

@@ -0,0 +1,3 @@
package http
const contextUserSubjectKey = "userSubject"

View File

@@ -0,0 +1,46 @@
package http
import (
"strconv"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type DashboardHandler struct {
service *service.DashboardService
}
func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardHandler {
return &DashboardHandler{service: dashboardService}
}
func (h *DashboardHandler) Stats(c *gin.Context) {
payload, err := h.service.Stats(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *DashboardHandler) Timeline(c *gin.Context) {
days := 30
if value := strings.TrimSpace(c.Query("days")); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
response.Error(c, apperror.BadRequest("DASHBOARD_TIMELINE_INVALID", "days 必须为整数", err))
return
}
days = parsed
}
payload, err := h.service.Timeline(c.Request.Context(), days)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}

View File

@@ -0,0 +1,57 @@
package http
import (
stdhttp "net/http"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/security"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// CORSMiddleware handles Cross-Origin Resource Sharing for the API.
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == stdhttp.MethodOptions {
c.AbortWithStatus(stdhttp.StatusNoContent)
return
}
c.Next()
}
}
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
header := strings.TrimSpace(c.GetHeader("Authorization"))
if !strings.HasPrefix(header, "Bearer ") {
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
c.Abort()
return
}
tokenString := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
claims, err := jwtManager.Parse(tokenString)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
c.Abort()
return
}
c.Set(contextUserSubjectKey, claims.Subject)
c.Next()
}
}
func ClientKey(c *gin.Context) string {
ip := strings.TrimSpace(c.ClientIP())
if ip == "" {
return "unknown"
}
return ip
}

View File

@@ -0,0 +1,101 @@
package http
import (
stdhttp "net/http"
"strconv"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type NodeHandler struct {
service *service.NodeService
}
func NewNodeHandler(service *service.NodeService) *NodeHandler {
return &NodeHandler{service: service}
}
func (h *NodeHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *NodeHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
item, err := h.service.Get(c.Request.Context(), uint(id))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NodeHandler) Create(c *gin.Context) {
var input service.NodeCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
token, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"token": token})
}
func (h *NodeHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
if err := h.service.Delete(c.Request.Context(), uint(id)); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
func (h *NodeHandler) ListDirectory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
path := c.DefaultQuery("path", "/")
entries, err := h.service.ListDirectory(c.Request.Context(), uint(id), path)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, entries)
}
func (h *NodeHandler) Heartbeat(c *gin.Context) {
var input struct {
Token string `json:"token" binding:"required"`
Hostname string `json:"hostname"`
IPAddress string `json:"ipAddress"`
AgentVersion string `json:"agentVersion"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
if err := h.service.Heartbeat(c.Request.Context(), input.Token, input.Hostname, input.IPAddress, input.AgentVersion); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"status": "ok"})
}

View File

@@ -0,0 +1,107 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
service *service.NotificationService
}
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
return &NotificationHandler{service: notificationService}
}
func (h *NotificationHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *NotificationHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NotificationHandler) Create(c *gin.Context) {
var input service.NotificationUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("NOTIFICATION_INVALID", "通知配置参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NotificationHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.NotificationUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("NOTIFICATION_INVALID", "通知配置参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NotificationHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func (h *NotificationHandler) Test(c *gin.Context) {
var input service.NotificationUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("NOTIFICATION_INVALID", "通知配置参数不合法", err))
return
}
if err := h.service.Test(c.Request.Context(), input); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
func (h *NotificationHandler) TestSaved(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.TestSaved(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true})
}

View File

@@ -0,0 +1,152 @@
package http
import (
"errors"
stdhttp "net/http"
"backupx/server/internal/apperror"
"backupx/server/internal/config"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type RouterDependencies struct {
Config config.Config
Version string
Logger *zap.Logger
AuthService *service.AuthService
SystemService *service.SystemService
StorageTargetService *service.StorageTargetService
BackupTaskService *service.BackupTaskService
BackupExecutionService *service.BackupExecutionService
BackupRecordService *service.BackupRecordService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
SettingsService *service.SettingsService
NodeService *service.NodeService
JWTManager *security.JWTManager
UserRepository repository.UserRepository
SystemConfigRepo repository.SystemConfigRepository
}
func NewRouter(deps RouterDependencies) *gin.Engine {
gin.SetMode(deps.Config.Server.Mode)
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(CORSMiddleware())
engine.Use(requestLogger(deps.Logger))
authHandler := NewAuthHandler(deps.AuthService)
systemHandler := NewSystemHandler(deps.SystemService)
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService)
notificationHandler := NewNotificationHandler(deps.NotificationService)
dashboardHandler := NewDashboardHandler(deps.DashboardService)
settingsHandler := NewSettingsHandler(deps.SettingsService)
api := engine.Group("/api")
{
auth := api.Group("/auth")
{
auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login)
auth.POST("/logout", AuthMiddleware(deps.JWTManager), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager), authHandler.ChangePassword)
}
system := api.Group("/system")
system.Use(AuthMiddleware(deps.JWTManager))
system.GET("/info", systemHandler.Info)
storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager))
storageTargets.GET("", storageTargetHandler.List)
storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.POST("", storageTargetHandler.Create)
storageTargets.PUT("/:id", storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
storageTargets.POST("/test", storageTargetHandler.TestConnection)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
backupTasks := api.Group("/backup/tasks")
backupTasks.Use(AuthMiddleware(deps.JWTManager))
backupTasks.GET("", backupTaskHandler.List)
backupTasks.GET("/:id", backupTaskHandler.Get)
backupTasks.POST("", backupTaskHandler.Create)
backupTasks.PUT("/:id", backupTaskHandler.Update)
backupTasks.DELETE("/:id", backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", backupRunHandler.Run)
backupRecords := api.Group("/backup/records")
backupRecords.Use(AuthMiddleware(deps.JWTManager))
backupRecords.GET("", backupRecordHandler.List)
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager))
dashboard.GET("/stats", dashboardHandler.Stats)
dashboard.GET("/timeline", dashboardHandler.Timeline)
notifications := api.Group("/notifications")
notifications.Use(AuthMiddleware(deps.JWTManager))
notifications.GET("", notificationHandler.List)
notifications.GET("/:id", notificationHandler.Get)
notifications.POST("", notificationHandler.Create)
notifications.PUT("/:id", notificationHandler.Update)
notifications.DELETE("/:id", notificationHandler.Delete)
notifications.POST("/test", notificationHandler.Test)
notifications.POST("/:id/test", notificationHandler.TestSaved)
settings := api.Group("/settings")
settings.Use(AuthMiddleware(deps.JWTManager))
settings.GET("", settingsHandler.Get)
settings.PUT("", settingsHandler.Update)
nodeHandler := NewNodeHandler(deps.NodeService)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager))
nodes.GET("", nodeHandler.List)
nodes.GET("/:id", nodeHandler.Get)
nodes.POST("", nodeHandler.Create)
nodes.DELETE("/:id", nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
// Agent heartbeat (public, token-authenticated)
api.POST("/agent/heartbeat", nodeHandler.Heartbeat)
}
engine.NoRoute(func(c *gin.Context) {
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
})
return engine
}
func requestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
logger.Info("http request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.String("client_ip", c.ClientIP()),
)
}
}

View File

@@ -0,0 +1,94 @@
package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
)
func TestSetupLoginAndProfileFlow(t *testing.T) {
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New error: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open error: %v", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("ResolveSecurity error: %v", err)
}
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute))
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
router := NewRouter(RouterDependencies{
Config: cfg,
Version: "test",
Logger: log,
AuthService: authService,
SystemService: systemService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
setupBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password-123",
"displayName": "Admin",
})
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
setupRequest.Header.Set("Content-Type", "application/json")
setupRecorder := httptest.NewRecorder()
router.ServeHTTP(setupRecorder, setupRequest)
if setupRecorder.Code != http.StatusOK {
t.Fatalf("expected setup 200, got %d", setupRecorder.Code)
}
var setupResponse struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupRecorder.Body.Bytes(), &setupResponse); err != nil {
t.Fatalf("unmarshal setup response: %v", err)
}
if setupResponse.Data.Token == "" {
t.Fatalf("expected token in setup response")
}
profileRequest := httptest.NewRequest(http.MethodGet, "/api/auth/profile", nil)
profileRequest.Header.Set("Authorization", "Bearer "+setupResponse.Data.Token)
profileRecorder := httptest.NewRecorder()
router.ServeHTTP(profileRecorder, profileRequest)
if profileRecorder.Code != http.StatusOK {
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
}
}

View File

@@ -0,0 +1,39 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type SettingsHandler struct {
settingsService *service.SettingsService
}
func NewSettingsHandler(settingsService *service.SettingsService) *SettingsHandler {
return &SettingsHandler{settingsService: settingsService}
}
func (h *SettingsHandler) Get(c *gin.Context) {
settings, err := h.settingsService.GetAll(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, settings)
}
func (h *SettingsHandler) Update(c *gin.Context) {
var input map[string]string
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("SETTINGS_INVALID", "设置参数不合法", err))
return
}
settings, err := h.settingsService.Update(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, settings)
}

View File

@@ -0,0 +1,244 @@
package http
import (
"fmt"
"strconv"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type StorageTargetHandler struct {
service *service.StorageTargetService
}
type storageTargetGoogleDriveAuthRequest struct {
TargetID *uint `json:"targetId"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Config map[string]any `json:"config"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
FolderID string `json:"folderId"`
}
func NewStorageTargetHandler(service *service.StorageTargetService) *StorageTargetHandler {
return &StorageTargetHandler{service: service}
}
func (h *StorageTargetHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *StorageTargetHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) Create(c *gin.Context) {
var input service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func (h *StorageTargetHandler) TestConnection(c *gin.Context) {
var payload service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&payload); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_TEST_INVALID", "测试连接参数不合法", err))
return
}
if err := h.service.TestConnection(c.Request.Context(), service.StorageTargetTestInput{Payload: payload}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "连接成功"})
}
func (h *StorageTargetHandler) TestSavedConnection(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.TestConnection(c.Request.Context(), service.StorageTargetTestInput{TargetID: &id}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "连接成功"})
}
func (h *StorageTargetHandler) StartGoogleDriveOAuth(c *gin.Context) {
var request storageTargetGoogleDriveAuthRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 授权参数不合法", err))
return
}
input := service.GoogleDriveAuthStartInput{
TargetID: request.TargetID,
Name: strings.TrimSpace(request.Name),
Description: strings.TrimSpace(request.Description),
Enabled: request.Enabled,
ClientID: firstNonEmpty(asString(request.Config["clientId"]), request.ClientID),
ClientSecret: firstNonEmpty(asString(request.Config["clientSecret"]), request.ClientSecret),
FolderID: firstNonEmpty(asString(request.Config["folderId"]), request.FolderID),
}
result, err := h.service.StartGoogleDriveOAuth(c.Request.Context(), input, requestOrigin(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"authUrl": result.AuthorizationURL})
}
func (h *StorageTargetHandler) CompleteGoogleDriveOAuth(c *gin.Context) {
var input service.GoogleDriveAuthCompleteInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 回调参数不合法", err))
return
}
item, err := h.service.CompleteGoogleDriveOAuth(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) HandleGoogleDriveCallback(c *gin.Context) {
if queryError := strings.TrimSpace(c.Query("error")); queryError != "" {
response.Success(c, gin.H{"success": false, "message": queryError})
return
}
input := service.GoogleDriveAuthCompleteInput{State: strings.TrimSpace(c.Query("state")), Code: strings.TrimSpace(c.Query("code"))}
if input.State == "" || input.Code == "" {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 回调参数不合法", nil))
return
}
item, err := h.service.CompleteGoogleDriveOAuth(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "Google Drive 授权成功", "target": item})
}
func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
profile, err := h.service.GoogleDriveProfile(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, profile)
}
func parseUintParam(c *gin.Context, key string) (uint, bool) {
value := strings.TrimSpace(c.Param(key))
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
response.Error(c, apperror.BadRequest("INVALID_ID", fmt.Sprintf("参数 %s 不合法", key), err))
return 0, false
}
return uint(parsed), true
}
func requestOrigin(c *gin.Context) string {
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin != "" {
return origin
}
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
func asString(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func (h *StorageTargetHandler) GetUsage(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
usage, err := h.service.GetUsage(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, usage)
}

View File

@@ -0,0 +1,19 @@
package http
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type SystemHandler struct {
systemService *service.SystemService
}
func NewSystemHandler(systemService *service.SystemService) *SystemHandler {
return &SystemHandler{systemService: systemService}
}
func (h *SystemHandler) Info(c *gin.Context) {
response.Success(c, h.systemService.GetInfo(c.Request.Context()))
}

View File

@@ -0,0 +1,98 @@
//go:build ignore
package httpapi
import (
"net/http"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type authHandler struct {
service *service.AuthService
logger *zap.Logger
}
type setupRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
DisplayName string `json:"displayName" binding:"required,min=1,max=128"`
}
type loginRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
func newAuthHandler(service *service.AuthService, logger *zap.Logger) *authHandler {
return &authHandler{service: service, logger: logger}
}
func (h *authHandler) registerRoutes(router gin.IRouter, protected gin.IRouter) {
router.GET("/auth/setup/status", h.getSetupStatus)
router.POST("/auth/setup", h.setup)
router.POST("/auth/login", h.login)
protected.GET("/auth/profile", h.profile)
}
func (h *authHandler) getSetupStatus(c *gin.Context) {
initialized, err := h.service.GetSetupStatus(c.Request.Context())
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, gin.H{"initialized": initialized})
}
func (h *authHandler) setup(c *gin.Context) {
payload, err := bindJSON[setupRequest](c, h.logger)
if err != nil {
writeError(c, h.logger, err)
return
}
result, err := h.service.Setup(c.Request.Context(), service.SetupInput{
Username: payload.Username,
Password: payload.Password,
DisplayName: payload.DisplayName,
})
if err != nil {
writeError(c, h.logger, err)
return
}
c.JSON(http.StatusCreated, response.Envelope{Code: "OK", Message: "success", Data: result})
}
func (h *authHandler) login(c *gin.Context) {
payload, err := bindJSON[loginRequest](c, h.logger)
if err != nil {
writeError(c, h.logger, err)
return
}
result, err := h.service.Login(c.Request.Context(), service.LoginInput{
Username: payload.Username,
Password: payload.Password,
RemoteAddr: c.ClientIP(),
})
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, result)
}
func (h *authHandler) profile(c *gin.Context) {
userID, err := getUserID(c)
if err != nil {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证信息无效")
return
}
result, err := h.service.GetCurrentUser(c.Request.Context(), userID)
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, result)
}

View File

@@ -0,0 +1,23 @@
//go:build ignore
package httpapi
import (
"fmt"
"github.com/gin-gonic/gin"
)
const claimsContextKey = "authClaims"
func getUserID(c *gin.Context) (uint, error) {
value, ok := c.Get(claimsContextKey)
if !ok {
return 0, fmt.Errorf("missing auth claims")
}
claims, ok := value.(AuthClaims)
if !ok {
return 0, fmt.Errorf("invalid auth claims")
}
return claims.UserID, nil
}

View File

@@ -0,0 +1,92 @@
//go:build ignore
package httpapi
import (
"errors"
"fmt"
"net/http"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/security"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type AuthClaims struct {
UserID uint
Username string
Role string
}
func Recovery(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("panic recovered", zap.Any("panic", recovered), zap.String("path", c.Request.URL.Path))
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误")
c.Abort()
}
}()
c.Next()
}
}
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
logger.Info("http request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.String("client_ip", c.ClientIP()),
)
}
}
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
authorization := strings.TrimSpace(c.GetHeader("Authorization"))
if authorization == "" || !strings.HasPrefix(strings.ToLower(authorization), "bearer ") {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "缺少有效的认证令牌")
c.Abort()
return
}
tokenValue := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer"))
if tokenValue == authorization {
tokenValue = strings.TrimSpace(strings.TrimPrefix(authorization, "bearer"))
}
claims, err := jwtManager.Parse(tokenValue)
if err != nil {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证令牌无效或已过期")
c.Abort()
return
}
c.Set(claimsContextKey, AuthClaims{UserID: claims.UserID, Username: claims.Username, Role: claims.Role})
c.Next()
}
}
func writeError(c *gin.Context, logger *zap.Logger, err error) {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
if appErr.Err != nil {
logger.Warn("request failed", zap.String("code", appErr.Code), zap.Error(appErr.Err))
}
response.Error(c, appErr.Status, appErr.Code, appErr.Message)
return
}
logger.Error("unexpected error", zap.Error(err))
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误")
}
func bindJSON[T any](c *gin.Context, logger *zap.Logger) (*T, error) {
var payload T
if err := c.ShouldBindJSON(&payload); err != nil {
logger.Warn("bind json failed", zap.Error(err))
return nil, apperror.Wrap(http.StatusBadRequest, "INVALID_REQUEST", fmt.Sprintf("请求参数错误: %v", err), err)
}
return &payload, nil
}

View File

@@ -0,0 +1,38 @@
//go:build ignore
package httpapi
import (
"backupx/server/internal/security"
"backupx/server/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type Dependencies struct {
Logger *zap.Logger
AuthService *service.AuthService
SystemService *service.SystemService
JWTManager *security.JWTManager
Mode string
}
func NewRouter(deps Dependencies) *gin.Engine {
gin.SetMode(deps.Mode)
router := gin.New()
router.Use(Recovery(deps.Logger), RequestLogger(deps.Logger))
api := router.Group("/api")
authHandler := newAuthHandler(deps.AuthService, deps.Logger)
systemHandler := newSystemHandler(deps.SystemService)
protected := api.Group("")
protected.Use(AuthMiddleware(deps.JWTManager))
authHandler.registerRoutes(api, protected)
systemHandler.registerRoutes(protected)
api.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
return router
}

View File

@@ -0,0 +1,96 @@
//go:build ignore
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
)
func TestSetupLoginProfileAndSystemInfo(t *testing.T) {
tmpDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tmpDir, "backupx.db")},
Security: config.SecurityConfig{JWTSecret: "test-jwt-secret", JWTExpire: "1h", EncryptionKey: "test-encryption-key"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
jwtManager := security.NewJWTManager(cfg.Security.JWTSecret, time.Hour)
authService := service.NewAuthService(repository.NewUserRepository(db), jwtManager, security.NewLoginLimiter(5, time.Minute))
systemService := service.NewSystemService(cfg, "test", time.Now().Add(-time.Minute))
router := NewRouter(Dependencies{Logger: log, AuthService: authService, SystemService: systemService, JWTManager: jwtManager, Mode: "test"})
setupBody := map[string]string{"username": "admin", "password": "super-secret", "displayName": "管理员"}
setupResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/setup", setupBody, "")
if setupResp.Code != http.StatusCreated {
t.Fatalf("unexpected setup status: %d body=%s", setupResp.Code, setupResp.Body.String())
}
var setupPayload struct {
Code string `json:"code"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupResp.Body.Bytes(), &setupPayload); err != nil {
t.Fatalf("decode setup response: %v", err)
}
if setupPayload.Data.Token == "" {
t.Fatal("expected token in setup response")
}
profileResp := performJSONRequest(t, router, http.MethodGet, "/api/auth/profile", nil, setupPayload.Data.Token)
if profileResp.Code != http.StatusOK {
t.Fatalf("unexpected profile status: %d body=%s", profileResp.Code, profileResp.Body.String())
}
loginBody := map[string]string{"username": "admin", "password": "super-secret"}
loginResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/login", loginBody, "")
if loginResp.Code != http.StatusOK {
t.Fatalf("unexpected login status: %d body=%s", loginResp.Code, loginResp.Body.String())
}
systemResp := performJSONRequest(t, router, http.MethodGet, "/api/system/info", nil, setupPayload.Data.Token)
if systemResp.Code != http.StatusOK {
t.Fatalf("unexpected system info status: %d body=%s", systemResp.Code, systemResp.Body.String())
}
}
func performJSONRequest(t *testing.T, handler http.Handler, method string, path string, payload any, token string) *httptest.ResponseRecorder {
t.Helper()
var body []byte
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
body = encoded
}
request := httptest.NewRequest(method, path, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}

View File

@@ -0,0 +1,25 @@
//go:build ignore
package httpapi
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type systemHandler struct {
service *service.SystemService
}
func newSystemHandler(service *service.SystemService) *systemHandler {
return &systemHandler{service: service}
}
func (h *systemHandler) registerRoutes(protected gin.IRouter) {
protected.GET("/system/info", h.info)
}
func (h *systemHandler) info(c *gin.Context) {
response.Success(c, h.service.GetInfo())
}

View File

@@ -0,0 +1,53 @@
package logger
import (
"fmt"
"os"
"path/filepath"
"strings"
"backupx/server/internal/config"
"github.com/natefinch/lumberjack"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func New(cfg config.LogConfig) (*zap.Logger, error) {
level := parseLevel(cfg.Level)
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.TimeKey = "time"
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
encoder := zapcore.NewJSONEncoder(encoderCfg)
writers := []zapcore.WriteSyncer{zapcore.AddSync(os.Stdout)}
if cfg.File != "" {
if err := os.MkdirAll(filepath.Dir(cfg.File), 0o755); err != nil {
return nil, fmt.Errorf("create log dir: %w", err)
}
rotator := &lumberjack.Logger{
Filename: cfg.File,
MaxSize: cfg.MaxSize,
MaxBackups: cfg.MaxBackups,
MaxAge: cfg.MaxAge,
LocalTime: false,
Compress: true,
}
writers = append(writers, zapcore.AddSync(rotator))
}
core := zapcore.NewCore(encoder, zapcore.NewMultiWriteSyncer(writers...), level)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)), nil
}
func parseLevel(value string) zapcore.Level {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return zapcore.DebugLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}

View File

@@ -0,0 +1,32 @@
package model
import "time"
const (
BackupRecordStatusRunning = "running"
BackupRecordStatusSuccess = "success"
BackupRecordStatusFailed = "failed"
)
type BackupRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BackupRecord) TableName() string {
return "backup_records"
}

View File

@@ -0,0 +1,50 @@
package model
import "time"
const (
BackupTaskTypeFile = "file"
BackupTaskTypeMySQL = "mysql"
BackupTaskTypeSQLite = "sqlite"
BackupTaskTypePostgreSQL = "postgresql"
)
const (
BackupTaskStatusIdle = "idle"
BackupTaskStatusRunning = "running"
BackupTaskStatusSuccess = "success"
BackupTaskStatusFailed = "failed"
)
type BackupTask struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
Type string `gorm:"size:20;index;not null" json:"type"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
DBPort int `gorm:"column:db_port" json:"dbPort"`
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"`
Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BackupTask) TableName() string {
return "backup_tasks"
}

View File

@@ -0,0 +1,30 @@
package model
import "time"
const (
NodeStatusOnline = "online"
NodeStatusOffline = "offline"
)
// Node represents a managed server node in the cluster.
// The default "local" node is auto-created for single-machine backward compatibility.
type Node struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Hostname string `gorm:"size:255" json:"hostname"`
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Node) TableName() string {
return "nodes"
}

View File

@@ -0,0 +1,19 @@
package model
import "time"
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Notification) TableName() string {
return "notifications"
}

View File

@@ -0,0 +1,19 @@
package model
import "time"
type OAuthSession struct {
ID uint `gorm:"primaryKey" json:"id"`
ProviderType string `gorm:"column:provider_type;size:32;index;not null" json:"providerType"`
State string `gorm:"size:255;uniqueIndex;not null" json:"state"`
PayloadCiphertext string `gorm:"column:payload_ciphertext;type:text;not null" json:"-"`
TargetID *uint `gorm:"column:target_id" json:"targetId,omitempty"`
ExpiresAt time.Time `gorm:"column:expires_at;index;not null" json:"expiresAt"`
UsedAt *time.Time `gorm:"column:used_at" json:"usedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (OAuthSession) TableName() string {
return "oauth_sessions"
}

View File

@@ -0,0 +1,22 @@
package model
import "time"
type StorageTarget struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Type string `gorm:"size:32;index;not null" json:"type"`
Description string `gorm:"size:255" json:"description"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
ConfigVersion int `gorm:"not null;default:1" json:"configVersion"`
LastTestedAt *time.Time `gorm:"column:last_tested_at" json:"lastTestedAt,omitempty"`
LastTestStatus string `gorm:"column:last_test_status;size:32;not null;default:'unknown'" json:"lastTestStatus"`
LastTestMessage string `gorm:"column:last_test_message;size:512" json:"lastTestMessage"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (StorageTarget) TableName() string {
return "storage_targets"
}

View File

@@ -0,0 +1,16 @@
package model
import "time"
type SystemConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:128;uniqueIndex;not null" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
Encrypted bool `gorm:"not null;default:false" json:"encrypted"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SystemConfig) TableName() string {
return "system_configs"
}

View File

@@ -0,0 +1,18 @@
package model
import "time"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (User) TableName() string {
return "users"
}

View File

@@ -0,0 +1,88 @@
package notify
import (
"context"
"crypto/tls"
"fmt"
"net/smtp"
"strconv"
"strings"
)
type EmailNotifier struct{}
func NewEmailNotifier() *EmailNotifier { return &EmailNotifier{} }
func (n *EmailNotifier) Type() string { return "email" }
func (n *EmailNotifier) SensitiveFields() []string { return []string{"password"} }
func (n *EmailNotifier) Validate(config map[string]any) error {
host := strings.TrimSpace(asString(config["host"]))
port := asInt(config["port"])
from := strings.TrimSpace(asString(config["from"]))
to := strings.TrimSpace(asString(config["to"]))
if host == "" || port <= 0 || from == "" || to == "" {
return fmt.Errorf("email host/port/from/to are required")
}
return nil
}
func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message Message) error {
if err := n.Validate(config); err != nil {
return err
}
host := strings.TrimSpace(asString(config["host"]))
port := asInt(config["port"])
username := strings.TrimSpace(asString(config["username"]))
password := strings.TrimSpace(asString(config["password"]))
from := strings.TrimSpace(asString(config["from"]))
toList := splitCommaValues(asString(config["to"]))
address := host + ":" + strconv.Itoa(port)
headers := []string{"From: " + from, "To: " + strings.Join(toList, ", "), "Subject: " + message.Title, "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "", message.Body}
var auth smtp.Auth
if username != "" {
auth = smtp.PlainAuth("", username, password, host)
}
rawMessage := []byte(strings.Join(headers, "\r\n"))
if port == 465 {
tlsConfig := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", address, tlsConfig)
if err != nil {
return fmt.Errorf("dial tls for smtp port 465 failed: %w", err)
}
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("create smtp client over tls failed: %w", err)
}
defer client.Close()
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
if err = client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth failed: %w", err)
}
}
}
if err = client.Mail(from); err != nil {
return fmt.Errorf("smtp mail from failed: %w", err)
}
for _, toAddr := range toList {
if err = client.Rcpt(toAddr); err != nil {
return fmt.Errorf("smtp rcpt failed for %s: %w", toAddr, err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data failed: %w", err)
}
if _, err = writer.Write(rawMessage); err != nil {
return fmt.Errorf("smtp write message failed: %w", err)
}
if err = writer.Close(); err != nil {
return fmt.Errorf("smtp data close failed: %w", err)
}
return client.Quit()
}
return smtp.SendMail(address, auth, from, toList, rawMessage)
}

View File

@@ -0,0 +1,49 @@
package notify
import (
"fmt"
"strconv"
"strings"
)
func asString(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func asInt(value any) int {
switch actual := value.(type) {
case int:
return actual
case int64:
return int(actual)
case float64:
return int(actual)
case string:
parsed, _ := strconv.Atoi(strings.TrimSpace(actual))
return parsed
default:
return 0
}
}
func splitCommaValues(value string) []string {
items := strings.Split(value, ",")
result := make([]string, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func validateRequiredConfig(config map[string]any, fields ...string) error {
for _, field := range fields {
if strings.TrimSpace(asString(config[field])) == "" {
return fmt.Errorf("%s is required", field)
}
}
return nil
}

View File

@@ -0,0 +1,75 @@
package notify
import (
"context"
"fmt"
"sort"
"sync"
)
type Registry struct {
mu sync.RWMutex
notifiers map[string]Notifier
}
func NewRegistry(notifiers ...Notifier) *Registry {
registry := &Registry{notifiers: make(map[string]Notifier)}
for _, notifier := range notifiers {
registry.Register(notifier)
}
return registry
}
func (r *Registry) Register(notifier Notifier) {
if notifier == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.notifiers == nil {
r.notifiers = make(map[string]Notifier)
}
r.notifiers[notifier.Type()] = notifier
}
func (r *Registry) Types() []string {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]string, 0, len(r.notifiers))
for key := range r.notifiers {
items = append(items, key)
}
sort.Strings(items)
return items
}
func (r *Registry) SensitiveFields(notificationType string) []string {
notifier, ok := r.Notifier(notificationType)
if !ok {
return nil
}
return notifier.SensitiveFields()
}
func (r *Registry) Validate(notificationType string, config map[string]any) error {
notifier, ok := r.Notifier(notificationType)
if !ok {
return fmt.Errorf("unsupported notification type: %s", notificationType)
}
return notifier.Validate(config)
}
func (r *Registry) Send(ctx context.Context, notificationType string, config map[string]any, message Message) error {
notifier, ok := r.Notifier(notificationType)
if !ok {
return fmt.Errorf("unsupported notification type: %s", notificationType)
}
return notifier.Send(ctx, config, message)
}
func (r *Registry) Notifier(notificationType string) (Notifier, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
notifier, ok := r.notifiers[notificationType]
return notifier, ok
}

View File

@@ -0,0 +1,54 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type TelegramNotifier struct {
client *http.Client
}
func NewTelegramNotifier() *TelegramNotifier {
return &TelegramNotifier{client: &http.Client{Timeout: 10 * time.Second}}
}
func (n *TelegramNotifier) Type() string { return "telegram" }
func (n *TelegramNotifier) SensitiveFields() []string { return []string{"botToken"} }
func (n *TelegramNotifier) Validate(config map[string]any) error {
if strings.TrimSpace(asString(config["botToken"])) == "" || strings.TrimSpace(asString(config["chatId"])) == "" {
return fmt.Errorf("telegram botToken/chatId are required")
}
return nil
}
func (n *TelegramNotifier) Send(ctx context.Context, config map[string]any, message Message) error {
if err := n.Validate(config); err != nil {
return err
}
botToken := strings.TrimSpace(asString(config["botToken"]))
chatID := strings.TrimSpace(asString(config["chatId"]))
payload, err := json.Marshal(map[string]any{"chat_id": chatID, "text": message.Title + "\n\n" + message.Body})
if err != nil {
return fmt.Errorf("marshal telegram payload: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.telegram.org/bot"+botToken+"/sendMessage", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create telegram request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := n.client.Do(request)
if err != nil {
return fmt.Errorf("send telegram request: %w", err)
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("telegram response status: %s", response.Status)
}
return nil
}

View File

@@ -0,0 +1,16 @@
package notify
import "context"
type Message struct {
Title string `json:"title"`
Body string `json:"body"`
Fields map[string]any `json:"fields,omitempty"`
}
type Notifier interface {
Type() string
SensitiveFields() []string
Validate(config map[string]any) error
Send(ctx context.Context, config map[string]any, message Message) error
}

View File

@@ -0,0 +1,55 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type WebhookNotifier struct {
client *http.Client
}
func NewWebhookNotifier() *WebhookNotifier {
return &WebhookNotifier{client: &http.Client{Timeout: 10 * time.Second}}
}
func (n *WebhookNotifier) Type() string { return "webhook" }
func (n *WebhookNotifier) SensitiveFields() []string { return []string{"secret"} }
func (n *WebhookNotifier) Validate(config map[string]any) error {
if strings.TrimSpace(asString(config["url"])) == "" {
return fmt.Errorf("webhook url is required")
}
return nil
}
func (n *WebhookNotifier) Send(ctx context.Context, config map[string]any, message Message) error {
if err := n.Validate(config); err != nil {
return err
}
body, err := json.Marshal(map[string]any{"title": message.Title, "body": message.Body, "fields": message.Fields})
if err != nil {
return fmt.Errorf("marshal webhook payload: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(asString(config["url"])), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create webhook request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
if secret := strings.TrimSpace(asString(config["secret"])); secret != "" {
request.Header.Set("X-BackupX-Secret", secret)
}
response, err := n.client.Do(request)
if err != nil {
return fmt.Errorf("send webhook request: %w", err)
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("webhook response status: %s", response.Status)
}
return nil
}

View File

@@ -0,0 +1,183 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type BackupRecordListOptions struct {
TaskID *uint
Status string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
type BackupTimelinePoint struct {
Date string `json:"date"`
Total int64 `json:"total"`
Success int64 `json:"success"`
Failed int64 `json:"failed"`
}
type BackupStorageUsageItem struct {
StorageTargetID uint `json:"storageTargetId"`
TotalSize int64 `json:"totalSize"`
}
type BackupRecordRepository interface {
List(context.Context, BackupRecordListOptions) ([]model.BackupRecord, error)
FindByID(context.Context, uint) (*model.BackupRecord, error)
Create(context.Context, *model.BackupRecord) error
Update(context.Context, *model.BackupRecord) error
Delete(context.Context, uint) error
ListRecent(context.Context, int) ([]model.BackupRecord, error)
ListSuccessfulByTask(context.Context, uint) ([]model.BackupRecord, error)
Count(context.Context) (int64, error)
CountSince(context.Context, time.Time) (int64, error)
CountSuccessSince(context.Context, time.Time) (int64, error)
SumFileSize(context.Context) (int64, error)
TimelineSince(context.Context, time.Time) ([]BackupTimelinePoint, error)
StorageUsage(context.Context) ([]BackupStorageUsageItem, error)
}
type GormBackupRecordRepository struct {
db *gorm.DB
}
func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
return &GormBackupRecordRepository{db: db}
}
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
if options.Status != "" {
query = query.Where("status = ?", options.Status)
}
if options.DateFrom != nil {
query = query.Where("started_at >= ?", options.DateFrom.UTC())
}
if options.DateTo != nil {
query = query.Where("started_at <= ?", options.DateTo.UTC())
}
if options.Limit > 0 {
query = query.Limit(options.Limit)
}
if options.Offset > 0 {
query = query.Offset(options.Offset)
}
var items []model.BackupRecord
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) FindByID(ctx context.Context, id uint) (*model.BackupRecord, error) {
var item model.BackupRecord
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormBackupRecordRepository) Create(ctx context.Context, item *model.BackupRecord) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormBackupRecordRepository) Update(ctx context.Context, item *model.BackupRecord) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormBackupRecordRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.BackupRecord{}, id).Error
}
func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int) ([]model.BackupRecord, error) {
if limit <= 0 {
limit = 10
}
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) ListSuccessfulByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupRecordRepository) CountSince(ctx context.Context, since time.Time) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Where("started_at >= ?", since.UTC()).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupRecordRepository) CountSuccessSince(ctx context.Context, since time.Time) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Where("started_at >= ? AND status = ?", since.UTC(), "success").Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupRecordRepository) SumFileSize(ctx context.Context) (int64, error) {
var sum int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Select("COALESCE(SUM(file_size), 0)").Scan(&sum).Error; err != nil {
return 0, err
}
return sum, nil
}
func (r *GormBackupRecordRepository) TimelineSince(ctx context.Context, since time.Time) ([]BackupTimelinePoint, error) {
var items []BackupTimelinePoint
query := `
SELECT
strftime('%Y-%m-%d', started_at) AS date,
COUNT(*) AS total,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed
FROM backup_records
WHERE started_at >= ?
GROUP BY strftime('%Y-%m-%d', started_at)
ORDER BY date ASC
`
if err := r.db.WithContext(ctx).Raw(query, since.UTC()).Scan(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) StorageUsage(ctx context.Context) ([]BackupStorageUsageItem, error) {
var items []BackupStorageUsageItem
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Select("storage_target_id, COALESCE(SUM(file_size), 0) AS total_size").Group("storage_target_id").Order("storage_target_id asc").Scan(&items).Error; err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,115 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newBackupRecordTestRepository(t *testing.T) *GormBackupRecordRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
storageTarget := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
if err := db.Create(storageTarget).Error; err != nil {
t.Fatalf("seed storage target error: %v", err)
}
task := &model.BackupTask{Name: "website", Type: "file", Enabled: true, SourcePath: "/srv/www/site", StorageTargetID: storageTarget.ID, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := db.Create(task).Error; err != nil {
t.Fatalf("seed backup task error: %v", err)
}
return NewBackupRecordRepository(db)
}
func TestBackupRecordRepositoryQueries(t *testing.T) {
ctx := context.Background()
repo := newBackupRecordTestRepository(t)
now := time.Now().UTC()
completedAt := now.Add(2 * time.Minute)
record := &model.BackupRecord{
TaskID: 1,
StorageTargetID: 1,
Status: "success",
FileName: "website.tar.gz",
FileSize: 1024,
StoragePath: "tasks/1/website.tar.gz",
DurationSeconds: 120,
LogContent: "done",
StartedAt: now,
CompletedAt: &completedAt,
}
if err := repo.Create(ctx, record); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByID(ctx, record.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if stored == nil || stored.FileName != "website.tar.gz" {
t.Fatalf("unexpected stored record: %#v", stored)
}
listed, err := repo.List(ctx, BackupRecordListOptions{TaskID: &record.TaskID, Status: "success"})
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(listed) != 1 {
t.Fatalf("expected one listed record, got %d", len(listed))
}
recent, err := repo.ListRecent(ctx, 5)
if err != nil {
t.Fatalf("ListRecent returned error: %v", err)
}
if len(recent) != 1 {
t.Fatalf("expected one recent record, got %d", len(recent))
}
total, err := repo.Count(ctx)
if err != nil {
t.Fatalf("Count returned error: %v", err)
}
if total != 1 {
t.Fatalf("expected total count 1, got %d", total)
}
successCount, err := repo.CountSuccessSince(ctx, now.Add(-time.Hour))
if err != nil {
t.Fatalf("CountSuccessSince returned error: %v", err)
}
if successCount != 1 {
t.Fatalf("expected success count 1, got %d", successCount)
}
sum, err := repo.SumFileSize(ctx)
if err != nil {
t.Fatalf("SumFileSize returned error: %v", err)
}
if sum != 1024 {
t.Fatalf("expected file size sum 1024, got %d", sum)
}
timeline, err := repo.TimelineSince(ctx, now.Add(-time.Hour))
if err != nil {
t.Fatalf("TimelineSince returned error: %v", err)
}
if len(timeline) != 1 || timeline[0].Success != 1 {
t.Fatalf("unexpected timeline: %#v", timeline)
}
usage, err := repo.StorageUsage(ctx)
if err != nil {
t.Fatalf("StorageUsage returned error: %v", err)
}
if len(usage) != 1 || usage[0].TotalSize != 1024 {
t.Fatalf("unexpected usage: %#v", usage)
}
if err := repo.Delete(ctx, record.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
}

View File

@@ -0,0 +1,116 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type BackupTaskListOptions struct {
Type string
Enabled *bool
}
type BackupTaskRepository interface {
List(context.Context, BackupTaskListOptions) ([]model.BackupTask, error)
FindByID(context.Context, uint) (*model.BackupTask, error)
FindByName(context.Context, string) (*model.BackupTask, error)
ListSchedulable(context.Context) ([]model.BackupTask, error)
Count(context.Context) (int64, error)
CountEnabled(context.Context) (int64, error)
CountByStorageTargetID(context.Context, uint) (int64, error)
Create(context.Context, *model.BackupTask) error
Update(context.Context, *model.BackupTask) error
Delete(context.Context, uint) error
}
type GormBackupTaskRepository struct {
db *gorm.DB
}
func NewBackupTaskRepository(db *gorm.DB) *GormBackupTaskRepository {
return &GormBackupTaskRepository{db: db}
}
func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskListOptions) ([]model.BackupTask, error) {
query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Order("updated_at desc")
if options.Type != "" {
query = query.Where("type = ?", options.Type)
}
if options.Enabled != nil {
query = query.Where("enabled = ?", *options.Enabled)
}
var items []model.BackupTask
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupTaskRepository) FindByID(ctx context.Context, id uint) (*model.BackupTask, error) {
var item model.BackupTask
if err := r.db.WithContext(ctx).Preload("StorageTarget").First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormBackupTaskRepository) FindByName(ctx context.Context, name string) (*model.BackupTask, error) {
var item model.BackupTask
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormBackupTaskRepository) ListSchedulable(ctx context.Context) ([]model.BackupTask, error) {
var items []model.BackupTask
if err := r.db.WithContext(ctx).Preload("StorageTarget").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupTaskRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupTaskRepository) CountEnabled(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Where("enabled = ?", true).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupTaskRepository) CountByStorageTargetID(ctx context.Context, storageTargetID uint) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Where("storage_target_id = ?", storageTargetID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupTaskRepository) Create(ctx context.Context, item *model.BackupTask) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormBackupTaskRepository) Update(ctx context.Context, item *model.BackupTask) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormBackupTaskRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.BackupTask{}, id).Error
}

View File

@@ -0,0 +1,94 @@
package repository
import (
"context"
"path/filepath"
"testing"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newBackupTaskTestRepository(t *testing.T) *GormBackupTaskRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
if err := db.Create(&model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}).Error; err != nil {
t.Fatalf("seed storage target error: %v", err)
}
return NewBackupTaskRepository(db)
}
func TestBackupTaskRepositoryCRUD(t *testing.T) {
ctx := context.Background()
repo := newBackupTaskTestRepository(t)
task := &model.BackupTask{
Name: "website",
Type: "file",
Enabled: true,
SourcePath: "/srv/www/site",
StorageTargetID: 1,
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
LastStatus: "idle",
}
if err := repo.Create(ctx, task); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByID(ctx, task.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if stored == nil || stored.Name != "website" {
t.Fatalf("unexpected stored task: %#v", stored)
}
stored.Enabled = false
stored.CronExpr = "0 3 * * *"
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
schedulable, err := repo.ListSchedulable(ctx)
if err != nil {
t.Fatalf("ListSchedulable returned error: %v", err)
}
if len(schedulable) != 0 {
t.Fatalf("expected disabled task not schedulable, got %d", len(schedulable))
}
stored.Enabled = true
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
schedulable, err = repo.ListSchedulable(ctx)
if err != nil {
t.Fatalf("ListSchedulable returned error: %v", err)
}
if len(schedulable) != 1 {
t.Fatalf("expected one schedulable task, got %d", len(schedulable))
}
count, err := repo.CountByStorageTargetID(ctx, 1)
if err != nil {
t.Fatalf("CountByStorageTargetID returned error: %v", err)
}
if count != 1 {
t.Fatalf("expected referenced task count 1, got %d", count)
}
if err := repo.Delete(ctx, task.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
deleted, err := repo.FindByID(ctx, task.ID)
if err != nil {
t.Fatalf("FindByID after delete returned error: %v", err)
}
if deleted != nil {
t.Fatalf("expected task deleted, got %#v", deleted)
}
}

View File

@@ -0,0 +1,80 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type NodeRepository interface {
List(context.Context) ([]model.Node, error)
FindByID(context.Context, uint) (*model.Node, error)
FindByToken(context.Context, string) (*model.Node, error)
FindLocal(context.Context) (*model.Node, error)
Create(context.Context, *model.Node) error
Update(context.Context, *model.Node) error
Delete(context.Context, uint) error
}
type GormNodeRepository struct {
db *gorm.DB
}
func NewNodeRepository(db *gorm.DB) *GormNodeRepository {
return &GormNodeRepository{db: db}
}
func (r *GormNodeRepository) List(ctx context.Context) ([]model.Node, error) {
var items []model.Node
if err := r.db.WithContext(ctx).Order("is_local desc, updated_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormNodeRepository) FindByID(ctx context.Context, id uint) (*model.Node, error) {
var item model.Node
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNodeRepository) FindByToken(ctx context.Context, token string) (*model.Node, error) {
var item model.Node
if err := r.db.WithContext(ctx).Where("token = ?", token).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNodeRepository) FindLocal(ctx context.Context) (*model.Node, error) {
var item model.Node
if err := r.db.WithContext(ctx).Where("is_local = ?", true).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNodeRepository) Create(ctx context.Context, item *model.Node) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormNodeRepository) Update(ctx context.Context, item *model.Node) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormNodeRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Node{}, id).Error
}

View File

@@ -0,0 +1,83 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type NotificationRepository interface {
List(context.Context) ([]model.Notification, error)
ListEnabledForEvent(context.Context, bool) ([]model.Notification, error)
FindByID(context.Context, uint) (*model.Notification, error)
FindByName(context.Context, string) (*model.Notification, error)
Create(context.Context, *model.Notification) error
Update(context.Context, *model.Notification) error
Delete(context.Context, uint) error
}
type GormNotificationRepository struct {
db *gorm.DB
}
func NewNotificationRepository(db *gorm.DB) *GormNotificationRepository {
return &GormNotificationRepository{db: db}
}
func (r *GormNotificationRepository) List(ctx context.Context) ([]model.Notification, error) {
var items []model.Notification
if err := r.db.WithContext(ctx).Order("updated_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormNotificationRepository) ListEnabledForEvent(ctx context.Context, success bool) ([]model.Notification, error) {
query := r.db.WithContext(ctx).Model(&model.Notification{}).Where("enabled = ?", true)
if success {
query = query.Where("on_success = ?", true)
} else {
query = query.Where("on_failure = ?", true)
}
var items []model.Notification
if err := query.Order("updated_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormNotificationRepository) FindByID(ctx context.Context, id uint) (*model.Notification, error) {
var item model.Notification
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNotificationRepository) FindByName(ctx context.Context, name string) (*model.Notification, error) {
var item model.Notification
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNotificationRepository) Create(ctx context.Context, item *model.Notification) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormNotificationRepository) Update(ctx context.Context, item *model.Notification) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormNotificationRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Notification{}, id).Error
}

View File

@@ -0,0 +1,69 @@
package repository
import (
"context"
"path/filepath"
"testing"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newNotificationTestRepository(t *testing.T) *GormNotificationRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
return NewNotificationRepository(db)
}
func TestNotificationRepositoryCRUD(t *testing.T) {
ctx := context.Background()
repo := newNotificationTestRepository(t)
item := &model.Notification{
Type: "webhook",
Name: "ops-webhook",
ConfigCiphertext: "ciphertext",
Enabled: true,
OnSuccess: false,
OnFailure: true,
}
if err := repo.Create(ctx, item); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByName(ctx, "ops-webhook")
if err != nil {
t.Fatalf("FindByName returned error: %v", err)
}
if stored == nil || stored.Name != "ops-webhook" {
t.Fatalf("unexpected notification: %#v", stored)
}
enabledForFailure, err := repo.ListEnabledForEvent(ctx, false)
if err != nil {
t.Fatalf("ListEnabledForEvent returned error: %v", err)
}
if len(enabledForFailure) != 1 {
t.Fatalf("expected one failure notification, got %d", len(enabledForFailure))
}
stored.OnSuccess = true
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
enabledForSuccess, err := repo.ListEnabledForEvent(ctx, true)
if err != nil {
t.Fatalf("ListEnabledForEvent returned error: %v", err)
}
if len(enabledForSuccess) != 1 {
t.Fatalf("expected one success notification, got %d", len(enabledForSuccess))
}
if err := repo.Delete(ctx, item.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
}

View File

@@ -0,0 +1,48 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type OAuthSessionRepository interface {
Create(context.Context, *model.OAuthSession) error
Update(context.Context, *model.OAuthSession) error
FindByState(context.Context, string) (*model.OAuthSession, error)
DeleteExpired(context.Context, time.Time) error
}
type GormOAuthSessionRepository struct {
db *gorm.DB
}
func NewOAuthSessionRepository(db *gorm.DB) *GormOAuthSessionRepository {
return &GormOAuthSessionRepository{db: db}
}
func (r *GormOAuthSessionRepository) Create(ctx context.Context, item *model.OAuthSession) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormOAuthSessionRepository) Update(ctx context.Context, item *model.OAuthSession) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormOAuthSessionRepository) FindByState(ctx context.Context, state string) (*model.OAuthSession, error) {
var item model.OAuthSession
if err := r.db.WithContext(ctx).Where("state = ?", state).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormOAuthSessionRepository) DeleteExpired(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("expires_at <= ?", before).Delete(&model.OAuthSession{}).Error
}

View File

@@ -0,0 +1,73 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newOAuthSessionTestRepository(t *testing.T) *GormOAuthSessionRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
return NewOAuthSessionRepository(db)
}
func TestOAuthSessionRepositoryCRUDAndDeleteExpired(t *testing.T) {
ctx := context.Background()
repo := newOAuthSessionTestRepository(t)
expiresAt := time.Now().UTC().Add(5 * time.Minute)
session := &model.OAuthSession{
ProviderType: "google_drive",
State: "oauth-state",
PayloadCiphertext: "ciphertext",
ExpiresAt: expiresAt,
}
if err := repo.Create(ctx, session); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByState(ctx, "oauth-state")
if err != nil {
t.Fatalf("FindByState returned error: %v", err)
}
if stored == nil || stored.State != "oauth-state" {
t.Fatalf("unexpected stored session: %#v", stored)
}
now := time.Now().UTC()
stored.UsedAt = &now
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
if err := repo.DeleteExpired(ctx, time.Now().UTC().Add(-time.Minute)); err != nil {
t.Fatalf("DeleteExpired returned error: %v", err)
}
stillThere, err := repo.FindByState(ctx, "oauth-state")
if err != nil {
t.Fatalf("FindByState after DeleteExpired returned error: %v", err)
}
if stillThere == nil {
t.Fatalf("expected unexpired session to remain")
}
if err := repo.DeleteExpired(ctx, time.Now().UTC().Add(10*time.Minute)); err != nil {
t.Fatalf("DeleteExpired returned error: %v", err)
}
deleted, err := repo.FindByState(ctx, "oauth-state")
if err != nil {
t.Fatalf("FindByState after expiration delete returned error: %v", err)
}
if deleted != nil {
t.Fatalf("expected session to be deleted, got %#v", deleted)
}
}

View File

@@ -0,0 +1,68 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type StorageTargetRepository interface {
List(context.Context) ([]model.StorageTarget, error)
FindByID(context.Context, uint) (*model.StorageTarget, error)
FindByName(context.Context, string) (*model.StorageTarget, error)
Create(context.Context, *model.StorageTarget) error
Update(context.Context, *model.StorageTarget) error
Delete(context.Context, uint) error
}
type GormStorageTargetRepository struct {
db *gorm.DB
}
func NewStorageTargetRepository(db *gorm.DB) *GormStorageTargetRepository {
return &GormStorageTargetRepository{db: db}
}
func (r *GormStorageTargetRepository) List(ctx context.Context) ([]model.StorageTarget, error) {
var items []model.StorageTarget
if err := r.db.WithContext(ctx).Order("updated_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormStorageTargetRepository) FindByID(ctx context.Context, id uint) (*model.StorageTarget, error) {
var item model.StorageTarget
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormStorageTargetRepository) FindByName(ctx context.Context, name string) (*model.StorageTarget, error) {
var item model.StorageTarget
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormStorageTargetRepository) Create(ctx context.Context, item *model.StorageTarget) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormStorageTargetRepository) Update(ctx context.Context, item *model.StorageTarget) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormStorageTargetRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.StorageTarget{}, id).Error
}

View File

@@ -0,0 +1,81 @@
package repository
import (
"context"
"path/filepath"
"testing"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func openTestDB(t *testing.T) context.Context {
t.Helper()
return context.Background()
}
func newStorageTestRepository(t *testing.T) *GormStorageTargetRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
return NewStorageTargetRepository(db)
}
func TestStorageTargetRepositoryCRUD(t *testing.T) {
ctx := openTestDB(t)
repo := newStorageTestRepository(t)
item := &model.StorageTarget{
Name: "local",
Type: "local_disk",
Enabled: true,
ConfigCiphertext: "ciphertext",
ConfigVersion: 1,
LastTestStatus: "unknown",
}
if err := repo.Create(ctx, item); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByID(ctx, item.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if stored == nil || stored.Name != "local" {
t.Fatalf("unexpected stored target: %#v", stored)
}
byName, err := repo.FindByName(ctx, "local")
if err != nil {
t.Fatalf("FindByName returned error: %v", err)
}
if byName == nil || byName.ID != item.ID {
t.Fatalf("expected target lookup by name to match, got %#v", byName)
}
stored.Description = "updated"
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
items, err := repo.List(ctx)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(items) != 1 || items[0].Description != "updated" {
t.Fatalf("unexpected list result: %#v", items)
}
if err := repo.Delete(ctx, item.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
deleted, err := repo.FindByID(ctx, item.ID)
if err != nil {
t.Fatalf("FindByID after delete returned error: %v", err)
}
if deleted != nil {
t.Fatalf("expected target to be deleted, got %#v", deleted)
}
}

View File

@@ -0,0 +1,50 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type SystemConfigRepository interface {
GetByKey(context.Context, string) (*model.SystemConfig, error)
List(context.Context) ([]model.SystemConfig, error)
Upsert(context.Context, *model.SystemConfig) error
}
type GormSystemConfigRepository struct {
db *gorm.DB
}
func NewSystemConfigRepository(db *gorm.DB) *GormSystemConfigRepository {
return &GormSystemConfigRepository{db: db}
}
func (r *GormSystemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) {
var item model.SystemConfig
if err := r.db.WithContext(ctx).Where("key = ?", key).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormSystemConfigRepository) List(ctx context.Context) ([]model.SystemConfig, error) {
var items []model.SystemConfig
if err := r.db.WithContext(ctx).Order("key ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormSystemConfigRepository) Upsert(ctx context.Context, item *model.SystemConfig) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "encrypted", "updated_at"}),
}).Create(item).Error
}

View File

@@ -0,0 +1,63 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type UserRepository interface {
Count(context.Context) (int64, error)
Create(context.Context, *model.User) error
Update(context.Context, *model.User) error
FindByUsername(context.Context, string) (*model.User, error)
FindByID(context.Context, uint) (*model.User, error)
}
type GormUserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *GormUserRepository {
return &GormUserRepository{db: db}
}
func (r *GormUserRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormUserRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *GormUserRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *GormUserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *GormUserRepository) FindByID(ctx context.Context, id uint) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}

View File

@@ -0,0 +1,109 @@
package scheduler
import (
"context"
"fmt"
"sync"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
servicepkg "backupx/server/internal/service"
"github.com/robfig/cron/v3"
"go.uber.org/zap"
)
type TaskRunner interface {
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
}
type Service struct {
mu sync.Mutex
cron *cron.Cron
tasks repository.BackupTaskRepository
runner TaskRunner
logger *zap.Logger
entries map[uint]cron.EntryID
}
func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger *zap.Logger) *Service {
parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
}
func (s *Service) Start(ctx context.Context) error {
if err := s.Reload(ctx); err != nil {
return err
}
s.cron.Start()
return nil
}
func (s *Service) Stop(ctx context.Context) error {
stopCtx := s.cron.Stop()
select {
case <-stopCtx.Done():
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) Reload(ctx context.Context) error {
items, err := s.tasks.ListSchedulable(ctx)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for taskID, entryID := range s.entries {
s.cron.Remove(entryID)
delete(s.entries, taskID)
}
for _, item := range items {
item := item
if err := s.syncTaskLocked(&item); err != nil {
return err
}
}
return nil
}
func (s *Service) SyncTask(_ context.Context, task *model.BackupTask) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.syncTaskLocked(task)
}
func (s *Service) RemoveTask(_ context.Context, taskID uint) error {
s.mu.Lock()
defer s.mu.Unlock()
if entryID, ok := s.entries[taskID]; ok {
s.cron.Remove(entryID)
delete(s.entries, taskID)
}
return nil
}
func (s *Service) syncTaskLocked(task *model.BackupTask) error {
if task == nil {
return fmt.Errorf("task is required")
}
if entryID, ok := s.entries[task.ID]; ok {
s.cron.Remove(entryID)
delete(s.entries, task.ID)
}
if !task.Enabled || task.CronExpr == "" {
return nil
}
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
if _, runErr := s.runner.RunTaskByID(context.Background(), task.ID); runErr != nil && s.logger != nil {
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", task.ID), zap.Error(runErr))
}
})
if err != nil {
return err
}
s.entries[task.ID] = entryID
return nil
}

View File

@@ -0,0 +1,58 @@
package scheduler
import (
"backupx/server/internal/repository"
servicepkg "backupx/server/internal/service"
"context"
"testing"
"time"
"backupx/server/internal/model"
)
type fakeTaskRepository struct {
items []model.BackupTask
}
func (r *fakeTaskRepository) List(context.Context, repository.BackupTaskListOptions) ([]model.BackupTask, error) {
return nil, nil
}
func (r *fakeTaskRepository) FindByID(context.Context, uint) (*model.BackupTask, error) {
return nil, nil
}
func (r *fakeTaskRepository) FindByName(context.Context, string) (*model.BackupTask, error) {
return nil, nil
}
func (r *fakeTaskRepository) ListSchedulable(context.Context) ([]model.BackupTask, error) {
return r.items, nil
}
func (r *fakeTaskRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *fakeTaskRepository) CountEnabled(context.Context) (int64, error) { return 0, nil }
func (r *fakeTaskRepository) CountByStorageTargetID(context.Context, uint) (int64, error) {
return 0, nil
}
func (r *fakeTaskRepository) Create(context.Context, *model.BackupTask) error { return nil }
func (r *fakeTaskRepository) Update(context.Context, *model.BackupTask) error { return nil }
func (r *fakeTaskRepository) Delete(context.Context, uint) error { return nil }
type fakeRunner struct{ taskIDs []uint }
func (r *fakeRunner) RunTaskByID(_ context.Context, id uint) (*servicepkg.BackupRecordDetail, error) {
r.taskIDs = append(r.taskIDs, id)
return nil, nil
}
func TestServiceSyncTaskAndTrigger(t *testing.T) {
repo := &fakeTaskRepository{}
runner := &fakeRunner{}
service := NewService(repo, runner, nil)
if err := service.SyncTask(context.Background(), &model.BackupTask{ID: 1, Enabled: true, CronExpr: "*/1 * * * * *"}); err != nil {
t.Fatalf("SyncTask returned error: %v", err)
}
service.cron.Start()
defer service.cron.Stop()
time.Sleep(1100 * time.Millisecond)
if len(runner.taskIDs) == 0 {
t.Fatalf("expected scheduled runner to be triggered")
}
}

View File

@@ -0,0 +1,60 @@
//go:build ignore
package security
import (
"fmt"
"time"
"backupx/server/internal/model"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secret []byte
duration time.Duration
}
func NewJWTManager(secret string, duration time.Duration) *JWTManager {
return &JWTManager{secret: []byte(secret), duration: duration}
}
func (m *JWTManager) IssueToken(user *model.User) (string, error) {
now := time.Now().UTC()
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", user.ID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.duration)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) Parse(tokenValue string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}

View File

@@ -0,0 +1,25 @@
//go:build ignore
package security
import (
"testing"
"time"
"backupx/server/internal/model"
)
func TestJWTManagerIssueAndParse(t *testing.T) {
manager := NewJWTManager("test-secret", time.Hour)
token, err := manager.IssueToken(&model.User{ID: 7, Username: "admin", Role: "admin"})
if err != nil {
t.Fatalf("IssueToken() error = %v", err)
}
claims, err := manager.Parse(token)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if claims.UserID != 7 || claims.Username != "admin" {
t.Fatalf("unexpected claims: %+v", claims)
}
}

View File

@@ -0,0 +1,54 @@
//go:build ignore
package security
import (
"sync"
"time"
)
type limiterEntry struct {
Count int
ResetAt time.Time
}
type LoginLimiter struct {
mu sync.Mutex
window time.Duration
max int
records map[string]limiterEntry
}
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
return &LoginLimiter{window: window, max: max, records: make(map[string]limiterEntry)}
}
func (l *LoginLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
entry, ok := l.records[key]
if !ok || time.Now().After(entry.ResetAt) {
delete(l.records, key)
return true
}
return entry.Count < l.max
}
func (l *LoginLimiter) RegisterFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
entry, ok := l.records[key]
if !ok || now.After(entry.ResetAt) {
l.records[key] = limiterEntry{Count: 1, ResetAt: now.Add(l.window)}
return
}
entry.Count++
l.records[key] = entry
}
func (l *LoginLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.records, key)
}

View File

@@ -0,0 +1,17 @@
package security
import "golang.org/x/crypto/bcrypt"
const PasswordCost = 12
func HashPassword(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), PasswordCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func ComparePassword(hashedPassword, plainPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
}

View File

@@ -0,0 +1,16 @@
package security
import "testing"
func TestHashAndComparePassword(t *testing.T) {
hash, err := HashPassword("super-secret-password")
if err != nil {
t.Fatalf("HashPassword returned error: %v", err)
}
if hash == "super-secret-password" {
t.Fatalf("expected hashed password to differ from plain text")
}
if err := ComparePassword(hash, "super-secret-password"); err != nil {
t.Fatalf("ComparePassword returned error: %v", err)
}
}

View File

@@ -0,0 +1,50 @@
package security
import (
"sync"
"time"
)
type rateEntry struct {
count int
windowEnd time.Time
}
type LoginRateLimiter struct {
limit int
window time.Duration
mu sync.Mutex
items map[string]rateEntry
}
func NewLoginRateLimiter(limit int, window time.Duration) *LoginRateLimiter {
return &LoginRateLimiter{
limit: limit,
window: window,
items: make(map[string]rateEntry),
}
}
func (r *LoginRateLimiter) Allow(key string) bool {
now := time.Now().UTC()
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.items[key]
if !ok || now.After(entry.windowEnd) {
r.items[key] = rateEntry{count: 0, windowEnd: now.Add(r.window)}
entry = r.items[key]
}
if entry.count >= r.limit {
return false
}
entry.count++
r.items[key] = entry
return true
}
func (r *LoginRateLimiter) Reset(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.items, key)
}

View File

@@ -0,0 +1,14 @@
package security
import (
"crypto/rand"
"encoding/base64"
)
func GenerateSecret(bytesLength int) (string, error) {
buffer := make([]byte, bytesLength)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}

View File

@@ -0,0 +1,93 @@
//go:build ignore
package security
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"backupx/server/internal/config"
)
type PersistedSecrets struct {
JWTSecret string `json:"jwtSecret"`
EncryptionKey string `json:"encryptionKey"`
}
func EnsureSecrets(cfg *config.Config) error {
if cfg.Security.JWTSecret != "" && cfg.Security.EncryptionKey != "" {
return nil
}
storePath := filepath.Join(filepath.Dir(cfg.Database.Path), "backupx.secrets.json")
current, err := loadSecrets(storePath)
if err != nil {
return err
}
if current == nil {
current = &PersistedSecrets{}
}
if current.JWTSecret == "" {
current.JWTSecret, err = randomHex(32)
if err != nil {
return err
}
}
if current.EncryptionKey == "" {
current.EncryptionKey, err = randomHex(32)
if err != nil {
return err
}
}
if err := saveSecrets(storePath, current); err != nil {
return err
}
if cfg.Security.JWTSecret == "" {
cfg.Security.JWTSecret = current.JWTSecret
}
if cfg.Security.EncryptionKey == "" {
cfg.Security.EncryptionKey = current.EncryptionKey
}
return nil
}
func loadSecrets(path string) (*PersistedSecrets, error) {
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read secrets: %w", err)
}
var secrets PersistedSecrets
if err := json.Unmarshal(content, &secrets); err != nil {
return nil, fmt.Errorf("decode secrets: %w", err)
}
return &secrets, nil
}
func saveSecrets(path string, secrets *PersistedSecrets) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create secrets dir: %w", err)
}
content, err := json.MarshalIndent(secrets, "", " ")
if err != nil {
return fmt.Errorf("encode secrets: %w", err)
}
if err := os.WriteFile(path, content, 0o600); err != nil {
return fmt.Errorf("write secrets: %w", err)
}
return nil
}
func randomHex(size int) (string, error) {
bytes := make([]byte, size)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate random secret: %w", err)
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,57 @@
package security
import (
"fmt"
"strconv"
"time"
"backupx/server/internal/model"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secret []byte
expiry time.Duration
}
func NewJWTManager(secret string, expiry time.Duration) *JWTManager {
return &JWTManager{secret: []byte(secret), expiry: expiry}
}
func (m *JWTManager) Generate(user *model.User) (string, error) {
now := time.Now().UTC()
claims := Claims{
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.FormatUint(uint64(user.ID), 10),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.expiry)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) Parse(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}

View File

@@ -0,0 +1,30 @@
package security
import (
"testing"
"time"
"backupx/server/internal/model"
)
func TestJWTManagerGenerateAndParse(t *testing.T) {
manager := NewJWTManager("test-secret", time.Hour)
user := &model.User{ID: 7, Username: "admin", Role: "admin"}
token, err := manager.Generate(user)
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
claims, err := manager.Parse(token)
if err != nil {
t.Fatalf("Parse returned error: %v", err)
}
if claims.Subject != "7" {
t.Fatalf("expected subject 7, got %s", claims.Subject)
}
if claims.Username != "admin" {
t.Fatalf("expected username admin, got %s", claims.Username)
}
}

View File

@@ -0,0 +1,194 @@
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/security"
)
type SetupInput struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
DisplayName string `json:"displayName" binding:"required,min=1,max=128"`
}
type LoginInput struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
type AuthPayload struct {
Token string `json:"token"`
User *UserOutput `json:"user"`
}
type UserOutput struct {
ID uint `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
}
type AuthService struct {
users repository.UserRepository
configs repository.SystemConfigRepository
jwtManager *security.JWTManager
rateLimiter *security.LoginRateLimiter
}
func NewAuthService(
users repository.UserRepository,
configs repository.SystemConfigRepository,
jwtManager *security.JWTManager,
rateLimiter *security.LoginRateLimiter,
) *AuthService {
return &AuthService{users: users, configs: configs, jwtManager: jwtManager, rateLimiter: rateLimiter}
}
func (s *AuthService) SetupStatus(ctx context.Context) (bool, error) {
count, err := s.users.Count(ctx)
if err != nil {
return false, apperror.Internal("AUTH_STATUS_FAILED", "无法检查初始化状态", err)
}
return count > 0, nil
}
func (s *AuthService) Setup(ctx context.Context, input SetupInput) (*AuthPayload, error) {
initialized, err := s.SetupStatus(ctx)
if err != nil {
return nil, err
}
if initialized {
return nil, apperror.Conflict("AUTH_SETUP_DISABLED", "系统已初始化,请直接登录", nil)
}
existing, err := s.users.FindByUsername(ctx, strings.TrimSpace(input.Username))
if err != nil {
return nil, apperror.Internal("AUTH_LOOKUP_FAILED", "无法检查账户状态", err)
}
if existing != nil {
return nil, apperror.Conflict("AUTH_USERNAME_EXISTS", "用户名已存在", nil)
}
hash, err := security.HashPassword(input.Password)
if err != nil {
return nil, apperror.Internal("AUTH_HASH_FAILED", "无法处理密码", err)
}
user := &model.User{
Username: strings.TrimSpace(input.Username),
PasswordHash: hash,
DisplayName: strings.TrimSpace(input.DisplayName),
Role: "admin",
}
if err := s.users.Create(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_CREATE_USER_FAILED", "无法创建管理员账户", err)
}
token, err := s.jwtManager.Generate(user)
if err != nil {
return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err)
}
return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil
}
func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey string) (*AuthPayload, error) {
if clientKey == "" {
clientKey = "unknown"
}
if !s.rateLimiter.Allow(clientKey) {
return nil, apperror.TooManyRequests("AUTH_RATE_LIMITED", "登录尝试过于频繁,请稍后再试", nil)
}
user, err := s.users.FindByUsername(ctx, strings.TrimSpace(input.Username))
if err != nil {
return nil, apperror.Internal("AUTH_LOOKUP_FAILED", "无法执行登录校验", err)
}
if user == nil {
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", nil)
}
if err := security.ComparePassword(user.PasswordHash, input.Password); err != nil {
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", err)
}
s.rateLimiter.Reset(clientKey)
token, err := s.jwtManager.Generate(user)
if err != nil {
return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err)
}
return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil
}
func (s *AuthService) GetCurrentUser(ctx context.Context, subject string) (*UserOutput, error) {
userID, err := strconv.ParseUint(subject, 10, 64)
if err != nil {
return nil, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
}
user, err := s.users.FindByID(ctx, uint(userID))
if err != nil {
return nil, apperror.Internal("AUTH_LOOKUP_FAILED", "无法获取当前用户", err)
}
if user == nil {
return nil, apperror.Unauthorized("AUTH_USER_NOT_FOUND", "当前用户不存在", errors.New("user not found"))
}
return ToUserOutput(user), nil
}
type ChangePasswordInput struct {
OldPassword string `json:"oldPassword" binding:"required,min=8,max=128"`
NewPassword string `json:"newPassword" binding:"required,min=8,max=128"`
}
func (s *AuthService) ChangePassword(ctx context.Context, subject string, input ChangePasswordInput) error {
userID, err := strconv.ParseUint(subject, 10, 64)
if err != nil {
return apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
}
user, err := s.users.FindByID(ctx, uint(userID))
if err != nil {
return apperror.Internal("AUTH_LOOKUP_FAILED", "无法获取当前用户", err)
}
if user == nil {
return apperror.Unauthorized("AUTH_USER_NOT_FOUND", "当前用户不存在", errors.New("user not found"))
}
if err := security.ComparePassword(user.PasswordHash, input.OldPassword); err != nil {
return apperror.BadRequest("AUTH_WRONG_PASSWORD", "旧密码不正确", err)
}
hash, err := security.HashPassword(input.NewPassword)
if err != nil {
return apperror.Internal("AUTH_HASH_FAILED", "无法处理密码", err)
}
user.PasswordHash = hash
if err := s.users.Update(ctx, user); err != nil {
return apperror.Internal("AUTH_UPDATE_FAILED", "密码修改失败", err)
}
return nil
}
func ToUserOutput(user *model.User) *UserOutput {
if user == nil {
return nil
}
return &UserOutput{
ID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
Role: user.Role,
}
}
func SubjectFromContextValue(value any) (string, error) {
subject, ok := value.(string)
if !ok || strings.TrimSpace(subject) == "" {
return "", fmt.Errorf("invalid subject context")
}
return subject, nil
}

View File

@@ -0,0 +1,162 @@
package service
import (
"context"
"testing"
"time"
"backupx/server/internal/model"
"backupx/server/internal/security"
)
type fakeUserRepository struct {
users []*model.User
}
func (r *fakeUserRepository) Count(context.Context) (int64, error) {
return int64(len(r.users)), nil
}
func (r *fakeUserRepository) Create(_ context.Context, user *model.User) error {
user.ID = uint(len(r.users) + 1)
r.users = append(r.users, user)
return nil
}
func (r *fakeUserRepository) FindByUsername(_ context.Context, username string) (*model.User, error) {
for _, user := range r.users {
if user.Username == username {
return user, nil
}
}
return nil, nil
}
func (r *fakeUserRepository) FindByID(_ context.Context, id uint) (*model.User, error) {
for _, user := range r.users {
if user.ID == id {
return user, nil
}
}
return nil, nil
}
func (r *fakeUserRepository) Update(_ context.Context, user *model.User) error {
for i, u := range r.users {
if u.ID == user.ID {
r.users[i] = user
return nil
}
}
return nil
}
type fakeSystemConfigRepository struct{}
func (r *fakeSystemConfigRepository) GetByKey(context.Context, string) (*model.SystemConfig, error) {
return nil, nil
}
func (r *fakeSystemConfigRepository) List(context.Context) ([]model.SystemConfig, error) {
return nil, nil
}
func (r *fakeSystemConfigRepository) Upsert(context.Context, *model.SystemConfig) error {
return nil
}
func TestAuthServiceSetupAndLogin(t *testing.T) {
users := &fakeUserRepository{}
service := NewAuthService(
users,
&fakeSystemConfigRepository{},
security.NewJWTManager("test-secret", time.Hour),
security.NewLoginRateLimiter(5, time.Minute),
)
setupResult, err := service.Setup(context.Background(), SetupInput{
Username: "admin",
Password: "password-123",
DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup returned error: %v", err)
}
if setupResult.User.Username != "admin" {
t.Fatalf("expected username admin, got %s", setupResult.User.Username)
}
loginResult, err := service.Login(context.Background(), LoginInput{
Username: "admin",
Password: "password-123",
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
if loginResult.Token == "" {
t.Fatalf("expected non-empty token")
}
}
func newTestAuthService() (*AuthService, *fakeUserRepository) {
users := &fakeUserRepository{}
svc := NewAuthService(
users,
&fakeSystemConfigRepository{},
security.NewJWTManager("test-secret", time.Hour),
security.NewLoginRateLimiter(5, time.Minute),
)
return svc, users
}
func TestChangePassword(t *testing.T) {
svc, _ := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
err = svc.ChangePassword(context.Background(), "1", ChangePasswordInput{
OldPassword: "password-123",
NewPassword: "new-password-456",
})
if err != nil {
t.Fatalf("ChangePassword: %v", err)
}
// Old password should no longer work
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123",
}, "127.0.0.1")
if err == nil {
t.Fatalf("expected login with old password to fail")
}
// New password should work
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "new-password-456",
}, "127.0.0.1")
if err != nil {
t.Fatalf("login with new password: %v", err)
}
}
func TestChangePasswordWrongOld(t *testing.T) {
svc, _ := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
err = svc.ChangePassword(context.Background(), "1", ChangePasswordInput{
OldPassword: "wrong-password",
NewPassword: "new-password-456",
})
if err == nil {
t.Fatalf("expected ChangePassword with wrong old password to fail")
}
}

View File

@@ -0,0 +1,487 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
backupretention "backupx/server/internal/backup/retention"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/pkg/compress"
backupcrypto "backupx/server/pkg/crypto"
)
type BackupExecutionNotification struct {
Task *model.BackupTask
Record *model.BackupRecord
Error error
}
type BackupResultNotifier interface {
NotifyBackupResult(context.Context, BackupExecutionNotification) error
}
type noopBackupNotifier struct{}
func (noopBackupNotifier) NotifyBackupResult(context.Context, BackupExecutionNotification) error {
return nil
}
type DownloadedArtifact struct {
FileName string
Reader io.ReadCloser
}
type BackupExecutionService struct {
tasks repository.BackupTaskRepository
records repository.BackupRecordRepository
targets repository.StorageTargetRepository
storageRegistry *storage.Registry
runnerRegistry *backup.Registry
logHub *backup.LogHub
retention *backupretention.Service
cipher *codec.ConfigCipher
notifier BackupResultNotifier
async func(func())
now func() time.Time
tempDir string
semaphore chan struct{}
}
func NewBackupExecutionService(
tasks repository.BackupTaskRepository,
records repository.BackupRecordRepository,
targets repository.StorageTargetRepository,
storageRegistry *storage.Registry,
runnerRegistry *backup.Registry,
logHub *backup.LogHub,
retention *backupretention.Service,
cipher *codec.ConfigCipher,
notifier BackupResultNotifier,
tempDir string,
maxConcurrent int,
) *BackupExecutionService {
if notifier == nil {
notifier = noopBackupNotifier{}
}
if tempDir == "" {
tempDir = "/tmp/backupx"
}
if maxConcurrent <= 0 {
maxConcurrent = 2
}
return &BackupExecutionService{
tasks: tasks,
records: records,
targets: targets,
storageRegistry: storageRegistry,
runnerRegistry: runnerRegistry,
logHub: logHub,
retention: retention,
cipher: cipher,
notifier: notifier,
async: func(job func()) {
go job()
},
now: func() time.Time { return time.Now().UTC() },
tempDir: tempDir,
semaphore: make(chan struct{}, maxConcurrent),
}
}
func (s *BackupExecutionService) RunTaskByID(ctx context.Context, id uint) (*BackupRecordDetail, error) {
return s.startTask(ctx, id, true)
}
func (s *BackupExecutionService) RunTaskByIDSync(ctx context.Context, id uint) (*BackupRecordDetail, error) {
return s.startTask(ctx, id, false)
}
func (s *BackupExecutionService) DownloadRecord(ctx context.Context, recordID uint) (*DownloadedArtifact, error) {
record, provider, err := s.loadRecordProvider(ctx, recordID)
if err != nil {
return nil, err
}
reader, err := provider.Download(ctx, record.StoragePath)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_DOWNLOAD_FAILED", "无法下载备份文件", err)
}
fileName := record.FileName
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(record.StoragePath)
}
return &DownloadedArtifact{FileName: fileName, Reader: reader}, nil
}
func (s *BackupExecutionService) RestoreRecord(ctx context.Context, recordID uint) error {
record, provider, err := s.loadRecordProvider(ctx, recordID)
if err != nil {
return err
}
task, err := s.tasks.FindByID(ctx, record.TaskID)
if err != nil {
return apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取关联备份任务", err)
}
if task == nil {
return apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在,无法执行恢复", fmt.Errorf("backup task %d not found", record.TaskID))
}
tempDir, err := os.MkdirTemp("", "backupx-restore-*")
if err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法创建恢复目录", err)
}
defer os.RemoveAll(tempDir)
artifactPath := filepath.Join(tempDir, filepath.Base(record.FileName))
if strings.TrimSpace(filepath.Base(record.FileName)) == "" {
artifactPath = filepath.Join(tempDir, filepath.Base(record.StoragePath))
}
reader, err := provider.Download(ctx, record.StoragePath)
if err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法下载备份文件", err)
}
if err := writeReaderToFile(artifactPath, reader); err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法写入恢复文件", err)
}
preparedPath, err := s.prepareArtifactForRestore(artifactPath)
if err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法准备恢复文件", err)
}
spec, err := s.buildTaskSpec(task, record.StartedAt)
if err != nil {
return err
}
runner, err := s.runnerRegistry.Runner(spec.Type)
if err != nil {
return apperror.BadRequest("BACKUP_TASK_INVALID", "不支持的备份任务类型", err)
}
if err := runner.Restore(ctx, spec, preparedPath, backup.NopLogWriter{}); err != nil {
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "恢复备份失败", err)
}
return nil
}
func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint) error {
record, provider, err := s.loadRecordProvider(ctx, recordID)
if err != nil {
return err
}
if strings.TrimSpace(record.StoragePath) != "" {
if err := provider.Delete(ctx, record.StoragePath); err != nil {
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法删除备份文件", err)
}
}
if err := s.records.Delete(ctx, recordID); err != nil {
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法删除备份记录", err)
}
return nil
}
func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async bool) (*BackupRecordDetail, error) {
task, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if task == nil {
return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
startedAt := s.now()
record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: task.StorageTargetID, Status: "running", StartedAt: startedAt}
if err := s.records.Create(ctx, record); err != nil {
return nil, apperror.Internal("BACKUP_RECORD_CREATE_FAILED", "无法创建备份记录", err)
}
task.LastRunAt = &startedAt
task.LastStatus = "running"
if err := s.tasks.Update(ctx, task); err != nil {
return nil, apperror.Internal("BACKUP_TASK_UPDATE_FAILED", "无法更新任务状态", err)
}
run := func() {
s.executeTask(context.Background(), task, record.ID, startedAt)
}
if async {
s.async(run)
} else {
run()
}
return s.getRecordDetail(ctx, record.ID)
}
func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time) {
s.semaphore <- struct{}{}
defer func() { <-s.semaphore }()
logger := backup.NewExecutionLogger(recordID, s.logHub)
status := "failed"
errMessage := ""
var fileName string
var fileSize int64
var storagePath string
completeRecord := func() {
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, storagePath); finalizeErr != nil {
logger.Errorf("写回备份记录失败:%v", finalizeErr)
}
if err := s.notifier.NotifyBackupResult(ctx, BackupExecutionNotification{Task: task, Record: &model.BackupRecord{ID: recordID, TaskID: task.ID, Status: status, FileName: fileName, FileSize: fileSize, StoragePath: storagePath, ErrorMessage: errMessage, StartedAt: startedAt}, Error: buildOptionalError(errMessage)}); err != nil {
logger.Warnf("发送备份通知失败:%v", err)
}
s.logHub.Complete(recordID, status)
}
defer completeRecord()
spec, err := s.buildTaskSpec(task, startedAt)
if err != nil {
errMessage = err.Error()
logger.Errorf("构建任务运行时配置失败:%v", err)
return
}
provider, err := s.resolveProvider(ctx, task.StorageTargetID)
if err != nil {
errMessage = err.Error()
logger.Errorf("创建存储客户端失败:%v", err)
return
}
runner, err := s.runnerRegistry.Runner(spec.Type)
if err != nil {
errMessage = err.Error()
logger.Errorf("获取备份执行器失败:%v", err)
return
}
result, err := runner.Run(ctx, spec, logger)
if err != nil {
errMessage = err.Error()
logger.Errorf("执行备份失败:%v", err)
return
}
defer os.RemoveAll(result.TempDir)
finalPath := result.ArtifactPath
if strings.EqualFold(task.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
logger.Infof("开始压缩备份文件")
compressedPath, compressErr := compress.GzipFile(finalPath)
if compressErr != nil {
errMessage = compressErr.Error()
logger.Errorf("压缩备份文件失败:%v", compressErr)
return
}
finalPath = compressedPath
}
if task.Encrypt {
logger.Infof("开始加密备份文件")
encryptedPath, encryptErr := backupcrypto.EncryptFile(s.cipher.Key(), finalPath)
if encryptErr != nil {
errMessage = encryptErr.Error()
logger.Errorf("加密备份文件失败:%v", encryptErr)
return
}
finalPath = encryptedPath
}
info, err := os.Stat(finalPath)
if err != nil {
errMessage = err.Error()
logger.Errorf("获取备份文件信息失败:%v", err)
return
}
fileSize = info.Size()
fileName = filepath.Base(finalPath)
storagePath = backup.BuildStorageKey(task.Type, startedAt, fileName)
artifact, err := os.Open(finalPath)
if err != nil {
errMessage = err.Error()
logger.Errorf("打开备份文件失败:%v", err)
return
}
defer artifact.Close()
logger.Infof("开始上传备份到存储目标")
if err := provider.Upload(ctx, storagePath, artifact, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); err != nil {
errMessage = err.Error()
logger.Errorf("上传备份文件失败:%v", err)
return
}
if s.retention != nil {
cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider)
if cleanupErr != nil {
logger.Warnf("执行保留策略失败:%v", cleanupErr)
} else {
for _, warning := range cleanupResult.Warnings {
logger.Warnf("保留策略警告:%s", warning)
}
}
}
status = "success"
logger.Infof("备份执行完成")
}
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, storagePath string) error {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
return err
}
if record == nil {
return fmt.Errorf("backup record %d not found", recordID)
}
completedAt := s.now()
record.Status = status
record.FileName = fileName
record.FileSize = fileSize
record.StoragePath = storagePath
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
record.ErrorMessage = strings.TrimSpace(errorMessage)
record.LogContent = strings.TrimSpace(logContent)
record.CompletedAt = &completedAt
if err := s.records.Update(ctx, record); err != nil {
return err
}
task.LastRunAt = &startedAt
task.LastStatus = status
return s.tasks.Update(ctx, task)
}
func (s *BackupExecutionService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
target, err := s.targets.FindByID(ctx, targetID)
if err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
}
if target == nil {
return nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
}
configMap := map[string]any{}
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
}
provider, err := s.storageRegistry.Create(ctx, target.Type, configMap)
if err != nil {
return nil, err
}
return provider, nil
}
func (s *BackupExecutionService) buildTaskSpec(task *model.BackupTask, startedAt time.Time) (backup.TaskSpec, error) {
excludePatterns := []string{}
if strings.TrimSpace(task.ExcludePatterns) != "" {
if err := json.Unmarshal([]byte(task.ExcludePatterns), &excludePatterns); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析排除规则", err)
}
}
password := ""
if strings.TrimSpace(task.DBPasswordCiphertext) != "" {
plain, err := s.cipher.Decrypt(task.DBPasswordCiphertext)
if err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECRYPT_FAILED", "无法解密数据库密码", err)
}
password = string(plain)
}
return backup.TaskSpec{
ID: task.ID,
Name: task.Name,
Type: task.Type,
SourcePath: task.SourcePath,
ExcludePatterns: excludePatterns,
StorageTargetID: task.StorageTargetID,
StorageTargetType: "",
Compression: task.Compression,
Encrypt: task.Encrypt,
RetentionDays: task.RetentionDays,
MaxBackups: task.MaxBackups,
StartedAt: startedAt,
TempDir: s.tempDir,
Database: backup.DatabaseSpec{
Host: task.DBHost,
Port: task.DBPort,
User: task.DBUser,
Password: password,
Names: []string{task.DBName},
Path: task.DBPath,
},
}, nil
}
func (s *BackupExecutionService) loadRecordProvider(ctx context.Context, recordID uint) (*model.BackupRecord, storage.StorageProvider, error) {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
return nil, nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if record == nil {
return nil, nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
}
provider, err := s.resolveProvider(ctx, record.StorageTargetID)
if err != nil {
return nil, nil, err
}
return record, provider, nil
}
func (s *BackupExecutionService) prepareArtifactForRestore(artifactPath string) (string, error) {
currentPath := artifactPath
if strings.HasSuffix(strings.ToLower(currentPath), ".enc") {
decryptedPath, err := backupcrypto.DecryptFile(s.cipher.Key(), currentPath)
if err != nil {
return "", err
}
currentPath = decryptedPath
}
if strings.HasSuffix(strings.ToLower(currentPath), ".gz") {
decompressedPath, err := compress.GunzipFile(currentPath)
if err != nil {
return "", err
}
currentPath = decompressedPath
}
return currentPath, nil
}
func (s *BackupExecutionService) getRecordDetail(ctx context.Context, recordID uint) (*BackupRecordDetail, error) {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if record == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
}
return toBackupRecordDetail(record, s.logHub), nil
}
func writeReaderToFile(targetPath string, reader io.ReadCloser) error {
defer reader.Close()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
file, err := os.Create(targetPath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, reader)
return err
}
func buildOptionalError(message string) error {
if strings.TrimSpace(message) == "" {
return nil
}
return fmt.Errorf("%s", message)
}
func buildStorageProviderFromRepos(ctx context.Context, storageTargetID uint, storageTargets repository.StorageTargetRepository, storageRegistry *storage.Registry, cipher *codec.ConfigCipher) (storage.StorageProvider, *model.StorageTarget, error) {
target, err := storageTargets.FindByID(ctx, storageTargetID)
if err != nil {
return nil, nil, apperror.Internal("BACKUP_STORAGE_TARGET_LOOKUP_FAILED", "无法读取存储目标", err)
}
if target == nil {
return nil, nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "存储目标不存在", nil)
}
var configMap map[string]any
if err := cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
}
provider, err := storageRegistry.Create(ctx, storage.ParseProviderType(target.Type), configMap)
if err != nil {
return nil, nil, err
}
return provider, target, nil
}

View File

@@ -0,0 +1,103 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"backupx/server/internal/backup"
backupretention "backupx/server/internal/backup/retention"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/internal/storage/localdisk"
)
func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRecordService, repository.BackupTaskRepository, repository.StorageTargetRepository, repository.BackupRecordRepository, string, string) {
t.Helper()
baseDir := t.TempDir()
storageDir := filepath.Join(baseDir, "storage")
sourceDir := filepath.Join(baseDir, "source")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
cipher := codec.NewConfigCipher("execution-secret")
tasks := repository.NewBackupTaskRepository(db)
targets := repository.NewStorageTargetRepository(db)
records := repository.NewBackupRecordRepository(db)
configCiphertext, err := cipher.EncryptJSON(map[string]any{"basePath": storageDir})
if err != nil {
t.Fatalf("EncryptJSON returned error: %v", err)
}
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "local", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: configCiphertext, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("Create storage target returned error: %v", err)
}
if err := tasks.Create(context.Background(), &model.BackupTask{Name: "site-files", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}); err != nil {
t.Fatalf("Create backup task returned error: %v", err)
}
logHub := backup.NewLogHub()
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewMySQLRunner(nil), backup.NewSQLiteRunner(), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(localdisk.NewFactory())
retentionService := backupretention.NewService(records)
executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, "", 2)
recordService := NewBackupRecordService(records, executionService, logHub)
return executionService, recordService, tasks, targets, records, sourceDir, storageDir
}
func TestBackupExecutionServiceRunTaskByIDSync(t *testing.T) {
executionService, _, _, _, records, _, storageDir := newExecutionTestServices(t)
detail, err := executionService.RunTaskByIDSync(context.Background(), 1)
if err != nil {
t.Fatalf("RunTaskByIDSync returned error: %v", err)
}
if detail.Status != "success" || detail.StoragePath == "" {
t.Fatalf("unexpected record detail: %#v", detail)
}
stored, err := records.FindByID(context.Background(), detail.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if stored == nil || stored.Status != "success" {
t.Fatalf("unexpected stored record: %#v", stored)
}
if _, err := os.Stat(filepath.Join(storageDir, filepath.FromSlash(detail.StoragePath))); err != nil {
t.Fatalf("expected artifact in local storage: %v", err)
}
}
func TestBackupRecordServiceRestore(t *testing.T) {
executionService, recordService, _, _, _, sourceDir, _ := newExecutionTestServices(t)
detail, err := executionService.RunTaskByIDSync(context.Background(), 1)
if err != nil {
t.Fatalf("RunTaskByIDSync returned error: %v", err)
}
if err := os.RemoveAll(sourceDir); err != nil {
t.Fatalf("RemoveAll returned error: %v", err)
}
if err := recordService.Restore(context.Background(), detail.ID); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "index.html"))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "hello" {
t.Fatalf("unexpected restored content: %s", string(content))
}
}

View File

@@ -0,0 +1,134 @@
package service
import (
"context"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
type BackupRecordListInput struct {
TaskID *uint
Status string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
type BackupRecordSummary struct {
ID uint `json:"id"`
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
StorageTargetID uint `json:"storageTargetId"`
StorageTargetName string `json:"storageTargetName"`
Status string `json:"status"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
StoragePath string `json:"storagePath"`
DurationSeconds int `json:"durationSeconds"`
ErrorMessage string `json:"errorMessage"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type BackupRecordDetail struct {
BackupRecordSummary
LogContent string `json:"logContent"`
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
}
type BackupRecordService struct {
records repository.BackupRecordRepository
execution *BackupExecutionService
logHub *backup.LogHub
}
func NewBackupRecordService(records repository.BackupRecordRepository, execution *BackupExecutionService, logHub *backup.LogHub) *BackupRecordService {
return &BackupRecordService{records: records, execution: execution, logHub: logHub}
}
func (s *BackupRecordService) List(ctx context.Context, input BackupRecordListInput) ([]BackupRecordSummary, error) {
items, err := s.records.List(ctx, repository.BackupRecordListOptions{TaskID: input.TaskID, Status: strings.TrimSpace(input.Status), DateFrom: input.DateFrom, DateTo: input.DateTo, Limit: input.Limit, Offset: input.Offset})
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_LIST_FAILED", "无法获取备份记录列表", err)
}
result := make([]BackupRecordSummary, 0, len(items))
for _, item := range items {
result = append(result, toBackupRecordSummary(&item))
}
return result, nil
}
func (s *BackupRecordService) Get(ctx context.Context, id uint) (*BackupRecordDetail, error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if item == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", err)
}
return toBackupRecordDetail(item, s.logHub), nil
}
func (s *BackupRecordService) SubscribeLogs(ctx context.Context, id uint, buffer int) (<-chan backup.LogEvent, func(), error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if item == nil {
return nil, nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", err)
}
channel, cancel := s.logHub.Subscribe(id, buffer)
return channel, cancel, nil
}
func (s *BackupRecordService) Download(ctx context.Context, id uint) (*DownloadedArtifact, error) {
return s.execution.DownloadRecord(ctx, id)
}
func (s *BackupRecordService) Restore(ctx context.Context, id uint) error {
return s.execution.RestoreRecord(ctx, id)
}
func (s *BackupRecordService) Delete(ctx context.Context, id uint) error {
return s.execution.DeleteRecord(ctx, id)
}
func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
return BackupRecordSummary{
ID: item.ID,
TaskID: item.TaskID,
TaskName: item.Task.Name,
StorageTargetID: item.StorageTargetID,
StorageTargetName: item.StorageTarget.Name,
Status: item.Status,
FileName: item.FileName,
FileSize: item.FileSize,
StoragePath: item.StoragePath,
DurationSeconds: item.DurationSeconds,
ErrorMessage: item.ErrorMessage,
StartedAt: item.StartedAt,
CompletedAt: item.CompletedAt,
}
}
func toBackupRecordDetail(item *model.BackupRecord, logHub *backup.LogHub) *BackupRecordDetail {
detail := &BackupRecordDetail{BackupRecordSummary: toBackupRecordSummary(item), LogContent: item.LogContent}
if item.Status == "running" && logHub != nil {
events := logHub.Snapshot(item.ID)
detail.LogEvents = events
if len(events) > 0 {
lines := make([]string, 0, len(events))
for _, event := range events {
lines = append(lines, event.Message)
}
detail.LogContent = strings.Join(lines, "\n")
}
}
return detail
}

View File

@@ -0,0 +1,417 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage/codec"
)
const backupTaskMaskedValue = "********"
type BackupTaskUpsertInput struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr" binding:"max=64"`
SourcePath string `json:"sourcePath" binding:"max=500"`
ExcludePatterns []string `json:"excludePatterns"`
DBHost string `json:"dbHost" binding:"max=255"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser" binding:"max=100"`
DBPassword string `json:"dbPassword" binding:"max=255"`
DBName string `json:"dbName" binding:"max=255"`
DBPath string `json:"dbPath" binding:"max=500"`
StorageTargetID uint `json:"storageTargetId" binding:"required"`
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
Encrypt bool `json:"encrypt"`
MaxBackups int `json:"maxBackups"`
}
type BackupTaskToggleInput struct {
Enabled *bool `json:"enabled"`
}
type BackupTaskSummary struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr"`
StorageTargetID uint `json:"storageTargetId"`
StorageTargetName string `json:"storageTargetName"`
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression"`
Encrypt bool `json:"encrypt"`
MaxBackups int `json:"maxBackups"`
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
LastStatus string `json:"lastStatus"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BackupTaskDetail struct {
BackupTaskSummary
SourcePath string `json:"sourcePath"`
ExcludePatterns []string `json:"excludePatterns"`
DBHost string `json:"dbHost"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser"`
DBName string `json:"dbName"`
DBPath string `json:"dbPath"`
MaskedFields []string `json:"maskedFields,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type BackupTaskScheduler interface {
SyncTask(ctx context.Context, task *model.BackupTask) error
RemoveTask(ctx context.Context, taskID uint) error
}
type BackupTaskService struct {
tasks repository.BackupTaskRepository
targets repository.StorageTargetRepository
cipher *codec.ConfigCipher
scheduler BackupTaskScheduler
}
func NewBackupTaskService(
tasks repository.BackupTaskRepository,
targets repository.StorageTargetRepository,
cipher *codec.ConfigCipher,
) *BackupTaskService {
return &BackupTaskService{tasks: tasks, targets: targets, cipher: cipher}
}
func (s *BackupTaskService) SetScheduler(scheduler BackupTaskScheduler) {
s.scheduler = scheduler
}
func (s *BackupTaskService) List(ctx context.Context) ([]BackupTaskSummary, error) {
items, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_LIST_FAILED", "无法获取备份任务列表", err)
}
result := make([]BackupTaskSummary, 0, len(items))
for _, item := range items {
result = append(result, toBackupTaskSummary(&item))
}
return result, nil
}
func (s *BackupTaskService) Get(ctx context.Context, id uint) (*BackupTaskDetail, error) {
item, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if item == nil {
return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
return s.toDetail(item)
}
func (s *BackupTaskService) Create(ctx context.Context, input BackupTaskUpsertInput) (*BackupTaskDetail, error) {
input.Type = normalizeBackupTaskType(input.Type)
if err := s.validateInput(ctx, nil, input); err != nil {
return nil, err
}
existing, err := s.tasks.FindByName(ctx, strings.TrimSpace(input.Name))
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_LOOKUP_FAILED", "无法检查备份任务名称", err)
}
if existing != nil {
return nil, apperror.Conflict("BACKUP_TASK_NAME_EXISTS", "备份任务名称已存在", nil)
}
item, err := s.buildTask(nil, input)
if err != nil {
return nil, err
}
if err := s.tasks.Create(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_CREATE_FAILED", "无法创建备份任务", err)
}
if s.scheduler != nil {
if err := s.scheduler.SyncTask(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法同步备份任务调度", err)
}
}
return s.Get(ctx, item.ID)
}
func (s *BackupTaskService) Update(ctx context.Context, id uint, input BackupTaskUpsertInput) (*BackupTaskDetail, error) {
input.Type = normalizeBackupTaskType(input.Type)
existing, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if existing == nil {
return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
if err := s.validateInput(ctx, existing, input); err != nil {
return nil, err
}
sameName, err := s.tasks.FindByName(ctx, strings.TrimSpace(input.Name))
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_LOOKUP_FAILED", "无法检查备份任务名称", err)
}
if sameName != nil && sameName.ID != existing.ID {
return nil, apperror.Conflict("BACKUP_TASK_NAME_EXISTS", "备份任务名称已存在", nil)
}
item, err := s.buildTask(existing, input)
if err != nil {
return nil, err
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
if err := s.tasks.Update(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_UPDATE_FAILED", "无法更新备份任务", err)
}
if s.scheduler != nil {
if err := s.scheduler.SyncTask(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法同步备份任务调度", err)
}
}
return s.Get(ctx, item.ID)
}
func (s *BackupTaskService) Delete(ctx context.Context, id uint) error {
existing, err := s.tasks.FindByID(ctx, id)
if err != nil {
return apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if existing == nil {
return apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
if s.scheduler != nil {
if err := s.scheduler.RemoveTask(ctx, id); err != nil {
return apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法移除备份任务调度", err)
}
}
if err := s.tasks.Delete(ctx, id); err != nil {
return apperror.Internal("BACKUP_TASK_DELETE_FAILED", "无法删除备份任务", err)
}
if s.scheduler != nil {
_ = s.scheduler.RemoveTask(ctx, id)
}
return nil
}
func (s *BackupTaskService) Toggle(ctx context.Context, id uint, enabled bool) (*BackupTaskSummary, error) {
item, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if item == nil {
return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
item.Enabled = enabled
if err := s.tasks.Update(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_UPDATE_FAILED", "无法更新备份任务状态", err)
}
if s.scheduler != nil {
if err := s.scheduler.SyncTask(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法同步备份任务调度", err)
}
}
returnPtr, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
returnValue := toBackupTaskSummary(returnPtr)
return &returnValue, nil
}
func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.BackupTask, input BackupTaskUpsertInput) error {
if strings.TrimSpace(input.Name) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "任务名称不能为空", nil)
}
if input.StorageTargetID == 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "请选择存储目标", nil)
}
target, err := s.targets.FindByID(ctx, input.StorageTargetID)
if err != nil {
return apperror.Internal("BACKUP_TASK_STORAGE_LOOKUP_FAILED", "无法检查存储目标", err)
}
if target == nil {
return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
}
if input.RetentionDays < 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
}
if input.MaxBackups < 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "最大保留份数不能小于 0", nil)
}
if input.Compression == "" {
input.Compression = "gzip"
}
if strings.TrimSpace(input.CronExpr) != "" && len(strings.Fields(strings.TrimSpace(input.CronExpr))) < 5 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "Cron 表达式格式不正确", nil)
}
passwordRequired := existing == nil || existing.DBPasswordCiphertext == ""
return validateTaskTypeSpecificFields(input, passwordRequired)
}
func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequired bool) error {
switch normalizeBackupTaskType(input.Type) {
case "file":
if strings.TrimSpace(input.SourcePath) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil)
}
case "mysql", "postgresql":
if strings.TrimSpace(input.DBHost) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库主机不能为空", nil)
}
if input.DBPort <= 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库端口必须大于 0", nil)
}
if strings.TrimSpace(input.DBUser) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库用户名不能为空", nil)
}
if passwordRequired && strings.TrimSpace(input.DBPassword) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库密码不能为空", nil)
}
if strings.TrimSpace(input.DBName) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库名称不能为空", nil)
}
case "sqlite":
if strings.TrimSpace(input.DBPath) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "SQLite 备份必须填写数据库文件路径", nil)
}
default:
return apperror.BadRequest("BACKUP_TASK_INVALID", "不支持的备份任务类型", nil)
}
return nil
}
func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTaskUpsertInput) (*model.BackupTask, error) {
excludePatterns, err := encodeExcludePatterns(input.ExcludePatterns)
if err != nil {
return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "排除规则格式不合法", err)
}
passwordCiphertext := ""
if existing != nil {
passwordCiphertext = existing.DBPasswordCiphertext
}
if text := strings.TrimSpace(input.DBPassword); text != "" && text != backupTaskMaskedValue {
ciphertext, encryptErr := s.cipher.Encrypt([]byte(text))
if encryptErr != nil {
return nil, apperror.Internal("BACKUP_TASK_ENCRYPT_FAILED", "无法保存数据库密码", encryptErr)
}
passwordCiphertext = ciphertext
}
compression := strings.TrimSpace(input.Compression)
if compression == "" {
compression = "gzip"
}
maxBackups := input.MaxBackups
if maxBackups == 0 {
maxBackups = 10
}
item := &model.BackupTask{
Name: strings.TrimSpace(input.Name),
Type: normalizeBackupTaskType(input.Type),
Enabled: input.Enabled,
CronExpr: strings.TrimSpace(input.CronExpr),
SourcePath: strings.TrimSpace(input.SourcePath),
ExcludePatterns: excludePatterns,
DBHost: strings.TrimSpace(input.DBHost),
DBPort: input.DBPort,
DBUser: strings.TrimSpace(input.DBUser),
DBPasswordCiphertext: passwordCiphertext,
DBName: strings.TrimSpace(input.DBName),
DBPath: strings.TrimSpace(input.DBPath),
StorageTargetID: input.StorageTargetID,
RetentionDays: input.RetentionDays,
Compression: compression,
Encrypt: input.Encrypt,
MaxBackups: maxBackups,
LastStatus: "idle",
}
if existing != nil {
item.LastRunAt = existing.LastRunAt
item.LastStatus = existing.LastStatus
item.CreatedAt = existing.CreatedAt
}
return item, nil
}
func (s *BackupTaskService) toDetail(item *model.BackupTask) (*BackupTaskDetail, error) {
excludePatterns, err := decodeExcludePatterns(item.ExcludePatterns)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析备份任务配置", err)
}
detail := &BackupTaskDetail{
BackupTaskSummary: toBackupTaskSummary(item),
SourcePath: item.SourcePath,
ExcludePatterns: excludePatterns,
DBHost: item.DBHost,
DBPort: item.DBPort,
DBUser: item.DBUser,
DBName: item.DBName,
DBPath: item.DBPath,
CreatedAt: item.CreatedAt,
}
if item.DBPasswordCiphertext != "" {
detail.MaskedFields = []string{"dbPassword"}
}
return detail, nil
}
func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
storageTargetName := ""
if item != nil {
storageTargetName = item.StorageTarget.Name
}
return BackupTaskSummary{
ID: item.ID,
Name: item.Name,
Type: normalizeBackupTaskType(item.Type),
Enabled: item.Enabled,
CronExpr: item.CronExpr,
StorageTargetID: item.StorageTargetID,
StorageTargetName: storageTargetName,
RetentionDays: item.RetentionDays,
Compression: item.Compression,
Encrypt: item.Encrypt,
MaxBackups: item.MaxBackups,
LastRunAt: item.LastRunAt,
LastStatus: item.LastStatus,
UpdatedAt: item.UpdatedAt,
}
}
func encodeExcludePatterns(value []string) (string, error) {
if len(value) == 0 {
return "[]", nil
}
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(encoded), nil
}
func decodeExcludePatterns(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return []string{}, nil
}
var items []string
if err := json.Unmarshal([]byte(value), &items); err != nil {
return nil, err
}
return items, nil
}
func normalizeBackupTaskType(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "pgsql" {
return "postgresql"
}
return normalized
}

View File

@@ -0,0 +1,119 @@
package service
import (
"context"
"path/filepath"
"testing"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage/codec"
)
func newBackupTaskServiceForTest(t *testing.T) (*BackupTaskService, repository.StorageTargetRepository, repository.BackupTaskRepository) {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
service := NewBackupTaskService(tasks, targets, codec.NewConfigCipher("task-service-secret"))
return service, targets, tasks
}
func TestBackupTaskServiceCreateAndGet(t *testing.T) {
ctx := context.Background()
service, targets, _ := newBackupTaskServiceForTest(t)
if err := targets.Create(ctx, &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "ciphertext", ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("seed storage target error: %v", err)
}
created, err := service.Create(ctx, BackupTaskUpsertInput{
Name: "site-files",
Type: "file",
Enabled: true,
SourcePath: "/srv/site",
ExcludePatterns: []string{"*.log", "node_modules"},
StorageTargetID: 1,
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if created.Name != "site-files" || len(created.ExcludePatterns) != 2 {
t.Fatalf("unexpected created task: %#v", created)
}
loaded, err := service.Get(ctx, created.ID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded.StorageTargetName != "local" {
t.Fatalf("expected storage target name local, got %s", loaded.StorageTargetName)
}
}
func TestBackupTaskServiceKeepsMaskedPasswordOnUpdate(t *testing.T) {
ctx := context.Background()
service, targets, tasks := newBackupTaskServiceForTest(t)
if err := targets.Create(ctx, &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "ciphertext", ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("seed storage target error: %v", err)
}
created, err := service.Create(ctx, BackupTaskUpsertInput{
Name: "mysql-prod",
Type: "mysql",
Enabled: true,
DBHost: "127.0.0.1",
DBPort: 3306,
DBUser: "root",
DBPassword: "secret",
DBName: "app",
StorageTargetID: 1,
RetentionDays: 7,
Compression: "gzip",
MaxBackups: 5,
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := tasks.FindByID(ctx, created.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
originalCiphertext := stored.DBPasswordCiphertext
updated, err := service.Update(ctx, created.ID, BackupTaskUpsertInput{
Name: created.Name,
Type: created.Type,
Enabled: true,
DBHost: "127.0.0.1",
DBPort: 3306,
DBUser: "root",
DBPassword: "",
DBName: "app_updated",
StorageTargetID: 1,
RetentionDays: 7,
Compression: "gzip",
MaxBackups: 5,
})
if err != nil {
t.Fatalf("Update returned error: %v", err)
}
if len(updated.MaskedFields) != 1 || updated.MaskedFields[0] != "dbPassword" {
t.Fatalf("expected masked dbPassword field, got %#v", updated.MaskedFields)
}
reloaded, err := tasks.FindByID(ctx, created.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if reloaded.DBPasswordCiphertext != originalCiphertext {
t.Fatalf("expected ciphertext unchanged")
}
}

Some files were not shown because too many files have changed in this diff Show More