哈尔滨美食地图

This commit is contained in:
2026-01-15 11:37:22 +08:00
commit 7817cb6ea4
84 changed files with 10258 additions and 0 deletions

40
backend/.env.example Normal file
View 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_HASHSHA256 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
View 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"]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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