commit 7817cb6ea4f8ae3be32490e49bb301886abce9f4 Author: tiedanzi Date: Thu Jan 15 11:37:22 2026 +0800 哈尔滨美食地图 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79a824a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.env +**/.env + +# Go +**/*.exe +**/*.exe~ +**/*.dll +**/*.so +**/*.dylib +**/bin/ +**/dist/ +**/.git1beifen/ + +# Node +**/node_modules/ + +# Uploads +backend/static/upload/ + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/0451meishiditu.iml b/.idea/0451meishiditu.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/0451meishiditu.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9961ff8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da963a --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# 哈尔滨美食地图(0451meishiditu)管理后台 & 后端 + +## 技术栈 + +- 后端:Go + Gin + GORM + MySQL + Redis +- 管理后台:Vue3 + Vite + TypeScript + Element Plus + ECharts +- 部署:Docker Compose + Nginx + +## 快速启动(Docker Compose) + +1. 复制环境变量文件 + - `backend/.env.example` → `backend/.env` + - `admin/.env.example` → `admin/.env` + +2. 启动(会同时启动 MySQL、Redis、后端、管理后台、Swagger UI) + - `docker compose -f deploy/docker-compose.full.yml up -d --build` + +3. 访问 + - 管理后台:`http://localhost:5173` + - 后端健康检查:`http://localhost:8080/healthz` + - Swagger 文档:`http://localhost:8081`(加载 `docs/openapi.yaml`) + +## 已实现模块(管理后台) + +- 数据概览(Dashboard) +- 店铺管理(列表/详情/上下架/删除) +- 分类管理 +- 评论管理(审核/删除) +- 商家入驻审核(通过/拒绝) +- 排行管理(查看/重算综合分) +- 用户管理(列表/详情/启用禁用) +- 管理员管理(新增/重置密码/启用禁用) +- APIKey 管理(创建/撤销/查看使用时间) +- 系统设置(CORS) + +## 认证与安全(非常重要) + +1) APIKey(所有 `/api/**` 必须携带) +- Header:`X-API-Key: ` +- 开发环境默认值来自 `backend/.env`:`API_KEY=dev-api-key-change-me` + +2) 管理端 JWT(管理端 `/api/admin/**`,除登录外必须携带) +- Header:`Authorization: Bearer ` + +3) 用户端 JWT(用户端需要登录的接口必须携带) +- Header:`Authorization: Bearer ` + +## 默认管理员账号(首次启动初始化) + +由 `backend/.env` 控制(首次启动会自动创建): +- `ADMIN_INIT_USERNAME=admin` +- `ADMIN_INIT_PASSWORD=admin123456` + +建议: +- 生产环境务必修改 `JWT_SECRET`、`API_KEY`(或使用 `API_KEY_HASH`) +- 首次进入后台后,在「管理员管理」创建新管理员,并禁用默认管理员 + +## CORS 跨域设置(支持手动配置) + +默认允许: +- `http://localhost:5173` + +配置方式: +1) 推荐:在管理后台「系统设置」配置 +- 一行一个 Origin,例如: + - `http://localhost:5173` + - `https://admin.example.com` +- 支持 `*`(仅建议开发环境) +- 保存后立即生效(内存热更新 + 持久化到 DB) + +2) 通过 `backend/.env` 配置 +- `CORS_ALLOW_ORIGINS`:使用英文逗号分隔 +- 示例:`CORS_ALLOW_ORIGINS=http://localhost:5173,https://admin.example.com` + +## 用户注册/登录验证码 + +- `GET /api/captcha/new` 获取验证码(返回 `captcha_id` + `svg`) +- `POST /api/user/register`、`POST /api/user/login` 需要携带: + - `captcha_id` + - `captcha_code` + +## 管理员登录验证码 + +- `POST /api/admin/login` 同样需要携带 `captcha_id` + `captcha_code`(防止暴力破解) +- 管理后台登录页已集成验证码展示与点击刷新 + +## 公共读取接口(前端可调用) + +这些接口仍然需要 `X-API-Key`,但不需要管理员 JWT: +- 分类列表:`GET /api/categories`(只返回 enabled=true) +- 店铺列表:`GET /api/stores`(只返回 active) +- 店铺详情:`GET /api/stores/:id`(只返回 active) +- 店铺评论:`GET /api/stores/:id/reviews`(只返回 approved) +- 店铺排行:`GET /api/rankings/stores` + +详见:`docs/API.md`(中文说明)和 `docs/openapi.yaml`(Swagger) + +## 图片上传与目录权限 + +- 上传接口:`POST /api/admin/upload`(`multipart/form-data`,字段名 `file`) +- Docker 下默认把上传目录挂载到数据卷:`backend_uploads:/app/static/upload` +- 已处理常见的 `mkdir failed` 权限问题(容器启动时自动创建/授权上传目录) + +## 文档 + +- Swagger/OpenAPI:`docs/openapi.yaml`(Swagger UI:`http://localhost:8081`) +- 中文接口说明:`docs/API.md` + +## 常见启动问题 + +- 容器名/端口/数据卷冲突:执行 `docker compose -f deploy/docker-compose.full.yml down -v` 后再 `up -d --build` + - 注意:`down -v` 会清空 MySQL/Redis 数据 diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 0000000..05ad4e7 --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=http://localhost:8080 +VITE_API_KEY=dev-api-key-change-me + diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..f6ffa9c --- /dev/null +++ b/admin/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS build +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 + diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..2f16915 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,13 @@ + + + + + + 哈尔滨美食地图 - 管理后台 + + +
+ + + + diff --git a/admin/nginx.conf b/admin/nginx.conf new file mode 100644 index 0000000..7233771 --- /dev/null +++ b/admin/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} + diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..5f185a3 --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,2236 @@ +{ + "name": "meishiditu-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meishiditu-admin", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.7", + "echarts": "^5.5.1", + "element-plus": "^2.8.6", + "pinia": "^2.2.6", + "vue": "^3.5.13", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.4", + "sass": "^1.80.4", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.12.0.tgz", + "integrity": "sha512-M9YLSn2np9OnqrSKWsiXvGe3qnF8pd94+TScsHj1aTMCD+nSEvucXermf807qNt6hOP040le0e5Aft7E9ZfHmA==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.97.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.0.tgz", + "integrity": "sha512-KR0igP1z4avUJetEuIeOdDlwaUDvkH8wSx7FdSjyYBS3dpyX3TzHfAMO0G1Q4/3cdjcmi3r7idh+KCmKqS+KeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..ae90b57 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,26 @@ +{ + "name": "meishiditu-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --host" + }, + "dependencies": { + "axios": "^1.7.7", + "echarts": "^5.5.1", + "element-plus": "^2.8.6", + "pinia": "^2.2.6", + "vue": "^3.5.13", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.4", + "sass": "^1.80.4", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} + diff --git a/admin/src/App.vue b/admin/src/App.vue new file mode 100644 index 0000000..c2549a1 --- /dev/null +++ b/admin/src/App.vue @@ -0,0 +1,4 @@ + + diff --git a/admin/src/components/DishesEditor.vue b/admin/src/components/DishesEditor.vue new file mode 100644 index 0000000..12e0da5 --- /dev/null +++ b/admin/src/components/DishesEditor.vue @@ -0,0 +1,57 @@ + + + + + + diff --git a/admin/src/components/Uploader.vue b/admin/src/components/Uploader.vue new file mode 100644 index 0000000..b1ed94b --- /dev/null +++ b/admin/src/components/Uploader.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/admin/src/components/UploaderList.vue b/admin/src/components/UploaderList.vue new file mode 100644 index 0000000..5ce523e --- /dev/null +++ b/admin/src/components/UploaderList.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/admin/src/lib/http.ts b/admin/src/lib/http.ts new file mode 100644 index 0000000..8103b70 --- /dev/null +++ b/admin/src/lib/http.ts @@ -0,0 +1,27 @@ +import axios from 'axios' +import { useAuthStore } from '../stores/auth' + +export const http = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '' +}) + +http.interceptors.request.use((config) => { + const auth = useAuthStore() + config.headers = config.headers || {} + config.headers['X-API-Key'] = import.meta.env.VITE_API_KEY + if (auth.token) config.headers['Authorization'] = `Bearer ${auth.token}` + return config +}) + +http.interceptors.response.use( + (res) => res, + (err) => { + if (err?.response?.status === 401) { + const auth = useAuthStore() + auth.logout() + location.href = '/login' + } + return Promise.reject(err) + } +) + diff --git a/admin/src/main.ts b/admin/src/main.ts new file mode 100644 index 0000000..b9e209d --- /dev/null +++ b/admin/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import './styles/global.scss' + +import App from './App.vue' +import { router } from './router' + +createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app') + diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts new file mode 100644 index 0000000..5833ad3 --- /dev/null +++ b/admin/src/router/index.ts @@ -0,0 +1,50 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const Login = () => import('../views/LoginView.vue') +const Layout = () => import('../views/LayoutView.vue') +const Dashboard = () => import('../views/DashboardView.vue') +const Categories = () => import('../views/CategoriesView.vue') +const Stores = () => import('../views/StoresView.vue') +const StoreDetail = () => import('../views/StoreDetailView.vue') +const Reviews = () => import('../views/ReviewsView.vue') +const APIKeys = () => import('../views/APIKeysView.vue') +const Settings = () => import('../views/SettingsView.vue') +const MerchantApps = () => import('../views/MerchantApplicationsView.vue') +const Rankings = () => import('../views/RankingsView.vue') +const Users = () => import('../views/UsersView.vue') +const UserDetail = () => import('../views/UserDetailView.vue') +const AdminUsers = () => import('../views/AdminUsersView.vue') + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: Login }, + { + path: '/', + component: Layout, + children: [ + { path: '', redirect: '/dashboard' }, + { path: 'dashboard', component: Dashboard }, + { path: 'categories', component: Categories }, + { path: 'stores', component: Stores }, + { path: 'stores/:id', component: StoreDetail }, + { path: 'reviews', component: Reviews }, + { path: 'apikeys', component: APIKeys }, + { path: 'settings', component: Settings }, + { path: 'merchant-applications', component: MerchantApps }, + { path: 'rankings', component: Rankings }, + { path: 'users', component: Users }, + { path: 'users/:id', component: UserDetail }, + { path: 'admin-users', component: AdminUsers } + ] + } + ] +}) + +router.beforeEach((to) => { + const auth = useAuthStore() + if (to.path === '/login') return true + if (!auth.token) return '/login' + return true +}) diff --git a/admin/src/stores/auth.ts b/admin/src/stores/auth.ts new file mode 100644 index 0000000..d1fad73 --- /dev/null +++ b/admin/src/stores/auth.ts @@ -0,0 +1,18 @@ +import { defineStore } from 'pinia' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: localStorage.getItem('admin_token') || '' + }), + actions: { + setToken(token: string) { + this.token = token + localStorage.setItem('admin_token', token) + }, + logout() { + this.token = '' + localStorage.removeItem('admin_token') + } + } +}) + diff --git a/admin/src/styles/global.scss b/admin/src/styles/global.scss new file mode 100644 index 0000000..c0f44b3 --- /dev/null +++ b/admin/src/styles/global.scss @@ -0,0 +1,38 @@ +:root { + --app-bg: radial-gradient(1200px circle at 20% 10%, #f6fbff 0%, #f5f7fb 45%, #f1f5ff 100%); + --el-color-primary: #2563eb; + --el-border-radius-base: 12px; +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + background: var(--app-bg); + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", + "Segoe UI Emoji"; + color: #1f2937; +} + +.card { + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(15, 23, 42, 0.06); + border-radius: 14px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(8px); +} + +.el-menu { + --el-menu-item-height: 44px; + --el-menu-hover-bg-color: rgba(37, 99, 235, 0.08); + --el-menu-active-color: #1d4ed8; +} + +/* Ensure image preview overlay is above tables/cards */ +.el-image-viewer__wrapper { + z-index: 4000 !important; +} diff --git a/admin/src/views/APIKeysView.vue b/admin/src/views/APIKeysView.vue new file mode 100644 index 0000000..8f94cde --- /dev/null +++ b/admin/src/views/APIKeysView.vue @@ -0,0 +1,167 @@ + + + + + + diff --git a/admin/src/views/AdminUsersView.vue b/admin/src/views/AdminUsersView.vue new file mode 100644 index 0000000..cabd1d9 --- /dev/null +++ b/admin/src/views/AdminUsersView.vue @@ -0,0 +1,174 @@ + + + + + + diff --git a/admin/src/views/CategoriesView.vue b/admin/src/views/CategoriesView.vue new file mode 100644 index 0000000..299f621 --- /dev/null +++ b/admin/src/views/CategoriesView.vue @@ -0,0 +1,154 @@ + + + + + + diff --git a/admin/src/views/DashboardView.vue b/admin/src/views/DashboardView.vue new file mode 100644 index 0000000..032ec01 --- /dev/null +++ b/admin/src/views/DashboardView.vue @@ -0,0 +1,120 @@ + + + + + + diff --git a/admin/src/views/LayoutView.vue b/admin/src/views/LayoutView.vue new file mode 100644 index 0000000..3583687 --- /dev/null +++ b/admin/src/views/LayoutView.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/admin/src/views/LoginView.vue b/admin/src/views/LoginView.vue new file mode 100644 index 0000000..bfd97f4 --- /dev/null +++ b/admin/src/views/LoginView.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/admin/src/views/MerchantApplicationsView.vue b/admin/src/views/MerchantApplicationsView.vue new file mode 100644 index 0000000..07d7b5a --- /dev/null +++ b/admin/src/views/MerchantApplicationsView.vue @@ -0,0 +1,158 @@ + + + + + + diff --git a/admin/src/views/RankingsView.vue b/admin/src/views/RankingsView.vue new file mode 100644 index 0000000..7789150 --- /dev/null +++ b/admin/src/views/RankingsView.vue @@ -0,0 +1,103 @@ + + + + + + diff --git a/admin/src/views/ReviewsView.vue b/admin/src/views/ReviewsView.vue new file mode 100644 index 0000000..dc69251 --- /dev/null +++ b/admin/src/views/ReviewsView.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/admin/src/views/SettingsView.vue b/admin/src/views/SettingsView.vue new file mode 100644 index 0000000..f9429e5 --- /dev/null +++ b/admin/src/views/SettingsView.vue @@ -0,0 +1,78 @@ + + + + + + diff --git a/admin/src/views/StoreDetailView.vue b/admin/src/views/StoreDetailView.vue new file mode 100644 index 0000000..126ad8a --- /dev/null +++ b/admin/src/views/StoreDetailView.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/admin/src/views/StoresView.vue b/admin/src/views/StoresView.vue new file mode 100644 index 0000000..c45e808 --- /dev/null +++ b/admin/src/views/StoresView.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/admin/src/views/UserDetailView.vue b/admin/src/views/UserDetailView.vue new file mode 100644 index 0000000..04dc086 --- /dev/null +++ b/admin/src/views/UserDetailView.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/admin/src/views/UsersView.vue b/admin/src/views/UsersView.vue new file mode 100644 index 0000000..bd4b94a --- /dev/null +++ b/admin/src/views/UsersView.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/admin/src/vite-env.d.ts b/admin/src/vite-env.d.ts new file mode 100644 index 0000000..ed77210 --- /dev/null +++ b/admin/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..ca258bb --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true + }, + "include": ["src"] +} + diff --git a/admin/vite.config.ts b/admin/vite.config.ts new file mode 100644 index 0000000..c4710e6 --- /dev/null +++ b/admin/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + return { + plugins: [vue()], + build: { + rollupOptions: { + output: { + manualChunks: { + vendor: ['vue', 'vue-router', 'pinia', 'axios'], + 'vendor-ui': ['element-plus'], + echarts: ['echarts'] + } + } + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: env.VITE_API_BASE_URL || 'http://localhost:8080', + changeOrigin: true + } + } + } + } +}) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d9f3320 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,40 @@ +# App +APP_ENV=dev +APP_PORT=8080 +PUBLIC_BASE_URL=http://localhost:8080 + +# Admin init (首次启动会创建) +ADMIN_INIT_USERNAME=admin +ADMIN_INIT_PASSWORD=admin123456 + +# Auth +JWT_SECRET=please_change_me + +# API Key(管理后台每次请求都需要携带 X-API-Key) +# 生产环境建议用 API_KEY_HASH(SHA256 hex),不要直接放明文 API_KEY +API_KEY=dev-api-key-change-me +# API_KEY_HASH= + +# Database(也可在 docker-compose 里覆盖) +DB_HOST=127.0.0.1 +DB_PORT=3309 +DB_NAME=mydb +DB_USER=user +DB_PASSWORD=password123 +DB_PARAMS=charset=utf8mb4&parseTime=True&loc=Local + +# Redis +REDIS_ADDR=127.0.0.1:6381 +REDIS_PASSWORD= +REDIS_DB=0 + +# Upload +UPLOAD_DIR=./static/upload +MAX_UPLOAD_MB=10 + +# CORS(管理后台域名) +CORS_ALLOW_ORIGINS=http://localhost:5173 + +# AMap(地址解析/选点,当前为预留) +AMAP_KEY= + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f6c0dac --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..ad38729 --- /dev/null +++ b/backend/cmd/server/main.go @@ -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") +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..cc5dc3d --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..5fa4c76 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..ccd6e89 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -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 +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..fe99be3 --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} + diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..f792bec --- /dev/null +++ b/backend/internal/db/db.go @@ -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 +} diff --git a/backend/internal/handlers/admin_admins.go b/backend/internal/handlers/admin_admins.go new file mode 100644 index 0000000..5a26382 --- /dev/null +++ b/backend/internal/handlers/admin_admins.go @@ -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}) +} + diff --git a/backend/internal/handlers/admin_auth.go b/backend/internal/handlers/admin_auth.go new file mode 100644 index 0000000..34baf24 --- /dev/null +++ b/backend/internal/handlers/admin_auth.go @@ -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"), + }) +} diff --git a/backend/internal/handlers/admin_rankings.go b/backend/internal/handlers/admin_rankings.go new file mode 100644 index 0000000..c65f93f --- /dev/null +++ b/backend/internal/handlers/admin_rankings.go @@ -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}) +} + diff --git a/backend/internal/handlers/admin_user_get.go b/backend/internal/handlers/admin_user_get.go new file mode 100644 index 0000000..dad1803 --- /dev/null +++ b/backend/internal/handlers/admin_user_get.go @@ -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) +} + diff --git a/backend/internal/handlers/admin_users.go b/backend/internal/handlers/admin_users.go new file mode 100644 index 0000000..41d8fd1 --- /dev/null +++ b/backend/internal/handlers/admin_users.go @@ -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}) +} + diff --git a/backend/internal/handlers/apikeys.go b/backend/internal/handlers/apikeys.go new file mode 100644 index 0000000..2d72d06 --- /dev/null +++ b/backend/internal/handlers/apikeys.go @@ -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}) +} diff --git a/backend/internal/handlers/captcha.go b/backend/internal/handlers/captcha.go new file mode 100644 index 0000000..8ba3db1 --- /dev/null +++ b/backend/internal/handlers/captcha.go @@ -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 + esc := html.EscapeString(code) + return `` + + `` + + `` + + `` + esc + `` + + `` +} diff --git a/backend/internal/handlers/categories.go b/backend/internal/handlers/categories.go new file mode 100644 index 0000000..50f2f0d --- /dev/null +++ b/backend/internal/handlers/categories.go @@ -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}) +} diff --git a/backend/internal/handlers/dashboard.go b/backend/internal/handlers/dashboard.go new file mode 100644 index 0000000..d7b3a3e --- /dev/null +++ b/backend/internal/handlers/dashboard.go @@ -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 +} diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go new file mode 100644 index 0000000..4348cfd --- /dev/null +++ b/backend/internal/handlers/handlers.go @@ -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} +} diff --git a/backend/internal/handlers/merchant_apply.go b/backend/internal/handlers/merchant_apply.go new file mode 100644 index 0000000..e9dbd5e --- /dev/null +++ b/backend/internal/handlers/merchant_apply.go @@ -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") +} diff --git a/backend/internal/handlers/pagination.go b/backend/internal/handlers/pagination.go new file mode 100644 index 0000000..84d4d9d --- /dev/null +++ b/backend/internal/handlers/pagination.go @@ -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) +} + diff --git a/backend/internal/handlers/public_read.go b/backend/internal/handlers/public_read.go new file mode 100644 index 0000000..e9225c1 --- /dev/null +++ b/backend/internal/handlers/public_read.go @@ -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), + }) +} diff --git a/backend/internal/handlers/public_store.go b/backend/internal/handlers/public_store.go new file mode 100644 index 0000000..72f7128 --- /dev/null +++ b/backend/internal/handlers/public_store.go @@ -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}) +} + diff --git a/backend/internal/handlers/reviews.go b/backend/internal/handlers/reviews.go new file mode 100644 index 0000000..e77fd0b --- /dev/null +++ b/backend/internal/handlers/reviews.go @@ -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}) +} diff --git a/backend/internal/handlers/settings.go b/backend/internal/handlers/settings.go new file mode 100644 index 0000000..0b8bea6 --- /dev/null +++ b/backend/internal/handlers/settings.go @@ -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}) +} diff --git a/backend/internal/handlers/stores.go b/backend/internal/handlers/stores.go new file mode 100644 index 0000000..c1e9a36 --- /dev/null +++ b/backend/internal/handlers/stores.go @@ -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 +} diff --git a/backend/internal/handlers/upload.go b/backend/internal/handlers/upload.go new file mode 100644 index 0000000..ba0c6ce --- /dev/null +++ b/backend/internal/handlers/upload.go @@ -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) +} diff --git a/backend/internal/handlers/user_auth.go b/backend/internal/handlers/user_auth.go new file mode 100644 index 0000000..b5bbf37 --- /dev/null +++ b/backend/internal/handlers/user_auth.go @@ -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") +} diff --git a/backend/internal/httpx/router.go b/backend/internal/httpx/router.go new file mode 100644 index 0000000..2cb3fd1 --- /dev/null +++ b/backend/internal/httpx/router.go @@ -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 +} diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 0000000..1733f4a --- /dev/null +++ b/backend/internal/logger/logger.go @@ -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) } + diff --git a/backend/internal/middleware/access_log.go b/backend/internal/middleware/access_log.go new file mode 100644 index 0000000..113705c --- /dev/null +++ b/backend/internal/middleware/access_log.go @@ -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)), + ) + } +} + diff --git a/backend/internal/middleware/apikey.go b/backend/internal/middleware/apikey.go new file mode 100644 index 0000000..d9dadf9 --- /dev/null +++ b/backend/internal/middleware/apikey.go @@ -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() + } +} diff --git a/backend/internal/middleware/jwt.go b/backend/internal/middleware/jwt.go new file mode 100644 index 0000000..ed96429 --- /dev/null +++ b/backend/internal/middleware/jwt.go @@ -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() + } +} diff --git a/backend/internal/middleware/recover.go b/backend/internal/middleware/recover.go new file mode 100644 index 0000000..f277ac9 --- /dev/null +++ b/backend/internal/middleware/recover.go @@ -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" + } +} diff --git a/backend/internal/middleware/request_id.go b/backend/internal/middleware/request_id.go new file mode 100644 index 0000000..901afcb --- /dev/null +++ b/backend/internal/middleware/request_id.go @@ -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() + } +} + diff --git a/backend/internal/middleware/user_jwt.go b/backend/internal/middleware/user_jwt.go new file mode 100644 index 0000000..ac39d33 --- /dev/null +++ b/backend/internal/middleware/user_jwt.go @@ -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() + } +} + diff --git a/backend/internal/migrate/migrate.go b/backend/internal/migrate/migrate.go new file mode 100644 index 0000000..0e6a145 --- /dev/null +++ b/backend/internal/migrate/migrate.go @@ -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 +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..f6a767c --- /dev/null +++ b/backend/internal/models/models.go @@ -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:"-"` +} diff --git a/backend/internal/redisx/redis.go b/backend/internal/redisx/redis.go new file mode 100644 index 0000000..b884453 --- /dev/null +++ b/backend/internal/redisx/redis.go @@ -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, + }) +} + diff --git a/backend/internal/resp/resp.go b/backend/internal/resp/resp.go new file mode 100644 index 0000000..2f660c3 --- /dev/null +++ b/backend/internal/resp/resp.go @@ -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}) +} + diff --git a/backend/internal/seed/admin.go b/backend/internal/seed/admin.go new file mode 100644 index 0000000..2b81b3f --- /dev/null +++ b/backend/internal/seed/admin.go @@ -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 +} + diff --git a/backend/internal/settings/store.go b/backend/internal/settings/store.go new file mode 100644 index 0000000..e76308b --- /dev/null +++ b/backend/internal/settings/store.go @@ -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 + }) +} + diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh new file mode 100644 index 0000000..002cce7 --- /dev/null +++ b/backend/scripts/entrypoint.sh @@ -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 + diff --git a/deploy/docker-compose.full.yml b/deploy/docker-compose.full.yml new file mode 100644 index 0000000..6d55769 --- /dev/null +++ b/deploy/docker-compose.full.yml @@ -0,0 +1,112 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: mydb + MYSQL_USER: user + MYSQL_PASSWORD: password123 + ports: + - "3309:3306" + volumes: + - mysql_data:/var/lib/mysql + networks: + - app-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 20s + + redis: + image: redis:7.4.1 + ports: + - "6381:6379" + volumes: + - redis_data:/data + networks: + - app-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 5s + + backend: + build: + context: ../backend + dockerfile: Dockerfile + env_file: + - ../backend/.env + environment: + DB_HOST: mysql + DB_PORT: "3306" + DB_NAME: mydb + DB_USER: user + DB_PASSWORD: password123 + REDIS_ADDR: redis:6379 + PUBLIC_BASE_URL: http://localhost:8080 + ports: + - "8080:8080" + volumes: + - backend_uploads:/app/static/upload + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s + + admin: + build: + context: ../admin + dockerfile: Dockerfile + ports: + - "5173:80" + depends_on: + backend: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + + swagger: + image: swaggerapi/swagger-ui:latest + container_name: meishiditu-swagger + ports: + - "8081:8080" + environment: + SWAGGER_JSON: /openapi.yaml + volumes: + - ../docs/openapi.yaml:/openapi.yaml:ro + networks: + - app-network + depends_on: + - backend + +volumes: + mysql_data: + redis_data: + backend_uploads: + +networks: + app-network: + driver: bridge diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..cf8bec5 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,340 @@ +# API 使用说明(中文) + +## Base URL + +- 后端:`http://localhost:8080` +- Swagger(推荐看这个,字段更全):`http://localhost:8081`(加载 `docs/openapi.yaml`) + +## 统一响应结构 + +后端所有接口统一返回: +```json +{ "code": 0, "message": "ok", "data": {}, "meta": {} } +``` + +- `code=0` 表示成功 +- `code!=0` 表示失败(通常等于 HTTP 状态码) + +## 必须携带的请求头 + +本项目所有 `/api/**` 接口都需要 APIKey: +- `X-API-Key: <你的apikey>` + +管理端接口(`/api/admin/**`,除登录外)还需要管理员 JWT: +- `Authorization: Bearer ` + +用户端需要登录的接口(`/api/user/**` 的部分接口)需要用户 JWT: +- `Authorization: Bearer ` + +默认开发环境(可在 `backend/.env` 修改): +- `API_KEY=dev-api-key-change-me` + +## PowerShell / curl 提示(Windows) + +- Windows PowerShell 里的 `curl` 可能是 `Invoke-WebRequest` 的别名;建议用 `curl.exe` 或 `Invoke-RestMethod`。 + +## 验证码(Captcha) + +用户注册/登录 **必须先获取验证码**,再把 `captcha_id` + `captcha_code` 带到接口里。 + +### 获取验证码(SVG) + +`GET /api/captcha/new` + +```bash +curl.exe "http://localhost:8080/api/captcha/new" -H "X-API-Key: dev-api-key-change-me" +``` + +返回示例: +```json +{ + "code": 0, + "message": "ok", + "data": { + "captcha_id": "c180df303849ea6dc75c670c4f1062b0", + "svg": "XHSXY" + } +} +``` + +说明: +- `captcha_id` 有效期 5 分钟 +- 校验成功后删除(一次性使用) +- `svg` 是字符串:前端可用 `v-html` 渲染,或转成 `data:image/svg+xml` 显示在 `` + +PowerShell 测试(从 SVG 中提取验证码文本,仅用于本地调试): +```powershell +$apiKey='dev-api-key-change-me' +$cap = Invoke-RestMethod -Method Get -Uri http://localhost:8080/api/captcha/new -Headers @{"X-API-Key"=$apiKey} +$id = $cap.data.captcha_id +$svg = $cap.data.svg +$code = [regex]::Match($svg,'>([0-9A-Z]{5})<').Groups[1].Value +``` + +## 用户侧(Web/小程序) + +### 用户注册 + +`POST /api/user/register` + +请求示例: +```bash +curl.exe -X POST "http://localhost:8080/api/user/register" ` + -H "X-API-Key: dev-api-key-change-me" ` + -H "Content-Type: application/json" ` + -d "{\"username\":\"u1\",\"password\":\"pass123456\",\"captcha_id\":\"$CAPTCHA_ID\",\"captcha_code\":\"$CAPTCHA_CODE\"}" +``` + +请求结构体(JSON): +```json +{ + "username": "u1", + "password": "pass123456", + "captcha_id": "xxxx", + "captcha_code": "ABCDE" +} +``` + +返回: +- `data.token`:用户 JWT +- `data.user`:用户信息 + +### 用户登录 + +`POST /api/user/login` + +请求结构体(JSON): +```json +{ + "username": "u1", + "password": "pass123456", + "captcha_id": "xxxx", + "captcha_code": "ABCDE" +} +``` + +返回: +- `data.token`:用户 JWT +- `data.user`:用户信息(简化字段) + +### 当前用户 + +`GET /api/user/me`(需要 `Authorization: Bearer `) + +### 我的评论记录 + +`GET /api/user/reviews?page=1&page_size=20`(需要 `Authorization: Bearer `) + +### 提交评论(进入待审核) + +`POST /api/user/stores/:id/reviews` + +图片上传说明: +- 评论的 `image_urls` 需要先上传拿到图片 URL +- 用户上传接口:`POST /api/user/upload`(`multipart/form-data`,字段名 `file`) + +请求结构体(JSON): +```json +{ + "rating": 5, + "content": "太好吃了", + "image_urls": ["http://localhost:8080/static/upload/2025/12/xx.jpg"], + "recommend_dishes": [ + { "name": "锅包肉", "image_url": "http://...", "like": true } + ] +} +``` + +返回示例: +```json +{ "code": 0, "message": "ok", "data": { "id": 1, "status": "pending" } } +``` + +### 点赞店铺(toggle) + +`POST /api/user/stores/:id/like` + +返回示例: +```json +{ "code": 0, "message": "ok", "data": { "liked": true } } +``` + +### 店铺搜索 / 热榜(公开) + +- 搜索:`GET /api/stores/search?keyword=锅包肉&page=1&page_size=20`(会累积热度) +- 热榜:`GET /api/stores/hot?by=hotness&limit=20` + +### 公共读取接口(前端可直接调用) + +- 分类列表:`GET /api/categories`(只返回 enabled=true) +- 店铺列表:`GET /api/stores?page=1&page_size=20&category_id=1&keyword=xx`(只返回 active) +- 店铺详情:`GET /api/stores/:id`(只返回 active) +- 店铺评论:`GET /api/stores/:id/reviews?page=1&page_size=20`(只返回 approved) +- 店铺排行:`GET /api/rankings/stores?by=hotness&page=1&page_size=20` + +## 商家入驻(公开提交) + +`POST /api/merchant/apply` + +请求结构体(JSON): +```json +{ + "store_name": "示例店铺", + "category_id": 1, + "address": "哈尔滨市xx路", + "open_hours": "10:00-22:00", + "phone": "13000000000", + "cover_url": "", + "image_urls": [], + "description": "商家描述", + "contact_name": "张三", + "contact_phone": "13000000000" +} +``` + +返回示例: +```json +{ "code": 0, "message": "ok", "data": { "id": 1, "status": "pending" } } +``` + +## 管理后台(Admin) + +### 管理员登录 + +`POST /api/admin/login` + +说明: +- 管理员登录也需要验证码(防止暴力破解) +- 先调用 `GET /api/captcha/new` 获取 `captcha_id` + `svg`,再提交到登录接口 + +```bash +curl.exe -X POST "http://localhost:8080/api/admin/login" ` + -H "X-API-Key: dev-api-key-change-me" ` + -H "Content-Type: application/json" ` + -d "{\"username\":\"admin\",\"password\":\"admin123456\",\"captcha_id\":\"$CAPTCHA_ID\",\"captcha_code\":\"$CAPTCHA_CODE\"}" +``` + +返回: +- `data.token`:管理员 JWT + +### CORS 跨域设置(可视化配置) + +- 获取:`GET /api/admin/settings/cors` +- 保存:`PUT /api/admin/settings/cors` + +保存 payload: +```json +{ "origins": ["http://localhost:5173", "https://admin.example.com"] } +``` + +### 上传图片 + +`POST /api/admin/upload`(`multipart/form-data`,字段名 `file`) + +```bash +curl.exe -X POST "http://localhost:8080/api/admin/upload" ` + -H "X-API-Key: dev-api-key-change-me" ` + -H "Authorization: Bearer " ` + -F "file=@./test.jpg" +``` + +返回: +- `data.url`:可访问 URL(例如 `http://localhost:8080/static/upload/2025/12/xx.jpg`) + +### 用户上传图片(用于评论) + +`POST /api/user/upload`(`multipart/form-data`,字段名 `file`,需要用户 JWT) + +```bash +curl.exe -X POST "http://localhost:8080/api/user/upload" ` + -H "X-API-Key: dev-api-key-change-me" ` + -H "Authorization: Bearer " ` + -F "file=@./test.jpg" +``` + +### 分类管理 + +- 列表:`GET /api/admin/categories?page=1&page_size=20&keyword=xxx` +- 新增:`POST /api/admin/categories` +- 编辑:`PUT /api/admin/categories/:id` +- 删除:`DELETE /api/admin/categories/:id` + +新增/编辑 payload: +```json +{ "name": "烧烤", "icon_url": "", "sort_order": 10, "enabled": true } +``` + +### 店铺管理 + +- 列表:`GET /api/admin/stores?page=1&page_size=20&keyword=xx&status=active&category_id=1` +- 详情:`GET /api/admin/stores/:id` +- 新增:`POST /api/admin/stores` +- 编辑:`PUT /api/admin/stores/:id` +- 上下架:`PATCH /api/admin/stores/:id/status`(`{"status":"active|inactive"}`) +- 删除:`DELETE /api/admin/stores/:id` + +新增/编辑 payload: +```json +{ + "name": "示例店铺", + "category_id": 1, + "address": "哈尔滨市xx路", + "open_hours": "10:00-22:00", + "phone": "13000000000", + "cover_url": "http://localhost:8080/static/upload/2025/12/cover.jpg", + "description": "描述", + "status": "active", + "images": [{ "url": "http://...", "sort_order": 0 }], + "dishes": [{ "name": "锅包肉", "description": "必点", "image_url": "http://...", "sort_order": 0 }] +} +``` + +### 评论管理(审核/删除) + +- 列表:`GET /api/admin/reviews?page=1&page_size=20&status=pending&store_id=1&user_id=2` +- 审核:`PATCH /api/admin/reviews/:id/status`(`{"status":"pending|approved|blocked"}`) +- 删除:`DELETE /api/admin/reviews/:id` + +### 商家入驻审核 + +- 列表:`GET /api/admin/merchant/applications?status=pending&page=1&page_size=20` +- 审核:`PATCH /api/admin/merchant/applications/:id/review` + +审核 payload: +- 通过:`{ "action": "approve" }` +- 拒绝:`{ "action": "reject", "reject_reason": "资料不完整" }` + +### 排行管理 + +- 查看排行:`GET /api/admin/rankings/stores?by=hotness&page=1&page_size=20` + - `by=hotness|likes|search|reviews` +- 重算综合分:`POST /api/admin/rankings/stores/recalc` + +### APIKey 管理 + +- 列表:`GET /api/admin/apikeys?page=1&page_size=20` +- 创建:`POST /api/admin/apikeys`(创建成功会返回一次性明文 `data.key`,请立即保存) +- 撤销:`PATCH /api/admin/apikeys/:id/revoke` + +### 用户管理(管理端) + +- 列表:`GET /api/admin/users?keyword=xx&status=active&page=1&page_size=20` +- 详情:`GET /api/admin/users/:id` +- 启用/禁用:`PATCH /api/admin/users/:id/status`(`{"status":"active|disabled"}`) + +### 管理员管理 + +- 列表:`GET /api/admin/admins?keyword=xx&page=1&page_size=20` +- 新增:`POST /api/admin/admins` +- 重置密码:`PATCH /api/admin/admins/:id/password` +- 启用/禁用:`PATCH /api/admin/admins/:id/enabled` + +新增 payload: +```json +{ "username": "ops", "password": "StrongPass123", "enabled": true } +``` + +## OpenAPI + +- 文件:`docs/openapi.yaml` +- Swagger UI:`http://localhost:8081` diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..bf3610b --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,1256 @@ +openapi: 3.0.3 +info: + title: 哈尔滨美食地图 API + version: 0.2.0 + description: | + 统一约定: + - 除 `/healthz` 外,所有 `/api/**` 接口都必须携带 `X-API-Key` + - 管理端 `/api/admin/**`(除登录外)还必须携带 `Authorization: Bearer ` + - 用户端 `/api/user/**`(需要登录的接口)必须携带 `Authorization: Bearer ` +servers: + - url: http://localhost:8080 + +tags: + - name: Health + description: 健康检查 + - name: Captcha + description: 验证码 + - name: AdminAuth + description: 管理员登录与身份 + - name: Settings + description: 系统设置(CORS) + - name: APIKeys + description: APIKey 管理 + - name: Merchant + description: 商家入驻与审核 + - name: Categories + description: 分类管理 + - name: Stores + description: 店铺管理与搜索/热榜 + - name: Reviews + description: 评论管理与用户发评 + - name: Users + description: 用户注册/登录/信息 + - name: Rankings + description: 排行(管理端) + - name: Upload + description: 上传(管理端) + +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + + schemas: + RespBase: + type: object + properties: + code: + type: integer + description: 0 表示成功;非 0 通常等于 HTTP 状态码 + message: + type: string + data: {} + meta: {} + required: [code, message] + + PaginationMeta: + type: object + properties: + page: { type: integer, example: 1 } + page_size: { type: integer, example: 20 } + total: { type: integer, example: 123 } + total_page: { type: integer, example: 7 } + + AdminUser: + type: object + properties: + id: { type: integer, example: 1 } + username: { type: string, example: admin } + role: { type: string, example: admin } + enabled: { type: boolean, example: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + User: + type: object + properties: + id: { type: integer, example: 7 } + username: { type: string, example: tiedanzi888 } + status: { type: string, example: active } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + Category: + type: object + properties: + id: { type: integer, example: 1 } + name: { type: string, example: 烧烤 } + icon_url: { type: string, example: "http://localhost:8080/static/upload/2025/12/xx.png" } + sort_order: { type: integer, example: 10 } + enabled: { type: boolean, example: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + StoreImage: + type: object + properties: + id: { type: integer } + url: { type: string, example: "http://localhost:8080/static/upload/2025/12/xx.jpg" } + sort_order: { type: integer, example: 0 } + + SignatureDish: + type: object + properties: + id: { type: integer } + name: { type: string, example: 锅包肉 } + description: { type: string, example: 必点 } + image_url: { type: string, example: "http://localhost:8080/static/upload/2025/12/dish.jpg" } + sort_order: { type: integer, example: 0 } + + Store: + type: object + properties: + id: { type: integer, example: 1 } + name: { type: string, example: 示例店铺 } + category_id: { type: integer, example: 1 } + category: + $ref: "#/components/schemas/Category" + address: { type: string, example: 哈尔滨市道里区xxx路 } + open_hours: { type: string, example: "10:00-22:00" } + phone: { type: string, example: "13000000000" } + cover_url: { type: string, example: "http://localhost:8080/static/upload/2025/12/cover.jpg" } + description: { type: string, example: 店铺描述 } + status: { type: string, example: active } + images: + type: array + items: { $ref: "#/components/schemas/StoreImage" } + dishes: + type: array + items: { $ref: "#/components/schemas/SignatureDish" } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + Review: + type: object + properties: + id: { type: integer, example: 1 } + store_id: { type: integer, example: 1 } + store: + $ref: "#/components/schemas/Store" + user_id: { type: integer, nullable: true } + user_name: { type: string, example: tiedanzi888 } + rating: { type: integer, example: 5 } + content: { type: string, example: 太好吃了 } + image_urls: + type: array + items: { type: string } + recommend_dishes: + type: array + items: + type: object + additionalProperties: true + status: { type: string, example: pending } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + MerchantApplication: + type: object + properties: + id: { type: integer } + store_name: { type: string } + category_id: { type: integer } + address: { type: string } + open_hours: { type: string } + phone: { type: string } + cover_url: { type: string } + image_urls: + type: array + items: { type: string } + description: { type: string } + contact_name: { type: string } + contact_phone: { type: string } + status: { type: string, example: pending } + reject_reason: { type: string } + reviewed_at: { type: string, format: date-time, nullable: true } + reviewer_id: { type: integer, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + APIKeyItem: + type: object + properties: + id: { type: integer } + name: { type: string } + prefix: { type: string } + status: { type: string, example: active } + last_used_at: { type: string, format: date-time, nullable: true } + revoked_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + # ---- Requests ---- + CaptchaNewData: + type: object + properties: + captcha_id: { type: string, example: c180df303849ea6dc75c670c4f1062b0 } + svg: { type: string, description: SVG 字符串(可直接渲染) } + required: [captcha_id, svg] + + AdminLoginReq: + type: object + required: [username, password, captcha_id, captcha_code] + properties: + username: { type: string, example: admin } + password: { type: string, example: admin123456 } + captcha_id: { type: string } + captcha_code: { type: string, example: XHSXY } + + UserRegisterReq: + type: object + required: [username, password, captcha_id, captcha_code] + properties: + username: { type: string, example: tiedanzi888 } + password: { type: string, example: malegebi } + captcha_id: { type: string } + captcha_code: { type: string, example: XHSXY } + + UserLoginReq: + type: object + required: [username, password, captcha_id, captcha_code] + properties: + username: { type: string } + password: { type: string } + captcha_id: { type: string } + captcha_code: { type: string } + + CORSUpdateReq: + type: object + required: [origins] + properties: + origins: + type: array + items: { type: string } + example: ["http://localhost:5173", "https://admin.example.com"] + + APIKeyCreateReq: + type: object + required: [name] + properties: + name: { type: string, example: 前端管理后台 } + + CategoryUpsertReq: + type: object + required: [name] + properties: + name: { type: string, example: 烧烤 } + icon_url: { type: string } + sort_order: { type: integer, example: 10 } + enabled: { type: boolean, example: true } + + StoreUpsertReq: + type: object + required: [name, category_id, address] + properties: + name: { type: string } + category_id: { type: integer } + address: { type: string } + open_hours: { type: string } + phone: { type: string } + cover_url: { type: string } + description: { type: string } + status: { type: string, description: active|inactive } + images: + type: array + items: + type: object + required: [url] + properties: + url: { type: string } + sort_order: { type: integer } + dishes: + type: array + items: + type: object + required: [name] + properties: + name: { type: string } + description: { type: string } + image_url: { type: string } + sort_order: { type: integer } + + StatusReq: + type: object + required: [status] + properties: + status: { type: string } + + MerchantApplyReq: + type: object + required: [store_name, category_id, address, contact_name, contact_phone] + properties: + store_name: { type: string } + category_id: { type: integer } + address: { type: string } + open_hours: { type: string } + phone: { type: string } + cover_url: { type: string } + image_urls: + type: array + items: { type: string } + description: { type: string } + contact_name: { type: string } + contact_phone: { type: string } + + MerchantReviewReq: + type: object + required: [action] + properties: + action: { type: string, enum: [approve, reject] } + reject_reason: { type: string } + + ReviewCreateReq: + type: object + required: [rating] + properties: + rating: { type: integer, minimum: 1, maximum: 5, example: 5 } + content: { type: string, example: 太好吃了 } + image_urls: + type: array + items: { type: string } + recommend_dishes: + type: array + items: + type: object + additionalProperties: true + + AdminCreateReq: + type: object + required: [username, password] + properties: + username: { type: string } + password: { type: string } + enabled: { type: boolean, default: true } + + AdminUpdatePasswordReq: + type: object + required: [password] + properties: + password: { type: string } + + AdminUpdateEnabledReq: + type: object + required: [enabled] + properties: + enabled: { type: boolean } + + parameters: + Page: + in: query + name: page + schema: { type: integer, default: 1 } + PageSize: + in: query + name: page_size + schema: { type: integer, default: 20 } + +paths: + /healthz: + get: + tags: [Health] + summary: 健康检查 + responses: + "200": + description: ok + + /api/captcha/new: + get: + tags: [Captcha] + summary: 获取验证码(SVG) + description: | + - `captcha_id` 有效期 5 分钟 + - 校验成功后删除(一次性使用) + security: + - ApiKeyAuth: [] + responses: + "200": + description: ok + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/RespBase" + - type: object + properties: + data: { $ref: "#/components/schemas/CaptchaNewData" } + examples: + ok: + value: + code: 0 + message: ok + data: + captcha_id: c180df303849ea6dc75c670c4f1062b0 + svg: "XHSXY" + + /api/user/register: + post: + tags: [Users] + summary: 用户注册(需要验证码) + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/UserRegisterReq" } + responses: + "200": + description: ok + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/RespBase" + - type: object + properties: + data: + type: object + properties: + token: { type: string } + user: { $ref: "#/components/schemas/User" } + "409": + description: username already exists + + /api/user/login: + post: + tags: [Users] + summary: 用户登录(需要验证码) + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/UserLoginReq" } + responses: + "200": + description: ok + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/RespBase" + - type: object + properties: + data: + type: object + properties: + token: { type: string } + user: + type: object + properties: + id: { type: integer } + username: { type: string } + + /api/user/me: + get: + tags: [Users] + summary: 当前用户信息 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + responses: + "200": + description: ok + + /api/user/reviews: + get: + tags: [Reviews] + summary: 我的评论列表(用户端) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/user/upload: + post: + tags: [Upload] + summary: 用户上传图片(用于评论等) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + description: ok + + /api/user/douyin/login: + post: + tags: [Users] + summary: 抖音登录(预留) + security: + - ApiKeyAuth: [] + responses: + "501": + description: not implemented + + /api/user/stores/{id}/reviews: + post: + tags: [Reviews] + summary: 用户提交评论(进入待审核) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewCreateReq" } + responses: + "200": + description: ok + + /api/user/stores/{id}/like: + post: + tags: [Stores] + summary: 用户点赞/取消点赞(toggle) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + + /api/merchant/apply: + post: + tags: [Merchant] + summary: 商家入驻提交 + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MerchantApplyReq" } + responses: + "200": + description: ok + + /api/categories: + get: + tags: [Categories] + summary: 分类列表(公开,只返回 enabled=true) + security: + - ApiKeyAuth: [] + responses: + "200": + description: ok + + /api/stores: + get: + tags: [Stores] + summary: 店铺列表(公开,只返回 active) + security: + - ApiKeyAuth: [] + parameters: + - in: query + name: keyword + schema: { type: string } + - in: query + name: category_id + schema: { type: integer } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/stores/{id}: + get: + tags: [Stores] + summary: 店铺详情(公开,只返回 active) + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + + /api/stores/{id}/reviews: + get: + tags: [Reviews] + summary: 店铺评论列表(公开,只返回 approved) + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/rankings/stores: + get: + tags: [Rankings] + summary: 店铺排行(公开,只返回 active) + security: + - ApiKeyAuth: [] + parameters: + - in: query + name: by + schema: + type: string + enum: [hotness, likes, search, reviews] + default: hotness + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/stores/search: + get: + tags: [Stores] + summary: 店铺搜索(会累积热度) + security: + - ApiKeyAuth: [] + parameters: + - in: query + name: keyword + schema: { type: string } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/stores/hot: + get: + tags: [Stores] + summary: 店铺热榜 + security: + - ApiKeyAuth: [] + parameters: + - in: query + name: by + schema: + type: string + enum: [hotness, likes, search, reviews] + default: hotness + - in: query + name: limit + schema: { type: integer, default: 20 } + responses: + "200": + description: ok + + /api/admin/login: + post: + tags: [AdminAuth] + summary: 管理员登录 + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AdminLoginReq" } + responses: + "200": + description: ok + + /api/admin/me: + get: + tags: [AdminAuth] + summary: 当前管理员信息 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + responses: + "200": + description: ok + + /api/admin/dashboard/overview: + get: + tags: [AdminAuth] + summary: 后台概览(统计) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + responses: + "200": + description: ok + + /api/admin/settings/cors: + get: + tags: [Settings] + summary: 获取 CORS origins + security: + - ApiKeyAuth: [] + - BearerAuth: [] + responses: + "200": + description: ok + put: + tags: [Settings] + summary: 更新 CORS origins + security: + - ApiKeyAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CORSUpdateReq" } + responses: + "200": + description: ok + + /api/admin/apikeys: + get: + tags: [APIKeys] + summary: APIKey 列表 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + post: + tags: [APIKeys] + summary: 创建 APIKey(只会返回一次明文 key) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/APIKeyCreateReq" } + responses: + "200": + description: ok + + /api/admin/apikeys/{id}/revoke: + patch: + tags: [APIKeys] + summary: 撤销 APIKey + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + + /api/admin/merchant/applications: + get: + tags: [Merchant] + summary: 商家入驻申请列表 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: status + schema: { type: string, enum: [pending, approved, rejected] } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/admin/merchant/applications/{id}/review: + patch: + tags: [Merchant] + summary: 审核商家入驻(approve/reject) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MerchantReviewReq" } + responses: + "200": + description: ok + + /api/admin/categories: + get: + tags: [Categories] + summary: 分类列表 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: keyword + schema: { type: string } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + post: + tags: [Categories] + summary: 新增分类 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryUpsertReq" } + responses: + "200": + description: ok + + /api/admin/categories/{id}: + put: + tags: [Categories] + summary: 编辑分类 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryUpsertReq" } + responses: + "200": + description: ok + delete: + tags: [Categories] + summary: 删除分类(软删) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + + /api/admin/stores: + get: + tags: [Stores] + summary: 店铺列表 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: keyword + schema: { type: string } + - in: query + name: status + schema: { type: string, enum: [active, inactive] } + - in: query + name: category_id + schema: { type: integer } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + post: + tags: [Stores] + summary: 新增店铺 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StoreUpsertReq" } + responses: + "200": + description: ok + + /api/admin/stores/{id}: + get: + tags: [Stores] + summary: 店铺详情(含图片/招牌菜) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + put: + tags: [Stores] + summary: 编辑店铺 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StoreUpsertReq" } + responses: + "200": + description: ok + delete: + tags: [Stores] + summary: 删除店铺(软删) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + + /api/admin/stores/{id}/status: + patch: + tags: [Stores] + summary: 上下架店铺 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusReq" } + examples: + active: { value: { status: active } } + inactive: { value: { status: inactive } } + responses: + "200": + description: ok + + /api/admin/reviews: + get: + tags: [Reviews] + summary: 评论列表 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: status + schema: { type: string, enum: [pending, approved, blocked] } + - in: query + name: store_id + schema: { type: integer } + - in: query + name: user_id + schema: { type: integer } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/admin/reviews/{id}/status: + patch: + tags: [Reviews] + summary: 修改评论状态(审核) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusReq" } + examples: + approve: { value: { status: approved } } + block: { value: { status: blocked } } + responses: + "200": + description: ok + + /api/admin/reviews/{id}: + delete: + tags: [Reviews] + summary: 删除评论(软删) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + + /api/admin/users: + get: + tags: [Users] + summary: 用户列表(管理端) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: keyword + schema: { type: string } + - in: query + name: status + schema: { type: string, enum: [active, disabled] } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/admin/users/{id}: + get: + tags: [Users] + summary: 用户详情(管理端) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: ok + + /api/admin/users/{id}/status: + patch: + tags: [Users] + summary: 修改用户状态(启用/禁用) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusReq" } + examples: + disable: { value: { status: disabled } } + active: { value: { status: active } } + responses: + "200": + description: ok + + /api/admin/admins: + get: + tags: [AdminAuth] + summary: 管理员列表 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: keyword + schema: { type: string } + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + post: + tags: [AdminAuth] + summary: 创建管理员 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AdminCreateReq" } + responses: + "200": + description: ok + + /api/admin/admins/{id}/password: + patch: + tags: [AdminAuth] + summary: 重置管理员密码 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AdminUpdatePasswordReq" } + responses: + "200": + description: ok + + /api/admin/admins/{id}/enabled: + patch: + tags: [AdminAuth] + summary: 启用/禁用管理员 + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AdminUpdateEnabledReq" } + responses: + "200": + description: ok + + /api/admin/rankings/stores: + get: + tags: [Rankings] + summary: 店铺排行(管理端) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: by + schema: + type: string + enum: [hotness, likes, search, reviews] + default: hotness + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + description: ok + + /api/admin/rankings/stores/recalc: + post: + tags: [Rankings] + summary: 重算综合分(score) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + parameters: + - in: query + name: limit + schema: { type: integer, default: 5000 } + responses: + "200": + description: ok + + /api/admin/upload: + post: + tags: [Upload] + summary: 上传图片(multipart/form-data) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + description: ok diff --git a/后端设计文档.md b/后端设计文档.md new file mode 100644 index 0000000..7353f53 --- /dev/null +++ b/后端设计文档.md @@ -0,0 +1,145 @@ +# 🥟 哈尔滨美食地图 · 第一阶段 + +## **管理后台系统(Admin Panel)详细设计文档** + +> 技术栈:Go (Gin) + PostgreSQL + Vue3 + Element Plus +> 目标:为后续 Web 网站和抖音小程序提供数据支撑的**高可用、易维护的管理后台** + +------ + +## 一、项目目标 + +构建一个**独立的 Web 管理后台**,供运营人员或管理员: + +- 录入/编辑/删除哈尔滨本地美食店铺 +- 管理分类、招牌菜、用户评论(审核) +- 查看数据统计与排行榜预览 +- 支持图片上传与地理位置标注 + +该后台将作为整个美食地图项目的**唯一数据入口**。 + +------ + +## 二、整体架构 + +``` +┌──────────────────────┐ +│ Admin Frontend │ ← Vue3 + Element Plus (SPA) +└──────────┬───────────┘ + │ HTTP / JSON +┌──────────▼───────────┐ +│ Go Backend (Gin) │ ← RESTful API + JWT Auth +└──────────┬───────────┘ + │ +┌──────────▼───────────┐ +│ Mysql │ ← 存储店铺、分类、评论等 +└──────────┬───────────┘ + │ +┌──────────▼───────────┐ +│ redis │ ← 缓存 +└──────────┬───────────┘ +┌──────────▼───────────┐ +│ 阿里云 OSS / 本地存储 │ ← 图片文件存储 +└──────────────────────┘ +``` + +------ + +## 三、核心功能模块(管理后台) + +### 1. **管理员登录 / 权限控制** + +- 账号密码登录(支持初始超级管理员) +- JWT Token 认证 +- 后续可扩展角色权限(当前 MVP 只需“管理员”角色) + +### 2. **美食分类管理** + +- 新增/编辑/删除分类(如:东北菜、俄餐、烧烤、小吃) +- 分类图标(可选) +- 排序权重(用于前端展示顺序) + +### 3. **店铺管理(核心)** + +- 创建新店铺: + - 店铺名称(必填) + - 所属分类(单选) + - 地址(文本 + 自动解析经纬度 via 高德 API) + - 手动调整经纬度(地图选点组件) + - 营业时间、电话(可选) + - 封面图 + 多图上传(最多6张) + - 必点招牌菜(可添加多个,带名称+描述+图片) +- 编辑/下架/删除店铺 +- 批量导入(CSV 模板下载 + 上传解析,MVP 可暂缓) + +### 4. **评论管理** + +- 列表展示所有用户评论(含评分、内容、图片) +- 支持审核状态:通过 / 屏蔽 +- 可删除恶意评论 +- 按店铺筛选 + +### 5. **数据概览(Dashboard)** + +- 总店铺数、总评论数、分类分布饼图 +- 最近7天新增店铺趋势 +- 高评分店铺 Top5 预览 + +### 6. **系统设置(可选)** + +- OSS 配置(AccessKey、Bucket) +- 高德地图 Key 配置(用于地址转经纬度) + +------ + +## 四、技术选型详情 + +| 模块 | 技术 | 说明 | +| --------- | ----------------------------------------------- | ----------------------------------------------- | +| 后端语言 | Go 1.22+ | 高性能、编译快、并发好 | +| Web 框架 | [Gin](https://gin-gonic.com/) | 轻量、路由清晰、中间件丰富 | +| 数据库 | mysql:8.0 | 支持 JSON、数组、地理扩展(未来可升级 PostGIS) | +| ORM | [GORM](https://gorm.io/) | Go 最流行 ORM,支持关联、事务、软删除 | +| 认证 | JWT + Bcrypt | 密码加密 + Token 验证 | +| 文件存储 | 阿里云 OSS(生产) / 本地 static/upload(开发) | 图片统一 CDN 加速 | +| 前端框架 | Vue 3 + Vite + TypeScript | 响应式、组件化 | +| UI 组件库 | [Element Plus](https://element-plus.org/) | 企业级后台 UI,开箱即用 | +| 地图组件 | 高德地图 JS API(Web 端选点) | 用于手动标注店铺位置 | +| 构建部署 | Docker + Nginx | | + +## 五、设置添加前端调用apikey,做好加密防止别人随意调用接口内容。 + +## 六、补充不足,完善数据库表,api接口等。 + +## 七、数据库链接信息 + + 根据docker-compose.yml信息进行链接,告诉我配置文件位置,后期可以自行修改 + +```yml +mysql: + image: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/wechatpadpro/mysql:8.0 + container_name: mysql-db + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: mydb + MYSQL_USER: user + MYSQL_PASSWORD: password123 + ports: + - "3309:3306" + volumes: + - mysql_data:/var/lib/mysql + networks: + - app-network + + redis: + image: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/redis:7.0.8 + container_name: redis-cache + ports: + +- "6381:6379" + lumes: + - redis_data:/data + tworks: + - app-network +``` +