2
@@ -3,6 +3,7 @@ import axios from 'axios'
|
|||||||
const USE_MOCK = String(import.meta.env.VITE_USE_MOCK || 'false').toLowerCase() === 'true'
|
const USE_MOCK = String(import.meta.env.VITE_USE_MOCK || 'false').toLowerCase() === 'true'
|
||||||
const storageBase = ((): string => { try { return localStorage.getItem('API_BASE_URL') || '' } catch { return '' } })()
|
const storageBase = ((): string => { try { return localStorage.getItem('API_BASE_URL') || '' } catch { return '' } })()
|
||||||
const API_BASE_URL = (storageBase || import.meta.env.VITE_APP_API_BASE_URL || 'http://127.0.0.1:8080').replace(/\/$/, '')
|
const API_BASE_URL = (storageBase || import.meta.env.VITE_APP_API_BASE_URL || 'http://127.0.0.1:8080').replace(/\/$/, '')
|
||||||
|
export const API_BASE = API_BASE_URL
|
||||||
const SHOP_ID = ((): number => {
|
const SHOP_ID = ((): number => {
|
||||||
try { const v = localStorage.getItem('SHOP_ID'); if (v) return Number(v) } catch {}
|
try { const v = localStorage.getItem('SHOP_ID'); if (v) return Number(v) } catch {}
|
||||||
return Number(import.meta.env.VITE_APP_SHOP_ID || '1')
|
return Number(import.meta.env.VITE_APP_SHOP_ID || '1')
|
||||||
@@ -17,11 +18,20 @@ export const http = axios.create({ baseURL: API_BASE_URL, timeout: 15000 })
|
|||||||
http.interceptors.request.use(cfg => {
|
http.interceptors.request.use(cfg => {
|
||||||
cfg.headers = cfg.headers || {}
|
cfg.headers = cfg.headers || {}
|
||||||
if (!cfg.headers['X-Shop-Id']) cfg.headers['X-Shop-Id'] = String(SHOP_ID)
|
if (!cfg.headers['X-Shop-Id']) cfg.headers['X-Shop-Id'] = String(SHOP_ID)
|
||||||
|
cfg.headers['Accept'] = cfg.headers['Accept'] || 'application/json,application/octet-stream'
|
||||||
const uid = ((): string => {
|
const uid = ((): string => {
|
||||||
try { const v = localStorage.getItem('USER_ID') || '' ; if (v) return v } catch {}
|
try { const v = localStorage.getItem('USER_ID') || '' ; if (v) return v } catch {}
|
||||||
return String((import.meta as any).env?.VITE_ADMIN_USER_ID || '')
|
return String((import.meta as any).env?.VITE_ADMIN_USER_ID || '')
|
||||||
})()
|
})()
|
||||||
if (uid) cfg.headers['X-User-Id'] = uid
|
if (uid) cfg.headers['X-User-Id'] = uid
|
||||||
|
// 管理端接口需要 X-Admin-Id 头
|
||||||
|
const aid = ((): string => {
|
||||||
|
try { const v = localStorage.getItem('ADMIN_ID') || '' ; if (v) return v } catch {}
|
||||||
|
return String((import.meta as any).env?.VITE_ADMIN_ID || '')
|
||||||
|
})()
|
||||||
|
if (aid) cfg.headers['X-Admin-Id'] = aid
|
||||||
|
else if (uid) cfg.headers['X-Admin-Id'] = uid
|
||||||
|
else cfg.headers['X-Admin-Id'] = String(SHOP_ID)
|
||||||
return cfg
|
return cfg
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,4 +46,12 @@ export function post<T>(url: string, body?: any) { return http.post<T>(url, body
|
|||||||
export function put<T>(url: string, body?: any) { return http.put<T>(url, body).then(r => r.data) }
|
export function put<T>(url: string, body?: any) { return http.put<T>(url, body).then(r => r.data) }
|
||||||
export function del<T>(url: string, body?: any) { return http.delete<T>(url, { data: body }).then(r => r.data) }
|
export function del<T>(url: string, body?: any) { return http.delete<T>(url, { data: body }).then(r => r.data) }
|
||||||
|
|
||||||
|
export function withBaseUrl(u: string): string {
|
||||||
|
if (!u) return u
|
||||||
|
const s = String(u)
|
||||||
|
if (/^(?:https?:)?\/\//i.test(s) || s.startsWith('data:')) return s
|
||||||
|
if (s.startsWith('/')) return API_BASE_URL + s
|
||||||
|
return API_BASE_URL + '/' + s
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import './styles/theme.scss'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
|
// 自动注入管理员身份(仅当本地未设置时)
|
||||||
|
try {
|
||||||
|
const k = 'USER_ID'
|
||||||
|
const existed = localStorage.getItem(k)
|
||||||
|
if (!existed || !existed.trim()) {
|
||||||
|
// 可被环境变量覆盖:VITE_ADMIN_USER_ID
|
||||||
|
const envUid = String((import.meta as any).env?.VITE_ADMIN_USER_ID || '')
|
||||||
|
localStorage.setItem(k, envUid || '1')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/',
|
path: '/',
|
||||||
component: Shell,
|
component: Shell,
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirect: '/vip-review' },
|
{ path: '', redirect: '/vip/system' },
|
||||||
{ path: 'vip-review', component: () => import('../views/vip/VipReview.vue') },
|
{ path: 'vip/system', component: () => import('../views/vip/VipSystem.vue') },
|
||||||
{ path: 'vip', component: () => import('../views/vip/VipList.vue') },
|
{ path: 'vip/list', component: () => import('../views/vip/VipList.vue') },
|
||||||
{ path: 'users', component: () => import('../views/users/UserList.vue') },
|
{ path: 'users', component: () => import('../views/users/UserList.vue') },
|
||||||
{ path: 'parts', component: () => import('../views/parts/UserParts.vue') },
|
{ path: 'parts', component: () => import('../views/parts/UserParts.vue') },
|
||||||
|
{ path: 'parts/submissions', component: () => import('../views/parts/Submissions.vue') },
|
||||||
|
{ path: 'parts/templates', component: () => import('../views/parts/Templates.vue') },
|
||||||
{ path: 'consult', component: () => import('../views/consult/ConsultList.vue') }
|
{ path: 'consult', component: () => import('../views/consult/ConsultList.vue') }
|
||||||
|
,{ path: 'notice/list', component: () => import('../views/notice/NoticeList.vue') }
|
||||||
,{ path: 'dict/units', component: () => import('../views/dict/Units.vue') }
|
,{ path: 'dict/units', component: () => import('../views/dict/Units.vue') }
|
||||||
,{ path: 'dict/categories', component: () => import('../views/dict/Categories.vue') }
|
,{ path: 'dict/categories', component: () => import('../views/dict/Categories.vue') }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,11 +6,14 @@
|
|||||||
<span class="name">配件管家·管理端</span>
|
<span class="name">配件管家·管理端</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="active" router background-color="transparent" text-color="var(--muted-color)" active-text-color="var(--primary-royal)">
|
<el-menu :default-active="active" router background-color="transparent" text-color="var(--muted-color)" active-text-color="var(--primary-royal)">
|
||||||
<el-menu-item index="/vip-review"><i class="el-icon-star-off"></i><span>VIP审核</span></el-menu-item>
|
<el-menu-item index="/vip/system"><i class="el-icon-setting"></i><span>VIP系统</span></el-menu-item>
|
||||||
<el-menu-item index="/vip"><i class="el-icon-star-on"></i><span>VIP管理</span></el-menu-item>
|
<el-menu-item index="/vip/list"><i class="el-icon-star-on"></i><span>VIP列表</span></el-menu-item>
|
||||||
<el-menu-item index="/users"><i class="el-icon-user"></i><span>用户管理</span></el-menu-item>
|
<el-menu-item index="/users"><i class="el-icon-user"></i><span>用户管理</span></el-menu-item>
|
||||||
<el-menu-item index="/parts"><i class="el-icon-box"></i><span>用户配件管理</span></el-menu-item>
|
<el-menu-item index="/parts"><i class="el-icon-box"></i><span>用户配件管理</span></el-menu-item>
|
||||||
|
<el-menu-item index="/parts/submissions"><i class="el-icon-finished"></i><span>配件审核</span></el-menu-item>
|
||||||
|
<el-menu-item index="/parts/templates"><i class="el-icon-collection"></i><span>模板管理</span></el-menu-item>
|
||||||
<el-menu-item index="/consult"><i class="el-icon-message"></i><span>咨询回复</span></el-menu-item>
|
<el-menu-item index="/consult"><i class="el-icon-message"></i><span>咨询回复</span></el-menu-item>
|
||||||
|
<el-menu-item index="/notice/list"><i class="el-icon-notebook-1"></i><span>公告管理</span></el-menu-item>
|
||||||
<el-menu-item index="/dict/units"><i class="el-icon-collection"></i><span>主单位</span></el-menu-item>
|
<el-menu-item index="/dict/units"><i class="el-icon-collection"></i><span>主单位</span></el-menu-item>
|
||||||
<el-menu-item index="/dict/categories"><i class="el-icon-collection-tag"></i><span>主类别</span></el-menu-item>
|
<el-menu-item index="/dict/categories"><i class="el-icon-collection-tag"></i><span>主类别</span></el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
|
|||||||
@@ -20,19 +20,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { get } from '../../../api/http'
|
||||||
|
|
||||||
interface Props {
|
const isVip = ref(false)
|
||||||
isVip: boolean
|
const vipStart = ref<string | Date | null>('')
|
||||||
vipStart?: string | Date | null
|
const vipEnd = ref<string | Date | null>('')
|
||||||
vipEnd?: string | Date | null
|
const placeholder = ref('-')
|
||||||
placeholder?: string
|
|
||||||
|
const props = withDefaults(defineProps<{ userId?: number }>(), { userId: undefined })
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
async function loadVipByUser(userId?: number){
|
||||||
|
try {
|
||||||
|
if (!userId) return
|
||||||
|
const data = await get(`/api/admin/vips/user/${userId}`)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const active = Number(data.isVip || 0) === 1 && Number(data.status || 0) === 1 && (!!data.expireAt ? new Date(data.expireAt).getTime() >= Date.now() : true)
|
||||||
|
isVip.value = active
|
||||||
|
vipStart.value = data.createdAt || ''
|
||||||
|
vipEnd.value = data.expireAt || ''
|
||||||
|
} else {
|
||||||
|
isVip.value = false
|
||||||
|
vipStart.value = ''
|
||||||
|
vipEnd.value = ''
|
||||||
|
}
|
||||||
|
} catch(e) { isVip.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
placeholder: '-'
|
|
||||||
})
|
|
||||||
|
|
||||||
function toDisplay(value?: string | Date | null, placeholder = '-') {
|
function toDisplay(value?: string | Date | null, placeholder = '-') {
|
||||||
if (value === null || value === undefined || value === '') return placeholder
|
if (value === null || value === undefined || value === '') return placeholder
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
@@ -46,8 +62,23 @@ function toDisplay(value?: string | Date | null, placeholder = '-') {
|
|||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayStart = computed(() => toDisplay(props.vipStart, props.placeholder))
|
const displayStart = computed(() => toDisplay(vipStart.value, placeholder.value))
|
||||||
const displayEnd = computed(() => toDisplay(props.vipEnd, props.placeholder))
|
const displayEnd = computed(() => toDisplay(vipEnd.value, placeholder.value))
|
||||||
|
|
||||||
|
function resolveUserId(): number | undefined {
|
||||||
|
try {
|
||||||
|
if (props.userId) return Number(props.userId)
|
||||||
|
const qId = (route.query?.userId as any) || (route.params?.userId as any)
|
||||||
|
if (qId) return Number(qId)
|
||||||
|
const ls = ((): number | undefined => { try { const v = localStorage.getItem('USER_ID') || ''; return v?Number(v):undefined } catch { return undefined } })()
|
||||||
|
return ls
|
||||||
|
} catch { return undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { const id = resolveUserId(); if (id) loadVipByUser(id) })
|
||||||
|
watch(() => [props.userId, route.fullPath], () => { const id = resolveUserId(); if (id) loadVipByUser(id) })
|
||||||
|
|
||||||
|
defineExpose({ loadVipByUser })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<el-select v-model="q.status" style="width:140px"><el-option label="未解决" value="open" /><el-option label="已解决" value="resolved" /></el-select>
|
<el-select v-model="q.status" style="width:140px"><el-option label="未解决" value="open" /><el-option label="已解决" value="resolved" /></el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input v-model="q.kw" placeholder="主题/内容/用户ID" clearable />
|
<el-input v-model="q.kw" placeholder="内容/用户ID" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="fetch">查询</el-button>
|
<el-button type="primary" @click="fetch">查询</el-button>
|
||||||
@@ -19,15 +19,16 @@
|
|||||||
<el-table :data="rows" size="large" @row-dblclick="openReply">
|
<el-table :data="rows" size="large" @row-dblclick="openReply">
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||||
<el-table-column prop="topic" label="主题" width="220" />
|
|
||||||
<el-table-column prop="message" label="内容" />
|
<el-table-column prop="message" label="内容" />
|
||||||
|
<el-table-column prop="replyContent" label="回复内容" />
|
||||||
<el-table-column label="状态" width="120">
|
<el-table-column label="状态" width="120">
|
||||||
<template #default="{row}"><el-tag :type="row.status==='resolved'?'success':'warning'">{{ row.status==='resolved'?'已解决':'未解决' }}</el-tag></template>
|
<template #default="{row}"><el-tag :type="row.status==='resolved'?'success':'warning'">{{ row.status==='resolved'?'已解决':'未解决' }}</el-tag></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column fixed="right" label="操作" width="220">
|
<el-table-column fixed="right" label="操作" width="220">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-button size="small" type="primary" @click="openReply(row)">回复</el-button>
|
<el-button size="small" type="primary" @click="openReply(row)" :disabled="row.status==='resolved'">回复</el-button>
|
||||||
<el-button size="small" @click="resolve(row)" v-if="row.status!=='resolved'">标记解决</el-button>
|
<el-button size="small" @click="resolve(row)" v-if="row.status!=='resolved'">标记解决</el-button>
|
||||||
|
<el-button size="small" v-if="row.replyContent" @click="viewReply(row)">查看回复</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { get, post, put } from '../../api/http'
|
import { get, post, put } from '../../api/http'
|
||||||
|
|
||||||
const q = reactive({ status: 'open', kw: '' })
|
const q = reactive({ status: 'open', kw: '' })
|
||||||
@@ -64,6 +66,7 @@ function reset(){ q.status='open'; q.kw=''; fetch() }
|
|||||||
function openReply(row: any){ visible.value = true; current.value = row; reply.value='' }
|
function openReply(row: any){ visible.value = true; current.value = row; reply.value='' }
|
||||||
async function sendReply(){ await post(`/api/admin/consults/${current.value.id}/reply`, { content: reply.value }); visible.value=false; await fetch() }
|
async function sendReply(){ await post(`/api/admin/consults/${current.value.id}/reply`, { content: reply.value }); visible.value=false; await fetch() }
|
||||||
async function resolve(row: any){ await put(`/api/admin/consults/${row.id}/resolve`, {}); row.status = 'resolved' }
|
async function resolve(row: any){ await put(`/api/admin/consults/${row.id}/resolve`, {}); row.status = 'resolved' }
|
||||||
|
function viewReply(row: any){ ElMessageBox.alert(row.replyContent || '无', '已回复内容', { confirmButtonText: '知道了' }) }
|
||||||
|
|
||||||
onMounted(fetch)
|
onMounted(fetch)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, onMounted } from 'vue'
|
import { reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { get, post, put, del } from '../../api/http'
|
import { get, post, put, del } from '../../api/http'
|
||||||
|
|
||||||
type Category = { id: number; name: string }
|
type Category = { id: number; name: string }
|
||||||
@@ -38,28 +39,43 @@ async function reload() {
|
|||||||
try {
|
try {
|
||||||
const res = await get<any>('/api/product-categories')
|
const res = await get<any>('/api/product-categories')
|
||||||
state.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
state.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '加载失败')
|
||||||
} finally { state.loading = false }
|
} finally { state.loading = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
if (!state.newName.trim()) return
|
if (!state.newName || !state.newName.trim()) { ElMessage.warning('请输入名称'); return }
|
||||||
state.loading = true
|
state.loading = true
|
||||||
try {
|
try {
|
||||||
await post('/api/admin/dicts/categories', { name: state.newName.trim() })
|
await post('/api/admin/dicts/categories', { name: state.newName.trim() })
|
||||||
state.newName = ''
|
state.newName = ''
|
||||||
await reload()
|
await reload()
|
||||||
|
ElMessage.success('已新增')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '新增失败')
|
||||||
} finally { state.loading = false }
|
} finally { state.loading = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function update(row: Category) {
|
async function update(row: Category) {
|
||||||
if (!row?.id || !row.name?.trim()) return
|
if (!row?.id || !row.name?.trim()) return
|
||||||
await put(`/api/admin/dicts/categories/${row.id}`, { name: row.name.trim() })
|
try {
|
||||||
|
await put(`/api/admin/dicts/categories/${row.id}`, { name: row.name.trim() })
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '保存失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(row: Category) {
|
async function remove(row: Category) {
|
||||||
if (!row?.id) return
|
if (!row?.id) return
|
||||||
await del(`/api/admin/dicts/categories/${row.id}`)
|
try {
|
||||||
await reload()
|
await del(`/api/admin/dicts/categories/${row.id}`)
|
||||||
|
await reload()
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(reload)
|
onMounted(reload)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, onMounted } from 'vue'
|
import { reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { get, post, put, del } from '../../api/http'
|
import { get, post, put, del } from '../../api/http'
|
||||||
|
|
||||||
type Unit = { id: number; name: string }
|
type Unit = { id: number; name: string }
|
||||||
@@ -38,28 +39,43 @@ async function reload() {
|
|||||||
try {
|
try {
|
||||||
const res = await get<any>('/api/product-units')
|
const res = await get<any>('/api/product-units')
|
||||||
state.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
state.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '加载失败')
|
||||||
} finally { state.loading = false }
|
} finally { state.loading = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
if (!state.newName.trim()) return
|
if (!state.newName || !state.newName.trim()) { ElMessage.warning('请输入名称'); return }
|
||||||
state.loading = true
|
state.loading = true
|
||||||
try {
|
try {
|
||||||
await post('/api/admin/dicts/units', { name: state.newName.trim() })
|
await post('/api/admin/dicts/units', { name: state.newName.trim() })
|
||||||
state.newName = ''
|
state.newName = ''
|
||||||
await reload()
|
await reload()
|
||||||
|
ElMessage.success('已新增')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '新增失败')
|
||||||
} finally { state.loading = false }
|
} finally { state.loading = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function update(row: Unit) {
|
async function update(row: Unit) {
|
||||||
if (!row?.id || !row.name?.trim()) return
|
if (!row?.id || !row.name?.trim()) return
|
||||||
await put(`/api/admin/dicts/units/${row.id}`, { name: row.name.trim() })
|
try {
|
||||||
|
await put(`/api/admin/dicts/units/${row.id}`, { name: row.name.trim() })
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '保存失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(row: Unit) {
|
async function remove(row: Unit) {
|
||||||
if (!row?.id) return
|
if (!row?.id) return
|
||||||
await del(`/api/admin/dicts/units/${row.id}`)
|
try {
|
||||||
await reload()
|
await del(`/api/admin/dicts/units/${row.id}`)
|
||||||
|
await reload()
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(reload)
|
onMounted(reload)
|
||||||
|
|||||||
201
admin/src/views/notice/NoticeList.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-title"><span class="royal">📢</span> 公告管理</div>
|
||||||
|
|
||||||
|
<div class="panel" style="padding: 12px; margin-bottom: 12px;">
|
||||||
|
<el-form :inline="true" :model="q">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="q.status" placeholder="全部" clearable style="width: 160px;">
|
||||||
|
<el-option label="全部" :value="''" />
|
||||||
|
<el-option label="草稿" value="draft" />
|
||||||
|
<el-option label="已发布" value="published" />
|
||||||
|
<el-option label="已下线" value="offline" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input v-model="q.kw" placeholder="标题/内容" clearable style="width: 240px;" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchList">查询</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item style="float:right; margin-left:auto;">
|
||||||
|
<el-button type="success" @click="openEdit()">新建公告</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="padding:0;">
|
||||||
|
<el-table :data="rows" style="width:100%" size="large" :border="false">
|
||||||
|
<el-table-column type="index" width="60" label="#" />
|
||||||
|
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="tag" label="标签" width="120" />
|
||||||
|
<el-table-column prop="pinned" label="置顶" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.pinned" type="warning">YES</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="startsAt" label="开始" width="180" />
|
||||||
|
<el-table-column prop="endsAt" label="结束" width="180" />
|
||||||
|
<el-table-column prop="status" label="状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="publish(row)" :disabled="row.status==='published'">发布</el-button>
|
||||||
|
<el-button size="small" type="warning" @click="offline(row)" :disabled="row.status==='offline'">下线</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dlg.visible" :title="dlgTitle" width="680px">
|
||||||
|
<el-form :model="form" label-width="100px">
|
||||||
|
<el-form-item label="标题" required>
|
||||||
|
<el-input v-model="form.title" maxlength="120" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内容" required>
|
||||||
|
<el-input v-model="form.content" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签">
|
||||||
|
<el-input v-model="form.tag" maxlength="32" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="置顶">
|
||||||
|
<el-switch v-model="form.pinned" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始时间">
|
||||||
|
<el-date-picker v-model="form.startsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssXXX" placeholder="不填即刻生效" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结束时间">
|
||||||
|
<el-date-picker v-model="form.endsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssXXX" placeholder="不填长期有效" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="form.status" style="width: 160px;">
|
||||||
|
<el-option label="草稿" value="draft" />
|
||||||
|
<el-option label="已发布" value="published" />
|
||||||
|
<el-option label="已下线" value="offline" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dlg.visible=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { get, post, put } from '../../api/http'
|
||||||
|
|
||||||
|
type Notice = {
|
||||||
|
id?: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
tag?: string
|
||||||
|
pinned?: boolean
|
||||||
|
startsAt?: string | null
|
||||||
|
endsAt?: string | null
|
||||||
|
status?: 'draft'|'published'|'offline'
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = reactive({ status: '' as '' | 'draft' | 'published' | 'offline', kw: '' })
|
||||||
|
const rows = ref<Notice[]>([])
|
||||||
|
const dlg = reactive({ visible: false, editingId: 0 as number })
|
||||||
|
const form = reactive<Notice>({ title: '', content: '', tag: '', pinned: false, status: 'draft', startsAt: '', endsAt: '' })
|
||||||
|
|
||||||
|
const dlgTitle = computed(() => dlg.editingId ? '编辑公告' : '新建公告')
|
||||||
|
|
||||||
|
function statusLabel(s?: string) {
|
||||||
|
if (s === 'published') return '已发布'; if (s === 'offline') return '已下线'; return '草稿'
|
||||||
|
}
|
||||||
|
function statusTagType(s?: string) {
|
||||||
|
if (s === 'published') return 'success'; if (s === 'offline') return 'info'; return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQuery(){ q.status=''; q.kw=''; fetchList() }
|
||||||
|
|
||||||
|
async function fetchList(){
|
||||||
|
const data = await get<any>('/api/admin/notices', { status: q.status || undefined, kw: q.kw || undefined })
|
||||||
|
const list = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
||||||
|
rows.value = list.map((n: any) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
content: n.content,
|
||||||
|
tag: n.tag,
|
||||||
|
pinned: !!n.pinned,
|
||||||
|
startsAt: n.startsAt ? toDateTimeString(n.startsAt) : '',
|
||||||
|
endsAt: n.endsAt ? toDateTimeString(n.endsAt) : '',
|
||||||
|
status: n.status,
|
||||||
|
createdAt: toDateTimeString(n.createdAt),
|
||||||
|
updatedAt: toDateTimeString(n.updatedAt)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row?: Notice){
|
||||||
|
dlg.editingId = row?.id || 0
|
||||||
|
form.title = row?.title || ''
|
||||||
|
form.content = row?.content || ''
|
||||||
|
form.tag = row?.tag || ''
|
||||||
|
form.pinned = !!row?.pinned
|
||||||
|
form.startsAt = row?.startsAt || ''
|
||||||
|
form.endsAt = row?.endsAt || ''
|
||||||
|
form.status = (row?.status as any) || 'draft'
|
||||||
|
dlg.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(){
|
||||||
|
const payload: any = {
|
||||||
|
title: (form.title||'').trim(),
|
||||||
|
content: (form.content||'').trim(),
|
||||||
|
tag: form.tag||'',
|
||||||
|
pinned: !!form.pinned,
|
||||||
|
startsAt: form.startsAt || null,
|
||||||
|
endsAt: form.endsAt || null,
|
||||||
|
status: form.status
|
||||||
|
}
|
||||||
|
if (!payload.title) { return window.alert('请输入标题') }
|
||||||
|
if (!payload.content) { return window.alert('请输入内容') }
|
||||||
|
if (dlg.editingId) await put(`/api/admin/notices/${dlg.editingId}`, payload)
|
||||||
|
else await post('/api/admin/notices', payload)
|
||||||
|
dlg.visible = false
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish(row: Notice){
|
||||||
|
if (!row.id) return
|
||||||
|
await post(`/api/admin/notices/${row.id}/publish`)
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
async function offline(row: Notice){
|
||||||
|
if (!row.id) return
|
||||||
|
await post(`/api/admin/notices/${row.id}/offline`)
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeString(v: any): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(v)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
const pad = (n:number)=> String(n).padStart(2,'0')
|
||||||
|
const yyyy=d.getFullYear(), MM=pad(d.getMonth()+1), dd=pad(d.getDate()), hh=pad(d.getHours()), mm=pad(d.getMinutes()), ss=pad(d.getSeconds())
|
||||||
|
return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}`
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { position: relative; z-index: 1; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
322
admin/src/views/parts/Submissions.vue
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-title">配件审核</div>
|
||||||
|
<div class="panel" style="padding:12px; margin-bottom:12px;">
|
||||||
|
<el-form :inline="true" :model="query">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="query.status" placeholder="全部" style="width:140px;">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input v-model="query.kw" placeholder="型号/名称/品牌" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提交时间">
|
||||||
|
<el-date-picker v-model="query.range" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchList">查询</el-button>
|
||||||
|
<el-button @click="reset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item style="margin-left:auto;">
|
||||||
|
<el-button type="success" @click="exportExcel" :loading="exporting">导出Excel</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="padding:0;">
|
||||||
|
<el-table :data="rows" style="width:100%" :loading="loading">
|
||||||
|
<el-table-column type="index" width="60" label="#" />
|
||||||
|
<el-table-column prop="model" label="型号" width="160" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="180" />
|
||||||
|
<el-table-column prop="brand" label="品牌" width="120" />
|
||||||
|
<el-table-column prop="status" label="状态" width="120">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="提交时间" width="180">
|
||||||
|
<template #default="{row}">{{ formatDate(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="reviewedAt" label="审核时间" width="180">
|
||||||
|
<template #default="{row}">{{ formatDate(row.reviewedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="approve(row.id)" :disabled="row.status!=='pending'">通过</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="openReject(row.id)" :disabled="row.status!=='pending'">驳回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="prev, pager, next, jumper, ->, total"
|
||||||
|
:total="total"
|
||||||
|
:page-size="query.size"
|
||||||
|
:current-page="query.page"
|
||||||
|
@current-change="onPage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="detail.visible" title="配件详情" width="720px">
|
||||||
|
<el-form v-if="detail.data" :model="detail.data" label-width="108px" class="detail-form">
|
||||||
|
<el-form-item label="型号">
|
||||||
|
<el-input v-model="detail.data.model" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="detail.data.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="品牌">
|
||||||
|
<el-input v-model="detail.data.brand" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规格">
|
||||||
|
<el-input v-model="detail.data.spec" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模板">
|
||||||
|
<el-input v-model="detail.data.templateId" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="参数">
|
||||||
|
<el-table :data="paramRows" size="small" style="width:100%">
|
||||||
|
<el-table-column prop="fieldLabel" label="名称" />
|
||||||
|
<el-table-column prop="unit" label="单位" width="100" />
|
||||||
|
<el-table-column prop="required" label="必填" width="80">
|
||||||
|
<template #default="{row}"><el-tag :type="row.required?'danger':'info'">{{ row.required?'是':'否' }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="value" label="提交值" />
|
||||||
|
</el-table>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="参数JSON">
|
||||||
|
<el-input type="textarea" v-model="detail.parameterText" :rows="4" :placeholder="jsonPlaceholder" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input type="textarea" v-model="detail.data.remark" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="图片">
|
||||||
|
<div class="thumb-list">
|
||||||
|
<div v-for="(img,idx) in detail.images" :key="idx" class="thumb">
|
||||||
|
<el-image :src="withBase(img)" :preview-src-list="detail.images.map(withBase)" fit="cover" />
|
||||||
|
<el-button type="text" @click="removeImage(idx)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-upload
|
||||||
|
action="#"
|
||||||
|
:http-request="uploadImage"
|
||||||
|
:show-file-list="false"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<el-button>上传图片</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detail.visible=false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="saveDetail" :loading="saving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="reject.visible" title="驳回原因" width="420px">
|
||||||
|
<el-input type="textarea" v-model="reject.reason" placeholder="请输入驳回原因" :rows="4" />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="reject.visible=false">取消</el-button>
|
||||||
|
<el-button type="danger" @click="doReject" :loading="reject.loading">确认驳回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, onMounted, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { get, post, put, withBaseUrl, http } from '../../api/http'
|
||||||
|
|
||||||
|
const query = reactive({ status: '', kw: '', range: [], page: 1, size: 20 })
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
const detail = reactive({ visible: false, id: 0, data: null as any, images: [] as string[], parameterText: '', template: null as any })
|
||||||
|
const paramRows = ref<any[]>([])
|
||||||
|
const jsonPlaceholder = '{"key":"value"}'
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const reject = reactive({ visible: false, id: 0, reason: '', loading: false })
|
||||||
|
|
||||||
|
function statusLabel(status?: string) {
|
||||||
|
if (status === 'approved') return '已通过'
|
||||||
|
if (status === 'rejected') return '已驳回'
|
||||||
|
return '待审核'
|
||||||
|
}
|
||||||
|
function statusType(status?: string) {
|
||||||
|
if (status === 'approved') return 'success'
|
||||||
|
if (status === 'rejected') return 'danger'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
function formatDate(v?: string) {
|
||||||
|
if (!v) return '-'
|
||||||
|
try {
|
||||||
|
const d = new Date(v)
|
||||||
|
if (Number.isNaN(d.getTime())) return '-'
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
|
||||||
|
} catch { return '-' }
|
||||||
|
}
|
||||||
|
function withBase(u: string){ return withBaseUrl(u) }
|
||||||
|
|
||||||
|
async function fetchList(){
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { status: query.status || undefined, kw: query.kw || undefined, page: query.page, size: query.size }
|
||||||
|
if (Array.isArray(query.range) && query.range.length === 2) {
|
||||||
|
params.startAt = query.range[0]
|
||||||
|
params.endAt = query.range[1]
|
||||||
|
}
|
||||||
|
const res = await get('/api/admin/parts/submissions', params)
|
||||||
|
rows.value = Array.isArray(res?.list) ? res.list : []
|
||||||
|
total.value = Number(res?.total || rows.value.length)
|
||||||
|
} catch (e:any) {
|
||||||
|
ElMessage.error(e.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function reset(){
|
||||||
|
query.status=''
|
||||||
|
query.kw=''
|
||||||
|
query.range=[]
|
||||||
|
query.page=1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
function onPage(p:number){ query.page = p; fetchList() }
|
||||||
|
|
||||||
|
async function openDetail(id:number){
|
||||||
|
try {
|
||||||
|
const data = await get(`/api/admin/parts/submissions/${id}`)
|
||||||
|
detail.visible = true
|
||||||
|
detail.id = id
|
||||||
|
detail.data = data
|
||||||
|
detail.images = Array.isArray(data?.images) ? [...data.images] : []
|
||||||
|
detail.parameterText = data?.parameters ? JSON.stringify(data.parameters, null, 2) : ''
|
||||||
|
paramRows.value = []
|
||||||
|
detail.template = null
|
||||||
|
if (data?.templateId) {
|
||||||
|
try {
|
||||||
|
const t = await get(`/api/admin/part-templates/${data.templateId}`)
|
||||||
|
detail.template = t
|
||||||
|
const mp = data?.parameters || {}
|
||||||
|
paramRows.value = (t?.params||[]).map((p:any)=>({ fieldLabel: p.fieldLabel, unit: p.unit, required: !!p.required, value: mp[p.fieldKey] }))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch (e:any) {
|
||||||
|
ElMessage.error(e.message || '加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(idx:number){ detail.images.splice(idx,1) }
|
||||||
|
async function uploadImage(options:any){
|
||||||
|
try {
|
||||||
|
const file = options.file
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await fetch(withBaseUrl('/api/attachments'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('上传失败')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data && data.url) {
|
||||||
|
detail.images.push(data.url)
|
||||||
|
options.onSuccess(data, file)
|
||||||
|
} else {
|
||||||
|
throw new Error('上传失败')
|
||||||
|
}
|
||||||
|
} catch (e:any) {
|
||||||
|
options.onError(e)
|
||||||
|
ElMessage.error(e.message || '上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDetail(){
|
||||||
|
if (!detail.data) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
let paramsObj: any = null
|
||||||
|
if (detail.parameterText) {
|
||||||
|
try { paramsObj = JSON.parse(detail.parameterText) } catch { return ElMessage.warning('参数JSON格式错误') }
|
||||||
|
}
|
||||||
|
await put(`/api/admin/parts/submissions/${detail.id}`, {
|
||||||
|
name: detail.data.name,
|
||||||
|
brand: detail.data.brand,
|
||||||
|
spec: detail.data.spec,
|
||||||
|
unitId: detail.data.unitId,
|
||||||
|
categoryId: detail.data.categoryId,
|
||||||
|
parameters: paramsObj,
|
||||||
|
images: detail.images,
|
||||||
|
remark: detail.data.remark,
|
||||||
|
barcode: detail.data.barcode
|
||||||
|
})
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
detail.visible=false
|
||||||
|
fetchList()
|
||||||
|
} catch (e:any) {
|
||||||
|
ElMessage.error(e.message || '保存失败')
|
||||||
|
} finally { saving.value=false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve(id:number){
|
||||||
|
try {
|
||||||
|
await post(`/api/admin/parts/submissions/${id}/approve`, {})
|
||||||
|
ElMessage.success('已通过')
|
||||||
|
fetchList()
|
||||||
|
} catch (e:any) { ElMessage.error(e.message || '操作失败') }
|
||||||
|
}
|
||||||
|
function openReject(id:number){ reject.visible=true; reject.id=id; reject.reason='' }
|
||||||
|
async function doReject(){
|
||||||
|
if (!reject.reason.trim()) return ElMessage.warning('请输入驳回原因')
|
||||||
|
reject.loading=true
|
||||||
|
try {
|
||||||
|
await post(`/api/admin/parts/submissions/${reject.id}/reject`, { remark: reject.reason })
|
||||||
|
reject.visible=false
|
||||||
|
detail.visible=false
|
||||||
|
ElMessage.success('已驳回')
|
||||||
|
fetchList()
|
||||||
|
} catch (e:any) {
|
||||||
|
ElMessage.error(e.message || '操作失败')
|
||||||
|
} finally { reject.loading=false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportExcel(){
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { status: query.status || undefined, kw: query.kw || undefined }
|
||||||
|
if (Array.isArray(query.range) && query.range.length === 2) {
|
||||||
|
params.startAt = query.range[0]
|
||||||
|
params.endAt = query.range[1]
|
||||||
|
}
|
||||||
|
const res = await http.get('/api/admin/parts/submissions/export', { params, responseType: 'blob' })
|
||||||
|
const blob = res.data
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = `配件审核_${Date.now()}.xlsx`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
} catch (e:any) { ElMessage.error(e.message || '导出失败') }
|
||||||
|
finally { exporting.value=false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { position: relative; }
|
||||||
|
.pager { padding: 12px; display:flex; justify-content:flex-end; }
|
||||||
|
.detail-form { max-height: 60vh; overflow-y:auto; }
|
||||||
|
.thumb-list { display:flex; flex-wrap:wrap; gap:12px; }
|
||||||
|
.thumb { width: 120px; display:flex; flex-direction: column; gap: 8px; }
|
||||||
|
.thumb .el-image { width: 120px; height: 120px; border-radius: 8px; }
|
||||||
|
</style>
|
||||||
173
admin/src/views/parts/Templates.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="header">
|
||||||
|
<h2>模板管理</h2>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" @click="openCreate()">新建模板</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form inline :model="query">
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="query.categoryId" clearable placeholder="全部分类" @change="load()" style="width:200px">
|
||||||
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-table :data="list" style="width:100%" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="模板名" />
|
||||||
|
<el-table-column prop="categoryId" label="分类ID" width="100" />
|
||||||
|
<el-table-column prop="modelRule" label="型号规则" />
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.status===1?'success':'info'">{{ row.status===1?'启用':'停用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button type="primary" text @click="openEdit(row)">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="dlg.visible" :title="dlg.id? '编辑模板':'新建模板'" width="720">
|
||||||
|
<el-form :model="form" label-width="100px">
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="form.categoryId" placeholder="选择分类">
|
||||||
|
<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="form.name" maxlength="120" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="型号规则">
|
||||||
|
<el-input v-model="form.modelRule" placeholder="可填备注或正则" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-divider>参数字段</el-divider>
|
||||||
|
<div>
|
||||||
|
<el-button size="small" @click="addParam()">新增字段</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="form.params" size="small" style="width:100%;margin-top:10px">
|
||||||
|
<el-table-column label="#" width="48">
|
||||||
|
<template #default="{ $index }">{{ $index+1 }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="键">
|
||||||
|
<template #default="{row}"><el-input v-model="row.fieldKey" placeholder="英文字母/下划线" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="名称">
|
||||||
|
<template #default="{row}"><el-input v-model="row.fieldLabel" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="120">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-select v-model="row.type" style="width:110px">
|
||||||
|
<el-option label="string" value="string" />
|
||||||
|
<el-option label="number" value="number" />
|
||||||
|
<el-option label="boolean" value="boolean" />
|
||||||
|
<el-option label="enum" value="enum" />
|
||||||
|
<el-option label="date" value="date" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="必填" width="80">
|
||||||
|
<template #default="{row}"><el-switch v-model="row.required" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="单位" width="120">
|
||||||
|
<template #default="{row}"><el-input v-model="row.unit" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="枚举项">
|
||||||
|
<template #default="{row}"><el-input v-model="row.enumOptionsText" placeholder="逗号分隔(type=enum)" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="检索" width="80">
|
||||||
|
<template #default="{row}"><el-switch v-model="row.searchable" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="去重" width="80">
|
||||||
|
<template #default="{row}"><el-switch v-model="row.dedupeParticipate" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90">
|
||||||
|
<template #default="{ $index }"><el-button text type="danger" @click="removeParam($index)">删除</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dlg.visible=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="save()">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { http } from '../../api/http'
|
||||||
|
|
||||||
|
const list = ref<any[]>([])
|
||||||
|
const categories = ref<any[]>([])
|
||||||
|
const query = reactive({ categoryId: undefined as any })
|
||||||
|
|
||||||
|
const dlg = reactive({ visible: false, id: 0 })
|
||||||
|
const form = reactive<any>({ id: 0, categoryId: undefined, name: '', modelRule: '', status: 1, params: [] as any[] })
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
http.get('/api/product-templates', { params: { categoryId: query.categoryId } }).then(res => {
|
||||||
|
list.value = res.data.list || res.data || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function loadCategories() {
|
||||||
|
http.get('/api/product-categories').then(res => {
|
||||||
|
categories.value = res.data.list || res.data || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
dlg.visible = true; dlg.id = 0
|
||||||
|
Object.assign(form, { id:0, categoryId: undefined, name:'', modelRule:'', status:1, params:[] })
|
||||||
|
}
|
||||||
|
function openEdit(row:any) {
|
||||||
|
dlg.visible = true; dlg.id = row.id
|
||||||
|
http.get(`/api/admin/part-templates/${row.id}`).then(res => {
|
||||||
|
const d = res.data
|
||||||
|
Object.assign(form, { id: d.id, categoryId: d.categoryId, name: d.name, modelRule: d.modelRule, status: d.status,
|
||||||
|
params: (d.params||[]).map((p:any)=>({ ...p, enumOptionsText: (p.enumOptions||[]).join(',') })) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addParam() {
|
||||||
|
form.params.push({ fieldKey:'', fieldLabel:'', type:'string', required:false, unit:'', enumOptionsText:'', searchable:false, dedupeParticipate:false, sortOrder:0 })
|
||||||
|
}
|
||||||
|
function removeParam(i:number) {
|
||||||
|
form.params.splice(i,1)
|
||||||
|
}
|
||||||
|
function save() {
|
||||||
|
// 前端校验:fieldKey/fieldLabel/类型/重复
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const p of form.params) {
|
||||||
|
const key = String(p.fieldKey||'').trim()
|
||||||
|
const label = String(p.fieldLabel||'').trim()
|
||||||
|
if (!key) { ElMessage.warning('参数键不能为空'); return }
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { ElMessage.warning(`参数键不合法: ${key}`); return }
|
||||||
|
if (!label) { ElMessage.warning('参数名称不能为空'); return }
|
||||||
|
if (!['string','number','boolean','enum','date'].includes(p.type)) { ElMessage.warning(`不支持的类型: ${p.type}`); return }
|
||||||
|
if (seen.has(key)) { ElMessage.warning(`参数键重复: ${key}`); return }
|
||||||
|
seen.add(key)
|
||||||
|
}
|
||||||
|
const payload:any = { categoryId: form.categoryId, name: form.name, modelRule: form.modelRule, status: form.status,
|
||||||
|
params: form.params.map((p:any)=>({ fieldKey:p.fieldKey, fieldLabel:p.fieldLabel, type:p.type, required:p.required, unit:p.unit,
|
||||||
|
enumOptions: (p.enumOptionsText||'').split(',').map((s:string)=>s.trim()).filter((s:string)=>s), searchable:p.searchable, dedupeParticipate:p.dedupeParticipate, sortOrder:p.sortOrder })) }
|
||||||
|
const req = dlg.id ? http.put(`/api/admin/part-templates/${dlg.id}`, { ...payload, deleteAllRelatedProductsAndSubmissions: true })
|
||||||
|
: http.post('/api/admin/part-templates', payload)
|
||||||
|
req.then(()=>{ ElMessage.success('保存成功'); dlg.visible=false; load() })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(()=>{ loadCategories(); load() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.header { display:flex; align-items:center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -3,13 +3,6 @@
|
|||||||
<div class="page-title"><span class="royal">♛</span> 用户配件管理</div>
|
<div class="page-title"><span class="royal">♛</span> 用户配件管理</div>
|
||||||
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
||||||
<el-form :inline="true" :model="q">
|
<el-form :inline="true" :model="q">
|
||||||
<el-form-item label="状态">
|
|
||||||
<el-select v-model="q.status" style="width:140px">
|
|
||||||
<el-option label="待审" value="pending" />
|
|
||||||
<el-option label="通过" value="approved" />
|
|
||||||
<el-option label="驳回" value="rejected" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input v-model="q.kw" placeholder="品牌/型号/规格/用户ID" clearable />
|
<el-input v-model="q.kw" placeholder="品牌/型号/规格/用户ID" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -29,19 +22,12 @@
|
|||||||
<el-table-column label="图片" width="160">
|
<el-table-column label="图片" width="160">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
||||||
<el-image v-for="(img,i) in (row.images||[])" :key="i" :src="img" fit="cover" style="width:48px;height:48px;border-radius:8px;" :preview-src-list="row.images" />
|
<el-image v-for="(img,i) in (row.images||[])" :key="i" :src="withBaseUrl(img)" fit="cover" style="width:48px;height:48px;border-radius:8px;" :preview-src-list="(row.images||[]).map(withBaseUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="140">
|
<el-table-column fixed="right" label="操作" width="120">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-tag :type="row.status===1?'success':'danger'">{{ row.status===1?'正常':'黑名单' }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column fixed="right" label="操作" width="260">
|
|
||||||
<template #default="{row}">
|
|
||||||
<el-button size="small" type="warning" @click="blacklist(row)" v-if="row.status===1">拉黑</el-button>
|
|
||||||
<el-button size="small" type="success" @click="restore(row)" v-else>恢复</el-button>
|
|
||||||
<el-button size="small" @click="edit(row)">编辑</el-button>
|
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -65,9 +51,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { get, put } from '../../api/http'
|
import { get, put, withBaseUrl } from '../../api/http'
|
||||||
|
|
||||||
const q = reactive({ status: 'pending', kw: '' })
|
const q = reactive({ kw: '' })
|
||||||
const rows = ref<any[]>([])
|
const rows = ref<any[]>([])
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const form = reactive<any>({})
|
const form = reactive<any>({})
|
||||||
@@ -77,15 +63,12 @@ const imagesConcat = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function fetch(){
|
async function fetch(){
|
||||||
// 需要后端提供:GET /api/admin/parts?status=&kw=
|
const data = await get<any>('/api/admin/parts', { kw: q.kw })
|
||||||
const data = await get<any>('/api/admin/parts', { status: q.status, kw: q.kw })
|
|
||||||
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
||||||
}
|
}
|
||||||
function reset(){ q.status='pending'; q.kw=''; fetch() }
|
function reset(){ q.kw=''; fetch() }
|
||||||
function edit(row: any){ visible.value=true; Object.assign(form, JSON.parse(JSON.stringify(row))) }
|
function edit(row: any){ visible.value=true; Object.assign(form, JSON.parse(JSON.stringify(row))) }
|
||||||
async function save(){ await put(`/api/admin/parts/${form.id}`, form); visible.value=false; await fetch() }
|
async function save(){ await put(`/api/admin/parts/${form.id}`, form); visible.value=false; await fetch() }
|
||||||
async function blacklist(row: any){ await put(`/api/admin/parts/${row.id}/blacklist`, {}); await fetch() }
|
|
||||||
async function restore(row: any){ await put(`/api/admin/parts/${row.id}/restore`, {}); await fetch() }
|
|
||||||
|
|
||||||
onMounted(fetch)
|
onMounted(fetch)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
91
admin/src/views/supplier/SupplierList.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-title"><span class="royal">♛</span> 供应商管理</div>
|
||||||
|
|
||||||
|
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
||||||
|
<el-form :inline="true" :model="q">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="q.kw" placeholder="名称/联系人/手机/电话" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-checkbox v-model="q.debtOnly">只看欠款</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetch">查询</el-button>
|
||||||
|
<el-button @click="reset">重置</el-button>
|
||||||
|
<el-button type="success" @click="openCreate">新增供应商</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="padding:0;">
|
||||||
|
<el-table :data="rows" size="large">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="供应商名称" min-width="200" />
|
||||||
|
<el-table-column prop="contactName" label="联系人" width="140" />
|
||||||
|
<el-table-column prop="mobile" label="手机" width="160" />
|
||||||
|
<el-table-column prop="phone" label="电话" width="160" />
|
||||||
|
<el-table-column prop="address" label="经营地址" min-width="240" show-overflow-tooltip />
|
||||||
|
<el-table-column label="应付款" width="140">
|
||||||
|
<template #default="{row}">¥ {{ Number(row.apPayable||0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column fixed="right" label="操作" width="200">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="visible" :title="form.id ? '编辑供应商' : '新增供应商'" width="640">
|
||||||
|
<el-form :model="form" label-width="120px">
|
||||||
|
<el-form-item label="供应商名称"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="联系人"><el-input v-model="form.contactName" /></el-form-item>
|
||||||
|
<el-form-item label="手机"><el-input v-model="form.mobile" /></el-form-item>
|
||||||
|
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
|
||||||
|
<el-form-item label="经营地址"><el-input v-model="form.address" /></el-form-item>
|
||||||
|
<el-form-item label="初始应付款"><el-input v-model.number="form.apOpening" type="number" /></el-form-item>
|
||||||
|
<el-form-item label="当前应付款"><el-input v-model.number="form.apPayable" type="number" /></el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="200" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { get, post, put } from '../../api/http'
|
||||||
|
|
||||||
|
const q = reactive({ kw: '', debtOnly: false })
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const visible = ref(false)
|
||||||
|
const form = reactive<any>({ id: null, name:'', contactName:'', mobile:'', phone:'', address:'', apOpening:0, apPayable:0, remark:'' })
|
||||||
|
|
||||||
|
async function fetch(){
|
||||||
|
const data = await get<any>('/api/suppliers', { kw: q.kw, debtOnly: q.debtOnly, page: 1, size: 100 })
|
||||||
|
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data) ? data : [])
|
||||||
|
}
|
||||||
|
function reset(){ q.kw=''; q.debtOnly=false; fetch() }
|
||||||
|
function openCreate(){ Object.assign(form, { id:null, name:'', contactName:'', mobile:'', phone:'', address:'', apOpening:0, apPayable:0, remark:'' }); visible.value=true }
|
||||||
|
function openEdit(row: any){ Object.assign(form, row); visible.value=true }
|
||||||
|
async function save(){
|
||||||
|
if (!form.name) return (window as any).ElMessage?.warning?.('请填写供应商名称') || void 0
|
||||||
|
if (form.id) await put(`/api/suppliers/${form.id}`, form)
|
||||||
|
else await post('/api/suppliers', form)
|
||||||
|
visible.value=false
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetch)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { position: relative; z-index: 1; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="fetch">查询</el-button>
|
<el-button type="primary" @click="fetch">查询</el-button>
|
||||||
<el-button @click="reset">重置</el-button>
|
<el-button @click="reset">重置</el-button>
|
||||||
<el-button type="success" @click="openEdit()">新增VIP</el-button>
|
<el-button type="success" @click="openEdit()">新增VIP</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +49,8 @@
|
|||||||
<el-button type="primary" @click="save">保存</el-button>
|
<el-button type="primary" @click="save">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ async function toggle(row: any){
|
|||||||
await fetch()
|
await fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(fetch)
|
onMounted(fetch)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-title"><span class="royal">♛</span> VIP审核</div>
|
|
||||||
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
|
||||||
<el-form :inline="true" :model="q">
|
|
||||||
<el-form-item label="关键词">
|
|
||||||
<el-input v-model="q.kw" placeholder="手机号/姓名/用户ID" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="fetch">查询</el-button>
|
|
||||||
<el-button @click="reset">重置</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
|
||||||
<div class="panel" style="padding:16px;">
|
|
||||||
<el-empty v-if="rows.length===0" description="暂无待审核" />
|
|
||||||
<div v-else class="cards">
|
|
||||||
<el-card v-for="it in rows" :key="it.id" class="vip-card" shadow="hover">
|
|
||||||
<div class="vip-card__header">
|
|
||||||
<div class="avatar">{{ (it.name||'用').slice(0,1) }}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<div class="name">{{ it.name || ('用户#'+it.userId) }}</div>
|
|
||||||
<div class="sub">ID {{ it.userId }} · {{ it.phone || '无手机号' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vip-card__body">
|
|
||||||
<div class="remark" :title="it.remark || ''">{{ it.remark || '无备注' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="vip-card__actions">
|
|
||||||
<el-button size="small" type="success" @click="approve(it)">通过</el-button>
|
|
||||||
<el-button size="small" type="warning" @click="reject(it)">驳回</el-button>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { get, post } from '../../api/http'
|
|
||||||
|
|
||||||
const q = reactive({ kw: '' })
|
|
||||||
const rows = ref<any[]>([])
|
|
||||||
|
|
||||||
function levelLabel(v: string){
|
|
||||||
const m: Record<string,string> = { gold: '黄金VIP', platinum: '铂金VIP', diamond: '钻石VIP' }
|
|
||||||
return m[v] || v || 'VIP'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetch(){
|
|
||||||
// 使用 /api/admin/vips?status=0 列出待审核(未启用)记录;phone 作为筛选
|
|
||||||
const params: any = { status: 0 }
|
|
||||||
if (q.kw) params.phone = q.kw
|
|
||||||
const data = await get<any>('/api/admin/vips', params)
|
|
||||||
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
|
||||||
}
|
|
||||||
function reset(){ q.kw=''; fetch() }
|
|
||||||
async function approve(row: any){ await post(`/api/admin/vips/${row.id}/approve`, {}); await fetch() }
|
|
||||||
async function reject(row: any){ await post(`/api/admin/vips/${row.id}/reject`, {}); await fetch() }
|
|
||||||
|
|
||||||
onMounted(fetch)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page { position: relative; z-index: 1; }
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
|
||||||
.vip-card { border-radius: 14px; overflow: hidden; }
|
|
||||||
.vip-card__header { display:flex; align-items:center; gap:12px; padding: 6px 4px 6px; border-bottom: 1px solid var(--panel-border); }
|
|
||||||
.avatar { width: 36px; height: 36px; border-radius: 18px; background: rgba(180,136,15,.18); display:flex; align-items:center; justify-content:center; font-weight: 800; color: var(--primary-royal); }
|
|
||||||
.meta { display:flex; flex-direction:column; gap:2px; }
|
|
||||||
.name { font-weight: 700; }
|
|
||||||
.sub { font-size: 12px; color: var(--muted-color); }
|
|
||||||
.level { margin-left: auto; }
|
|
||||||
.vip-card__body { padding: 8px 4px; color: var(--muted-color); min-height: 44px; }
|
|
||||||
.remark { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.vip-card__actions { display:flex; justify-content:flex-end; gap: 8px; padding-top: 6px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
67
admin/src/views/vip/VipSystem.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-title"><span class="royal">♛</span> VIP系统</div>
|
||||||
|
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
||||||
|
<el-form :inline="true" :model="price">
|
||||||
|
<el-form-item label="VIP价格(元/月)">
|
||||||
|
<el-input v-model.number="price.value" type="number" placeholder="输入价格" style="width:160px;" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="savePrice">保存</el-button>
|
||||||
|
<el-button @click="loadPrice">刷新</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="padding:0;">
|
||||||
|
<div class="panel-title" style="padding: 12px; border-bottom: 1px solid var(--panel-border); display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<div>充值记录</div>
|
||||||
|
<div>
|
||||||
|
<el-input v-model="q.kw" placeholder="手机号/姓名" size="small" style="width:220px; margin-right: 8px;" clearable />
|
||||||
|
<el-button size="small" type="primary" @click="fetchRecharges">查询</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-table :data="rows" style="width:100%" size="large" :border="false">
|
||||||
|
<el-table-column type="index" width="60" label="#" />
|
||||||
|
<el-table-column prop="shopName" label="店铺" width="160" />
|
||||||
|
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="140" />
|
||||||
|
<el-table-column prop="phone" label="手机号" width="140" />
|
||||||
|
<el-table-column prop="price" label="价格" width="100" />
|
||||||
|
<el-table-column prop="durationDays" label="时长(天)" width="100" />
|
||||||
|
<el-table-column prop="expireTo" label="到期时间" width="180" />
|
||||||
|
<el-table-column prop="channel" label="渠道" width="120" />
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { get, put } from '../../api/http'
|
||||||
|
|
||||||
|
const price = reactive({ value: 0 })
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const q = reactive({ kw: '' })
|
||||||
|
|
||||||
|
async function loadPrice(){
|
||||||
|
const data = await get<any>('/api/admin/vip/system/price')
|
||||||
|
price.value = Number(data?.price || 0)
|
||||||
|
}
|
||||||
|
async function savePrice(){
|
||||||
|
await put('/api/admin/vip/system/price', { price: price.value })
|
||||||
|
}
|
||||||
|
async function fetchRecharges(){
|
||||||
|
const data = await get<any>('/api/admin/vip/system/recharges', { kw: q.kw })
|
||||||
|
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => { await loadPrice(); await fetchRecharges() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { position: relative; z-index: 1; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -263,7 +263,6 @@ CREATE TABLE IF NOT EXISTS customers (
|
|||||||
user_id BIGINT UNSIGNED NOT NULL,
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
phone VARCHAR(32) NULL,
|
phone VARCHAR(32) NULL,
|
||||||
level VARCHAR(32) NULL COMMENT '客户等级标签',
|
|
||||||
price_level ENUM('retail','distribution','wholesale','big_client') NOT NULL DEFAULT 'retail' COMMENT '默认售价列',
|
price_level ENUM('retail','distribution','wholesale','big_client') NOT NULL DEFAULT 'retail' COMMENT '默认售价列',
|
||||||
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
remark VARCHAR(255) NULL,
|
remark VARCHAR(255) NULL,
|
||||||
|
|||||||
@@ -60,6 +60,18 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 邮件发送 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>5.2.5</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- 认证:JWT 签发 -->
|
<!-- 认证:JWT 签发 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.auth0</groupId>
|
<groupId>com.auth0</groupId>
|
||||||
|
|||||||
BIN
backend/run.log
Normal file
@@ -83,16 +83,13 @@ public class AccountService {
|
|||||||
String dateStart = (startDate == null || startDate.isBlank()) ? null : startDate;
|
String dateStart = (startDate == null || startDate.isBlank()) ? null : startDate;
|
||||||
String dateEnd = (endDate == null || endDate.isBlank()) ? null : endDate;
|
String dateEnd = (endDate == null || endDate.isBlank()) ? null : endDate;
|
||||||
|
|
||||||
// opening = 截止开始日期前净额
|
// opening = 截止开始日期前净额(仅 payments)
|
||||||
String payOpenSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE -amount END),0) FROM payments WHERE" + baseCond + (dateStart==null?"":" AND pay_time<?");
|
String payOpenSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE -amount END),0) FROM payments WHERE" + baseCond + (dateStart==null?"":" AND pay_time<?");
|
||||||
java.util.List<Object> payOpenPs = new java.util.ArrayList<>(basePs);
|
java.util.List<Object> payOpenPs = new java.util.ArrayList<>(basePs);
|
||||||
if (dateStart!=null) payOpenPs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
|
if (dateStart!=null) payOpenPs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
|
||||||
String otOpenSql = "SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE -amount END),0) FROM other_transactions WHERE" + baseCond + (dateStart==null?"":" AND tx_time<?");
|
java.math.BigDecimal opening = sum.apply(payOpenSql, payOpenPs);
|
||||||
java.util.List<Object> otOpenPs = new java.util.ArrayList<>(basePs);
|
|
||||||
if (dateStart!=null) otOpenPs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
|
|
||||||
java.math.BigDecimal opening = sum.apply(payOpenSql, payOpenPs).add(sum.apply(otOpenSql, otOpenPs));
|
|
||||||
|
|
||||||
// 区间收入/支出(含两表)
|
// 区间收入/支出(仅 payments)
|
||||||
String payRangeSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN direction='out' THEN amount ELSE 0 END),0) FROM payments WHERE" + baseCond +
|
String payRangeSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN direction='out' THEN amount ELSE 0 END),0) FROM payments WHERE" + baseCond +
|
||||||
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?");
|
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?");
|
||||||
java.util.List<Object> payRangePs = new java.util.ArrayList<>(basePs);
|
java.util.List<Object> payRangePs = new java.util.ArrayList<>(basePs);
|
||||||
@@ -102,31 +99,17 @@ public class AccountService {
|
|||||||
java.math.BigDecimal payIn = (java.math.BigDecimal) pr.values().toArray()[0];
|
java.math.BigDecimal payIn = (java.math.BigDecimal) pr.values().toArray()[0];
|
||||||
java.math.BigDecimal payOut = (java.math.BigDecimal) pr.values().toArray()[1];
|
java.math.BigDecimal payOut = (java.math.BigDecimal) pr.values().toArray()[1];
|
||||||
|
|
||||||
String otRangeSql = "SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) FROM other_transactions WHERE" + baseCond +
|
java.math.BigDecimal income = payIn;
|
||||||
(dateStart==null?"":" AND tx_time>=?") + (dateEnd==null?"":" AND tx_time<=?");
|
java.math.BigDecimal expense = payOut;
|
||||||
java.util.List<Object> otRangePs = new java.util.ArrayList<>(basePs);
|
|
||||||
if (dateStart!=null) otRangePs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
|
|
||||||
if (dateEnd!=null) otRangePs.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59"));
|
|
||||||
java.util.Map<String, Object> or = jdbcTemplate.queryForMap(otRangeSql, otRangePs.toArray());
|
|
||||||
java.math.BigDecimal otIn = (java.math.BigDecimal) or.values().toArray()[0];
|
|
||||||
java.math.BigDecimal otOut = (java.math.BigDecimal) or.values().toArray()[1];
|
|
||||||
|
|
||||||
java.math.BigDecimal income = payIn.add(otIn);
|
|
||||||
java.math.BigDecimal expense = payOut.add(otOut);
|
|
||||||
java.math.BigDecimal ending = opening.add(income).subtract(expense);
|
java.math.BigDecimal ending = opening.add(income).subtract(expense);
|
||||||
|
|
||||||
// 明细列表(合并两表,按时间倒序)
|
// 明细列表(仅 payments,按时间倒序)
|
||||||
String listSql = "SELECT id, biz_type AS src, pay_time AS tx_time, direction, amount, remark, biz_id, NULL AS category FROM payments WHERE" + baseCond +
|
String listSql = "SELECT id, biz_type AS src, pay_time AS tx_time, direction, amount, remark, biz_id, category FROM payments WHERE" + baseCond +
|
||||||
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?") +
|
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?") +
|
||||||
" UNION ALL " +
|
|
||||||
"SELECT id, 'other' AS src, tx_time, CASE WHEN type='income' THEN 'in' ELSE 'out' END AS direction, amount, remark, NULL AS biz_id, category FROM other_transactions WHERE" + baseCond +
|
|
||||||
(dateStart==null?"":" AND tx_time>=?") + (dateEnd==null?"":" AND tx_time<=?") +
|
|
||||||
" ORDER BY tx_time DESC LIMIT ? OFFSET ?";
|
" ORDER BY tx_time DESC LIMIT ? OFFSET ?";
|
||||||
java.util.List<Object> lp = new java.util.ArrayList<>(basePs);
|
java.util.List<Object> lp = new java.util.ArrayList<>(basePs);
|
||||||
if (dateStart!=null) { lp.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); }
|
if (dateStart!=null) { lp.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); }
|
||||||
if (dateEnd!=null) { lp.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59")); }
|
if (dateEnd!=null) { lp.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
if (dateStart!=null) { lp.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); }
|
|
||||||
if (dateEnd!=null) { lp.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59")); }
|
|
||||||
lp.add(size); lp.add(page * size);
|
lp.add(size); lp.add(page * size);
|
||||||
List<Map<String,Object>> list = jdbcTemplate.queryForList(listSql, lp.toArray());
|
List<Map<String,Object>> list = jdbcTemplate.queryForList(listSql, lp.toArray());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/auth")
|
||||||
|
public class AdminAuthController {
|
||||||
|
|
||||||
|
private final AdminAuthService adminAuthService;
|
||||||
|
|
||||||
|
public AdminAuthController(AdminAuthService adminAuthService) {
|
||||||
|
this.adminAuthService = adminAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody AdminAuthService.LoginRequest req) {
|
||||||
|
var resp = adminAuthService.login(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import com.example.demo.auth.JwtProperties;
|
||||||
|
import com.example.demo.auth.JwtService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AdminAuthService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JwtProperties jwtProps;
|
||||||
|
|
||||||
|
public AdminAuthService(JdbcTemplate jdbcTemplate,
|
||||||
|
JwtService jwtService,
|
||||||
|
JwtProperties jwtProps) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.jwtProps = jwtProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LoginRequest { public String username; public String phone; public String password; }
|
||||||
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> admin; }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public LoginResponse login(LoginRequest req) {
|
||||||
|
String keyTmp = null;
|
||||||
|
String valTmp = null;
|
||||||
|
if (req.username != null && !req.username.isBlank()) { keyTmp = "username"; valTmp = req.username.trim(); }
|
||||||
|
else if (req.phone != null && !req.phone.isBlank()) { keyTmp = "phone"; valTmp = req.phone.trim(); }
|
||||||
|
if (keyTmp == null) throw new IllegalArgumentException("用户名或手机号不能为空");
|
||||||
|
if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空");
|
||||||
|
final String loginKey = keyTmp;
|
||||||
|
final String loginVal = valTmp;
|
||||||
|
|
||||||
|
Map<String,Object> row = jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement("SELECT id, username, phone, password_hash, status FROM admins WHERE "+loginKey+"=? LIMIT 1");
|
||||||
|
ps.setString(1, loginVal);
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("username", rs.getString(2));
|
||||||
|
m.put("phone", rs.getString(3));
|
||||||
|
m.put("password_hash", rs.getString(4));
|
||||||
|
m.put("status", rs.getInt(5));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (row == null) throw new IllegalArgumentException("管理员不存在");
|
||||||
|
int status = ((Number)row.get("status")).intValue();
|
||||||
|
if (status != 1) throw new IllegalArgumentException("管理员未启用");
|
||||||
|
String hash = (String) row.get("password_hash");
|
||||||
|
if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD");
|
||||||
|
boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash);
|
||||||
|
if (!ok) throw new IllegalArgumentException("密码错误");
|
||||||
|
|
||||||
|
Long adminId = ((Number)row.get("id")).longValue();
|
||||||
|
String username = (String) row.get("username");
|
||||||
|
|
||||||
|
String token = jwtService.signAdminToken(adminId, username);
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
Map<String,Object> admin = new HashMap<>();
|
||||||
|
admin.put("adminId", adminId);
|
||||||
|
admin.put("username", username);
|
||||||
|
admin.put("phone", row.get("phone"));
|
||||||
|
out.admin = admin;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,15 +4,18 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/consults")
|
@RequestMapping("/api/admin/consults")
|
||||||
public class AdminConsultController {
|
public class AdminConsultController {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
public AdminConsultController(JdbcTemplate jdbcTemplate) {
|
public AdminConsultController(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.defaults = defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -23,8 +26,9 @@ public class AdminConsultController {
|
|||||||
@RequestParam(name = "size", defaultValue = "20") int size) {
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
||||||
StringBuilder sql = new StringBuilder(
|
StringBuilder sql = new StringBuilder(
|
||||||
"SELECT c.id,c.user_id AS userId,c.shop_id AS shopId,s.name AS shopName,c.topic,c.message,c.status,c.created_at " +
|
"SELECT c.id,c.user_id AS userId,c.shop_id AS shopId,s.name AS shopName,c.message,c.status,c.created_at," +
|
||||||
"FROM consults c JOIN shops s ON s.id=c.shop_id WHERE 1=1");
|
"cr.content AS replyContent, cr.created_at AS replyAt " +
|
||||||
|
"FROM consults c JOIN shops s ON s.id=c.shop_id LEFT JOIN consult_replies cr ON cr.consult_id=c.id WHERE 1=1");
|
||||||
List<Object> ps = new ArrayList<>();
|
List<Object> ps = new ArrayList<>();
|
||||||
if (shopId != null) { sql.append(" AND c.shop_id=?"); ps.add(shopId); }
|
if (shopId != null) { sql.append(" AND c.shop_id=?"); ps.add(shopId); }
|
||||||
if (status != null && !status.isBlank()) { sql.append(" AND c.status=?"); ps.add(status); }
|
if (status != null && !status.isBlank()) { sql.append(" AND c.status=?"); ps.add(status); }
|
||||||
@@ -38,10 +42,11 @@ public class AdminConsultController {
|
|||||||
m.put("userId", rs.getLong("userId"));
|
m.put("userId", rs.getLong("userId"));
|
||||||
m.put("shopId", rs.getLong("shopId"));
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
m.put("shopName", rs.getString("shopName"));
|
m.put("shopName", rs.getString("shopName"));
|
||||||
m.put("topic", rs.getString("topic"));
|
|
||||||
m.put("message", rs.getString("message"));
|
m.put("message", rs.getString("message"));
|
||||||
m.put("status", rs.getString("status"));
|
m.put("status", rs.getString("status"));
|
||||||
m.put("createdAt", rs.getTimestamp("created_at"));
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
m.put("replyContent", rs.getString("replyContent"));
|
||||||
|
m.put("replyAt", rs.getTimestamp("replyAt"));
|
||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
|
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
|
||||||
@@ -49,11 +54,18 @@ public class AdminConsultController {
|
|||||||
|
|
||||||
@PostMapping("/{id}/reply")
|
@PostMapping("/{id}/reply")
|
||||||
public ResponseEntity<?> reply(@PathVariable("id") Long id,
|
public ResponseEntity<?> reply(@PathVariable("id") Long id,
|
||||||
@RequestHeader(name = "X-User-Id") Long userId,
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
@RequestBody Map<String,Object> body) {
|
@RequestBody Map<String,Object> body) {
|
||||||
String content = body == null ? null : String.valueOf(body.get("content"));
|
String content = body == null ? null : String.valueOf(body.get("content"));
|
||||||
if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","content required"));
|
if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","content required"));
|
||||||
jdbcTemplate.update("INSERT INTO consult_replies (consult_id,user_id,content,created_at) VALUES (?,?,?,NOW())", id, userId, content);
|
// 单条只允许一条回复:唯一索引兜底,这里先查避免 500
|
||||||
|
Integer exists = jdbcTemplate.query("SELECT 1 FROM consult_replies WHERE consult_id=? LIMIT 1", ps -> ps.setLong(1, id), rs -> rs.next() ? 1 : 0);
|
||||||
|
if (Objects.equals(exists, 1)) {
|
||||||
|
return ResponseEntity.status(409).body(Map.of("message", "该咨询已被回复"));
|
||||||
|
}
|
||||||
|
Long uid = (userId != null ? userId : (adminId != null ? adminId : defaults.getUserId()));
|
||||||
|
jdbcTemplate.update("INSERT INTO consult_replies (consult_id,user_id,content,created_at) VALUES (?,?,?,NOW())", id, uid, content);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +74,42 @@ public class AdminConsultController {
|
|||||||
jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id);
|
jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New: Get full history of a user's consults with replies for admin view
|
||||||
|
@GetMapping("/users/{userId}/history")
|
||||||
|
public ResponseEntity<?> userHistory(@PathVariable("userId") Long userId,
|
||||||
|
@RequestParam(name = "shopId", required = false) Long shopId) {
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT id, topic, message, status, created_at FROM consults WHERE user_id=?");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
ps.add(userId);
|
||||||
|
if (shopId != null) { sql.append(" AND shop_id=?"); ps.add(shopId); }
|
||||||
|
sql.append(" ORDER BY id DESC");
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
Long cid = rs.getLong("id");
|
||||||
|
m.put("id", cid);
|
||||||
|
m.put("topic", rs.getString("topic"));
|
||||||
|
m.put("message", rs.getString("message"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
List<Map<String,Object>> replies = jdbcTemplate.query(
|
||||||
|
"SELECT id, user_id AS userId, content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id ASC",
|
||||||
|
(rs2, j) -> {
|
||||||
|
Map<String,Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("id", rs2.getLong("id"));
|
||||||
|
r.put("userId", rs2.getLong("userId"));
|
||||||
|
r.put("content", rs2.getString("content"));
|
||||||
|
r.put("createdAt", rs2.getTimestamp("created_at"));
|
||||||
|
return r;
|
||||||
|
}, cid);
|
||||||
|
m.put("replies", replies);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
Map<String,Object> body = new HashMap<>();
|
||||||
|
body.put("list", list);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,16 +29,12 @@ public class AdminDictController {
|
|||||||
this.defaults = defaults;
|
this.defaults = defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isPlatformAdmin(Long userId) {
|
// 管理员校验已由拦截器基于 admins 表统一处理
|
||||||
Integer admin = jdbcTemplate.queryForObject("SELECT is_platform_admin FROM users WHERE id=? LIMIT 1", Integer.class, userId);
|
|
||||||
return admin != null && admin == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Units =====
|
// ===== Units =====
|
||||||
@PostMapping("/units")
|
@PostMapping("/units")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> createUnit(@RequestHeader("X-User-Id") Long userId, @RequestBody Map<String,Object> body) {
|
public ResponseEntity<?> createUnit(@RequestBody Map<String,Object> body) {
|
||||||
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
|
|
||||||
String name = body == null ? null : (String) body.get("name");
|
String name = body == null ? null : (String) body.get("name");
|
||||||
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
Long sid = defaults.getDictShopId();
|
Long sid = defaults.getDictShopId();
|
||||||
@@ -46,7 +42,7 @@ public class AdminDictController {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
ProductUnit u = new ProductUnit();
|
ProductUnit u = new ProductUnit();
|
||||||
u.setShopId(sid);
|
u.setShopId(sid);
|
||||||
u.setUserId(userId);
|
u.setUserId(defaults.getUserId());
|
||||||
u.setName(name.trim());
|
u.setName(name.trim());
|
||||||
u.setCreatedAt(now);
|
u.setCreatedAt(now);
|
||||||
u.setUpdatedAt(now);
|
u.setUpdatedAt(now);
|
||||||
@@ -56,15 +52,14 @@ public class AdminDictController {
|
|||||||
|
|
||||||
@PutMapping("/units/{id}")
|
@PutMapping("/units/{id}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> updateUnit(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
|
public ResponseEntity<?> updateUnit(@PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
|
||||||
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
|
|
||||||
String name = body == null ? null : (String) body.get("name");
|
String name = body == null ? null : (String) body.get("name");
|
||||||
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
ProductUnit u = unitRepository.findById(id).orElse(null);
|
ProductUnit u = unitRepository.findById(id).orElse(null);
|
||||||
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
if (!u.getName().equals(name.trim()) && unitRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
|
if (!u.getName().equals(name.trim()) && unitRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
|
||||||
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
||||||
u.setUserId(userId);
|
u.setUserId(defaults.getUserId());
|
||||||
u.setName(name.trim());
|
u.setName(name.trim());
|
||||||
u.setUpdatedAt(LocalDateTime.now());
|
u.setUpdatedAt(LocalDateTime.now());
|
||||||
unitRepository.save(u);
|
unitRepository.save(u);
|
||||||
@@ -73,8 +68,7 @@ public class AdminDictController {
|
|||||||
|
|
||||||
@DeleteMapping("/units/{id}")
|
@DeleteMapping("/units/{id}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> deleteUnit(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id) {
|
public ResponseEntity<?> deleteUnit(@PathVariable("id") Long id) {
|
||||||
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
|
|
||||||
ProductUnit u = unitRepository.findById(id).orElse(null);
|
ProductUnit u = unitRepository.findById(id).orElse(null);
|
||||||
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
// 引用保护:若有商品使用该单位,阻止删除
|
// 引用保护:若有商品使用该单位,阻止删除
|
||||||
@@ -89,8 +83,7 @@ public class AdminDictController {
|
|||||||
// ===== Categories =====
|
// ===== Categories =====
|
||||||
@PostMapping("/categories")
|
@PostMapping("/categories")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> createCategory(@RequestHeader("X-User-Id") Long userId, @RequestBody Map<String,Object> body) {
|
public ResponseEntity<?> createCategory(@RequestBody Map<String,Object> body) {
|
||||||
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
|
|
||||||
String name = body == null ? null : (String) body.get("name");
|
String name = body == null ? null : (String) body.get("name");
|
||||||
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
Long sid = defaults.getDictShopId();
|
Long sid = defaults.getDictShopId();
|
||||||
@@ -98,7 +91,7 @@ public class AdminDictController {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
ProductCategory c = new ProductCategory();
|
ProductCategory c = new ProductCategory();
|
||||||
c.setShopId(sid);
|
c.setShopId(sid);
|
||||||
c.setUserId(userId);
|
c.setUserId(defaults.getUserId());
|
||||||
c.setName(name.trim());
|
c.setName(name.trim());
|
||||||
c.setSortOrder(0);
|
c.setSortOrder(0);
|
||||||
c.setCreatedAt(now);
|
c.setCreatedAt(now);
|
||||||
@@ -109,15 +102,14 @@ public class AdminDictController {
|
|||||||
|
|
||||||
@PutMapping("/categories/{id}")
|
@PutMapping("/categories/{id}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> updateCategory(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
|
public ResponseEntity<?> updateCategory(@PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
|
||||||
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
|
|
||||||
String name = body == null ? null : (String) body.get("name");
|
String name = body == null ? null : (String) body.get("name");
|
||||||
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
ProductCategory c = categoryRepository.findById(id).orElse(null);
|
ProductCategory c = categoryRepository.findById(id).orElse(null);
|
||||||
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
if (!c.getName().equals(name.trim()) && categoryRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
|
if (!c.getName().equals(name.trim()) && categoryRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
|
||||||
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
||||||
c.setUserId(userId);
|
c.setUserId(defaults.getUserId());
|
||||||
c.setName(name.trim());
|
c.setName(name.trim());
|
||||||
c.setUpdatedAt(LocalDateTime.now());
|
c.setUpdatedAt(LocalDateTime.now());
|
||||||
categoryRepository.save(c);
|
categoryRepository.save(c);
|
||||||
@@ -126,8 +118,7 @@ public class AdminDictController {
|
|||||||
|
|
||||||
@DeleteMapping("/categories/{id}")
|
@DeleteMapping("/categories/{id}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> deleteCategory(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id) {
|
public ResponseEntity<?> deleteCategory(@PathVariable("id") Long id) {
|
||||||
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
|
|
||||||
ProductCategory c = categoryRepository.findById(id).orElse(null);
|
ProductCategory c = categoryRepository.findById(id).orElse(null);
|
||||||
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
// 子类与引用保护
|
// 子类与引用保护
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/notices")
|
||||||
|
public class AdminNoticeController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminNoticeController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int limit = Math.max(1, size);
|
||||||
|
int offset = Math.max(0, page - 1) * limit;
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT id,title,content,tag,is_pinned AS pinned,starts_at,ends_at,status,created_at,updated_at FROM notices WHERE deleted_at IS NULL");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
sql.append(" AND status=?");
|
||||||
|
ps.add(status.trim());
|
||||||
|
}
|
||||||
|
if (kw != null && !kw.isBlank()) {
|
||||||
|
sql.append(" AND (title LIKE ? OR content LIKE ?)");
|
||||||
|
String like = "%" + kw.trim() + "%";
|
||||||
|
ps.add(like); ps.add(like);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY is_pinned DESC, created_at DESC LIMIT ? OFFSET ?");
|
||||||
|
ps.add(limit);
|
||||||
|
ps.add(offset);
|
||||||
|
|
||||||
|
List<Map<String, Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("title", rs.getString("title"));
|
||||||
|
m.put("content", rs.getString("content"));
|
||||||
|
m.put("tag", rs.getString("tag"));
|
||||||
|
m.put("pinned", rs.getBoolean("pinned"));
|
||||||
|
m.put("startsAt", rs.getTimestamp("starts_at"));
|
||||||
|
m.put("endsAt", rs.getTimestamp("ends_at"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
m.put("updatedAt", rs.getTimestamp("updated_at"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("list", list);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestBody Map<String, Object> body) {
|
||||||
|
String title = optString(body.get("title"));
|
||||||
|
String content = optString(body.get("content"));
|
||||||
|
if (title == null || title.isBlank()) return ResponseEntity.badRequest().body(Map.of("message", "title required"));
|
||||||
|
if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message", "content required"));
|
||||||
|
|
||||||
|
String tag = optString(body.get("tag"));
|
||||||
|
Boolean pinned = optBoolean(body.get("pinned"));
|
||||||
|
String status = sanitizeStatus(optString(body.get("status"))); // draft|published|offline
|
||||||
|
Timestamp startsAt = parseDateTime(optString(body.get("startsAt")));
|
||||||
|
Timestamp endsAt = parseDateTime(optString(body.get("endsAt")));
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO notices (title,content,tag,is_pinned,starts_at,ends_at,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,NOW(),NOW())",
|
||||||
|
title, content, tag, (pinned != null && pinned) ? 1 : 0, startsAt, endsAt, (status == null ? "draft" : status)
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody Map<String, Object> body) {
|
||||||
|
List<String> sets = new ArrayList<>();
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
|
||||||
|
String title = optString(body.get("title"));
|
||||||
|
String content = optString(body.get("content"));
|
||||||
|
String tag = optString(body.get("tag"));
|
||||||
|
Boolean pinned = optBoolean(body.get("pinned"));
|
||||||
|
String status = sanitizeStatus(optString(body.get("status")));
|
||||||
|
Timestamp startsAt = parseDateTime(optString(body.get("startsAt")));
|
||||||
|
Timestamp endsAt = parseDateTime(optString(body.get("endsAt")));
|
||||||
|
|
||||||
|
if (title != null) { sets.add("title=?"); ps.add(title); }
|
||||||
|
if (content != null) { sets.add("content=?"); ps.add(content); }
|
||||||
|
if (tag != null) { sets.add("tag=?"); ps.add(tag); }
|
||||||
|
if (pinned != null) { sets.add("is_pinned=?"); ps.add(pinned ? 1 : 0); }
|
||||||
|
if (status != null) { sets.add("status=?"); ps.add(status); }
|
||||||
|
if (body.containsKey("startsAt")) { sets.add("starts_at=?"); ps.add(startsAt); }
|
||||||
|
if (body.containsKey("endsAt")) { sets.add("ends_at=?"); ps.add(endsAt); }
|
||||||
|
|
||||||
|
if (sets.isEmpty()) return ResponseEntity.ok().build();
|
||||||
|
String sql = "UPDATE notices SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
|
||||||
|
ps.add(id);
|
||||||
|
jdbcTemplate.update(sql, ps.toArray());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/publish")
|
||||||
|
public ResponseEntity<?> publish(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE notices SET status='published', updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/offline")
|
||||||
|
public ResponseEntity<?> offline(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE notices SET status='offline', updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); }
|
||||||
|
private static Boolean optBoolean(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
String s = String.valueOf(v);
|
||||||
|
return ("1".equals(s) || "true".equalsIgnoreCase(s));
|
||||||
|
}
|
||||||
|
private static Timestamp parseDateTime(String s) {
|
||||||
|
if (s == null || s.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
// ISO-8601 with zone
|
||||||
|
OffsetDateTime odt = OffsetDateTime.parse(s);
|
||||||
|
return Timestamp.from(odt.toInstant());
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
try {
|
||||||
|
// ISO-8601 local date-time
|
||||||
|
LocalDateTime ldt = LocalDateTime.parse(s, DateTimeFormatter.ISO_DATE_TIME);
|
||||||
|
return Timestamp.valueOf(ldt);
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
try {
|
||||||
|
// Fallback: yyyy-MM-dd HH:mm:ss
|
||||||
|
LocalDateTime ldt = LocalDateTime.parse(s, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
return Timestamp.valueOf(ldt);
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private static String sanitizeStatus(String s) {
|
||||||
|
if (s == null) return null;
|
||||||
|
String v = s.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if ("draft".equals(v) || "published".equals(v) || "offline".equals(v)) return v;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.demo.admin;
|
package com.example.demo.admin;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -32,16 +33,11 @@ public class AdminPartController {
|
|||||||
String like = "%" + kw.trim() + "%";
|
String like = "%" + kw.trim() + "%";
|
||||||
ps.add(like); ps.add(like); ps.add(like); ps.add(like);
|
ps.add(like); ps.add(like); ps.add(like); ps.add(like);
|
||||||
}
|
}
|
||||||
|
// 为兼容已有前端查询参数,保留 status 入参但不再基于黑名单做过滤(忽略非数字值)
|
||||||
if (status != null && !status.isBlank()) {
|
if (status != null && !status.isBlank()) {
|
||||||
// 支持两种入参:"1/0"(正常/黑名单)或历史字符串(忽略过滤避免报错)
|
|
||||||
try {
|
try {
|
||||||
int s = Integer.parseInt(status);
|
Integer.parseInt(status); // no-op, keep compatibility
|
||||||
int isBlack = (s == 1 ? 0 : 1);
|
} catch (NumberFormatException ignore) { }
|
||||||
sql.append(" AND p.is_blacklisted=?");
|
|
||||||
ps.add(isBlack);
|
|
||||||
} catch (NumberFormatException ignore) {
|
|
||||||
// 兼容历史:pending/approved/rejected → 不加过滤
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sql.append(" ORDER BY p.id DESC LIMIT ").append(offset).append(", ").append(size);
|
sql.append(" ORDER BY p.id DESC LIMIT ").append(offset).append(", ").append(size);
|
||||||
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
@@ -54,22 +50,122 @@ public class AdminPartController {
|
|||||||
m.put("brand", rs.getString("brand"));
|
m.put("brand", rs.getString("brand"));
|
||||||
m.put("model", rs.getString("model"));
|
m.put("model", rs.getString("model"));
|
||||||
m.put("spec", rs.getString("spec"));
|
m.put("spec", rs.getString("spec"));
|
||||||
m.put("status", rs.getInt("is_blacklisted") == 1 ? 0 : 1);
|
|
||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
|
// 附加每个商品的图片列表
|
||||||
|
if (!list.isEmpty()) {
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
for (Map<String,Object> m : list) {
|
||||||
|
Object v = m.get("id");
|
||||||
|
if (v instanceof Number) ids.add(((Number) v).longValue());
|
||||||
|
}
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
StringBuilder in = new StringBuilder();
|
||||||
|
for (int i = 0; i < ids.size(); i++) { if (i>0) in.append(','); in.append('?'); }
|
||||||
|
List<Map<String,Object>> imgRows = jdbcTemplate.query(
|
||||||
|
"SELECT product_id AS productId, url FROM product_images WHERE product_id IN (" + in + ") ORDER BY sort_order ASC, id ASC",
|
||||||
|
ids.toArray(),
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String,Object> m = new HashMap<>();
|
||||||
|
m.put("productId", rs.getLong("productId"));
|
||||||
|
m.put("url", rs.getString("url"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Map<Long, List<String>> map = new HashMap<>();
|
||||||
|
for (Map<String,Object> r : imgRows) {
|
||||||
|
Long pid = ((Number) r.get("productId")).longValue();
|
||||||
|
String url = String.valueOf(r.get("url"));
|
||||||
|
map.computeIfAbsent(pid, k -> new ArrayList<>()).add(url);
|
||||||
|
}
|
||||||
|
for (Map<String,Object> m : list) {
|
||||||
|
Long pid = ((Number) m.get("id")).longValue();
|
||||||
|
List<String> imgs = map.get(pid);
|
||||||
|
m.put("images", imgs == null ? Collections.emptyList() : imgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
|
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/blacklist")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<?> blacklist(@PathVariable("id") Long id) {
|
@Transactional
|
||||||
jdbcTemplate.update("UPDATE products SET is_blacklisted=1 WHERE id=?", id);
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
// 校验商品是否存在,并取出 shopId
|
||||||
|
List<Map<String, Object>> prodRows = jdbcTemplate.query(
|
||||||
|
"SELECT id, shop_id FROM products WHERE id=?",
|
||||||
|
new Object[]{id},
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("shopId", rs.getLong(2));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (prodRows.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
Long shopId = ((Number) prodRows.get(0).get("shopId")).longValue();
|
||||||
|
|
||||||
|
String brand = optString(body.get("brand"));
|
||||||
|
String model = optString(body.get("model"));
|
||||||
|
String spec = optString(body.get("spec"));
|
||||||
|
List<String> images = optStringList(body.get("images"));
|
||||||
|
|
||||||
|
// 更新 products 基本字段(可选)
|
||||||
|
List<String> sets = new ArrayList<>();
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (brand != null) { sets.add("brand=?"); ps.add(brand); }
|
||||||
|
if (model != null) { sets.add("model=?"); ps.add(model); }
|
||||||
|
if (spec != null) { sets.add("spec=?"); ps.add(spec); }
|
||||||
|
if (!sets.isEmpty()) {
|
||||||
|
String sql = "UPDATE products SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
|
||||||
|
ps.add(id);
|
||||||
|
jdbcTemplate.update(sql, ps.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 覆盖式更新图片(提供 images 列表时)
|
||||||
|
if (images != null) {
|
||||||
|
if (adminId == null) throw new IllegalArgumentException("X-Admin-Id 不能为空");
|
||||||
|
jdbcTemplate.update("DELETE FROM product_images WHERE product_id= ?", id);
|
||||||
|
if (!images.isEmpty()) {
|
||||||
|
List<Object[]> batch = new ArrayList<>();
|
||||||
|
int sort = 0;
|
||||||
|
for (String url : images) {
|
||||||
|
if (url == null || url.isBlank()) continue;
|
||||||
|
// 管理员为平台操作,不属于店铺用户;使用默认用户ID(1)满足非空外键
|
||||||
|
batch.add(new Object[]{shopId, 1L, id, url.trim(), sort++});
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
jdbcTemplate.batchUpdate(
|
||||||
|
"INSERT INTO product_images (shop_id, user_id, product_id, url, sort_order, created_at) VALUES (?,?,?,?,?,NOW())",
|
||||||
|
batch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/restore")
|
private static String optString(Object v) { return v == null ? null : String.valueOf(v); }
|
||||||
public ResponseEntity<?> restore(@PathVariable("id") Long id) {
|
|
||||||
jdbcTemplate.update("UPDATE products SET is_blacklisted=0 WHERE id=?", id);
|
@SuppressWarnings("unchecked")
|
||||||
return ResponseEntity.ok().build();
|
private static List<String> optStringList(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v instanceof List) {
|
||||||
|
List<?> src = (List<?>) v;
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (Object o : src) { if (o != null) out.add(String.valueOf(o)); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// 兼容逗号分隔字符串
|
||||||
|
String s = String.valueOf(v);
|
||||||
|
if (s.isBlank()) return new ArrayList<>();
|
||||||
|
String[] arr = s.split(",");
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (String x : arr) { String t = x.trim(); if (!t.isEmpty()) out.add(t); }
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,24 @@ public class AdminUserController {
|
|||||||
if (status != null) { sets.add("status=?"); ps.add(status); }
|
if (status != null) { sets.add("status=?"); ps.add(status); }
|
||||||
if (isOwner != null) { sets.add("is_owner=?"); ps.add(isOwner ? 1 : 0); }
|
if (isOwner != null) { sets.add("is_owner=?"); ps.add(isOwner ? 1 : 0); }
|
||||||
if (sets.isEmpty()) return ResponseEntity.ok().build();
|
if (sets.isEmpty()) return ResponseEntity.ok().build();
|
||||||
String sql = "UPDATE users SET " + String.join(",", sets) + " WHERE id=?";
|
String sql = "UPDATE users SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
|
||||||
ps.add(id);
|
ps.add(id);
|
||||||
jdbcTemplate.update(sql, ps.toArray());
|
jdbcTemplate.update(sql, ps.toArray());
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/blacklist")
|
||||||
|
public ResponseEntity<?> blacklist(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE users SET status=0, updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/restore")
|
||||||
|
public ResponseEntity<?> restore(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE users SET status=1, updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); }
|
private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); }
|
||||||
private static Integer optInteger(Object v) { try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch (Exception e) { return null; } }
|
private static Integer optInteger(Object v) { try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch (Exception e) { return null; } }
|
||||||
private static Boolean optBoolean(Object v) { if (v==null) return null; String s=String.valueOf(v); return ("1".equals(s) || "true".equalsIgnoreCase(s)); }
|
private static Boolean optBoolean(Object v) { if (v==null) return null; String s=String.valueOf(v); return ("1".equals(s) || "true".equalsIgnoreCase(s)); }
|
||||||
|
|||||||
@@ -75,18 +75,30 @@ public class AdminVipController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
@GetMapping("/user/{userId}")
|
||||||
public ResponseEntity<?> approve(@PathVariable("id") Long id,
|
public ResponseEntity<?> getByUser(@PathVariable("userId") Long userId) {
|
||||||
@RequestHeader(name = "X-User-Id") Long reviewerId) {
|
String sql = "SELECT v.id,v.user_id AS userId,v.is_vip AS isVip,v.status,v.expire_at AS expireAt, v.created_at AS createdAt, v.shop_id AS shopId,s.name AS shopName,u.name,u.phone " +
|
||||||
jdbcTemplate.update("UPDATE vip_users SET status=1, reviewer_id=?, reviewed_at=NOW(), updated_at=NOW() WHERE id=?", reviewerId, id);
|
"FROM vip_users v JOIN users u ON u.id=v.user_id JOIN shops s ON s.id=v.shop_id WHERE v.user_id=? ORDER BY v.id DESC LIMIT 1";
|
||||||
return ResponseEntity.ok().build();
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.query(sql, (rs) -> {
|
||||||
}
|
java.util.List<java.util.Map<String,Object>> rows = new java.util.ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
@PostMapping("/{id}/reject")
|
java.util.Map<String,Object> m = new java.util.LinkedHashMap<>();
|
||||||
public ResponseEntity<?> reject(@PathVariable("id") Long id,
|
m.put("id", rs.getLong("id"));
|
||||||
@RequestHeader(name = "X-User-Id") Long reviewerId) {
|
m.put("userId", rs.getLong("userId"));
|
||||||
jdbcTemplate.update("UPDATE vip_users SET status=0, reviewer_id=?, reviewed_at=NOW(), updated_at=NOW() WHERE id=?", reviewerId, id);
|
m.put("isVip", rs.getInt("isVip"));
|
||||||
return ResponseEntity.ok().build();
|
m.put("status", rs.getInt("status"));
|
||||||
|
m.put("expireAt", rs.getTimestamp("expireAt"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("createdAt"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("shopName", rs.getString("shopName"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("phone", rs.getString("phone"));
|
||||||
|
rows.add(m);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, userId);
|
||||||
|
if (list == null || list.isEmpty()) return ResponseEntity.ok(java.util.Map.of());
|
||||||
|
return ResponseEntity.ok(list.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String str(Object v){ return v==null?null:String.valueOf(v); }
|
private static String str(Object v){ return v==null?null:String.valueOf(v); }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/vip/price")
|
||||||
|
public class AdminVipPriceController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminVipPriceController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> getPrice(@RequestHeader(name = "X-Admin-Id") Long adminId) {
|
||||||
|
Double price = 0d;
|
||||||
|
try {
|
||||||
|
price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return ResponseEntity.ok(Map.of("price", price));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
public ResponseEntity<?> setPrice(@RequestHeader(name = "X-Admin-Id") Long adminId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
Double price = asDouble(body.get("price"));
|
||||||
|
if (price == null) return ResponseEntity.badRequest().body(Map.of("message", "price required"));
|
||||||
|
// 单记录表:清空后插入
|
||||||
|
jdbcTemplate.update("DELETE FROM vip_price");
|
||||||
|
jdbcTemplate.update("INSERT INTO vip_price (price) VALUES (?)", price);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double asDouble(Object v) {
|
||||||
|
try { return v == null ? null : Double.valueOf(String.valueOf(v)); } catch (Exception e) { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/vip/system")
|
||||||
|
public class AdminVipSystemController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminVipSystemController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/price")
|
||||||
|
public ResponseEntity<?> getPrice(@RequestHeader(name = "X-Admin-Id") Long adminId) {
|
||||||
|
Double price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d);
|
||||||
|
return ResponseEntity.ok(Map.of("price", price));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/price")
|
||||||
|
public ResponseEntity<?> setPrice(@RequestHeader(name = "X-Admin-Id") Long adminId, @RequestBody Map<String,Object> body) {
|
||||||
|
Double price = asDouble(body.get("price"));
|
||||||
|
if (price == null) return ResponseEntity.badRequest().body(Map.of("message","price required"));
|
||||||
|
jdbcTemplate.update("DELETE FROM vip_price");
|
||||||
|
jdbcTemplate.update("INSERT INTO vip_price(price) VALUES(?)", price);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recharges")
|
||||||
|
public ResponseEntity<?> listRecharges(@RequestHeader(name = "X-Admin-Id") Long adminId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT r.id,r.shop_id AS shopId,s.name AS shopName,r.user_id AS userId,u.name,u.phone,r.price,r.duration_days AS durationDays,r.expire_from AS expireFrom,r.expire_to AS expireTo,r.channel,r.created_at AS createdAt FROM vip_recharges r LEFT JOIN users u ON u.id=r.user_id LEFT JOIN shops s ON s.id=r.shop_id WHERE 1=1");
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>();
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (u.phone LIKE ? OR u.name LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
||||||
|
sql.append(" ORDER BY r.id DESC LIMIT ").append(size).append(" OFFSET ").append(offset);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
Map<String,Object> resp = new HashMap<>();
|
||||||
|
resp.put("list", list);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double asDouble(Object v) { try { return v==null?null:Double.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/normal-admin")
|
||||||
|
public class NormalAdminApprovalController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public NormalAdminApprovalController(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||||
|
|
||||||
|
@GetMapping("/applications")
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, (page - 1) * size);
|
||||||
|
String base = "SELECT a.id,a.shop_id AS shopId,a.user_id AS userId,u.name,u.email,u.phone,a.remark,a.created_at AS createdAt " +
|
||||||
|
"FROM normal_admin_audits a JOIN users u ON u.id=a.user_id WHERE a.action='apply' AND NOT EXISTS (" +
|
||||||
|
"SELECT 1 FROM normal_admin_audits x WHERE x.user_id=a.user_id AND x.created_at>a.created_at AND x.action IN ('approve','reject'))";
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (kw != null && !kw.isBlank()) {
|
||||||
|
base += " AND (u.name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?)";
|
||||||
|
String like = "%" + kw.trim() + "%";
|
||||||
|
ps.add(like); ps.add(like); ps.add(like);
|
||||||
|
}
|
||||||
|
String pageSql = base + " ORDER BY a.created_at DESC LIMIT ? OFFSET ?";
|
||||||
|
ps.add(size); ps.add(offset);
|
||||||
|
List<Map<String,Object>> list = jdbc.query(pageSql, ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("email", rs.getString("email"));
|
||||||
|
m.put("phone", rs.getString("phone"));
|
||||||
|
m.put("remark", rs.getString("remark"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("createdAt"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
// 简化:total 暂以当前页大小代替(可扩展 count)
|
||||||
|
return ResponseEntity.ok(Map.of("list", list, "total", list.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/applications/{userId}/approve")
|
||||||
|
public ResponseEntity<?> approve(@PathVariable("userId") long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody(required = false) Map<String,Object> body) {
|
||||||
|
// 记录 previous_role
|
||||||
|
final String prev = jdbc.query("SELECT role FROM users WHERE id=? LIMIT 1", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null);
|
||||||
|
if (prev == null) return ResponseEntity.badRequest().body(Map.of("error", "user not found"));
|
||||||
|
jdbc.update("UPDATE users SET role=? WHERE id=?", ps -> { ps.setString(1, "normal_admin"); ps.setLong(2, userId); });
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) " +
|
||||||
|
"SELECT u.shop_id, u.id, 'approve', ?, ?, ?, ?, NOW() FROM users u WHERE u.id=?",
|
||||||
|
ps -> { ps.setString(1, body != null ? Objects.toString(body.get("remark"), null) : null);
|
||||||
|
if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId);
|
||||||
|
ps.setString(3, prev); ps.setString(4, "normal_admin"); ps.setLong(5, userId);} );
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/applications/{userId}/reject")
|
||||||
|
public ResponseEntity<?> reject(@PathVariable("userId") long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody Map<String,Object> body) {
|
||||||
|
String remark = body == null ? null : Objects.toString(body.get("remark"), null);
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,created_at) " +
|
||||||
|
"SELECT u.shop_id, u.id, 'reject', ?, ?, NOW() FROM users u WHERE u.id=?",
|
||||||
|
ps -> { ps.setString(1, remark); if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId); ps.setLong(3, userId);} );
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{userId}/revoke")
|
||||||
|
public ResponseEntity<?> revoke(@PathVariable("userId") long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody(required = false) Map<String,Object> body) {
|
||||||
|
// 找到最近一次 approve 的 previous_role
|
||||||
|
final String prev = jdbc.query("SELECT previous_role FROM normal_admin_audits WHERE user_id=? AND action='approve' ORDER BY created_at DESC LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null);
|
||||||
|
String finalPrev = prev;
|
||||||
|
if (finalPrev == null || finalPrev.isBlank()) {
|
||||||
|
// fallback:根据是否店主回退
|
||||||
|
Boolean owner = jdbc.query("SELECT is_owner FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getBoolean(1) : false);
|
||||||
|
finalPrev = (owner != null && owner) ? "owner" : "staff";
|
||||||
|
}
|
||||||
|
String prevRoleForAudit = finalPrev;
|
||||||
|
jdbc.update("UPDATE users SET role=? WHERE id=?", ps -> { ps.setString(1, prevRoleForAudit); ps.setLong(2, userId); });
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) " +
|
||||||
|
"SELECT u.shop_id, u.id, 'revoke', ?, ?, 'normal_admin', ?, NOW() FROM users u WHERE u.id=?",
|
||||||
|
ps -> { ps.setString(1, body == null ? null : Objects.toString(body.get("remark"), null));
|
||||||
|
if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId);
|
||||||
|
ps.setString(3, prevRoleForAudit); ps.setLong(4, userId);} );
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,10 +6,14 @@ import org.springframework.http.CacheControl;
|
|||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -21,30 +25,56 @@ import java.nio.file.Path;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/attachments")
|
@RequestMapping("/api/attachments")
|
||||||
public class AttachmentController {
|
public class AttachmentController {
|
||||||
|
|
||||||
private final AttachmentPlaceholderProperties placeholderProperties;
|
private final AttachmentPlaceholderProperties placeholderProperties;
|
||||||
private final AttachmentUrlValidator urlValidator;
|
private final AttachmentUrlValidator urlValidator;
|
||||||
|
private final AttachmentStorageService storageService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties,
|
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties,
|
||||||
AttachmentUrlValidator urlValidator) {
|
AttachmentUrlValidator urlValidator,
|
||||||
|
AttachmentStorageService storageService,
|
||||||
|
JdbcTemplate jdbcTemplate) {
|
||||||
this.placeholderProperties = placeholderProperties;
|
this.placeholderProperties = placeholderProperties;
|
||||||
this.urlValidator = urlValidator;
|
this.urlValidator = urlValidator;
|
||||||
|
this.storageService = storageService;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ResponseEntity<Map<String, Object>> upload(@RequestParam("file") MultipartFile file,
|
public ResponseEntity<Map<String, Object>> upload(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
@RequestParam(value = "ownerType", required = false) String ownerType,
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
@RequestParam(value = "ownerId", required = false) String ownerId) {
|
@RequestParam("file") MultipartFile file,
|
||||||
// 占位实现:忽略文件内容,始终返回占位图 URL
|
@RequestParam(value = "ownerType", required = false) String ownerType,
|
||||||
String url = StringUtils.hasText(placeholderProperties.getUrlPath()) ? placeholderProperties.getUrlPath() : "/api/attachments/placeholder";
|
@RequestParam(value = "ownerId", required = false) Long ownerId) throws IOException {
|
||||||
Map<String, Object> body = new HashMap<>();
|
AttachmentStorageService.StoredObject so = storageService.store(file);
|
||||||
body.put("url", url);
|
|
||||||
return ResponseEntity.ok(body);
|
String ot = StringUtils.hasText(ownerType) ? ownerType.trim() : "global";
|
||||||
}
|
Long oid = ownerId == null ? 0L : ownerId;
|
||||||
|
|
||||||
|
// 写入 attachments 表
|
||||||
|
String metaJson = buildMetaJson(so.relativePath(), so.contentType(), so.size());
|
||||||
|
try {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO attachments (shop_id, user_id, owner_type, owner_id, url, hash, meta, created_at) VALUES (?,?,?,?,?,?,?,NOW())",
|
||||||
|
shopId, userId, ot, oid, "/api/attachments/content/" + so.sha256(), so.sha256(), metaJson
|
||||||
|
);
|
||||||
|
} catch (DuplicateKeyException ignore) {
|
||||||
|
// 已存在相同hash记录,忽略插入以实现幂等
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("url", "/api/attachments/content/" + so.sha256());
|
||||||
|
body.put("contentType", so.contentType());
|
||||||
|
body.put("contentLength", so.size());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<Map<String, Object>> validateUrlJson(@RequestBody Map<String, Object> body) {
|
public ResponseEntity<Map<String, Object>> validateUrlJson(@RequestBody Map<String, Object> body) {
|
||||||
@@ -84,18 +114,118 @@ public class AttachmentController {
|
|||||||
} catch (IOException ignore) {
|
} catch (IOException ignore) {
|
||||||
contentType = null;
|
contentType = null;
|
||||||
}
|
}
|
||||||
MediaType mediaType;
|
MediaType mediaType;
|
||||||
try {
|
try {
|
||||||
mediaType = StringUtils.hasText(contentType) ? MediaType.parseMediaType(contentType) : MediaType.IMAGE_PNG;
|
if (contentType == null || contentType.isBlank()) {
|
||||||
} catch (Exception e) {
|
mediaType = MediaType.IMAGE_PNG;
|
||||||
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
} else {
|
||||||
}
|
mediaType = MediaType.parseMediaType(contentType);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=placeholder")
|
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=placeholder")
|
||||||
.cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic())
|
.cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic())
|
||||||
.contentType(mediaType)
|
.contentType(mediaType)
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/content/{sha256}")
|
||||||
|
public ResponseEntity<Resource> contentByHash(@PathVariable("sha256") String sha256) throws IOException {
|
||||||
|
if (!StringUtils.hasText(sha256)) return ResponseEntity.badRequest().build();
|
||||||
|
// 从数据库读取 meta.path 获取相对路径
|
||||||
|
String relativePath = null;
|
||||||
|
try {
|
||||||
|
String meta = jdbcTemplate.query("SELECT meta FROM attachments WHERE hash=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> ps.setString(1, sha256),
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
relativePath = extractPathFromMetaJson(meta);
|
||||||
|
if (!StringUtils.hasText(relativePath)) {
|
||||||
|
relativePath = extractPathFromMeta(meta);
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) { relativePath = null; }
|
||||||
|
|
||||||
|
Path found = null;
|
||||||
|
if (StringUtils.hasText(relativePath)) {
|
||||||
|
try { found = storageService.resolveAbsolutePath(relativePath); } catch (Exception ignore) { found = null; }
|
||||||
|
}
|
||||||
|
if (found == null || !Files.exists(found)) {
|
||||||
|
// 兜底:全目录扫描(少量文件可接受)
|
||||||
|
Path storageRoot = storageService != null ? storageService.getStorageRoot() : Path.of("./data/attachments");
|
||||||
|
if (Files.exists(storageRoot)) {
|
||||||
|
found = findFileByHash(storageRoot, sha256);
|
||||||
|
}
|
||||||
|
if (found == null) return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource resource = new FileSystemResource(found);
|
||||||
|
String contentType = null;
|
||||||
|
try { contentType = Files.probeContentType(found); } catch (IOException ignore) { contentType = null; }
|
||||||
|
MediaType mediaType;
|
||||||
|
try {
|
||||||
|
if (contentType == null || contentType.isBlank()) {
|
||||||
|
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
} else {
|
||||||
|
mediaType = MediaType.parseMediaType(contentType);
|
||||||
|
}
|
||||||
|
} catch (Exception e) { mediaType = MediaType.APPLICATION_OCTET_STREAM; }
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + found.getFileName())
|
||||||
|
.cacheControl(CacheControl.maxAge(30, java.util.concurrent.TimeUnit.DAYS).cachePublic())
|
||||||
|
.contentType(mediaType)
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path findFileByHash(Path root, String sha256) throws IOException {
|
||||||
|
try (var stream = Files.walk(root)) {
|
||||||
|
return stream
|
||||||
|
.filter(p -> Files.isRegularFile(p))
|
||||||
|
.filter(p -> {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
return name.equals(sha256) || name.startsWith(sha256 + ".");
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildMetaJson(String relativePath, String contentType, long size) {
|
||||||
|
return "{" +
|
||||||
|
"\"path\":\"" + escapeJson(relativePath) + "\"," +
|
||||||
|
"\"contentType\":\"" + escapeJson(contentType) + "\"," +
|
||||||
|
"\"size\":" + size +
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractPathFromMeta(String meta) {
|
||||||
|
if (!StringUtils.hasText(meta)) return null;
|
||||||
|
int i = meta.indexOf("\"path\":\"");
|
||||||
|
if (i < 0) return null;
|
||||||
|
int s = i + 8; // length of "path":"
|
||||||
|
int e = meta.indexOf('"', s);
|
||||||
|
if (e < 0) return null;
|
||||||
|
String val = meta.substring(s, e);
|
||||||
|
return val.replace("\\\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractPathFromMetaJson(String meta) {
|
||||||
|
if (!StringUtils.hasText(meta)) return null;
|
||||||
|
try {
|
||||||
|
ObjectMapper om = new ObjectMapper();
|
||||||
|
Map<String,Object> m = om.readValue(meta, new TypeReference<Map<String,Object>>(){});
|
||||||
|
Object p = m.get("path");
|
||||||
|
return p == null ? null : String.valueOf(p);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJson(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AttachmentStorageService {
|
||||||
|
|
||||||
|
private final AttachmentUploadProperties props;
|
||||||
|
|
||||||
|
public AttachmentStorageService(AttachmentUploadProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredObject store(MultipartFile file) throws IOException {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("文件为空");
|
||||||
|
}
|
||||||
|
long size = file.getSize();
|
||||||
|
if (size > props.getMaxSizeBytes()) {
|
||||||
|
throw new IllegalArgumentException("文件过大,超过上限" + props.getMaxSizeMb() + "MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentType = normalizeContentType(file.getContentType());
|
||||||
|
if (!isAllowedContentType(contentType)) {
|
||||||
|
// 尝试根据扩展名推断
|
||||||
|
String guessed = guessContentTypeFromFilename(file.getOriginalFilename());
|
||||||
|
if (!isAllowedContentType(guessed)) {
|
||||||
|
throw new IllegalArgumentException("不支持的文件类型");
|
||||||
|
}
|
||||||
|
contentType = guessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算哈希
|
||||||
|
String sha256 = sha256Hex(file);
|
||||||
|
|
||||||
|
// 生成相对路径:yyyy/MM/dd/<sha256>.<ext>
|
||||||
|
String ext = extensionForContentType(contentType);
|
||||||
|
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||||
|
Path baseDir = Path.of(props.getStorageDir());
|
||||||
|
Path dir = baseDir.resolve(datePath);
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Path target = dir.resolve(sha256 + (ext == null ? "" : ("." + ext)));
|
||||||
|
|
||||||
|
// 保存文件(覆盖策略:相同哈希重复上传时幂等)
|
||||||
|
try (InputStream in = file.getInputStream()) {
|
||||||
|
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
String relativePath = baseDir.toAbsolutePath().normalize().relativize(target.toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||||
|
return new StoredObject(relativePath, contentType, size, sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveAbsolutePath(String relativePath) {
|
||||||
|
if (!StringUtils.hasText(relativePath)) {
|
||||||
|
throw new IllegalArgumentException("路径无效");
|
||||||
|
}
|
||||||
|
return Path.of(props.getStorageDir()).resolve(relativePath).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getStorageRoot() {
|
||||||
|
return Path.of(props.getStorageDir()).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return null;
|
||||||
|
int idx = ct.indexOf(';');
|
||||||
|
String base = (idx > 0 ? ct.substring(0, idx) : ct).trim().toLowerCase(Locale.ROOT);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return false;
|
||||||
|
for (String allowed : props.getAllowedContentTypes()) {
|
||||||
|
if (ct.equalsIgnoreCase(allowed)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extensionForContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return null;
|
||||||
|
return switch (ct) {
|
||||||
|
case "image/jpeg" -> "jpg";
|
||||||
|
case "image/png" -> "png";
|
||||||
|
case "image/gif" -> "gif";
|
||||||
|
case "image/webp" -> "webp";
|
||||||
|
case "image/svg+xml" -> "svg";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String guessContentTypeFromFilename(String name) {
|
||||||
|
if (!StringUtils.hasText(name)) return null;
|
||||||
|
String n = name.toLowerCase(Locale.ROOT);
|
||||||
|
if (n.endsWith(".jpg") || n.endsWith(".jpeg")) return MediaType.IMAGE_JPEG_VALUE;
|
||||||
|
if (n.endsWith(".png")) return MediaType.IMAGE_PNG_VALUE;
|
||||||
|
if (n.endsWith(".gif")) return MediaType.IMAGE_GIF_VALUE;
|
||||||
|
if (n.endsWith(".webp")) return "image/webp";
|
||||||
|
if (n.endsWith(".svg")) return "image/svg+xml";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256Hex(MultipartFile file) throws IOException {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
try (InputStream in = file.getInputStream(); DigestInputStream dis = new DigestInputStream(in, md)) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while (dis.read(buffer) != -1) { /* drain */ }
|
||||||
|
}
|
||||||
|
byte[] digest = md.digest();
|
||||||
|
return HexFormat.of().formatHex(digest);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 不可用", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StoredObject(String relativePath, String contentType, long size, String sha256) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "attachments.upload")
|
||||||
|
public class AttachmentUploadProperties {
|
||||||
|
|
||||||
|
private String storageDir = "./data/attachments";
|
||||||
|
private int maxSizeMb = 5;
|
||||||
|
private List<String> allowedContentTypes = new ArrayList<>(Arrays.asList(
|
||||||
|
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"
|
||||||
|
));
|
||||||
|
|
||||||
|
public String getStorageDir() { return storageDir; }
|
||||||
|
public void setStorageDir(String storageDir) { this.storageDir = storageDir; }
|
||||||
|
|
||||||
|
public int getMaxSizeMb() { return maxSizeMb; }
|
||||||
|
public void setMaxSizeMb(int maxSizeMb) { this.maxSizeMb = maxSizeMb; }
|
||||||
|
|
||||||
|
public List<String> getAllowedContentTypes() { return allowedContentTypes; }
|
||||||
|
public void setAllowedContentTypes(List<String> allowedContentTypes) { this.allowedContentTypes = allowedContentTypes; }
|
||||||
|
|
||||||
|
public long getMaxSizeBytes() { return (long) maxSizeMb * 1024L * 1024L; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.context.request.RequestAttributes;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth/email")
|
||||||
|
public class EmailAuthController {
|
||||||
|
|
||||||
|
private final EmailAuthService emailAuthService;
|
||||||
|
|
||||||
|
public EmailAuthController(EmailAuthService emailAuthService) {
|
||||||
|
this.emailAuthService = emailAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/send")
|
||||||
|
public ResponseEntity<?> send(@RequestBody EmailAuthService.SendCodeRequest req,
|
||||||
|
@RequestHeader(value = "X-Forwarded-For", required = false) String xff,
|
||||||
|
@RequestHeader(value = "X-Real-IP", required = false) String xri) {
|
||||||
|
String ip = xri != null ? xri : (xff != null ? xff.split(",")[0].trim() : getClientIp());
|
||||||
|
EmailAuthService.SendCodeResponse resp = emailAuthService.sendCode(req, ip);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody EmailAuthService.LoginRequest req) {
|
||||||
|
EmailAuthService.LoginResponse resp = emailAuthService.login(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody EmailAuthService.RegisterRequest req) {
|
||||||
|
EmailAuthService.LoginResponse resp = emailAuthService.register(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reset-password")
|
||||||
|
public ResponseEntity<?> resetPassword(@RequestBody EmailAuthService.ResetPasswordRequest req) {
|
||||||
|
EmailAuthService.ResetPasswordResponse resp = emailAuthService.resetPassword(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientIp() {
|
||||||
|
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attrs instanceof ServletRequestAttributes sra) {
|
||||||
|
var req = sra.getRequest();
|
||||||
|
return req.getRemoteAddr();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import com.example.demo.common.EmailSenderService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EmailAuthService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JwtProperties jwtProps;
|
||||||
|
private final com.example.demo.common.ShopDefaultsProperties shopDefaults;
|
||||||
|
private final EmailSenderService emailSender;
|
||||||
|
|
||||||
|
public EmailAuthService(JdbcTemplate jdbcTemplate,
|
||||||
|
JwtService jwtService,
|
||||||
|
JwtProperties jwtProps,
|
||||||
|
com.example.demo.common.ShopDefaultsProperties shopDefaults,
|
||||||
|
EmailSenderService emailSender) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.jwtProps = jwtProps;
|
||||||
|
this.shopDefaults = shopDefaults;
|
||||||
|
this.emailSender = emailSender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SendCodeRequest { public String email; public String scene; }
|
||||||
|
public static class SendCodeResponse { public boolean ok; public long cooldownSec; }
|
||||||
|
public static class LoginRequest { public String email; public String code; public String name; }
|
||||||
|
public static class RegisterRequest { public String email; public String code; public String name; public String password; }
|
||||||
|
public static class ResetPasswordRequest { public String email; public String code; public String newPassword; public String confirmPassword; }
|
||||||
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
||||||
|
public static class ResetPasswordResponse { public boolean ok; }
|
||||||
|
|
||||||
|
private String generateCode() {
|
||||||
|
SecureRandom rng = new SecureRandom();
|
||||||
|
int n = 100000 + rng.nextInt(900000);
|
||||||
|
return String.valueOf(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hmacSha256Hex(String secret, String message) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
md.update((message + secret).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(md.digest());
|
||||||
|
} catch (Exception e) { throw new RuntimeException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureEmailFormat(String email) {
|
||||||
|
if (email == null || email.isBlank()) throw new IllegalArgumentException("邮箱不能为空");
|
||||||
|
String e = email.trim();
|
||||||
|
if (!e.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) throw new IllegalArgumentException("邮箱格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensurePasswordFormat(String password) {
|
||||||
|
if (password == null || password.isBlank()) throw new IllegalArgumentException("密码不能为空");
|
||||||
|
if (password.length() < 6) throw new IllegalArgumentException("密码至少6位");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String,Object> fetchLatestCode(String email, String scene) {
|
||||||
|
String sc = (scene == null || scene.isBlank()) ? "login" : scene.trim();
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement(
|
||||||
|
"SELECT id, code_hash, salt, expire_at, status, fail_count FROM email_codes WHERE email=? AND scene=? ORDER BY id DESC LIMIT 1");
|
||||||
|
ps.setString(1, email);
|
||||||
|
ps.setString(2, sc);
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> m = new java.util.HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("code_hash", rs.getString(2));
|
||||||
|
m.put("salt", rs.getString(3));
|
||||||
|
m.put("expire_at", rs.getTimestamp(4));
|
||||||
|
m.put("status", rs.getInt(5));
|
||||||
|
m.put("fail_count", rs.getInt(6));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateAndConsumeCode(String email, String scene, String code) {
|
||||||
|
if (code == null || code.isBlank()) throw new IllegalArgumentException("验证码不能为空");
|
||||||
|
String lowerEmail = email.trim().toLowerCase();
|
||||||
|
Map<String,Object> row = fetchLatestCode(lowerEmail, scene);
|
||||||
|
if (row == null && scene != null && !scene.isBlank() && !"login".equals(scene)) {
|
||||||
|
row = fetchLatestCode(lowerEmail, "login");
|
||||||
|
}
|
||||||
|
if (row == null) throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
Long id = ((Number)row.get("id")).longValue();
|
||||||
|
int status = ((Number)row.get("status")).intValue();
|
||||||
|
if (status != 0) throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
java.sql.Timestamp expireAt = (java.sql.Timestamp) row.get("expire_at");
|
||||||
|
if (expireAt.before(new java.util.Date())) {
|
||||||
|
jdbcTemplate.update("UPDATE email_codes SET status=2 WHERE id=?", id);
|
||||||
|
throw new IllegalArgumentException("CODE_EXPIRED");
|
||||||
|
}
|
||||||
|
int failCount = ((Number)row.get("fail_count")).intValue();
|
||||||
|
if (failCount >= 5) throw new IllegalArgumentException("TOO_MANY_FAILS");
|
||||||
|
String expect = (String) row.get("code_hash");
|
||||||
|
String salt = (String) row.get("salt");
|
||||||
|
String actual = hmacSha256Hex(salt, code);
|
||||||
|
if (!actual.equalsIgnoreCase(expect)) {
|
||||||
|
jdbcTemplate.update("UPDATE email_codes SET fail_count=fail_count+1 WHERE id=?", id);
|
||||||
|
throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("UPDATE email_codes SET status=1 WHERE id=?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SendCodeResponse sendCode(SendCodeRequest req, String clientIp) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
String scene = (req.scene == null || req.scene.isBlank()) ? "login" : req.scene.trim().toLowerCase();
|
||||||
|
|
||||||
|
Long cntRecent = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(1) FROM email_codes WHERE email=? AND scene=? AND created_at >= NOW() - INTERVAL 60 SECOND",
|
||||||
|
Long.class, email, scene);
|
||||||
|
if (cntRecent != null && cntRecent > 0) {
|
||||||
|
SendCodeResponse out = new SendCodeResponse();
|
||||||
|
out.ok = false; out.cooldownSec = 60;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
String code = generateCode();
|
||||||
|
String salt = Long.toHexString(System.nanoTime());
|
||||||
|
String codeHash = hmacSha256Hex(salt, code);
|
||||||
|
int ttl = 300; // 五分钟有效
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO email_codes(email, scene, code_hash, salt, expire_at, status, fail_count, ip, created_at, updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,DATE_ADD(NOW(), INTERVAL ? SECOND),0,0,?,NOW(),NOW())");
|
||||||
|
ps.setString(1, email);
|
||||||
|
ps.setString(2, scene);
|
||||||
|
ps.setString(3, codeHash);
|
||||||
|
ps.setString(4, salt);
|
||||||
|
ps.setInt(5, ttl);
|
||||||
|
ps.setString(6, clientIp);
|
||||||
|
return ps;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 真实发信
|
||||||
|
String subject = "邮箱验证码";
|
||||||
|
String content = "您的验证码是 " + code + " ,5分钟内有效。如非本人操作请忽略。";
|
||||||
|
emailSender.sendPlainText(email, subject, content);
|
||||||
|
|
||||||
|
SendCodeResponse out = new SendCodeResponse();
|
||||||
|
out.ok = true; out.cooldownSec = 60;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse login(LoginRequest req) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
validateAndConsumeCode(email, "login", req.code);
|
||||||
|
|
||||||
|
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email);
|
||||||
|
Long userId;
|
||||||
|
Long shopId;
|
||||||
|
if (!existing.isEmpty()) {
|
||||||
|
userId = existing.get(0);
|
||||||
|
List<Long> sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId);
|
||||||
|
shopId = sids.isEmpty() ? 1L : sids.get(0);
|
||||||
|
Integer st = jdbcTemplate.queryForObject("SELECT status FROM users WHERE id=?", Integer.class, userId);
|
||||||
|
if (st != null && st.intValue() != 1) {
|
||||||
|
throw new IllegalArgumentException("你已被管理员拉黑");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String displayName = (req.name == null || req.name.isBlank()) ? maskEmailForName(email) : req.name.trim();
|
||||||
|
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
|
||||||
|
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, shopName);
|
||||||
|
return ps;
|
||||||
|
}, shopKey);
|
||||||
|
Number shopGenId = shopKey.getKey();
|
||||||
|
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
|
||||||
|
shopId = shopGenId.longValue();
|
||||||
|
|
||||||
|
var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
final Long sid = shopId;
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO users(shop_id, email, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setLong(1, sid);
|
||||||
|
ps.setString(2, email);
|
||||||
|
ps.setString(3, displayName);
|
||||||
|
ps.setString(4, shopDefaults.getOwnerRole());
|
||||||
|
return ps;
|
||||||
|
}, userKey);
|
||||||
|
Number userGenId = userKey.getKey();
|
||||||
|
if (userGenId == null) throw new IllegalStateException("创建用户失败");
|
||||||
|
userId = userGenId.longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtService.signToken(userId, shopId, null, "email_otp", email);
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
java.util.HashMap<String,Object> userMap = new java.util.HashMap<>();
|
||||||
|
userMap.put("userId", userId);
|
||||||
|
userMap.put("shopId", shopId);
|
||||||
|
userMap.put("email", email);
|
||||||
|
out.user = userMap;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse register(RegisterRequest req) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
ensurePasswordFormat(req.password);
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
validateAndConsumeCode(email, "register", req.code);
|
||||||
|
|
||||||
|
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email);
|
||||||
|
if (!existing.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("邮箱已注册");
|
||||||
|
}
|
||||||
|
|
||||||
|
String displayName = (req.name == null || req.name.isBlank()) ? maskEmailForName(email) : req.name.trim();
|
||||||
|
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
|
||||||
|
|
||||||
|
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, shopName);
|
||||||
|
return ps;
|
||||||
|
}, shopKey);
|
||||||
|
Number shopGenId = shopKey.getKey();
|
||||||
|
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
|
||||||
|
Long shopId = shopGenId.longValue();
|
||||||
|
|
||||||
|
var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO users(shop_id, email, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setLong(1, shopId);
|
||||||
|
ps.setString(2, email);
|
||||||
|
ps.setString(3, displayName);
|
||||||
|
ps.setString(4, shopDefaults.getOwnerRole());
|
||||||
|
return ps;
|
||||||
|
}, userKey);
|
||||||
|
Number userGenId = userKey.getKey();
|
||||||
|
if (userGenId == null) throw new IllegalStateException("创建用户失败");
|
||||||
|
Long userId = userGenId.longValue();
|
||||||
|
|
||||||
|
String bcrypt = org.springframework.security.crypto.bcrypt.BCrypt.hashpw(req.password, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10));
|
||||||
|
jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", bcrypt, userId);
|
||||||
|
|
||||||
|
String token = jwtService.signToken(userId, shopId, null, "email_register", email);
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
java.util.HashMap<String,Object> userMap = new java.util.HashMap<>();
|
||||||
|
userMap.put("userId", userId);
|
||||||
|
userMap.put("shopId", shopId);
|
||||||
|
userMap.put("email", email);
|
||||||
|
out.user = userMap;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ResetPasswordResponse resetPassword(ResetPasswordRequest req) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
if (req.newPassword == null || req.newPassword.isBlank()) throw new IllegalArgumentException("新密码不能为空");
|
||||||
|
if (req.confirmPassword == null || !req.newPassword.equals(req.confirmPassword)) {
|
||||||
|
throw new IllegalArgumentException("两次密码不一致");
|
||||||
|
}
|
||||||
|
ensurePasswordFormat(req.newPassword);
|
||||||
|
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
validateAndConsumeCode(email, "reset", req.code);
|
||||||
|
|
||||||
|
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email);
|
||||||
|
if (existing.isEmpty()) throw new IllegalArgumentException("用户不存在");
|
||||||
|
Long userId = existing.get(0);
|
||||||
|
|
||||||
|
String bcrypt = org.springframework.security.crypto.bcrypt.BCrypt.hashpw(req.newPassword, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10));
|
||||||
|
jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", bcrypt, userId);
|
||||||
|
|
||||||
|
ResetPasswordResponse resp = new ResetPasswordResponse();
|
||||||
|
resp.ok = true;
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskEmailForName(String email) {
|
||||||
|
String e = String.valueOf(email);
|
||||||
|
int at = e.indexOf('@');
|
||||||
|
if (at > 1) {
|
||||||
|
String name = e.substring(0, at);
|
||||||
|
if (name.length() <= 2) return "用户" + name.charAt(0) + "*";
|
||||||
|
return "用户" + name.substring(0, 2) + "***";
|
||||||
|
}
|
||||||
|
return "邮箱用户";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +32,34 @@ public class JwtService {
|
|||||||
return jwt.sign(alg);
|
return jwt.sign(alg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String signToken(Long userId, Long shopId, String phone, String provider, String email) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
|
||||||
|
var jwt = JWT.create()
|
||||||
|
.withIssuer(props.getIssuer())
|
||||||
|
.withIssuedAt(java.util.Date.from(now))
|
||||||
|
.withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds())))
|
||||||
|
.withClaim("userId", userId)
|
||||||
|
.withClaim("shopId", shopId)
|
||||||
|
.withClaim("provider", provider);
|
||||||
|
if (phone != null && !phone.isBlank()) jwt.withClaim("phone", phone);
|
||||||
|
if (email != null && !email.isBlank()) jwt.withClaim("email", email);
|
||||||
|
return jwt.sign(alg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String signAdminToken(Long adminId, String username) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
|
||||||
|
var jwt = JWT.create()
|
||||||
|
.withIssuer(props.getIssuer())
|
||||||
|
.withIssuedAt(java.util.Date.from(now))
|
||||||
|
.withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds())))
|
||||||
|
.withClaim("adminId", adminId)
|
||||||
|
.withClaim("role", "admin");
|
||||||
|
if (username != null && !username.isBlank()) jwt.withClaim("username", username);
|
||||||
|
return jwt.sign(alg);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String,Object> parseClaims(String authorizationHeader) {
|
public Map<String,Object> parseClaims(String authorizationHeader) {
|
||||||
Map<String,Object> out = new HashMap<>();
|
Map<String,Object> out = new HashMap<>();
|
||||||
if (authorizationHeader == null || authorizationHeader.isBlank()) return out;
|
if (authorizationHeader == null || authorizationHeader.isBlank()) return out;
|
||||||
@@ -48,9 +76,15 @@ public class JwtService {
|
|||||||
Long userId = jwt.getClaim("userId").asLong();
|
Long userId = jwt.getClaim("userId").asLong();
|
||||||
Long shopId = jwt.getClaim("shopId").asLong();
|
Long shopId = jwt.getClaim("shopId").asLong();
|
||||||
String phone = jwt.getClaim("phone").asString();
|
String phone = jwt.getClaim("phone").asString();
|
||||||
|
String email = jwt.getClaim("email").asString();
|
||||||
|
Long adminId = jwt.getClaim("adminId").asLong();
|
||||||
|
String role = jwt.getClaim("role").asString();
|
||||||
if (userId != null) out.put("userId", userId);
|
if (userId != null) out.put("userId", userId);
|
||||||
if (shopId != null) out.put("shopId", shopId);
|
if (shopId != null) out.put("shopId", shopId);
|
||||||
if (phone != null && !phone.isBlank()) out.put("phone", phone);
|
if (phone != null && !phone.isBlank()) out.put("phone", phone);
|
||||||
|
if (email != null && !email.isBlank()) out.put("email", email);
|
||||||
|
if (adminId != null) out.put("adminId", adminId);
|
||||||
|
if (role != null && !role.isBlank()) out.put("role", role);
|
||||||
} catch (Exception ignore) { }
|
} catch (Exception ignore) { }
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/normal-admin")
|
||||||
|
public class NormalAdminApplyController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public NormalAdminApplyController(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||||
|
|
||||||
|
@PostMapping("/apply")
|
||||||
|
public ResponseEntity<?> apply(@RequestHeader(name = "X-User-Id") long userId,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestBody(required = false) Map<String,Object> body) {
|
||||||
|
final Long sidFinal;
|
||||||
|
if (shopId == null) {
|
||||||
|
Long sid = jdbc.query("SELECT shop_id FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getLong(1): null);
|
||||||
|
if (sid == null) return ResponseEntity.badRequest().body(Map.of("error", "user not found"));
|
||||||
|
sidFinal = sid;
|
||||||
|
} else { sidFinal = shopId; }
|
||||||
|
// 校验 VIP(根据配置可选)
|
||||||
|
boolean requireVip = true; // 默认要求VIP有效
|
||||||
|
Integer vipOk = jdbc.query(
|
||||||
|
"SELECT CASE WHEN (is_vip=1 AND status=1 AND (expire_at IS NULL OR expire_at>NOW())) THEN 1 ELSE 0 END FROM vip_users WHERE user_id=? AND shop_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> { ps.setLong(1, userId); ps.setLong(2, sidFinal); },
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : 0
|
||||||
|
);
|
||||||
|
if (requireVip && (vipOk == null || vipOk != 1)) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("error", "vip required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String remark = body == null ? null : Objects.toString(body.get("remark"), null);
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,created_at) VALUES (?,?,?,?,NOW())",
|
||||||
|
ps -> { ps.setLong(1, sidFinal); ps.setLong(2, userId); ps.setString(3, "apply"); ps.setString(4, remark); });
|
||||||
|
|
||||||
|
// 是否自动通过
|
||||||
|
boolean autoApprove = false; // 默认false,后续接入 system_parameters
|
||||||
|
if (autoApprove) {
|
||||||
|
// 将角色变更为 normal_admin 并写入 approve 审计
|
||||||
|
String prev = jdbc.query("SELECT role FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null);
|
||||||
|
jdbc.update("UPDATE users SET role='normal_admin' WHERE id=?", ps -> ps.setLong(1, userId));
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) VALUES (?,?,?,?,NULL,?,?,NOW())",
|
||||||
|
ps -> { ps.setLong(1, sidFinal); ps.setLong(2, userId); ps.setString(3, "approve"); ps.setString(4, "auto"); ps.setString(5, prev); ps.setString(6, "normal_admin"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -22,18 +22,22 @@ public class PasswordAuthService {
|
|||||||
this.jwtProps = jwtProps;
|
this.jwtProps = jwtProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class LoginRequest { public String phone; public String password; }
|
public static class LoginRequest { public String account; public String email; public String phone; public String password; }
|
||||||
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public LoginResponse login(LoginRequest req) {
|
public LoginResponse login(LoginRequest req) {
|
||||||
ensurePhoneFormat(req.phone);
|
String account = firstNonBlank(req.account, req.email, req.phone);
|
||||||
|
if (account == null || account.isBlank()) throw new IllegalArgumentException("账号不能为空");
|
||||||
if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空");
|
if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空");
|
||||||
|
|
||||||
|
boolean byEmail = account.contains("@");
|
||||||
Map<String, Object> row = jdbcTemplate.query(
|
Map<String, Object> row = jdbcTemplate.query(
|
||||||
con -> {
|
con -> {
|
||||||
var ps = con.prepareStatement("SELECT id, shop_id, password_hash, status FROM users WHERE phone=? LIMIT 1");
|
var ps = con.prepareStatement(byEmail
|
||||||
ps.setString(1, req.phone);
|
? "SELECT id, shop_id, password_hash, status, email FROM users WHERE email=? LIMIT 1"
|
||||||
|
: "SELECT id, shop_id, password_hash, status, phone FROM users WHERE phone=? LIMIT 1");
|
||||||
|
ps.setString(1, account.trim());
|
||||||
return ps;
|
return ps;
|
||||||
},
|
},
|
||||||
rs -> {
|
rs -> {
|
||||||
@@ -43,6 +47,7 @@ public class PasswordAuthService {
|
|||||||
m.put("shop_id", rs.getLong(2));
|
m.put("shop_id", rs.getLong(2));
|
||||||
m.put("password_hash", rs.getString(3));
|
m.put("password_hash", rs.getString(3));
|
||||||
m.put("status", rs.getInt(4));
|
m.put("status", rs.getInt(4));
|
||||||
|
m.put("account", rs.getString(5));
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -50,7 +55,7 @@ public class PasswordAuthService {
|
|||||||
);
|
);
|
||||||
if (row == null) throw new IllegalArgumentException("用户不存在");
|
if (row == null) throw new IllegalArgumentException("用户不存在");
|
||||||
int status = ((Number)row.get("status")).intValue();
|
int status = ((Number)row.get("status")).intValue();
|
||||||
if (status != 1) throw new IllegalArgumentException("用户未启用");
|
if (status != 1) throw new IllegalArgumentException("你已被管理员拉黑");
|
||||||
String hash = (String) row.get("password_hash");
|
String hash = (String) row.get("password_hash");
|
||||||
if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD");
|
if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD");
|
||||||
boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash);
|
boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash);
|
||||||
@@ -58,21 +63,25 @@ public class PasswordAuthService {
|
|||||||
|
|
||||||
Long userId = ((Number)row.get("id")).longValue();
|
Long userId = ((Number)row.get("id")).longValue();
|
||||||
Long shopId = ((Number)row.get("shop_id")).longValue();
|
Long shopId = ((Number)row.get("shop_id")).longValue();
|
||||||
|
String accValue = String.valueOf(row.get("account"));
|
||||||
|
|
||||||
String token = jwtService.signToken(userId, shopId, req.phone, "password");
|
String token = byEmail
|
||||||
|
? jwtService.signToken(userId, shopId, null, "password", accValue)
|
||||||
|
: jwtService.signToken(userId, shopId, accValue, "password");
|
||||||
LoginResponse out = new LoginResponse();
|
LoginResponse out = new LoginResponse();
|
||||||
out.token = token;
|
out.token = token;
|
||||||
out.expiresIn = jwtProps.getTtlSeconds();
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
Map<String,Object> userMap = new HashMap<>();
|
Map<String,Object> userMap = new HashMap<>();
|
||||||
userMap.put("userId", userId); userMap.put("shopId", shopId); userMap.put("phone", req.phone);
|
userMap.put("userId", userId); userMap.put("shopId", shopId);
|
||||||
|
if (byEmail) userMap.put("email", accValue); else userMap.put("phone", accValue);
|
||||||
out.user = userMap;
|
out.user = userMap;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensurePhoneFormat(String phone) {
|
private static String firstNonBlank(String... arr) {
|
||||||
if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空");
|
if (arr == null) return null;
|
||||||
String p = phone.replaceAll("\\s+", "");
|
for (String s : arr) { if (s != null && !s.isBlank()) return s; }
|
||||||
if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确");
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class SmsAuthService {
|
|||||||
|
|
||||||
public static class SendCodeRequest { public String phone; public String scene; }
|
public static class SendCodeRequest { public String phone; public String scene; }
|
||||||
public static class SendCodeResponse { public boolean ok; public long cooldownSec; }
|
public static class SendCodeResponse { public boolean ok; public long cooldownSec; }
|
||||||
public static class LoginRequest { public String phone; public String code; }
|
public static class LoginRequest { public String phone; public String code; public String name; }
|
||||||
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
||||||
|
|
||||||
private String generateCode() {
|
private String generateCode() {
|
||||||
@@ -147,9 +147,14 @@ public class SmsAuthService {
|
|||||||
userId = existing.get(0);
|
userId = existing.get(0);
|
||||||
List<Long> sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId);
|
List<Long> sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId);
|
||||||
shopId = sids.isEmpty() ? 1L : sids.get(0);
|
shopId = sids.isEmpty() ? 1L : sids.get(0);
|
||||||
|
// 拉黑校验:status 必须为 1 才允许登录
|
||||||
|
Integer st = jdbcTemplate.queryForObject("SELECT status FROM users WHERE id=?", Integer.class, userId);
|
||||||
|
if (st != null && st.intValue() != 1) {
|
||||||
|
throw new IllegalArgumentException("你已被管理员拉黑");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
String userName = maskPhoneForName(phone);
|
String displayName = (req.name == null || req.name.isBlank()) ? maskPhoneForName(phone) : req.name.trim();
|
||||||
String shopName = String.format(shopDefaults.getNamePattern(), userName);
|
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
|
||||||
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
jdbcTemplate.update(con -> {
|
jdbcTemplate.update(con -> {
|
||||||
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
@@ -166,7 +171,7 @@ public class SmsAuthService {
|
|||||||
var ps = con.prepareStatement("INSERT INTO users(shop_id, phone, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
var ps = con.prepareStatement("INSERT INTO users(shop_id, phone, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
ps.setLong(1, sid);
|
ps.setLong(1, sid);
|
||||||
ps.setString(2, phone);
|
ps.setString(2, phone);
|
||||||
ps.setString(3, userName);
|
ps.setString(3, displayName);
|
||||||
ps.setString(4, shopDefaults.getOwnerRole());
|
ps.setString(4, shopDefaults.getOwnerRole());
|
||||||
return ps;
|
return ps;
|
||||||
}, userKey);
|
}, userKey);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.client.HttpStatusCodeException;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/barcode")
|
||||||
|
public class BarcodeProxyController {
|
||||||
|
|
||||||
|
private final PythonBarcodeProperties properties;
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BarcodeProxyController.class);
|
||||||
|
|
||||||
|
public BarcodeProxyController(PythonBarcodeProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis());
|
||||||
|
factory.setReadTimeout((int) Duration.ofSeconds(8).toMillis());
|
||||||
|
this.restTemplate = new RestTemplate(factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ResponseEntity<String> scan(@RequestPart("file") MultipartFile file) throws IOException {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body("{\"success\":false,\"message\":\"文件为空\"}");
|
||||||
|
}
|
||||||
|
long maxBytes = (long) properties.getMaxUploadMb() * 1024L * 1024L;
|
||||||
|
if (file.getSize() > maxBytes) {
|
||||||
|
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||||
|
.body(String.format("{\"success\":false,\"message\":\"文件过大(> %dMB)\"}", properties.getMaxUploadMb()));
|
||||||
|
}
|
||||||
|
String url = String.format("http://%s:%d/api/barcode/scan", properties.getHost(), properties.getPort());
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug("转发条码识别请求: url={} filename={} size={}B", url, file.getOriginalFilename(), file.getSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 multipart/form-data 请求转发
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
HttpHeaders fileHeaders = new HttpHeaders();
|
||||||
|
String contentType = file.getContentType();
|
||||||
|
MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
if (contentType != null && !contentType.isBlank()) {
|
||||||
|
try {
|
||||||
|
mediaType = MediaType.parseMediaType(contentType);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileHeaders.setContentType(mediaType);
|
||||||
|
String originalName = file.getOriginalFilename();
|
||||||
|
if (originalName == null || originalName.isBlank()) {
|
||||||
|
originalName = file.getName();
|
||||||
|
}
|
||||||
|
if (originalName == null || originalName.isBlank()) {
|
||||||
|
originalName = "upload.bin";
|
||||||
|
}
|
||||||
|
fileHeaders.setContentDisposition(ContentDisposition.builder("form-data")
|
||||||
|
.name("file")
|
||||||
|
.filename(originalName)
|
||||||
|
.build());
|
||||||
|
final String finalFilename = originalName;
|
||||||
|
ByteArrayResource resource = new ByteArrayResource(file.getBytes()) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return finalFilename;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpEntity<ByteArrayResource> fileEntity = new HttpEntity<>(resource, fileHeaders);
|
||||||
|
body.add("file", fileEntity);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
HttpEntity<MultiValueMap<String, Object>> req = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
|
try {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
ResponseEntity<String> resp = restTemplate.postForEntity(url, req, String.class);
|
||||||
|
long cost = System.currentTimeMillis() - t0;
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
String bodyStr = resp.getBody();
|
||||||
|
if (bodyStr != null && bodyStr.length() > 500) {
|
||||||
|
bodyStr = bodyStr.substring(0, 500) + "...";
|
||||||
|
}
|
||||||
|
log.debug("转发完成: status={} cost={}ms resp={}", resp.getStatusCodeValue(), cost, bodyStr);
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(resp.getStatusCode())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(resp.getBody());
|
||||||
|
} catch (HttpStatusCodeException ex) {
|
||||||
|
String bodyStr = ex.getResponseBodyAsString();
|
||||||
|
if (bodyStr != null && bodyStr.length() > 500) {
|
||||||
|
bodyStr = bodyStr.substring(0, 500) + "...";
|
||||||
|
}
|
||||||
|
log.warn("Python 服务返回非 2xx: status={} body={}", ex.getStatusCode(), bodyStr);
|
||||||
|
MediaType respType = ex.getResponseHeaders() != null
|
||||||
|
? ex.getResponseHeaders().getContentType()
|
||||||
|
: MediaType.APPLICATION_JSON;
|
||||||
|
if (respType == null) {
|
||||||
|
respType = MediaType.APPLICATION_JSON;
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
|
.contentType(respType)
|
||||||
|
.body(ex.getResponseBodyAsString());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Python 服务不可用或超时等异常
|
||||||
|
log.warn("转发到 Python 服务失败: {}:{} path=/api/barcode/scan, err={}", properties.getHost(), properties.getPort(), ex.toString());
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body("{\"success\":false,\"message\":\"识别服务不可用,请稍后重试\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class PythonBarcodeAutoStarter implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PythonBarcodeAutoStarter.class);
|
||||||
|
private final PythonBarcodeProcessManager manager;
|
||||||
|
private final PythonBarcodeProperties properties;
|
||||||
|
|
||||||
|
public PythonBarcodeAutoStarter(PythonBarcodeProcessManager manager, PythonBarcodeProperties properties) {
|
||||||
|
this.manager = manager;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
log.info("Python 条码识别服务未启用 (python.barcode.enabled=false)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("启动 Python 条码识别服务...");
|
||||||
|
manager.startIfEnabled();
|
||||||
|
log.info("Python 条码识别服务已就绪");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void onShutdown() {
|
||||||
|
if (properties.isEnabled()) {
|
||||||
|
log.info("停止 Python 条码识别服务...");
|
||||||
|
manager.stopIfRunning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class PythonBarcodeProcessManager {
|
||||||
|
|
||||||
|
private final PythonBarcodeProperties properties;
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private Process process;
|
||||||
|
|
||||||
|
public PythonBarcodeProcessManager(PythonBarcodeProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis());
|
||||||
|
factory.setReadTimeout((int) Duration.ofSeconds(2).toMillis());
|
||||||
|
this.restTemplate = new RestTemplate(factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void startIfEnabled() {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(properties.getPython());
|
||||||
|
if (properties.isUseModuleMain()) {
|
||||||
|
cmd.add("-m");
|
||||||
|
cmd.add(properties.getAppModule());
|
||||||
|
} else {
|
||||||
|
// 预留:可扩展为自定义脚本路径
|
||||||
|
cmd.add("-m");
|
||||||
|
cmd.add(properties.getAppModule());
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.directory(new File(properties.getWorkingDir()));
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
if (StringUtils.hasText(properties.getLogFile())) {
|
||||||
|
pb.redirectOutput(new File(properties.getLogFile()));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process = pb.start();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("启动 Python 条码服务失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待健康检查
|
||||||
|
long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(properties.getStartupTimeoutSec());
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
if (checkHealth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(500);
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Python 条码服务在超时时间内未就绪");
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void stopIfRunning() {
|
||||||
|
if (process != null) {
|
||||||
|
process.destroy();
|
||||||
|
try {
|
||||||
|
if (!process.waitFor(2, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAlive() {
|
||||||
|
return process != null && process.isAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkHealth() {
|
||||||
|
String url = String.format("http://%s:%d%s", properties.getHost(), properties.getPort(), properties.getHealthPath());
|
||||||
|
try {
|
||||||
|
restTemplate.getForObject(url, String.class);
|
||||||
|
return true;
|
||||||
|
} catch (RestClientException ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "python.barcode")
|
||||||
|
public class PythonBarcodeProperties {
|
||||||
|
|
||||||
|
/** 是否在后端启动时同时启动 Python 服务 */
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/** Python 服务运行目录(需包含 app 与 config 目录),相对后端工作目录 */
|
||||||
|
private String workingDir = "./txm";
|
||||||
|
|
||||||
|
/** Python 解释器命令(如 python 或 python3 或 venv 路径)*/
|
||||||
|
private String python = "python";
|
||||||
|
|
||||||
|
/** 以模块方式启动的模块名(例如 app.server.main)*/
|
||||||
|
private String appModule = "app.server.main";
|
||||||
|
|
||||||
|
/** 是否使用 `python -m app.server.main` 启动(否则自行指定命令)*/
|
||||||
|
private boolean useModuleMain = true;
|
||||||
|
|
||||||
|
/** Python 服务监听地址(供 Java 代理转发与健康探测用)*/
|
||||||
|
private String host = "127.0.0.1";
|
||||||
|
|
||||||
|
/** Python 服务监听端口 */
|
||||||
|
private int port = 8000;
|
||||||
|
|
||||||
|
/** 健康检查路径(GET),FastAPI 默认可用 openapi.json */
|
||||||
|
private String healthPath = "/openapi.json";
|
||||||
|
|
||||||
|
/** 启动等待超时(秒)*/
|
||||||
|
private int startupTimeoutSec = 20;
|
||||||
|
|
||||||
|
/** 可选:将 Python 输出重定向到文件(为空则继承控制台)*/
|
||||||
|
private String logFile = "";
|
||||||
|
|
||||||
|
/** 上传大小限制(MB),用于 Java 侧预校验,需与 Python 端配置保持一致 */
|
||||||
|
private int maxUploadMb = 8;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWorkingDir() {
|
||||||
|
return workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWorkingDir(String workingDir) {
|
||||||
|
this.workingDir = workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPython() {
|
||||||
|
return python;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPython(String python) {
|
||||||
|
this.python = python;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAppModule() {
|
||||||
|
return appModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAppModule(String appModule) {
|
||||||
|
this.appModule = appModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUseModuleMain() {
|
||||||
|
return useModuleMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUseModuleMain(boolean useModuleMain) {
|
||||||
|
this.useModuleMain = useModuleMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHost() {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHost(String host) {
|
||||||
|
this.host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPort() {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPort(int port) {
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHealthPath() {
|
||||||
|
return healthPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHealthPath(String healthPath) {
|
||||||
|
this.healthPath = healthPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStartupTimeoutSec() {
|
||||||
|
return startupTimeoutSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartupTimeoutSec(int startupTimeoutSec) {
|
||||||
|
this.startupTimeoutSec = startupTimeoutSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLogFile() {
|
||||||
|
return logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogFile(String logFile) {
|
||||||
|
this.logFile = logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxUploadMb() {
|
||||||
|
return maxUploadMb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxUploadMb(int maxUploadMb) {
|
||||||
|
this.maxUploadMb = maxUploadMb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.demo.common;
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
@@ -13,6 +14,9 @@ public class AdminAuthInterceptor implements HandlerInterceptor {
|
|||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Value("${admin.auth.header-name:X-Admin-Id}")
|
||||||
|
private String adminHeaderName;
|
||||||
|
|
||||||
public AdminAuthInterceptor(JdbcTemplate jdbcTemplate) {
|
public AdminAuthInterceptor(JdbcTemplate jdbcTemplate) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
@@ -23,16 +27,59 @@ public class AdminAuthInterceptor implements HandlerInterceptor {
|
|||||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// 简化版:临时从 X-User-Id 读取管理员身份,后续可改为 JWT
|
// 允许登录端点无鉴权
|
||||||
String userIdHeader = request.getHeader("X-User-Id");
|
String path = request.getRequestURI();
|
||||||
if (userIdHeader == null || userIdHeader.isBlank()) {
|
if (path != null && path.startsWith("/api/admin/auth/login")) {
|
||||||
response.sendError(401, "missing X-User-Id");
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先支持 Bearer Token
|
||||||
|
String authorization = request.getHeader("Authorization");
|
||||||
|
if (authorization != null && authorization.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
com.example.demo.auth.JwtService jwtSvc = org.springframework.web.context.support.WebApplicationContextUtils
|
||||||
|
.getRequiredWebApplicationContext(request.getServletContext())
|
||||||
|
.getBean(com.example.demo.auth.JwtService.class);
|
||||||
|
java.util.Map<String,Object> claims = jwtSvc.parseClaims(authorization);
|
||||||
|
Object aid = claims.get("adminId");
|
||||||
|
if (aid instanceof Long a) {
|
||||||
|
Integer status = jdbcTemplate.query(
|
||||||
|
"SELECT status FROM admins WHERE id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, a),
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : null
|
||||||
|
);
|
||||||
|
if (status != null && status == 1) return true;
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到兼容请求头 X-Admin-Id
|
||||||
|
String adminIdHeader = request.getHeader(adminHeaderName);
|
||||||
|
if (adminIdHeader == null || adminIdHeader.isBlank()) {
|
||||||
|
// 进一步兼容:若前端仍使用 X-User-Id,则尝试以其作为管理员ID进行校验
|
||||||
|
String userIdHeader = request.getHeader("X-User-Id");
|
||||||
|
if (userIdHeader != null && !userIdHeader.isBlank()) {
|
||||||
|
try {
|
||||||
|
Long maybeAdminId = Long.valueOf(userIdHeader);
|
||||||
|
Integer stat = jdbcTemplate.query(
|
||||||
|
"SELECT status FROM admins WHERE id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, maybeAdminId),
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : null
|
||||||
|
);
|
||||||
|
if (stat != null && stat == 1) return true;
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
}
|
||||||
|
response.sendError(401, "missing " + adminHeaderName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
Long uid;
|
Long adminId;
|
||||||
try { uid = Long.valueOf(userIdHeader); } catch (Exception e) { response.sendError(401, "invalid user"); return false; }
|
try { adminId = Long.valueOf(adminIdHeader); } catch (Exception e) { response.sendError(401, "invalid admin"); return false; }
|
||||||
Integer admin = jdbcTemplate.queryForObject("SELECT is_platform_admin FROM users WHERE id=? LIMIT 1", Integer.class, uid);
|
Integer status = jdbcTemplate.query(
|
||||||
if (admin == null || admin != 1) {
|
"SELECT status FROM admins WHERE id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, adminId),
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : null
|
||||||
|
);
|
||||||
|
if (status == null || status != 1) {
|
||||||
response.sendError(403, "forbidden");
|
response.sendError(403, "forbidden");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.mail")
|
||||||
|
public class EmailProperties {
|
||||||
|
private String from;
|
||||||
|
private String subjectPrefix;
|
||||||
|
|
||||||
|
public String getFrom() { return from; }
|
||||||
|
public void setFrom(String from) { this.from = from; }
|
||||||
|
|
||||||
|
public String getSubjectPrefix() { return subjectPrefix; }
|
||||||
|
public void setSubjectPrefix(String subjectPrefix) { this.subjectPrefix = subjectPrefix; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EmailSenderService {
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
private final EmailProperties props;
|
||||||
|
|
||||||
|
@Value("${spring.mail.username:}")
|
||||||
|
private String mailUsername;
|
||||||
|
|
||||||
|
public EmailSenderService(JavaMailSender mailSender, EmailProperties props) {
|
||||||
|
this.mailSender = mailSender;
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendPlainText(String to, String subject, String content) {
|
||||||
|
if (to == null || to.isBlank()) throw new IllegalArgumentException("收件人邮箱不能为空");
|
||||||
|
if (subject == null) subject = "";
|
||||||
|
if (content == null) content = "";
|
||||||
|
try {
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
|
||||||
|
helper.setFrom(resolveFromAddress());
|
||||||
|
helper.setTo(to.trim());
|
||||||
|
helper.setSubject(composeSubject(subject));
|
||||||
|
helper.setText(content, false);
|
||||||
|
mailSender.send(message);
|
||||||
|
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("发送邮件失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String composeSubject(String subject) {
|
||||||
|
String prefix = props.getSubjectPrefix();
|
||||||
|
if (prefix == null || prefix.isBlank()) return subject;
|
||||||
|
return prefix + " " + subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFromAddress() {
|
||||||
|
String from = props.getFrom();
|
||||||
|
if (from == null || from.isBlank()) from = mailUsername;
|
||||||
|
if (from == null || from.isBlank()) {
|
||||||
|
throw new IllegalStateException("邮件服务未配置,请设置 MAIL_USERNAME/MAIL_PASSWORD 以及 MAIL_FROM 或 spring.mail.username");
|
||||||
|
}
|
||||||
|
return from.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
39
backend/src/main/java/com/example/demo/common/JsonUtils.java
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
public final class JsonUtils {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private JsonUtils() {}
|
||||||
|
|
||||||
|
public static String toJson(Object value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("JSON 序列化失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> T fromJson(String json, Class<T> clazz) {
|
||||||
|
if (json == null || json.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(json, clazz);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("JSON 解析失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> T fromJson(String json, TypeReference<T> type) {
|
||||||
|
if (json == null || json.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(json, type);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("JSON 解析失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 普通管理端(admin-lite)鉴权拦截器
|
||||||
|
* 要求:
|
||||||
|
* - 仅拦截 /api/normal-admin/parts/**
|
||||||
|
* - 通过 X-User-Id 校验 users.status=1 且 role='normal_admin'
|
||||||
|
* - 若要求 VIP 有效(NORMAL_ADMIN_REQUIRE_VIP_ACTIVE=true),校验 vip_users 有效期
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class NormalAdminAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public NormalAdminAuthInterceptor(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception {
|
||||||
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 仅拦截 /api/normal-admin/parts/**
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
if (path == null || !path.startsWith("/api/normal-admin/parts/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String userIdHeader = request.getHeader("X-User-Id");
|
||||||
|
if (userIdHeader == null || userIdHeader.isBlank()) {
|
||||||
|
response.sendError(401, "missing X-User-Id");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long userId;
|
||||||
|
try { userId = Long.parseLong(userIdHeader); } catch (Exception e) { response.sendError(401, "invalid user"); return false; }
|
||||||
|
|
||||||
|
// 校验普通管理员角色
|
||||||
|
var row = jdbcTemplate.query(
|
||||||
|
"SELECT u.status,u.role,u.shop_id FROM users u WHERE u.id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, userId),
|
||||||
|
rs -> rs.next() ? new Object[]{ rs.getInt(1), rs.getString(2), rs.getLong(3) } : null
|
||||||
|
);
|
||||||
|
if (row == null) { response.sendError(401, "user not found"); return false; }
|
||||||
|
int status = (int) row[0];
|
||||||
|
String role = (String) row[1];
|
||||||
|
long shopId = (long) row[2];
|
||||||
|
if (status != 1 || role == null || !"normal_admin".equalsIgnoreCase(role.trim())) {
|
||||||
|
response.sendError(403, "forbidden");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选校验:VIP 有效
|
||||||
|
boolean requireVip = Boolean.parseBoolean(String.valueOf(System.getenv().getOrDefault("NORMAL_ADMIN_REQUIRE_VIP_ACTIVE", "true")));
|
||||||
|
if (requireVip) {
|
||||||
|
Integer vipOk = jdbcTemplate.query(
|
||||||
|
"SELECT CASE WHEN (is_vip=1 AND status=1 AND (expire_at IS NULL OR expire_at>NOW())) THEN 1 ELSE 0 END FROM vip_users WHERE user_id=? AND shop_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> { ps.setLong(1, userId); ps.setLong(2, shopId); },
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : 0
|
||||||
|
);
|
||||||
|
if (vipOk == null || vipOk != 1) {
|
||||||
|
response.sendError(403, "vip expired or not active");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过:允许进入控制器
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,17 +1,33 @@
|
|||||||
package com.example.demo.common;
|
package com.example.demo.common;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
public WebConfig() { }
|
private final AdminAuthInterceptor adminAuthInterceptor;
|
||||||
|
private final NormalAdminAuthInterceptor normalAdminAuthInterceptor;
|
||||||
|
|
||||||
|
public WebConfig(AdminAuthInterceptor adminAuthInterceptor, NormalAdminAuthInterceptor normalAdminAuthInterceptor) {
|
||||||
|
this.adminAuthInterceptor = adminAuthInterceptor;
|
||||||
|
this.normalAdminAuthInterceptor = normalAdminAuthInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(@NonNull InterceptorRegistry registry) {
|
||||||
// 登录功能已移除,此处不再注册管理端鉴权拦截器
|
// 注册管理端鉴权拦截器:保护 /api/admin/**
|
||||||
|
InterceptorRegistration r = registry.addInterceptor(adminAuthInterceptor);
|
||||||
|
r.addPathPatterns("/api/admin/**");
|
||||||
|
// 放行登录接口
|
||||||
|
r.excludePathPatterns("/api/admin/auth/login");
|
||||||
|
|
||||||
|
// 注册普通管理端拦截器:保护 /api/normal-admin/parts/**
|
||||||
|
InterceptorRegistration nr = registry.addInterceptor(normalAdminAuthInterceptor);
|
||||||
|
nr.addPathPatterns("/api/normal-admin/parts/**");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.example.demo.consult;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/consults")
|
||||||
|
public class ConsultController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public ConsultController(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
if (body == null) body = new HashMap<>();
|
||||||
|
String topic = ""; // 主题字段已废弃
|
||||||
|
String message = Optional.ofNullable(body.get("message")).map(String::valueOf).orElse(null);
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "message required"));
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("INSERT INTO consults (shop_id,user_id,topic,message,status,created_at,updated_at) VALUES (?,?,?,?, 'open', NOW(), NOW())",
|
||||||
|
sid, uid, topic, message);
|
||||||
|
Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||||
|
Map<String,Object> resp = new HashMap<>();
|
||||||
|
resp.put("id", id);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/latest")
|
||||||
|
public ResponseEntity<?> latest(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
List<Map<String, Object>> list = jdbcTemplate.query(
|
||||||
|
"SELECT id, topic, message, status, created_at FROM consults WHERE shop_id=? AND user_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> { ps.setLong(1, sid); ps.setLong(2, uid); },
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("topic", rs.getString("topic"));
|
||||||
|
m.put("message", rs.getString("message"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(Collections.emptyMap());
|
||||||
|
}
|
||||||
|
Map<String,Object> latest = list.get(0);
|
||||||
|
Object idObj = latest.get("id");
|
||||||
|
Long consultId = (idObj instanceof Number) ? ((Number) idObj).longValue() : Long.valueOf(String.valueOf(idObj));
|
||||||
|
Map<String,Object> reply = jdbcTemplate.query(
|
||||||
|
"SELECT content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> r = new HashMap<>();
|
||||||
|
r.put("content", rs.getString("content"));
|
||||||
|
r.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, consultId
|
||||||
|
);
|
||||||
|
latest.put("replied", Objects.equals("resolved", String.valueOf(latest.get("status"))));
|
||||||
|
if (reply != null) {
|
||||||
|
latest.put("latestReply", reply.get("content"));
|
||||||
|
latest.put("latestReplyAt", reply.get("createdAt"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容:GET /api/consults 等同于 /api/consults/latest
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> latestAlias(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
return latest(shopId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户确认已读:查看过管理员回复后,将状态回到 open
|
||||||
|
@PutMapping("/{id}/ack")
|
||||||
|
public ResponseEntity<?> ack(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
// 若该咨询属于该用户,则把状态改回 open(仅在当前为 resolved 时)
|
||||||
|
int updated = jdbcTemplate.update(
|
||||||
|
"UPDATE consults SET status='open', updated_at=NOW() WHERE id=? AND user_id=? AND status='resolved'",
|
||||||
|
id, (userId == null ? defaults.getUserId() : userId));
|
||||||
|
Map<String,Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("updated", updated);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
Map<String,Object> consult = jdbcTemplate.query(
|
||||||
|
"SELECT id, shop_id AS shopId, user_id AS userId, topic, message, status, created_at FROM consults WHERE id=?",
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("topic", rs.getString("topic"));
|
||||||
|
m.put("message", rs.getString("message"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, id);
|
||||||
|
if (consult == null) return ResponseEntity.notFound().build();
|
||||||
|
if (userId != null) {
|
||||||
|
Object ownerObj = consult.get("userId");
|
||||||
|
Long ownerId = (ownerObj instanceof Number) ? ((Number) ownerObj).longValue() : Long.valueOf(String.valueOf(ownerObj));
|
||||||
|
if (!Objects.equals(ownerId, userId)) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("message", "forbidden"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Map<String,Object>> replies = jdbcTemplate.query(
|
||||||
|
"SELECT id, user_id AS userId, content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id ASC",
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String,Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("id", rs.getLong("id"));
|
||||||
|
r.put("userId", rs.getLong("userId"));
|
||||||
|
r.put("content", rs.getString("content"));
|
||||||
|
r.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return r;
|
||||||
|
}, id);
|
||||||
|
Map<String,Object> body = new LinkedHashMap<>();
|
||||||
|
body.putAll(consult);
|
||||||
|
body.put("replies", replies);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +41,6 @@ public class CustomerController {
|
|||||||
body.put("contactName", c.getContactName());
|
body.put("contactName", c.getContactName());
|
||||||
body.put("mobile", c.getMobile());
|
body.put("mobile", c.getMobile());
|
||||||
body.put("phone", c.getPhone());
|
body.put("phone", c.getPhone());
|
||||||
body.put("level", c.getLevel());
|
|
||||||
body.put("priceLevel", c.getPriceLevel());
|
body.put("priceLevel", c.getPriceLevel());
|
||||||
body.put("remark", c.getRemark());
|
body.put("remark", c.getRemark());
|
||||||
body.put("address", c.getAddress());
|
body.put("address", c.getAddress());
|
||||||
|
|||||||
@@ -10,19 +10,17 @@ public class CustomerDtos {
|
|||||||
public String contactName;
|
public String contactName;
|
||||||
public String mobile;
|
public String mobile;
|
||||||
public String phone;
|
public String phone;
|
||||||
public String level;
|
|
||||||
public String priceLevel;
|
public String priceLevel;
|
||||||
public String remark;
|
public String remark;
|
||||||
public BigDecimal receivable;
|
public BigDecimal receivable;
|
||||||
public CustomerListItem() {}
|
public CustomerListItem() {}
|
||||||
public CustomerListItem(Long id, String name, String contactName, String mobile, String phone, String level, String priceLevel, String remark, BigDecimal receivable) {
|
public CustomerListItem(Long id, String name, String contactName, String mobile, String phone, String priceLevel, String remark, BigDecimal receivable) {
|
||||||
this.id = id; this.name = name; this.contactName = contactName; this.mobile = mobile; this.phone = phone; this.level = level; this.priceLevel = priceLevel; this.remark = remark; this.receivable = receivable;
|
this.id = id; this.name = name; this.contactName = contactName; this.mobile = mobile; this.phone = phone; this.priceLevel = priceLevel; this.remark = remark; this.receivable = receivable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class CreateOrUpdateCustomerRequest {
|
public static class CreateOrUpdateCustomerRequest {
|
||||||
public String name;
|
public String name;
|
||||||
public String level;
|
|
||||||
public String priceLevel;
|
public String priceLevel;
|
||||||
public String contactName;
|
public String contactName;
|
||||||
public String mobile;
|
public String mobile;
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ public class Customer {
|
|||||||
@Column(name = "address", length = 255)
|
@Column(name = "address", length = 255)
|
||||||
private String address;
|
private String address;
|
||||||
|
|
||||||
@Column(name = "level", length = 32)
|
|
||||||
private String level;
|
|
||||||
|
|
||||||
@Column(name = "contact_name", length = 64)
|
@Column(name = "contact_name", length = 64)
|
||||||
private String contactName;
|
private String contactName;
|
||||||
|
|
||||||
@@ -70,8 +67,6 @@ public class Customer {
|
|||||||
public void setMobile(String mobile) { this.mobile = mobile; }
|
public void setMobile(String mobile) { this.mobile = mobile; }
|
||||||
public String getAddress() { return address; }
|
public String getAddress() { return address; }
|
||||||
public void setAddress(String address) { this.address = address; }
|
public void setAddress(String address) { this.address = address; }
|
||||||
public String getLevel() { return level; }
|
|
||||||
public void setLevel(String level) { this.level = level; }
|
|
||||||
public String getContactName() { return contactName; }
|
public String getContactName() { return contactName; }
|
||||||
public void setContactName(String contactName) { this.contactName = contactName; }
|
public void setContactName(String contactName) { this.contactName = contactName; }
|
||||||
public String getPriceLevel() { return priceLevel; }
|
public String getPriceLevel() { return priceLevel; }
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class CustomerService {
|
|||||||
public java.util.Map<String, Object> search(Long shopId, String kw, boolean debtOnly, int page, int size) {
|
public java.util.Map<String, Object> search(Long shopId, String kw, boolean debtOnly, int page, int size) {
|
||||||
List<Customer> list = customerRepository.search(shopId, kw, PageRequest.of(page, size));
|
List<Customer> list = customerRepository.search(shopId, kw, PageRequest.of(page, size));
|
||||||
List<CustomerDtos.CustomerListItem> items = list.stream().map(c -> new CustomerDtos.CustomerListItem(
|
List<CustomerDtos.CustomerListItem> items = list.stream().map(c -> new CustomerDtos.CustomerListItem(
|
||||||
c.getId(), c.getName(), c.getContactName(), c.getMobile(), c.getPhone(), c.getLevel(), c.getPriceLevel(), c.getRemark(), calcReceivable(shopId, c.getId(), c.getArOpening())
|
c.getId(), c.getName(), c.getContactName(), c.getMobile(), c.getPhone(), c.getPriceLevel(), c.getRemark(), calcReceivable(shopId, c.getId(), c.getArOpening())
|
||||||
)).collect(Collectors.toList());
|
)).collect(Collectors.toList());
|
||||||
if (debtOnly) {
|
if (debtOnly) {
|
||||||
items = items.stream().filter(it -> it.receivable != null && it.receivable.compareTo(BigDecimal.ZERO) > 0).collect(Collectors.toList());
|
items = items.stream().filter(it -> it.receivable != null && it.receivable.compareTo(BigDecimal.ZERO) > 0).collect(Collectors.toList());
|
||||||
@@ -45,7 +45,7 @@ public class CustomerService {
|
|||||||
public Long create(Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
public Long create(Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
||||||
Customer c = new Customer();
|
Customer c = new Customer();
|
||||||
c.setShopId(shopId); c.setUserId(userId);
|
c.setShopId(shopId); c.setUserId(userId);
|
||||||
c.setName(req.name); c.setLevel(req.level); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel));
|
c.setName(req.name); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel));
|
||||||
c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address);
|
c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address);
|
||||||
c.setStatus(1);
|
c.setStatus(1);
|
||||||
c.setArOpening(req.arOpening == null ? BigDecimal.ZERO : req.arOpening);
|
c.setArOpening(req.arOpening == null ? BigDecimal.ZERO : req.arOpening);
|
||||||
@@ -60,7 +60,7 @@ public class CustomerService {
|
|||||||
public void update(Long id, Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
public void update(Long id, Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
||||||
Customer c = customerRepository.findById(id).orElseThrow();
|
Customer c = customerRepository.findById(id).orElseThrow();
|
||||||
if (!c.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺修改");
|
if (!c.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺修改");
|
||||||
c.setName(req.name); c.setLevel(req.level); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel));
|
c.setName(req.name); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel));
|
||||||
c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address);
|
c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address);
|
||||||
if (req.arOpening != null) c.setArOpening(req.arOpening);
|
if (req.arOpening != null) c.setArOpening(req.arOpening);
|
||||||
c.setRemark(req.remark);
|
c.setRemark(req.remark);
|
||||||
|
|||||||
@@ -14,32 +14,27 @@ public class DashboardRepository {
|
|||||||
|
|
||||||
public BigDecimal sumTodaySalesOrders(Long shopId) {
|
public BigDecimal sumTodaySalesOrders(Long shopId) {
|
||||||
Object result = entityManager.createNativeQuery(
|
Object result = entityManager.createNativeQuery(
|
||||||
"SELECT COALESCE(SUM(amount), 0) FROM sales_orders " +
|
"SELECT COALESCE((SELECT SUM(amount) FROM sales_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=CURRENT_DATE() AND order_time<CURRENT_DATE()+INTERVAL 1 DAY),0) - " +
|
||||||
"WHERE shop_id = :shopId AND status = 'approved' " +
|
"COALESCE((SELECT SUM(amount) FROM sales_return_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=CURRENT_DATE() AND order_time<CURRENT_DATE()+INTERVAL 1 DAY),0)"
|
||||||
"AND order_time >= CURRENT_DATE() AND order_time < DATE_ADD(CURRENT_DATE(), INTERVAL 1 DAY)"
|
|
||||||
).setParameter("shopId", shopId).getSingleResult();
|
).setParameter("shopId", shopId).getSingleResult();
|
||||||
return toBigDecimal(result);
|
return toBigDecimal(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public BigDecimal sumMonthGrossProfitApprox(Long shopId) {
|
public BigDecimal sumMonthGrossProfitApprox(Long shopId) {
|
||||||
Object result = entityManager.createNativeQuery(
|
Object result = entityManager.createNativeQuery(
|
||||||
"SELECT COALESCE(SUM(soi.amount - soi.quantity * COALESCE(pp.purchase_price, 0)), 0) AS gp " +
|
"SELECT COALESCE((SELECT SUM(soi.amount - soi.quantity * COALESCE(pp.purchase_price,0)) FROM sales_orders so " +
|
||||||
"FROM sales_orders so " +
|
"JOIN sales_order_items soi ON soi.order_id=so.id LEFT JOIN product_prices pp ON pp.product_id=soi.product_id AND pp.shop_id=so.shop_id " +
|
||||||
"JOIN sales_order_items soi ON soi.order_id = so.id " +
|
"WHERE so.shop_id=:shopId AND so.status='approved' AND so.order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND so.order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0) - " +
|
||||||
"LEFT JOIN product_prices pp ON pp.product_id = soi.product_id AND pp.shop_id = so.shop_id " +
|
"COALESCE((SELECT SUM(sroi.amount) FROM sales_return_orders sro JOIN sales_return_order_items sroi ON sroi.order_id=sro.id " +
|
||||||
"WHERE so.shop_id = :shopId AND so.status = 'approved' " +
|
"WHERE sro.shop_id=:shopId AND sro.status='approved' AND sro.order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND sro.order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0)"
|
||||||
"AND so.order_time >= DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') " +
|
|
||||||
"AND so.order_time < DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)"
|
|
||||||
).setParameter("shopId", shopId).getSingleResult();
|
).setParameter("shopId", shopId).getSingleResult();
|
||||||
return toBigDecimal(result);
|
return toBigDecimal(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public BigDecimal sumMonthSalesOrders(Long shopId) {
|
public BigDecimal sumMonthSalesOrders(Long shopId) {
|
||||||
Object result = entityManager.createNativeQuery(
|
Object result = entityManager.createNativeQuery(
|
||||||
"SELECT COALESCE(SUM(amount), 0) FROM sales_orders " +
|
"SELECT COALESCE((SELECT SUM(amount) FROM sales_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0) - " +
|
||||||
"WHERE shop_id = :shopId AND status = 'approved' " +
|
"COALESCE((SELECT SUM(amount) FROM sales_return_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0)"
|
||||||
"AND order_time >= DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') " +
|
|
||||||
"AND order_time < DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)"
|
|
||||||
).setParameter("shopId", shopId).getSingleResult();
|
).setParameter("shopId", shopId).getSingleResult();
|
||||||
return toBigDecimal(result);
|
return toBigDecimal(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public class OrderController {
|
|||||||
|
|
||||||
@GetMapping("/orders")
|
@GetMapping("/orders")
|
||||||
public ResponseEntity<?> list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
public ResponseEntity<?> list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
@RequestParam(name = "biz", required = false) String biz,
|
@RequestParam(name = "biz", required = false) String biz,
|
||||||
@RequestParam(name = "type", required = false) String type,
|
@RequestParam(name = "type", required = false) String type,
|
||||||
@RequestParam(name = "kw", required = false) String kw,
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
@@ -57,7 +58,8 @@ public class OrderController {
|
|||||||
@RequestParam(name = "startDate", required = false) String startDate,
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
@RequestParam(name = "endDate", required = false) String endDate) {
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
return ResponseEntity.ok(orderService.list(sid, biz, type, kw, Math.max(0, page-1), size, startDate, endDate));
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.list(sid, uid, biz, type, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/orders/{id}")
|
@GetMapping("/orders/{id}")
|
||||||
@@ -70,6 +72,7 @@ public class OrderController {
|
|||||||
// 兼容前端直接调用 /api/purchase-orders
|
// 兼容前端直接调用 /api/purchase-orders
|
||||||
@GetMapping("/purchase-orders")
|
@GetMapping("/purchase-orders")
|
||||||
public ResponseEntity<?> purchaseOrders(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
public ResponseEntity<?> purchaseOrders(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
@RequestParam(name = "status", required = false) String status,
|
@RequestParam(name = "status", required = false) String status,
|
||||||
@RequestParam(name = "kw", required = false) String kw,
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
@RequestParam(name = "page", defaultValue = "1") int page,
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
@@ -78,7 +81,8 @@ public class OrderController {
|
|||||||
@RequestParam(name = "endDate", required = false) String endDate) {
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
String type = ("returned".equalsIgnoreCase(status) ? "purchase.return" : "purchase.in");
|
String type = ("returned".equalsIgnoreCase(status) ? "purchase.return" : "purchase.in");
|
||||||
return ResponseEntity.ok(orderService.list(sid, "purchase", type, kw, Math.max(0, page-1), size, startDate, endDate));
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.list(sid, uid, "purchase", type, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/purchase-orders/{id}")
|
@GetMapping("/purchase-orders/{id}")
|
||||||
@@ -90,6 +94,7 @@ public class OrderController {
|
|||||||
|
|
||||||
@GetMapping("/payments")
|
@GetMapping("/payments")
|
||||||
public ResponseEntity<?> listPayments(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
public ResponseEntity<?> listPayments(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
@RequestParam(name = "direction", required = false) String direction,
|
@RequestParam(name = "direction", required = false) String direction,
|
||||||
@RequestParam(name = "bizType", required = false) String bizType,
|
@RequestParam(name = "bizType", required = false) String bizType,
|
||||||
@RequestParam(name = "accountId", required = false) Long accountId,
|
@RequestParam(name = "accountId", required = false) Long accountId,
|
||||||
@@ -99,11 +104,13 @@ public class OrderController {
|
|||||||
@RequestParam(name = "startDate", required = false) String startDate,
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
@RequestParam(name = "endDate", required = false) String endDate) {
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
return ResponseEntity.ok(orderService.listPayments(sid, direction, bizType, accountId, kw, Math.max(0, page-1), size, startDate, endDate));
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.listPayments(sid, uid, direction, bizType, accountId, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/other-transactions")
|
@GetMapping("/other-transactions")
|
||||||
public ResponseEntity<?> listOtherTransactions(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
public ResponseEntity<?> listOtherTransactions(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
@RequestParam(name = "type", required = false) String type,
|
@RequestParam(name = "type", required = false) String type,
|
||||||
@RequestParam(name = "accountId", required = false) Long accountId,
|
@RequestParam(name = "accountId", required = false) Long accountId,
|
||||||
@RequestParam(name = "kw", required = false) String kw,
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
@@ -112,7 +119,8 @@ public class OrderController {
|
|||||||
@RequestParam(name = "startDate", required = false) String startDate,
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
@RequestParam(name = "endDate", required = false) String endDate) {
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
return ResponseEntity.ok(orderService.listOtherTransactions(sid, type, accountId, kw, Math.max(0, page-1), size, startDate, endDate));
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.listOtherTransactions(sid, uid, type, accountId, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/other-transactions")
|
@PostMapping("/other-transactions")
|
||||||
@@ -126,6 +134,7 @@ public class OrderController {
|
|||||||
|
|
||||||
@GetMapping("/inventories/logs")
|
@GetMapping("/inventories/logs")
|
||||||
public ResponseEntity<?> listInventoryLogs(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
public ResponseEntity<?> listInventoryLogs(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
@RequestParam(name = "productId", required = false) Long productId,
|
@RequestParam(name = "productId", required = false) Long productId,
|
||||||
@RequestParam(name = "reason", required = false) String reason,
|
@RequestParam(name = "reason", required = false) String reason,
|
||||||
@RequestParam(name = "kw", required = false) String kw,
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
@@ -134,7 +143,8 @@ public class OrderController {
|
|||||||
@RequestParam(name = "startDate", required = false) String startDate,
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
@RequestParam(name = "endDate", required = false) String endDate) {
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
return ResponseEntity.ok(orderService.listInventoryLogs(sid, productId, reason, kw, Math.max(0, page-1), size, startDate, endDate));
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.listInventoryLogs(sid, uid, productId, reason, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.example.demo.order;
|
|||||||
import com.example.demo.common.AccountDefaultsProperties;
|
import com.example.demo.common.AccountDefaultsProperties;
|
||||||
import com.example.demo.order.dto.OrderDtos;
|
import com.example.demo.order.dto.OrderDtos;
|
||||||
import com.example.demo.product.entity.Inventory;
|
import com.example.demo.product.entity.Inventory;
|
||||||
|
import com.example.demo.product.entity.ProductPrice;
|
||||||
import com.example.demo.product.repo.InventoryRepository;
|
import com.example.demo.product.repo.InventoryRepository;
|
||||||
|
import com.example.demo.product.repo.ProductPriceRepository;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
@@ -13,7 +15,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class OrderService {
|
public class OrderService {
|
||||||
@@ -21,13 +25,16 @@ public class OrderService {
|
|||||||
private final InventoryRepository inventoryRepository;
|
private final InventoryRepository inventoryRepository;
|
||||||
private final AccountDefaultsProperties accountDefaults;
|
private final AccountDefaultsProperties accountDefaults;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ProductPriceRepository productPriceRepository;
|
||||||
|
|
||||||
public OrderService(InventoryRepository inventoryRepository,
|
public OrderService(InventoryRepository inventoryRepository,
|
||||||
JdbcTemplate jdbcTemplate,
|
JdbcTemplate jdbcTemplate,
|
||||||
AccountDefaultsProperties accountDefaults) {
|
AccountDefaultsProperties accountDefaults,
|
||||||
|
ProductPriceRepository productPriceRepository) {
|
||||||
this.inventoryRepository = inventoryRepository;
|
this.inventoryRepository = inventoryRepository;
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.accountDefaults = accountDefaults;
|
this.accountDefaults = accountDefaults;
|
||||||
|
this.productPriceRepository = productPriceRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -58,6 +65,16 @@ public class OrderService {
|
|||||||
totalRef[0] = totalRef[0].add(scale2(line));
|
totalRef[0] = totalRef[0].add(scale2(line));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预取成本价格(仅销售出库/退货需要)
|
||||||
|
Map<Long, BigDecimal> costPriceCache = new HashMap<>();
|
||||||
|
if (isSaleOut || isSaleReturn) {
|
||||||
|
for (OrderDtos.Item it : req.items) {
|
||||||
|
Long pid = it.productId;
|
||||||
|
if (pid == null || costPriceCache.containsKey(pid)) continue;
|
||||||
|
costPriceCache.put(pid, resolveProductCostPrice(pid, shopId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 库存变动(保存即 approved)
|
// 库存变动(保存即 approved)
|
||||||
LocalDateTime now = nowUtc();
|
LocalDateTime now = nowUtc();
|
||||||
for (OrderDtos.Item it : req.items) {
|
for (OrderDtos.Item it : req.items) {
|
||||||
@@ -81,9 +98,16 @@ public class OrderService {
|
|||||||
inventoryRepository.save(inv);
|
inventoryRepository.save(inv);
|
||||||
|
|
||||||
// 写入库存流水(可选金额)
|
// 写入库存流水(可选金额)
|
||||||
String imSql = "INSERT INTO inventory_movements (shop_id,user_id,product_id,source_type,source_id,qty_delta,amount_delta,reason,tx_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,NULL,NOW())";
|
String imSql = "INSERT INTO inventory_movements (shop_id,user_id,product_id,source_type,source_id,qty_delta,amount_delta,cost_price,cost_amount,reason,tx_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NOW())";
|
||||||
String sourceType = isSaleOut ? "sale" : (isPurchaseIn ? "purchase" : (isSaleReturn ? "sale_return" : "purchase_return"));
|
String sourceType = isSaleOut ? "sale" : (isPurchaseIn ? "purchase" : (isSaleReturn ? "sale_return" : "purchase_return"));
|
||||||
jdbcTemplate.update(imSql, shopId, userId, pid, sourceType, null, delta, null, null, java.sql.Timestamp.from(now.atZone(java.time.ZoneOffset.UTC).toInstant()));
|
BigDecimal costPrice = null;
|
||||||
|
BigDecimal costAmount = null;
|
||||||
|
if (isSaleOut || isSaleReturn) {
|
||||||
|
costPrice = costPriceCache.getOrDefault(pid, BigDecimal.ZERO);
|
||||||
|
costAmount = scale2(costPrice.multiply(delta));
|
||||||
|
}
|
||||||
|
jdbcTemplate.update(imSql, shopId, userId, pid, sourceType, null, delta, null, costPrice, costAmount,
|
||||||
|
null, java.sql.Timestamp.from(now.atZone(java.time.ZoneOffset.UTC).toInstant()), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
String prefix = isSaleOut || isSaleReturn ? (isSaleReturn ? "SR" : "SO") : (isPurchaseReturn ? "PR" : "PO");
|
String prefix = isSaleOut || isSaleReturn ? (isSaleReturn ? "SR" : "SO") : (isPurchaseReturn ? "PR" : "PO");
|
||||||
@@ -134,15 +158,21 @@ public class OrderService {
|
|||||||
// insert items(销售类有折扣列,采购类无折扣列)
|
// insert items(销售类有折扣列,采购类无折扣列)
|
||||||
boolean itemsHasDiscount = isSaleOut || isSaleReturn;
|
boolean itemsHasDiscount = isSaleOut || isSaleReturn;
|
||||||
String itemSql = itemsHasDiscount
|
String itemSql = itemsHasDiscount
|
||||||
? ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,discount_rate,amount) VALUES (?,?,?,?,?,?)")
|
? ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,discount_rate,cost_price,cost_amount,amount) VALUES (?,?,?,?,?,?,?,?)")
|
||||||
: ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,amount) VALUES (?,?,?,?,?)");
|
: ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,amount) VALUES (?,?,?,?,?)");
|
||||||
for (OrderDtos.Item it : req.items) {
|
for (OrderDtos.Item it : req.items) {
|
||||||
BigDecimal qty = n(it.quantity);
|
BigDecimal qty = n(it.quantity);
|
||||||
BigDecimal price = n(it.unitPrice);
|
BigDecimal price = n(it.unitPrice);
|
||||||
BigDecimal dr = n(it.discountRate);
|
BigDecimal dr = n(it.discountRate);
|
||||||
BigDecimal line = scale2(qty.multiply(price).multiply(BigDecimal.ONE.subtract(dr.divide(new BigDecimal("100")))));
|
BigDecimal line = scale2(qty.multiply(price).multiply(BigDecimal.ONE.subtract(dr.divide(new BigDecimal("100")))));
|
||||||
|
BigDecimal costPrice = BigDecimal.ZERO;
|
||||||
|
BigDecimal costAmount = BigDecimal.ZERO;
|
||||||
if (itemsHasDiscount) {
|
if (itemsHasDiscount) {
|
||||||
jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, dr, line);
|
costPrice = costPriceCache.getOrDefault(it.productId, BigDecimal.ZERO);
|
||||||
|
costAmount = scale2(qty.multiply(costPrice));
|
||||||
|
}
|
||||||
|
if (itemsHasDiscount) {
|
||||||
|
jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, dr, costPrice, costAmount, line);
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, line);
|
jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, line);
|
||||||
}
|
}
|
||||||
@@ -158,6 +188,14 @@ public class OrderService {
|
|||||||
return new OrderDtos.CreateOrderResponse(orderId, orderNo);
|
return new OrderDtos.CreateOrderResponse(orderId, orderNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BigDecimal resolveProductCostPrice(Long productId, Long shopId) {
|
||||||
|
return productPriceRepository.findById(productId)
|
||||||
|
.filter(price -> price.getShopId().equals(shopId))
|
||||||
|
.map(ProductPrice::getPurchasePrice)
|
||||||
|
.map(OrderService::scale2)
|
||||||
|
.orElse(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public OrderDtos.CreatePaymentsResponse createPayments(Long shopId, Long userId, java.util.List<OrderDtos.PaymentItem> req, String bizType) {
|
public OrderDtos.CreatePaymentsResponse createPayments(Long shopId, Long userId, java.util.List<OrderDtos.PaymentItem> req, String bizType) {
|
||||||
ensureDefaultAccounts(shopId, userId);
|
ensureDefaultAccounts(shopId, userId);
|
||||||
@@ -201,6 +239,11 @@ public class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 联动账户余额:收款加,付款减
|
||||||
|
java.math.BigDecimal delta = "in".equals(direction) ? n(p.amount) : n(p.amount).negate();
|
||||||
|
jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?",
|
||||||
|
scale2(delta), accountId, shopId);
|
||||||
}
|
}
|
||||||
return new OrderDtos.CreatePaymentsResponse(ids);
|
return new OrderDtos.CreatePaymentsResponse(ids);
|
||||||
}
|
}
|
||||||
@@ -252,66 +295,83 @@ public class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public java.util.Map<String,Object> list(Long shopId, String biz, String type, String kw, int page, int size, String startDate, String endDate) {
|
public java.util.Map<String,Object> list(Long shopId, Long userId, String biz, String type, String kw, int page, int size, String startDate, String endDate) {
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
java.util.List<Object> ps = new java.util.ArrayList<>();
|
java.util.List<Object> ps = new java.util.ArrayList<>();
|
||||||
ps.add(shopId);
|
ps.add(shopId);
|
||||||
|
|
||||||
if ("purchase".equals(biz)) {
|
if ("purchase".equals(biz)) {
|
||||||
// 进货单(含退货按状态过滤),返回驼峰并带供应商名称
|
// 进货单(含退货:并入 purchase_return_orders)
|
||||||
sql.append("SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.amount, s.name AS supplierName,\n")
|
sql.append("SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.amount, s.name AS supplierName, 'purchase.in' AS docType FROM purchase_orders po LEFT JOIN suppliers s ON s.id = po.supplier_id WHERE po.shop_id=?");
|
||||||
.append("CASE WHEN po.status='returned' THEN 'purchase.return' ELSE 'purchase.in' END AS docType\n")
|
|
||||||
.append("FROM purchase_orders po\n")
|
|
||||||
.append("LEFT JOIN suppliers s ON s.id = po.supplier_id\n")
|
|
||||||
.append("WHERE po.shop_id=?");
|
|
||||||
if ("purchase.return".equals(type)) {
|
|
||||||
sql.append(" AND po.status='returned'");
|
|
||||||
}
|
|
||||||
if (kw != null && !kw.isBlank()) { sql.append(" AND (po.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (po.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "po.order_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sql.append(" AND po.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND po.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sql.append(" AND po.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND po.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
sql.append(" ORDER BY po.order_time DESC LIMIT ? OFFSET ?");
|
if (!("in".equalsIgnoreCase(type) || "purchase.in".equalsIgnoreCase(type))) {
|
||||||
} else { // 默认销售
|
// 仅退货
|
||||||
// 销售单,返回驼峰并带客户名称
|
sql = new StringBuilder("SELECT pro.id, pro.order_no AS orderNo, pro.order_time AS orderTime, pro.amount, s.name AS supplierName, 'purchase.return' AS docType FROM purchase_return_orders pro LEFT JOIN suppliers s ON s.id = pro.supplier_id WHERE pro.shop_id=?");
|
||||||
sql.append("SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.amount, c.name AS customerName, 'sale.out' AS docType\n")
|
ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
.append("FROM sales_orders so\n")
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (pro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
.append("LEFT JOIN customers c ON c.id = so.customer_id\n")
|
applyNonVipWindow(sql, ps, userId, "pro.order_time");
|
||||||
.append("WHERE so.shop_id=?");
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND pro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND pro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
} else {
|
||||||
|
// 合并退货
|
||||||
|
sql.append(" UNION ALL SELECT pro.id, pro.order_no AS orderNo, pro.order_time AS orderTime, pro.amount, s.name AS supplierName, 'purchase.return' AS docType FROM purchase_return_orders pro LEFT JOIN suppliers s ON s.id = pro.supplier_id WHERE pro.shop_id=?");
|
||||||
|
ps.add(shopId);
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (pro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "pro.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND pro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND pro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY orderTime DESC LIMIT ? OFFSET ?");
|
||||||
|
} else { // 销售
|
||||||
|
sql.append("SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.amount, c.name AS customerName, 'sale.out' AS docType FROM sales_orders so LEFT JOIN customers c ON c.id = so.customer_id WHERE so.shop_id=?");
|
||||||
if (kw != null && !kw.isBlank()) { sql.append(" AND (so.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (so.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "so.order_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sql.append(" AND so.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND so.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sql.append(" AND so.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND so.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
sql.append(" ORDER BY so.order_time DESC LIMIT ? OFFSET ?");
|
if (!("out".equalsIgnoreCase(type) || "sale.out".equalsIgnoreCase(type))) {
|
||||||
|
sql = new StringBuilder("SELECT sro.id, sro.order_no AS orderNo, sro.order_time AS orderTime, sro.amount, c.name AS customerName, 'sale.return' AS docType FROM sales_return_orders sro LEFT JOIN customers c ON c.id = sro.customer_id WHERE sro.shop_id=?");
|
||||||
|
ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (sro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "sro.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND sro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND sro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
} else {
|
||||||
|
sql.append(" UNION ALL SELECT sro.id, sro.order_no AS orderNo, sro.order_time AS orderTime, sro.amount, c.name AS customerName, 'sale.return' AS docType FROM sales_return_orders sro LEFT JOIN customers c ON c.id = sro.customer_id WHERE sro.shop_id=?");
|
||||||
|
ps.add(shopId);
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (sro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "sro.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND sro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND sro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY orderTime DESC LIMIT ? OFFSET ?");
|
||||||
}
|
}
|
||||||
|
|
||||||
ps.add(size);
|
ps.add(size);
|
||||||
ps.add(page * size);
|
ps.add(page * size);
|
||||||
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
|
||||||
// 汇总
|
// 汇总:净额(主单 - 退货)
|
||||||
StringBuilder sumSql;
|
java.math.BigDecimal total;
|
||||||
java.util.List<Object> sumPs = new java.util.ArrayList<>();
|
|
||||||
sumPs.add(shopId);
|
|
||||||
if ("purchase".equals(biz)) {
|
if ("purchase".equals(biz)) {
|
||||||
sumSql = new StringBuilder("SELECT COALESCE(SUM(amount),0) FROM purchase_orders WHERE shop_id=?");
|
java.math.BigDecimal inSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM purchase_orders WHERE shop_id=?", java.math.BigDecimal.class, shopId));
|
||||||
if ("purchase.return".equals(type)) { sumSql.append(" AND status='returned'"); }
|
java.math.BigDecimal retSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM purchase_return_orders WHERE shop_id=?", java.math.BigDecimal.class, shopId));
|
||||||
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (order_no LIKE ?)"); sumPs.add('%' + kw + '%'); }
|
total = inSum.subtract(retSum);
|
||||||
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND order_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
|
||||||
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND order_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
|
||||||
} else {
|
} else {
|
||||||
sumSql = new StringBuilder("SELECT COALESCE(SUM(amount),0) FROM sales_orders WHERE shop_id=?");
|
java.math.BigDecimal outSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM sales_orders WHERE shop_id=? AND status='approved'", java.math.BigDecimal.class, shopId));
|
||||||
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (order_no LIKE ?)"); sumPs.add('%' + kw + '%'); }
|
java.math.BigDecimal retSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM sales_return_orders WHERE shop_id=? AND status='approved'", java.math.BigDecimal.class, shopId));
|
||||||
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND order_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
total = outSum.subtract(retSum);
|
||||||
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND order_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
|
||||||
}
|
}
|
||||||
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
|
||||||
java.util.Map<String,Object> resp = new java.util.HashMap<>();
|
java.util.Map<String,Object> resp = new java.util.HashMap<>();
|
||||||
resp.put("list", list);
|
resp.put("list", list);
|
||||||
resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total);
|
resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public java.util.Map<String,Object> listPayments(Long shopId, String direction, String bizType, Long accountId, String kw, int page, int size, String startDate, String endDate) {
|
public java.util.Map<String,Object> listPayments(Long shopId, Long userId, String direction, String bizType, Long accountId, String kw, int page, int size, String startDate, String endDate) {
|
||||||
StringBuilder sql = new StringBuilder("SELECT p.id, p.biz_type AS bizType, p.account_id, a.name AS accountName, p.direction, p.amount, p.pay_time AS orderTime,\n" +
|
StringBuilder sql = new StringBuilder("SELECT p.id, p.biz_type AS bizType, p.account_id, a.name AS accountName, p.direction, p.amount, p.pay_time AS orderTime, p.category AS category,\n" +
|
||||||
"CASE \n" +
|
"CASE \n" +
|
||||||
" WHEN p.biz_type='sale' AND p.direction='in' THEN 'sale.collect' \n" +
|
" WHEN p.biz_type='sale' AND p.direction='in' THEN 'sale.collect' \n" +
|
||||||
" WHEN p.biz_type='purchase' AND p.direction='out' THEN 'purchase.pay' \n" +
|
" WHEN p.biz_type='purchase' AND p.direction='out' THEN 'purchase.pay' \n" +
|
||||||
@@ -325,6 +385,7 @@ public class OrderService {
|
|||||||
if (bizType != null && !bizType.isBlank()) { sql.append(" AND p.biz_type=?"); ps.add(bizType); }
|
if (bizType != null && !bizType.isBlank()) { sql.append(" AND p.biz_type=?"); ps.add(bizType); }
|
||||||
if (accountId != null) { sql.append(" AND p.account_id=?"); ps.add(accountId); }
|
if (accountId != null) { sql.append(" AND p.account_id=?"); ps.add(accountId); }
|
||||||
if (kw != null && !kw.isBlank()) { sql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); ps.add('%'+kw+'%'); }
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); ps.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "p.pay_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sql.append(" AND p.pay_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND p.pay_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sql.append(" AND p.pay_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND p.pay_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
sql.append(" ORDER BY p.pay_time DESC LIMIT ? OFFSET ?");
|
sql.append(" ORDER BY p.pay_time DESC LIMIT ? OFFSET ?");
|
||||||
@@ -336,18 +397,20 @@ public class OrderService {
|
|||||||
if (bizType != null && !bizType.isBlank()) { sumSql.append(" AND p.biz_type=?"); sumPs.add(bizType); }
|
if (bizType != null && !bizType.isBlank()) { sumSql.append(" AND p.biz_type=?"); sumPs.add(bizType); }
|
||||||
if (accountId != null) { sumSql.append(" AND p.account_id=?"); sumPs.add(accountId); }
|
if (accountId != null) { sumSql.append(" AND p.account_id=?"); sumPs.add(accountId); }
|
||||||
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); sumPs.add('%'+kw+'%'); }
|
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); sumPs.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sumSql, sumPs, userId, "pay_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND p.pay_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND p.pay_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND p.pay_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND p.pay_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
||||||
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
|
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public java.util.Map<String,Object> listOtherTransactions(Long shopId, String type, Long accountId, String kw, int page, int size, String startDate, String endDate) {
|
public java.util.Map<String,Object> listOtherTransactions(Long shopId, Long userId, String type, Long accountId, String kw, int page, int size, String startDate, String endDate) {
|
||||||
StringBuilder sql = new StringBuilder("SELECT ot.id, ot.`type`, CONCAT('other.', ot.`type`) AS docType, ot.account_id, a.name AS accountName, ot.amount, ot.tx_time AS txTime, ot.remark FROM other_transactions ot LEFT JOIN accounts a ON a.id=ot.account_id WHERE ot.shop_id=?");
|
StringBuilder sql = new StringBuilder("SELECT ot.id, ot.`type`, CONCAT('other.', ot.`type`) AS docType, ot.account_id, a.name AS accountName, ot.amount, ot.tx_time AS txTime, ot.remark FROM other_transactions ot LEFT JOIN accounts a ON a.id=ot.account_id WHERE ot.shop_id=?");
|
||||||
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
|
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
if (type != null && !type.isBlank()) { sql.append(" AND ot.`type`=?"); ps.add(type); }
|
if (type != null && !type.isBlank()) { sql.append(" AND ot.`type`=?"); ps.add(type); }
|
||||||
if (accountId != null) { sql.append(" AND ot.account_id=?"); ps.add(accountId); }
|
if (accountId != null) { sql.append(" AND ot.account_id=?"); ps.add(accountId); }
|
||||||
if (kw != null && !kw.isBlank()) { sql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "ot.tx_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sql.append(" AND ot.tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND ot.tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sql.append(" AND ot.tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND ot.tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
sql.append(" ORDER BY ot.tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
|
sql.append(" ORDER BY ot.tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
|
||||||
@@ -357,6 +420,7 @@ public class OrderService {
|
|||||||
if (type != null && !type.isBlank()) { sumSql.append(" AND ot.`type`=?"); sumPs.add(type); }
|
if (type != null && !type.isBlank()) { sumSql.append(" AND ot.`type`=?"); sumPs.add(type); }
|
||||||
if (accountId != null) { sumSql.append(" AND ot.account_id=?"); sumPs.add(accountId); }
|
if (accountId != null) { sumSql.append(" AND ot.account_id=?"); sumPs.add(accountId); }
|
||||||
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
|
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sumSql, sumPs, userId, "tx_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND ot.tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND ot.tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND ot.tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND ot.tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
||||||
@@ -410,7 +474,7 @@ public class OrderService {
|
|||||||
final Long idForPayment = id;
|
final Long idForPayment = id;
|
||||||
jdbcTemplate.update(con -> {
|
jdbcTemplate.update(con -> {
|
||||||
java.sql.PreparedStatement ps = con.prepareStatement(
|
java.sql.PreparedStatement ps = con.prepareStatement(
|
||||||
"INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,NOW())",
|
"INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,category,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,NOW())",
|
||||||
new String[]{"id"}
|
new String[]{"id"}
|
||||||
);
|
);
|
||||||
int i = 1;
|
int i = 1;
|
||||||
@@ -422,7 +486,8 @@ public class OrderService {
|
|||||||
ps.setString(i++, direction);
|
ps.setString(i++, direction);
|
||||||
ps.setBigDecimal(i++, scale2(amt));
|
ps.setBigDecimal(i++, scale2(amt));
|
||||||
ps.setTimestamp(i++, whenTs);
|
ps.setTimestamp(i++, whenTs);
|
||||||
ps.setString(i, req.remark);
|
ps.setString(i++, req.remark);
|
||||||
|
ps.setString(i, req.category);
|
||||||
return ps;
|
return ps;
|
||||||
}, payKh);
|
}, payKh);
|
||||||
|
|
||||||
@@ -435,12 +500,13 @@ public class OrderService {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public java.util.Map<String,Object> listInventoryLogs(Long shopId, Long productId, String reason, String kw, int page, int size, String startDate, String endDate) {
|
public java.util.Map<String,Object> listInventoryLogs(Long shopId, Long userId, Long productId, String reason, String kw, int page, int size, String startDate, String endDate) {
|
||||||
StringBuilder sql = new StringBuilder("SELECT id, product_id, qty_delta, amount_delta, COALESCE(amount_delta,0) AS amount, reason, tx_time AS txTime, remark FROM inventory_movements WHERE shop_id=?");
|
StringBuilder sql = new StringBuilder("SELECT id, product_id, qty_delta, amount_delta, COALESCE(amount_delta,0) AS amount, reason, tx_time AS txTime, remark FROM inventory_movements WHERE shop_id=?");
|
||||||
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
|
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
if (productId != null) { sql.append(" AND product_id=?"); ps.add(productId); }
|
if (productId != null) { sql.append(" AND product_id=?"); ps.add(productId); }
|
||||||
if (reason != null && !reason.isBlank()) { sql.append(" AND reason=?"); ps.add(reason); }
|
if (reason != null && !reason.isBlank()) { sql.append(" AND reason=?"); ps.add(reason); }
|
||||||
if (kw != null && !kw.isBlank()) { sql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "tx_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sql.append(" AND tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sql.append(" AND tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
sql.append(" ORDER BY tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
|
sql.append(" ORDER BY tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
|
||||||
@@ -450,12 +516,52 @@ public class OrderService {
|
|||||||
if (productId != null) { sumSql.append(" AND product_id=?"); sumPs.add(productId); }
|
if (productId != null) { sumSql.append(" AND product_id=?"); sumPs.add(productId); }
|
||||||
if (reason != null && !reason.isBlank()) { sumSql.append(" AND reason=?"); sumPs.add(reason); }
|
if (reason != null && !reason.isBlank()) { sumSql.append(" AND reason=?"); sumPs.add(reason); }
|
||||||
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
|
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sumSql, sumPs, userId, "tx_time");
|
||||||
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
||||||
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
|
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非VIP时间窗拼接:默认60天;VIP用户不加限制
|
||||||
|
private void applyNonVipWindow(StringBuilder sql, java.util.List<Object> ps, Long userId, String column) {
|
||||||
|
if (userId == null) return; // 无法判定用户,避免误拦截
|
||||||
|
boolean vip = isVipActive(userId);
|
||||||
|
if (vip) return;
|
||||||
|
int days = readNonVipRetentionDaysOrDefault(60);
|
||||||
|
if (days <= 0) return;
|
||||||
|
sql.append(" AND ").append(column).append(">= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)");
|
||||||
|
ps.add(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isVipActive(Long userId) {
|
||||||
|
try {
|
||||||
|
java.util.List<java.util.Map<String,Object>> rows = jdbcTemplate.queryForList("SELECT is_vip,status,expire_at FROM vip_users WHERE user_id=? ORDER BY id DESC LIMIT 1", userId);
|
||||||
|
if (rows.isEmpty()) return false;
|
||||||
|
java.util.Map<String,Object> r = rows.get(0);
|
||||||
|
int isVip = ((Number) r.getOrDefault("is_vip", 0)).intValue();
|
||||||
|
int status = ((Number) r.getOrDefault("status", 0)).intValue();
|
||||||
|
java.sql.Timestamp exp = (java.sql.Timestamp) r.get("expire_at");
|
||||||
|
boolean notExpired = (exp == null) || !exp.toInstant().isBefore(java.time.Instant.now());
|
||||||
|
return isVip == 1 && status == 1 && notExpired;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readNonVipRetentionDaysOrDefault(int dft) {
|
||||||
|
try {
|
||||||
|
String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='vip.dataRetentionDaysForNonVip' ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
if (v == null) return dft;
|
||||||
|
v = v.trim();
|
||||||
|
if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1);
|
||||||
|
return Integer.parseInt(v);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return dft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 详情:销售单
|
// 详情:销售单
|
||||||
public java.util.Map<String,Object> getSalesOrderDetail(Long shopId, Long id) {
|
public java.util.Map<String,Object> getSalesOrderDetail(Long shopId, Long id) {
|
||||||
java.util.List<java.util.Map<String,Object>> heads = jdbcTemplate.queryForList(
|
java.util.List<java.util.Map<String,Object>> heads = jdbcTemplate.queryForList(
|
||||||
@@ -465,7 +571,7 @@ public class OrderService {
|
|||||||
if (heads.isEmpty()) return java.util.Map.of();
|
if (heads.isEmpty()) return java.util.Map.of();
|
||||||
java.util.Map<String,Object> head = heads.get(0);
|
java.util.Map<String,Object> head = heads.get(0);
|
||||||
java.util.List<java.util.Map<String,Object>> items = jdbcTemplate.queryForList(
|
java.util.List<java.util.Map<String,Object>> items = jdbcTemplate.queryForList(
|
||||||
"SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.discount_rate AS discountRate, i.amount\n" +
|
"SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.discount_rate AS discountRate, i.cost_price AS costPrice, i.cost_amount AS costAmount, i.amount\n" +
|
||||||
"FROM sales_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?",
|
"FROM sales_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?",
|
||||||
id);
|
id);
|
||||||
java.util.List<java.util.Map<String,Object>> pays = jdbcTemplate.queryForList(
|
java.util.List<java.util.Map<String,Object>> pays = jdbcTemplate.queryForList(
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import com.example.demo.product.repo.PartTemplateRepository;
|
||||||
|
import com.example.demo.product.repo.PartTemplateParamRepository;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -17,11 +20,16 @@ public class MetadataController {
|
|||||||
private final UnitRepository unitRepository;
|
private final UnitRepository unitRepository;
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final AppDefaultsProperties defaults;
|
private final AppDefaultsProperties defaults;
|
||||||
|
private final PartTemplateRepository templateRepository;
|
||||||
|
private final PartTemplateParamRepository paramRepository;
|
||||||
|
|
||||||
public MetadataController(UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults) {
|
public MetadataController(UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults,
|
||||||
|
PartTemplateRepository templateRepository, PartTemplateParamRepository paramRepository) {
|
||||||
this.unitRepository = unitRepository;
|
this.unitRepository = unitRepository;
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.defaults = defaults;
|
this.defaults = defaults;
|
||||||
|
this.templateRepository = templateRepository;
|
||||||
|
this.paramRepository = paramRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/product-units")
|
@GetMapping("/api/product-units")
|
||||||
@@ -37,6 +45,44 @@ public class MetadataController {
|
|||||||
body.put("list", categoryRepository.listByShop(defaults.getDictShopId()));
|
body.put("list", categoryRepository.listByShop(defaults.getDictShopId()));
|
||||||
return ResponseEntity.ok(body);
|
return ResponseEntity.ok(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/product-templates")
|
||||||
|
public ResponseEntity<?> listTemplates(@RequestParam(name = "categoryId", required = false) Long categoryId) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
java.util.List<com.example.demo.product.entity.PartTemplate> list =
|
||||||
|
categoryId == null ? templateRepository.findByStatusOrderByIdDesc(1)
|
||||||
|
: templateRepository.findByStatusAndCategoryIdOrderByIdDesc(1, categoryId);
|
||||||
|
java.util.List<java.util.Map<String,Object>> out = new java.util.ArrayList<>();
|
||||||
|
for (com.example.demo.product.entity.PartTemplate t : list) {
|
||||||
|
java.util.Map<String,Object> m = new java.util.HashMap<>();
|
||||||
|
m.put("id", t.getId());
|
||||||
|
m.put("categoryId", t.getCategoryId());
|
||||||
|
m.put("name", t.getName());
|
||||||
|
m.put("modelRule", t.getModelRule());
|
||||||
|
m.put("status", t.getStatus());
|
||||||
|
java.util.List<com.example.demo.product.entity.PartTemplateParam> params =
|
||||||
|
paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(t.getId());
|
||||||
|
java.util.List<java.util.Map<String,Object>> ps = new java.util.ArrayList<>();
|
||||||
|
for (com.example.demo.product.entity.PartTemplateParam p : params) {
|
||||||
|
java.util.Map<String,Object> pm = new java.util.HashMap<>();
|
||||||
|
pm.put("fieldKey", p.getFieldKey());
|
||||||
|
pm.put("fieldLabel", p.getFieldLabel());
|
||||||
|
pm.put("type", p.getType());
|
||||||
|
pm.put("required", p.getRequired());
|
||||||
|
pm.put("unit", p.getUnit());
|
||||||
|
java.util.List<String> enums = com.example.demo.common.JsonUtils.fromJson(p.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference<java.util.List<String>>(){});
|
||||||
|
pm.put("enumOptions", enums);
|
||||||
|
pm.put("searchable", p.getSearchable());
|
||||||
|
pm.put("dedupeParticipate", p.getDedupeParticipate());
|
||||||
|
pm.put("sortOrder", p.getSortOrder());
|
||||||
|
ps.add(pm);
|
||||||
|
}
|
||||||
|
m.put("params", ps);
|
||||||
|
out.add(m);
|
||||||
|
}
|
||||||
|
body.put("list", out);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.product.service.ProductSubmissionService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/normal-admin/parts")
|
||||||
|
public class NormalAdminSubmissionController {
|
||||||
|
|
||||||
|
private final ProductSubmissionService submissionService;
|
||||||
|
|
||||||
|
public NormalAdminSubmissionController(ProductSubmissionService submissionService) {
|
||||||
|
this.submissionService = submissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理现有管理端接口,但不暴露跨店查询参数,实际范围由拦截器限定
|
||||||
|
@GetMapping("/submissions")
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
// 普通管理端不允许跨店过滤,reviewer/shopId 均不提供
|
||||||
|
return ResponseEntity.ok(submissionService.listAdmin(status, kw, null, null, null, null, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/submissions/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
|
||||||
|
return submissionService.findDetail(id)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/submissions/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody com.example.demo.product.dto.ProductSubmissionDtos.UpdateRequest req) {
|
||||||
|
submissionService.updateSubmission(id, req);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/{id}/approve")
|
||||||
|
public ResponseEntity<?> approve(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody(required = false) com.example.demo.product.dto.ProductSubmissionDtos.ApproveRequest req) {
|
||||||
|
// 这里将 X-User-Id 作为审批人记录(普通管理员为用户表)
|
||||||
|
var resp = submissionService.approve(id, userId, req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/{id}/reject")
|
||||||
|
public ResponseEntity<?> reject(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody com.example.demo.product.dto.ProductSubmissionDtos.RejectRequest req) {
|
||||||
|
submissionService.reject(id, userId, req);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("ok", true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.product.dto.PartTemplateDtos;
|
||||||
|
import com.example.demo.product.service.PartTemplateService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/part-templates")
|
||||||
|
public class PartTemplateController {
|
||||||
|
|
||||||
|
private final PartTemplateService templateService;
|
||||||
|
|
||||||
|
public PartTemplateController(PartTemplateService templateService) {
|
||||||
|
this.templateService = templateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestBody PartTemplateDtos.CreateRequest req,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId) {
|
||||||
|
Long id = templateService.create(req, adminId);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("id", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody PartTemplateDtos.UpdateRequest req) {
|
||||||
|
templateService.update(id, req);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
|
||||||
|
return ResponseEntity.ok(templateService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list() {
|
||||||
|
return ResponseEntity.ok(templateService.list());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.product.dto.ProductSubmissionDtos;
|
||||||
|
import com.example.demo.product.service.ProductSubmissionService;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping
|
||||||
|
public class ProductSubmissionController {
|
||||||
|
|
||||||
|
private final ProductSubmissionService submissionService;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public ProductSubmissionController(ProductSubmissionService submissionService,
|
||||||
|
AppDefaultsProperties defaults) {
|
||||||
|
this.submissionService = submissionService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/products/submissions")
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody ProductSubmissionDtos.CreateRequest req) {
|
||||||
|
Long sid = shopId == null ? defaults.getShopId() : shopId;
|
||||||
|
Long uid = userId == null ? defaults.getUserId() : userId;
|
||||||
|
Long id = submissionService.createSubmission(sid, uid, req);
|
||||||
|
return ResponseEntity.ok(Map.of("id", id, "status", "pending"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/products/submissions/check-model")
|
||||||
|
public ResponseEntity<?> checkModel(@RequestBody ProductSubmissionDtos.CheckModelRequest req) {
|
||||||
|
ProductSubmissionDtos.CheckModelResponse resp = submissionService.checkModel(req == null ? null : req.model,
|
||||||
|
req == null ? null : req.templateId,
|
||||||
|
req == null ? null : req.name);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/products/submissions")
|
||||||
|
public ResponseEntity<?> listMine(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
Long sid = shopId == null ? defaults.getShopId() : shopId;
|
||||||
|
Long uid = userId == null ? defaults.getUserId() : userId;
|
||||||
|
return ResponseEntity.ok(submissionService.listMine(sid, uid, status, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/products/submissions/{id}")
|
||||||
|
public ResponseEntity<?> detailMine(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
Long sid = shopId == null ? defaults.getShopId() : shopId;
|
||||||
|
Long uid = userId == null ? defaults.getUserId() : userId;
|
||||||
|
return submissionService.findMineDetail(id, sid, uid)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/admin/parts/submissions")
|
||||||
|
public ResponseEntity<?> listAdmin(@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "reviewerId", required = false) Long reviewerId,
|
||||||
|
@RequestParam(name = "startAt", required = false) String startAt,
|
||||||
|
@RequestParam(name = "endAt", required = false) String endAt,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(submissionService.listAdmin(status, kw, shopId, reviewerId, startAt, endAt, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/admin/parts/submissions/export")
|
||||||
|
public void export(@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "reviewerId", required = false) Long reviewerId,
|
||||||
|
@RequestParam(name = "startAt", required = false) String startAt,
|
||||||
|
@RequestParam(name = "endAt", required = false) String endAt,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
submissionService.export(status, kw, shopId, reviewerId, startAt, endAt, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/admin/parts/submissions/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
|
||||||
|
return submissionService.findDetail(id)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/admin/parts/submissions/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody ProductSubmissionDtos.UpdateRequest req) {
|
||||||
|
submissionService.updateSubmission(id, req);
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/admin/parts/submissions/{id}/approve")
|
||||||
|
public ResponseEntity<?> approve(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody(required = false) ProductSubmissionDtos.ApproveRequest req) {
|
||||||
|
ProductSubmissionDtos.ApproveResponse resp = submissionService.approve(id, adminId, req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/admin/parts/submissions/{id}/reject")
|
||||||
|
public ResponseEntity<?> reject(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody ProductSubmissionDtos.RejectRequest req) {
|
||||||
|
submissionService.reject(id, adminId, req);
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.demo.product.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class PartTemplateDtos {
|
||||||
|
|
||||||
|
public static class ParamDef {
|
||||||
|
public String fieldKey;
|
||||||
|
public String fieldLabel;
|
||||||
|
public String type; // string/number/boolean/enum/date
|
||||||
|
public boolean required;
|
||||||
|
public String unit; // 自定义单位文本
|
||||||
|
public List<String> enumOptions; // type=enum 时可用
|
||||||
|
public boolean searchable;
|
||||||
|
public boolean dedupeParticipate;
|
||||||
|
public int sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CreateRequest {
|
||||||
|
public Long categoryId;
|
||||||
|
public String name;
|
||||||
|
public String modelRule; // 可空
|
||||||
|
public Integer status; // 1/0
|
||||||
|
public List<ParamDef> params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateRequest {
|
||||||
|
public Long categoryId;
|
||||||
|
public String name;
|
||||||
|
public String modelRule;
|
||||||
|
public Integer status;
|
||||||
|
public List<ParamDef> params; // 覆盖式更新
|
||||||
|
public boolean deleteAllRelatedProductsAndSubmissions; // 开关:按你的规则默认true
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TemplateItem {
|
||||||
|
public Long id;
|
||||||
|
public Long categoryId;
|
||||||
|
public String name;
|
||||||
|
public String modelRule;
|
||||||
|
public Integer status;
|
||||||
|
public LocalDateTime createdAt;
|
||||||
|
public LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TemplateDetail extends TemplateItem {
|
||||||
|
public List<ParamDef> params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ package com.example.demo.product.dto;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public class ProductDtos {
|
public class ProductDtos {
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ public class ProductDtos {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static class CreateOrUpdateProductRequest {
|
public static class CreateOrUpdateProductRequest {
|
||||||
|
public Long templateId;
|
||||||
public String name;
|
public String name;
|
||||||
public String barcode;
|
public String barcode;
|
||||||
public String brand;
|
public String brand;
|
||||||
@@ -49,12 +51,16 @@ public class ProductDtos {
|
|||||||
public String origin;
|
public String origin;
|
||||||
public Long categoryId;
|
public Long categoryId;
|
||||||
public Long unitId;
|
public Long unitId;
|
||||||
|
public String dedupeKey;
|
||||||
public BigDecimal safeMin;
|
public BigDecimal safeMin;
|
||||||
public BigDecimal safeMax;
|
public BigDecimal safeMax;
|
||||||
public Prices prices;
|
public Prices prices;
|
||||||
public BigDecimal stock;
|
public BigDecimal stock;
|
||||||
public List<String> images;
|
public List<String> images;
|
||||||
public String remark; // map to products.description
|
public String remark; // map to products.description
|
||||||
|
public Long sourceSubmissionId;
|
||||||
|
public Long globalSkuId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Prices {
|
public static class Prices {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.example.demo.product.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class ProductSubmissionDtos {
|
||||||
|
|
||||||
|
public static class CreateRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
public String brand;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
public Long unitId;
|
||||||
|
public Long categoryId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
public List<String> images;
|
||||||
|
public String remark;
|
||||||
|
public String barcode;
|
||||||
|
public java.math.BigDecimal safeMin;
|
||||||
|
public java.math.BigDecimal safeMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public String name;
|
||||||
|
public String brand;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
public Long unitId;
|
||||||
|
public Long categoryId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
public List<String> images;
|
||||||
|
public String remark;
|
||||||
|
public String barcode;
|
||||||
|
public java.math.BigDecimal safeMin;
|
||||||
|
public java.math.BigDecimal safeMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApproveRequest {
|
||||||
|
public String remark;
|
||||||
|
public Long assignGlobalSkuId;
|
||||||
|
public boolean createGlobalSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApproveResponse {
|
||||||
|
public boolean ok;
|
||||||
|
public Long productId;
|
||||||
|
public Long globalSkuId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RejectRequest {
|
||||||
|
public String remark;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SubmissionItem {
|
||||||
|
public Long id;
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
public String brand;
|
||||||
|
public String status;
|
||||||
|
public String submitter;
|
||||||
|
public Long shopId;
|
||||||
|
public LocalDateTime createdAt;
|
||||||
|
public LocalDateTime reviewedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SubmissionDetail {
|
||||||
|
public Long id;
|
||||||
|
public Long shopId;
|
||||||
|
public Long userId;
|
||||||
|
public Long templateId;
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
public String brand;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
public Long unitId;
|
||||||
|
public Long categoryId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
public List<String> images;
|
||||||
|
public String remark;
|
||||||
|
public String barcode;
|
||||||
|
public java.math.BigDecimal safeMin;
|
||||||
|
public java.math.BigDecimal safeMax;
|
||||||
|
public String status;
|
||||||
|
public Long reviewerId;
|
||||||
|
public String reviewRemark;
|
||||||
|
public LocalDateTime reviewedAt;
|
||||||
|
public LocalDateTime createdAt;
|
||||||
|
public String dedupeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PageResult<T> {
|
||||||
|
public List<T> list;
|
||||||
|
public long total;
|
||||||
|
public int page;
|
||||||
|
public int size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CheckModelRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CheckModelResponse {
|
||||||
|
public boolean available;
|
||||||
|
public String model;
|
||||||
|
public int similarAcrossTemplates; // 跨模板同名同型号命中数量(提示用)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "part_templates")
|
||||||
|
public class PartTemplate {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "category_id", nullable = false)
|
||||||
|
private Long categoryId;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 120)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "model_rule")
|
||||||
|
private String modelRule;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false)
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Column(name = "created_by_admin_id")
|
||||||
|
private Long createdByAdminId;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getCategoryId() { return categoryId; }
|
||||||
|
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getModelRule() { return modelRule; }
|
||||||
|
public void setModelRule(String modelRule) { this.modelRule = modelRule; }
|
||||||
|
public Integer getStatus() { return status; }
|
||||||
|
public void setStatus(Integer status) { this.status = status; }
|
||||||
|
public Long getCreatedByAdminId() { return createdByAdminId; }
|
||||||
|
public void setCreatedByAdminId(Long createdByAdminId) { this.createdByAdminId = createdByAdminId; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "part_template_params")
|
||||||
|
public class PartTemplateParam {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "template_id", nullable = false)
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
@Column(name = "field_key", nullable = false, length = 64)
|
||||||
|
private String fieldKey;
|
||||||
|
|
||||||
|
@Column(name = "field_label", nullable = false, length = 120)
|
||||||
|
private String fieldLabel;
|
||||||
|
|
||||||
|
@Column(name = "type", nullable = false, length = 16)
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Column(name = "required", nullable = false)
|
||||||
|
private Boolean required;
|
||||||
|
|
||||||
|
@Column(name = "unit", length = 32)
|
||||||
|
private String unit;
|
||||||
|
|
||||||
|
@Column(name = "enum_options", columnDefinition = "json")
|
||||||
|
private String enumOptionsJson;
|
||||||
|
|
||||||
|
@Column(name = "searchable", nullable = false)
|
||||||
|
private Boolean searchable;
|
||||||
|
|
||||||
|
@Column(name = "dedupe_participate", nullable = false)
|
||||||
|
private Boolean dedupeParticipate;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getTemplateId() { return templateId; }
|
||||||
|
public void setTemplateId(Long templateId) { this.templateId = templateId; }
|
||||||
|
public String getFieldKey() { return fieldKey; }
|
||||||
|
public void setFieldKey(String fieldKey) { this.fieldKey = fieldKey; }
|
||||||
|
public String getFieldLabel() { return fieldLabel; }
|
||||||
|
public void setFieldLabel(String fieldLabel) { this.fieldLabel = fieldLabel; }
|
||||||
|
public String getType() { return type; }
|
||||||
|
public void setType(String type) { this.type = type; }
|
||||||
|
public Boolean getRequired() { return required; }
|
||||||
|
public void setRequired(Boolean required) { this.required = required; }
|
||||||
|
public String getUnit() { return unit; }
|
||||||
|
public void setUnit(String unit) { this.unit = unit; }
|
||||||
|
public String getEnumOptionsJson() { return enumOptionsJson; }
|
||||||
|
public void setEnumOptionsJson(String enumOptionsJson) { this.enumOptionsJson = enumOptionsJson; }
|
||||||
|
public Boolean getSearchable() { return searchable; }
|
||||||
|
public void setSearchable(Boolean searchable) { this.searchable = searchable; }
|
||||||
|
public Boolean getDedupeParticipate() { return dedupeParticipate; }
|
||||||
|
public void setDedupeParticipate(Boolean dedupeParticipate) { this.dedupeParticipate = dedupeParticipate; }
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ public class Product {
|
|||||||
@Column(name = "unit_id", nullable = false)
|
@Column(name = "unit_id", nullable = false)
|
||||||
private Long unitId;
|
private Long unitId;
|
||||||
|
|
||||||
|
@Column(name = "template_id")
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
@Column(name = "brand", length = 64)
|
@Column(name = "brand", length = 64)
|
||||||
private String brand;
|
private String brand;
|
||||||
|
|
||||||
@@ -42,6 +45,9 @@ public class Product {
|
|||||||
@Column(name = "barcode", length = 32)
|
@Column(name = "barcode", length = 32)
|
||||||
private String barcode;
|
private String barcode;
|
||||||
|
|
||||||
|
@Column(name = "dedupe_key", length = 512)
|
||||||
|
private String dedupeKey;
|
||||||
|
|
||||||
@Column(name = "description")
|
@Column(name = "description")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@@ -51,6 +57,15 @@ public class Product {
|
|||||||
@Column(name = "safe_max", precision = 18, scale = 3)
|
@Column(name = "safe_max", precision = 18, scale = 3)
|
||||||
private BigDecimal safeMax;
|
private BigDecimal safeMax;
|
||||||
|
|
||||||
|
@Column(name = "global_sku_id")
|
||||||
|
private Long globalSkuId;
|
||||||
|
|
||||||
|
@Column(name = "source_submission_id")
|
||||||
|
private Long sourceSubmissionId;
|
||||||
|
|
||||||
|
@Column(name = "attributes_json", columnDefinition = "json")
|
||||||
|
private String attributesJson;
|
||||||
|
|
||||||
@Column(name = "created_at")
|
@Column(name = "created_at")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@@ -71,6 +86,8 @@ public class Product {
|
|||||||
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
|
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
|
||||||
public Long getUnitId() { return unitId; }
|
public Long getUnitId() { return unitId; }
|
||||||
public void setUnitId(Long unitId) { this.unitId = unitId; }
|
public void setUnitId(Long unitId) { this.unitId = unitId; }
|
||||||
|
public Long getTemplateId() { return templateId; }
|
||||||
|
public void setTemplateId(Long templateId) { this.templateId = templateId; }
|
||||||
public String getBrand() { return brand; }
|
public String getBrand() { return brand; }
|
||||||
public void setBrand(String brand) { this.brand = brand; }
|
public void setBrand(String brand) { this.brand = brand; }
|
||||||
public String getModel() { return model; }
|
public String getModel() { return model; }
|
||||||
@@ -81,12 +98,20 @@ public class Product {
|
|||||||
public void setOrigin(String origin) { this.origin = origin; }
|
public void setOrigin(String origin) { this.origin = origin; }
|
||||||
public String getBarcode() { return barcode; }
|
public String getBarcode() { return barcode; }
|
||||||
public void setBarcode(String barcode) { this.barcode = barcode; }
|
public void setBarcode(String barcode) { this.barcode = barcode; }
|
||||||
|
public String getDedupeKey() { return dedupeKey; }
|
||||||
|
public void setDedupeKey(String dedupeKey) { this.dedupeKey = dedupeKey; }
|
||||||
public String getDescription() { return description; }
|
public String getDescription() { return description; }
|
||||||
public void setDescription(String description) { this.description = description; }
|
public void setDescription(String description) { this.description = description; }
|
||||||
public BigDecimal getSafeMin() { return safeMin; }
|
public BigDecimal getSafeMin() { return safeMin; }
|
||||||
public void setSafeMin(BigDecimal safeMin) { this.safeMin = safeMin; }
|
public void setSafeMin(BigDecimal safeMin) { this.safeMin = safeMin; }
|
||||||
public BigDecimal getSafeMax() { return safeMax; }
|
public BigDecimal getSafeMax() { return safeMax; }
|
||||||
public void setSafeMax(BigDecimal safeMax) { this.safeMax = safeMax; }
|
public void setSafeMax(BigDecimal safeMax) { this.safeMax = safeMax; }
|
||||||
|
public Long getGlobalSkuId() { return globalSkuId; }
|
||||||
|
public void setGlobalSkuId(Long globalSkuId) { this.globalSkuId = globalSkuId; }
|
||||||
|
public Long getSourceSubmissionId() { return sourceSubmissionId; }
|
||||||
|
public void setSourceSubmissionId(Long sourceSubmissionId) { this.sourceSubmissionId = sourceSubmissionId; }
|
||||||
|
public String getAttributesJson() { return attributesJson; }
|
||||||
|
public void setAttributesJson(String attributesJson) { this.attributesJson = attributesJson; }
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "part_submissions")
|
||||||
|
public class ProductSubmission {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "name")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "external_code")
|
||||||
|
private String externalCode;
|
||||||
|
|
||||||
|
@Column(name = "model_unique", nullable = false)
|
||||||
|
private String modelUnique;
|
||||||
|
|
||||||
|
private String brand;
|
||||||
|
private String spec;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private String origin;
|
||||||
|
|
||||||
|
@Column(name = "unit_id")
|
||||||
|
private Long unitId;
|
||||||
|
|
||||||
|
@Column(name = "category_id")
|
||||||
|
private Long categoryId;
|
||||||
|
|
||||||
|
@Column(name = "template_id")
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
@Column(name = "attributes")
|
||||||
|
private String attributesJson;
|
||||||
|
|
||||||
|
@Column(name = "images")
|
||||||
|
private String imagesJson;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private java.math.BigDecimal safeMin;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private java.math.BigDecimal safeMax;
|
||||||
|
|
||||||
|
private String size;
|
||||||
|
private String aperture;
|
||||||
|
|
||||||
|
@Column(name = "compatible")
|
||||||
|
private String compatibleText;
|
||||||
|
|
||||||
|
private String barcode;
|
||||||
|
|
||||||
|
@Column(name = "dedupe_key")
|
||||||
|
private String dedupeKey;
|
||||||
|
|
||||||
|
@Column(name = "remark")
|
||||||
|
private String remarkText;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Status status;
|
||||||
|
|
||||||
|
@Column(name = "reviewer_id")
|
||||||
|
private Long reviewerId;
|
||||||
|
|
||||||
|
@Column(name = "product_id")
|
||||||
|
private Long productId;
|
||||||
|
|
||||||
|
@Column(name = "global_sku_id")
|
||||||
|
private Long globalSkuId;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_at")
|
||||||
|
private LocalDateTime reviewedAt;
|
||||||
|
|
||||||
|
@Column(name = "review_remark")
|
||||||
|
private String reviewRemark;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
pending,
|
||||||
|
approved,
|
||||||
|
rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getExternalCode() { return externalCode; }
|
||||||
|
public void setExternalCode(String externalCode) { this.externalCode = externalCode; }
|
||||||
|
public String getModelUnique() { return modelUnique; }
|
||||||
|
public void setModelUnique(String modelUnique) { this.modelUnique = modelUnique; }
|
||||||
|
public String getBrand() { return brand; }
|
||||||
|
public void setBrand(String brand) { this.brand = brand; }
|
||||||
|
public String getSpec() { return spec; }
|
||||||
|
public void setSpec(String spec) { this.spec = spec; }
|
||||||
|
public String getOrigin() { return origin; }
|
||||||
|
public void setOrigin(String origin) { this.origin = origin; }
|
||||||
|
public Long getUnitId() { return unitId; }
|
||||||
|
public void setUnitId(Long unitId) { this.unitId = unitId; }
|
||||||
|
public Long getCategoryId() { return categoryId; }
|
||||||
|
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
|
||||||
|
public Long getTemplateId() { return templateId; }
|
||||||
|
public void setTemplateId(Long templateId) { this.templateId = templateId; }
|
||||||
|
public String getAttributesJson() { return attributesJson; }
|
||||||
|
public void setAttributesJson(String attributesJson) { this.attributesJson = attributesJson; }
|
||||||
|
public String getImagesJson() { return imagesJson; }
|
||||||
|
public void setImagesJson(String imagesJson) { this.imagesJson = imagesJson; }
|
||||||
|
public java.math.BigDecimal getSafeMin() { return safeMin; }
|
||||||
|
public void setSafeMin(java.math.BigDecimal safeMin) { this.safeMin = safeMin; }
|
||||||
|
public java.math.BigDecimal getSafeMax() { return safeMax; }
|
||||||
|
public void setSafeMax(java.math.BigDecimal safeMax) { this.safeMax = safeMax; }
|
||||||
|
public String getSize() { return size; }
|
||||||
|
public void setSize(String size) { this.size = size; }
|
||||||
|
public String getAperture() { return aperture; }
|
||||||
|
public void setAperture(String aperture) { this.aperture = aperture; }
|
||||||
|
public String getCompatibleText() { return compatibleText; }
|
||||||
|
public void setCompatibleText(String compatibleText) { this.compatibleText = compatibleText; }
|
||||||
|
public String getBarcode() { return barcode; }
|
||||||
|
public void setBarcode(String barcode) { this.barcode = barcode; }
|
||||||
|
public String getDedupeKey() { return dedupeKey; }
|
||||||
|
public void setDedupeKey(String dedupeKey) { this.dedupeKey = dedupeKey; }
|
||||||
|
public String getRemarkText() { return remarkText; }
|
||||||
|
public void setRemarkText(String remarkText) { this.remarkText = remarkText; }
|
||||||
|
public Status getStatus() { return status; }
|
||||||
|
public void setStatus(Status status) { this.status = status; }
|
||||||
|
public Long getReviewerId() { return reviewerId; }
|
||||||
|
public void setReviewerId(Long reviewerId) { this.reviewerId = reviewerId; }
|
||||||
|
public Long getProductId() { return productId; }
|
||||||
|
public void setProductId(Long productId) { this.productId = productId; }
|
||||||
|
public Long getGlobalSkuId() { return globalSkuId; }
|
||||||
|
public void setGlobalSkuId(Long globalSkuId) { this.globalSkuId = globalSkuId; }
|
||||||
|
public LocalDateTime getReviewedAt() { return reviewedAt; }
|
||||||
|
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
|
||||||
|
public String getReviewRemark() { return reviewRemark; }
|
||||||
|
public void setReviewRemark(String reviewRemark) { this.reviewRemark = reviewRemark; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.demo.product.repo;
|
||||||
|
|
||||||
|
import com.example.demo.product.entity.PartTemplateParam;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PartTemplateParamRepository extends JpaRepository<PartTemplateParam, Long> {
|
||||||
|
List<PartTemplateParam> findByTemplateIdOrderBySortOrderAscIdAsc(Long templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.demo.product.repo;
|
||||||
|
|
||||||
|
import com.example.demo.product.entity.PartTemplate;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface PartTemplateRepository extends JpaRepository<PartTemplate, Long> {
|
||||||
|
java.util.List<PartTemplate> findByStatusOrderByIdDesc(Integer status);
|
||||||
|
java.util.List<PartTemplate> findByStatusAndCategoryIdOrderByIdDesc(Integer status, Long categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
|
|||||||
boolean existsByShopIdAndBarcode(Long shopId, String barcode);
|
boolean existsByShopIdAndBarcode(Long shopId, String barcode);
|
||||||
|
|
||||||
long countByShopIdAndBarcodeAndIdNot(Long shopId, String barcode, Long id);
|
long countByShopIdAndBarcodeAndIdNot(Long shopId, String barcode, Long id);
|
||||||
|
|
||||||
|
java.util.Optional<Product> findByShopIdAndModelAndDeletedAtIsNull(Long shopId, String model);
|
||||||
|
|
||||||
|
long countByTemplateIdAndNameAndModelAndDeletedAtIsNull(Long templateId, String name, String model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.demo.product.repo;
|
||||||
|
|
||||||
|
import com.example.demo.product.entity.ProductSubmission;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ProductSubmissionRepository extends JpaRepository<ProductSubmission, Long> {
|
||||||
|
|
||||||
|
Page<ProductSubmission> findByShopIdAndUserIdAndStatusIn(Long shopId, Long userId, List<ProductSubmission.Status> statuses, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT ps FROM ProductSubmission ps WHERE (:statusList IS NULL OR ps.status IN :statusList) " +
|
||||||
|
"AND (:kw IS NULL OR ps.modelUnique LIKE :kw OR ps.name LIKE :kw OR ps.brand LIKE :kw) " +
|
||||||
|
"AND (:shopId IS NULL OR ps.shopId = :shopId) " +
|
||||||
|
"AND (:reviewerId IS NULL OR ps.reviewerId = :reviewerId) " +
|
||||||
|
"AND (:startAt IS NULL OR ps.createdAt >= :startAt) " +
|
||||||
|
"AND (:endAt IS NULL OR ps.createdAt <= :endAt)")
|
||||||
|
Page<ProductSubmission> searchAdmin(@Param("statusList") List<ProductSubmission.Status> statusList,
|
||||||
|
@Param("kw") String kw,
|
||||||
|
@Param("shopId") Long shopId,
|
||||||
|
@Param("reviewerId") Long reviewerId,
|
||||||
|
@Param("startAt") java.time.LocalDateTime startAt,
|
||||||
|
@Param("endAt") java.time.LocalDateTime endAt,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
Optional<ProductSubmission> findByModelUnique(String modelUnique);
|
||||||
|
|
||||||
|
boolean existsByModelUnique(String modelUnique);
|
||||||
|
|
||||||
|
boolean existsByTemplateIdAndNameAndModelUnique(Long templateId, String name, String modelUnique);
|
||||||
|
|
||||||
|
long countByNameAndModelUniqueAndTemplateIdNot(String name, String modelUnique, Long templateId);
|
||||||
|
|
||||||
|
Optional<ProductSubmission> findByIdAndShopIdAndUserId(Long id, Long shopId, Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package com.example.demo.product.service;
|
||||||
|
|
||||||
|
import com.example.demo.common.JsonUtils;
|
||||||
|
import com.example.demo.product.dto.PartTemplateDtos;
|
||||||
|
import com.example.demo.product.entity.PartTemplate;
|
||||||
|
import com.example.demo.product.entity.PartTemplateParam;
|
||||||
|
import com.example.demo.product.repo.PartTemplateParamRepository;
|
||||||
|
import com.example.demo.product.repo.PartTemplateRepository;
|
||||||
|
import com.example.demo.product.repo.ProductRepository;
|
||||||
|
import com.example.demo.product.repo.ProductSubmissionRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PartTemplateService {
|
||||||
|
|
||||||
|
private final PartTemplateRepository templateRepository;
|
||||||
|
private final PartTemplateParamRepository paramRepository;
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
private final ProductSubmissionRepository submissionRepository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public PartTemplateService(PartTemplateRepository templateRepository,
|
||||||
|
PartTemplateParamRepository paramRepository,
|
||||||
|
ProductRepository productRepository,
|
||||||
|
ProductSubmissionRepository submissionRepository,
|
||||||
|
JdbcTemplate jdbcTemplate) {
|
||||||
|
this.templateRepository = templateRepository;
|
||||||
|
this.paramRepository = paramRepository;
|
||||||
|
this.productRepository = productRepository;
|
||||||
|
this.submissionRepository = submissionRepository;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Long create(PartTemplateDtos.CreateRequest req, Long adminId) {
|
||||||
|
validate(req);
|
||||||
|
validateParamDefs(req.params);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
PartTemplate t = new PartTemplate();
|
||||||
|
t.setCategoryId(req.categoryId);
|
||||||
|
t.setName(req.name);
|
||||||
|
t.setModelRule(req.modelRule);
|
||||||
|
t.setStatus(req.status == null ? 1 : req.status);
|
||||||
|
t.setCreatedByAdminId(adminId);
|
||||||
|
t.setCreatedAt(now);
|
||||||
|
t.setUpdatedAt(now);
|
||||||
|
templateRepository.save(t);
|
||||||
|
|
||||||
|
upsertParams(t.getId(), req.params, now);
|
||||||
|
return t.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void update(Long id, PartTemplateDtos.UpdateRequest req) {
|
||||||
|
PartTemplate t = templateRepository.findById(id).orElseThrow();
|
||||||
|
if (req.categoryId != null) t.setCategoryId(req.categoryId);
|
||||||
|
if (req.name != null) t.setName(req.name);
|
||||||
|
if (req.modelRule != null) t.setModelRule(req.modelRule);
|
||||||
|
if (req.status != null) t.setStatus(req.status);
|
||||||
|
t.setUpdatedAt(LocalDateTime.now());
|
||||||
|
templateRepository.save(t);
|
||||||
|
|
||||||
|
if (req.params != null) {
|
||||||
|
validateParamDefs(req.params);
|
||||||
|
// 覆盖式更新
|
||||||
|
paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(id).forEach(p -> paramRepository.deleteById(p.getId()));
|
||||||
|
upsertParams(id, req.params, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.deleteAllRelatedProductsAndSubmissions) {
|
||||||
|
// 全删关联 products + 其价格、库存、图片 + submissions(软删)
|
||||||
|
// 软删 products
|
||||||
|
jdbcTemplate.update("UPDATE products SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id);
|
||||||
|
// 物理清空商品图片/价格/库存
|
||||||
|
jdbcTemplate.update("DELETE FROM product_images WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id);
|
||||||
|
jdbcTemplate.update("DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id);
|
||||||
|
jdbcTemplate.update("DELETE FROM inventories WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id);
|
||||||
|
// 软删 submissions
|
||||||
|
jdbcTemplate.update("UPDATE part_submissions SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartTemplateDtos.TemplateDetail detail(Long id) {
|
||||||
|
PartTemplate t = templateRepository.findById(id).orElseThrow();
|
||||||
|
PartTemplateDtos.TemplateDetail d = new PartTemplateDtos.TemplateDetail();
|
||||||
|
d.id = t.getId();
|
||||||
|
d.categoryId = t.getCategoryId();
|
||||||
|
d.name = t.getName();
|
||||||
|
d.modelRule = t.getModelRule();
|
||||||
|
d.status = t.getStatus();
|
||||||
|
d.createdAt = t.getCreatedAt();
|
||||||
|
d.updatedAt = t.getUpdatedAt();
|
||||||
|
d.params = toParamDtos(paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(id));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PartTemplateDtos.TemplateItem> list() {
|
||||||
|
List<PartTemplate> list = templateRepository.findAll();
|
||||||
|
List<PartTemplateDtos.TemplateItem> out = new ArrayList<>();
|
||||||
|
for (PartTemplate t : list) {
|
||||||
|
PartTemplateDtos.TemplateItem it = new PartTemplateDtos.TemplateItem();
|
||||||
|
it.id = t.getId();
|
||||||
|
it.categoryId = t.getCategoryId();
|
||||||
|
it.name = t.getName();
|
||||||
|
it.modelRule = t.getModelRule();
|
||||||
|
it.status = t.getStatus();
|
||||||
|
it.createdAt = t.getCreatedAt();
|
||||||
|
it.updatedAt = t.getUpdatedAt();
|
||||||
|
out.add(it);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upsertParams(Long templateId, List<PartTemplateDtos.ParamDef> params, LocalDateTime now) {
|
||||||
|
if (params == null) return;
|
||||||
|
int idx = 0;
|
||||||
|
for (PartTemplateDtos.ParamDef def : params) {
|
||||||
|
PartTemplateParam p = new PartTemplateParam();
|
||||||
|
p.setTemplateId(templateId);
|
||||||
|
p.setFieldKey(def.fieldKey);
|
||||||
|
p.setFieldLabel(def.fieldLabel);
|
||||||
|
p.setType(def.type);
|
||||||
|
p.setRequired(def.required);
|
||||||
|
p.setUnit(def.unit);
|
||||||
|
p.setEnumOptionsJson(def.enumOptions == null ? null : JsonUtils.toJson(def.enumOptions));
|
||||||
|
p.setSearchable(def.searchable);
|
||||||
|
p.setDedupeParticipate(def.dedupeParticipate);
|
||||||
|
p.setSortOrder(def.sortOrder == 0 ? idx : def.sortOrder);
|
||||||
|
p.setCreatedAt(now);
|
||||||
|
p.setUpdatedAt(now);
|
||||||
|
paramRepository.save(p);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PartTemplateDtos.ParamDef> toParamDtos(List<PartTemplateParam> list) {
|
||||||
|
List<PartTemplateDtos.ParamDef> out = new ArrayList<>();
|
||||||
|
for (PartTemplateParam p : list) {
|
||||||
|
PartTemplateDtos.ParamDef d = new PartTemplateDtos.ParamDef();
|
||||||
|
d.fieldKey = p.getFieldKey();
|
||||||
|
d.fieldLabel = p.getFieldLabel();
|
||||||
|
d.type = p.getType();
|
||||||
|
d.required = Boolean.TRUE.equals(p.getRequired());
|
||||||
|
d.unit = p.getUnit();
|
||||||
|
d.enumOptions = JsonUtils.fromJson(p.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>(){});
|
||||||
|
d.searchable = Boolean.TRUE.equals(p.getSearchable());
|
||||||
|
d.dedupeParticipate = Boolean.TRUE.equals(p.getDedupeParticipate());
|
||||||
|
d.sortOrder = p.getSortOrder() == null ? 0 : p.getSortOrder();
|
||||||
|
out.add(d);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(PartTemplateDtos.CreateRequest req) {
|
||||||
|
if (req == null) throw new IllegalArgumentException("请求不能为空");
|
||||||
|
if (req.categoryId == null) throw new IllegalArgumentException("categoryId必填");
|
||||||
|
if (req.name == null || req.name.isBlank()) throw new IllegalArgumentException("name必填");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateParamDefs(java.util.List<PartTemplateDtos.ParamDef> params) {
|
||||||
|
if (params == null) return;
|
||||||
|
java.util.Set<String> keys = new java.util.HashSet<>();
|
||||||
|
for (PartTemplateDtos.ParamDef def : params) {
|
||||||
|
if (def == null) throw new IllegalArgumentException("参数定义不能为空");
|
||||||
|
String key = def.fieldKey == null ? null : def.fieldKey.trim();
|
||||||
|
String label = def.fieldLabel == null ? null : def.fieldLabel.trim();
|
||||||
|
def.fieldKey = key;
|
||||||
|
def.fieldLabel = label;
|
||||||
|
if (key == null || key.isEmpty()) throw new IllegalArgumentException("参数键(fieldKey)不能为空");
|
||||||
|
if (!key.matches("[A-Za-z_][A-Za-z0-9_]*")) throw new IllegalArgumentException("参数键仅支持字母/数字/下划线,且不能以数字开头: " + key);
|
||||||
|
if (label == null || label.isEmpty()) throw new IllegalArgumentException("参数名称(fieldLabel)不能为空");
|
||||||
|
String type = def.type == null ? "" : def.type;
|
||||||
|
if (!("string".equals(type) || "number".equals(type) || "boolean".equals(type) || "enum".equals(type) || "date".equals(type))) {
|
||||||
|
throw new IllegalArgumentException("不支持的参数类型: " + type);
|
||||||
|
}
|
||||||
|
if (keys.contains(key)) throw new IllegalArgumentException("参数键重复: " + key);
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.example.demo.product.service;
|
package com.example.demo.product.service;
|
||||||
|
|
||||||
|
import com.example.demo.common.JsonUtils;
|
||||||
import com.example.demo.product.dto.ProductDtos;
|
import com.example.demo.product.dto.ProductDtos;
|
||||||
import com.example.demo.product.entity.Inventory;
|
import com.example.demo.product.entity.Inventory;
|
||||||
import com.example.demo.product.entity.Product;
|
import com.example.demo.product.entity.Product;
|
||||||
import com.example.demo.product.entity.ProductImage;
|
import com.example.demo.product.entity.ProductImage;
|
||||||
import com.example.demo.product.entity.ProductPrice;
|
import com.example.demo.product.entity.ProductPrice;
|
||||||
|
import com.example.demo.product.entity.ProductSubmission;
|
||||||
import com.example.demo.product.repo.InventoryRepository;
|
import com.example.demo.product.repo.InventoryRepository;
|
||||||
import com.example.demo.product.repo.ProductImageRepository;
|
import com.example.demo.product.repo.ProductImageRepository;
|
||||||
import com.example.demo.product.repo.ProductPriceRepository;
|
import com.example.demo.product.repo.ProductPriceRepository;
|
||||||
@@ -43,6 +45,98 @@ public class ProductService {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void bindSubmission(Long productId, Long submissionId) {
|
||||||
|
productRepository.findById(productId).ifPresent(p -> {
|
||||||
|
p.setSourceSubmissionId(submissionId);
|
||||||
|
productRepository.save(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateGlobalSku(Long productId, Long globalSkuId) {
|
||||||
|
productRepository.findById(productId).ifPresent(p -> {
|
||||||
|
p.setGlobalSkuId(globalSkuId);
|
||||||
|
productRepository.save(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long findProductByModel(Long shopId, String model) {
|
||||||
|
if (model == null || model.isBlank()) return null;
|
||||||
|
Optional<Product> existed = productRepository.findByShopIdAndModelAndDeletedAtIsNull(shopId, model);
|
||||||
|
return existed.map(Product::getId).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateProductFromSubmission(Long productId, ProductSubmission submission, Long globalSkuId) {
|
||||||
|
Product product = productRepository.findById(productId).orElse(null);
|
||||||
|
if (product == null) return;
|
||||||
|
boolean changed = false;
|
||||||
|
if (submission.getTemplateId() != null && (product.getTemplateId() == null || !submission.getTemplateId().equals(product.getTemplateId()))) {
|
||||||
|
product.setTemplateId(submission.getTemplateId());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getName() != null && !submission.getName().isBlank()) {
|
||||||
|
product.setName(submission.getName());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getBrand() != null && !submission.getBrand().isBlank()) {
|
||||||
|
product.setBrand(submission.getBrand());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getSpec() != null && !submission.getSpec().isBlank()) {
|
||||||
|
product.setSpec(submission.getSpec());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getBarcode() != null && !submission.getBarcode().isBlank()) {
|
||||||
|
product.setBarcode(submission.getBarcode());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getCategoryId() != null && !submission.getCategoryId().equals(product.getCategoryId())) {
|
||||||
|
product.setCategoryId(submission.getCategoryId());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getUnitId() != null && !submission.getUnitId().equals(product.getUnitId())) {
|
||||||
|
product.setUnitId(submission.getUnitId());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getRemarkText() != null && !submission.getRemarkText().isBlank()) {
|
||||||
|
product.setDescription(submission.getRemarkText());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getOrigin() != null && !submission.getOrigin().isBlank()) {
|
||||||
|
product.setOrigin(submission.getOrigin());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getSafeMin() != null && submission.getSafeMin().compareTo(BigDecimal.ZERO) >= 0) {
|
||||||
|
product.setSafeMin(submission.getSafeMin());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getSafeMax() != null && submission.getSafeMax().compareTo(BigDecimal.ZERO) >= 0) {
|
||||||
|
product.setSafeMax(submission.getSafeMax());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (globalSkuId != null) {
|
||||||
|
product.setGlobalSkuId(globalSkuId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getAttributesJson() != null && !submission.getAttributesJson().isBlank()) {
|
||||||
|
product.setAttributesJson(submission.getAttributesJson());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (submission.getDedupeKey() != null && !submission.getDedupeKey().isBlank()) {
|
||||||
|
product.setDedupeKey(submission.getDedupeKey());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
product.setUpdatedAt(LocalDateTime.now());
|
||||||
|
productRepository.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> images = JsonUtils.fromJson(submission.getImagesJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
|
||||||
|
syncImages(submission.getUserId(), productId, product.getShopId(), images);
|
||||||
|
}
|
||||||
|
|
||||||
public Page<ProductDtos.ProductListItem> search(Long shopId, String kw, Long categoryId, int page, int size) {
|
public Page<ProductDtos.ProductListItem> search(Long shopId, String kw, Long categoryId, int page, int size) {
|
||||||
try {
|
try {
|
||||||
Page<Product> p = productRepository.search(shopId, kw, categoryId, PageRequest.of(page, size));
|
Page<Product> p = productRepository.search(shopId, kw, categoryId, PageRequest.of(page, size));
|
||||||
@@ -133,6 +227,7 @@ public class ProductService {
|
|||||||
Product p = new Product();
|
Product p = new Product();
|
||||||
p.setShopId(shopId);
|
p.setShopId(shopId);
|
||||||
p.setUserId(userId);
|
p.setUserId(userId);
|
||||||
|
p.setTemplateId(req.templateId);
|
||||||
p.setName(req.name);
|
p.setName(req.name);
|
||||||
p.setBarcode(emptyToNull(req.barcode));
|
p.setBarcode(emptyToNull(req.barcode));
|
||||||
p.setBrand(emptyToNull(req.brand));
|
p.setBrand(emptyToNull(req.brand));
|
||||||
@@ -141,9 +236,15 @@ public class ProductService {
|
|||||||
p.setOrigin(emptyToNull(req.origin));
|
p.setOrigin(emptyToNull(req.origin));
|
||||||
p.setCategoryId(req.categoryId);
|
p.setCategoryId(req.categoryId);
|
||||||
p.setUnitId(req.unitId);
|
p.setUnitId(req.unitId);
|
||||||
|
p.setDedupeKey(emptyToNull(req.dedupeKey));
|
||||||
p.setSafeMin(req.safeMin);
|
p.setSafeMin(req.safeMin);
|
||||||
p.setSafeMax(req.safeMax);
|
p.setSafeMax(req.safeMax);
|
||||||
p.setDescription(emptyToNull(req.remark));
|
p.setDescription(emptyToNull(req.remark));
|
||||||
|
p.setSourceSubmissionId(req.sourceSubmissionId);
|
||||||
|
p.setGlobalSkuId(req.globalSkuId);
|
||||||
|
if (req.parameters != null && !req.parameters.isEmpty()) {
|
||||||
|
p.setAttributesJson(JsonUtils.toJson(req.parameters));
|
||||||
|
}
|
||||||
p.setCreatedAt(now);
|
p.setCreatedAt(now);
|
||||||
p.setUpdatedAt(now);
|
p.setUpdatedAt(now);
|
||||||
productRepository.save(p);
|
productRepository.save(p);
|
||||||
@@ -155,6 +256,20 @@ public class ProductService {
|
|||||||
return p.getId();
|
return p.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Long createFromSubmission(ProductSubmission submission, ProductDtos.CreateOrUpdateProductRequest req) {
|
||||||
|
Long productId = create(submission.getShopId(), submission.getUserId(), req);
|
||||||
|
productRepository.findById(productId).ifPresent(p -> {
|
||||||
|
p.setSourceSubmissionId(submission.getId());
|
||||||
|
if (req.globalSkuId != null) {
|
||||||
|
p.setGlobalSkuId(req.globalSkuId);
|
||||||
|
}
|
||||||
|
p.setUpdatedAt(LocalDateTime.now());
|
||||||
|
productRepository.save(p);
|
||||||
|
});
|
||||||
|
return productId;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void update(Long id, Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) {
|
public void update(Long id, Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) {
|
||||||
validate(shopId, req);
|
validate(shopId, req);
|
||||||
@@ -167,6 +282,7 @@ public class ProductService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.setUserId(userId);
|
p.setUserId(userId);
|
||||||
|
p.setTemplateId(req.templateId);
|
||||||
p.setName(req.name);
|
p.setName(req.name);
|
||||||
p.setBarcode(emptyToNull(req.barcode));
|
p.setBarcode(emptyToNull(req.barcode));
|
||||||
p.setBrand(emptyToNull(req.brand));
|
p.setBrand(emptyToNull(req.brand));
|
||||||
@@ -175,6 +291,7 @@ public class ProductService {
|
|||||||
p.setOrigin(emptyToNull(req.origin));
|
p.setOrigin(emptyToNull(req.origin));
|
||||||
p.setCategoryId(req.categoryId);
|
p.setCategoryId(req.categoryId);
|
||||||
p.setUnitId(req.unitId);
|
p.setUnitId(req.unitId);
|
||||||
|
p.setDedupeKey(emptyToNull(req.dedupeKey));
|
||||||
p.setSafeMin(req.safeMin);
|
p.setSafeMin(req.safeMin);
|
||||||
p.setSafeMax(req.safeMax);
|
p.setSafeMax(req.safeMax);
|
||||||
p.setDescription(emptyToNull(req.remark));
|
p.setDescription(emptyToNull(req.remark));
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
package com.example.demo.product.service;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.common.JsonUtils;
|
||||||
|
import com.example.demo.product.dto.ProductDtos;
|
||||||
|
import com.example.demo.product.dto.ProductSubmissionDtos;
|
||||||
|
import com.example.demo.product.entity.ProductSubmission;
|
||||||
|
import com.example.demo.product.repo.ProductSubmissionRepository;
|
||||||
|
import com.example.demo.product.repo.PartTemplateParamRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.CellStyle;
|
||||||
|
import org.apache.poi.ss.usermodel.CreationHelper;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ProductSubmissionService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ProductSubmissionService.class);
|
||||||
|
|
||||||
|
private final ProductSubmissionRepository submissionRepository;
|
||||||
|
private final ProductService productService;
|
||||||
|
private final PartTemplateParamRepository templateParamRepository;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public ProductSubmissionService(ProductSubmissionRepository submissionRepository,
|
||||||
|
ProductService productService,
|
||||||
|
AppDefaultsProperties defaults,
|
||||||
|
PartTemplateParamRepository templateParamRepository) {
|
||||||
|
this.submissionRepository = submissionRepository;
|
||||||
|
this.productService = productService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
this.templateParamRepository = templateParamRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Long createSubmission(Long shopId, Long userId, ProductSubmissionDtos.CreateRequest req) {
|
||||||
|
validateCreate(shopId, userId, req);
|
||||||
|
ProductSubmission submission = new ProductSubmission();
|
||||||
|
submission.setShopId(shopId);
|
||||||
|
submission.setUserId(userId);
|
||||||
|
submission.setTemplateId(req.templateId);
|
||||||
|
submission.setName(req.name);
|
||||||
|
submission.setModelUnique(normalizeModel(req.model));
|
||||||
|
submission.setBrand(req.brand);
|
||||||
|
submission.setSpec(req.spec);
|
||||||
|
submission.setOrigin(req.origin);
|
||||||
|
submission.setUnitId(req.unitId);
|
||||||
|
submission.setCategoryId(req.categoryId);
|
||||||
|
submission.setAttributesJson(JsonUtils.toJson(req.parameters));
|
||||||
|
submission.setImagesJson(JsonUtils.toJson(req.images));
|
||||||
|
submission.setRemarkText(req.remark);
|
||||||
|
submission.setBarcode(req.barcode);
|
||||||
|
submission.setSafeMin(req.safeMin);
|
||||||
|
submission.setSafeMax(req.safeMax);
|
||||||
|
submission.setDedupeKey(buildDedupeKey(req.templateId, req.name, req.model, req.brand, req.parameters));
|
||||||
|
submission.setStatus(ProductSubmission.Status.pending);
|
||||||
|
submission.setCreatedAt(LocalDateTime.now());
|
||||||
|
submission.setUpdatedAt(LocalDateTime.now());
|
||||||
|
submissionRepository.save(submission);
|
||||||
|
return submission.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductSubmissionDtos.PageResult<ProductSubmissionDtos.SubmissionItem> listMine(Long shopId, Long userId, String status, int page, int size) {
|
||||||
|
String normalizedStatus = (status == null || status.isBlank() || "undefined".equalsIgnoreCase(status)) ? null : status;
|
||||||
|
Page<ProductSubmission> result = submissionRepository.findByShopIdAndUserIdAndStatusIn(
|
||||||
|
shopId, userId, resolveStatuses(normalizedStatus), PageRequest.of(Math.max(page - 1, 0), size, Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||||
|
return toPageResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductSubmissionDtos.PageResult<ProductSubmissionDtos.SubmissionItem> listAdmin(String status, String kw, Long shopId,
|
||||||
|
Long reviewerId, String startAt, String endAt,
|
||||||
|
int page, int size) {
|
||||||
|
List<ProductSubmission.Status> statuses = resolveStatuses(status);
|
||||||
|
String kwLike = (kw == null || kw.isBlank()) ? null : "%" + kw.trim() + "%";
|
||||||
|
LocalDateTime start = parseDate(startAt);
|
||||||
|
LocalDateTime endTime = parseDate(endAt);
|
||||||
|
Page<ProductSubmission> result = submissionRepository.searchAdmin(statuses.isEmpty() ? null : statuses,
|
||||||
|
kwLike, shopId, reviewerId, start, endTime, PageRequest.of(Math.max(page - 1, 0), size, Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||||
|
return toPageResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void export(String status, String kw, Long shopId, Long reviewerId, String startAt, String endAt,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
List<ProductSubmission.Status> statuses = resolveStatuses(status);
|
||||||
|
String kwLike = (kw == null || kw.isBlank()) ? null : "%" + kw.trim() + "%";
|
||||||
|
LocalDateTime start = parseDate(startAt);
|
||||||
|
LocalDateTime endTime = parseDate(endAt);
|
||||||
|
List<ProductSubmission> records = submissionRepository.searchAdmin(statuses.isEmpty() ? null : statuses,
|
||||||
|
kwLike, shopId, reviewerId, start, endTime, PageRequest.of(0, 2000, Sort.by(Sort.Direction.DESC, "createdAt"))).getContent();
|
||||||
|
try (Workbook workbook = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = workbook.createSheet("Submissions");
|
||||||
|
CreationHelper creationHelper = workbook.getCreationHelper();
|
||||||
|
CellStyle dateStyle = workbook.createCellStyle();
|
||||||
|
dateStyle.setDataFormat(creationHelper.createDataFormat().getFormat("yyyy-mm-dd hh:mm"));
|
||||||
|
|
||||||
|
int rowIdx = 0;
|
||||||
|
Row header = sheet.createRow(rowIdx++);
|
||||||
|
String[] headers = {"ID", "型号", "名称", "品牌", "状态", "提交人", "店铺", "提交时间", "审核时间", "审核备注"};
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
header.createCell(i).setCellValue(headers[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ProductSubmission submission : records) {
|
||||||
|
Row row = sheet.createRow(rowIdx++);
|
||||||
|
int col = 0;
|
||||||
|
row.createCell(col++).setCellValue(submission.getId());
|
||||||
|
row.createCell(col++).setCellValue(nvl(submission.getModelUnique()));
|
||||||
|
row.createCell(col++).setCellValue(nvl(submission.getName()));
|
||||||
|
row.createCell(col++).setCellValue(nvl(submission.getBrand()));
|
||||||
|
row.createCell(col++).setCellValue(submission.getStatus().name());
|
||||||
|
row.createCell(col++).setCellValue(submission.getUserId() != null ? submission.getUserId() : 0);
|
||||||
|
row.createCell(col++).setCellValue(submission.getShopId() != null ? submission.getShopId() : 0);
|
||||||
|
setDateCell(row.createCell(col++), submission.getCreatedAt(), dateStyle);
|
||||||
|
setDateCell(row.createCell(col++), submission.getReviewedAt(), dateStyle);
|
||||||
|
row.createCell(col).setCellValue(nvl(submission.getReviewRemark()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
sheet.autoSizeColumn(i);
|
||||||
|
int width = sheet.getColumnWidth(i);
|
||||||
|
sheet.setColumnWidth(i, Math.min(width + 512, 10000));
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileName = "part_submissions_" + System.currentTimeMillis() + ".xlsx";
|
||||||
|
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileName);
|
||||||
|
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||||
|
workbook.write(response.getOutputStream());
|
||||||
|
response.flushBuffer();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("导出失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ProductSubmissionDtos.SubmissionDetail> findDetail(Long id) {
|
||||||
|
return submissionRepository.findById(id).map(this::toDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ProductSubmissionDtos.SubmissionDetail> findMineDetail(Long id, Long shopId, Long userId) {
|
||||||
|
return submissionRepository.findByIdAndShopIdAndUserId(id, shopId, userId).map(this::toDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateSubmission(Long id, ProductSubmissionDtos.UpdateRequest req) {
|
||||||
|
ProductSubmission submission = submissionRepository.findById(id).orElseThrow();
|
||||||
|
if (submission.getStatus() != ProductSubmission.Status.pending) {
|
||||||
|
throw new IllegalArgumentException("仅待审核记录可编辑");
|
||||||
|
}
|
||||||
|
submission.setName(req.name != null ? req.name : submission.getName());
|
||||||
|
submission.setBrand(req.brand != null ? req.brand : submission.getBrand());
|
||||||
|
submission.setSpec(req.spec != null ? req.spec : submission.getSpec());
|
||||||
|
submission.setOrigin(req.origin != null ? req.origin : submission.getOrigin());
|
||||||
|
submission.setUnitId(req.unitId != null ? req.unitId : submission.getUnitId());
|
||||||
|
submission.setCategoryId(req.categoryId != null ? req.categoryId : submission.getCategoryId());
|
||||||
|
if (req.parameters != null) submission.setAttributesJson(JsonUtils.toJson(req.parameters));
|
||||||
|
if (req.images != null) submission.setImagesJson(JsonUtils.toJson(req.images));
|
||||||
|
if (req.remark != null) submission.setRemarkText(req.remark);
|
||||||
|
if (req.barcode != null) submission.setBarcode(req.barcode);
|
||||||
|
if (req.safeMin != null) submission.setSafeMin(req.safeMin);
|
||||||
|
if (req.safeMax != null) submission.setSafeMax(req.safeMax);
|
||||||
|
// 参数或基础字段变更后重算去重键
|
||||||
|
Map<String,Object> params = JsonUtils.fromJson(submission.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference<Map<String,Object>>() {});
|
||||||
|
submission.setDedupeKey(buildDedupeKey(submission.getTemplateId(), submission.getName(), submission.getModelUnique(), submission.getBrand(), params));
|
||||||
|
submission.setUpdatedAt(LocalDateTime.now());
|
||||||
|
submissionRepository.save(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ProductSubmissionDtos.ApproveResponse approve(Long id, Long adminId, ProductSubmissionDtos.ApproveRequest req) {
|
||||||
|
ProductSubmission submission = submissionRepository.findById(id).orElseThrow();
|
||||||
|
if (submission.getStatus() != ProductSubmission.Status.pending) {
|
||||||
|
throw new IllegalArgumentException("记录已审核");
|
||||||
|
}
|
||||||
|
handleApproval(submission, req);
|
||||||
|
submission.setStatus(ProductSubmission.Status.approved);
|
||||||
|
submission.setReviewerId(resolveReviewer(adminId));
|
||||||
|
submission.setReviewRemark(req == null ? null : req.remark);
|
||||||
|
submission.setReviewedAt(LocalDateTime.now());
|
||||||
|
submission.setUpdatedAt(LocalDateTime.now());
|
||||||
|
submissionRepository.save(submission);
|
||||||
|
|
||||||
|
ProductSubmissionDtos.ApproveResponse resp = new ProductSubmissionDtos.ApproveResponse();
|
||||||
|
resp.ok = true;
|
||||||
|
resp.productId = submission.getProductId();
|
||||||
|
resp.globalSkuId = submission.getGlobalSkuId();
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void reject(Long id, Long adminId, ProductSubmissionDtos.RejectRequest req) {
|
||||||
|
if (req == null || req.remark == null || req.remark.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("请填写驳回原因");
|
||||||
|
}
|
||||||
|
ProductSubmission submission = submissionRepository.findById(id).orElseThrow();
|
||||||
|
if (submission.getStatus() != ProductSubmission.Status.pending) {
|
||||||
|
throw new IllegalArgumentException("记录已审核");
|
||||||
|
}
|
||||||
|
submission.setStatus(ProductSubmission.Status.rejected);
|
||||||
|
submission.setReviewerId(resolveReviewer(adminId));
|
||||||
|
submission.setReviewRemark(req.remark);
|
||||||
|
submission.setReviewedAt(LocalDateTime.now());
|
||||||
|
submission.setUpdatedAt(LocalDateTime.now());
|
||||||
|
submissionRepository.save(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCreate(Long shopId, Long userId, ProductSubmissionDtos.CreateRequest req) {
|
||||||
|
if (req == null) throw new IllegalArgumentException("请求不能为空");
|
||||||
|
if (req.model == null || req.model.isBlank()) throw new IllegalArgumentException("型号必填");
|
||||||
|
String normalized = normalizeModel(req.model);
|
||||||
|
submissionRepository.findByModelUnique(normalized).ifPresent(existing -> {
|
||||||
|
throw new IllegalArgumentException("该型号已提交");
|
||||||
|
});
|
||||||
|
// 模板参数强校验
|
||||||
|
if (req.templateId != null && req.templateId > 0) {
|
||||||
|
Map<String,Object> params = req.parameters == null ? java.util.Collections.emptyMap() : req.parameters;
|
||||||
|
var defs = templateParamRepository.findByTemplateIdOrderBySortOrderAscIdAsc(req.templateId);
|
||||||
|
for (var def : defs) {
|
||||||
|
String key = def.getFieldKey();
|
||||||
|
Object v = params.get(key);
|
||||||
|
if (Boolean.TRUE.equals(def.getRequired())) {
|
||||||
|
if (v == null || (v instanceof String s && s.isBlank())) {
|
||||||
|
throw new IllegalArgumentException("缺少必填参数: " + def.getFieldLabel());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v == null) continue;
|
||||||
|
switch (String.valueOf(def.getType())) {
|
||||||
|
case "number" -> {
|
||||||
|
if (!(v instanceof Number)) {
|
||||||
|
try { new java.math.BigDecimal(String.valueOf(v)); } catch (Exception e) { throw new IllegalArgumentException(def.getFieldLabel()+"应为数字"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "boolean" -> {
|
||||||
|
if (!(v instanceof Boolean)) {
|
||||||
|
String s = String.valueOf(v).toLowerCase();
|
||||||
|
if (!"true".equals(s) && !"false".equals(s) && !"1".equals(s) && !"0".equals(s)) {
|
||||||
|
throw new IllegalArgumentException(def.getFieldLabel()+"应为布尔");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "enum" -> {
|
||||||
|
java.util.List<String> opts = JsonUtils.fromJson(def.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference<java.util.List<String>>() {});
|
||||||
|
if (opts != null && !opts.isEmpty()) {
|
||||||
|
if (!opts.contains(String.valueOf(v))) throw new IllegalArgumentException(def.getFieldLabel()+"取值不在枚举范围");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "date" -> {
|
||||||
|
try { java.time.LocalDate.parse(String.valueOf(v)); } catch (Exception e) { throw new IllegalArgumentException(def.getFieldLabel()+"应为日期(YYYY-MM-DD)"); }
|
||||||
|
}
|
||||||
|
default -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductSubmissionDtos.CheckModelResponse checkModel(String model, Long templateId, String name) {
|
||||||
|
if (model == null || model.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("型号不能为空");
|
||||||
|
}
|
||||||
|
String normalized = normalizeModel(model);
|
||||||
|
boolean exists = submissionRepository.existsByModelUnique(normalized)
|
||||||
|
|| (templateId != null && name != null && submissionRepository.existsByTemplateIdAndNameAndModelUnique(templateId, name, normalized));
|
||||||
|
ProductSubmissionDtos.CheckModelResponse resp = new ProductSubmissionDtos.CheckModelResponse();
|
||||||
|
resp.model = normalized;
|
||||||
|
resp.available = !exists;
|
||||||
|
// 跨模板提示:非阻断
|
||||||
|
resp.similarAcrossTemplates = (int)(name == null ? 0 : submissionRepository.countByNameAndModelUniqueAndTemplateIdNot(name, normalized, templateId == null ? -1L : templateId));
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeModel(String model) {
|
||||||
|
return model == null ? null : model.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeText(String text) {
|
||||||
|
if (text == null) return null;
|
||||||
|
String s = text.trim().toUpperCase();
|
||||||
|
s = s.replaceAll("[\\s\\-_/]+", "");
|
||||||
|
s = s.replace('(','(').replace(')',')');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDedupeKey(Long templateId, String name, String model, String brand, Map<String,Object> params) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(templateId == null ? 0 : templateId).append('|');
|
||||||
|
sb.append(normalizeText(name)).append('|');
|
||||||
|
sb.append(normalizeText(model)).append('|');
|
||||||
|
sb.append(normalizeText(brand)).append('|');
|
||||||
|
if (params != null && !params.isEmpty()) {
|
||||||
|
java.util.Map<String,Object> filtered = params;
|
||||||
|
try {
|
||||||
|
if (templateId != null && templateId > 0) {
|
||||||
|
java.util.Set<String> keys = templateParamRepository.findByTemplateIdOrderBySortOrderAscIdAsc(templateId)
|
||||||
|
.stream().filter(p -> Boolean.TRUE.equals(p.getDedupeParticipate()))
|
||||||
|
.map(com.example.demo.product.entity.PartTemplateParam::getFieldKey)
|
||||||
|
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||||
|
if (!keys.isEmpty()) {
|
||||||
|
java.util.Map<String,Object> temp = new java.util.HashMap<>();
|
||||||
|
for (String k : keys) if (params.containsKey(k)) temp.put(k, params.get(k));
|
||||||
|
filtered = temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
java.util.TreeMap<String,Object> sorted = new java.util.TreeMap<>(filtered);
|
||||||
|
String p = JsonUtils.toJson(sorted);
|
||||||
|
if (p != null) sb.append(p);
|
||||||
|
}
|
||||||
|
return org.springframework.util.DigestUtils.md5DigestAsHex(sb.toString().getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ProductSubmission.Status> resolveStatuses(String status) {
|
||||||
|
if (status == null || status.isBlank()) {
|
||||||
|
return List.of(ProductSubmission.Status.pending,
|
||||||
|
ProductSubmission.Status.approved,
|
||||||
|
ProductSubmission.Status.rejected);
|
||||||
|
}
|
||||||
|
return switch (status.toLowerCase()) {
|
||||||
|
case "pending" -> List.of(ProductSubmission.Status.pending);
|
||||||
|
case "approved" -> List.of(ProductSubmission.Status.approved);
|
||||||
|
case "rejected" -> List.of(ProductSubmission.Status.rejected);
|
||||||
|
default -> List.of(ProductSubmission.Status.pending,
|
||||||
|
ProductSubmission.Status.approved,
|
||||||
|
ProductSubmission.Status.rejected);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductSubmissionDtos.PageResult<ProductSubmissionDtos.SubmissionItem> toPageResult(Page<ProductSubmission> page) {
|
||||||
|
ProductSubmissionDtos.PageResult<ProductSubmissionDtos.SubmissionItem> result = new ProductSubmissionDtos.PageResult<>();
|
||||||
|
result.list = page.getContent().stream().map(this::toItem).toList();
|
||||||
|
result.total = page.getTotalElements();
|
||||||
|
result.page = page.getNumber() + 1;
|
||||||
|
result.size = page.getSize();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductSubmissionDtos.SubmissionItem toItem(ProductSubmission submission) {
|
||||||
|
ProductSubmissionDtos.SubmissionItem item = new ProductSubmissionDtos.SubmissionItem();
|
||||||
|
item.id = submission.getId();
|
||||||
|
item.name = submission.getName();
|
||||||
|
item.model = submission.getModelUnique();
|
||||||
|
item.brand = submission.getBrand();
|
||||||
|
item.status = submission.getStatus().name();
|
||||||
|
item.shopId = submission.getShopId();
|
||||||
|
item.createdAt = submission.getCreatedAt();
|
||||||
|
item.reviewedAt = submission.getReviewedAt();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductSubmissionDtos.SubmissionDetail toDetail(ProductSubmission submission) {
|
||||||
|
ProductSubmissionDtos.SubmissionDetail detail = new ProductSubmissionDtos.SubmissionDetail();
|
||||||
|
detail.id = submission.getId();
|
||||||
|
detail.shopId = submission.getShopId();
|
||||||
|
detail.userId = submission.getUserId();
|
||||||
|
detail.name = submission.getName();
|
||||||
|
detail.model = submission.getModelUnique();
|
||||||
|
detail.brand = submission.getBrand();
|
||||||
|
detail.spec = submission.getSpec();
|
||||||
|
detail.origin = submission.getOrigin();
|
||||||
|
detail.unitId = submission.getUnitId();
|
||||||
|
detail.categoryId = submission.getCategoryId();
|
||||||
|
detail.templateId = submission.getTemplateId();
|
||||||
|
detail.parameters = JsonUtils.fromJson(submission.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference<Map<String,Object>>() {});
|
||||||
|
detail.images = JsonUtils.fromJson(submission.getImagesJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
|
||||||
|
detail.remark = submission.getRemarkText();
|
||||||
|
detail.barcode = submission.getBarcode();
|
||||||
|
detail.safeMin = submission.getSafeMin();
|
||||||
|
detail.safeMax = submission.getSafeMax();
|
||||||
|
detail.status = submission.getStatus().name();
|
||||||
|
detail.reviewerId = submission.getReviewerId();
|
||||||
|
detail.reviewRemark = submission.getReviewRemark();
|
||||||
|
detail.reviewedAt = submission.getReviewedAt();
|
||||||
|
detail.createdAt = submission.getCreatedAt();
|
||||||
|
detail.dedupeKey = submission.getDedupeKey();
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleApproval(ProductSubmission submission, ProductSubmissionDtos.ApproveRequest req) {
|
||||||
|
Long targetProductId;
|
||||||
|
if (submission.getProductId() != null && submission.getProductId() > 0) {
|
||||||
|
targetProductId = submission.getProductId();
|
||||||
|
productService.updateProductFromSubmission(targetProductId, submission, req == null ? null : req.assignGlobalSkuId);
|
||||||
|
} else {
|
||||||
|
Long existingProduct = productService.findProductByModel(submission.getShopId(), submission.getModelUnique());
|
||||||
|
if (existingProduct == null) {
|
||||||
|
ProductDtos.CreateOrUpdateProductRequest payload = buildProductPayload(submission);
|
||||||
|
targetProductId = productService.createFromSubmission(submission, payload);
|
||||||
|
} else {
|
||||||
|
targetProductId = existingProduct;
|
||||||
|
productService.updateProductFromSubmission(targetProductId, submission, req == null ? null : req.assignGlobalSkuId);
|
||||||
|
}
|
||||||
|
submission.setProductId(targetProductId);
|
||||||
|
}
|
||||||
|
if (req != null && req.assignGlobalSkuId != null) {
|
||||||
|
productService.updateGlobalSku(targetProductId, req.assignGlobalSkuId);
|
||||||
|
submission.setGlobalSkuId(req.assignGlobalSkuId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductDtos.CreateOrUpdateProductRequest buildProductPayload(ProductSubmission submission) {
|
||||||
|
ProductDtos.CreateOrUpdateProductRequest payload = new ProductDtos.CreateOrUpdateProductRequest();
|
||||||
|
payload.templateId = submission.getTemplateId();
|
||||||
|
payload.name = (submission.getName() != null && !submission.getName().isBlank()) ? submission.getName() : submission.getModelUnique();
|
||||||
|
payload.barcode = submission.getBarcode();
|
||||||
|
payload.brand = submission.getBrand();
|
||||||
|
payload.model = submission.getModelUnique();
|
||||||
|
payload.spec = submission.getSpec();
|
||||||
|
payload.origin = submission.getOrigin();
|
||||||
|
payload.categoryId = submission.getCategoryId();
|
||||||
|
payload.unitId = submission.getUnitId();
|
||||||
|
payload.dedupeKey = submission.getDedupeKey();
|
||||||
|
payload.safeMin = submission.getSafeMin();
|
||||||
|
payload.safeMax = submission.getSafeMax();
|
||||||
|
payload.remark = submission.getRemarkText();
|
||||||
|
payload.sourceSubmissionId = submission.getId();
|
||||||
|
payload.globalSkuId = submission.getGlobalSkuId();
|
||||||
|
payload.parameters = JsonUtils.fromJson(submission.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference<Map<String,Object>>() {});
|
||||||
|
|
||||||
|
payload.prices = new ProductDtos.Prices();
|
||||||
|
payload.prices.purchasePrice = BigDecimal.ZERO;
|
||||||
|
payload.prices.retailPrice = BigDecimal.ZERO;
|
||||||
|
payload.prices.wholesalePrice = BigDecimal.ZERO;
|
||||||
|
payload.prices.bigClientPrice = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
payload.stock = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
List<String> images = JsonUtils.fromJson(submission.getImagesJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
|
||||||
|
payload.images = images != null ? new ArrayList<>(images) : new ArrayList<>();
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime parseDate(String text) {
|
||||||
|
if (text == null || text.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return LocalDateTime.parse(text);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveReviewer(Long adminId) {
|
||||||
|
return (adminId == null || adminId == 0L) ? defaults.getUserId() : adminId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setDateCell(Cell cell, LocalDateTime time, CellStyle style) {
|
||||||
|
if (time == null) {
|
||||||
|
cell.setBlank();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cell.setCellValue(java.sql.Timestamp.valueOf(time));
|
||||||
|
cell.setCellStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nvl(String v) {
|
||||||
|
return v == null ? "" : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.demo.report;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/report")
|
||||||
|
public class ReportController {
|
||||||
|
|
||||||
|
private final ReportService reportService;
|
||||||
|
|
||||||
|
public ReportController(ReportService reportService) {
|
||||||
|
this.reportService = reportService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sales")
|
||||||
|
public ResponseEntity<?> salesReport(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "dimension", required = false) String dimension,
|
||||||
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
|
return ResponseEntity.ok(reportService.getSalesReport(shopId, dimension, startDate, endDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
189
backend/src/main/java/com/example/demo/report/ReportService.java
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package com.example.demo.report;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ReportService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public ReportService(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getSalesReport(Long shopId, String dimensionParam, String startDate, String endDate) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
String dimension = normalizeDimension(dimensionParam);
|
||||||
|
Timestamp startTs = parseStartTimestamp(startDate);
|
||||||
|
Timestamp endTs = parseEndTimestamp(endDate);
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
String sql = buildSalesReportSql(dimension, params, sid, startTs, endTs);
|
||||||
|
List<Map<String, Object>> items = jdbcTemplate.query(sql, params.toArray(), (rs, rowNum) -> {
|
||||||
|
Map<String, Object> row = new HashMap<>();
|
||||||
|
long key = rs.getLong("key_id");
|
||||||
|
if (rs.wasNull()) {
|
||||||
|
row.put("key", null);
|
||||||
|
} else {
|
||||||
|
row.put("key", key);
|
||||||
|
}
|
||||||
|
row.put("name", rs.getString("name"));
|
||||||
|
String spec = rs.getString("spec");
|
||||||
|
if (spec != null && !spec.isBlank()) {
|
||||||
|
row.put("spec", spec);
|
||||||
|
}
|
||||||
|
BigDecimal salesAmount = scale2(nullSafe(rs.getBigDecimal("sales_amount")));
|
||||||
|
BigDecimal costAmount = scale2(nullSafe(rs.getBigDecimal("cost_amount")));
|
||||||
|
BigDecimal profit = scale2(salesAmount.subtract(costAmount));
|
||||||
|
BigDecimal profitRate = salesAmount.compareTo(BigDecimal.ZERO) == 0
|
||||||
|
? BigDecimal.ZERO
|
||||||
|
: profit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"));
|
||||||
|
row.put("salesAmount", salesAmount);
|
||||||
|
row.put("costAmount", costAmount);
|
||||||
|
row.put("profit", profit);
|
||||||
|
row.put("profitRate", profitRate.setScale(2, RoundingMode.HALF_UP));
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
BigDecimal totalSales = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalCost = BigDecimal.ZERO;
|
||||||
|
for (Map<String, Object> item : items) {
|
||||||
|
totalSales = totalSales.add((BigDecimal) item.get("salesAmount"));
|
||||||
|
totalCost = totalCost.add((BigDecimal) item.get("costAmount"));
|
||||||
|
}
|
||||||
|
BigDecimal totalProfit = scale2(totalSales.subtract(totalCost));
|
||||||
|
BigDecimal totalProfitRate = totalSales.compareTo(BigDecimal.ZERO) == 0
|
||||||
|
? BigDecimal.ZERO
|
||||||
|
: totalProfit.divide(totalSales, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"));
|
||||||
|
|
||||||
|
Map<String, Object> summary = new HashMap<>();
|
||||||
|
summary.put("salesAmount", totalSales);
|
||||||
|
summary.put("costAmount", totalCost);
|
||||||
|
summary.put("profit", totalProfit);
|
||||||
|
summary.put("profitRate", totalProfitRate.setScale(2, RoundingMode.HALF_UP));
|
||||||
|
summary.put("itemCount", items.size());
|
||||||
|
|
||||||
|
Map<String, Object> resp = new HashMap<>();
|
||||||
|
resp.put("dimension", dimension);
|
||||||
|
resp.put("startDate", startDate);
|
||||||
|
resp.put("endDate", endDate);
|
||||||
|
resp.put("items", items);
|
||||||
|
resp.put("summary", summary);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeDimension(String dimension) {
|
||||||
|
if (dimension == null) return "customer";
|
||||||
|
String d = dimension.trim().toLowerCase();
|
||||||
|
if ("product".equals(d)) return "product";
|
||||||
|
return "customer";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSalesReportSql(String dimension, List<Object> params, Long shopId, Timestamp start, Timestamp end) {
|
||||||
|
params.add(shopId);
|
||||||
|
String keyExpr = "customer".equals(dimension) ? "COALESCE(so.customer_id, 0)" : "soi.product_id";
|
||||||
|
String nameExpr = "customer".equals(dimension)
|
||||||
|
? "COALESCE(c.name, '未指定客户')"
|
||||||
|
: "COALESCE(p.name, '未命名商品')";
|
||||||
|
String specExpr = "customer".equals(dimension) ? "''" : "COALESCE(p.spec, '')";
|
||||||
|
String joinExpr = "customer".equals(dimension)
|
||||||
|
? "LEFT JOIN customers c ON c.id = so.customer_id"
|
||||||
|
: "LEFT JOIN products p ON p.id = soi.product_id";
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("SELECT key_id, name, spec, SUM(sales_amount) AS sales_amount, SUM(cost_amount) AS cost_amount FROM (\n");
|
||||||
|
sb.append("SELECT ").append(keyExpr).append(" AS key_id, ")
|
||||||
|
.append(nameExpr).append(" AS name, ")
|
||||||
|
.append(specExpr).append(" AS spec, ")
|
||||||
|
.append("soi.amount AS sales_amount, COALESCE(soi.cost_amount,0) AS cost_amount\n")
|
||||||
|
.append("FROM sales_orders so JOIN sales_order_items soi ON soi.order_id = so.id ")
|
||||||
|
.append(joinExpr)
|
||||||
|
.append(" WHERE so.shop_id=? AND so.status='approved'");
|
||||||
|
applyDateFilter(sb, params, "so.order_time", start, end);
|
||||||
|
|
||||||
|
sb.append("\nUNION ALL\n");
|
||||||
|
|
||||||
|
params.add(shopId);
|
||||||
|
keyExpr = "customer".equals(dimension) ? "COALESCE(sro.customer_id, 0)" : "sroi.product_id";
|
||||||
|
nameExpr = "customer".equals(dimension)
|
||||||
|
? "COALESCE(c.name, '未指定客户')"
|
||||||
|
: "COALESCE(p.name, '未命名商品')";
|
||||||
|
specExpr = "customer".equals(dimension) ? "''" : "COALESCE(p.spec, '')";
|
||||||
|
joinExpr = "customer".equals(dimension)
|
||||||
|
? "LEFT JOIN customers c ON c.id = sro.customer_id"
|
||||||
|
: "LEFT JOIN products p ON p.id = sroi.product_id";
|
||||||
|
|
||||||
|
sb.append("SELECT ").append(keyExpr).append(" AS key_id, ")
|
||||||
|
.append(nameExpr).append(" AS name, ")
|
||||||
|
.append(specExpr).append(" AS spec, ")
|
||||||
|
.append("-sroi.amount AS sales_amount, -COALESCE(sroi.cost_amount,0) AS cost_amount\n")
|
||||||
|
.append("FROM sales_return_orders sro JOIN sales_return_order_items sroi ON sroi.order_id = sro.id ")
|
||||||
|
.append(joinExpr)
|
||||||
|
.append(" WHERE sro.shop_id=? AND sro.status='approved'");
|
||||||
|
applyDateFilter(sb, params, "sro.order_time", start, end);
|
||||||
|
|
||||||
|
sb.append("\n) t GROUP BY key_id, name, spec ORDER BY sales_amount DESC");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyDateFilter(StringBuilder sql, List<Object> params, String column, Timestamp start, Timestamp end) {
|
||||||
|
if (start != null) {
|
||||||
|
sql.append(" AND ").append(column).append(">=?");
|
||||||
|
params.add(start);
|
||||||
|
}
|
||||||
|
if (end != null) {
|
||||||
|
sql.append(" AND ").append(column).append("<?");
|
||||||
|
params.add(end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Timestamp parseStartTimestamp(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
if (value.length() > 10) {
|
||||||
|
LocalDateTime dt = LocalDateTime.parse(value.trim());
|
||||||
|
return Timestamp.valueOf(dt);
|
||||||
|
}
|
||||||
|
LocalDate date = LocalDate.parse(value.trim());
|
||||||
|
return Timestamp.valueOf(date.atStartOfDay());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Timestamp parseEndTimestamp(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
if (value.length() > 10) {
|
||||||
|
LocalDateTime dt = LocalDateTime.parse(value.trim());
|
||||||
|
return Timestamp.valueOf(dt);
|
||||||
|
}
|
||||||
|
LocalDate date = LocalDate.parse(value.trim());
|
||||||
|
return Timestamp.valueOf(date.plusDays(1).atStartOfDay());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal scale2(BigDecimal v) {
|
||||||
|
return v.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal nullSafe(BigDecimal v) {
|
||||||
|
return v == null ? BigDecimal.ZERO : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,24 @@ public class SupplierController {
|
|||||||
return ResponseEntity.ok(supplierService.search(sid, kw, debtOnly, Math.max(0, page-1), size));
|
return ResponseEntity.ok(supplierService.search(sid, kw, debtOnly, Math.max(0, page-1), size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
|
||||||
|
java.util.Optional<java.util.Map<String,Object>> r = supplierService.findById(id);
|
||||||
|
if (r.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
java.util.Map<String,Object> row = r.get();
|
||||||
|
java.util.Map<String,Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("id", ((Number)row.get("id")).longValue());
|
||||||
|
body.put("name", (String) row.get("name"));
|
||||||
|
body.put("contactName", (String) row.get("contact_name"));
|
||||||
|
body.put("mobile", (String) row.get("mobile"));
|
||||||
|
body.put("phone", (String) row.get("phone"));
|
||||||
|
body.put("address", (String) row.get("address"));
|
||||||
|
body.put("apOpening", row.get("ap_opening"));
|
||||||
|
body.put("apPayable", row.get("ap_payable"));
|
||||||
|
body.put("remark", (String) row.get("remark"));
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.example.demo.user;
|
||||||
|
|
||||||
|
import com.example.demo.auth.JwtService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/user")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
public UserController(UserService userService, JwtService jwtService) {
|
||||||
|
this.userService = userService;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long ensureUserId(String authorization) {
|
||||||
|
Map<String,Object> claims = jwtService.parseClaims(authorization);
|
||||||
|
Object uid = claims.get("userId");
|
||||||
|
if (uid == null) throw new IllegalArgumentException("未登录");
|
||||||
|
return ((Number) uid).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<?> me(@RequestHeader(name = "Authorization", required = false) String authorization,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userIdHeader) {
|
||||||
|
Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization);
|
||||||
|
var body = userService.getProfile(userId);
|
||||||
|
return ResponseEntity.ok(body == null ? java.util.Map.of() : body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me")
|
||||||
|
public ResponseEntity<?> update(@RequestHeader(name = "Authorization", required = false) String authorization,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userIdHeader,
|
||||||
|
@RequestBody UserService.UpdateProfileRequest req) {
|
||||||
|
Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization);
|
||||||
|
userService.updateProfile(userId, req);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me/password")
|
||||||
|
public ResponseEntity<?> changePassword(@RequestHeader(name = "Authorization", required = false) String authorization,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userIdHeader,
|
||||||
|
@RequestBody UserService.ChangePasswordRequest req) {
|
||||||
|
Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization);
|
||||||
|
userService.changePassword(userId, req);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me/phone")
|
||||||
|
public ResponseEntity<?> changePhone(@RequestHeader(name = "Authorization", required = false) String authorization,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userIdHeader,
|
||||||
|
@RequestBody UserService.ChangePhoneRequest req) {
|
||||||
|
Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization);
|
||||||
|
userService.changePhone(userId, req);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
136
backend/src/main/java/com/example/demo/user/UserService.java
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package com.example.demo.user;
|
||||||
|
|
||||||
|
import com.example.demo.attachment.AttachmentUrlValidator;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCrypt;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AttachmentUrlValidator urlValidator;
|
||||||
|
|
||||||
|
public UserService(JdbcTemplate jdbcTemplate, AttachmentUrlValidator urlValidator) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.urlValidator = urlValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<String, Object> getProfile(Long userId) {
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement("SELECT id, shop_id, phone, email, name, avatar_url FROM users WHERE id=? LIMIT 1");
|
||||||
|
ps.setLong(1, userId);
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("shopId", rs.getLong("shop_id"));
|
||||||
|
m.put("phone", rs.getString("phone"));
|
||||||
|
m.put("email", rs.getString("email"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("avatarUrl", rs.getString("avatar_url"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateProfileRequest { public String name; public String avatarUrl; }
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateProfile(Long userId, UpdateProfileRequest req) {
|
||||||
|
if ((req.name == null || req.name.isBlank()) && (req.avatarUrl == null || req.avatarUrl.isBlank())) {
|
||||||
|
return; // nothing to update
|
||||||
|
}
|
||||||
|
StringBuilder sql = new StringBuilder("UPDATE users SET ");
|
||||||
|
java.util.List<Object> args = new java.util.ArrayList<>();
|
||||||
|
boolean first = true;
|
||||||
|
if (req.name != null) {
|
||||||
|
String nm = req.name.trim();
|
||||||
|
if (nm.isEmpty()) throw new IllegalArgumentException("姓名不能为空");
|
||||||
|
if (nm.length() > 64) throw new IllegalArgumentException("姓名过长");
|
||||||
|
sql.append(first ? "name=?" : ", name=?");
|
||||||
|
args.add(nm);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
if (req.avatarUrl != null) {
|
||||||
|
String au = req.avatarUrl.trim();
|
||||||
|
if (au.isEmpty()) {
|
||||||
|
sql.append(first ? "avatar_url=NULL" : ", avatar_url=NULL");
|
||||||
|
} else if (au.startsWith("/")) {
|
||||||
|
String normalized = au.replaceAll("/{2,}", "/");
|
||||||
|
sql.append(first ? "avatar_url=?" : ", avatar_url=?");
|
||||||
|
args.add(normalized);
|
||||||
|
} else {
|
||||||
|
var vr = urlValidator.validate(au);
|
||||||
|
sql.append(first ? "avatar_url=?" : ", avatar_url=?");
|
||||||
|
args.add(vr.url());
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
sql.append(", updated_at=NOW() WHERE id=?");
|
||||||
|
args.add(userId);
|
||||||
|
jdbcTemplate.update(sql.toString(), args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ChangePasswordRequest { public String oldPassword; public String newPassword; }
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(Long userId, ChangePasswordRequest req) {
|
||||||
|
if (req.newPassword == null || req.newPassword.isBlank()) throw new IllegalArgumentException("新密码不能为空");
|
||||||
|
if (req.newPassword.length() < 6) throw new IllegalArgumentException("新密码至少6位");
|
||||||
|
Map<String, Object> row = jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement("SELECT password_hash FROM users WHERE id=?");
|
||||||
|
ps.setLong(1, userId);
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("password_hash", rs.getString(1));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (row == null) throw new IllegalArgumentException("用户不存在");
|
||||||
|
String existing = (String) row.get("password_hash");
|
||||||
|
if (existing != null && !existing.isBlank()) {
|
||||||
|
if (req.oldPassword == null || req.oldPassword.isBlank()) throw new IllegalArgumentException("请提供旧密码");
|
||||||
|
boolean ok = BCrypt.checkpw(req.oldPassword, existing);
|
||||||
|
if (!ok) throw new IllegalArgumentException("旧密码不正确");
|
||||||
|
}
|
||||||
|
String newHash = BCrypt.hashpw(req.newPassword, BCrypt.gensalt());
|
||||||
|
jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", newHash, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ChangePhoneRequest { public String phone; public String code; }
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void changePhone(Long userId, ChangePhoneRequest req) {
|
||||||
|
ensurePhoneFormat(req.phone);
|
||||||
|
// 无需验证码,直接保存;保留唯一性与格式校验
|
||||||
|
try {
|
||||||
|
jdbcTemplate.update("UPDATE users SET phone=?, updated_at=NOW() WHERE id=?", req.phone, userId);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
throw new IllegalArgumentException("该手机号已被占用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensurePhoneFormat(String phone) {
|
||||||
|
if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空");
|
||||||
|
String p = phone.replaceAll("\\s+", "");
|
||||||
|
if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
143
backend/src/main/java/com/example/demo/vip/VipController.java
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package com.example.demo.vip;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/vip")
|
||||||
|
public class VipController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public VipController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/status")
|
||||||
|
public ResponseEntity<?> status(@RequestHeader(name = "X-User-Id", required = true) Long userId) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
String sql = "SELECT is_vip,status,expire_at FROM vip_users WHERE user_id=? ORDER BY id DESC LIMIT 1";
|
||||||
|
Map<String, Object> row = null;
|
||||||
|
try {
|
||||||
|
row = jdbcTemplate.query(sql, ps -> ps.setLong(1, userId), rs -> rs.next() ? Map.of(
|
||||||
|
"isVip", rs.getInt("is_vip"),
|
||||||
|
"status", rs.getInt("status"),
|
||||||
|
"expireAt", rs.getTimestamp("expire_at")
|
||||||
|
) : null);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
boolean isVipActive = false;
|
||||||
|
Timestamp expireAt = null;
|
||||||
|
int status = 0;
|
||||||
|
if (row != null) {
|
||||||
|
int isVip = ((Number) row.getOrDefault("isVip", 0)).intValue();
|
||||||
|
status = ((Number) row.getOrDefault("status", 0)).intValue();
|
||||||
|
Object exp = row.get("expireAt");
|
||||||
|
expireAt = exp instanceof Timestamp ? (Timestamp) exp : null;
|
||||||
|
isVipActive = (isVip == 1) && (status == 1) && (expireAt == null || !expireAt.toInstant().isBefore(Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Double price = 0d;
|
||||||
|
try {
|
||||||
|
price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
int nonVipRetentionDays = readNonVipRetentionDaysOrDefault(60);
|
||||||
|
|
||||||
|
result.put("isVip", isVipActive);
|
||||||
|
result.put("status", status);
|
||||||
|
result.put("expireAt", expireAt);
|
||||||
|
result.put("price", price);
|
||||||
|
result.put("nonVipRetentionDays", nonVipRetentionDays);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recharges")
|
||||||
|
public ResponseEntity<?> recharges(@RequestHeader(name = "X-User-Id", required = true) Long userId,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int safeSize = Math.max(1, Math.min(100, size));
|
||||||
|
int offset = Math.max(0, page - 1) * safeSize;
|
||||||
|
String sql = "SELECT id, price, duration_days AS durationDays, expire_from AS expireFrom, expire_to AS expireTo, channel, created_at AS createdAt " +
|
||||||
|
"FROM vip_recharges WHERE user_id=? ORDER BY id DESC LIMIT " + offset + ", " + safeSize;
|
||||||
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.query(sql, ps -> ps.setLong(1, userId), rs -> {
|
||||||
|
java.util.List<java.util.Map<String,Object>> rows = new java.util.ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
java.util.Map<String,Object> m = new java.util.LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("price", rs.getBigDecimal("price"));
|
||||||
|
m.put("durationDays", rs.getInt("durationDays"));
|
||||||
|
m.put("expireFrom", rs.getTimestamp("expireFrom"));
|
||||||
|
m.put("expireTo", rs.getTimestamp("expireTo"));
|
||||||
|
m.put("channel", rs.getString("channel"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("createdAt"));
|
||||||
|
rows.add(m);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("list", list));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/pay")
|
||||||
|
public ResponseEntity<?> pay(@RequestHeader(name = "X-User-Id", required = true) Long userId) {
|
||||||
|
Long shopId = jdbcTemplate.query("SELECT shop_id FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getLong(1) : null);
|
||||||
|
if (shopId == null) return ResponseEntity.badRequest().body(Map.of("message", "invalid user"));
|
||||||
|
|
||||||
|
int durationDays = readDurationDaysOrDefault(30);
|
||||||
|
Double price = 0d;
|
||||||
|
try { price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d); } catch (Exception ignored) {}
|
||||||
|
Instant newExpire = Instant.now().plus(durationDays, ChronoUnit.DAYS);
|
||||||
|
Timestamp expireTs = Timestamp.from(newExpire.atOffset(ZoneOffset.UTC).toInstant());
|
||||||
|
|
||||||
|
Long existingId = jdbcTemplate.query("SELECT id FROM vip_users WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getLong(1) : null);
|
||||||
|
|
||||||
|
if (existingId == null) {
|
||||||
|
jdbcTemplate.update("INSERT INTO vip_users (shop_id,user_id,is_vip,status,expire_at,remark,created_at,updated_at) VALUES (?,?,?,?,?,NULL,NOW(),NOW())",
|
||||||
|
shopId, userId, 1, 1, expireTs);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update("UPDATE vip_users SET is_vip=1,status=1,expire_at=?,updated_at=NOW() WHERE id=?",
|
||||||
|
expireTs, existingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("INSERT INTO vip_recharges (shop_id,user_id,price,duration_days,expire_from,expire_to,channel,created_at) VALUES (?,?,?,?,?,?, 'oneclick', NOW())",
|
||||||
|
shopId, userId, price, durationDays, null, expireTs);
|
||||||
|
|
||||||
|
return status(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readDurationDaysOrDefault(int dft) {
|
||||||
|
try {
|
||||||
|
String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='vip.durationDays' ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
if (v == null) return dft;
|
||||||
|
v = v.trim();
|
||||||
|
if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1);
|
||||||
|
return Integer.parseInt(v);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return dft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readNonVipRetentionDaysOrDefault(int dft) {
|
||||||
|
try {
|
||||||
|
String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='vip.dataRetentionDaysForNonVip' ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
if (v == null) return dft;
|
||||||
|
v = v.trim();
|
||||||
|
if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1);
|
||||||
|
return Integer.parseInt(v);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return dft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +20,21 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
|
|||||||
logging.level.com.example.demo=DEBUG
|
logging.level.com.example.demo=DEBUG
|
||||||
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=INFO
|
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=INFO
|
||||||
|
|
||||||
|
# 邮件 SMTP(通过环境变量注入,避免硬编码)
|
||||||
|
spring.mail.host=${MAIL_HOST:smtp.qq.com}
|
||||||
|
spring.mail.port=${MAIL_PORT:465}
|
||||||
|
spring.mail.username=${MAIL_USERNAME:}
|
||||||
|
spring.mail.password=${MAIL_PASSWORD:}
|
||||||
|
spring.mail.protocol=${MAIL_PROTOCOL:smtps}
|
||||||
|
spring.mail.properties.mail.smtp.auth=true
|
||||||
|
spring.mail.properties.mail.smtp.ssl.enable=true
|
||||||
|
spring.mail.properties.mail.smtp.starttls.enable=false
|
||||||
|
spring.mail.properties.mail.smtp.connectiontimeout=${MAIL_CONNECT_TIMEOUT_MS:5000}
|
||||||
|
spring.mail.properties.mail.smtp.timeout=${MAIL_READ_TIMEOUT_MS:5000}
|
||||||
|
spring.mail.properties.mail.smtp.writetimeout=${MAIL_WRITE_TIMEOUT_MS:5000}
|
||||||
|
app.mail.from=${MAIL_FROM:}
|
||||||
|
app.mail.subject-prefix=${MAIL_SUBJECT_PREFIX:[配件查询]}
|
||||||
|
|
||||||
# CORS 简单放开(如需跨域)
|
# CORS 简单放开(如需跨域)
|
||||||
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}
|
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}
|
||||||
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
||||||
@@ -52,6 +67,11 @@ attachments.url.allowlist=${ATTACHMENTS_URL_ALLOWLIST:}
|
|||||||
# 逗号分隔Content-Type
|
# 逗号分隔Content-Type
|
||||||
attachments.url.allowed-content-types=${ATTACHMENTS_URL_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/gif,image/webp,image/svg+xml}
|
attachments.url.allowed-content-types=${ATTACHMENTS_URL_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/gif,image/webp,image/svg+xml}
|
||||||
|
|
||||||
|
# 本地上传直传配置(方案B)
|
||||||
|
attachments.upload.storage-dir=${ATTACHMENTS_DIR:./data/attachments}
|
||||||
|
attachments.upload.max-size-mb=${ATTACHMENTS_UPLOAD_MAX_SIZE_MB:5}
|
||||||
|
attachments.upload.allowed-content-types=${ATTACHMENTS_UPLOAD_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/gif,image/webp,image/svg+xml}
|
||||||
|
|
||||||
# 应用默认上下文(用于开发/演示环境)
|
# 应用默认上下文(用于开发/演示环境)
|
||||||
app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1}
|
app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1}
|
||||||
app.defaults.user-id=${APP_DEFAULT_USER_ID:2}
|
app.defaults.user-id=${APP_DEFAULT_USER_ID:2}
|
||||||
@@ -69,3 +89,17 @@ app.account.defaults.wechat-name=${APP_ACCOUNT_WECHAT_NAME:微信}
|
|||||||
app.account.defaults.alipay-name=${APP_ACCOUNT_ALIPAY_NAME:支付宝}
|
app.account.defaults.alipay-name=${APP_ACCOUNT_ALIPAY_NAME:支付宝}
|
||||||
|
|
||||||
# 登录相关配置已移除
|
# 登录相关配置已移除
|
||||||
|
admin.auth.header-name=${ADMIN_AUTH_HEADER:X-Admin-Id}
|
||||||
|
|
||||||
|
# Python 条码识别服务配置(可通过环境变量注入,默认不启用)
|
||||||
|
python.barcode.enabled=${PY_BARCODE_ENABLED:false}
|
||||||
|
python.barcode.working-dir=${PY_BARCODE_WORKDIR:./txm}
|
||||||
|
python.barcode.python=${PY_BARCODE_PYTHON:python}
|
||||||
|
python.barcode.app-module=${PY_BARCODE_APP_MODULE:app.server.main}
|
||||||
|
python.barcode.use-module-main=${PY_BARCODE_USE_MODULE:true}
|
||||||
|
python.barcode.host=${PY_BARCODE_HOST:127.0.0.1}
|
||||||
|
python.barcode.port=${PY_BARCODE_PORT:8000}
|
||||||
|
python.barcode.health-path=${PY_BARCODE_HEALTH:/openapi.json}
|
||||||
|
python.barcode.startup-timeout-sec=${PY_BARCODE_TIMEOUT:20}
|
||||||
|
python.barcode.log-file=${PY_BARCODE_LOG:}
|
||||||
|
python.barcode.max-upload-mb=${PY_BARCODE_MAX_UPLOAD_MB:8}
|
||||||
|
|||||||
BIN
backend/txm/2dbb4e14b6b0df047806bef434338058.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
23
backend/txm/README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
条形码识别端口(EAN-13)
|
||||||
|
|
||||||
|
本项目使用 Python 与 OpenCV 实现 EAN-13 条形码视觉识别,并提供 Tkinter 界面进行本地测试。配置项集中在 `config/config.yaml`,不做任何硬编码;程序仅在用户操作时执行识别,不会自动运行后台任务。
|
||||||
|
|
||||||
|
运行环境
|
||||||
|
- Python 3.9+
|
||||||
|
- Windows 10/11(其他平台需要替换字体路径等少量配置)
|
||||||
|
|
||||||
|
快速开始
|
||||||
|
1. 安装依赖:参考 `requirements.txt`
|
||||||
|
2. 运行 Tk 测试界面(不会自动识别,需手动选择图片):
|
||||||
|
```bash
|
||||||
|
python -m app.ui.tk_app
|
||||||
|
```
|
||||||
|
|
||||||
|
目录结构
|
||||||
|
- `app/` 核心源码
|
||||||
|
- `config/` 配置文件(YAML)
|
||||||
|
- `doc/` 文档(开放 API、数据库文档等)
|
||||||
|
|
||||||
|
许可证:MIT
|
||||||
|
|
||||||
|
|
||||||
3
backend/txm/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""EAN-13 条形码识别应用包初始化。"""
|
||||||
|
|
||||||
|
|
||||||