哈尔滨美食地图
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# Go
|
||||
**/*.exe
|
||||
**/*.exe~
|
||||
**/*.dll
|
||||
**/*.so
|
||||
**/*.dylib
|
||||
**/bin/
|
||||
**/dist/
|
||||
**/.git1beifen/
|
||||
|
||||
# Node
|
||||
**/node_modules/
|
||||
|
||||
# Uploads
|
||||
backend/static/upload/
|
||||
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
.idea/0451meishiditu.iml
generated
Normal file
9
.idea/0451meishiditu.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/0451meishiditu.iml" filepath="$PROJECT_DIR$/.idea/0451meishiditu.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
112
README.md
Normal file
112
README.md
Normal file
@@ -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: <apikey>`
|
||||
- 开发环境默认值来自 `backend/.env`:`API_KEY=dev-api-key-change-me`
|
||||
|
||||
2) 管理端 JWT(管理端 `/api/admin/**`,除登录外必须携带)
|
||||
- Header:`Authorization: Bearer <admin_token>`
|
||||
|
||||
3) 用户端 JWT(用户端需要登录的接口必须携带)
|
||||
- Header:`Authorization: Bearer <user_token>`
|
||||
|
||||
## 默认管理员账号(首次启动初始化)
|
||||
|
||||
由 `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 数据
|
||||
3
admin/.env.example
Normal file
3
admin/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_API_KEY=dev-api-key-change-me
|
||||
|
||||
14
admin/Dockerfile
Normal file
14
admin/Dockerfile
Normal file
@@ -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
|
||||
|
||||
13
admin/index.html
Normal file
13
admin/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>哈尔滨美食地图 - 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
20
admin/nginx.conf
Normal file
20
admin/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
2236
admin/package-lock.json
generated
Normal file
2236
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
admin/package.json
Normal file
26
admin/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
4
admin/src/App.vue
Normal file
4
admin/src/App.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
57
admin/src/components/DishesEditor.vue
Normal file
57
admin/src/components/DishesEditor.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<div v-for="(d, i) in modelValue" :key="i" class="dish">
|
||||
<div class="head">
|
||||
<div class="idx">#{{ i + 1 }}</div>
|
||||
<el-button text type="danger" @click="remove(i)">删除</el-button>
|
||||
</div>
|
||||
<el-input v-model="d.name" placeholder="菜名" />
|
||||
<el-input v-model="d.description" placeholder="描述" />
|
||||
<Uploader v-model="d.image_url" />
|
||||
<el-input-number v-model="d.sort_order" size="small" :min="0" :max="9999" />
|
||||
</div>
|
||||
<el-button @click="add">添加招牌菜</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Uploader from './Uploader.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: { name: string; description: string; image_url: string; sort_order: number }[] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: { name: string; description: string; image_url: string; sort_order: number }[]): void
|
||||
}>()
|
||||
|
||||
function add() {
|
||||
emit('update:modelValue', [...props.modelValue, { name: '', description: '', image_url: '', sort_order: 0 }])
|
||||
}
|
||||
function remove(i: number) {
|
||||
const next = [...props.modelValue]
|
||||
next.splice(i, 1)
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.dish {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.idx {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
59
admin/src/components/Uploader.vue
Normal file
59
admin/src/components/Uploader.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="uploader">
|
||||
<el-upload :show-file-list="false" :http-request="doUpload" accept="image/*">
|
||||
<el-button>上传</el-button>
|
||||
</el-upload>
|
||||
<div v-if="modelValue" class="preview">
|
||||
<el-image
|
||||
:src="modelValue"
|
||||
:preview-src-list="[modelValue]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="img"
|
||||
/>
|
||||
<el-button text type="danger" @click="emit('update:modelValue', '')">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { http } from '../lib/http'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineProps<{ modelValue: string }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
|
||||
async function doUpload(opt: any) {
|
||||
const form = new FormData()
|
||||
form.append('file', opt.file)
|
||||
try {
|
||||
const res = await http.post('/api/admin/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
const url = res.data?.data?.url
|
||||
if (!url) throw new Error('missing url')
|
||||
opt.onSuccess?.(res.data, opt.file)
|
||||
emit('update:modelValue', url)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '上传失败')
|
||||
opt.onError?.(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.uploader {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.img {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
82
admin/src/components/UploaderList.vue
Normal file
82
admin/src/components/UploaderList.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<Uploader v-model="pendingUrl" />
|
||||
<div class="list">
|
||||
<div v-for="(x, i) in modelValue" :key="i" class="item">
|
||||
<el-image
|
||||
v-if="x.url"
|
||||
:src="x.url"
|
||||
:preview-src-list="previewList"
|
||||
:initial-index="previewList.indexOf(x.url)"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="img"
|
||||
/>
|
||||
<div class="ops">
|
||||
<el-input-number v-model="x.sort_order" size="small" :min="0" :max="9999" />
|
||||
<el-button text type="danger" @click="remove(i)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="addFromPending" :disabled="!pendingUrl">添加图片</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import Uploader from './Uploader.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: { url: string; sort_order: number }[] }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: { url: string; sort_order: number }[]): void }>()
|
||||
|
||||
const pendingUrl = ref('')
|
||||
const previewList = computed(() => props.modelValue.map((x) => x.url).filter(Boolean))
|
||||
|
||||
function addFromPending() {
|
||||
if (!pendingUrl.value) return
|
||||
emit('update:modelValue', [...props.modelValue, { url: pendingUrl.value, sort_order: 0 }])
|
||||
pendingUrl.value = ''
|
||||
}
|
||||
|
||||
function remove(i: number) {
|
||||
const next = [...props.modelValue]
|
||||
next.splice(i, 1)
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 86px 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
img {
|
||||
display: none;
|
||||
}
|
||||
.img {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
border-radius: 12px;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
27
admin/src/lib/http.ts
Normal file
27
admin/src/lib/http.ts
Normal file
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
11
admin/src/main.ts
Normal file
11
admin/src/main.ts
Normal file
@@ -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')
|
||||
|
||||
50
admin/src/router/index.ts
Normal file
50
admin/src/router/index.ts
Normal file
@@ -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
|
||||
})
|
||||
18
admin/src/stores/auth.ts
Normal file
18
admin/src/stores/auth.ts
Normal file
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
38
admin/src/styles/global.scss
Normal file
38
admin/src/styles/global.scss
Normal file
@@ -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;
|
||||
}
|
||||
167
admin/src/views/APIKeysView.vue
Normal file
167
admin/src/views/APIKeysView.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<div class="title">APIKey 管理</div>
|
||||
<div class="spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">创建 APIKey</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" />
|
||||
<el-table-column prop="prefix" label="前缀" width="140" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_used_at" label="最后使用" width="180" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" plain :disabled="row.status !== 'active'" @click="revoke(row)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dlg.open" title="创建 APIKey" width="520">
|
||||
<el-form :model="dlg.form" label-width="90px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="dlg.form.name" placeholder="例如:运营后台 / 自动化脚本" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dlg.createdKey" label="Key">
|
||||
<el-input :model-value="dlg.createdKey" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copy(dlg.createdKey)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="hint">该 Key 仅展示一次,请立即保存。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg.open = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="dlg.loading" :disabled="!!dlg.createdKey" @click="create">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
type APIKeyRow = {
|
||||
id: number
|
||||
name: string
|
||||
prefix: string
|
||||
status: 'active' | 'revoked'
|
||||
last_used_at?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<APIKeyRow[]>([])
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
form: { name: '' },
|
||||
createdKey: ''
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
dlg.open = true
|
||||
dlg.form = { name: '' }
|
||||
dlg.createdKey = ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/apikeys', { params: { page: meta.page, page_size: meta.page_size } })
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
async function create() {
|
||||
dlg.loading = true
|
||||
try {
|
||||
const res = await http.post('/api/admin/apikeys', dlg.form)
|
||||
dlg.createdKey = res.data?.data?.key || ''
|
||||
ElMessage.success('已创建')
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
dlg.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row: APIKeyRow) {
|
||||
await ElMessageBox.confirm(`确认撤销 APIKey「${row.name}」?`, '提示', { type: 'warning' })
|
||||
await http.patch(`/api/admin/apikeys/${row.id}/revoke`, {})
|
||||
ElMessage.success('已撤销')
|
||||
load()
|
||||
}
|
||||
|
||||
async function copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 800;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
174
admin/src/views/AdminUsersView.vue
Normal file
174
admin/src/views/AdminUsersView.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索管理员用户名" style="max-width: 260px" clearable @change="load" />
|
||||
<div class="spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">新增管理员</el-button>
|
||||
<el-button @click="load" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="160" />
|
||||
<el-table-column prop="role" label="角色" width="120" />
|
||||
<el-table-column prop="enabled" label="启用" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openReset(row)">重置密码</el-button>
|
||||
<el-button size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dlgCreate.open" title="新增管理员" width="520">
|
||||
<el-form :model="dlgCreate.form" label-width="90px">
|
||||
<el-form-item label="用户名"><el-input v-model="dlgCreate.form.username" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="dlgCreate.form.password" show-password /></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="dlgCreate.form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlgCreate.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlgCreate.loading" @click="create">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dlgPwd.open" title="重置密码" width="480">
|
||||
<el-form :model="dlgPwd.form" label-width="90px">
|
||||
<el-form-item label="新密码"><el-input v-model="dlgPwd.form.password" show-password /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlgPwd.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlgPwd.loading" @click="resetPwd">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const dlgCreate = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
form: { username: '', password: '', enabled: true }
|
||||
})
|
||||
const dlgPwd = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
id: 0,
|
||||
form: { password: '' }
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
dlgCreate.open = true
|
||||
dlgCreate.form = { username: '', password: '', enabled: true }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/admins', { params: { page: meta.page, page_size: meta.page_size, keyword: keyword.value } })
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
async function create() {
|
||||
dlgCreate.loading = true
|
||||
try {
|
||||
await http.post('/api/admin/admins', dlgCreate.form)
|
||||
ElMessage.success('已创建')
|
||||
dlgCreate.open = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
dlgCreate.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function openReset(row: any) {
|
||||
dlgPwd.open = true
|
||||
dlgPwd.id = row.id
|
||||
dlgPwd.form = { password: '' }
|
||||
}
|
||||
|
||||
async function resetPwd() {
|
||||
dlgPwd.loading = true
|
||||
try {
|
||||
await http.patch(`/api/admin/admins/${dlgPwd.id}/password`, dlgPwd.form)
|
||||
ElMessage.success('已更新')
|
||||
dlgPwd.open = false
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '更新失败')
|
||||
} finally {
|
||||
dlgPwd.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(row: any) {
|
||||
try {
|
||||
await http.patch(`/api/admin/admins/${row.id}/enabled`, { enabled: !row.enabled })
|
||||
ElMessage.success('已更新')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
154
admin/src/views/CategoriesView.vue
Normal file
154
admin/src/views/CategoriesView.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索分类" style="max-width: 260px" clearable @change="load" />
|
||||
<div class="spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">新增分类</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="sort_order" label="排序" width="120" />
|
||||
<el-table-column prop="enabled" label="启用" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dlg.open" :title="dlg.mode === 'create' ? '新增分类' : '编辑分类'" width="480">
|
||||
<el-form :model="dlg.form" label-width="90px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="dlg.form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标URL">
|
||||
<el-input v-model="dlg.form.icon_url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="dlg.form.sort_order" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="dlg.form.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlg.loading" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
type Category = { id: number; name: string; icon_url: string; sort_order: number; enabled: boolean }
|
||||
|
||||
const items = ref<Category[]>([])
|
||||
const keyword = ref('')
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create' as 'create' | 'edit',
|
||||
loading: false,
|
||||
id: 0,
|
||||
form: { name: '', icon_url: '', sort_order: 0, enabled: true }
|
||||
})
|
||||
|
||||
async function load() {
|
||||
const res = await http.get('/api/admin/categories', {
|
||||
params: { page: meta.page, page_size: meta.page_size, keyword: keyword.value }
|
||||
})
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dlg.mode = 'create'
|
||||
dlg.id = 0
|
||||
dlg.form = { name: '', icon_url: '', sort_order: 0, enabled: true }
|
||||
dlg.open = true
|
||||
}
|
||||
|
||||
function openEdit(row: Category) {
|
||||
dlg.mode = 'edit'
|
||||
dlg.id = row.id
|
||||
dlg.form = { name: row.name, icon_url: row.icon_url, sort_order: row.sort_order, enabled: row.enabled }
|
||||
dlg.open = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
dlg.loading = true
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await http.post('/api/admin/categories', dlg.form)
|
||||
ElMessage.success('已创建')
|
||||
} else {
|
||||
await http.put(`/api/admin/categories/${dlg.id}`, dlg.form)
|
||||
ElMessage.success('已更新')
|
||||
}
|
||||
dlg.open = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
dlg.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: Category) {
|
||||
await ElMessageBox.confirm(`确认删除分类「${row.name}」?`, '提示', { type: 'warning' })
|
||||
await http.delete(`/api/admin/categories/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
120
admin/src/views/DashboardView.vue
Normal file
120
admin/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="card pad kpi">
|
||||
<div class="kpi-title">店铺总数</div>
|
||||
<div class="kpi-value">{{ data?.total_stores ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="card pad kpi">
|
||||
<div class="kpi-title">评论总数</div>
|
||||
<div class="kpi-value">{{ data?.total_reviews ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="card pad kpi">
|
||||
<div class="kpi-title">分类数量</div>
|
||||
<div class="kpi-value">{{ data?.total_categories ?? '-' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card pad chart" style="grid-column: span 2">
|
||||
<div class="title">近 7 天新增店铺</div>
|
||||
<div ref="trendRef" class="echart"></div>
|
||||
</div>
|
||||
<div class="card pad chart">
|
||||
<div class="title">分类分布</div>
|
||||
<div ref="pieRef" class="echart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card pad" style="grid-column: 1 / -1">
|
||||
<div class="title">高评分店铺 Top 5(>=3 条已审核评论)</div>
|
||||
<el-table :data="data?.top_rated_stores || []" style="width: 100%" size="small">
|
||||
<el-table-column prop="name" label="店铺" />
|
||||
<el-table-column prop="avg" label="均分" width="120">
|
||||
<template #default="{ row }">{{ Number(row.avg).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="评论数" width="120" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
type Overview = {
|
||||
total_stores: number
|
||||
total_reviews: number
|
||||
total_categories: number
|
||||
category_dist: { name: string; count: number }[]
|
||||
stores_last7days: { date: string; count: number }[]
|
||||
top_rated_stores: { name: string; avg: number; count: number }[]
|
||||
}
|
||||
|
||||
const data = ref<Overview | null>(null)
|
||||
const trendRef = ref<HTMLDivElement | null>(null)
|
||||
const pieRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function renderCharts() {
|
||||
if (!data.value) return
|
||||
|
||||
if (trendRef.value) {
|
||||
const chart = echarts.init(trendRef.value)
|
||||
chart.setOption({
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: data.value.stores_last7days.map((x) => x.date) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'line', data: data.value.stores_last7days.map((x) => x.count), smooth: true, areaStyle: {} }]
|
||||
})
|
||||
window.addEventListener('resize', () => chart.resize())
|
||||
}
|
||||
|
||||
if (pieRef.value) {
|
||||
const chart = echarts.init(pieRef.value)
|
||||
chart.setOption({
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
|
||||
data: data.value.category_dist.map((x) => ({ name: x.name || '未分类', value: x.count }))
|
||||
}
|
||||
]
|
||||
})
|
||||
window.addEventListener('resize', () => chart.resize())
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await http.get('/api/admin/dashboard/overview')
|
||||
data.value = res.data?.data
|
||||
renderCharts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.kpi-title {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.echart {
|
||||
height: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
137
admin/src/views/LayoutView.vue
Normal file
137
admin/src/views/LayoutView.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sider card">
|
||||
<div class="brand">
|
||||
<div class="logo">0451</div>
|
||||
<div class="title">美食地图</div>
|
||||
</div>
|
||||
<el-menu :default-active="active" router class="menu" background-color="transparent">
|
||||
<el-menu-item index="/dashboard">数据概览</el-menu-item>
|
||||
<el-menu-item index="/merchant-applications">商家入驻审核</el-menu-item>
|
||||
<el-menu-item index="/categories">分类管理</el-menu-item>
|
||||
<el-menu-item index="/stores">店铺管理</el-menu-item>
|
||||
<el-menu-item index="/reviews">评论管理</el-menu-item>
|
||||
<el-menu-item index="/rankings">排行管理</el-menu-item>
|
||||
<el-menu-item index="/users">用户管理</el-menu-item>
|
||||
<el-menu-item index="/admin-users">管理员管理</el-menu-item>
|
||||
<el-menu-item index="/apikeys">APIKey 管理</el-menu-item>
|
||||
<el-menu-item index="/settings">系统设置</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="top card">
|
||||
<div class="left">
|
||||
<div class="page">{{ pageTitle }}</div>
|
||||
<div class="sub">管理后台 · 第一阶段</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<el-button text @click="onLogout">退出登录</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="content">
|
||||
<router-view />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const active = computed(() => route.path)
|
||||
const pageTitle = computed(() => {
|
||||
const p = route.path
|
||||
if (p.startsWith('/dashboard')) return '数据概览'
|
||||
if (p.startsWith('/merchant-applications')) return '商家入驻审核'
|
||||
if (p.startsWith('/categories')) return '分类管理'
|
||||
if (p.startsWith('/stores')) return '店铺管理'
|
||||
if (p.startsWith('/reviews')) return '评论管理'
|
||||
if (p.startsWith('/rankings')) return '排行管理'
|
||||
if (p.startsWith('/users')) return '用户管理'
|
||||
if (p.startsWith('/admin-users')) return '管理员管理'
|
||||
if (p.startsWith('/apikeys')) return 'APIKey 管理'
|
||||
if (p.startsWith('/settings')) return '系统设置'
|
||||
return '管理后台'
|
||||
})
|
||||
|
||||
function onLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sider {
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 10px 14px;
|
||||
}
|
||||
.logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.page {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
139
admin/src/views/LoginView.vue
Normal file
139
admin/src/views/LoginView.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<div class="panel card">
|
||||
<div class="headline">
|
||||
<div class="h1">哈尔滨美食地图</div>
|
||||
<div class="h2">管理后台登录</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.username" placeholder="用户名" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" placeholder="密码" size="large" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="form.captcha_code" placeholder="验证码" size="large" maxlength="10" />
|
||||
<div class="captcha-img" @click="loadCaptcha" v-html="captcha.svg" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" style="width: 100%" :loading="loading" @click="onLogin">
|
||||
登录
|
||||
</el-button>
|
||||
|
||||
<div class="hint">
|
||||
需要在请求头携带 <code>X-API-Key</code>(见 <code>admin/.env</code> 与 <code>backend/.env</code>)。
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const form = reactive({ username: 'admin', password: 'admin123456', captcha_id: '', captcha_code: '' })
|
||||
const captcha = reactive({ id: '', svg: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadCaptcha() {
|
||||
try {
|
||||
const res = await http.get('/api/captcha/new')
|
||||
captcha.id = res.data?.data?.captcha_id || ''
|
||||
captcha.svg = res.data?.data?.svg || ''
|
||||
form.captcha_id = captcha.id
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '获取验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.post('/api/admin/login', form)
|
||||
const token = res.data?.data?.token
|
||||
if (!token) throw new Error('missing token')
|
||||
auth.setToken(token)
|
||||
router.push('/dashboard')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '登录失败')
|
||||
await loadCaptcha()
|
||||
form.captcha_code = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCaptcha)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrap {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(420px, 92vw);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.h2 {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 140px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.captcha-img {
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.captcha-img :deep(svg) {
|
||||
width: 140px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
158
admin/src/views/MerchantApplicationsView.vue
Normal file
158
admin/src/views/MerchantApplicationsView.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<el-segmented v-model="status" :options="statusOptions" @change="onFilter" />
|
||||
<div class="spacer"></div>
|
||||
<el-button @click="load" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="store_name" label="店铺名称" min-width="160" />
|
||||
<el-table-column prop="contact_name" label="联系人" width="120" />
|
||||
<el-table-column prop="contact_phone" label="联系电话" width="140" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="tagType(row.status)">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="提交时间" width="180" />
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button size="small" type="success" :disabled="row.status !== 'pending'" @click="approve(row)">通过</el-button>
|
||||
<el-button size="small" type="danger" plain :disabled="row.status !== 'pending'" @click="reject(row)">拒绝</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="dlg.open" title="入驻申请详情" size="640px">
|
||||
<div v-if="dlg.item" class="detail">
|
||||
<div class="kv"><span>店铺:</span>{{ dlg.item.store_name }}</div>
|
||||
<div class="kv"><span>地址:</span>{{ dlg.item.address }}</div>
|
||||
<div class="kv"><span>经纬度:</span>{{ dlg.item.lat }}, {{ dlg.item.lng }}</div>
|
||||
<div class="kv"><span>电话:</span>{{ dlg.item.phone }}</div>
|
||||
<div class="kv"><span>营业时间:</span>{{ dlg.item.open_hours }}</div>
|
||||
<div class="kv"><span>描述:</span>{{ dlg.item.description }}</div>
|
||||
<div class="kv"><span>联系人:</span>{{ dlg.item.contact_name }} / {{ dlg.item.contact_phone }}</div>
|
||||
<div class="kv"><span>状态:</span>{{ dlg.item.status }}</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
type AppRow = any
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<AppRow[]>([])
|
||||
const status = ref<'pending' | 'approved' | 'rejected'>('pending')
|
||||
const statusOptions = [
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' }
|
||||
]
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const dlg = reactive({ open: false, item: null as any })
|
||||
|
||||
function tagType(s: string) {
|
||||
if (s === 'approved') return 'success'
|
||||
if (s === 'rejected') return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function openDetail(row: AppRow) {
|
||||
dlg.item = row
|
||||
dlg.open = true
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/merchant/applications', {
|
||||
params: { page: meta.page, page_size: meta.page_size, status: status.value }
|
||||
})
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
function onFilter() {
|
||||
meta.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function approve(row: AppRow) {
|
||||
await ElMessageBox.confirm(`确认通过「${row.store_name}」的入驻申请?`, '提示', { type: 'warning' })
|
||||
await http.patch(`/api/admin/merchant/applications/${row.id}/review`, { action: 'approve' })
|
||||
ElMessage.success('已通过')
|
||||
load()
|
||||
}
|
||||
|
||||
async function reject(row: AppRow) {
|
||||
const reason = await ElMessageBox.prompt('请输入拒绝原因:', '拒绝入驻', { confirmButtonText: '确定', cancelButtonText: '取消' }).catch(
|
||||
() => null
|
||||
)
|
||||
if (!reason) return
|
||||
await http.patch(`/api/admin/merchant/applications/${row.id}/review`, { action: 'reject', reject_reason: reason.value })
|
||||
ElMessage.success('已拒绝')
|
||||
load()
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.detail {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.kv span {
|
||||
opacity: 0.7;
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
103
admin/src/views/RankingsView.vue
Normal file
103
admin/src/views/RankingsView.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="by" style="width: 180px" @change="load">
|
||||
<el-option label="综合热度" value="hotness" />
|
||||
<el-option label="点赞数" value="likes" />
|
||||
<el-option label="搜索数" value="search" />
|
||||
<el-option label="评论数" value="reviews" />
|
||||
</el-select>
|
||||
<div class="spacer"></div>
|
||||
<el-button @click="recalc" :loading="recalcLoading">重算综合分</el-button>
|
||||
<el-button @click="load" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small" v-loading="loading">
|
||||
<el-table-column prop="store_id" label="店铺ID" width="90" />
|
||||
<el-table-column prop="name" label="店铺" min-width="160" />
|
||||
<el-table-column prop="likes_count" label="点赞" width="100" />
|
||||
<el-table-column prop="search_count" label="搜索" width="100" />
|
||||
<el-table-column prop="reviews_count" label="评论" width="100" />
|
||||
<el-table-column prop="score" label="综合分" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
const by = ref('hotness')
|
||||
const loading = ref(false)
|
||||
const recalcLoading = ref(false)
|
||||
const items = ref<any[]>([])
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/rankings/stores', { params: { by: by.value, page: meta.page, page_size: meta.page_size } })
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
async function recalc() {
|
||||
recalcLoading.value = true
|
||||
try {
|
||||
await http.post('/api/admin/rankings/stores/recalc', {})
|
||||
ElMessage.success('已触发重算')
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '重算失败')
|
||||
} finally {
|
||||
recalcLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
146
admin/src/views/ReviewsView.vue
Normal file
146
admin/src/views/ReviewsView.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="status" placeholder="状态" clearable style="width: 160px" @change="load">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已屏蔽" value="blocked" />
|
||||
</el-select>
|
||||
<el-input v-model="storeId" placeholder="店铺ID筛选" style="width: 160px" clearable @change="load" />
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="店铺" min-width="160">
|
||||
<template #default="{ row }">{{ row.store?.name || row.store_id }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rating" label="评分" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column prop="content" label="内容" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column label="图片" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="imgs">
|
||||
<el-image
|
||||
v-for="(u, idx) in normalizeImageURLs(row.image_urls).slice(0, 3)"
|
||||
:key="idx"
|
||||
:src="u"
|
||||
:preview-src-list="normalizeImageURLs(row.image_urls)"
|
||||
:initial-index="idx"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="thumb"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="setStatus(row, 'approved')">通过</el-button>
|
||||
<el-button size="small" @click="setStatus(row, 'blocked')">屏蔽</el-button>
|
||||
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
type Review = any
|
||||
const items = ref<Review[]>([])
|
||||
const status = ref<string | undefined>('pending')
|
||||
const storeId = ref('')
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
async function load() {
|
||||
const res = await http.get('/api/admin/reviews', {
|
||||
params: {
|
||||
page: meta.page,
|
||||
page_size: meta.page_size,
|
||||
status: status.value,
|
||||
store_id: storeId.value || undefined
|
||||
}
|
||||
})
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
async function setStatus(row: Review, next: string) {
|
||||
await http.patch(`/api/admin/reviews/${row.id}/status`, { status: next })
|
||||
ElMessage.success('已更新')
|
||||
load()
|
||||
}
|
||||
|
||||
async function onDelete(row: Review) {
|
||||
await ElMessageBox.confirm(`确认删除评论 #${row.id}?`, '提示', { type: 'warning' })
|
||||
await http.delete(`/api/admin/reviews/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
function normalizeImageURLs(v: any): string[] {
|
||||
if (!v) return []
|
||||
if (Array.isArray(v)) return v.filter(Boolean)
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(v)
|
||||
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||
} catch {}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.imgs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.thumb {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
78
admin/src/views/SettingsView.vue
Normal file
78
admin/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<div class="title">系统设置</div>
|
||||
<div class="spacer"></div>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</div>
|
||||
|
||||
<el-form label-width="160px">
|
||||
<el-form-item label="允许跨域域名(Origins)">
|
||||
<el-input
|
||||
v-model="originsText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="一行一个 Origin,例如:https://admin.example.com 或 http://localhost:5173;允许全部可填 *"
|
||||
/>
|
||||
<div class="hint">修改后立即生效(后端内存更新 + DB 持久化)。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
const originsText = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
const res = await http.get('/api/admin/settings/cors')
|
||||
const origins: string[] = res.data?.data?.origins || []
|
||||
originsText.value = origins.join('\n')
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const origins = originsText.value
|
||||
.split('\n')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
await http.put('/api/admin/settings/cors', { origins })
|
||||
ElMessage.success('已保存')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 800;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
325
admin/src/views/StoreDetailView.vue
Normal file
325
admin/src/views/StoreDetailView.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="card pad">
|
||||
<div class="head">
|
||||
<div class="title">店铺详情</div>
|
||||
<div class="spacer"></div>
|
||||
<el-button type="primary" @click="openEdit" :disabled="!store">编辑</el-button>
|
||||
<el-button @click="back">返回</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions v-if="store" :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ store.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ store.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称" :span="2">{{ store.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ store.category?.name || store.category_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电话">{{ store.phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址" :span="2">{{ store.address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="营业时间" :span="2">{{ store.open_hours || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ store.description || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="store?.cover_url" class="images">
|
||||
<div class="sub">封面图</div>
|
||||
<el-image :src="store.cover_url" :preview-src-list="[store.cover_url]" preview-teleported fit="cover" class="cover" />
|
||||
</div>
|
||||
|
||||
<div v-if="store?.images?.length" class="images">
|
||||
<div class="sub">店内环境图</div>
|
||||
<div class="img-grid">
|
||||
<el-image
|
||||
v-for="img in store.images"
|
||||
:key="img.id"
|
||||
:src="img.url"
|
||||
:preview-src-list="storeImageUrls"
|
||||
:initial-index="storeImageUrls.indexOf(img.url)"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
class="grid-img"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="store?.dishes?.length" class="images">
|
||||
<div class="sub">招牌菜</div>
|
||||
<el-table :data="store.dishes" size="small">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="description" label="描述" />
|
||||
<el-table-column prop="image_url" label="图片">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image_url"
|
||||
:src="row.image_url"
|
||||
:preview-src-list="[row.image_url]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="thumb"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card pad">
|
||||
<div class="head">
|
||||
<div class="title">评论列表</div>
|
||||
<div class="spacer"></div>
|
||||
<el-select v-model="status" placeholder="状态" clearable style="width: 160px" @change="loadReviews">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已屏蔽" value="blocked" />
|
||||
</el-select>
|
||||
<el-button @click="loadReviews">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="reviews" size="small" v-loading="loadingReviews">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="rating" label="评分" width="90" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column prop="user_name" label="用户" width="140" />
|
||||
<el-table-column prop="content" label="内容" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column label="图片" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="review-imgs">
|
||||
<el-image
|
||||
v-for="(u, idx) in normalizeImageURLs(row.image_urls).slice(0, 3)"
|
||||
:key="idx"
|
||||
:src="u"
|
||||
:preview-src-list="normalizeImageURLs(row.image_urls)"
|
||||
:initial-index="idx"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="review-thumb"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="setStatus(row, 'approved')">通过</el-button>
|
||||
<el-button size="small" @click="setStatus(row, 'blocked')">屏蔽</el-button>
|
||||
<el-button size="small" type="danger" plain @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="dlg.open" title="编辑店铺" size="720px">
|
||||
<el-form v-if="dlg.form" :model="dlg.form" label-width="90px">
|
||||
<el-form-item label="名称"><el-input v-model="dlg.form.name" /></el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="dlg.form.category_id" style="width: 100%">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="dlg.form.address" /></el-form-item>
|
||||
<el-form-item label="电话"><el-input v-model="dlg.form.phone" /></el-form-item>
|
||||
<el-form-item label="营业时间"><el-input v-model="dlg.form.open_hours" /></el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<Uploader v-model="dlg.form.cover_url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="多图">
|
||||
<UploaderList v-model="dlg.form.images" />
|
||||
</el-form-item>
|
||||
<el-form-item label="招牌菜">
|
||||
<DishesEditor v-model="dlg.form.dishes" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="dlg.form.description" type="textarea" :rows="4" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlg.loading" @click="onSave">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
import Uploader from '../components/Uploader.vue'
|
||||
import UploaderList from '../components/UploaderList.vue'
|
||||
import DishesEditor from '../components/DishesEditor.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const id = Number(route.params.id)
|
||||
|
||||
const store = ref<any>(null)
|
||||
const storeImageUrls = ref<string[]>([])
|
||||
const reviews = ref<any[]>([])
|
||||
const status = ref<string | undefined>()
|
||||
const loadingReviews = ref(false)
|
||||
const categories = ref<{ id: number; name: string }[]>([])
|
||||
const dlg = ref<any>({
|
||||
open: false,
|
||||
loading: false,
|
||||
form: null as any
|
||||
})
|
||||
|
||||
function back() {
|
||||
router.push('/stores')
|
||||
}
|
||||
|
||||
async function loadStore() {
|
||||
const res = await http.get(`/api/admin/stores/${id}`)
|
||||
store.value = res.data?.data
|
||||
storeImageUrls.value = (store.value?.images || []).map((x: any) => x.url).filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await http.get('/api/admin/categories', { params: { page: 1, page_size: 200 } })
|
||||
categories.value = res.data?.data || []
|
||||
}
|
||||
|
||||
async function loadReviews() {
|
||||
loadingReviews.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/reviews', { params: { store_id: id, status: status.value, page: 1, page_size: 50 } })
|
||||
reviews.value = res.data?.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
} finally {
|
||||
loadingReviews.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(row: any, next: string) {
|
||||
await http.patch(`/api/admin/reviews/${row.id}/status`, { status: next })
|
||||
ElMessage.success('已更新')
|
||||
loadReviews()
|
||||
}
|
||||
|
||||
async function del(row: any) {
|
||||
await ElMessageBox.confirm(`确认删除评论 #${row.id}?`, '提示', { type: 'warning' })
|
||||
await http.delete(`/api/admin/reviews/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
loadReviews()
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!store.value) return
|
||||
dlg.value.form = {
|
||||
name: store.value.name,
|
||||
category_id: store.value.category_id,
|
||||
address: store.value.address,
|
||||
phone: store.value.phone,
|
||||
open_hours: store.value.open_hours,
|
||||
cover_url: store.value.cover_url,
|
||||
description: store.value.description,
|
||||
images: (store.value.images || []).map((x: any) => ({ url: x.url, sort_order: x.sort_order || 0 })),
|
||||
dishes: (store.value.dishes || []).map((x: any) => ({
|
||||
name: x.name,
|
||||
description: x.description,
|
||||
image_url: x.image_url,
|
||||
sort_order: x.sort_order || 0
|
||||
}))
|
||||
}
|
||||
dlg.value.open = true
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!dlg.value.form) return
|
||||
dlg.value.loading = true
|
||||
try {
|
||||
const payload = {
|
||||
...dlg.value.form,
|
||||
images: (dlg.value.form.images || []).map((x: any) => ({ url: x.url, sort_order: x.sort_order || 0 })),
|
||||
dishes: (dlg.value.form.dishes || []).map((x: any) => ({ ...x, sort_order: x.sort_order || 0 }))
|
||||
}
|
||||
await http.put(`/api/admin/stores/${id}`, payload)
|
||||
ElMessage.success('已保存')
|
||||
dlg.value.open = false
|
||||
await loadStore()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
dlg.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageURLs(v: any): string[] {
|
||||
if (!v) return []
|
||||
if (Array.isArray(v)) return v.filter(Boolean)
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(v)
|
||||
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||
} catch {}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories()
|
||||
await loadStore()
|
||||
await loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 800;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.sub {
|
||||
font-weight: 700;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
.img-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.cover {
|
||||
width: 220px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.grid-img {
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.thumb {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.review-imgs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.review-thumb {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
283
admin/src/views/StoresView.vue
Normal file
283
admin/src/views/StoresView.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索店铺" style="max-width: 260px" clearable @change="load" />
|
||||
<el-select v-model="categoryId" placeholder="分类" clearable style="width: 180px" @change="load">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="status" placeholder="状态" clearable style="width: 140px" @change="load">
|
||||
<el-option label="上架" value="active" />
|
||||
<el-option label="下架" value="inactive" />
|
||||
</el-select>
|
||||
<div class="spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">新增店铺</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="店铺" min-width="180" />
|
||||
<el-table-column label="分类" width="160">
|
||||
<template #default="{ row }">{{ row.category?.name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '上架' : '下架' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="address" label="地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="goDetail(row)">详情</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" @click="toggle(row)">{{ row.status === 'active' ? '下架' : '上架' }}</el-button>
|
||||
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="dlg.open" :title="dlg.mode === 'create' ? '新增店铺' : '编辑店铺'" size="720px">
|
||||
<el-form :model="dlg.form" label-width="90px">
|
||||
<el-form-item label="名称"><el-input v-model="dlg.form.name" /></el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="dlg.form.category_id" style="width: 100%">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="dlg.form.address" /></el-form-item>
|
||||
<el-form-item label="电话"><el-input v-model="dlg.form.phone" /></el-form-item>
|
||||
<el-form-item label="营业时间"><el-input v-model="dlg.form.open_hours" /></el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<Uploader v-model="dlg.form.cover_url" />
|
||||
<el-text type="info" size="small">用于店铺列表/详情的主图(建议 1 张)</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="店内环境图">
|
||||
<UploaderList v-model="dlg.form.images" />
|
||||
<el-text type="info" size="small">用于展示店内环境/门头/菜单等(多张)</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="招牌菜">
|
||||
<DishesEditor v-model="dlg.form.dishes" />
|
||||
<el-text type="info" size="small">用于展示推荐菜品(可配置图片)</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="dlg.form.description" type="textarea" :rows="4" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dlg.loading" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
import Uploader from '../components/Uploader.vue'
|
||||
import UploaderList from '../components/UploaderList.vue'
|
||||
import DishesEditor from '../components/DishesEditor.vue'
|
||||
|
||||
type Category = { id: number; name: string }
|
||||
type Store = any
|
||||
|
||||
const categories = ref<Category[]>([])
|
||||
const items = ref<Store[]>([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const categoryId = ref<number | undefined>()
|
||||
const status = ref<string | undefined>()
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create' as 'create' | 'edit',
|
||||
loading: false,
|
||||
id: 0,
|
||||
form: {
|
||||
name: '',
|
||||
category_id: 0,
|
||||
address: '',
|
||||
phone: '',
|
||||
open_hours: '',
|
||||
cover_url: '',
|
||||
description: '',
|
||||
images: [] as { url: string; sort_order: number }[],
|
||||
dishes: [] as { name: string; description: string; image_url: string; sort_order: number }[]
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await http.get('/api/admin/categories', { params: { page: 1, page_size: 200 } })
|
||||
categories.value = res.data?.data || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/stores', {
|
||||
params: {
|
||||
page: meta.page,
|
||||
page_size: meta.page_size,
|
||||
keyword: keyword.value,
|
||||
category_id: categoryId.value,
|
||||
status: status.value
|
||||
}
|
||||
})
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dlg.mode = 'create'
|
||||
dlg.id = 0
|
||||
dlg.form = {
|
||||
name: '',
|
||||
category_id: categories.value?.[0]?.id || 0,
|
||||
address: '',
|
||||
phone: '',
|
||||
open_hours: '',
|
||||
cover_url: '',
|
||||
description: '',
|
||||
images: [],
|
||||
dishes: []
|
||||
}
|
||||
dlg.open = true
|
||||
}
|
||||
|
||||
function goDetail(row: any) {
|
||||
router.push(`/stores/${row.id}`)
|
||||
}
|
||||
|
||||
async function openEdit(row: Store) {
|
||||
dlg.mode = 'edit'
|
||||
dlg.id = row.id
|
||||
let s: any
|
||||
try {
|
||||
const res = await http.get(`/api/admin/stores/${row.id}`)
|
||||
s = res.data?.data
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
return
|
||||
}
|
||||
dlg.form = {
|
||||
name: s.name,
|
||||
category_id: s.category_id,
|
||||
address: s.address,
|
||||
phone: s.phone,
|
||||
open_hours: s.open_hours,
|
||||
cover_url: s.cover_url,
|
||||
description: s.description,
|
||||
images: (s.images || []).map((x: any) => ({ url: x.url, sort_order: x.sort_order })),
|
||||
dishes: (s.dishes || []).map((x: any) => ({
|
||||
name: x.name,
|
||||
description: x.description,
|
||||
image_url: x.image_url,
|
||||
sort_order: x.sort_order
|
||||
}))
|
||||
}
|
||||
dlg.open = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
dlg.loading = true
|
||||
try {
|
||||
const payload = {
|
||||
...dlg.form,
|
||||
images: dlg.form.images.map((x) => ({ url: x.url, sort_order: x.sort_order || 0 })),
|
||||
dishes: dlg.form.dishes.map((x) => ({ ...x, sort_order: x.sort_order || 0 }))
|
||||
}
|
||||
if (dlg.mode === 'create') {
|
||||
await http.post('/api/admin/stores', payload)
|
||||
ElMessage.success('已创建')
|
||||
} else {
|
||||
await http.put(`/api/admin/stores/${dlg.id}`, payload)
|
||||
ElMessage.success('已更新')
|
||||
}
|
||||
dlg.open = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
dlg.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(row: Store) {
|
||||
const next = row.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await http.patch(`/api/admin/stores/${row.id}/status`, { status: next })
|
||||
ElMessage.success('已更新')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: Store) {
|
||||
await ElMessageBox.confirm(`确认删除店铺「${row.name}」?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
await http.delete(`/api/admin/stores/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadCategories()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '分类加载失败')
|
||||
}
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
107
admin/src/views/UserDetailView.vue
Normal file
107
admin/src/views/UserDetailView.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="card pad">
|
||||
<div class="head">
|
||||
<div class="title">用户详情</div>
|
||||
<div class="spacer"></div>
|
||||
<el-button @click="back">返回</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions v-if="user" :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ user.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ user.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名" :span="2">{{ user.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="抖音 OpenID" :span="2">{{ user.douyin_openid || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间" :span="2">{{ user.created_at }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="card pad">
|
||||
<div class="head">
|
||||
<div class="title">该用户的评论</div>
|
||||
<div class="spacer"></div>
|
||||
<el-select v-model="status" placeholder="状态" clearable style="width: 160px" @change="loadReviews">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已屏蔽" value="blocked" />
|
||||
</el-select>
|
||||
<el-button @click="loadReviews">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="reviews" size="small" v-loading="loadingReviews">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="店铺" min-width="160">
|
||||
<template #default="{ row }">{{ row.store?.name || row.store_id }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rating" label="评分" width="90" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column prop="content" label="内容" min-width="260" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const id = Number(route.params.id)
|
||||
|
||||
const user = ref<any>(null)
|
||||
const reviews = ref<any[]>([])
|
||||
const status = ref<string | undefined>()
|
||||
const loadingReviews = ref(false)
|
||||
|
||||
function back() {
|
||||
router.push('/users')
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
const res = await http.get(`/api/admin/users/${id}`)
|
||||
user.value = res.data?.data
|
||||
}
|
||||
|
||||
async function loadReviews() {
|
||||
loadingReviews.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/reviews', { params: { user_id: id, status: status.value, page: 1, page_size: 50 } })
|
||||
reviews.value = res.data?.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
} finally {
|
||||
loadingReviews.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUser()
|
||||
await loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 800;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
112
admin/src/views/UsersView.vue
Normal file
112
admin/src/views/UsersView.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="card pad">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索用户名" style="max-width: 260px" clearable @change="load" />
|
||||
<el-select v-model="status" placeholder="状态" clearable style="width: 160px" @change="load">
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="禁用" value="disabled" />
|
||||
</el-select>
|
||||
<div class="spacer"></div>
|
||||
<el-button @click="load" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" style="width: 100%" size="small" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="160" />
|
||||
<el-table-column prop="status" label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="注册时间" width="180" />
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="goDetail(row)">详情</el-button>
|
||||
<el-button size="small" @click="setStatus(row, row.status === 'active' ? 'disabled' : 'active')">
|
||||
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.page_size"
|
||||
:current-page="meta.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '../lib/http'
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const status = ref<string | undefined>()
|
||||
const meta = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
const router = useRouter()
|
||||
|
||||
function goDetail(row: any) {
|
||||
router.push(`/users/${row.id}`)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get('/api/admin/users', { params: { page: meta.page, page_size: meta.page_size, keyword: keyword.value, status: status.value } })
|
||||
items.value = res.data?.data || []
|
||||
Object.assign(meta, res.data?.meta || {})
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
meta.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
async function setStatus(row: any, next: string) {
|
||||
try {
|
||||
await http.patch(`/api/admin/users/${row.id}/status`, { status: next })
|
||||
ElMessage.success('已更新')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
2
admin/src/vite-env.d.ts
vendored
Normal file
2
admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
17
admin/tsconfig.json
Normal file
17
admin/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
|
||||
29
admin/vite.config.ts
Normal file
29
admin/vite.config.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
40
backend/.env.example
Normal file
40
backend/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# App
|
||||
APP_ENV=dev
|
||||
APP_PORT=8080
|
||||
PUBLIC_BASE_URL=http://localhost:8080
|
||||
|
||||
# Admin init (首次启动会创建)
|
||||
ADMIN_INIT_USERNAME=admin
|
||||
ADMIN_INIT_PASSWORD=admin123456
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=please_change_me
|
||||
|
||||
# API Key(管理后台每次请求都需要携带 X-API-Key)
|
||||
# 生产环境建议用 API_KEY_HASH(SHA256 hex),不要直接放明文 API_KEY
|
||||
API_KEY=dev-api-key-change-me
|
||||
# API_KEY_HASH=
|
||||
|
||||
# Database(也可在 docker-compose 里覆盖)
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3309
|
||||
DB_NAME=mydb
|
||||
DB_USER=user
|
||||
DB_PASSWORD=password123
|
||||
DB_PARAMS=charset=utf8mb4&parseTime=True&loc=Local
|
||||
|
||||
# Redis
|
||||
REDIS_ADDR=127.0.0.1:6381
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=./static/upload
|
||||
MAX_UPLOAD_MB=10
|
||||
|
||||
# CORS(管理后台域名)
|
||||
CORS_ALLOW_ORIGINS=http://localhost:5173
|
||||
|
||||
# AMap(地址解析/选点,当前为预留)
|
||||
AMAP_KEY=
|
||||
|
||||
29
backend/Dockerfile
Normal file
29
backend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 解决部分网络环境无法访问 proxy.golang.org 的问题(可在构建时覆盖)
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
ARG GOSUMDB=sum.golang.google.cn
|
||||
ENV GOPROXY=$GOPROXY
|
||||
ENV GOSUMDB=$GOSUMDB
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
||||
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata su-exec && adduser -D -H appuser
|
||||
|
||||
COPY --from=build /out/server /app/server
|
||||
COPY --from=build /src/static /app/static
|
||||
COPY --from=build /src/scripts /app/scripts
|
||||
|
||||
ENV APP_PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
|
||||
76
backend/cmd/server/main.go
Normal file
76
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/db"
|
||||
"0451meishiditu/backend/internal/httpx"
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
"0451meishiditu/backend/internal/migrate"
|
||||
"0451meishiditu/backend/internal/redisx"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
"0451meishiditu/backend/internal/seed"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log := logger.New(cfg.AppEnv)
|
||||
defer func() { _ = log.Sync() }()
|
||||
|
||||
gdb, err := db.Open(cfg, log)
|
||||
if err != nil {
|
||||
log.Fatal("open db failed", logger.Err(err))
|
||||
}
|
||||
|
||||
rdb := redisx.New(cfg)
|
||||
|
||||
if err := migrate.AutoMigrate(gdb); err != nil {
|
||||
log.Fatal("auto migrate failed", logger.Err(err))
|
||||
}
|
||||
|
||||
if err := seed.EnsureInitialAdmin(gdb, cfg); err != nil {
|
||||
log.Fatal("seed admin failed", logger.Err(err))
|
||||
}
|
||||
|
||||
st, err := settings.New(gdb, cfg)
|
||||
if err != nil {
|
||||
log.Fatal("settings init failed", logger.Err(err))
|
||||
}
|
||||
|
||||
router := httpx.NewRouter(cfg, log, gdb, rdb, st)
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.AppPort,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Info("server started", logger.Str("addr", srv.Addr))
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal("listen failed", logger.Err(err))
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
log.Info("server stopped")
|
||||
}
|
||||
52
backend/go.mod
Normal file
52
backend/go.mod
Normal file
@@ -0,0 +1,52 @@
|
||||
module 0451meishiditu/backend
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/gzip v1.2.2
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
gorm.io/datatypes v1.2.5
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
155
backend/go.sum
Normal file
155
backend/go.sum
Normal file
@@ -0,0 +1,155 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
|
||||
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
|
||||
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
||||
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
76
backend/internal/auth/jwt.go
Normal file
76
backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type AdminClaims struct {
|
||||
AdminID uint `json:"admin_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewAdminToken(secret string, adminID uint, username, role string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := AdminClaims{
|
||||
AdminID: adminID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseAdminToken(secret, token string) (AdminClaims, error) {
|
||||
var claims AdminClaims
|
||||
parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
return AdminClaims{}, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
type UserClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewUserToken(secret string, userID uint, username string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := UserClaims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseUserToken(secret, token string) (UserClaims, error) {
|
||||
var claims UserClaims
|
||||
parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
return UserClaims{}, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
129
backend/internal/config/config.go
Normal file
129
backend/internal/config/config.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
AppPort string
|
||||
|
||||
PublicBaseURL string
|
||||
|
||||
AdminInitUsername string
|
||||
AdminInitPassword string
|
||||
|
||||
JWTSecret string
|
||||
|
||||
APIKey string
|
||||
APIKeyHash string
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBName string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBParams string
|
||||
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
|
||||
UploadDir string
|
||||
MaxUploadMB int64
|
||||
|
||||
CORSAllowOrigins []string
|
||||
|
||||
AMapKey string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := Config{
|
||||
AppEnv: getenv("APP_ENV", "dev"),
|
||||
AppPort: getenv("APP_PORT", "8080"),
|
||||
|
||||
PublicBaseURL: getenv("PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||
|
||||
AdminInitUsername: getenv("ADMIN_INIT_USERNAME", "admin"),
|
||||
AdminInitPassword: getenv("ADMIN_INIT_PASSWORD", "admin123456"),
|
||||
|
||||
JWTSecret: getenv("JWT_SECRET", ""),
|
||||
|
||||
APIKey: strings.TrimSpace(os.Getenv("API_KEY")),
|
||||
APIKeyHash: strings.ToLower(strings.TrimSpace(os.Getenv("API_KEY_HASH"))),
|
||||
|
||||
DBHost: getenv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getenv("DB_PORT", "3306"),
|
||||
DBName: getenv("DB_NAME", "mydb"),
|
||||
DBUser: getenv("DB_USER", "root"),
|
||||
DBPassword: strings.TrimSpace(os.Getenv("DB_PASSWORD")),
|
||||
DBParams: getenv("DB_PARAMS", "charset=utf8mb4&parseTime=True&loc=Local"),
|
||||
|
||||
RedisAddr: getenv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||
RedisPassword: strings.TrimSpace(os.Getenv("REDIS_PASSWORD")),
|
||||
RedisDB: mustAtoi(getenv("REDIS_DB", "0")),
|
||||
|
||||
UploadDir: getenv("UPLOAD_DIR", "./static/upload"),
|
||||
MaxUploadMB: int64(mustAtoi(getenv("MAX_UPLOAD_MB", "10"))),
|
||||
|
||||
CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "http://localhost:5173")),
|
||||
|
||||
AMapKey: strings.TrimSpace(os.Getenv("AMAP_KEY")),
|
||||
}
|
||||
|
||||
if cfg.JWTSecret == "" {
|
||||
return Config{}, errors.New("JWT_SECRET is required")
|
||||
}
|
||||
if cfg.APIKey == "" && cfg.APIKeyHash == "" {
|
||||
return Config{}, errors.New("API_KEY or API_KEY_HASH is required")
|
||||
}
|
||||
if cfg.APIKeyHash != "" && len(cfg.APIKeyHash) != 64 {
|
||||
return Config{}, errors.New("API_KEY_HASH must be sha256 hex (64 chars)")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) ExpectedAPIKeyHash() string {
|
||||
if c.APIKeyHash != "" {
|
||||
return c.APIKeyHash
|
||||
}
|
||||
sum := sha256.Sum256([]byte(c.APIKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func mustAtoi(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
v := strings.TrimSpace(part)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
66
backend/internal/db/db.go
Normal file
66
backend/internal/db/db.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Open(cfg config.Config, log *zap.Logger) (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?%s",
|
||||
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName, cfg.DBParams,
|
||||
)
|
||||
|
||||
gormLog := gormlogger.New(
|
||||
zap.NewStdLog(log.WithOptions(zap.AddCallerSkip(1))),
|
||||
gormlogger.Config{
|
||||
SlowThreshold: 500 * time.Millisecond,
|
||||
LogLevel: gormlogger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
},
|
||||
)
|
||||
|
||||
var gdb *gorm.DB
|
||||
var err error
|
||||
delay := 500 * time.Millisecond
|
||||
for attempt := 1; attempt <= 20; attempt++ {
|
||||
gdb, err = gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gormLog})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.Warn("db connect retry",
|
||||
logger.Str("host", cfg.DBHost),
|
||||
logger.Str("db", cfg.DBName),
|
||||
zap.Int("attempt", attempt),
|
||||
logger.Err(err),
|
||||
)
|
||||
time.Sleep(delay)
|
||||
if delay < 5*time.Second {
|
||||
delay *= 2
|
||||
if delay > 5*time.Second {
|
||||
delay = 5 * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
log.Info("db connected", logger.Str("host", cfg.DBHost), logger.Str("db", cfg.DBName))
|
||||
return gdb, nil
|
||||
}
|
||||
135
backend/internal/handlers/admin_admins.go
Normal file
135
backend/internal/handlers/admin_admins.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminListAdmins(c *gin.Context) {
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
q := h.db.Model(&models.AdminUser{})
|
||||
if keyword != "" {
|
||||
q = q.Where("username LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.AdminUser
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page-1)*pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type adminCreateReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminCreateAdmin(c *gin.Context) {
|
||||
var req adminCreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" || len(req.Password) < 6 {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid username or password")
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = "admin"
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
item := models.AdminUser{
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
Role: req.Role,
|
||||
Enabled: enabled,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
type adminPasswordReq struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminUpdateAdminPassword(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req adminPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Password) < 6 {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Model(&models.AdminUser{}).Where("id = ?", uint(id64)).
|
||||
Update("password_hash", string(hash)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
type adminEnabledReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminUpdateAdminEnabled(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req adminEnabledReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.AdminUser{}).Where("id = ?", uint(id64)).
|
||||
Update("enabled", req.Enabled).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
69
backend/internal/handlers/admin_auth.go
Normal file
69
backend/internal/handlers/admin_auth.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminLogin(c *gin.Context) {
|
||||
var req loginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
|
||||
return
|
||||
}
|
||||
|
||||
var admin models.AdminUser
|
||||
if err := h.db.Where("username = ?", req.Username).First(&admin).Error; err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
if !admin.Enabled {
|
||||
resp.Fail(c, http.StatusUnauthorized, "account disabled")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(req.Password)); err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.NewAdminToken(h.cfg.JWTSecret, admin.ID, admin.Username, admin.Role, 24*time.Hour)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "token error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OK(c, gin.H{
|
||||
"token": token,
|
||||
"admin": gin.H{
|
||||
"id": admin.ID,
|
||||
"username": admin.Username,
|
||||
"role": admin.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminMe(c *gin.Context) {
|
||||
resp.OK(c, gin.H{
|
||||
"id": c.GetUint("admin_id"),
|
||||
"username": c.GetString("admin_username"),
|
||||
"role": c.GetString("admin_role"),
|
||||
})
|
||||
}
|
||||
81
backend/internal/handlers/admin_rankings.go
Normal file
81
backend/internal/handlers/admin_rankings.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminStoreRanking(c *gin.Context) {
|
||||
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
if by == "" {
|
||||
by = "hotness"
|
||||
}
|
||||
|
||||
order := "store_metrics.score desc"
|
||||
switch by {
|
||||
case "likes":
|
||||
order = "store_metrics.likes_count desc"
|
||||
case "search":
|
||||
order = "store_metrics.search_count desc"
|
||||
case "reviews":
|
||||
order = "store_metrics.reviews_count desc"
|
||||
}
|
||||
|
||||
type row struct {
|
||||
StoreID uint `json:"store_id"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"category_id"`
|
||||
Status string `json:"status"`
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
SearchCount int64 `json:"search_count"`
|
||||
ReviewsCount int64 `json:"reviews_count"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
var total int64
|
||||
_ = h.db.Table("store_metrics").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null").
|
||||
Count(&total).Error
|
||||
|
||||
var out []row
|
||||
if err := h.db.Table("store_metrics").
|
||||
Select("stores.id as store_id, stores.name, stores.category_id, stores.status, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null").
|
||||
Order(order + ", stores.id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Scan(&out).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, out, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminRecalcStoreScore(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
if limit <= 0 || limit > 5000 {
|
||||
limit = 5000
|
||||
}
|
||||
// score = likes*2 + search*1 + reviews*3
|
||||
err := h.db.Exec("update store_metrics set score = likes_count*2 + search_count*1 + reviews_count*3, updated_at = now() limit ?", limit).Error
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "recalc failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
22
backend/internal/handlers/admin_user_get.go
Normal file
22
backend/internal/handlers/admin_user_get.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminUserGet(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var u models.User
|
||||
if err := h.db.First(&u, uint(id64)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, u)
|
||||
}
|
||||
|
||||
68
backend/internal/handlers/admin_users.go
Normal file
68
backend/internal/handlers/admin_users.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) AdminUserList(c *gin.Context) {
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
|
||||
q := h.db.Model(&models.User{})
|
||||
if keyword != "" {
|
||||
q = q.Where("username LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.User
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page-1)*pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type userStatusReq struct {
|
||||
Status string `json:"status" binding:"required"` // active/disabled
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminUserUpdateStatus(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req userStatusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status != "active" && req.Status != "disabled" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.User{}).Where("id = ?", uint(id64)).Update("status", req.Status).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
90
backend/internal/handlers/apikeys.go
Normal file
90
backend/internal/handlers/apikeys.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type apiKeyCreateReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) APIKeyList(c *gin.Context) {
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.APIKey{})
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.APIKey
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIKeyCreate(c *gin.Context) {
|
||||
var req apiKeyCreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
plain := "ak_" + randomHex(24)
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
prefix := plain
|
||||
if len(prefix) > 10 {
|
||||
prefix = prefix[:10]
|
||||
}
|
||||
|
||||
item := models.APIKey{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Prefix: prefix,
|
||||
HashSHA256: hash,
|
||||
Status: "active",
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OK(c, gin.H{
|
||||
"id": item.ID,
|
||||
"name": item.Name,
|
||||
"prefix": item.Prefix,
|
||||
"status": item.Status,
|
||||
"key": plain, // 仅返回一次,前端应提示保存
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIKeyRevoke(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
now := time.Now()
|
||||
if err := h.db.Model(&models.APIKey{}).
|
||||
Where("id = ? and status = 'active'", uint(id)).
|
||||
Updates(map[string]any{"status": "revoked", "revoked_at": &now}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "revoke failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"revoked": true})
|
||||
}
|
||||
80
backend/internal/handlers/captcha.go
Normal file
80
backend/internal/handlers/captcha.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type captchaNewResp struct {
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
SVG string `json:"svg"`
|
||||
}
|
||||
|
||||
// CaptchaNew returns a simple SVG captcha and stores the answer in Redis.
|
||||
// TTL: 5 minutes. One-time: successful validate will delete it.
|
||||
func (h *Handlers) CaptchaNew(c *gin.Context) {
|
||||
code := randomCaptchaCode(5)
|
||||
id := randomHexStr(16)
|
||||
key := "captcha:" + id
|
||||
if err := h.rdb.Set(c.Request.Context(), key, strings.ToLower(code), 5*time.Minute).Err(); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "captcha store failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, captchaNewResp{
|
||||
CaptchaID: id,
|
||||
SVG: captchaSVG(code),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) verifyCaptcha(c *gin.Context, captchaID, captchaCode string) bool {
|
||||
captchaID = strings.TrimSpace(captchaID)
|
||||
captchaCode = strings.ToLower(strings.TrimSpace(captchaCode))
|
||||
if captchaID == "" || captchaCode == "" {
|
||||
return false
|
||||
}
|
||||
key := "captcha:" + captchaID
|
||||
val, err := h.rdb.Get(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ok := strings.ToLower(strings.TrimSpace(val)) == captchaCode
|
||||
if ok {
|
||||
_ = h.rdb.Del(c.Request.Context(), key).Err()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func randomHexStr(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func randomCaptchaCode(n int) string {
|
||||
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
out := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func captchaSVG(code string) string {
|
||||
// Minimal SVG; frontends can render via v-html or <img src="data:image/svg+xml;utf8,...">
|
||||
esc := html.EscapeString(code)
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="44" viewBox="0 0 140 44">` +
|
||||
`<rect x="0" y="0" width="140" height="44" rx="10" fill="#f8fafc" stroke="#e2e8f0"/>` +
|
||||
`<text x="70" y="29" text-anchor="middle" font-family="ui-sans-serif,system-ui" font-size="22" font-weight="700" fill="#0f172a" letter-spacing="3">` + esc + `</text>` +
|
||||
`</svg>`
|
||||
}
|
||||
112
backend/internal/handlers/categories.go
Normal file
112
backend/internal/handlers/categories.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type categoryUpsertReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
IconURL string `json:"icon_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryList(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Category{})
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Category
|
||||
if err := q.Order("sort_order desc, id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryCreate(c *gin.Context) {
|
||||
var req categoryUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
item := models.Category{
|
||||
Name: req.Name,
|
||||
IconURL: req.IconURL,
|
||||
SortOrder: req.SortOrder,
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryUpdate(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req categoryUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
var item models.Category
|
||||
if err := h.db.First(&item, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
item.Name = req.Name
|
||||
item.IconURL = req.IconURL
|
||||
item.SortOrder = req.SortOrder
|
||||
if req.Enabled != nil {
|
||||
item.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
if err := h.db.Save(&item).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) CategoryDelete(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.db.Delete(&models.Category{}, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
113
backend/internal/handlers/dashboard.go
Normal file
113
backend/internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type dashboardOverview struct {
|
||||
TotalStores int64 `json:"total_stores"`
|
||||
TotalReviews int64 `json:"total_reviews"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
CategoryDist []categoryKV `json:"category_dist"`
|
||||
StoresLast7Days []dateKV `json:"stores_last7days"`
|
||||
TopRatedStores []topStoreRow `json:"top_rated_stores"`
|
||||
}
|
||||
|
||||
type categoryKV struct {
|
||||
CategoryID uint `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type dateKV struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type topStoreRow struct {
|
||||
StoreID uint `json:"store_id"`
|
||||
Name string `json:"name"`
|
||||
Avg float64 `json:"avg"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
func (h *Handlers) DashboardOverview(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
cacheKey := "admin:dashboard:overview:v1"
|
||||
|
||||
if b, err := h.rdb.Get(ctx, cacheKey).Bytes(); err == nil && len(b) > 0 {
|
||||
var cached dashboardOverview
|
||||
if json.Unmarshal(b, &cached) == nil {
|
||||
resp.OK(c, cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ov, err := h.buildDashboard(ctx)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
if b, err := json.Marshal(ov); err == nil {
|
||||
_ = h.rdb.Set(ctx, cacheKey, b, 30*time.Second).Err()
|
||||
}
|
||||
resp.OK(c, ov)
|
||||
}
|
||||
|
||||
func (h *Handlers) buildDashboard(ctx context.Context) (dashboardOverview, error) {
|
||||
var out dashboardOverview
|
||||
|
||||
if err := h.db.WithContext(ctx).Model(&models.Store{}).Count(&out.TotalStores).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := h.db.WithContext(ctx).Model(&models.Review{}).Count(&out.TotalReviews).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := h.db.WithContext(ctx).Model(&models.Category{}).Count(&out.TotalCategories).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Table("stores").
|
||||
Select("categories.id as category_id, categories.name as name, count(stores.id) as count").
|
||||
Joins("left join categories on categories.id = stores.category_id").
|
||||
Where("stores.deleted_at is null").
|
||||
Group("categories.id, categories.name").
|
||||
Order("count desc").
|
||||
Scan(&out.CategoryDist).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Table("stores").
|
||||
Select("date(created_at) as date, count(id) as count").
|
||||
Where("created_at >= date_sub(curdate(), interval 6 day) and deleted_at is null").
|
||||
Group("date(created_at)").
|
||||
Order("date asc").
|
||||
Scan(&out.StoresLast7Days).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Table("reviews").
|
||||
Select("stores.id as store_id, stores.name as name, avg(reviews.rating) as avg, count(reviews.id) as count").
|
||||
Joins("left join stores on stores.id = reviews.store_id").
|
||||
Where("reviews.status = 'approved' and reviews.deleted_at is null and stores.deleted_at is null").
|
||||
Group("stores.id, stores.name").
|
||||
Having("count(reviews.id) >= ?", 3).
|
||||
Order("avg desc").
|
||||
Limit(5).
|
||||
Scan(&out.TopRatedStores).Error; err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
22
backend/internal/handlers/handlers.go
Normal file
22
backend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
cfg config.Config
|
||||
log *zap.Logger
|
||||
db *gorm.DB
|
||||
rdb *redis.Client
|
||||
st *settings.Store
|
||||
}
|
||||
|
||||
func New(cfg config.Config, log *zap.Logger, db *gorm.DB, rdb *redis.Client, st *settings.Store) *Handlers {
|
||||
return &Handlers{cfg: cfg, log: log, db: db, rdb: rdb, st: st}
|
||||
}
|
||||
182
backend/internal/handlers/merchant_apply.go
Normal file
182
backend/internal/handlers/merchant_apply.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type merchantApplyReq struct {
|
||||
StoreName string `json:"store_name" binding:"required"`
|
||||
CategoryID uint `json:"category_id" binding:"required"`
|
||||
Address string `json:"address" binding:"required"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lng *float64 `json:"lng"`
|
||||
OpenHours string `json:"open_hours"`
|
||||
Phone string `json:"phone"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
Description string `json:"description"`
|
||||
|
||||
ContactName string `json:"contact_name" binding:"required"`
|
||||
ContactPhone string `json:"contact_phone" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) MerchantApply(c *gin.Context) {
|
||||
var req merchantApplyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
imgs, _ := json.Marshal(req.ImageURLs)
|
||||
app := models.MerchantApplication{
|
||||
StoreName: req.StoreName,
|
||||
CategoryID: req.CategoryID,
|
||||
Address: req.Address,
|
||||
Lat: req.Lat,
|
||||
Lng: req.Lng,
|
||||
OpenHours: req.OpenHours,
|
||||
Phone: req.Phone,
|
||||
CoverURL: req.CoverURL,
|
||||
ImageURLs: imgs,
|
||||
Description: req.Description,
|
||||
ContactName: req.ContactName,
|
||||
ContactPhone: req.ContactPhone,
|
||||
Status: "pending",
|
||||
RejectReason: "",
|
||||
ReviewedAt: nil,
|
||||
ReviewerID: nil,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&app).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "submit failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"id": app.ID, "status": app.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminMerchantApplyList(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.MerchantApplication{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.MerchantApplication
|
||||
if err := q.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type applyReviewReq struct {
|
||||
Action string `json:"action" binding:"required"` // approve/reject
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminMerchantApplyReview(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req applyReviewReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
var app models.MerchantApplication
|
||||
if err := h.db.First(&app, uint(id64)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if app.Status != "pending" {
|
||||
resp.Fail(c, http.StatusBadRequest, "already reviewed")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
adminID := c.GetUint("admin_id")
|
||||
|
||||
if req.Action == "approve" {
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
// create store
|
||||
store := models.Store{
|
||||
Name: app.StoreName,
|
||||
CategoryID: app.CategoryID,
|
||||
Address: app.Address,
|
||||
Lat: app.Lat,
|
||||
Lng: app.Lng,
|
||||
OpenHours: app.OpenHours,
|
||||
Phone: app.Phone,
|
||||
CoverURL: app.CoverURL,
|
||||
Description: app.Description,
|
||||
Status: "active",
|
||||
}
|
||||
if err := tx.Create(&store).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// images
|
||||
var urls []string
|
||||
_ = json.Unmarshal([]byte(app.ImageURLs), &urls)
|
||||
for i, u := range urls {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Create(&models.StoreImage{StoreID: store.ID, URL: u, SortOrder: i}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Model(&models.MerchantApplication{}).Where("id = ?", app.ID).Updates(map[string]any{
|
||||
"status": "approved",
|
||||
"reject_reason": "",
|
||||
"reviewed_at": &now,
|
||||
"reviewer_id": &adminID,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "approve failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"approved": true})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Action == "reject" {
|
||||
if err := h.db.Model(&models.MerchantApplication{}).Where("id = ?", app.ID).Updates(map[string]any{
|
||||
"status": "rejected",
|
||||
"reject_reason": req.RejectReason,
|
||||
"reviewed_at": &now,
|
||||
"reviewer_id": &adminID,
|
||||
}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "reject failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"rejected": true})
|
||||
return
|
||||
}
|
||||
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid action")
|
||||
}
|
||||
23
backend/internal/handlers/pagination.go
Normal file
23
backend/internal/handlers/pagination.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package handlers
|
||||
|
||||
import "strconv"
|
||||
|
||||
func parsePage(pageStr, sizeStr string) (int, int) {
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
size, _ := strconv.Atoi(sizeStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func calcTotalPage(total int64, pageSize int) int64 {
|
||||
if pageSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (total + int64(pageSize) - 1) / int64(pageSize)
|
||||
}
|
||||
|
||||
193
backend/internal/handlers/public_read.go
Normal file
193
backend/internal/handlers/public_read.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PublicCategoryList returns enabled categories for public clients (still requires X-API-Key).
|
||||
func (h *Handlers) PublicCategoryList(c *gin.Context) {
|
||||
var items []models.Category
|
||||
if err := h.db.
|
||||
Where("enabled = ?", true).
|
||||
Order("sort_order desc, id desc").
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
resp.OK(c, items)
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreList(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
categoryID, _ := strconv.ParseUint(c.Query("category_id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Store{}).Where("status = ?", "active")
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
if categoryID > 0 {
|
||||
q = q.Where("category_id = ?", uint(categoryID))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Store
|
||||
if err := q.Preload("Category").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreGet(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var item models.Store
|
||||
if err := h.db.
|
||||
Preload("Category").
|
||||
Preload("Images", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
Preload("Dishes", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
Where("status = ?", "active").
|
||||
First(&item, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreReviews(c *gin.Context) {
|
||||
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Review{}).
|
||||
Where("store_id = ? and status = ?", uint(storeID64), "approved")
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Review
|
||||
if err := q.Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreRanking(c *gin.Context) {
|
||||
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
if by == "" {
|
||||
by = "hotness"
|
||||
}
|
||||
|
||||
order := "store_metrics.score desc"
|
||||
switch by {
|
||||
case "likes":
|
||||
order = "store_metrics.likes_count desc"
|
||||
case "search":
|
||||
order = "store_metrics.search_count desc"
|
||||
case "reviews":
|
||||
order = "store_metrics.reviews_count desc"
|
||||
}
|
||||
|
||||
type row struct {
|
||||
StoreID uint `json:"store_id"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"category_id"`
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
SearchCount int64 `json:"search_count"`
|
||||
ReviewsCount int64 `json:"reviews_count"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
var total int64
|
||||
_ = h.db.Table("store_metrics").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null and stores.status = 'active'").
|
||||
Count(&total).Error
|
||||
|
||||
var out []row
|
||||
if err := h.db.Table("store_metrics").
|
||||
Select("stores.id as store_id, stores.name, stores.category_id, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
|
||||
Joins("left join stores on stores.id = store_metrics.store_id").
|
||||
Where("stores.deleted_at is null and stores.status = 'active'").
|
||||
Order(order + ", stores.id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Scan(&out).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, out, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// UserMyReviews lists the current user's submitted reviews.
|
||||
func (h *Handlers) UserMyReviews(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Review{}).Where("user_id = ?", userID)
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Review
|
||||
if err := q.Preload("Store").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
194
backend/internal/handlers/public_store.go
Normal file
194
backend/internal/handlers/public_store.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (h *Handlers) PublicStoreSearch(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Store{}).Where("status = ?", "active")
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Store
|
||||
if err := q.Preload("Category").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
// search heat: increment top 5 results
|
||||
if keyword != "" {
|
||||
for i := 0; i < len(items) && i < 5; i++ {
|
||||
_ = h.bumpSearchCount(items[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) bumpSearchCount(storeID uint) error {
|
||||
return h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var m models.StoreMetric
|
||||
err := tx.Where("store_id = ?", storeID).First(&m).Error
|
||||
if err == nil {
|
||||
return tx.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
|
||||
Updates(map[string]any{"search_count": gorm.Expr("search_count + 1"), "updated_at": time.Now()}).Error
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.StoreMetric{StoreID: storeID, SearchCount: 1, UpdatedAt: time.Now()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) PublicStoreHotRank(c *gin.Context) {
|
||||
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if by == "" {
|
||||
by = "hotness"
|
||||
}
|
||||
|
||||
order := "store_metrics.score desc"
|
||||
switch by {
|
||||
case "likes":
|
||||
order = "store_metrics.likes_count desc"
|
||||
case "search":
|
||||
order = "store_metrics.search_count desc"
|
||||
case "reviews":
|
||||
order = "store_metrics.reviews_count desc"
|
||||
}
|
||||
|
||||
type row struct {
|
||||
models.Store
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
SearchCount int64 `json:"search_count"`
|
||||
ReviewsCount int64 `json:"reviews_count"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
var out []row
|
||||
err := h.db.Table("stores").
|
||||
Select("stores.*, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
|
||||
Joins("left join store_metrics on store_metrics.store_id = stores.id").
|
||||
Where("stores.status = 'active' and stores.deleted_at is null").
|
||||
Order(order + ", stores.id desc").
|
||||
Limit(limit).
|
||||
Scan(&out).Error
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
resp.OK(c, out)
|
||||
}
|
||||
|
||||
type reviewCreateReq struct {
|
||||
Rating int `json:"rating" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
RecommendDishes []map[string]any `json:"recommend_dishes"`
|
||||
}
|
||||
|
||||
func (h *Handlers) UserCreateReview(c *gin.Context) {
|
||||
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
userID := c.GetUint("user_id")
|
||||
username := c.GetString("user_username")
|
||||
|
||||
var req reviewCreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Rating < 1 || req.Rating > 5 {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
imgs, _ := json.Marshal(req.ImageURLs)
|
||||
recd, _ := json.Marshal(req.RecommendDishes)
|
||||
|
||||
r := models.Review{
|
||||
StoreID: uint(storeID64),
|
||||
UserID: &userID,
|
||||
UserName: username,
|
||||
Rating: req.Rating,
|
||||
Content: req.Content,
|
||||
ImageURLs: datatypes.JSON(imgs),
|
||||
RecommendDishes: datatypes.JSON(recd),
|
||||
Status: "pending",
|
||||
}
|
||||
if err := h.db.Create(&r).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"id": r.ID, "status": r.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) UserToggleStoreLike(c *gin.Context) {
|
||||
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
storeID := uint(storeID64)
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var existed models.StoreLike
|
||||
err := h.db.Where("store_id = ? and user_id = ?", storeID, userID).First(&existed).Error
|
||||
if err == nil {
|
||||
if err := h.db.Where("id = ?", existed.ID).Delete(&models.StoreLike{}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "unlike failed")
|
||||
return
|
||||
}
|
||||
_ = h.db.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
|
||||
Updates(map[string]any{"likes_count": gorm.Expr("greatest(likes_count-1,0)"), "updated_at": time.Now()}).Error
|
||||
resp.OK(c, gin.H{"liked": false})
|
||||
return
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Create(&models.StoreLike{StoreID: storeID, UserID: userID, CreatedAt: time.Now()}).Error; err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "like failed")
|
||||
return
|
||||
}
|
||||
_ = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var m models.StoreMetric
|
||||
e := tx.Where("store_id = ?", storeID).First(&m).Error
|
||||
if e == nil {
|
||||
return tx.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
|
||||
Updates(map[string]any{"likes_count": gorm.Expr("likes_count + 1"), "updated_at": time.Now()}).Error
|
||||
}
|
||||
if e != nil && e != gorm.ErrRecordNotFound {
|
||||
return e
|
||||
}
|
||||
return tx.Create(&models.StoreMetric{StoreID: storeID, LikesCount: 1, UpdatedAt: time.Now()}).Error
|
||||
})
|
||||
|
||||
resp.OK(c, gin.H{"liked": true})
|
||||
}
|
||||
|
||||
80
backend/internal/handlers/reviews.go
Normal file
80
backend/internal/handlers/reviews.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) ReviewList(c *gin.Context) {
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
storeID, _ := strconv.ParseUint(c.Query("store_id"), 10, 64)
|
||||
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Review{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if storeID > 0 {
|
||||
q = q.Where("store_id = ?", uint(storeID))
|
||||
}
|
||||
if userID > 0 {
|
||||
q = q.Where("user_id = ?", uint(userID))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Review
|
||||
if err := q.Preload("Store").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) ReviewUpdateStatus(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req statusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status != "pending" && req.Status != "approved" && req.Status != "blocked" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.Review{}).Where("id = ?", uint(id)).Update("status", req.Status).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func (h *Handlers) ReviewDelete(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.db.Delete(&models.Review{}, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
45
backend/internal/handlers/settings.go
Normal file
45
backend/internal/handlers/settings.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type corsUpdateReq struct {
|
||||
Origins []string `json:"origins" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) SettingsGetCORS(c *gin.Context) {
|
||||
resp.OK(c, gin.H{"origins": h.st.CORSAllowOrigins()})
|
||||
}
|
||||
|
||||
func (h *Handlers) SettingsUpdateCORS(c *gin.Context) {
|
||||
var req corsUpdateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
var cleaned []string
|
||||
for _, o := range req.Origins {
|
||||
v := strings.TrimSpace(o)
|
||||
if v != "" {
|
||||
cleaned = append(cleaned, v)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
resp.Fail(c, http.StatusBadRequest, "origins required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := settings.UpsertCORSAllowOrigins(h.db, cleaned); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
h.st.SetCORSAllowOrigins(cleaned)
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
261
backend/internal/handlers/stores.go
Normal file
261
backend/internal/handlers/stores.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type storeImageReq struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type dishReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type storeUpsertReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
CategoryID uint `json:"category_id" binding:"required"`
|
||||
Address string `json:"address" binding:"required"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lng *float64 `json:"lng"`
|
||||
OpenHours string `json:"open_hours"`
|
||||
Phone string `json:"phone"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Images []storeImageReq `json:"images"`
|
||||
Dishes []dishReq `json:"dishes"`
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreList(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
categoryID, _ := strconv.ParseUint(c.Query("category_id"), 10, 64)
|
||||
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
|
||||
|
||||
q := h.db.Model(&models.Store{})
|
||||
if keyword != "" {
|
||||
q = q.Where("name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if categoryID > 0 {
|
||||
q = q.Where("category_id = ?", uint(categoryID))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Store
|
||||
if err := q.Preload("Category").
|
||||
Order("id desc").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&items).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
resp.OKMeta(c, items, gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": calcTotalPage(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreGet(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var item models.Store
|
||||
if err := h.db.
|
||||
Preload("Images", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
Preload("Dishes", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
|
||||
First(&item, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreCreate(c *gin.Context) {
|
||||
var req storeUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "active"
|
||||
}
|
||||
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
item := models.Store{
|
||||
Name: req.Name,
|
||||
CategoryID: req.CategoryID,
|
||||
Address: req.Address,
|
||||
Lat: req.Lat,
|
||||
Lng: req.Lng,
|
||||
OpenHours: req.OpenHours,
|
||||
Phone: req.Phone,
|
||||
CoverURL: req.CoverURL,
|
||||
Description: req.Description,
|
||||
Status: req.Status,
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreImages(tx, item.ID, req.Images); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreDishes(tx, item.ID, req.Dishes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var out models.Store
|
||||
if err := tx.Preload("Images").Preload("Dishes").First(&out, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
resp.OK(c, out)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "create failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreUpdate(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req storeUpsertReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var item models.Store
|
||||
if err := tx.First(&item, uint(id)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if req.Status == "" {
|
||||
req.Status = item.Status
|
||||
}
|
||||
item.Name = req.Name
|
||||
item.CategoryID = req.CategoryID
|
||||
item.Address = req.Address
|
||||
if req.Lat != nil {
|
||||
item.Lat = req.Lat
|
||||
}
|
||||
if req.Lng != nil {
|
||||
item.Lng = req.Lng
|
||||
}
|
||||
item.OpenHours = req.OpenHours
|
||||
item.Phone = req.Phone
|
||||
item.CoverURL = req.CoverURL
|
||||
item.Description = req.Description
|
||||
item.Status = req.Status
|
||||
|
||||
if err := tx.Save(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Where("store_id = ?", item.ID).Delete(&models.StoreImage{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("store_id = ?", item.ID).Delete(&models.SignatureDish{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreImages(tx, item.ID, req.Images); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertStoreDishes(tx, item.ID, req.Dishes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var out models.Store
|
||||
if err := tx.Preload("Images").Preload("Dishes").First(&out, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
resp.OK(c, out)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "update failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type statusReq struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreUpdateStatus(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req statusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
if req.Status != "active" && req.Status != "inactive" {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&models.Store{}).Where("id = ?", uint(id)).Update("status", req.Status).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func (h *Handlers) StoreDelete(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.db.Delete(&models.Store{}, uint(id)).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func upsertStoreImages(tx *gorm.DB, storeID uint, imgs []storeImageReq) error {
|
||||
for _, img := range imgs {
|
||||
if err := tx.Create(&models.StoreImage{
|
||||
StoreID: storeID,
|
||||
URL: img.URL,
|
||||
SortOrder: img.SortOrder,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertStoreDishes(tx *gorm.DB, storeID uint, dishes []dishReq) error {
|
||||
for _, d := range dishes {
|
||||
if err := tx.Create(&models.SignatureDish{
|
||||
StoreID: storeID,
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
ImageURL: d.ImageURL,
|
||||
SortOrder: d.SortOrder,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
82
backend/internal/handlers/upload.go
Normal file
82
backend/internal/handlers/upload.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) Upload(c *gin.Context) {
|
||||
url, ok := h.handleUpload(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// UserUpload allows logged-in users to upload images for reviews.
|
||||
func (h *Handlers) UserUpload(c *gin.Context) {
|
||||
url, ok := h.handleUpload(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handlers) handleUpload(c *gin.Context) (string, bool) {
|
||||
maxMB := h.cfg.MaxUploadMB
|
||||
if maxMB <= 0 {
|
||||
maxMB = 10
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMB*1024*1024)
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "missing file")
|
||||
return "", false
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
||||
default:
|
||||
resp.Fail(c, http.StatusBadRequest, "unsupported file type")
|
||||
return "", false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dir := filepath.Join(h.cfg.UploadDir, strconv.Itoa(now.Year()), now.Format("01"))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "mkdir failed")
|
||||
return "", false
|
||||
}
|
||||
|
||||
name := randomHex(16) + ext
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "save failed")
|
||||
return "", false
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(filepath.ToSlash(dst), ".")
|
||||
if !strings.HasPrefix(rel, "/") {
|
||||
rel = "/" + rel
|
||||
}
|
||||
url := strings.TrimRight(h.cfg.PublicBaseURL, "/") + rel
|
||||
return url, true
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
125
backend/internal/handlers/user_auth.go
Normal file
125
backend/internal/handlers/user_auth.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
mysqlerr "github.com/go-sql-driver/mysql"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type userRegisterReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) UserRegister(c *gin.Context) {
|
||||
var req userRegisterReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload: "+safeErrMsg(err))
|
||||
return
|
||||
}
|
||||
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
u := models.User{Username: req.Username, PasswordHash: string(hash), Status: "active"}
|
||||
if err := h.db.Create(&u).Error; err != nil {
|
||||
if isDuplicateErr(err) {
|
||||
resp.Fail(c, http.StatusConflict, "username already exists")
|
||||
return
|
||||
}
|
||||
resp.Fail(c, http.StatusBadRequest, "register failed: "+safeErrMsg(err))
|
||||
return
|
||||
}
|
||||
token, _ := auth.NewUserToken(h.cfg.JWTSecret, u.ID, u.Username, 30*24*time.Hour)
|
||||
resp.OK(c, gin.H{"token": token, "user": u})
|
||||
}
|
||||
|
||||
type userLoginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) UserLogin(c *gin.Context) {
|
||||
var req userLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid payload: "+safeErrMsg(err))
|
||||
return
|
||||
}
|
||||
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
|
||||
return
|
||||
}
|
||||
var u models.User
|
||||
if err := h.db.Where("username = ?", req.Username).First(&u).Error; err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
if u.Status != "active" {
|
||||
resp.Fail(c, http.StatusUnauthorized, "account disabled")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(req.Password)); err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
token, _ := auth.NewUserToken(h.cfg.JWTSecret, u.ID, u.Username, 30*24*time.Hour)
|
||||
resp.OK(c, gin.H{"token": token, "user": gin.H{"id": u.ID, "username": u.Username}})
|
||||
}
|
||||
|
||||
func isDuplicateErr(err error) bool {
|
||||
var me *mysqlerr.MySQLError
|
||||
if strings.Contains(err.Error(), "Duplicate entry") {
|
||||
return true
|
||||
}
|
||||
if errors.As(err, &me) {
|
||||
return me.Number == 1062
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func safeErrMsg(err error) string {
|
||||
s := err.Error()
|
||||
if len(s) > 200 {
|
||||
return s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *Handlers) UserMe(c *gin.Context) {
|
||||
id := c.GetUint("user_id")
|
||||
var u models.User
|
||||
if err := h.db.Select("id", "username", "status", "created_at", "updated_at").Where("id = ?", id).First(&u).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "db error")
|
||||
return
|
||||
}
|
||||
resp.OK(c, u)
|
||||
}
|
||||
|
||||
// 预留:抖音登录(需要对接抖音服务端换取 openid/session_key)
|
||||
type douyinLoginReq struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handlers) DouyinLogin(c *gin.Context) {
|
||||
_ = douyinLoginReq{}
|
||||
resp.Fail(c, http.StatusNotImplemented, "douyin login not implemented yet")
|
||||
}
|
||||
125
backend/internal/httpx/router.go
Normal file
125
backend/internal/httpx/router.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/handlers"
|
||||
"0451meishiditu/backend/internal/middleware"
|
||||
"0451meishiditu/backend/internal/settings"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewRouter(cfg config.Config, log *zap.Logger, db *gorm.DB, rdb *redis.Client, st *settings.Store) *gin.Engine {
|
||||
if cfg.AppEnv == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.Recover(log))
|
||||
r.Use(middleware.RequestID())
|
||||
r.Use(middleware.AccessLog(log))
|
||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOriginFunc: func(origin string) bool { return st.CORSAllowOrigin(origin) },
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Authorization", "Content-Type", "X-API-Key", "X-Request-Id"},
|
||||
ExposeHeaders: []string{"X-Request-Id"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.Static("/static", "./static")
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
h := handlers.New(cfg, log, db, rdb, st)
|
||||
|
||||
api := r.Group("/api")
|
||||
api.Use(middleware.APIKey(cfg, db))
|
||||
|
||||
public := api.Group("")
|
||||
public.GET("/captcha/new", h.CaptchaNew)
|
||||
public.GET("/categories", h.PublicCategoryList)
|
||||
public.GET("/stores", h.PublicStoreList)
|
||||
public.GET("/stores/:id", h.PublicStoreGet)
|
||||
public.GET("/stores/:id/reviews", h.PublicStoreReviews)
|
||||
public.GET("/rankings/stores", h.PublicStoreRanking)
|
||||
public.POST("/merchant/apply", h.MerchantApply)
|
||||
public.GET("/stores/search", h.PublicStoreSearch)
|
||||
public.GET("/stores/hot", h.PublicStoreHotRank)
|
||||
|
||||
user := api.Group("/user")
|
||||
user.POST("/register", h.UserRegister)
|
||||
user.POST("/login", h.UserLogin)
|
||||
user.POST("/douyin/login", h.DouyinLogin)
|
||||
userAuth := user.Group("")
|
||||
userAuth.Use(middleware.UserJWT(cfg))
|
||||
userAuth.GET("/me", h.UserMe)
|
||||
userAuth.GET("/reviews", h.UserMyReviews)
|
||||
userAuth.POST("/upload", h.UserUpload)
|
||||
userAuth.POST("/stores/:id/reviews", h.UserCreateReview)
|
||||
userAuth.POST("/stores/:id/like", h.UserToggleStoreLike)
|
||||
|
||||
admin := api.Group("/admin")
|
||||
admin.POST("/login", h.AdminLogin)
|
||||
|
||||
adminAuth := admin.Group("")
|
||||
adminAuth.Use(middleware.AdminJWT(cfg))
|
||||
|
||||
adminAuth.GET("/me", h.AdminMe)
|
||||
|
||||
adminAuth.GET("/dashboard/overview", h.DashboardOverview)
|
||||
|
||||
adminAuth.GET("/apikeys", h.APIKeyList)
|
||||
adminAuth.POST("/apikeys", h.APIKeyCreate)
|
||||
adminAuth.PATCH("/apikeys/:id/revoke", h.APIKeyRevoke)
|
||||
|
||||
adminAuth.GET("/settings/cors", h.SettingsGetCORS)
|
||||
adminAuth.PUT("/settings/cors", h.SettingsUpdateCORS)
|
||||
|
||||
adminAuth.GET("/merchant/applications", h.AdminMerchantApplyList)
|
||||
adminAuth.PATCH("/merchant/applications/:id/review", h.AdminMerchantApplyReview)
|
||||
|
||||
adminAuth.GET("/rankings/stores", h.AdminStoreRanking)
|
||||
adminAuth.POST("/rankings/stores/recalc", h.AdminRecalcStoreScore)
|
||||
|
||||
adminAuth.GET("/admins", h.AdminListAdmins)
|
||||
adminAuth.POST("/admins", h.AdminCreateAdmin)
|
||||
adminAuth.PATCH("/admins/:id/password", h.AdminUpdateAdminPassword)
|
||||
adminAuth.PATCH("/admins/:id/enabled", h.AdminUpdateAdminEnabled)
|
||||
|
||||
adminAuth.GET("/users", h.AdminUserList)
|
||||
adminAuth.GET("/users/:id", h.AdminUserGet)
|
||||
adminAuth.PATCH("/users/:id/status", h.AdminUserUpdateStatus)
|
||||
|
||||
adminAuth.GET("/categories", h.CategoryList)
|
||||
adminAuth.POST("/categories", h.CategoryCreate)
|
||||
adminAuth.PUT("/categories/:id", h.CategoryUpdate)
|
||||
adminAuth.DELETE("/categories/:id", h.CategoryDelete)
|
||||
|
||||
adminAuth.GET("/stores", h.StoreList)
|
||||
adminAuth.GET("/stores/:id", h.StoreGet)
|
||||
adminAuth.POST("/stores", h.StoreCreate)
|
||||
adminAuth.PUT("/stores/:id", h.StoreUpdate)
|
||||
adminAuth.PATCH("/stores/:id/status", h.StoreUpdateStatus)
|
||||
adminAuth.DELETE("/stores/:id", h.StoreDelete)
|
||||
|
||||
adminAuth.GET("/reviews", h.ReviewList)
|
||||
adminAuth.PATCH("/reviews/:id/status", h.ReviewUpdateStatus)
|
||||
adminAuth.DELETE("/reviews/:id", h.ReviewDelete)
|
||||
|
||||
adminAuth.POST("/upload", h.Upload)
|
||||
|
||||
return r
|
||||
}
|
||||
17
backend/internal/logger/logger.go
Normal file
17
backend/internal/logger/logger.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package logger
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
func New(env string) *zap.Logger {
|
||||
if env == "prod" {
|
||||
l, _ := zap.NewProduction()
|
||||
return l
|
||||
}
|
||||
l, _ := zap.NewDevelopment()
|
||||
return l
|
||||
}
|
||||
|
||||
func Err(err error) zap.Field { return zap.Error(err) }
|
||||
func Str(k, v string) zap.Field { return zap.String(k, v) }
|
||||
func Uint(k string, v uint) zap.Field { return zap.Uint(k, v) }
|
||||
|
||||
25
backend/internal/middleware/access_log.go
Normal file
25
backend/internal/middleware/access_log.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func AccessLog(log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
log.Info("http",
|
||||
logger.Str("method", c.Request.Method),
|
||||
logger.Str("path", c.Request.URL.Path),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("latency", time.Since(start)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
45
backend/internal/middleware/apikey.go
Normal file
45
backend/internal/middleware/apikey.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func APIKey(cfg config.Config, db *gorm.DB) gin.HandlerFunc {
|
||||
expectedHash := cfg.ExpectedAPIKeyHash()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.GetHeader("X-API-Key"))
|
||||
if key == "" {
|
||||
resp.Fail(c, http.StatusUnauthorized, "missing api key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
gotHash := hex.EncodeToString(sum[:])
|
||||
if subtle.ConstantTimeCompare([]byte(gotHash), []byte(expectedHash)) != 1 {
|
||||
// fallback to DB stored keys
|
||||
var ak models.APIKey
|
||||
err := db.Where("hash_sha256 = ? and status = 'active'", gotHash).First(&ak).Error
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid api key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_ = db.Model(&models.APIKey{}).Where("id = ?", ak.ID).UpdateColumn("last_used_at", &now).Error
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
34
backend/internal/middleware/jwt.go
Normal file
34
backend/internal/middleware/jwt.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminJWT(cfg config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
resp.Fail(c, http.StatusUnauthorized, "missing token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(h[len("bearer "):])
|
||||
claims, err := auth.ParseAdminToken(cfg.JWTSecret, token)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("admin_id", claims.AdminID)
|
||||
c.Set("admin_username", claims.Username)
|
||||
c.Set("admin_role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
27
backend/internal/middleware/recover.go
Normal file
27
backend/internal/middleware/recover.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"0451meishiditu/backend/internal/logger"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Recover(log *zap.Logger) gin.HandlerFunc {
|
||||
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Error("panic recovered", logger.Str("recovered", toString(recovered)))
|
||||
resp.Fail(c, http.StatusInternalServerError, "internal error")
|
||||
})
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
return "panic"
|
||||
}
|
||||
}
|
||||
19
backend/internal/middleware/request_id.go
Normal file
19
backend/internal/middleware/request_id.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := c.Request.Header.Get("X-Request-Id")
|
||||
if rid == "" {
|
||||
rid = uuid.NewString()
|
||||
}
|
||||
c.Writer.Header().Set("X-Request-Id", rid)
|
||||
c.Set("request_id", rid)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
34
backend/internal/middleware/user_jwt.go
Normal file
34
backend/internal/middleware/user_jwt.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"0451meishiditu/backend/internal/auth"
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UserJWT(cfg config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
resp.Fail(c, http.StatusUnauthorized, "missing token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(h[len("bearer "):])
|
||||
claims, err := auth.ParseUserToken(cfg.JWTSecret, token)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user_username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
37
backend/internal/migrate/migrate.go
Normal file
37
backend/internal/migrate/migrate.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"0451meishiditu/backend/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(
|
||||
&models.AdminUser{},
|
||||
&models.SystemSetting{},
|
||||
&models.APIKey{},
|
||||
&models.Category{},
|
||||
&models.MerchantApplication{},
|
||||
&models.User{},
|
||||
&models.Store{},
|
||||
&models.StoreMetric{},
|
||||
&models.StoreLike{},
|
||||
&models.StoreImage{},
|
||||
&models.SignatureDish{},
|
||||
&models.Review{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hotfix: allow users.douyin_open_id to be NULL to avoid unique-index conflicts on empty string.
|
||||
_ = db.Exec("ALTER TABLE users MODIFY douyin_open_id varchar(128) NULL").Error
|
||||
_ = db.Exec("UPDATE users SET douyin_open_id = NULL WHERE douyin_open_id = ''").Error
|
||||
|
||||
// Optional: lat/lng are deprecated; allow NULL for existing schemas.
|
||||
_ = db.Exec("ALTER TABLE stores MODIFY lat double NULL").Error
|
||||
_ = db.Exec("ALTER TABLE stores MODIFY lng double NULL").Error
|
||||
_ = db.Exec("ALTER TABLE merchant_applications MODIFY lat double NULL").Error
|
||||
_ = db.Exec("ALTER TABLE merchant_applications MODIFY lng double NULL").Error
|
||||
return nil
|
||||
}
|
||||
168
backend/internal/models/models.go
Normal file
168
backend/internal/models/models.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminUser struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Role string `gorm:"size:32;not null;default:'admin'" json:"role"`
|
||||
Enabled bool `gorm:"not null;default:true;index" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:64;uniqueIndex;not null" json:"name"`
|
||||
IconURL string `gorm:"size:512" json:"icon_url"`
|
||||
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
|
||||
Enabled bool `gorm:"not null;default:true;index" json:"enabled"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null;index" json:"name"`
|
||||
CategoryID uint `gorm:"not null;index" json:"category_id"`
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
|
||||
Address string `gorm:"size:255;not null" json:"address"`
|
||||
Lat *float64 `gorm:"index" json:"-"`
|
||||
Lng *float64 `gorm:"index" json:"-"`
|
||||
OpenHours string `gorm:"size:128" json:"open_hours"`
|
||||
Phone string `gorm:"size:64" json:"phone"`
|
||||
CoverURL string `gorm:"size:512" json:"cover_url"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"` // active/inactive
|
||||
Images []StoreImage `gorm:"foreignKey:StoreID" json:"images"`
|
||||
Dishes []SignatureDish `gorm:"foreignKey:StoreID" json:"dishes"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type StoreImage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index" json:"store_id"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type SignatureDish struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index" json:"store_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
ImageURL string `gorm:"size:512" json:"image_url"`
|
||||
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type Review struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index" json:"store_id"`
|
||||
Store Store `gorm:"foreignKey:StoreID" json:"store"`
|
||||
UserID *uint `gorm:"index" json:"user_id"`
|
||||
UserName string `gorm:"size:64" json:"user_name"`
|
||||
Rating int `gorm:"not null;index" json:"rating"` // 1-5
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
ImageURLs datatypes.JSON `gorm:"type:json" json:"image_urls"`
|
||||
// RecommendDishes: [{"name":"锅包肉","image_url":"","like":true}]
|
||||
RecommendDishes datatypes.JSON `gorm:"type:json" json:"recommend_dishes"`
|
||||
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending/approved/blocked
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type MerchantApplication struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
StoreName string `gorm:"size:128;not null;index" json:"store_name"`
|
||||
CategoryID uint `gorm:"not null;index" json:"category_id"`
|
||||
Address string `gorm:"size:255;not null" json:"address"`
|
||||
Lat *float64 `gorm:"index" json:"-"`
|
||||
Lng *float64 `gorm:"index" json:"-"`
|
||||
OpenHours string `gorm:"size:128" json:"open_hours"`
|
||||
Phone string `gorm:"size:64" json:"phone"`
|
||||
CoverURL string `gorm:"size:512" json:"cover_url"`
|
||||
ImageURLs datatypes.JSON `gorm:"type:json" json:"image_urls"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
|
||||
ContactName string `gorm:"size:64" json:"contact_name"`
|
||||
ContactPhone string `gorm:"size:64" json:"contact_phone"`
|
||||
|
||||
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending/approved/rejected
|
||||
RejectReason string `gorm:"type:text" json:"reject_reason"`
|
||||
ReviewedAt *time.Time `gorm:"index" json:"reviewed_at"`
|
||||
ReviewerID *uint `gorm:"index" json:"reviewer_id"`
|
||||
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
|
||||
DouyinOpenID *string `gorm:"size:128;uniqueIndex" json:"douyin_openid,omitempty"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"` // active/disabled
|
||||
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type StoreLike struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;index;uniqueIndex:uk_store_user" json:"store_id"`
|
||||
UserID uint `gorm:"not null;index;uniqueIndex:uk_store_user" json:"user_id"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
type StoreMetric struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
StoreID uint `gorm:"not null;uniqueIndex" json:"store_id"`
|
||||
LikesCount int64 `gorm:"not null;default:0;index" json:"likes_count"`
|
||||
SearchCount int64 `gorm:"not null;default:0;index" json:"search_count"`
|
||||
ReviewsCount int64 `gorm:"not null;default:0;index" json:"reviews_count"`
|
||||
Score float64 `gorm:"not null;default:0;index" json:"score"`
|
||||
UpdatedAt time.Time `gorm:"index" json:"updated_at"`
|
||||
}
|
||||
|
||||
type SystemSetting struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"size:128;uniqueIndex;not null" json:"key"`
|
||||
Value string `gorm:"type:text;not null" json:"value"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Prefix string `gorm:"size:16;index;not null" json:"prefix"`
|
||||
HashSHA256 string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||
Status string `gorm:"size:16;index;not null;default:'active'" json:"status"` // active/revoked
|
||||
LastUsedAt *time.Time `gorm:"index" json:"last_used_at"`
|
||||
RevokedAt *time.Time `gorm:"index" json:"revoked_at"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
23
backend/internal/redisx/redis.go
Normal file
23
backend/internal/redisx/redis.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package redisx
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func New(cfg config.Config) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: cfg.RedisAddr,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
DialTimeout: 3 * time.Second,
|
||||
ReadTimeout: 2 * time.Second,
|
||||
WriteTimeout: 2 * time.Second,
|
||||
PoolSize: 20,
|
||||
MinIdleConns: 2,
|
||||
})
|
||||
}
|
||||
|
||||
27
backend/internal/resp/resp.go
Normal file
27
backend/internal/resp/resp.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package resp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Body struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Body{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func OKMeta(c *gin.Context, data interface{}, meta interface{}) {
|
||||
c.JSON(http.StatusOK, Body{Code: 0, Message: "ok", Data: data, Meta: meta})
|
||||
}
|
||||
|
||||
func Fail(c *gin.Context, httpStatus int, msg string) {
|
||||
c.JSON(httpStatus, Body{Code: httpStatus, Message: msg})
|
||||
}
|
||||
|
||||
36
backend/internal/seed/admin.go
Normal file
36
backend/internal/seed/admin.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func EnsureInitialAdmin(db *gorm.DB, cfg config.Config) error {
|
||||
var cnt int64
|
||||
if err := db.Model(&models.AdminUser{}).Count(&cnt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cnt > 0 {
|
||||
return nil
|
||||
}
|
||||
if cfg.AdminInitUsername == "" || cfg.AdminInitPassword == "" {
|
||||
return errors.New("ADMIN_INIT_USERNAME and ADMIN_INIT_PASSWORD are required for initial seed")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminInitPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Create(&models.AdminUser{
|
||||
Username: cfg.AdminInitUsername,
|
||||
PasswordHash: string(hash),
|
||||
Role: "admin",
|
||||
}).Error
|
||||
}
|
||||
|
||||
102
backend/internal/settings/store.go
Normal file
102
backend/internal/settings/store.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"0451meishiditu/backend/internal/config"
|
||||
"0451meishiditu/backend/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const keyCORSAllowOrigins = "cors_allow_origins"
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
origins map[string]struct{}
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, cfg config.Config) (*Store, error) {
|
||||
s := &Store{origins: map[string]struct{}{}}
|
||||
|
||||
// init from DB if exists; otherwise seed from env
|
||||
var row models.SystemSetting
|
||||
err := db.Where("`key` = ?", keyCORSAllowOrigins).First(&row).Error
|
||||
if err == nil {
|
||||
s.SetCORSAllowOrigins(parseOrigins(row.Value))
|
||||
return s, nil
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.SetCORSAllowOrigins(cfg.CORSAllowOrigins)
|
||||
_ = db.Create(&models.SystemSetting{Key: keyCORSAllowOrigins, Value: strings.Join(cfg.CORSAllowOrigins, ",")}).Error
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) CORSAllowOrigins() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]string, 0, len(s.origins))
|
||||
for o := range s.origins {
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) CORSAllowOrigin(origin string) bool {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, ok := s.origins["*"]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := s.origins[origin]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Store) SetCORSAllowOrigins(origins []string) {
|
||||
m := map[string]struct{}{}
|
||||
for _, o := range origins {
|
||||
v := strings.TrimSpace(o)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
m[v] = struct{}{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.origins = m
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func parseOrigins(raw string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
v := strings.TrimSpace(part)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func UpsertCORSAllowOrigins(db *gorm.DB, origins []string) error {
|
||||
val := strings.Join(origins, ",")
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var row models.SystemSetting
|
||||
err := tx.Where("`key` = ?", keyCORSAllowOrigins).First(&row).Error
|
||||
if err == nil {
|
||||
return tx.Model(&models.SystemSetting{}).Where("id = ?", row.ID).Update("value", val).Error
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.SystemSetting{Key: keyCORSAllowOrigins, Value: val}).Error
|
||||
})
|
||||
}
|
||||
|
||||
15
backend/scripts/entrypoint.sh
Normal file
15
backend/scripts/entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
UPLOAD_DIR="${UPLOAD_DIR:-./static/upload}"
|
||||
|
||||
case "$UPLOAD_DIR" in
|
||||
/*) abs="$UPLOAD_DIR" ;;
|
||||
*) abs="/app/$UPLOAD_DIR" ;;
|
||||
esac
|
||||
|
||||
mkdir -p "$abs"
|
||||
chown -R appuser:appuser "$abs" || true
|
||||
|
||||
exec su-exec appuser /app/server
|
||||
|
||||
112
deploy/docker-compose.full.yml
Normal file
112
deploy/docker-compose.full.yml
Normal file
@@ -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
|
||||
340
docs/API.md
Normal file
340
docs/API.md
Normal file
@@ -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 <admin_token>`
|
||||
|
||||
用户端需要登录的接口(`/api/user/**` 的部分接口)需要用户 JWT:
|
||||
- `Authorization: Bearer <user_token>`
|
||||
|
||||
默认开发环境(可在 `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": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><svg ...>XHSXY</svg>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `captcha_id` 有效期 5 分钟
|
||||
- 校验成功后删除(一次性使用)
|
||||
- `svg` 是字符串:前端可用 `v-html` 渲染,或转成 `data:image/svg+xml` 显示在 `<img>`
|
||||
|
||||
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 <user_token>`)
|
||||
|
||||
### 我的评论记录
|
||||
|
||||
`GET /api/user/reviews?page=1&page_size=20`(需要 `Authorization: Bearer <user_token>`)
|
||||
|
||||
### 提交评论(进入待审核)
|
||||
|
||||
`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 <admin_token>" `
|
||||
-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 <user_token>" `
|
||||
-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`
|
||||
1256
docs/openapi.yaml
Normal file
1256
docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
145
后端设计文档.md
Normal file
145
后端设计文档.md
Normal file
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user