哈尔滨美食地图
This commit is contained in:
40
backend/.env.example
Normal file
40
backend/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# App
|
||||
APP_ENV=dev
|
||||
APP_PORT=8080
|
||||
PUBLIC_BASE_URL=http://localhost:8080
|
||||
|
||||
# Admin init (首次启动会创建)
|
||||
ADMIN_INIT_USERNAME=admin
|
||||
ADMIN_INIT_PASSWORD=admin123456
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=please_change_me
|
||||
|
||||
# API Key(管理后台每次请求都需要携带 X-API-Key)
|
||||
# 生产环境建议用 API_KEY_HASH(SHA256 hex),不要直接放明文 API_KEY
|
||||
API_KEY=dev-api-key-change-me
|
||||
# API_KEY_HASH=
|
||||
|
||||
# Database(也可在 docker-compose 里覆盖)
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3309
|
||||
DB_NAME=mydb
|
||||
DB_USER=user
|
||||
DB_PASSWORD=password123
|
||||
DB_PARAMS=charset=utf8mb4&parseTime=True&loc=Local
|
||||
|
||||
# Redis
|
||||
REDIS_ADDR=127.0.0.1:6381
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=./static/upload
|
||||
MAX_UPLOAD_MB=10
|
||||
|
||||
# CORS(管理后台域名)
|
||||
CORS_ALLOW_ORIGINS=http://localhost:5173
|
||||
|
||||
# AMap(地址解析/选点,当前为预留)
|
||||
AMAP_KEY=
|
||||
|
||||
29
backend/Dockerfile
Normal file
29
backend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 解决部分网络环境无法访问 proxy.golang.org 的问题(可在构建时覆盖)
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
ARG GOSUMDB=sum.golang.google.cn
|
||||
ENV GOPROXY=$GOPROXY
|
||||
ENV GOSUMDB=$GOSUMDB
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
||||
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata su-exec && adduser -D -H appuser
|
||||
|
||||
COPY --from=build /out/server /app/server
|
||||
COPY --from=build /src/static /app/static
|
||||
COPY --from=build /src/scripts /app/scripts
|
||||
|
||||
ENV APP_PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
|
||||
76
backend/cmd/server/main.go
Normal file
76
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/db"
|
||||
"0451meishiditu/backend/internal/httpx"
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
"0451meishiditu/backend/internal/migrate"
|
||||
"0451meishiditu/backend/internal/redisx"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
"0451meishiditu/backend/internal/seed"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log := logger.New(cfg.AppEnv)
|
||||
defer func() { _ = log.Sync() }()
|
||||
|
||||
gdb, err := db.Open(cfg, log)
|
||||
if err != nil {
|
||||
log.Fatal("open db failed", logger.Err(err))
|
||||
}
|
||||
|
||||
rdb := redisx.New(cfg)
|
||||
|
||||
if err := migrate.AutoMigrate(gdb); err != nil {
|
||||
log.Fatal("auto migrate failed", logger.Err(err))
|
||||
}
|
||||
|
||||
if err := seed.EnsureInitialAdmin(gdb, cfg); err != nil {
|
||||
log.Fatal("seed admin failed", logger.Err(err))
|
||||
}
|
||||
|
||||
st, err := settings.New(gdb, cfg)
|
||||
if err != nil {
|
||||
log.Fatal("settings init failed", logger.Err(err))
|
||||
}
|
||||
|
||||
router := httpx.NewRouter(cfg, log, gdb, rdb, st)
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.AppPort,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Info("server started", logger.Str("addr", srv.Addr))
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal("listen failed", logger.Err(err))
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
log.Info("server stopped")
|
||||
}
|
||||
52
backend/go.mod
Normal file
52
backend/go.mod
Normal file
@@ -0,0 +1,52 @@
|
||||
module 0451meishiditu/backend
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/gzip v1.2.2
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
gorm.io/datatypes v1.2.5
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // 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.10 // 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.4 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
155
backend/go.sum
Normal file
155
backend/go.sum
Normal file
@@ -0,0 +1,155 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
|
||||
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
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/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.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
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/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
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/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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
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.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
|
||||
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
||||
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
76
backend/internal/auth/jwt.go
Normal file
76
backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type AdminClaims struct {
|
||||
AdminID uint `json:"admin_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewAdminToken(secret string, adminID uint, username, role string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := AdminClaims{
|
||||
AdminID: adminID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseAdminToken(secret, token string) (AdminClaims, error) {
|
||||
var claims AdminClaims
|
||||
parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
return AdminClaims{}, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
type UserClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewUserToken(secret string, userID uint, username string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := UserClaims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseUserToken(secret, token string) (UserClaims, error) {
|
||||
var claims UserClaims
|
||||
parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
return UserClaims{}, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
129
backend/internal/config/config.go
Normal file
129
backend/internal/config/config.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
AppPort string
|
||||
|
||||
PublicBaseURL string
|
||||
|
||||
AdminInitUsername string
|
||||
AdminInitPassword string
|
||||
|
||||
JWTSecret string
|
||||
|
||||
APIKey string
|
||||
APIKeyHash string
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBName string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBParams string
|
||||
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
|
||||
UploadDir string
|
||||
MaxUploadMB int64
|
||||
|
||||
CORSAllowOrigins []string
|
||||
|
||||
AMapKey string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := Config{
|
||||
AppEnv: getenv("APP_ENV", "dev"),
|
||||
AppPort: getenv("APP_PORT", "8080"),
|
||||
|
||||
PublicBaseURL: getenv("PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||
|
||||
AdminInitUsername: getenv("ADMIN_INIT_USERNAME", "admin"),
|
||||
AdminInitPassword: getenv("ADMIN_INIT_PASSWORD", "admin123456"),
|
||||
|
||||
JWTSecret: getenv("JWT_SECRET", ""),
|
||||
|
||||
APIKey: strings.TrimSpace(os.Getenv("API_KEY")),
|
||||
APIKeyHash: strings.ToLower(strings.TrimSpace(os.Getenv("API_KEY_HASH"))),
|
||||
|
||||
DBHost: getenv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getenv("DB_PORT", "3306"),
|
||||
DBName: getenv("DB_NAME", "mydb"),
|
||||
DBUser: getenv("DB_USER", "root"),
|
||||
DBPassword: strings.TrimSpace(os.Getenv("DB_PASSWORD")),
|
||||
DBParams: getenv("DB_PARAMS", "charset=utf8mb4&parseTime=True&loc=Local"),
|
||||
|
||||
RedisAddr: getenv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||
RedisPassword: strings.TrimSpace(os.Getenv("REDIS_PASSWORD")),
|
||||
RedisDB: mustAtoi(getenv("REDIS_DB", "0")),
|
||||
|
||||
UploadDir: getenv("UPLOAD_DIR", "./static/upload"),
|
||||
MaxUploadMB: int64(mustAtoi(getenv("MAX_UPLOAD_MB", "10"))),
|
||||
|
||||
CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "http://localhost:5173")),
|
||||
|
||||
AMapKey: strings.TrimSpace(os.Getenv("AMAP_KEY")),
|
||||
}
|
||||
|
||||
if cfg.JWTSecret == "" {
|
||||
return Config{}, errors.New("JWT_SECRET is required")
|
||||
}
|
||||
if cfg.APIKey == "" && cfg.APIKeyHash == "" {
|
||||
return Config{}, errors.New("API_KEY or API_KEY_HASH is required")
|
||||
}
|
||||
if cfg.APIKeyHash != "" && len(cfg.APIKeyHash) != 64 {
|
||||
return Config{}, errors.New("API_KEY_HASH must be sha256 hex (64 chars)")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) ExpectedAPIKeyHash() string {
|
||||
if c.APIKeyHash != "" {
|
||||
return c.APIKeyHash
|
||||
}
|
||||
sum := sha256.Sum256([]byte(c.APIKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func mustAtoi(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
v := strings.TrimSpace(part)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
66
backend/internal/db/db.go
Normal file
66
backend/internal/db/db.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Open(cfg config.Config, log *zap.Logger) (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?%s",
|
||||
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName, cfg.DBParams,
|
||||
)
|
||||
|
||||
gormLog := gormlogger.New(
|
||||
zap.NewStdLog(log.WithOptions(zap.AddCallerSkip(1))),
|
||||
gormlogger.Config{
|
||||
SlowThreshold: 500 * time.Millisecond,
|
||||
LogLevel: gormlogger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
},
|
||||
)
|
||||
|
||||
var gdb *gorm.DB
|
||||
var err error
|
||||
delay := 500 * time.Millisecond
|
||||
for attempt := 1; attempt <= 20; attempt++ {
|
||||
gdb, err = gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gormLog})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.Warn("db connect retry",
|
||||
logger.Str("host", cfg.DBHost),
|
||||
logger.Str("db", cfg.DBName),
|
||||
zap.Int("attempt", attempt),
|
||||
logger.Err(err),
|
||||
)
|
||||
time.Sleep(delay)
|
||||
if delay < 5*time.Second {
|
||||
delay *= 2
|
||||
if delay > 5*time.Second {
|
||||
delay = 5 * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
log.Info("db connected", logger.Str("host", cfg.DBHost), logger.Str("db", cfg.DBName))
|
||||
return gdb, nil
|
||||
}
|
||||
135
backend/internal/handlers/admin_admins.go
Normal file
135
backend/internal/handlers/admin_admins.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminListAdmins(c *gin.Context) {
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
q := h.db.Model(&models.AdminUser{})
|
||||
if keyword != "" {
|
||||
q = q.Where("username LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.AdminUser
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page-1)*pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type adminCreateReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminCreateAdmin(c *gin.Context) {
|
||||
var req adminCreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" || len(req.Password) < 6 {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid username or password")
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = "admin"
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
item := models.AdminUser{
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
Role: req.Role,
|
||||
Enabled: enabled,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
type adminPasswordReq struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminUpdateAdminPassword(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req adminPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Password) < 6 {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Model(&models.AdminUser{}).Where("id = ?", uint(id64)).
|
||||
Update("password_hash", string(hash)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
type adminEnabledReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminUpdateAdminEnabled(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req adminEnabledReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.AdminUser{}).Where("id = ?", uint(id64)).
|
||||
Update("enabled", req.Enabled).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
69
backend/internal/handlers/admin_auth.go
Normal file
69
backend/internal/handlers/admin_auth.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminLogin(c *gin.Context) {
|
||||
var req loginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
|
||||
return
|
||||
}
|
||||
|
||||
var admin models.AdminUser
|
||||
if err := h.db.Where("username = ?", req.Username).First(&admin).Error; err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
if !admin.Enabled {
|
||||
resp.Fail(c, http.StatusUnauthorized, "account disabled")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(req.Password)); err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.NewAdminToken(h.cfg.JWTSecret, admin.ID, admin.Username, admin.Role, 24*time.Hour)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "token error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OK(c, gin.H{
|
||||
"token": token,
|
||||
"admin": gin.H{
|
||||
"id": admin.ID,
|
||||
"username": admin.Username,
|
||||
"role": admin.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminMe(c *gin.Context) {
|
||||
resp.OK(c, gin.H{
|
||||
"id": c.GetUint("admin_id"),
|
||||
"username": c.GetString("admin_username"),
|
||||
"role": c.GetString("admin_role"),
|
||||
})
|
||||
}
|
||||
81
backend/internal/handlers/admin_rankings.go
Normal file
81
backend/internal/handlers/admin_rankings.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminStoreRanking(c *gin.Context) {
|
||||
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
if by == "" {
|
||||
by = "hotness"
|
||||
}
|
||||
|
||||
order := "store_metrics.score desc"
|
||||
switch by {
|
||||
case "likes":
|
||||
order = "store_metrics.likes_count desc"
|
||||
case "search":
|
||||
order = "store_metrics.search_count desc"
|
||||
case "reviews":
|
||||
order = "store_metrics.reviews_count desc"
|
||||
}
|
||||
|
||||
type row struct {
|
||||
StoreID uint `json:"store_id"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"category_id"`
|
||||
Status string `json:"status"`
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
SearchCount int64 `json:"search_count"`
|
||||
ReviewsCount int64 `json:"reviews_count"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
var total int64
|
||||
_ = h.db.Table("store_metrics").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null").
|
||||
Count(&total).Error
|
||||
|
||||
var out []row
|
||||
if err := h.db.Table("store_metrics").
|
||||
Select("stores.id as store_id, stores.name, stores.category_id, stores.status, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null").
|
||||
Order(order + ", stores.id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Scan(&out).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, out, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminRecalcStoreScore(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
if limit <= 0 || limit > 5000 {
|
||||
limit = 5000
|
||||
}
|
||||
// score = likes*2 + search*1 + reviews*3
|
||||
err := h.db.Exec("update store_metrics set score = likes_count*2 + search_count*1 + reviews_count*3, updated_at = now() limit ?", limit).Error
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "recalc failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
22
backend/internal/handlers/admin_user_get.go
Normal file
22
backend/internal/handlers/admin_user_get.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminUserGet(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var u models.User
|
||||
if err := h.db.First(&u, uint(id64)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, u)
|
||||
}
|
||||
|
||||
68
backend/internal/handlers/admin_users.go
Normal file
68
backend/internal/handlers/admin_users.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminUserList(c *gin.Context) {
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
|
||||
q := h.db.Model(&models.User{})
|
||||
if keyword != "" {
|
||||
q = q.Where("username LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.User
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page-1)*pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type userStatusReq struct {
|
||||
Status string `json:"status" binding:"required"` // active/disabled
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminUserUpdateStatus(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req userStatusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status != "active" && req.Status != "disabled" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.User{}).Where("id = ?", uint(id64)).Update("status", req.Status).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
90
backend/internal/handlers/apikeys.go
Normal file
90
backend/internal/handlers/apikeys.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type apiKeyCreateReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) APIKeyList(c *gin.Context) {
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.APIKey{})
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.APIKey
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIKeyCreate(c *gin.Context) {
|
||||
var req apiKeyCreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
plain := "ak_" + randomHex(24)
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
prefix := plain
|
||||
if len(prefix) > 10 {
|
||||
prefix = prefix[:10]
|
||||
}
|
||||
|
||||
item := models.APIKey{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Prefix: prefix,
|
||||
HashSHA256: hash,
|
||||
Status: "active",
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OK(c, gin.H{
|
||||
"id": item.ID,
|
||||
"name": item.Name,
|
||||
"prefix": item.Prefix,
|
||||
"status": item.Status,
|
||||
"key": plain, // 仅返回一次,前端应提示保存
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIKeyRevoke(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
now := time.Now()
|
||||
if err := h.db.Model(&models.APIKey{}).
|
||||
Where("id = ? and status = 'active'", uint(id)).
|
||||
Updates(map[string]any{"status": "revoked", "revoked_at": &now}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "revoke failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"revoked": true})
|
||||
}
|
||||
80
backend/internal/handlers/captcha.go
Normal file
80
backend/internal/handlers/captcha.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type captchaNewResp struct {
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
SVG string `json:"svg"`
|
||||
}
|
||||
|
||||
// CaptchaNew returns a simple SVG captcha and stores the answer in Redis.
|
||||
// TTL: 5 minutes. One-time: successful validate will delete it.
|
||||
func (h *Handlers) CaptchaNew(c *gin.Context) {
|
||||
code := randomCaptchaCode(5)
|
||||
id := randomHexStr(16)
|
||||
key := "captcha:" + id
|
||||
if err := h.rdb.Set(c.Request.Context(), key, strings.ToLower(code), 5*time.Minute).Err(); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "captcha store failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, captchaNewResp{
|
||||
CaptchaID: id,
|
||||
SVG: captchaSVG(code),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) verifyCaptcha(c *gin.Context, captchaID, captchaCode string) bool {
|
||||
captchaID = strings.TrimSpace(captchaID)
|
||||
captchaCode = strings.ToLower(strings.TrimSpace(captchaCode))
|
||||
if captchaID == "" || captchaCode == "" {
|
||||
return false
|
||||
}
|
||||
key := "captcha:" + captchaID
|
||||
val, err := h.rdb.Get(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ok := strings.ToLower(strings.TrimSpace(val)) == captchaCode
|
||||
if ok {
|
||||
_ = h.rdb.Del(c.Request.Context(), key).Err()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func randomHexStr(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func randomCaptchaCode(n int) string {
|
||||
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
out := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func captchaSVG(code string) string {
|
||||
// Minimal SVG; frontends can render via v-html or <img src="data:image/svg+xml;utf8,...">
|
||||
esc := html.EscapeString(code)
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="44" viewBox="0 0 140 44">` +
|
||||
`<rect x="0" y="0" width="140" height="44" rx="10" fill="#f8fafc" stroke="#e2e8f0"/>` +
|
||||
`<text x="70" y="29" text-anchor="middle" font-family="ui-sans-serif,system-ui" font-size="22" font-weight="700" fill="#0f172a" letter-spacing="3">` + esc + `</text>` +
|
||||
`</svg>`
|
||||
}
|
||||
112
backend/internal/handlers/categories.go
Normal file
112
backend/internal/handlers/categories.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type categoryUpsertReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
IconURL string `json:"icon_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryList(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Category{})
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Category
|
||||
if err := q.Order("sort_order desc, id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryCreate(c *gin.Context) {
|
||||
var req categoryUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
item := models.Category{
|
||||
Name: req.Name,
|
||||
IconURL: req.IconURL,
|
||||
SortOrder: req.SortOrder,
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryUpdate(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req categoryUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
var item models.Category
|
||||
if err := h.db.First(&item, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
item.Name = req.Name
|
||||
item.IconURL = req.IconURL
|
||||
item.SortOrder = req.SortOrder
|
||||
if req.Enabled != nil {
|
||||
item.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
if err := h.db.Save(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryDelete(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.db.Delete(&models.Category{}, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
113
backend/internal/handlers/dashboard.go
Normal file
113
backend/internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type dashboardOverview struct {
|
||||
TotalStores int64 `json:"total_stores"`
|
||||
TotalReviews int64 `json:"total_reviews"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
CategoryDist []categoryKV `json:"category_dist"`
|
||||
StoresLast7Days []dateKV `json:"stores_last7days"`
|
||||
TopRatedStores []topStoreRow `json:"top_rated_stores"`
|
||||
}
|
||||
|
||||
type categoryKV struct {
|
||||
CategoryID uint `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type dateKV struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type topStoreRow struct {
|
||||
StoreID uint `json:"store_id"`
|
||||
Name string `json:"name"`
|
||||
Avg float64 `json:"avg"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
func (h *Handlers) DashboardOverview(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
cacheKey := "admin:dashboard:overview:v1"
|
||||
|
||||
if b, err := h.rdb.Get(ctx, cacheKey).Bytes(); err == nil && len(b) > 0 {
|
||||
var cached dashboardOverview
|
||||
if json.Unmarshal(b, &cached) == nil {
|
||||
resp.OK(c, cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ov, err := h.buildDashboard(ctx)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
if b, err := json.Marshal(ov); err == nil {
|
||||
_ = h.rdb.Set(ctx, cacheKey, b, 30*time.Second).Err()
|
||||
}
|
||||
resp.OK(c, ov)
|
||||
}
|
||||
|
||||
func (h *Handlers) buildDashboard(ctx context.Context) (dashboardOverview, error) {
|
||||
var out dashboardOverview
|
||||
|
||||
if err := h.db.WithContext(ctx).Model(&models.Store{}).Count(&out.TotalStores).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := h.db.WithContext(ctx).Model(&models.Review{}).Count(&out.TotalReviews).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := h.db.WithContext(ctx).Model(&models.Category{}).Count(&out.TotalCategories).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Table("stores").
|
||||
Select("categories.id as category_id, categories.name as name, count(stores.id) as count").
|
||||
Joins("left join categories on categories.id = stores.category_id").
|
||||
Where("stores.deleted_at is null").
|
||||
Group("categories.id, categories.name").
|
||||
Order("count desc").
|
||||
Scan(&out.CategoryDist).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Table("stores").
|
||||
Select("date(created_at) as date, count(id) as count").
|
||||
Where("created_at >= date_sub(curdate(), interval 6 day) and deleted_at is null").
|
||||
Group("date(created_at)").
|
||||
Order("date asc").
|
||||
Scan(&out.StoresLast7Days).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Table("reviews").
|
||||
Select("stores.id as store_id, stores.name as name, avg(reviews.rating) as avg, count(reviews.id) as count").
|
||||
Joins("left join stores on stores.id = reviews.store_id").
|
||||
Where("reviews.status = 'approved' and reviews.deleted_at is null and stores.deleted_at is null").
|
||||
Group("stores.id, stores.name").
|
||||
Having("count(reviews.id) >= ?", 3).
|
||||
Order("avg desc").
|
||||
Limit(5).
|
||||
Scan(&out.TopRatedStores).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
22
backend/internal/handlers/handlers.go
Normal file
22
backend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
cfg config.Config
|
||||
log *zap.Logger
|
||||
db *gorm.DB
|
||||
rdb *redis.Client
|
||||
st *settings.Store
|
||||
}
|
||||
|
||||
func New(cfg config.Config, log *zap.Logger, db *gorm.DB, rdb *redis.Client, st *settings.Store) *Handlers {
|
||||
return &Handlers{cfg: cfg, log: log, db: db, rdb: rdb, st: st}
|
||||
}
|
||||
182
backend/internal/handlers/merchant_apply.go
Normal file
182
backend/internal/handlers/merchant_apply.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type merchantApplyReq struct {
|
||||
StoreName string `json:"store_name" binding:"required"`
|
||||
CategoryID uint `json:"category_id" binding:"required"`
|
||||
Address string `json:"address" binding:"required"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lng *float64 `json:"lng"`
|
||||
OpenHours string `json:"open_hours"`
|
||||
Phone string `json:"phone"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
Description string `json:"description"`
|
||||
|
||||
ContactName string `json:"contact_name" binding:"required"`
|
||||
ContactPhone string `json:"contact_phone" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) MerchantApply(c *gin.Context) {
|
||||
var req merchantApplyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
imgs, _ := json.Marshal(req.ImageURLs)
|
||||
app := models.MerchantApplication{
|
||||
StoreName: req.StoreName,
|
||||
CategoryID: req.CategoryID,
|
||||
Address: req.Address,
|
||||
Lat: req.Lat,
|
||||
Lng: req.Lng,
|
||||
OpenHours: req.OpenHours,
|
||||
Phone: req.Phone,
|
||||
CoverURL: req.CoverURL,
|
||||
ImageURLs: imgs,
|
||||
Description: req.Description,
|
||||
ContactName: req.ContactName,
|
||||
ContactPhone: req.ContactPhone,
|
||||
Status: "pending",
|
||||
RejectReason: "",
|
||||
ReviewedAt: nil,
|
||||
ReviewerID: nil,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&app).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "submit failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"id": app.ID, "status": app.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminMerchantApplyList(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.MerchantApplication{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.MerchantApplication
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type applyReviewReq struct {
|
||||
Action string `json:"action" binding:"required"` // approve/reject
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminMerchantApplyReview(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req applyReviewReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
var app models.MerchantApplication
|
||||
if err := h.db.First(&app, uint(id64)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if app.Status != "pending" {
|
||||
resp.Fail(c, http.StatusBadRequest, "already reviewed")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
adminID := c.GetUint("admin_id")
|
||||
|
||||
if req.Action == "approve" {
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
// create store
|
||||
store := models.Store{
|
||||
Name: app.StoreName,
|
||||
CategoryID: app.CategoryID,
|
||||
Address: app.Address,
|
||||
Lat: app.Lat,
|
||||
Lng: app.Lng,
|
||||
OpenHours: app.OpenHours,
|
||||
Phone: app.Phone,
|
||||
CoverURL: app.CoverURL,
|
||||
Description: app.Description,
|
||||
Status: "active",
|
||||
}
|
||||
if err := tx.Create(&store).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// images
|
||||
var urls []string
|
||||
_ = json.Unmarshal([]byte(app.ImageURLs), &urls)
|
||||
for i, u := range urls {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Create(&models.StoreImage{StoreID: store.ID, URL: u, SortOrder: i}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Model(&models.MerchantApplication{}).Where("id = ?", app.ID).Updates(map[string]any{
|
||||
"status": "approved",
|
||||
"reject_reason": "",
|
||||
"reviewed_at": &now,
|
||||
"reviewer_id": &adminID,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "approve failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"approved": true})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Action == "reject" {
|
||||
if err := h.db.Model(&models.MerchantApplication{}).Where("id = ?", app.ID).Updates(map[string]any{
|
||||
"status": "rejected",
|
||||
"reject_reason": req.RejectReason,
|
||||
"reviewed_at": &now,
|
||||
"reviewer_id": &adminID,
|
||||
}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "reject failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"rejected": true})
|
||||
return
|
||||
}
|
||||
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid action")
|
||||
}
|
||||
23
backend/internal/handlers/pagination.go
Normal file
23
backend/internal/handlers/pagination.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package handlers
|
||||
|
||||
import "strconv"
|
||||
|
||||
func parsePage(pageStr, sizeStr string) (int, int) {
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
size, _ := strconv.Atoi(sizeStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func calcTotalPage(total int64, pageSize int) int64 {
|
||||
if pageSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (total + int64(pageSize) - 1) / int64(pageSize)
|
||||
}
|
||||
|
||||
193
backend/internal/handlers/public_read.go
Normal file
193
backend/internal/handlers/public_read.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PublicCategoryList returns enabled categories for public clients (still requires X-API-Key).
|
||||
func (h *Handlers) PublicCategoryList(c *gin.Context) {
|
||||
var items []models.Category
|
||||
if err := h.db.
|
||||
Where("enabled = ?", true).
|
||||
Order("sort_order desc, id desc").
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
resp.OK(c, items)
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreList(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
categoryID, _ := strconv.ParseUint(c.Query("category_id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Store{}).Where("status = ?", "active")
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
if categoryID > 0 {
|
||||
q = q.Where("category_id = ?", uint(categoryID))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Store
|
||||
if err := q.Preload("Category").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreGet(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var item models.Store
|
||||
if err := h.db.
|
||||
Preload("Category").
|
||||
Preload("Images", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
Preload("Dishes", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
Where("status = ?", "active").
|
||||
First(&item, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreReviews(c *gin.Context) {
|
||||
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Review{}).
|
||||
Where("store_id = ? and status = ?", uint(storeID64), "approved")
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Review
|
||||
if err := q.Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreRanking(c *gin.Context) {
|
||||
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
if by == "" {
|
||||
by = "hotness"
|
||||
}
|
||||
|
||||
order := "store_metrics.score desc"
|
||||
switch by {
|
||||
case "likes":
|
||||
order = "store_metrics.likes_count desc"
|
||||
case "search":
|
||||
order = "store_metrics.search_count desc"
|
||||
case "reviews":
|
||||
order = "store_metrics.reviews_count desc"
|
||||
}
|
||||
|
||||
type row struct {
|
||||
StoreID uint `json:"store_id"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"category_id"`
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
SearchCount int64 `json:"search_count"`
|
||||
ReviewsCount int64 `json:"reviews_count"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
var total int64
|
||||
_ = h.db.Table("store_metrics").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null and stores.status = 'active'").
|
||||
Count(&total).Error
|
||||
|
||||
var out []row
|
||||
if err := h.db.Table("store_metrics").
|
||||
Select("stores.id as store_id, stores.name, stores.category_id, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null and stores.status = 'active'").
|
||||
Order(order + ", stores.id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Scan(&out).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, out, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// UserMyReviews lists the current user's submitted reviews.
|
||||
func (h *Handlers) UserMyReviews(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Review{}).Where("user_id = ?", userID)
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Review
|
||||
if err := q.Preload("Store").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
194
backend/internal/handlers/public_store.go
Normal file
194
backend/internal/handlers/public_store.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (h *Handlers) PublicStoreSearch(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Store{}).Where("status = ?", "active")
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Store
|
||||
if err := q.Preload("Category").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
// search heat: increment top 5 results
|
||||
if keyword != "" {
|
||||
for i := 0; i < len(items) && i < 5; i++ {
|
||||
_ = h.bumpSearchCount(items[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) bumpSearchCount(storeID uint) error {
|
||||
return h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var m models.StoreMetric
|
||||
err := tx.Where("store_id = ?", storeID).First(&m).Error
|
||||
if err == nil {
|
||||
return tx.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
|
||||
Updates(map[string]any{"search_count": gorm.Expr("search_count + 1"), "updated_at": time.Now()}).Error
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.StoreMetric{StoreID: storeID, SearchCount: 1, UpdatedAt: time.Now()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreHotRank(c *gin.Context) {
|
||||
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if by == "" {
|
||||
by = "hotness"
|
||||
}
|
||||
|
||||
order := "store_metrics.score desc"
|
||||
switch by {
|
||||
case "likes":
|
||||
order = "store_metrics.likes_count desc"
|
||||
case "search":
|
||||
order = "store_metrics.search_count desc"
|
||||
case "reviews":
|
||||
order = "store_metrics.reviews_count desc"
|
||||
}
|
||||
|
||||
type row struct {
|
||||
models.Store
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
SearchCount int64 `json:"search_count"`
|
||||
ReviewsCount int64 `json:"reviews_count"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
var out []row
|
||||
err := h.db.Table("stores").
|
||||
Select("stores.*, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
|
||||
Joins("left join store_metrics on store_metrics.store_id = stores.id").
|
||||
Where("stores.status = 'active' and stores.deleted_at is null").
|
||||
Order(order + ", stores.id desc").
|
||||
Limit(limit).
|
||||
Scan(&out).Error
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
resp.OK(c, out)
|
||||
}
|
||||
|
||||
type reviewCreateReq struct {
|
||||
Rating int `json:"rating" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
RecommendDishes []map[string]any `json:"recommend_dishes"`
|
||||
}
|
||||
|
||||
func (h *Handlers) UserCreateReview(c *gin.Context) {
|
||||
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
userID := c.GetUint("user_id")
|
||||
username := c.GetString("user_username")
|
||||
|
||||
var req reviewCreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Rating < 1 || req.Rating > 5 {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
imgs, _ := json.Marshal(req.ImageURLs)
|
||||
recd, _ := json.Marshal(req.RecommendDishes)
|
||||
|
||||
r := models.Review{
|
||||
StoreID: uint(storeID64),
|
||||
UserID: &userID,
|
||||
UserName: username,
|
||||
Rating: req.Rating,
|
||||
Content: req.Content,
|
||||
ImageURLs: datatypes.JSON(imgs),
|
||||
RecommendDishes: datatypes.JSON(recd),
|
||||
Status: "pending",
|
||||
}
|
||||
if err := h.db.Create(&r).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"id": r.ID, "status": r.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) UserToggleStoreLike(c *gin.Context) {
|
||||
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
storeID := uint(storeID64)
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var existed models.StoreLike
|
||||
err := h.db.Where("store_id = ? and user_id = ?", storeID, userID).First(&existed).Error
|
||||
if err == nil {
|
||||
if err := h.db.Where("id = ?", existed.ID).Delete(&models.StoreLike{}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "unlike failed")
|
||||
return
|
||||
}
|
||||
_ = h.db.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
|
||||
Updates(map[string]any{"likes_count": gorm.Expr("greatest(likes_count-1,0)"), "updated_at": time.Now()}).Error
|
||||
resp.OK(c, gin.H{"liked": false})
|
||||
return
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Create(&models.StoreLike{StoreID: storeID, UserID: userID, CreatedAt: time.Now()}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "like failed")
|
||||
return
|
||||
}
|
||||
_ = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var m models.StoreMetric
|
||||
e := tx.Where("store_id = ?", storeID).First(&m).Error
|
||||
if e == nil {
|
||||
return tx.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
|
||||
Updates(map[string]any{"likes_count": gorm.Expr("likes_count + 1"), "updated_at": time.Now()}).Error
|
||||
}
|
||||
if e != nil && e != gorm.ErrRecordNotFound {
|
||||
return e
|
||||
}
|
||||
return tx.Create(&models.StoreMetric{StoreID: storeID, LikesCount: 1, UpdatedAt: time.Now()}).Error
|
||||
})
|
||||
|
||||
resp.OK(c, gin.H{"liked": true})
|
||||
}
|
||||
|
||||
80
backend/internal/handlers/reviews.go
Normal file
80
backend/internal/handlers/reviews.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) ReviewList(c *gin.Context) {
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
storeID, _ := strconv.ParseUint(c.Query("store_id"), 10, 64)
|
||||
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Review{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if storeID > 0 {
|
||||
q = q.Where("store_id = ?", uint(storeID))
|
||||
}
|
||||
if userID > 0 {
|
||||
q = q.Where("user_id = ?", uint(userID))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Review
|
||||
if err := q.Preload("Store").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) ReviewUpdateStatus(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req statusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status != "pending" && req.Status != "approved" && req.Status != "blocked" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.Review{}).Where("id = ?", uint(id)).Update("status", req.Status).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func (h *Handlers) ReviewDelete(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.db.Delete(&models.Review{}, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
45
backend/internal/handlers/settings.go
Normal file
45
backend/internal/handlers/settings.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type corsUpdateReq struct {
|
||||
Origins []string `json:"origins" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) SettingsGetCORS(c *gin.Context) {
|
||||
resp.OK(c, gin.H{"origins": h.st.CORSAllowOrigins()})
|
||||
}
|
||||
|
||||
func (h *Handlers) SettingsUpdateCORS(c *gin.Context) {
|
||||
var req corsUpdateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
var cleaned []string
|
||||
for _, o := range req.Origins {
|
||||
v := strings.TrimSpace(o)
|
||||
if v != "" {
|
||||
cleaned = append(cleaned, v)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
resp.Fail(c, http.StatusBadRequest, "origins required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := settings.UpsertCORSAllowOrigins(h.db, cleaned); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
h.st.SetCORSAllowOrigins(cleaned)
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
261
backend/internal/handlers/stores.go
Normal file
261
backend/internal/handlers/stores.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type storeImageReq struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type dishReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type storeUpsertReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
CategoryID uint `json:"category_id" binding:"required"`
|
||||
Address string `json:"address" binding:"required"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lng *float64 `json:"lng"`
|
||||
OpenHours string `json:"open_hours"`
|
||||
Phone string `json:"phone"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Images []storeImageReq `json:"images"`
|
||||
Dishes []dishReq `json:"dishes"`
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreList(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
categoryID, _ := strconv.ParseUint(c.Query("category_id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Store{})
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if categoryID > 0 {
|
||||
q = q.Where("category_id = ?", uint(categoryID))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Store
|
||||
if err := q.Preload("Category").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreGet(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var item models.Store
|
||||
if err := h.db.
|
||||
Preload("Images", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
Preload("Dishes", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
First(&item, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreCreate(c *gin.Context) {
|
||||
var req storeUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "active"
|
||||
}
|
||||
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
item := models.Store{
|
||||
Name: req.Name,
|
||||
CategoryID: req.CategoryID,
|
||||
Address: req.Address,
|
||||
Lat: req.Lat,
|
||||
Lng: req.Lng,
|
||||
OpenHours: req.OpenHours,
|
||||
Phone: req.Phone,
|
||||
CoverURL: req.CoverURL,
|
||||
Description: req.Description,
|
||||
Status: req.Status,
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreImages(tx, item.ID, req.Images); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreDishes(tx, item.ID, req.Dishes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var out models.Store
|
||||
if err := tx.Preload("Images").Preload("Dishes").First(&out, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
resp.OK(c, out)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreUpdate(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req storeUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var item models.Store
|
||||
if err := tx.First(&item, uint(id)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if req.Status == "" {
|
||||
req.Status = item.Status
|
||||
}
|
||||
item.Name = req.Name
|
||||
item.CategoryID = req.CategoryID
|
||||
item.Address = req.Address
|
||||
if req.Lat != nil {
|
||||
item.Lat = req.Lat
|
||||
}
|
||||
if req.Lng != nil {
|
||||
item.Lng = req.Lng
|
||||
}
|
||||
item.OpenHours = req.OpenHours
|
||||
item.Phone = req.Phone
|
||||
item.CoverURL = req.CoverURL
|
||||
item.Description = req.Description
|
||||
item.Status = req.Status
|
||||
|
||||
if err := tx.Save(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Where("store_id = ?", item.ID).Delete(&models.StoreImage{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("store_id = ?", item.ID).Delete(&models.SignatureDish{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreImages(tx, item.ID, req.Images); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreDishes(tx, item.ID, req.Dishes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var out models.Store
|
||||
if err := tx.Preload("Images").Preload("Dishes").First(&out, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
resp.OK(c, out)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "update failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type statusReq struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreUpdateStatus(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req statusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status != "active" && req.Status != "inactive" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.Store{}).Where("id = ?", uint(id)).Update("status", req.Status).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreDelete(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.db.Delete(&models.Store{}, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func upsertStoreImages(tx *gorm.DB, storeID uint, imgs []storeImageReq) error {
|
||||
for _, img := range imgs {
|
||||
if err := tx.Create(&models.StoreImage{
|
||||
StoreID: storeID,
|
||||
URL: img.URL,
|
||||
SortOrder: img.SortOrder,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertStoreDishes(tx *gorm.DB, storeID uint, dishes []dishReq) error {
|
||||
for _, d := range dishes {
|
||||
if err := tx.Create(&models.SignatureDish{
|
||||
StoreID: storeID,
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
ImageURL: d.ImageURL,
|
||||
SortOrder: d.SortOrder,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
82
backend/internal/handlers/upload.go
Normal file
82
backend/internal/handlers/upload.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) Upload(c *gin.Context) {
|
||||
url, ok := h.handleUpload(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// UserUpload allows logged-in users to upload images for reviews.
|
||||
func (h *Handlers) UserUpload(c *gin.Context) {
|
||||
url, ok := h.handleUpload(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handlers) handleUpload(c *gin.Context) (string, bool) {
|
||||
maxMB := h.cfg.MaxUploadMB
|
||||
if maxMB <= 0 {
|
||||
maxMB = 10
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMB*1024*1024)
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "missing file")
|
||||
return "", false
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
||||
default:
|
||||
resp.Fail(c, http.StatusBadRequest, "unsupported file type")
|
||||
return "", false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dir := filepath.Join(h.cfg.UploadDir, strconv.Itoa(now.Year()), now.Format("01"))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "mkdir failed")
|
||||
return "", false
|
||||
}
|
||||
|
||||
name := randomHex(16) + ext
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "save failed")
|
||||
return "", false
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(filepath.ToSlash(dst), ".")
|
||||
if !strings.HasPrefix(rel, "/") {
|
||||
rel = "/" + rel
|
||||
}
|
||||
url := strings.TrimRight(h.cfg.PublicBaseURL, "/") + rel
|
||||
return url, true
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
125
backend/internal/handlers/user_auth.go
Normal file
125
backend/internal/handlers/user_auth.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
mysqlerr "github.com/go-sql-driver/mysql"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type userRegisterReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) UserRegister(c *gin.Context) {
|
||||
var req userRegisterReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload: "+safeErrMsg(err))
|
||||
return
|
||||
}
|
||||
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
u := models.User{Username: req.Username, PasswordHash: string(hash), Status: "active"}
|
||||
if err := h.db.Create(&u).Error; err != nil {
|
||||
if isDuplicateErr(err) {
|
||||
resp.Fail(c, http.StatusConflict, "username already exists")
|
||||
return
|
||||
}
|
||||
resp.Fail(c, http.StatusBadRequest, "register failed: "+safeErrMsg(err))
|
||||
return
|
||||
}
|
||||
token, _ := auth.NewUserToken(h.cfg.JWTSecret, u.ID, u.Username, 30*24*time.Hour)
|
||||
resp.OK(c, gin.H{"token": token, "user": u})
|
||||
}
|
||||
|
||||
type userLoginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) UserLogin(c *gin.Context) {
|
||||
var req userLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload: "+safeErrMsg(err))
|
||||
return
|
||||
}
|
||||
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
|
||||
return
|
||||
}
|
||||
var u models.User
|
||||
if err := h.db.Where("username = ?", req.Username).First(&u).Error; err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
if u.Status != "active" {
|
||||
resp.Fail(c, http.StatusUnauthorized, "account disabled")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(req.Password)); err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
token, _ := auth.NewUserToken(h.cfg.JWTSecret, u.ID, u.Username, 30*24*time.Hour)
|
||||
resp.OK(c, gin.H{"token": token, "user": gin.H{"id": u.ID, "username": u.Username}})
|
||||
}
|
||||
|
||||
func isDuplicateErr(err error) bool {
|
||||
var me *mysqlerr.MySQLError
|
||||
if strings.Contains(err.Error(), "Duplicate entry") {
|
||||
return true
|
||||
}
|
||||
if errors.As(err, &me) {
|
||||
return me.Number == 1062
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func safeErrMsg(err error) string {
|
||||
s := err.Error()
|
||||
if len(s) > 200 {
|
||||
return s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *Handlers) UserMe(c *gin.Context) {
|
||||
id := c.GetUint("user_id")
|
||||
var u models.User
|
||||
if err := h.db.Select("id", "username", "status", "created_at", "updated_at").Where("id = ?", id).First(&u).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
resp.OK(c, u)
|
||||
}
|
||||
|
||||
// 预留:抖音登录(需要对接抖音服务端换取 openid/session_key)
|
||||
type douyinLoginReq struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) DouyinLogin(c *gin.Context) {
|
||||
_ = douyinLoginReq{}
|
||||
resp.Fail(c, http.StatusNotImplemented, "douyin login not implemented yet")
|
||||
}
|
||||
125
backend/internal/httpx/router.go
Normal file
125
backend/internal/httpx/router.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/handlers"
|
||||
"0451meishiditu/backend/internal/middleware"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewRouter(cfg config.Config, log *zap.Logger, db *gorm.DB, rdb *redis.Client, st *settings.Store) *gin.Engine {
|
||||
if cfg.AppEnv == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.Recover(log))
|
||||
r.Use(middleware.RequestID())
|
||||
r.Use(middleware.AccessLog(log))
|
||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOriginFunc: func(origin string) bool { return st.CORSAllowOrigin(origin) },
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Authorization", "Content-Type", "X-API-Key", "X-Request-Id"},
|
||||
ExposeHeaders: []string{"X-Request-Id"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.Static("/static", "./static")
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
h := handlers.New(cfg, log, db, rdb, st)
|
||||
|
||||
api := r.Group("/api")
|
||||
api.Use(middleware.APIKey(cfg, db))
|
||||
|
||||
public := api.Group("")
|
||||
public.GET("/captcha/new", h.CaptchaNew)
|
||||
public.GET("/categories", h.PublicCategoryList)
|
||||
public.GET("/stores", h.PublicStoreList)
|
||||
public.GET("/stores/:id", h.PublicStoreGet)
|
||||
public.GET("/stores/:id/reviews", h.PublicStoreReviews)
|
||||
public.GET("/rankings/stores", h.PublicStoreRanking)
|
||||
public.POST("/merchant/apply", h.MerchantApply)
|
||||
public.GET("/stores/search", h.PublicStoreSearch)
|
||||
public.GET("/stores/hot", h.PublicStoreHotRank)
|
||||
|
||||
user := api.Group("/user")
|
||||
user.POST("/register", h.UserRegister)
|
||||
user.POST("/login", h.UserLogin)
|
||||
user.POST("/douyin/login", h.DouyinLogin)
|
||||
userAuth := user.Group("")
|
||||
userAuth.Use(middleware.UserJWT(cfg))
|
||||
userAuth.GET("/me", h.UserMe)
|
||||
userAuth.GET("/reviews", h.UserMyReviews)
|
||||
userAuth.POST("/upload", h.UserUpload)
|
||||
userAuth.POST("/stores/:id/reviews", h.UserCreateReview)
|
||||
userAuth.POST("/stores/:id/like", h.UserToggleStoreLike)
|
||||
|
||||
admin := api.Group("/admin")
|
||||
admin.POST("/login", h.AdminLogin)
|
||||
|
||||
adminAuth := admin.Group("")
|
||||
adminAuth.Use(middleware.AdminJWT(cfg))
|
||||
|
||||
adminAuth.GET("/me", h.AdminMe)
|
||||
|
||||
adminAuth.GET("/dashboard/overview", h.DashboardOverview)
|
||||
|
||||
adminAuth.GET("/apikeys", h.APIKeyList)
|
||||
adminAuth.POST("/apikeys", h.APIKeyCreate)
|
||||
adminAuth.PATCH("/apikeys/:id/revoke", h.APIKeyRevoke)
|
||||
|
||||
adminAuth.GET("/settings/cors", h.SettingsGetCORS)
|
||||
adminAuth.PUT("/settings/cors", h.SettingsUpdateCORS)
|
||||
|
||||
adminAuth.GET("/merchant/applications", h.AdminMerchantApplyList)
|
||||
adminAuth.PATCH("/merchant/applications/:id/review", h.AdminMerchantApplyReview)
|
||||
|
||||
adminAuth.GET("/rankings/stores", h.AdminStoreRanking)
|
||||
adminAuth.POST("/rankings/stores/recalc", h.AdminRecalcStoreScore)
|
||||
|
||||
adminAuth.GET("/admins", h.AdminListAdmins)
|
||||
adminAuth.POST("/admins", h.AdminCreateAdmin)
|
||||
adminAuth.PATCH("/admins/:id/password", h.AdminUpdateAdminPassword)
|
||||
adminAuth.PATCH("/admins/:id/enabled", h.AdminUpdateAdminEnabled)
|
||||
|
||||
adminAuth.GET("/users", h.AdminUserList)
|
||||
adminAuth.GET("/users/:id", h.AdminUserGet)
|
||||
adminAuth.PATCH("/users/:id/status", h.AdminUserUpdateStatus)
|
||||
|
||||
adminAuth.GET("/categories", h.CategoryList)
|
||||
adminAuth.POST("/categories", h.CategoryCreate)
|
||||
adminAuth.PUT("/categories/:id", h.CategoryUpdate)
|
||||
adminAuth.DELETE("/categories/:id", h.CategoryDelete)
|
||||
|
||||
adminAuth.GET("/stores", h.StoreList)
|
||||
adminAuth.GET("/stores/:id", h.StoreGet)
|
||||
adminAuth.POST("/stores", h.StoreCreate)
|
||||
adminAuth.PUT("/stores/:id", h.StoreUpdate)
|
||||
adminAuth.PATCH("/stores/:id/status", h.StoreUpdateStatus)
|
||||
adminAuth.DELETE("/stores/:id", h.StoreDelete)
|
||||
|
||||
adminAuth.GET("/reviews", h.ReviewList)
|
||||
adminAuth.PATCH("/reviews/:id/status", h.ReviewUpdateStatus)
|
||||
adminAuth.DELETE("/reviews/:id", h.ReviewDelete)
|
||||
|
||||
adminAuth.POST("/upload", h.Upload)
|
||||
|
||||
return r
|
||||
}
|
||||
17
backend/internal/logger/logger.go
Normal file
17
backend/internal/logger/logger.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package logger
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
func New(env string) *zap.Logger {
|
||||
if env == "prod" {
|
||||
l, _ := zap.NewProduction()
|
||||
return l
|
||||
}
|
||||
l, _ := zap.NewDevelopment()
|
||||
return l
|
||||
}
|
||||
|
||||
func Err(err error) zap.Field { return zap.Error(err) }
|
||||
func Str(k, v string) zap.Field { return zap.String(k, v) }
|
||||
func Uint(k string, v uint) zap.Field { return zap.Uint(k, v) }
|
||||
|
||||
25
backend/internal/middleware/access_log.go
Normal file
25
backend/internal/middleware/access_log.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func AccessLog(log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
log.Info("http",
|
||||
logger.Str("method", c.Request.Method),
|
||||
logger.Str("path", c.Request.URL.Path),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("latency", time.Since(start)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
45
backend/internal/middleware/apikey.go
Normal file
45
backend/internal/middleware/apikey.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func APIKey(cfg config.Config, db *gorm.DB) gin.HandlerFunc {
|
||||
expectedHash := cfg.ExpectedAPIKeyHash()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.GetHeader("X-API-Key"))
|
||||
if key == "" {
|
||||
resp.Fail(c, http.StatusUnauthorized, "missing api key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
gotHash := hex.EncodeToString(sum[:])
|
||||
if subtle.ConstantTimeCompare([]byte(gotHash), []byte(expectedHash)) != 1 {
|
||||
// fallback to DB stored keys
|
||||
var ak models.APIKey
|
||||
err := db.Where("hash_sha256 = ? and status = 'active'", gotHash).First(&ak).Error
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid api key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_ = db.Model(&models.APIKey{}).Where("id = ?", ak.ID).UpdateColumn("last_used_at", &now).Error
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
34
backend/internal/middleware/jwt.go
Normal file
34
backend/internal/middleware/jwt.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminJWT(cfg config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
resp.Fail(c, http.StatusUnauthorized, "missing token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(h[len("bearer "):])
|
||||
claims, err := auth.ParseAdminToken(cfg.JWTSecret, token)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("admin_id", claims.AdminID)
|
||||
c.Set("admin_username", claims.Username)
|
||||
c.Set("admin_role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
27
backend/internal/middleware/recover.go
Normal file
27
backend/internal/middleware/recover.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Recover(log *zap.Logger) gin.HandlerFunc {
|
||||
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Error("panic recovered", logger.Str("recovered", toString(recovered)))
|
||||
resp.Fail(c, http.StatusInternalServerError, "internal error")
|
||||
})
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
return "panic"
|
||||
}
|
||||
}
|
||||
19
backend/internal/middleware/request_id.go
Normal file
19
backend/internal/middleware/request_id.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := c.Request.Header.Get("X-Request-Id")
|
||||
if rid == "" {
|
||||
rid = uuid.NewString()
|
||||
}
|
||||
c.Writer.Header().Set("X-Request-Id", rid)
|
||||
c.Set("request_id", rid)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
34
backend/internal/middleware/user_jwt.go
Normal file
34
backend/internal/middleware/user_jwt.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UserJWT(cfg config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
resp.Fail(c, http.StatusUnauthorized, "missing token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(h[len("bearer "):])
|
||||
claims, err := auth.ParseUserToken(cfg.JWTSecret, token)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user_username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
37
backend/internal/migrate/migrate.go
Normal file
37
backend/internal/migrate/migrate.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"0451meishiditu/backend/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(
|
||||
&models.AdminUser{},
|
||||
&models.SystemSetting{},
|
||||
&models.APIKey{},
|
||||
&models.Category{},
|
||||
&models.MerchantApplication{},
|
||||
&models.User{},
|
||||
&models.Store{},
|
||||
&models.StoreMetric{},
|
||||
&models.StoreLike{},
|
||||
&models.StoreImage{},
|
||||
&models.SignatureDish{},
|
||||
&models.Review{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hotfix: allow users.douyin_open_id to be NULL to avoid unique-index conflicts on empty string.
|
||||
_ = db.Exec("ALTER TABLE users MODIFY douyin_open_id varchar(128) NULL").Error
|
||||
_ = db.Exec("UPDATE users SET douyin_open_id = NULL WHERE douyin_open_id = ''").Error
|
||||
|
||||
// Optional: lat/lng are deprecated; allow NULL for existing schemas.
|
||||
_ = db.Exec("ALTER TABLE stores MODIFY lat double NULL").Error
|
||||
_ = db.Exec("ALTER TABLE stores MODIFY lng double NULL").Error
|
||||
_ = db.Exec("ALTER TABLE merchant_applications MODIFY lat double NULL").Error
|
||||
_ = db.Exec("ALTER TABLE merchant_applications MODIFY lng double NULL").Error
|
||||
return nil
|
||||
}
|
||||
168
backend/internal/models/models.go
Normal file
168
backend/internal/models/models.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminUser struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Role string `gorm:"size:32;not null;default:'admin'" json:"role"`
|
||||
Enabled bool `gorm:"not null;default:true;index" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:64;uniqueIndex;not null" json:"name"`
|
||||
IconURL string `gorm:"size:512" json:"icon_url"`
|
||||
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
|
||||
Enabled bool `gorm:"not null;default:true;index" json:"enabled"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null;index" json:"name"`
|
||||
CategoryID uint `gorm:"not null;index" json:"category_id"`
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
|
||||
Address string `gorm:"size:255;not null" json:"address"`
|
||||
Lat *float64 `gorm:"index" json:"-"`
|
||||
Lng *float64 `gorm:"index" json:"-"`
|
||||
OpenHours string `gorm:"size:128" json:"open_hours"`
|
||||
Phone string `gorm:"size:64" json:"phone"`
|
||||
CoverURL string `gorm:"size:512" json:"cover_url"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"` // active/inactive
|
||||
Images []StoreImage `gorm:"foreignKey:StoreID" json:"images"`
|
||||
Dishes []SignatureDish `gorm:"foreignKey:StoreID" json:"dishes"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type StoreImage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index" json:"store_id"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type SignatureDish struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index" json:"store_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
ImageURL string `gorm:"size:512" json:"image_url"`
|
||||
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type Review struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index" json:"store_id"`
|
||||
Store Store `gorm:"foreignKey:StoreID" json:"store"`
|
||||
UserID *uint `gorm:"index" json:"user_id"`
|
||||
UserName string `gorm:"size:64" json:"user_name"`
|
||||
Rating int `gorm:"not null;index" json:"rating"` // 1-5
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
ImageURLs datatypes.JSON `gorm:"type:json" json:"image_urls"`
|
||||
// RecommendDishes: [{"name":"锅包肉","image_url":"","like":true}]
|
||||
RecommendDishes datatypes.JSON `gorm:"type:json" json:"recommend_dishes"`
|
||||
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending/approved/blocked
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type MerchantApplication struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
StoreName string `gorm:"size:128;not null;index" json:"store_name"`
|
||||
CategoryID uint `gorm:"not null;index" json:"category_id"`
|
||||
Address string `gorm:"size:255;not null" json:"address"`
|
||||
Lat *float64 `gorm:"index" json:"-"`
|
||||
Lng *float64 `gorm:"index" json:"-"`
|
||||
OpenHours string `gorm:"size:128" json:"open_hours"`
|
||||
Phone string `gorm:"size:64" json:"phone"`
|
||||
CoverURL string `gorm:"size:512" json:"cover_url"`
|
||||
ImageURLs datatypes.JSON `gorm:"type:json" json:"image_urls"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
|
||||
ContactName string `gorm:"size:64" json:"contact_name"`
|
||||
ContactPhone string `gorm:"size:64" json:"contact_phone"`
|
||||
|
||||
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending/approved/rejected
|
||||
RejectReason string `gorm:"type:text" json:"reject_reason"`
|
||||
ReviewedAt *time.Time `gorm:"index" json:"reviewed_at"`
|
||||
ReviewerID *uint `gorm:"index" json:"reviewer_id"`
|
||||
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
|
||||
DouyinOpenID *string `gorm:"size:128;uniqueIndex" json:"douyin_openid,omitempty"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"` // active/disabled
|
||||
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type StoreLike struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index;uniqueIndex:uk_store_user" json:"store_id"`
|
||||
UserID uint `gorm:"not null;index;uniqueIndex:uk_store_user" json:"user_id"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
type StoreMetric struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;uniqueIndex" json:"store_id"`
|
||||
LikesCount int64 `gorm:"not null;default:0;index" json:"likes_count"`
|
||||
SearchCount int64 `gorm:"not null;default:0;index" json:"search_count"`
|
||||
ReviewsCount int64 `gorm:"not null;default:0;index" json:"reviews_count"`
|
||||
Score float64 `gorm:"not null;default:0;index" json:"score"`
|
||||
UpdatedAt time.Time `gorm:"index" json:"updated_at"`
|
||||
}
|
||||
|
||||
type SystemSetting 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"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Prefix string `gorm:"size:16;index;not null" json:"prefix"`
|
||||
HashSHA256 string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||
Status string `gorm:"size:16;index;not null;default:'active'" json:"status"` // active/revoked
|
||||
LastUsedAt *time.Time `gorm:"index" json:"last_used_at"`
|
||||
RevokedAt *time.Time `gorm:"index" json:"revoked_at"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
23
backend/internal/redisx/redis.go
Normal file
23
backend/internal/redisx/redis.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package redisx
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func New(cfg config.Config) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: cfg.RedisAddr,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
DialTimeout: 3 * time.Second,
|
||||
ReadTimeout: 2 * time.Second,
|
||||
WriteTimeout: 2 * time.Second,
|
||||
PoolSize: 20,
|
||||
MinIdleConns: 2,
|
||||
})
|
||||
}
|
||||
|
||||
27
backend/internal/resp/resp.go
Normal file
27
backend/internal/resp/resp.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package resp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Body struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Body{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func OKMeta(c *gin.Context, data interface{}, meta interface{}) {
|
||||
c.JSON(http.StatusOK, Body{Code: 0, Message: "ok", Data: data, Meta: meta})
|
||||
}
|
||||
|
||||
func Fail(c *gin.Context, httpStatus int, msg string) {
|
||||
c.JSON(httpStatus, Body{Code: httpStatus, Message: msg})
|
||||
}
|
||||
|
||||
36
backend/internal/seed/admin.go
Normal file
36
backend/internal/seed/admin.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func EnsureInitialAdmin(db *gorm.DB, cfg config.Config) error {
|
||||
var cnt int64
|
||||
if err := db.Model(&models.AdminUser{}).Count(&cnt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cnt > 0 {
|
||||
return nil
|
||||
}
|
||||
if cfg.AdminInitUsername == "" || cfg.AdminInitPassword == "" {
|
||||
return errors.New("ADMIN_INIT_USERNAME and ADMIN_INIT_PASSWORD are required for initial seed")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminInitPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Create(&models.AdminUser{
|
||||
Username: cfg.AdminInitUsername,
|
||||
PasswordHash: string(hash),
|
||||
Role: "admin",
|
||||
}).Error
|
||||
}
|
||||
|
||||
102
backend/internal/settings/store.go
Normal file
102
backend/internal/settings/store.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const keyCORSAllowOrigins = "cors_allow_origins"
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
origins map[string]struct{}
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, cfg config.Config) (*Store, error) {
|
||||
s := &Store{origins: map[string]struct{}{}}
|
||||
|
||||
// init from DB if exists; otherwise seed from env
|
||||
var row models.SystemSetting
|
||||
err := db.Where("`key` = ?", keyCORSAllowOrigins).First(&row).Error
|
||||
if err == nil {
|
||||
s.SetCORSAllowOrigins(parseOrigins(row.Value))
|
||||
return s, nil
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.SetCORSAllowOrigins(cfg.CORSAllowOrigins)
|
||||
_ = db.Create(&models.SystemSetting{Key: keyCORSAllowOrigins, Value: strings.Join(cfg.CORSAllowOrigins, ",")}).Error
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) CORSAllowOrigins() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]string, 0, len(s.origins))
|
||||
for o := range s.origins {
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) CORSAllowOrigin(origin string) bool {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, ok := s.origins["*"]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := s.origins[origin]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Store) SetCORSAllowOrigins(origins []string) {
|
||||
m := map[string]struct{}{}
|
||||
for _, o := range origins {
|
||||
v := strings.TrimSpace(o)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
m[v] = struct{}{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.origins = m
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func parseOrigins(raw string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
v := strings.TrimSpace(part)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func UpsertCORSAllowOrigins(db *gorm.DB, origins []string) error {
|
||||
val := strings.Join(origins, ",")
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var row models.SystemSetting
|
||||
err := tx.Where("`key` = ?", keyCORSAllowOrigins).First(&row).Error
|
||||
if err == nil {
|
||||
return tx.Model(&models.SystemSetting{}).Where("id = ?", row.ID).Update("value", val).Error
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.SystemSetting{Key: keyCORSAllowOrigins, Value: val}).Error
|
||||
})
|
||||
}
|
||||
|
||||
15
backend/scripts/entrypoint.sh
Normal file
15
backend/scripts/entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
UPLOAD_DIR="${UPLOAD_DIR:-./static/upload}"
|
||||
|
||||
case "$UPLOAD_DIR" in
|
||||
/*) abs="$UPLOAD_DIR" ;;
|
||||
*) abs="/app/$UPLOAD_DIR" ;;
|
||||
esac
|
||||
|
||||
mkdir -p "$abs"
|
||||
chown -R appuser:appuser "$abs" || true
|
||||
|
||||
exec su-exec appuser /app/server
|
||||
|
||||
Reference in New Issue
Block a user