哈尔滨美食地图

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

19
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

26
admin/package.json Normal file
View 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
View File

@@ -0,0 +1,4 @@
<template>
<router-view />
</template>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

17
admin/tsconfig.json Normal file
View 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
View 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
View File

@@ -0,0 +1,40 @@
# App
APP_ENV=dev
APP_PORT=8080
PUBLIC_BASE_URL=http://localhost:8080
# Admin init (首次启动会创建)
ADMIN_INIT_USERNAME=admin
ADMIN_INIT_PASSWORD=admin123456
# Auth
JWT_SECRET=please_change_me
# API Key管理后台每次请求都需要携带 X-API-Key
# 生产环境建议用 API_KEY_HASHSHA256 hex不要直接放明文 API_KEY
API_KEY=dev-api-key-change-me
# API_KEY_HASH=
# Database也可在 docker-compose 里覆盖)
DB_HOST=127.0.0.1
DB_PORT=3309
DB_NAME=mydb
DB_USER=user
DB_PASSWORD=password123
DB_PARAMS=charset=utf8mb4&parseTime=True&loc=Local
# Redis
REDIS_ADDR=127.0.0.1:6381
REDIS_PASSWORD=
REDIS_DB=0
# Upload
UPLOAD_DIR=./static/upload
MAX_UPLOAD_MB=10
# CORS管理后台域名
CORS_ALLOW_ORIGINS=http://localhost:5173
# AMap地址解析/选点,当前为预留)
AMAP_KEY=

29
backend/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM golang:1.23-alpine AS build
WORKDIR /src
RUN apk add --no-cache git ca-certificates tzdata
# 解决部分网络环境无法访问 proxy.golang.org 的问题(可在构建时覆盖)
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV GOPROXY=$GOPROXY
ENV GOSUMDB=$GOSUMDB
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata su-exec && adduser -D -H appuser
COPY --from=build /out/server /app/server
COPY --from=build /src/static /app/static
COPY --from=build /src/scripts /app/scripts
ENV APP_PORT=8080
EXPOSE 8080
ENTRYPOINT ["/app/scripts/entrypoint.sh"]

View File

@@ -0,0 +1,76 @@
package main
import (
"context"
"errors"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/db"
"0451meishiditu/backend/internal/httpx"
"0451meishiditu/backend/internal/logger"
"0451meishiditu/backend/internal/migrate"
"0451meishiditu/backend/internal/redisx"
"0451meishiditu/backend/internal/settings"
"0451meishiditu/backend/internal/seed"
)
func main() {
cfg, err := config.Load()
if err != nil {
panic(err)
}
log := logger.New(cfg.AppEnv)
defer func() { _ = log.Sync() }()
gdb, err := db.Open(cfg, log)
if err != nil {
log.Fatal("open db failed", logger.Err(err))
}
rdb := redisx.New(cfg)
if err := migrate.AutoMigrate(gdb); err != nil {
log.Fatal("auto migrate failed", logger.Err(err))
}
if err := seed.EnsureInitialAdmin(gdb, cfg); err != nil {
log.Fatal("seed admin failed", logger.Err(err))
}
st, err := settings.New(gdb, cfg)
if err != nil {
log.Fatal("settings init failed", logger.Err(err))
}
router := httpx.NewRouter(cfg, log, gdb, rdb, st)
srv := &http.Server{
Addr: ":" + cfg.AppPort,
Handler: router,
ReadTimeout: 15 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Info("server started", logger.Str("addr", srv.Addr))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal("listen failed", logger.Err(err))
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
log.Info("server stopped")
}

52
backend/go.mod Normal file
View File

@@ -0,0 +1,52 @@
module 0451meishiditu/backend
go 1.23.0
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-contrib/gzip v1.2.2
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.7.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.39.0
gorm.io/datatypes v1.2.5
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

155
backend/go.sum Normal file
View File

@@ -0,0 +1,155 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -0,0 +1,76 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type AdminClaims struct {
AdminID uint `json:"admin_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func NewAdminToken(secret string, adminID uint, username, role string, ttl time.Duration) (string, error) {
now := time.Now()
claims := AdminClaims{
AdminID: adminID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
func ParseAdminToken(secret, token string) (AdminClaims, error) {
var claims AdminClaims
parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil || !parsed.Valid {
return AdminClaims{}, errors.New("invalid token")
}
return claims, nil
}
type UserClaims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func NewUserToken(secret string, userID uint, username string, ttl time.Duration) (string, error) {
now := time.Now()
claims := UserClaims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
func ParseUserToken(secret, token string) (UserClaims, error) {
var claims UserClaims
parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil || !parsed.Valid {
return UserClaims{}, errors.New("invalid token")
}
return claims, nil
}

View File

@@ -0,0 +1,129 @@
package config
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
AppEnv string
AppPort string
PublicBaseURL string
AdminInitUsername string
AdminInitPassword string
JWTSecret string
APIKey string
APIKeyHash string
DBHost string
DBPort string
DBName string
DBUser string
DBPassword string
DBParams string
RedisAddr string
RedisPassword string
RedisDB int
UploadDir string
MaxUploadMB int64
CORSAllowOrigins []string
AMapKey string
}
func Load() (Config, error) {
_ = godotenv.Load()
cfg := Config{
AppEnv: getenv("APP_ENV", "dev"),
AppPort: getenv("APP_PORT", "8080"),
PublicBaseURL: getenv("PUBLIC_BASE_URL", "http://localhost:8080"),
AdminInitUsername: getenv("ADMIN_INIT_USERNAME", "admin"),
AdminInitPassword: getenv("ADMIN_INIT_PASSWORD", "admin123456"),
JWTSecret: getenv("JWT_SECRET", ""),
APIKey: strings.TrimSpace(os.Getenv("API_KEY")),
APIKeyHash: strings.ToLower(strings.TrimSpace(os.Getenv("API_KEY_HASH"))),
DBHost: getenv("DB_HOST", "127.0.0.1"),
DBPort: getenv("DB_PORT", "3306"),
DBName: getenv("DB_NAME", "mydb"),
DBUser: getenv("DB_USER", "root"),
DBPassword: strings.TrimSpace(os.Getenv("DB_PASSWORD")),
DBParams: getenv("DB_PARAMS", "charset=utf8mb4&parseTime=True&loc=Local"),
RedisAddr: getenv("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: strings.TrimSpace(os.Getenv("REDIS_PASSWORD")),
RedisDB: mustAtoi(getenv("REDIS_DB", "0")),
UploadDir: getenv("UPLOAD_DIR", "./static/upload"),
MaxUploadMB: int64(mustAtoi(getenv("MAX_UPLOAD_MB", "10"))),
CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "http://localhost:5173")),
AMapKey: strings.TrimSpace(os.Getenv("AMAP_KEY")),
}
if cfg.JWTSecret == "" {
return Config{}, errors.New("JWT_SECRET is required")
}
if cfg.APIKey == "" && cfg.APIKeyHash == "" {
return Config{}, errors.New("API_KEY or API_KEY_HASH is required")
}
if cfg.APIKeyHash != "" && len(cfg.APIKeyHash) != 64 {
return Config{}, errors.New("API_KEY_HASH must be sha256 hex (64 chars)")
}
return cfg, nil
}
func (c Config) ExpectedAPIKeyHash() string {
if c.APIKeyHash != "" {
return c.APIKeyHash
}
sum := sha256.Sum256([]byte(c.APIKey))
return hex.EncodeToString(sum[:])
}
func getenv(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func mustAtoi(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return n
}
func splitCSV(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
v := strings.TrimSpace(part)
if v != "" {
out = append(out, v)
}
}
return out
}

66
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,66 @@
package db
import (
"fmt"
"time"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/logger"
"go.uber.org/zap"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func Open(cfg config.Config, log *zap.Logger) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?%s",
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName, cfg.DBParams,
)
gormLog := gormlogger.New(
zap.NewStdLog(log.WithOptions(zap.AddCallerSkip(1))),
gormlogger.Config{
SlowThreshold: 500 * time.Millisecond,
LogLevel: gormlogger.Warn,
IgnoreRecordNotFoundError: true,
},
)
var gdb *gorm.DB
var err error
delay := 500 * time.Millisecond
for attempt := 1; attempt <= 20; attempt++ {
gdb, err = gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gormLog})
if err == nil {
break
}
log.Warn("db connect retry",
logger.Str("host", cfg.DBHost),
logger.Str("db", cfg.DBName),
zap.Int("attempt", attempt),
logger.Err(err),
)
time.Sleep(delay)
if delay < 5*time.Second {
delay *= 2
if delay > 5*time.Second {
delay = 5 * time.Second
}
}
}
if err != nil {
return nil, err
}
sqlDB, err := gdb.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
log.Info("db connected", logger.Str("host", cfg.DBHost), logger.Str("db", cfg.DBName))
return gdb, nil
}

View File

@@ -0,0 +1,135 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
func (h *Handlers) AdminListAdmins(c *gin.Context) {
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
keyword := strings.TrimSpace(c.Query("keyword"))
q := h.db.Model(&models.AdminUser{})
if keyword != "" {
q = q.Where("username LIKE ?", "%"+keyword+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.AdminUser
if err := q.Order("id desc").Limit(pageSize).Offset((page-1)*pageSize).Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
type adminCreateReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Role string `json:"role"`
Enabled *bool `json:"enabled"`
}
func (h *Handlers) AdminCreateAdmin(c *gin.Context) {
var req adminCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || len(req.Password) < 6 {
resp.Fail(c, http.StatusBadRequest, "invalid username or password")
return
}
if req.Role == "" {
req.Role = "admin"
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "hash error")
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
item := models.AdminUser{
Username: req.Username,
PasswordHash: string(hash),
Role: req.Role,
Enabled: enabled,
}
if err := h.db.Create(&item).Error; err != nil {
resp.Fail(c, http.StatusBadRequest, "create failed")
return
}
resp.OK(c, item)
}
type adminPasswordReq struct {
Password string `json:"password" binding:"required"`
}
func (h *Handlers) AdminUpdateAdminPassword(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req adminPasswordReq
if err := c.ShouldBindJSON(&req); err != nil || len(req.Password) < 6 {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "hash error")
return
}
if err := h.db.Model(&models.AdminUser{}).Where("id = ?", uint(id64)).
Update("password_hash", string(hash)).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "update failed")
return
}
resp.OK(c, gin.H{"updated": true})
}
type adminEnabledReq struct {
Enabled bool `json:"enabled"`
}
func (h *Handlers) AdminUpdateAdminEnabled(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req adminEnabledReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
if err := h.db.Model(&models.AdminUser{}).Where("id = ?", uint(id64)).
Update("enabled", req.Enabled).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "update failed")
return
}
resp.OK(c, gin.H{"updated": true})
}

View File

@@ -0,0 +1,69 @@
package handlers
import (
"net/http"
"time"
"0451meishiditu/backend/internal/auth"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
type loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaCode string `json:"captcha_code" binding:"required"`
}
func (h *Handlers) AdminLogin(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
return
}
var admin models.AdminUser
if err := h.db.Where("username = ?", req.Username).First(&admin).Error; err != nil {
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
return
}
if !admin.Enabled {
resp.Fail(c, http.StatusUnauthorized, "account disabled")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(req.Password)); err != nil {
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
return
}
token, err := auth.NewAdminToken(h.cfg.JWTSecret, admin.ID, admin.Username, admin.Role, 24*time.Hour)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "token error")
return
}
resp.OK(c, gin.H{
"token": token,
"admin": gin.H{
"id": admin.ID,
"username": admin.Username,
"role": admin.Role,
},
})
}
func (h *Handlers) AdminMe(c *gin.Context) {
resp.OK(c, gin.H{
"id": c.GetUint("admin_id"),
"username": c.GetString("admin_username"),
"role": c.GetString("admin_role"),
})
}

View File

@@ -0,0 +1,81 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
func (h *Handlers) AdminStoreRanking(c *gin.Context) {
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
if by == "" {
by = "hotness"
}
order := "store_metrics.score desc"
switch by {
case "likes":
order = "store_metrics.likes_count desc"
case "search":
order = "store_metrics.search_count desc"
case "reviews":
order = "store_metrics.reviews_count desc"
}
type row struct {
StoreID uint `json:"store_id"`
Name string `json:"name"`
CategoryID uint `json:"category_id"`
Status string `json:"status"`
LikesCount int64 `json:"likes_count"`
SearchCount int64 `json:"search_count"`
ReviewsCount int64 `json:"reviews_count"`
Score float64 `json:"score"`
}
var total int64
_ = h.db.Table("store_metrics").
Joins("left join stores on stores.id = store_metrics.store_id").
Where("stores.deleted_at is null").
Count(&total).Error
var out []row
if err := h.db.Table("store_metrics").
Select("stores.id as store_id, stores.name, stores.category_id, stores.status, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
Joins("left join stores on stores.id = store_metrics.store_id").
Where("stores.deleted_at is null").
Order(order + ", stores.id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Scan(&out).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, out, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) AdminRecalcStoreScore(c *gin.Context) {
limit, _ := strconv.Atoi(c.Query("limit"))
if limit <= 0 || limit > 5000 {
limit = 5000
}
// score = likes*2 + search*1 + reviews*3
err := h.db.Exec("update store_metrics set score = likes_count*2 + search_count*1 + reviews_count*3, updated_at = now() limit ?", limit).Error
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "recalc failed")
return
}
resp.OK(c, gin.H{"updated": true})
}

View File

@@ -0,0 +1,22 @@
package handlers
import (
"net/http"
"strconv"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
func (h *Handlers) AdminUserGet(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var u models.User
if err := h.db.First(&u, uint(id64)).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "not found")
return
}
resp.OK(c, u)
}

View File

@@ -0,0 +1,68 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
func (h *Handlers) AdminUserList(c *gin.Context) {
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
keyword := strings.TrimSpace(c.Query("keyword"))
status := strings.TrimSpace(c.Query("status"))
q := h.db.Model(&models.User{})
if keyword != "" {
q = q.Where("username LIKE ?", "%"+keyword+"%")
}
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.User
if err := q.Order("id desc").Limit(pageSize).Offset((page-1)*pageSize).Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
type userStatusReq struct {
Status string `json:"status" binding:"required"` // active/disabled
}
func (h *Handlers) AdminUserUpdateStatus(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req userStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
if req.Status != "active" && req.Status != "disabled" {
resp.Fail(c, http.StatusBadRequest, "invalid status")
return
}
if err := h.db.Model(&models.User{}).Where("id = ?", uint(id64)).Update("status", req.Status).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "update failed")
return
}
resp.OK(c, gin.H{"updated": true})
}

View File

@@ -0,0 +1,90 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
type apiKeyCreateReq struct {
Name string `json:"name" binding:"required"`
}
func (h *Handlers) APIKeyList(c *gin.Context) {
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.APIKey{})
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.APIKey
if err := q.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) APIKeyCreate(c *gin.Context) {
var req apiKeyCreateReq
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
plain := "ak_" + randomHex(24)
sum := sha256.Sum256([]byte(plain))
hash := hex.EncodeToString(sum[:])
prefix := plain
if len(prefix) > 10 {
prefix = prefix[:10]
}
item := models.APIKey{
Name: strings.TrimSpace(req.Name),
Prefix: prefix,
HashSHA256: hash,
Status: "active",
}
if err := h.db.Create(&item).Error; err != nil {
resp.Fail(c, http.StatusBadRequest, "create failed")
return
}
resp.OK(c, gin.H{
"id": item.ID,
"name": item.Name,
"prefix": item.Prefix,
"status": item.Status,
"key": plain, // 仅返回一次,前端应提示保存
})
}
func (h *Handlers) APIKeyRevoke(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
now := time.Now()
if err := h.db.Model(&models.APIKey{}).
Where("id = ? and status = 'active'", uint(id)).
Updates(map[string]any{"status": "revoked", "revoked_at": &now}).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "revoke failed")
return
}
resp.OK(c, gin.H{"revoked": true})
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"html"
"net/http"
"strings"
"time"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
type captchaNewResp struct {
CaptchaID string `json:"captcha_id"`
SVG string `json:"svg"`
}
// CaptchaNew returns a simple SVG captcha and stores the answer in Redis.
// TTL: 5 minutes. One-time: successful validate will delete it.
func (h *Handlers) CaptchaNew(c *gin.Context) {
code := randomCaptchaCode(5)
id := randomHexStr(16)
key := "captcha:" + id
if err := h.rdb.Set(c.Request.Context(), key, strings.ToLower(code), 5*time.Minute).Err(); err != nil {
resp.Fail(c, http.StatusInternalServerError, "captcha store failed")
return
}
resp.OK(c, captchaNewResp{
CaptchaID: id,
SVG: captchaSVG(code),
})
}
func (h *Handlers) verifyCaptcha(c *gin.Context, captchaID, captchaCode string) bool {
captchaID = strings.TrimSpace(captchaID)
captchaCode = strings.ToLower(strings.TrimSpace(captchaCode))
if captchaID == "" || captchaCode == "" {
return false
}
key := "captcha:" + captchaID
val, err := h.rdb.Get(c.Request.Context(), key).Result()
if err != nil {
return false
}
ok := strings.ToLower(strings.TrimSpace(val)) == captchaCode
if ok {
_ = h.rdb.Del(c.Request.Context(), key).Err()
}
return ok
}
func randomHexStr(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func randomCaptchaCode(n int) string {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
b := make([]byte, n)
_, _ = rand.Read(b)
out := make([]byte, n)
for i := 0; i < n; i++ {
out[i] = alphabet[int(b[i])%len(alphabet)]
}
return string(out)
}
func captchaSVG(code string) string {
// Minimal SVG; frontends can render via v-html or <img src="data:image/svg+xml;utf8,...">
esc := html.EscapeString(code)
return `<?xml version="1.0" encoding="UTF-8"?>` +
`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="44" viewBox="0 0 140 44">` +
`<rect x="0" y="0" width="140" height="44" rx="10" fill="#f8fafc" stroke="#e2e8f0"/>` +
`<text x="70" y="29" text-anchor="middle" font-family="ui-sans-serif,system-ui" font-size="22" font-weight="700" fill="#0f172a" letter-spacing="3">` + esc + `</text>` +
`</svg>`
}

View File

@@ -0,0 +1,112 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
type categoryUpsertReq struct {
Name string `json:"name" binding:"required"`
IconURL string `json:"icon_url"`
SortOrder int `json:"sort_order"`
Enabled *bool `json:"enabled"`
}
func (h *Handlers) CategoryList(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.Category{})
if keyword != "" {
q = q.Where("name LIKE ?", "%"+keyword+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.Category
if err := q.Order("sort_order desc, id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) CategoryCreate(c *gin.Context) {
var req categoryUpsertReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
item := models.Category{
Name: req.Name,
IconURL: req.IconURL,
SortOrder: req.SortOrder,
Enabled: enabled,
}
if err := h.db.Create(&item).Error; err != nil {
resp.Fail(c, http.StatusBadRequest, "create failed")
return
}
resp.OK(c, item)
}
func (h *Handlers) CategoryUpdate(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req categoryUpsertReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
var item models.Category
if err := h.db.First(&item, uint(id)).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "not found")
return
}
item.Name = req.Name
item.IconURL = req.IconURL
item.SortOrder = req.SortOrder
if req.Enabled != nil {
item.Enabled = *req.Enabled
}
if err := h.db.Save(&item).Error; err != nil {
resp.Fail(c, http.StatusBadRequest, "update failed")
return
}
resp.OK(c, item)
}
func (h *Handlers) CategoryDelete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.db.Delete(&models.Category{}, uint(id)).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "delete failed")
return
}
resp.OK(c, gin.H{"deleted": true})
}

View File

@@ -0,0 +1,113 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"time"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
type dashboardOverview struct {
TotalStores int64 `json:"total_stores"`
TotalReviews int64 `json:"total_reviews"`
TotalCategories int64 `json:"total_categories"`
CategoryDist []categoryKV `json:"category_dist"`
StoresLast7Days []dateKV `json:"stores_last7days"`
TopRatedStores []topStoreRow `json:"top_rated_stores"`
}
type categoryKV struct {
CategoryID uint `json:"category_id"`
Name string `json:"name"`
Count int64 `json:"count"`
}
type dateKV struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
type topStoreRow struct {
StoreID uint `json:"store_id"`
Name string `json:"name"`
Avg float64 `json:"avg"`
Count int64 `json:"count"`
}
func (h *Handlers) DashboardOverview(c *gin.Context) {
ctx := c.Request.Context()
cacheKey := "admin:dashboard:overview:v1"
if b, err := h.rdb.Get(ctx, cacheKey).Bytes(); err == nil && len(b) > 0 {
var cached dashboardOverview
if json.Unmarshal(b, &cached) == nil {
resp.OK(c, cached)
return
}
}
ov, err := h.buildDashboard(ctx)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
if b, err := json.Marshal(ov); err == nil {
_ = h.rdb.Set(ctx, cacheKey, b, 30*time.Second).Err()
}
resp.OK(c, ov)
}
func (h *Handlers) buildDashboard(ctx context.Context) (dashboardOverview, error) {
var out dashboardOverview
if err := h.db.WithContext(ctx).Model(&models.Store{}).Count(&out.TotalStores).Error; err != nil {
return out, err
}
if err := h.db.WithContext(ctx).Model(&models.Review{}).Count(&out.TotalReviews).Error; err != nil {
return out, err
}
if err := h.db.WithContext(ctx).Model(&models.Category{}).Count(&out.TotalCategories).Error; err != nil {
return out, err
}
if err := h.db.WithContext(ctx).
Table("stores").
Select("categories.id as category_id, categories.name as name, count(stores.id) as count").
Joins("left join categories on categories.id = stores.category_id").
Where("stores.deleted_at is null").
Group("categories.id, categories.name").
Order("count desc").
Scan(&out.CategoryDist).Error; err != nil {
return out, err
}
if err := h.db.WithContext(ctx).
Table("stores").
Select("date(created_at) as date, count(id) as count").
Where("created_at >= date_sub(curdate(), interval 6 day) and deleted_at is null").
Group("date(created_at)").
Order("date asc").
Scan(&out.StoresLast7Days).Error; err != nil {
return out, err
}
if err := h.db.WithContext(ctx).
Table("reviews").
Select("stores.id as store_id, stores.name as name, avg(reviews.rating) as avg, count(reviews.id) as count").
Joins("left join stores on stores.id = reviews.store_id").
Where("reviews.status = 'approved' and reviews.deleted_at is null and stores.deleted_at is null").
Group("stores.id, stores.name").
Having("count(reviews.id) >= ?", 3).
Order("avg desc").
Limit(5).
Scan(&out.TopRatedStores).Error; err != nil {
return out, err
}
return out, nil
}

View File

@@ -0,0 +1,22 @@
package handlers
import (
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/settings"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Handlers struct {
cfg config.Config
log *zap.Logger
db *gorm.DB
rdb *redis.Client
st *settings.Store
}
func New(cfg config.Config, log *zap.Logger, db *gorm.DB, rdb *redis.Client, st *settings.Store) *Handlers {
return &Handlers{cfg: cfg, log: log, db: db, rdb: rdb, st: st}
}

View File

@@ -0,0 +1,182 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type merchantApplyReq struct {
StoreName string `json:"store_name" binding:"required"`
CategoryID uint `json:"category_id" binding:"required"`
Address string `json:"address" binding:"required"`
Lat *float64 `json:"lat"`
Lng *float64 `json:"lng"`
OpenHours string `json:"open_hours"`
Phone string `json:"phone"`
CoverURL string `json:"cover_url"`
ImageURLs []string `json:"image_urls"`
Description string `json:"description"`
ContactName string `json:"contact_name" binding:"required"`
ContactPhone string `json:"contact_phone" binding:"required"`
}
func (h *Handlers) MerchantApply(c *gin.Context) {
var req merchantApplyReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
imgs, _ := json.Marshal(req.ImageURLs)
app := models.MerchantApplication{
StoreName: req.StoreName,
CategoryID: req.CategoryID,
Address: req.Address,
Lat: req.Lat,
Lng: req.Lng,
OpenHours: req.OpenHours,
Phone: req.Phone,
CoverURL: req.CoverURL,
ImageURLs: imgs,
Description: req.Description,
ContactName: req.ContactName,
ContactPhone: req.ContactPhone,
Status: "pending",
RejectReason: "",
ReviewedAt: nil,
ReviewerID: nil,
}
if err := h.db.Create(&app).Error; err != nil {
resp.Fail(c, http.StatusBadRequest, "submit failed")
return
}
resp.OK(c, gin.H{"id": app.ID, "status": app.Status})
}
func (h *Handlers) AdminMerchantApplyList(c *gin.Context) {
status := c.Query("status")
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.MerchantApplication{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.MerchantApplication
if err := q.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
type applyReviewReq struct {
Action string `json:"action" binding:"required"` // approve/reject
RejectReason string `json:"reject_reason"`
}
func (h *Handlers) AdminMerchantApplyReview(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req applyReviewReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
var app models.MerchantApplication
if err := h.db.First(&app, uint(id64)).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "not found")
return
}
if app.Status != "pending" {
resp.Fail(c, http.StatusBadRequest, "already reviewed")
return
}
now := time.Now()
adminID := c.GetUint("admin_id")
if req.Action == "approve" {
err := h.db.Transaction(func(tx *gorm.DB) error {
// create store
store := models.Store{
Name: app.StoreName,
CategoryID: app.CategoryID,
Address: app.Address,
Lat: app.Lat,
Lng: app.Lng,
OpenHours: app.OpenHours,
Phone: app.Phone,
CoverURL: app.CoverURL,
Description: app.Description,
Status: "active",
}
if err := tx.Create(&store).Error; err != nil {
return err
}
// images
var urls []string
_ = json.Unmarshal([]byte(app.ImageURLs), &urls)
for i, u := range urls {
if u == "" {
continue
}
if err := tx.Create(&models.StoreImage{StoreID: store.ID, URL: u, SortOrder: i}).Error; err != nil {
return err
}
}
return tx.Model(&models.MerchantApplication{}).Where("id = ?", app.ID).Updates(map[string]any{
"status": "approved",
"reject_reason": "",
"reviewed_at": &now,
"reviewer_id": &adminID,
}).Error
})
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "approve failed")
return
}
resp.OK(c, gin.H{"approved": true})
return
}
if req.Action == "reject" {
if err := h.db.Model(&models.MerchantApplication{}).Where("id = ?", app.ID).Updates(map[string]any{
"status": "rejected",
"reject_reason": req.RejectReason,
"reviewed_at": &now,
"reviewer_id": &adminID,
}).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "reject failed")
return
}
resp.OK(c, gin.H{"rejected": true})
return
}
resp.Fail(c, http.StatusBadRequest, "invalid action")
}

View File

@@ -0,0 +1,23 @@
package handlers
import "strconv"
func parsePage(pageStr, sizeStr string) (int, int) {
page, _ := strconv.Atoi(pageStr)
size, _ := strconv.Atoi(sizeStr)
if page < 1 {
page = 1
}
if size < 1 || size > 200 {
size = 20
}
return page, size
}
func calcTotalPage(total int64, pageSize int) int64 {
if pageSize <= 0 {
return 0
}
return (total + int64(pageSize) - 1) / int64(pageSize)
}

View File

@@ -0,0 +1,193 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PublicCategoryList returns enabled categories for public clients (still requires X-API-Key).
func (h *Handlers) PublicCategoryList(c *gin.Context) {
var items []models.Category
if err := h.db.
Where("enabled = ?", true).
Order("sort_order desc, id desc").
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OK(c, items)
}
func (h *Handlers) PublicStoreList(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
categoryID, _ := strconv.ParseUint(c.Query("category_id"), 10, 64)
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.Store{}).Where("status = ?", "active")
if keyword != "" {
q = q.Where("name LIKE ?", "%"+keyword+"%")
}
if categoryID > 0 {
q = q.Where("category_id = ?", uint(categoryID))
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.Store
if err := q.Preload("Category").
Order("id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) PublicStoreGet(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var item models.Store
if err := h.db.
Preload("Category").
Preload("Images", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
Preload("Dishes", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
Where("status = ?", "active").
First(&item, uint(id)).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "not found")
return
}
resp.OK(c, item)
}
func (h *Handlers) PublicStoreReviews(c *gin.Context) {
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.Review{}).
Where("store_id = ? and status = ?", uint(storeID64), "approved")
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.Review
if err := q.Order("id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) PublicStoreRanking(c *gin.Context) {
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
if by == "" {
by = "hotness"
}
order := "store_metrics.score desc"
switch by {
case "likes":
order = "store_metrics.likes_count desc"
case "search":
order = "store_metrics.search_count desc"
case "reviews":
order = "store_metrics.reviews_count desc"
}
type row struct {
StoreID uint `json:"store_id"`
Name string `json:"name"`
CategoryID uint `json:"category_id"`
LikesCount int64 `json:"likes_count"`
SearchCount int64 `json:"search_count"`
ReviewsCount int64 `json:"reviews_count"`
Score float64 `json:"score"`
}
var total int64
_ = h.db.Table("store_metrics").
Joins("left join stores on stores.id = store_metrics.store_id").
Where("stores.deleted_at is null and stores.status = 'active'").
Count(&total).Error
var out []row
if err := h.db.Table("store_metrics").
Select("stores.id as store_id, stores.name, stores.category_id, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
Joins("left join stores on stores.id = store_metrics.store_id").
Where("stores.deleted_at is null and stores.status = 'active'").
Order(order + ", stores.id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Scan(&out).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, out, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
// UserMyReviews lists the current user's submitted reviews.
func (h *Handlers) UserMyReviews(c *gin.Context) {
userID := c.GetUint("user_id")
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.Review{}).Where("user_id = ?", userID)
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.Review
if err := q.Preload("Store").
Order("id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}

View File

@@ -0,0 +1,194 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
)
func (h *Handlers) PublicStoreSearch(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.Store{}).Where("status = ?", "active")
if keyword != "" {
q = q.Where("name LIKE ?", "%"+keyword+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.Store
if err := q.Preload("Category").
Order("id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
// search heat: increment top 5 results
if keyword != "" {
for i := 0; i < len(items) && i < 5; i++ {
_ = h.bumpSearchCount(items[i].ID)
}
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) bumpSearchCount(storeID uint) error {
return h.db.Transaction(func(tx *gorm.DB) error {
var m models.StoreMetric
err := tx.Where("store_id = ?", storeID).First(&m).Error
if err == nil {
return tx.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
Updates(map[string]any{"search_count": gorm.Expr("search_count + 1"), "updated_at": time.Now()}).Error
}
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
return tx.Create(&models.StoreMetric{StoreID: storeID, SearchCount: 1, UpdatedAt: time.Now()}).Error
})
}
func (h *Handlers) PublicStoreHotRank(c *gin.Context) {
by := strings.TrimSpace(c.Query("by")) // hotness/likes/search/reviews
limit, _ := strconv.Atoi(c.Query("limit"))
if limit <= 0 || limit > 100 {
limit = 20
}
if by == "" {
by = "hotness"
}
order := "store_metrics.score desc"
switch by {
case "likes":
order = "store_metrics.likes_count desc"
case "search":
order = "store_metrics.search_count desc"
case "reviews":
order = "store_metrics.reviews_count desc"
}
type row struct {
models.Store
LikesCount int64 `json:"likes_count"`
SearchCount int64 `json:"search_count"`
ReviewsCount int64 `json:"reviews_count"`
Score float64 `json:"score"`
}
var out []row
err := h.db.Table("stores").
Select("stores.*, store_metrics.likes_count, store_metrics.search_count, store_metrics.reviews_count, store_metrics.score").
Joins("left join store_metrics on store_metrics.store_id = stores.id").
Where("stores.status = 'active' and stores.deleted_at is null").
Order(order + ", stores.id desc").
Limit(limit).
Scan(&out).Error
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OK(c, out)
}
type reviewCreateReq struct {
Rating int `json:"rating" binding:"required"`
Content string `json:"content"`
ImageURLs []string `json:"image_urls"`
RecommendDishes []map[string]any `json:"recommend_dishes"`
}
func (h *Handlers) UserCreateReview(c *gin.Context) {
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
userID := c.GetUint("user_id")
username := c.GetString("user_username")
var req reviewCreateReq
if err := c.ShouldBindJSON(&req); err != nil || req.Rating < 1 || req.Rating > 5 {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
imgs, _ := json.Marshal(req.ImageURLs)
recd, _ := json.Marshal(req.RecommendDishes)
r := models.Review{
StoreID: uint(storeID64),
UserID: &userID,
UserName: username,
Rating: req.Rating,
Content: req.Content,
ImageURLs: datatypes.JSON(imgs),
RecommendDishes: datatypes.JSON(recd),
Status: "pending",
}
if err := h.db.Create(&r).Error; err != nil {
resp.Fail(c, http.StatusBadRequest, "create failed")
return
}
resp.OK(c, gin.H{"id": r.ID, "status": r.Status})
}
func (h *Handlers) UserToggleStoreLike(c *gin.Context) {
storeID64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
storeID := uint(storeID64)
userID := c.GetUint("user_id")
var existed models.StoreLike
err := h.db.Where("store_id = ? and user_id = ?", storeID, userID).First(&existed).Error
if err == nil {
if err := h.db.Where("id = ?", existed.ID).Delete(&models.StoreLike{}).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "unlike failed")
return
}
_ = h.db.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
Updates(map[string]any{"likes_count": gorm.Expr("greatest(likes_count-1,0)"), "updated_at": time.Now()}).Error
resp.OK(c, gin.H{"liked": false})
return
}
if err != nil && err != gorm.ErrRecordNotFound {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
if err := h.db.Create(&models.StoreLike{StoreID: storeID, UserID: userID, CreatedAt: time.Now()}).Error; err != nil {
resp.Fail(c, http.StatusBadRequest, "like failed")
return
}
_ = h.db.Transaction(func(tx *gorm.DB) error {
var m models.StoreMetric
e := tx.Where("store_id = ?", storeID).First(&m).Error
if e == nil {
return tx.Model(&models.StoreMetric{}).Where("store_id = ?", storeID).
Updates(map[string]any{"likes_count": gorm.Expr("likes_count + 1"), "updated_at": time.Now()}).Error
}
if e != nil && e != gorm.ErrRecordNotFound {
return e
}
return tx.Create(&models.StoreMetric{StoreID: storeID, LikesCount: 1, UpdatedAt: time.Now()}).Error
})
resp.OK(c, gin.H{"liked": true})
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
func (h *Handlers) ReviewList(c *gin.Context) {
status := strings.TrimSpace(c.Query("status"))
storeID, _ := strconv.ParseUint(c.Query("store_id"), 10, 64)
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.Review{})
if status != "" {
q = q.Where("status = ?", status)
}
if storeID > 0 {
q = q.Where("store_id = ?", uint(storeID))
}
if userID > 0 {
q = q.Where("user_id = ?", uint(userID))
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.Review
if err := q.Preload("Store").
Order("id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) ReviewUpdateStatus(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req statusReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
if req.Status != "pending" && req.Status != "approved" && req.Status != "blocked" {
resp.Fail(c, http.StatusBadRequest, "invalid status")
return
}
if err := h.db.Model(&models.Review{}).Where("id = ?", uint(id)).Update("status", req.Status).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "update failed")
return
}
resp.OK(c, gin.H{"updated": true})
}
func (h *Handlers) ReviewDelete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.db.Delete(&models.Review{}, uint(id)).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "delete failed")
return
}
resp.OK(c, gin.H{"deleted": true})
}

View File

@@ -0,0 +1,45 @@
package handlers
import (
"net/http"
"strings"
"0451meishiditu/backend/internal/resp"
"0451meishiditu/backend/internal/settings"
"github.com/gin-gonic/gin"
)
type corsUpdateReq struct {
Origins []string `json:"origins" binding:"required"`
}
func (h *Handlers) SettingsGetCORS(c *gin.Context) {
resp.OK(c, gin.H{"origins": h.st.CORSAllowOrigins()})
}
func (h *Handlers) SettingsUpdateCORS(c *gin.Context) {
var req corsUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
var cleaned []string
for _, o := range req.Origins {
v := strings.TrimSpace(o)
if v != "" {
cleaned = append(cleaned, v)
}
}
if len(cleaned) == 0 {
resp.Fail(c, http.StatusBadRequest, "origins required")
return
}
if err := settings.UpsertCORSAllowOrigins(h.db, cleaned); err != nil {
resp.Fail(c, http.StatusInternalServerError, "update failed")
return
}
h.st.SetCORSAllowOrigins(cleaned)
resp.OK(c, gin.H{"updated": true})
}

View File

@@ -0,0 +1,261 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type storeImageReq struct {
URL string `json:"url" binding:"required"`
SortOrder int `json:"sort_order"`
}
type dishReq struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
SortOrder int `json:"sort_order"`
}
type storeUpsertReq struct {
Name string `json:"name" binding:"required"`
CategoryID uint `json:"category_id" binding:"required"`
Address string `json:"address" binding:"required"`
Lat *float64 `json:"lat"`
Lng *float64 `json:"lng"`
OpenHours string `json:"open_hours"`
Phone string `json:"phone"`
CoverURL string `json:"cover_url"`
Description string `json:"description"`
Status string `json:"status"`
Images []storeImageReq `json:"images"`
Dishes []dishReq `json:"dishes"`
}
func (h *Handlers) StoreList(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
status := strings.TrimSpace(c.Query("status"))
categoryID, _ := strconv.ParseUint(c.Query("category_id"), 10, 64)
page, pageSize := parsePage(c.Query("page"), c.Query("page_size"))
q := h.db.Model(&models.Store{})
if keyword != "" {
q = q.Where("name LIKE ?", "%"+keyword+"%")
}
if status != "" {
q = q.Where("status = ?", status)
}
if categoryID > 0 {
q = q.Where("category_id = ?", uint(categoryID))
}
var total int64
if err := q.Count(&total).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
var items []models.Store
if err := q.Preload("Category").
Order("id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&items).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OKMeta(c, items, gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": calcTotalPage(total, pageSize),
})
}
func (h *Handlers) StoreGet(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var item models.Store
if err := h.db.
Preload("Images", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
Preload("Dishes", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order desc, id desc") }).
First(&item, uint(id)).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "not found")
return
}
resp.OK(c, item)
}
func (h *Handlers) StoreCreate(c *gin.Context) {
var req storeUpsertReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
if req.Status == "" {
req.Status = "active"
}
err := h.db.Transaction(func(tx *gorm.DB) error {
item := models.Store{
Name: req.Name,
CategoryID: req.CategoryID,
Address: req.Address,
Lat: req.Lat,
Lng: req.Lng,
OpenHours: req.OpenHours,
Phone: req.Phone,
CoverURL: req.CoverURL,
Description: req.Description,
Status: req.Status,
}
if err := tx.Create(&item).Error; err != nil {
return err
}
if err := upsertStoreImages(tx, item.ID, req.Images); err != nil {
return err
}
if err := upsertStoreDishes(tx, item.ID, req.Dishes); err != nil {
return err
}
var out models.Store
if err := tx.Preload("Images").Preload("Dishes").First(&out, item.ID).Error; err != nil {
return err
}
resp.OK(c, out)
return nil
})
if err != nil {
resp.Fail(c, http.StatusBadRequest, "create failed")
return
}
}
func (h *Handlers) StoreUpdate(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req storeUpsertReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
var item models.Store
if err := tx.First(&item, uint(id)).Error; err != nil {
return err
}
if req.Status == "" {
req.Status = item.Status
}
item.Name = req.Name
item.CategoryID = req.CategoryID
item.Address = req.Address
if req.Lat != nil {
item.Lat = req.Lat
}
if req.Lng != nil {
item.Lng = req.Lng
}
item.OpenHours = req.OpenHours
item.Phone = req.Phone
item.CoverURL = req.CoverURL
item.Description = req.Description
item.Status = req.Status
if err := tx.Save(&item).Error; err != nil {
return err
}
if err := tx.Where("store_id = ?", item.ID).Delete(&models.StoreImage{}).Error; err != nil {
return err
}
if err := tx.Where("store_id = ?", item.ID).Delete(&models.SignatureDish{}).Error; err != nil {
return err
}
if err := upsertStoreImages(tx, item.ID, req.Images); err != nil {
return err
}
if err := upsertStoreDishes(tx, item.ID, req.Dishes); err != nil {
return err
}
var out models.Store
if err := tx.Preload("Images").Preload("Dishes").First(&out, item.ID).Error; err != nil {
return err
}
resp.OK(c, out)
return nil
})
if err != nil {
resp.Fail(c, http.StatusBadRequest, "update failed")
return
}
}
type statusReq struct {
Status string `json:"status" binding:"required"`
}
func (h *Handlers) StoreUpdateStatus(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req statusReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload")
return
}
if req.Status != "active" && req.Status != "inactive" {
resp.Fail(c, http.StatusBadRequest, "invalid status")
return
}
if err := h.db.Model(&models.Store{}).Where("id = ?", uint(id)).Update("status", req.Status).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "update failed")
return
}
resp.OK(c, gin.H{"updated": true})
}
func (h *Handlers) StoreDelete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.db.Delete(&models.Store{}, uint(id)).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "delete failed")
return
}
resp.OK(c, gin.H{"deleted": true})
}
func upsertStoreImages(tx *gorm.DB, storeID uint, imgs []storeImageReq) error {
for _, img := range imgs {
if err := tx.Create(&models.StoreImage{
StoreID: storeID,
URL: img.URL,
SortOrder: img.SortOrder,
}).Error; err != nil {
return err
}
}
return nil
}
func upsertStoreDishes(tx *gorm.DB, storeID uint, dishes []dishReq) error {
for _, d := range dishes {
if err := tx.Create(&models.SignatureDish{
StoreID: storeID,
Name: d.Name,
Description: d.Description,
ImageURL: d.ImageURL,
SortOrder: d.SortOrder,
}).Error; err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,82 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
func (h *Handlers) Upload(c *gin.Context) {
url, ok := h.handleUpload(c)
if !ok {
return
}
resp.OK(c, gin.H{"url": url})
}
// UserUpload allows logged-in users to upload images for reviews.
func (h *Handlers) UserUpload(c *gin.Context) {
url, ok := h.handleUpload(c)
if !ok {
return
}
resp.OK(c, gin.H{"url": url})
}
func (h *Handlers) handleUpload(c *gin.Context) (string, bool) {
maxMB := h.cfg.MaxUploadMB
if maxMB <= 0 {
maxMB = 10
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMB*1024*1024)
file, err := c.FormFile("file")
if err != nil {
resp.Fail(c, http.StatusBadRequest, "missing file")
return "", false
}
ext := strings.ToLower(filepath.Ext(file.Filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
resp.Fail(c, http.StatusBadRequest, "unsupported file type")
return "", false
}
now := time.Now()
dir := filepath.Join(h.cfg.UploadDir, strconv.Itoa(now.Year()), now.Format("01"))
if err := os.MkdirAll(dir, 0o755); err != nil {
resp.Fail(c, http.StatusInternalServerError, "mkdir failed")
return "", false
}
name := randomHex(16) + ext
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
resp.Fail(c, http.StatusInternalServerError, "save failed")
return "", false
}
rel := strings.TrimPrefix(filepath.ToSlash(dst), ".")
if !strings.HasPrefix(rel, "/") {
rel = "/" + rel
}
url := strings.TrimRight(h.cfg.PublicBaseURL, "/") + rel
return url, true
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -0,0 +1,125 @@
package handlers
import (
"errors"
"net/http"
"strings"
"time"
"0451meishiditu/backend/internal/auth"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
mysqlerr "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
type userRegisterReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaCode string `json:"captcha_code" binding:"required"`
}
func (h *Handlers) UserRegister(c *gin.Context) {
var req userRegisterReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload: "+safeErrMsg(err))
return
}
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "hash error")
return
}
req.Username = strings.TrimSpace(req.Username)
u := models.User{Username: req.Username, PasswordHash: string(hash), Status: "active"}
if err := h.db.Create(&u).Error; err != nil {
if isDuplicateErr(err) {
resp.Fail(c, http.StatusConflict, "username already exists")
return
}
resp.Fail(c, http.StatusBadRequest, "register failed: "+safeErrMsg(err))
return
}
token, _ := auth.NewUserToken(h.cfg.JWTSecret, u.ID, u.Username, 30*24*time.Hour)
resp.OK(c, gin.H{"token": token, "user": u})
}
type userLoginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaCode string `json:"captcha_code" binding:"required"`
}
func (h *Handlers) UserLogin(c *gin.Context) {
var req userLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid payload: "+safeErrMsg(err))
return
}
if !h.verifyCaptcha(c, req.CaptchaID, req.CaptchaCode) {
resp.Fail(c, http.StatusBadRequest, "invalid captcha")
return
}
var u models.User
if err := h.db.Where("username = ?", req.Username).First(&u).Error; err != nil {
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
return
}
if u.Status != "active" {
resp.Fail(c, http.StatusUnauthorized, "account disabled")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(req.Password)); err != nil {
resp.Fail(c, http.StatusUnauthorized, "invalid username or password")
return
}
token, _ := auth.NewUserToken(h.cfg.JWTSecret, u.ID, u.Username, 30*24*time.Hour)
resp.OK(c, gin.H{"token": token, "user": gin.H{"id": u.ID, "username": u.Username}})
}
func isDuplicateErr(err error) bool {
var me *mysqlerr.MySQLError
if strings.Contains(err.Error(), "Duplicate entry") {
return true
}
if errors.As(err, &me) {
return me.Number == 1062
}
return false
}
func safeErrMsg(err error) string {
s := err.Error()
if len(s) > 200 {
return s[:200]
}
return s
}
func (h *Handlers) UserMe(c *gin.Context) {
id := c.GetUint("user_id")
var u models.User
if err := h.db.Select("id", "username", "status", "created_at", "updated_at").Where("id = ?", id).First(&u).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "db error")
return
}
resp.OK(c, u)
}
// 预留:抖音登录(需要对接抖音服务端换取 openid/session_key
type douyinLoginReq struct {
Code string `json:"code" binding:"required"`
}
func (h *Handlers) DouyinLogin(c *gin.Context) {
_ = douyinLoginReq{}
resp.Fail(c, http.StatusNotImplemented, "douyin login not implemented yet")
}

View File

@@ -0,0 +1,125 @@
package httpx
import (
"net/http"
"time"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/handlers"
"0451meishiditu/backend/internal/middleware"
"0451meishiditu/backend/internal/settings"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
func NewRouter(cfg config.Config, log *zap.Logger, db *gorm.DB, rdb *redis.Client, st *settings.Store) *gin.Engine {
if cfg.AppEnv == "prod" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(middleware.Recover(log))
r.Use(middleware.RequestID())
r.Use(middleware.AccessLog(log))
r.Use(gzip.Gzip(gzip.DefaultCompression))
r.Use(cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool { return st.CORSAllowOrigin(origin) },
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type", "X-API-Key", "X-Request-Id"},
ExposeHeaders: []string{"X-Request-Id"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
r.Static("/static", "./static")
r.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
h := handlers.New(cfg, log, db, rdb, st)
api := r.Group("/api")
api.Use(middleware.APIKey(cfg, db))
public := api.Group("")
public.GET("/captcha/new", h.CaptchaNew)
public.GET("/categories", h.PublicCategoryList)
public.GET("/stores", h.PublicStoreList)
public.GET("/stores/:id", h.PublicStoreGet)
public.GET("/stores/:id/reviews", h.PublicStoreReviews)
public.GET("/rankings/stores", h.PublicStoreRanking)
public.POST("/merchant/apply", h.MerchantApply)
public.GET("/stores/search", h.PublicStoreSearch)
public.GET("/stores/hot", h.PublicStoreHotRank)
user := api.Group("/user")
user.POST("/register", h.UserRegister)
user.POST("/login", h.UserLogin)
user.POST("/douyin/login", h.DouyinLogin)
userAuth := user.Group("")
userAuth.Use(middleware.UserJWT(cfg))
userAuth.GET("/me", h.UserMe)
userAuth.GET("/reviews", h.UserMyReviews)
userAuth.POST("/upload", h.UserUpload)
userAuth.POST("/stores/:id/reviews", h.UserCreateReview)
userAuth.POST("/stores/:id/like", h.UserToggleStoreLike)
admin := api.Group("/admin")
admin.POST("/login", h.AdminLogin)
adminAuth := admin.Group("")
adminAuth.Use(middleware.AdminJWT(cfg))
adminAuth.GET("/me", h.AdminMe)
adminAuth.GET("/dashboard/overview", h.DashboardOverview)
adminAuth.GET("/apikeys", h.APIKeyList)
adminAuth.POST("/apikeys", h.APIKeyCreate)
adminAuth.PATCH("/apikeys/:id/revoke", h.APIKeyRevoke)
adminAuth.GET("/settings/cors", h.SettingsGetCORS)
adminAuth.PUT("/settings/cors", h.SettingsUpdateCORS)
adminAuth.GET("/merchant/applications", h.AdminMerchantApplyList)
adminAuth.PATCH("/merchant/applications/:id/review", h.AdminMerchantApplyReview)
adminAuth.GET("/rankings/stores", h.AdminStoreRanking)
adminAuth.POST("/rankings/stores/recalc", h.AdminRecalcStoreScore)
adminAuth.GET("/admins", h.AdminListAdmins)
adminAuth.POST("/admins", h.AdminCreateAdmin)
adminAuth.PATCH("/admins/:id/password", h.AdminUpdateAdminPassword)
adminAuth.PATCH("/admins/:id/enabled", h.AdminUpdateAdminEnabled)
adminAuth.GET("/users", h.AdminUserList)
adminAuth.GET("/users/:id", h.AdminUserGet)
adminAuth.PATCH("/users/:id/status", h.AdminUserUpdateStatus)
adminAuth.GET("/categories", h.CategoryList)
adminAuth.POST("/categories", h.CategoryCreate)
adminAuth.PUT("/categories/:id", h.CategoryUpdate)
adminAuth.DELETE("/categories/:id", h.CategoryDelete)
adminAuth.GET("/stores", h.StoreList)
adminAuth.GET("/stores/:id", h.StoreGet)
adminAuth.POST("/stores", h.StoreCreate)
adminAuth.PUT("/stores/:id", h.StoreUpdate)
adminAuth.PATCH("/stores/:id/status", h.StoreUpdateStatus)
adminAuth.DELETE("/stores/:id", h.StoreDelete)
adminAuth.GET("/reviews", h.ReviewList)
adminAuth.PATCH("/reviews/:id/status", h.ReviewUpdateStatus)
adminAuth.DELETE("/reviews/:id", h.ReviewDelete)
adminAuth.POST("/upload", h.Upload)
return r
}

View File

@@ -0,0 +1,17 @@
package logger
import "go.uber.org/zap"
func New(env string) *zap.Logger {
if env == "prod" {
l, _ := zap.NewProduction()
return l
}
l, _ := zap.NewDevelopment()
return l
}
func Err(err error) zap.Field { return zap.Error(err) }
func Str(k, v string) zap.Field { return zap.String(k, v) }
func Uint(k string, v uint) zap.Field { return zap.Uint(k, v) }

View File

@@ -0,0 +1,25 @@
package middleware
import (
"time"
"0451meishiditu/backend/internal/logger"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func AccessLog(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
log.Info("http",
logger.Str("method", c.Request.Method),
logger.Str("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("latency", time.Since(start)),
)
}
}

View File

@@ -0,0 +1,45 @@
package middleware
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"time"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/models"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func APIKey(cfg config.Config, db *gorm.DB) gin.HandlerFunc {
expectedHash := cfg.ExpectedAPIKeyHash()
return func(c *gin.Context) {
key := strings.TrimSpace(c.GetHeader("X-API-Key"))
if key == "" {
resp.Fail(c, http.StatusUnauthorized, "missing api key")
c.Abort()
return
}
sum := sha256.Sum256([]byte(key))
gotHash := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(gotHash), []byte(expectedHash)) != 1 {
// fallback to DB stored keys
var ak models.APIKey
err := db.Where("hash_sha256 = ? and status = 'active'", gotHash).First(&ak).Error
if err != nil {
resp.Fail(c, http.StatusUnauthorized, "invalid api key")
c.Abort()
return
}
now := time.Now()
_ = db.Model(&models.APIKey{}).Where("id = ?", ak.ID).UpdateColumn("last_used_at", &now).Error
}
c.Next()
}
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"strings"
"0451meishiditu/backend/internal/auth"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
func AdminJWT(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
h := strings.TrimSpace(c.GetHeader("Authorization"))
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
resp.Fail(c, http.StatusUnauthorized, "missing token")
c.Abort()
return
}
token := strings.TrimSpace(h[len("bearer "):])
claims, err := auth.ParseAdminToken(cfg.JWTSecret, token)
if err != nil {
resp.Fail(c, http.StatusUnauthorized, "invalid token")
c.Abort()
return
}
c.Set("admin_id", claims.AdminID)
c.Set("admin_username", claims.Username)
c.Set("admin_role", claims.Role)
c.Next()
}
}

View File

@@ -0,0 +1,27 @@
package middleware
import (
"net/http"
"0451meishiditu/backend/internal/logger"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func Recover(log *zap.Logger) gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.Error("panic recovered", logger.Str("recovered", toString(recovered)))
resp.Fail(c, http.StatusInternalServerError, "internal error")
})
}
func toString(v interface{}) string {
switch t := v.(type) {
case string:
return t
default:
return "panic"
}
}

View File

@@ -0,0 +1,19 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.Request.Header.Get("X-Request-Id")
if rid == "" {
rid = uuid.NewString()
}
c.Writer.Header().Set("X-Request-Id", rid)
c.Set("request_id", rid)
c.Next()
}
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"strings"
"0451meishiditu/backend/internal/auth"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/resp"
"github.com/gin-gonic/gin"
)
func UserJWT(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
h := strings.TrimSpace(c.GetHeader("Authorization"))
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
resp.Fail(c, http.StatusUnauthorized, "missing token")
c.Abort()
return
}
token := strings.TrimSpace(h[len("bearer "):])
claims, err := auth.ParseUserToken(cfg.JWTSecret, token)
if err != nil {
resp.Fail(c, http.StatusUnauthorized, "invalid token")
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("user_username", claims.Username)
c.Next()
}
}

View File

@@ -0,0 +1,37 @@
package migrate
import (
"0451meishiditu/backend/internal/models"
"gorm.io/gorm"
)
func AutoMigrate(db *gorm.DB) error {
if err := db.AutoMigrate(
&models.AdminUser{},
&models.SystemSetting{},
&models.APIKey{},
&models.Category{},
&models.MerchantApplication{},
&models.User{},
&models.Store{},
&models.StoreMetric{},
&models.StoreLike{},
&models.StoreImage{},
&models.SignatureDish{},
&models.Review{},
); err != nil {
return err
}
// Hotfix: allow users.douyin_open_id to be NULL to avoid unique-index conflicts on empty string.
_ = db.Exec("ALTER TABLE users MODIFY douyin_open_id varchar(128) NULL").Error
_ = db.Exec("UPDATE users SET douyin_open_id = NULL WHERE douyin_open_id = ''").Error
// Optional: lat/lng are deprecated; allow NULL for existing schemas.
_ = db.Exec("ALTER TABLE stores MODIFY lat double NULL").Error
_ = db.Exec("ALTER TABLE stores MODIFY lng double NULL").Error
_ = db.Exec("ALTER TABLE merchant_applications MODIFY lat double NULL").Error
_ = db.Exec("ALTER TABLE merchant_applications MODIFY lng double NULL").Error
return nil
}

View File

@@ -0,0 +1,168 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type AdminUser struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"size:32;not null;default:'admin'" json:"role"`
Enabled bool `gorm:"not null;default:true;index" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type Category struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;uniqueIndex;not null" json:"name"`
IconURL string `gorm:"size:512" json:"icon_url"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
Enabled bool `gorm:"not null;default:true;index" json:"enabled"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type Store struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null;index" json:"name"`
CategoryID uint `gorm:"not null;index" json:"category_id"`
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
Address string `gorm:"size:255;not null" json:"address"`
Lat *float64 `gorm:"index" json:"-"`
Lng *float64 `gorm:"index" json:"-"`
OpenHours string `gorm:"size:128" json:"open_hours"`
Phone string `gorm:"size:64" json:"phone"`
CoverURL string `gorm:"size:512" json:"cover_url"`
Description string `gorm:"type:text" json:"description"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"` // active/inactive
Images []StoreImage `gorm:"foreignKey:StoreID" json:"images"`
Dishes []SignatureDish `gorm:"foreignKey:StoreID" json:"dishes"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type StoreImage struct {
ID uint `gorm:"primaryKey" json:"id"`
StoreID uint `gorm:"not null;index" json:"store_id"`
URL string `gorm:"size:512;not null" json:"url"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type SignatureDish struct {
ID uint `gorm:"primaryKey" json:"id"`
StoreID uint `gorm:"not null;index" json:"store_id"`
Name string `gorm:"size:128;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
ImageURL string `gorm:"size:512" json:"image_url"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type Review struct {
ID uint `gorm:"primaryKey" json:"id"`
StoreID uint `gorm:"not null;index" json:"store_id"`
Store Store `gorm:"foreignKey:StoreID" json:"store"`
UserID *uint `gorm:"index" json:"user_id"`
UserName string `gorm:"size:64" json:"user_name"`
Rating int `gorm:"not null;index" json:"rating"` // 1-5
Content string `gorm:"type:text" json:"content"`
ImageURLs datatypes.JSON `gorm:"type:json" json:"image_urls"`
// RecommendDishes: [{"name":"锅包肉","image_url":"","like":true}]
RecommendDishes datatypes.JSON `gorm:"type:json" json:"recommend_dishes"`
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending/approved/blocked
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type MerchantApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
StoreName string `gorm:"size:128;not null;index" json:"store_name"`
CategoryID uint `gorm:"not null;index" json:"category_id"`
Address string `gorm:"size:255;not null" json:"address"`
Lat *float64 `gorm:"index" json:"-"`
Lng *float64 `gorm:"index" json:"-"`
OpenHours string `gorm:"size:128" json:"open_hours"`
Phone string `gorm:"size:64" json:"phone"`
CoverURL string `gorm:"size:512" json:"cover_url"`
ImageURLs datatypes.JSON `gorm:"type:json" json:"image_urls"`
Description string `gorm:"type:text" json:"description"`
ContactName string `gorm:"size:64" json:"contact_name"`
ContactPhone string `gorm:"size:64" json:"contact_phone"`
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending/approved/rejected
RejectReason string `gorm:"type:text" json:"reject_reason"`
ReviewedAt *time.Time `gorm:"index" json:"reviewed_at"`
ReviewerID *uint `gorm:"index" json:"reviewer_id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
DouyinOpenID *string `gorm:"size:128;uniqueIndex" json:"douyin_openid,omitempty"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"` // active/disabled
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type StoreLike struct {
ID uint `gorm:"primaryKey" json:"id"`
StoreID uint `gorm:"not null;index;uniqueIndex:uk_store_user" json:"store_id"`
UserID uint `gorm:"not null;index;uniqueIndex:uk_store_user" json:"user_id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
type StoreMetric struct {
ID uint `gorm:"primaryKey" json:"id"`
StoreID uint `gorm:"not null;uniqueIndex" json:"store_id"`
LikesCount int64 `gorm:"not null;default:0;index" json:"likes_count"`
SearchCount int64 `gorm:"not null;default:0;index" json:"search_count"`
ReviewsCount int64 `gorm:"not null;default:0;index" json:"reviews_count"`
Score float64 `gorm:"not null;default:0;index" json:"score"`
UpdatedAt time.Time `gorm:"index" json:"updated_at"`
}
type SystemSetting struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:128;uniqueIndex;not null" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type APIKey struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Prefix string `gorm:"size:16;index;not null" json:"prefix"`
HashSHA256 string `gorm:"size:64;uniqueIndex;not null" json:"-"`
Status string `gorm:"size:16;index;not null;default:'active'" json:"status"` // active/revoked
LastUsedAt *time.Time `gorm:"index" json:"last_used_at"`
RevokedAt *time.Time `gorm:"index" json:"revoked_at"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

View File

@@ -0,0 +1,23 @@
package redisx
import (
"time"
"0451meishiditu/backend/internal/config"
"github.com/redis/go-redis/v9"
)
func New(cfg config.Config) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
DialTimeout: 3 * time.Second,
ReadTimeout: 2 * time.Second,
WriteTimeout: 2 * time.Second,
PoolSize: 20,
MinIdleConns: 2,
})
}

View File

@@ -0,0 +1,27 @@
package resp
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Body struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Meta interface{} `json:"meta,omitempty"`
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Body{Code: 0, Message: "ok", Data: data})
}
func OKMeta(c *gin.Context, data interface{}, meta interface{}) {
c.JSON(http.StatusOK, Body{Code: 0, Message: "ok", Data: data, Meta: meta})
}
func Fail(c *gin.Context, httpStatus int, msg string) {
c.JSON(httpStatus, Body{Code: httpStatus, Message: msg})
}

View File

@@ -0,0 +1,36 @@
package seed
import (
"errors"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func EnsureInitialAdmin(db *gorm.DB, cfg config.Config) error {
var cnt int64
if err := db.Model(&models.AdminUser{}).Count(&cnt).Error; err != nil {
return err
}
if cnt > 0 {
return nil
}
if cfg.AdminInitUsername == "" || cfg.AdminInitPassword == "" {
return errors.New("ADMIN_INIT_USERNAME and ADMIN_INIT_PASSWORD are required for initial seed")
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminInitPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.Create(&models.AdminUser{
Username: cfg.AdminInitUsername,
PasswordHash: string(hash),
Role: "admin",
}).Error
}

View File

@@ -0,0 +1,102 @@
package settings
import (
"strings"
"sync"
"0451meishiditu/backend/internal/config"
"0451meishiditu/backend/internal/models"
"gorm.io/gorm"
)
const keyCORSAllowOrigins = "cors_allow_origins"
type Store struct {
mu sync.RWMutex
origins map[string]struct{}
}
func New(db *gorm.DB, cfg config.Config) (*Store, error) {
s := &Store{origins: map[string]struct{}{}}
// init from DB if exists; otherwise seed from env
var row models.SystemSetting
err := db.Where("`key` = ?", keyCORSAllowOrigins).First(&row).Error
if err == nil {
s.SetCORSAllowOrigins(parseOrigins(row.Value))
return s, nil
}
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
s.SetCORSAllowOrigins(cfg.CORSAllowOrigins)
_ = db.Create(&models.SystemSetting{Key: keyCORSAllowOrigins, Value: strings.Join(cfg.CORSAllowOrigins, ",")}).Error
return s, nil
}
func (s *Store) CORSAllowOrigins() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.origins))
for o := range s.origins {
out = append(out, o)
}
return out
}
func (s *Store) CORSAllowOrigin(origin string) bool {
origin = strings.TrimSpace(origin)
if origin == "" {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
if _, ok := s.origins["*"]; ok {
return true
}
_, ok := s.origins[origin]
return ok
}
func (s *Store) SetCORSAllowOrigins(origins []string) {
m := map[string]struct{}{}
for _, o := range origins {
v := strings.TrimSpace(o)
if v == "" {
continue
}
m[v] = struct{}{}
}
s.mu.Lock()
s.origins = m
s.mu.Unlock()
}
func parseOrigins(raw string) []string {
var out []string
for _, part := range strings.Split(raw, ",") {
v := strings.TrimSpace(part)
if v != "" {
out = append(out, v)
}
}
return out
}
func UpsertCORSAllowOrigins(db *gorm.DB, origins []string) error {
val := strings.Join(origins, ",")
return db.Transaction(func(tx *gorm.DB) error {
var row models.SystemSetting
err := tx.Where("`key` = ?", keyCORSAllowOrigins).First(&row).Error
if err == nil {
return tx.Model(&models.SystemSetting{}).Where("id = ?", row.ID).Update("value", val).Error
}
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
return tx.Create(&models.SystemSetting{Key: keyCORSAllowOrigins, Value: val}).Error
})
}

View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
UPLOAD_DIR="${UPLOAD_DIR:-./static/upload}"
case "$UPLOAD_DIR" in
/*) abs="$UPLOAD_DIR" ;;
*) abs="/app/$UPLOAD_DIR" ;;
esac
mkdir -p "$abs"
chown -R appuser:appuser "$abs" || true
exec su-exec appuser /app/server

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

File diff suppressed because it is too large Load Diff

145
后端设计文档.md Normal file
View 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 APIWeb 端选点) | 用于手动标注店铺位置 |
| 构建部署 | 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
```